integrate telegram bot

This commit is contained in:
hamid
2026-07-28 22:25:15 +03:30
parent e6a8f93a1e
commit 630c7907ec
16 changed files with 760 additions and 56 deletions
+11 -4
View File
@@ -130,8 +130,13 @@ stamped by `AuditFieldInterceptor` (Persistence), not in handlers.
a typo falls closed to the mock). Real adapters use `HttpClient` (typed via `IHttpClientFactory`) +
`System.Text.Json` + BCL crypto — **no new NuGet packages**; credentials come from `Seams:*` (user-secrets/env,
never committed). Swapping is a registration change; **no handler is touched**. The adapters:
`KavenegarSmsSender` (`Sms:Provider=kavenegar`**launch-critical**; when a real provider is selected the
Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `Finnotech{Shahkar,IdentityKyc,
`KavenegarSmsSender` (`Sms:Provider=kavenegar`**launch-critical**; when a real gateway is selected the
Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `TelegramSmsSender`
(`Sms:Provider=telegram`**Development-only, broadcast, not a gateway**: it posts to the standalone
`telegram-otp-bot/` relay, which pushes *every* code to a fixed list of Telegram chat ids, so manual testing
beats reading OTPs out of the log. It is the **one non-mock SMS provider that keeps the OTP-capture bridge
enabled** — see Startup wiring — and its `Seams:Sms:Telegram:ApiKey` is a user-secret, never committed),
`Finnotech{Shahkar,IdentityKyc,
BankAccountOwnership}` (`{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech`, shared `Seams:Finnotech`
creds), `NeshanGeocoder` (`Geocoding:Provider=neshan`), `S3ObjectStorage` (`ObjectStorage:Provider=s3` — MinIO/
S3/ArvanCloud via **manual AWS SigV4**, presigned GET = the real b6 signed-URL contract), `ZarinPalPaymentProvider`
@@ -611,8 +616,10 @@ AddForwardedHeadersConfiguration(config) // refinement-phase-5: trust Forwarded
AddRateLimitingPolicies() // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook)
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices(builder.Environment) // refinement-phase-9: gRPC reflection registered only in Development
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each
// OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development.
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates the registered ISmsSender to
// capture each OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development,
// and only for a capture-safe Seams:Sms:Provider (`mock` / unset, or the Development-only `telegram` relay).
// A real gateway (kavenegar) disables it, so the code only ever leaves the process over the SMS wire.
```
Pipeline order: **forwarded headers** → exception handler → Swagger → routing → **CORS → rate limiter →
+10 -5
View File
@@ -86,12 +86,17 @@ builder.Services.AddApplicationServices()
.AddRateLimitingPolicies();
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login
// without an SMS gateway. refinement-phase-8: the capture bridge runs ONLY while the log-only mock SMS sender is
// selected — once a real gateway (Seams:Sms:Provider) ships, the OTP is delivered over the wire and never logged
// or captured. Nothing here is wired in any other environment.
// without an SMS gateway. refinement-phase-8: the capture bridge runs ONLY for a capture-safe sender — the
// log-only mock, or the Development-only `telegram` relay (telegram-otp-bot/), which is a manual-testing
// convenience rather than a gateway and keeps the helper (and its e2e tests) working. A real gateway
// (kavenegar, and any future one) delivers over the wire and must never have the code logged or captured.
// Nothing here is wired in any other environment.
var smsProvider = configuration["Seams:Sms:Provider"];
var usingMockSms = string.IsNullOrWhiteSpace(smsProvider) || smsProvider.Equals("mock", StringComparison.OrdinalIgnoreCase);
if (builder.Environment.IsDevelopment() && usingMockSms)
var otpCaptureAllowedProvider =
string.IsNullOrWhiteSpace(smsProvider) ||
smsProvider.Equals("mock", StringComparison.OrdinalIgnoreCase) ||
smsProvider.Equals("telegram", StringComparison.OrdinalIgnoreCase);
if (builder.Environment.IsDevelopment() && otpCaptureAllowedProvider)
builder.Services.AddDevelopmentOtpCapture();
// The IPaymentCaptureSimulator + bookings/convert path is a Development/Testing affordance (b10's real webhook
@@ -19,6 +19,14 @@
"ObjectStorage": {
"RootPath": ""
},
"Sms": {
"Provider": "telegram",
"Telegram": {
"BaseUrl": "http://127.0.0.1:5010",
"ApiKey": "ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86",
"TimeoutSeconds": 10
}
},
"Geocoding": {
"ReturnNullCoordinates": false,
"LowConfidenceMarker": "NO_GEO",
@@ -0,0 +1,91 @@
#nullable enable
using System.Text;
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// <see cref="ISmsSender"/> over the standalone <b>Telegram OTP relay</b> (<c>telegram-otp-bot/</c>), selected by
/// <c>Seams:Sms:Provider = telegram</c>. Its two POST routes mirror this contract one-for-one, so each method is a
/// single JSON call: <c>POST /send_otp</c> and <c>POST /send</c>, authenticated with the shared
/// <c>X-Api-Key</c> secret.
///
/// <para><b>Development only.</b> The relay broadcasts every message to a fixed list of Telegram chat ids — every
/// recipient reads every code. It exists so manual testing beats reading OTPs out of the server log; it is not a
/// gateway and must never be selected in a deployed environment (see <see cref="TelegramOptions"/>).</para>
///
/// <para><b>The OTP is never logged</b> — only the phone tail and the relay's HTTP outcome, exactly like
/// <see cref="KavenegarSmsSender"/>. A non-2xx (the relay answers <c>502</c> when <i>no</i> recipient got the
/// message) or an <c>ok != true</c> body is a delivery failure and throws, so <c>RequestOtpCommand</c> reports a
/// real send failure instead of silently "succeeding" on an undelivered code.</para>
/// </summary>
public sealed class TelegramSmsSender(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<TelegramSmsSender> logger) : ISmsSender
{
/// <summary>The repo's committed stand-in for a secret — treated as "not configured".</summary>
private const string SecretPlaceholder = "ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86";
private readonly TelegramOptions _options = options.Value.Sms.Telegram;
public Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default)
=> PostAsync("send_otp", new { phone, code }, phone, cancellationToken);
public Task SendAsync(string phone, string message, CancellationToken cancellationToken = default)
=> PostAsync("send", new { phone, message }, phone, cancellationToken);
private async Task PostAsync(string path, object payload, string phone, CancellationToken cancellationToken)
{
// Fail with the config key rather than sending an unauthenticated request the relay answers with a bare
// 401 — the cause of that 401 is invisible from this side.
var apiKey = _options.ApiKey;
if (string.IsNullOrWhiteSpace(apiKey))
{
throw new InvalidOperationException(
"Seams:Sms:Telegram:ApiKey is not configured. Set it (user-secrets or environment) to the same " +
"value as the relay's API_KEY, or select another Seams:Sms:Provider.");
}
using var request = new HttpRequestMessage(HttpMethod.Post, path)
{
// Snake-case keys (phone/code/message) are exactly what the relay reads off the body.
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
};
request.Headers.Add("X-Api-Key", apiKey);
using var response = await httpClient.SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode || !IsDelivered(body))
{
logger.LogWarning(
"Telegram OTP relay delivery failed for phone ending {PhoneTail} — http {Http}",
Tail(phone), (int)response.StatusCode);
throw new InvalidOperationException(
$"Telegram OTP relay delivery failed (http {(int)response.StatusCode}).");
}
logger.LogInformation("Telegram OTP relay accepted the message for phone ending {PhoneTail}", Tail(phone));
}
/// <summary>A 2xx still carries the per-recipient outcome in <c>ok</c>; anything but <c>true</c> is a failure.</summary>
private static bool IsDelivered(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
return doc.RootElement.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.True;
}
catch (JsonException)
{
return false;
}
}
private static string Tail(string phone) =>
string.IsNullOrEmpty(phone) ? "????" : phone[^Math.Min(4, phone.Length)..];
}
@@ -63,6 +63,10 @@ public static class SeamProviders
public const string SmsIr = "smsir";
public const string Ghasedak = "ghasedak";
/// <summary><b>Development convenience channel, not an SMS gateway</b> — the local <c>telegram-otp-bot/</c>
/// relay broadcasts every code to a fixed list of Telegram chat ids. Never select it in a real environment.</summary>
public const string Telegram = "telegram";
// Object storage
public const string S3 = "s3";
@@ -89,12 +93,16 @@ public static class SeamProviders
/// <summary>
/// The outbound SMS rail (<c>ISmsSender</c>). <see cref="Provider"/> = <c>mock</c> logs the OTP (the b2
/// <c>LoggingSmsSender</c>); set it to <c>kavenegar</c> / <c>smsir</c> / <c>ghasedak</c> to deliver over a real
/// Iranian gateway. <b>refinement-phase-8:</b> when a real provider is selected the Development OTP-in-logs/echo
/// Iranian gateway. <b>refinement-phase-8:</b> when a real gateway is selected the Development OTP-in-logs/echo
/// bridge is disabled — the OTP must never be logged once real SMS ships.
///
/// <para><c>telegram</c> is the one exception: it is a <b>Development convenience channel, not an SMS gateway</b>
/// (see <see cref="TelegramOptions"/>), so the OTP-capture bridge stays enabled alongside it.</para>
/// </summary>
public sealed class SmsOptions
{
/// <summary><c>mock</c> (default) | <c>kavenegar</c> | <c>smsir</c> | <c>ghasedak</c>.</summary>
/// <summary><c>mock</c> (default) | <c>kavenegar</c> | <c>smsir</c> | <c>ghasedak</c> | <c>telegram</c>
/// (Development only).</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>Gateway API key / token (secret — user-secrets or environment, never committed).</summary>
@@ -108,6 +116,33 @@ public sealed class SmsOptions
/// <summary>The approved OTP template/pattern name the gateway sends the code through (verify-lookup APIs).</summary>
public string OtpTemplate { get; set; } = string.Empty;
/// <summary>Connection facts for the <c>telegram</c> relay; ignored by every other provider.</summary>
public TelegramOptions Telegram { get; set; } = new();
}
/// <summary>
/// The <b>Development-only</b> Telegram OTP relay (the standalone <c>telegram-otp-bot/</c> Node service),
/// selected by <c>Seams:Sms:Provider = telegram</c>. It replaces "read the OTP out of the server log" during
/// manual testing — the tester gets the code on their phone without paying an Iranian SMS gateway.
///
/// <para><b>It is not an SMS gateway.</b> There is no per-user routing: the relay <i>broadcasts</i> every code
/// to a fixed list of Telegram chat ids, so every configured recipient reads every login code. That is fine for
/// a test group and disqualifying for anything else — never point a deployed environment at it.</para>
/// </summary>
public sealed class TelegramOptions
{
/// <summary>The relay's root URL, e.g. <c>http://127.0.0.1:5010</c>.</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>The shared secret sent as the relay's <c>X-Api-Key</c> header — it must equal the relay's
/// <c>API_KEY</c>. <b>Secret:</b> committed config carries an empty/placeholder value; the real one comes
/// from user-secrets (<c>Seams:Sms:Telegram:ApiKey</c>) or the environment, never git.</summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>Per-request timeout. The relay itself talks to Telegram (over a proxy in a filtered region), so
/// it needs more headroom than a loopback call suggests.</summary>
public int TimeoutSeconds { get; set; } = 10;
}
/// <summary>
@@ -8,27 +8,42 @@ public static class DevelopmentSeamExtensions
{
/// <summary>
/// Development-only wiring for the OTP bring-up bridge. Registers <see cref="DevOtpStore"/> and decorates
/// the <see cref="ISmsSender"/> registered by <c>AddCrossCuttingSeams</c> (the log-only
/// <see cref="LoggingSmsSender"/>) with <see cref="DevCapturingSmsSender"/>, so each OTP is also captured
/// in memory for <c>/api/v1/dev/last_otp/{phone}</c> to serve. MUST be called only inside
/// <c>builder.Environment.IsDevelopment()</c>: nothing here is wired in any other environment, which —
/// together with the endpoint's own <c>IsDevelopment()</c> guard — makes the OTP echo impossible to enable
/// outside Development. Superseded by the real SMS gateway in refinement Phase 8.
/// the <see cref="ISmsSender"/> registered by <c>AddCrossCuttingSeams</c> with
/// <see cref="DevCapturingSmsSender"/>, so each OTP is also captured in memory for
/// <c>/api/v1/dev/last_otp/{phone}</c> to serve. MUST be called only inside
/// <c>builder.Environment.IsDevelopment()</c>, and only for a capture-safe provider (the log-only
/// <see cref="LoggingSmsSender"/> or the Development-only Telegram relay — <c>Program.cs</c> owns that
/// condition): nothing here is wired in any other environment, which — together with the endpoint's own
/// <c>IsDevelopment()</c> guard — makes the OTP echo impossible to enable outside Development.
/// </summary>
public static IServiceCollection AddDevelopmentOtpCapture(this IServiceCollection services)
{
services.AddSingleton<DevOtpStore>();
// Re-register ISmsSender as the capturing decorator over a fresh LoggingSmsSender (built through DI so
// it still gets its ILogger). The last registration wins for a single resolve, so callers transparently
// get the decorator; the code is still logged exactly as before, just also captured for the dev endpoint.
// Decorate whatever ISmsSender is already registered rather than assuming the mock — with
// Seams:Sms:Provider = telegram the inner sender is TelegramSmsSender, and re-creating a LoggingSmsSender
// here would silently swallow the delivery instead of capturing alongside it. Last registration wins for
// a single resolve, so callers transparently get the decorator and delivery behaviour is unchanged.
var inner = services.LastOrDefault(d => d.ServiceType == typeof(ISmsSender))
?? throw new InvalidOperationException(
"AddDevelopmentOtpCapture must run after AddCrossCuttingSeams — no ISmsSender is registered.");
services.Remove(inner);
services.AddSingleton<ISmsSender>(sp => new DevCapturingSmsSender(
ActivatorUtilities.CreateInstance<LoggingSmsSender>(sp),
ResolveSender(sp, inner),
sp.GetRequiredService<DevOtpStore>()));
return services;
}
private static ISmsSender ResolveSender(IServiceProvider sp, ServiceDescriptor descriptor) => descriptor switch
{
{ ImplementationInstance: ISmsSender instance } => instance,
{ ImplementationFactory: { } factory } => (ISmsSender)factory(sp),
{ ImplementationType: { } type } => (ISmsSender)ActivatorUtilities.CreateInstance(sp, type),
_ => throw new InvalidOperationException("The registered ISmsSender cannot be constructed for decoration."),
};
/// <summary>
/// Development/Testing-only re-registration of the real <see cref="MockPaymentCaptureSimulator"/> over the
/// production <see cref="DisabledPaymentCaptureSimulator"/> (refinement-phase-8, 6.4). The <c>bookings/convert</c>
@@ -87,16 +87,31 @@ public static class ServiceCollectionExtension
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<KavenegarSmsSender>>()));
}
else if (Is(provider, SeamProviders.Telegram))
{
// Development-only relay (telegram-otp-bot/) — a manual-testing convenience, not a gateway. The
// per-request timeout is generous because the relay's own hop to Telegram goes through a proxy.
var telegram = seams.Sms.Telegram;
services.AddHttpClient(HttpClients.Telegram, c =>
{
c.BaseAddress = new Uri(BaseOrDefault(telegram.BaseUrl, "http://127.0.0.1:5010").TrimEnd('/') + "/");
c.Timeout = TimeSpan.FromSeconds(telegram.TimeoutSeconds > 0 ? telegram.TimeoutSeconds : 10);
});
services.AddSingleton<ISmsSender>(sp => new TelegramSmsSender(
Client(sp, HttpClients.Telegram),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<TelegramSmsSender>>()));
}
else if (Is(provider, SeamProviders.SmsIr) || Is(provider, SeamProviders.Ghasedak))
{
throw new NotSupportedException(
$"SMS provider '{provider}' is not implemented — only 'kavenegar' has a real adapter (refinement-phase-8). " +
"Add its adapter behind ISmsSender, or use 'mock'/'kavenegar'.");
"Add its adapter behind ISmsSender, or use 'mock'/'kavenegar'/'telegram'.");
}
else
{
// b2 log-only mock; the Development OTP-capture decorator is layered on in Program.cs (Development only,
// and only while this mock is selected — a real SMS provider disables it so the OTP is never logged).
// and only for the capture-safe providers — a real SMS gateway disables it so the OTP is never logged).
services.AddSingleton<ISmsSender, LoggingSmsSender>();
}
}
@@ -278,6 +293,7 @@ public static class ServiceCollectionExtension
{
public const string ObjectStorage = "seam-object-storage";
public const string Sms = "seam-sms";
public const string Telegram = "seam-sms-telegram";
public const string Finnotech = "seam-finnotech";
public const string Geocoding = "seam-geocoding";
public const string Psp = "seam-psp";
@@ -0,0 +1,71 @@
using Baya.Application.Contracts.Common;
using Baya.Infrastructure.CrossCutting.Seams;
using Baya.Infrastructure.CrossCutting.Seams.Real;
using Baya.Infrastructure.CrossCutting.ServiceConfiguration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Baya.Test.Foundation.Seams;
/// <summary>
/// The Telegram OTP relay must be <b>invisible unless opted in</b>: an environment that configures no SMS
/// provider — or leaves the default <c>mock</c> — resolves the log-only sender exactly as it did before the
/// channel existed. Only <c>Seams:Sms:Provider = telegram</c> swaps it in.
/// </summary>
public class SmsProviderRegistrationTests
{
[Fact]
public void UnsetProvider_ResolvesTheLogOnlyMock()
=> Assert.IsType<LoggingSmsSender>(Resolve(null));
[Fact]
public void MockProvider_ResolvesTheLogOnlyMock()
=> Assert.IsType<LoggingSmsSender>(Resolve("mock"));
[Fact]
public void UnknownProvider_FallsClosedToTheLogOnlyMock()
=> Assert.IsType<LoggingSmsSender>(Resolve("telegramm"));
[Fact]
public void TelegramProvider_ResolvesTheRelayAdapter()
=> Assert.IsType<TelegramSmsSender>(Resolve("telegram"));
[Fact]
public async Task OtpCaptureBridge_DecoratesTheConfiguredSender_NotTheMock()
{
// The Development capture bridge runs alongside the relay, so it must wrap the *configured* sender —
// re-creating a LoggingSmsSender here would silently drop every Telegram delivery. Proven without a
// network call: only TelegramSmsSender refuses an unconfigured api key.
var services = Services("telegram", apiKey: null);
services.AddDevelopmentOtpCapture();
var provider = services.BuildServiceProvider();
var sender = provider.GetRequiredService<ISmsSender>();
Assert.IsType<DevCapturingSmsSender>(sender);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sender.SendOtpAsync("09120000001", "135790"));
Assert.Contains("Seams:Sms:Telegram:ApiKey", ex.Message);
// …and the code was still captured for GET /dev/last_otp before the inner sender ran.
Assert.Equal("135790", provider.GetRequiredService<DevOtpStore>().GetLatest("09120000001"));
}
private static ISmsSender Resolve(string? provider)
=> Services(provider, "0123456789abcdef0123456789abcdef").BuildServiceProvider().GetRequiredService<ISmsSender>();
private static ServiceCollection Services(string? provider, string? apiKey)
{
var settings = new Dictionary<string, string?>();
if (provider is not null) settings["Seams:Sms:Provider"] = provider;
if (apiKey is not null) settings["Seams:Sms:Telegram:ApiKey"] = apiKey;
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddCrossCuttingSeams(configuration);
return services;
}
}
@@ -0,0 +1,149 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Baya.Infrastructure.CrossCutting.Seams;
using Baya.Infrastructure.CrossCutting.Seams.Real;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Baya.Test.Foundation.Seams;
/// <summary>
/// The Development-only Telegram OTP relay adapter: it must speak the relay's contract exactly (routes, body
/// keys, <c>X-Api-Key</c>), treat an undelivered message as a failure rather than a silent success, and never
/// put the OTP code in a log line.
/// </summary>
public class TelegramSmsSenderTests
{
private const string ApiKey = "0123456789abcdef0123456789abcdef";
[Fact]
public async Task SendOtpAsync_PostsTheCodeToSendOtpWithTheApiKeyHeader()
{
var (sender, handler, _) = Build();
await sender.SendOtpAsync("09120000001", "135790");
var request = Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal("http://127.0.0.1:5010/send_otp", request.Url);
Assert.Equal(ApiKey, request.ApiKey);
using var body = JsonDocument.Parse(request.Body);
Assert.Equal("09120000001", body.RootElement.GetProperty("phone").GetString());
Assert.Equal("135790", body.RootElement.GetProperty("code").GetString());
}
[Fact]
public async Task SendAsync_PostsTheMessageToSend()
{
var (sender, handler, _) = Build();
await sender.SendAsync("09120000001", "your booking is confirmed");
var request = Assert.Single(handler.Requests);
Assert.Equal("http://127.0.0.1:5010/send", request.Url);
Assert.Equal(ApiKey, request.ApiKey);
using var body = JsonDocument.Parse(request.Body);
Assert.Equal("your booking is confirmed", body.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task NoRecipientReceivedIt_Throws()
{
// 502 = the relay reached nobody. Swallowing it would report a login code that was never delivered.
var (sender, _, _) = Build(HttpStatusCode.BadGateway, """{"ok":false,"delivered":[],"failed":[]}""");
await Assert.ThrowsAsync<InvalidOperationException>(() => sender.SendOtpAsync("09120000001", "135790"));
}
[Fact]
public async Task SuccessStatusWithNotOkBody_Throws()
{
var (sender, _, _) = Build(HttpStatusCode.OK, """{"ok":false}""");
await Assert.ThrowsAsync<InvalidOperationException>(() => sender.SendOtpAsync("09120000001", "135790"));
}
[Fact]
public async Task UnconfiguredApiKey_ThrowsNamingTheConfigKey()
{
var (sender, handler, _) = Build(apiKey: "ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86");
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => sender.SendOtpAsync("09120000001", "135790"));
Assert.Contains("Seams:Sms:Telegram:ApiKey", ex.Message);
Assert.Empty(handler.Requests); // never sent unauthenticated
}
[Fact]
public async Task TheOtpCodeNeverReachesTheLog()
{
var (ok, _, okLog) = Build();
await ok.SendOtpAsync("09120000001", "135790");
var (failing, _, failLog) = Build(HttpStatusCode.BadGateway, """{"ok":false}""");
await Assert.ThrowsAsync<InvalidOperationException>(() => failing.SendOtpAsync("09120000001", "135790"));
Assert.NotEmpty(okLog.Lines);
Assert.NotEmpty(failLog.Lines);
Assert.DoesNotContain(okLog.Lines.Concat(failLog.Lines), line => line.Contains("135790"));
}
private static (TelegramSmsSender Sender, StubHandler Handler, CapturingLogger Logger) Build(
HttpStatusCode status = HttpStatusCode.OK,
string body = """{"ok":true,"delivered":["11111111"],"failed":[]}""",
string apiKey = ApiKey)
{
var handler = new StubHandler(status, body);
var client = new HttpClient(handler) { BaseAddress = new Uri("http://127.0.0.1:5010/") };
var options = Options.Create(new SeamOptions
{
Sms = new SmsOptions
{
Provider = SeamProviders.Telegram,
Telegram = new TelegramOptions { BaseUrl = "http://127.0.0.1:5010", ApiKey = apiKey },
},
});
var logger = new CapturingLogger();
return (new TelegramSmsSender(client, options, logger), handler, logger);
}
private sealed record CapturedRequest(HttpMethod Method, string Url, string? ApiKey, string Body);
/// <summary>Answers every call from memory — the adapter is exercised with no network at all.</summary>
private sealed class StubHandler(HttpStatusCode status, string body) : HttpMessageHandler
{
public List<CapturedRequest> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(new CapturedRequest(
request.Method,
request.RequestUri!.ToString(),
request.Headers.TryGetValues("X-Api-Key", out var values) ? values.FirstOrDefault() : null,
request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken)));
return new HttpResponseMessage(status)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
}
}
private sealed class CapturingLogger : ILogger<TelegramSmsSender>
{
public List<string> Lines { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Lines.Add(formatter(state, exception));
}
}