using MailKit; using MailKit.Net.Smtp; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.StoreAndForward; namespace ScadaLink.NotificationService.Tests; /// /// WP-11/12: Tests for notification delivery — SMTP delivery, error classification, S&F integration. /// public class NotificationDeliveryServiceTests { private readonly INotificationRepository _repository = Substitute.For(); private readonly ISmtpClientWrapper _smtpClient = Substitute.For(); private NotificationDeliveryService CreateService(StoreAndForward.StoreAndForwardService? sf = null) { return new NotificationDeliveryService( _repository, () => _smtpClient, NullLogger.Instance, tokenService: null, storeAndForward: sf); } private void SetupHappyPath() { var list = new NotificationList("ops-team") { Id = 1 }; var recipients = new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }, new("Bob", "bob@example.com") { Id = 2, NotificationListId = 1 } }; var smtpConfig = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls" }; _repository.GetListByNameAsync("ops-team").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(recipients); _repository.GetAllSmtpConfigurationsAsync().Returns(new List { smtpConfig }); } [Fact] public async Task Send_ListNotFound_ReturnsError() { _repository.GetListByNameAsync("nonexistent").Returns((NotificationList?)null); var service = CreateService(); var result = await service.SendAsync("nonexistent", "Subject", "Body"); Assert.False(result.Success); Assert.Contains("not found", result.ErrorMessage); } [Fact] public async Task Send_NoRecipients_ReturnsError() { var list = new NotificationList("empty-list") { Id = 1 }; _repository.GetListByNameAsync("empty-list").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(new List()); var service = CreateService(); var result = await service.SendAsync("empty-list", "Subject", "Body"); Assert.False(result.Success); Assert.Contains("no recipients", result.ErrorMessage); } [Fact] public async Task Send_NoSmtpConfig_ReturnsError() { var list = new NotificationList("test") { Id = 1 }; var recipients = new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 } }; _repository.GetListByNameAsync("test").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(recipients); _repository.GetAllSmtpConfigurationsAsync().Returns(new List()); var service = CreateService(); var result = await service.SendAsync("test", "Subject", "Body"); Assert.False(result.Success); Assert.Contains("No SMTP configuration", result.ErrorMessage); } [Fact] public async Task Send_Successful_ReturnsSuccess() { SetupHappyPath(); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Something happened"); Assert.True(result.Success); Assert.Null(result.ErrorMessage); Assert.False(result.WasBuffered); } [Fact] public async Task Send_SmtpConnectsWithCorrectParams() { SetupHappyPath(); var service = CreateService(); await service.SendAsync("ops-team", "Alert", "Body"); await _smtpClient.Received().ConnectAsync( "smtp.example.com", 587, SmtpTlsMode.StartTls, Arg.Any(), Arg.Any()); await _smtpClient.Received().AuthenticateAsync("basic", "user:pass", Arg.Any()); await _smtpClient.Received().SendAsync( "noreply@example.com", Arg.Is>(bcc => bcc.Count() == 2), "Alert", "Body", Arg.Any()); } [Fact] public async Task Send_PermanentSmtpError_ReturnsErrorDirectly() { SetupHappyPath(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new SmtpPermanentException("550 Mailbox not found")); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.Contains("Permanent SMTP error", result.ErrorMessage); } [Fact] public async Task Send_TransientError_NoStoreAndForward_ReturnsError() { SetupHappyPath(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new TimeoutException("Connection timed out")); var service = CreateService(sf: null); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.Contains("store-and-forward not available", result.ErrorMessage); } [Fact] public async Task Send_UsesBccDelivery_AllRecipientsInBcc() { SetupHappyPath(); IEnumerable? capturedBcc = null; _smtpClient.SendAsync( Arg.Any(), Arg.Do>(bcc => capturedBcc = bcc), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); var service = CreateService(); await service.SendAsync("ops-team", "Alert", "Body"); Assert.NotNull(capturedBcc); var bccList = capturedBcc!.ToList(); Assert.Equal(2, bccList.Count); Assert.Contains("alice@example.com", bccList); Assert.Contains("bob@example.com", bccList); } [Fact] public async Task Send_TransientError_WithStoreAndForward_BuffersMessage() { SetupHappyPath(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new TimeoutException("Connection timed out")); var dbName = $"file:sf_test_{Guid.NewGuid():N}?mode=memory&cache=shared"; var storage = new StoreAndForward.StoreAndForwardStorage( $"Data Source={dbName}", NullLogger.Instance); await storage.InitializeAsync(); var sfOptions = new StoreAndForward.StoreAndForwardOptions(); var sfService = new StoreAndForward.StoreAndForwardService( storage, sfOptions, NullLogger.Instance); var service = CreateService(sf: sfService); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.True(result.WasBuffered); } // ── NotificationService-001: buffered-notification delivery handler ── private static StoreAndForward.StoreAndForwardMessage BufferedNotification(string listName) => new() { Id = Guid.NewGuid().ToString("N"), Category = ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.Notification, Target = listName, PayloadJson = $$"""{"ListName":"{{listName}}","Subject":"Alert","Message":"Body"}""", }; [Fact] public async Task DeliverBuffered_HappyPath_ReturnsTrue() { SetupHappyPath(); var service = CreateService(); var delivered = await service.DeliverBufferedAsync(BufferedNotification("ops-team")); Assert.True(delivered); } [Fact] public async Task DeliverBuffered_ListNoLongerExists_ReturnsFalseSoMessageParks() { _repository.GetListByNameAsync("gone-list").Returns((NotificationList?)null); var service = CreateService(); var delivered = await service.DeliverBufferedAsync(BufferedNotification("gone-list")); Assert.False(delivered); // permanent — the S&F engine parks the message } // ── NotificationService-002: cancellation must not be misclassified as transient ── /// /// Like but matches any , /// so tests that pass an already-cancelled token still resolve the list/recipients. /// private void SetupHappyPathAnyToken() { var list = new NotificationList("ops-team") { Id = 1 }; var recipients = new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 } }; var smtpConfig = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls" }; _repository.GetListByNameAsync("ops-team", Arg.Any()).Returns(list); _repository.GetRecipientsByListIdAsync(1, Arg.Any()).Returns(recipients); _repository.GetAllSmtpConfigurationsAsync(Arg.Any()) .Returns(new List { smtpConfig }); } [Fact] public async Task Send_CancellationRequested_PropagatesAndDoesNotBuffer() { SetupHappyPathAnyToken(); using var cts = new CancellationTokenSource(); cts.Cancel(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new OperationCanceledException(cts.Token)); var sfService = await CreateSfServiceAsync(); var service = CreateService(sf: sfService); await Assert.ThrowsAnyAsync( () => service.SendAsync("ops-team", "Alert", "Body", cancellationToken: cts.Token)); // The cancellation propagated instead of being buffered for retry. var depth = await sfService.GetBufferDepthAsync(); depth.TryGetValue(ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.Notification, out var count); Assert.Equal(0, count); } [Fact] public async Task Send_TaskCanceledException_WithCancellation_Propagates() { SetupHappyPathAnyToken(); using var cts = new CancellationTokenSource(); cts.Cancel(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new TaskCanceledException()); var service = CreateService(sf: null); await Assert.ThrowsAnyAsync( () => service.SendAsync("ops-team", "Alert", "Body", cancellationToken: cts.Token)); } // ── NotificationService-003: classify on MailKit typed exceptions / status codes ── [Fact] public async Task Send_Smtp5xxCommandException_ClassifiedPermanent() { SetupHappyPath(); // 550 MailboxUnavailable — a real permanent rejection. _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new SmtpCommandException( SmtpErrorCode.RecipientNotAccepted, SmtpStatusCode.MailboxUnavailable, "rejected")); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.Contains("Permanent SMTP error", result.ErrorMessage); } [Fact] public async Task Send_Smtp4xxCommandException_ClassifiedTransientAndBuffered() { SetupHappyPath(); // 450 MailboxBusy — a real transient failure. _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new SmtpCommandException( SmtpErrorCode.MessageNotAccepted, SmtpStatusCode.MailboxBusy, "try again")); var sfService = await CreateSfServiceAsync(); var service = CreateService(sf: sfService); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.True(result.WasBuffered); } [Fact] public async Task Send_NonSmtpExceptionWith5xxLookalikeText_NotPromotedToPermanent() { // NS-003: the old classifier promoted ANY exception whose message contained // "5." / "550" / etc. to a permanent SMTP error — so an unrelated failure // referencing a host like "smtp5.example.com" was silently swallowed as a // clean permanent NotificationResult. Classification now uses MailKit's // typed exceptions only, so a non-SMTP exception is no longer misclassified. SetupHappyPath(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new InvalidOperationException("internal error talking to smtp5.example.com")); var service = CreateService(); // The exception is not classified at all (not a typed SMTP failure); it // surfaces rather than being mistaken for a permanent 5xx rejection. await Assert.ThrowsAsync( () => service.SendAsync("ops-team", "Alert", "Body")); } [Fact] public async Task Send_SmtpProtocolException_ClassifiedTransient() { SetupHappyPath(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new SmtpProtocolException("protocol error")); var sfService = await CreateSfServiceAsync(); var service = CreateService(sf: sfService); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.True(result.WasBuffered); } // ── NotificationService-004: DeliverAsync must create exactly one client and dispose it ── private sealed class TrackingSmtpClient : ISmtpClientWrapper, IDisposable { public bool Disposed { get; private set; } public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task SendAsync(string from, IEnumerable bccRecipients, string subject, string body, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public void Dispose() => Disposed = true; } [Fact] public async Task Send_CreatesExactlyOneSmtpClient_AndDisposesIt() { SetupHappyPath(); var created = new List(); var service = new NotificationDeliveryService( _repository, () => { var c = new TrackingSmtpClient(); created.Add(c); return c; }, NullLogger.Instance); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.Single(created); // NS-004: factory invoked once, not twice Assert.True(created[0].Disposed); // the client actually used is disposed } private static async Task CreateSfServiceAsync() { var dbName = $"file:sf_test_{Guid.NewGuid():N}?mode=memory&cache=shared"; var storage = new StoreAndForwardStorage( $"Data Source={dbName}", NullLogger.Instance); await storage.InitializeAsync(); return new StoreAndForwardService( storage, new StoreAndForwardOptions(), NullLogger.Instance); } // ── NotificationService-005: explicit TLS mode passed through to the wrapper ── /// An SMTP wrapper that records the TLS mode and timeout it was connected with. private sealed class RecordingTlsClient : ISmtpClientWrapper { public SmtpTlsMode? TlsMode { get; private set; } public int ConnectionTimeoutSeconds { get; private set; } public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default) { TlsMode = tlsMode; ConnectionTimeoutSeconds = connectionTimeoutSeconds; return Task.CompletedTask; } public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task SendAsync(string from, IEnumerable bccRecipients, string subject, string body, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } private void SetupHappyPathWithSmtp(SmtpConfiguration smtpConfig) { var list = new NotificationList("ops-team") { Id = 1 }; var recipients = new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 } }; _repository.GetListByNameAsync("ops-team").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(recipients); _repository.GetAllSmtpConfigurationsAsync().Returns(new List { smtpConfig }); } [Fact] public async Task Send_TlsModeNone_DoesNotNegotiateTls() { // NS-005: TlsMode "none" must connect with SmtpTlsMode.None, not the old // SecureSocketOptions.Auto (which let MailKit opportunistically negotiate TLS). var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 25, Credentials = "user:pass", TlsMode = "none" }; SetupHappyPathWithSmtp(cfg); var recording = new RecordingTlsClient(); var service = new NotificationDeliveryService( _repository, () => recording, NullLogger.Instance); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.Equal(SmtpTlsMode.None, recording.TlsMode); } [Fact] public async Task Send_TlsModeSsl_UsesImplicitSsl() { // NS-005: TlsMode "ssl" (port 465 implicit TLS) must be honoured, not // collapsed into the same path as "none". var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 465, Credentials = "user:pass", TlsMode = "ssl" }; SetupHappyPathWithSmtp(cfg); var recording = new RecordingTlsClient(); var service = new NotificationDeliveryService( _repository, () => recording, NullLogger.Instance); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.Equal(SmtpTlsMode.Ssl, recording.TlsMode); } [Fact] public async Task Send_UnknownTlsMode_ReturnsErrorNotSilentFallback() { var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "bogus" }; SetupHappyPathWithSmtp(cfg); var service = new NotificationDeliveryService( _repository, () => new RecordingTlsClient(), NullLogger.Instance); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.Contains("TLS mode", result.ErrorMessage); } // ── NotificationService-007: connection timeout passed through to the wrapper ── [Fact] public async Task Send_PassesConfiguredConnectionTimeoutToClient() { // NS-007: SmtpConfiguration.ConnectionTimeoutSeconds must reach the wrapper // so SmtpClient.Timeout is set; it was previously dead configuration. var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls", ConnectionTimeoutSeconds = 17 }; SetupHappyPathWithSmtp(cfg); var recording = new RecordingTlsClient(); var service = new NotificationDeliveryService( _repository, () => recording, NullLogger.Instance); await service.SendAsync("ops-team", "Alert", "Body"); Assert.Equal(17, recording.ConnectionTimeoutSeconds); } [Fact] public async Task Send_MaxConcurrentConnections_LimitsConcurrentDeliveries() { // NS-007: MaxConcurrentConnections must throttle concurrent SMTP deliveries. var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls", MaxConcurrentConnections = 2 }; SetupHappyPathWithSmtp(cfg); var inFlight = 0; var maxObserved = 0; var gate = new SemaphoreSlim(0); var sync = new object(); var service = new NotificationDeliveryService( _repository, () => new BlockingSmtpClient( onSend: async () => { lock (sync) { inFlight++; if (inFlight > maxObserved) maxObserved = inFlight; } await gate.WaitAsync(); lock (sync) { inFlight--; } }), NullLogger.Instance); var sends = Enumerable.Range(0, 6) .Select(_ => service.SendAsync("ops-team", "Alert", "Body")) .ToList(); // Give the throttled sends time to reach the SMTP send call. await Task.Delay(200); gate.Release(6); await Task.WhenAll(sends); Assert.True(maxObserved <= 2, $"Expected at most 2 concurrent deliveries, observed {maxObserved}"); } private sealed class BlockingSmtpClient : ISmtpClientWrapper, IDisposable { private readonly Func _onSend; public BlockingSmtpClient(Func onSend) => _onSend = onSend; public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task SendAsync(string from, IEnumerable bccRecipients, string subject, string body, CancellationToken cancellationToken = default) => _onSend(); public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public void Dispose() { } } // ── NotificationService-008: recipient address validation ── [Fact] public async Task Send_MalformedRecipientAddress_ReturnsCleanError_DoesNotThrow() { // NS-008: a malformed recipient address previously caused MailboxAddress.Parse // to throw ParseException, which escaped SendAsync unhandled. It must now // produce a clean NotificationResult failure. var list = new NotificationList("ops-team") { Id = 1 }; var recipients = new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }, new("Bad", "not a valid address @@") { Id = 2, NotificationListId = 1 } }; var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls" }; _repository.GetListByNameAsync("ops-team").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(recipients); _repository.GetAllSmtpConfigurationsAsync().Returns(new List { cfg }); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.Contains("address", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); Assert.Contains("not a valid address @@", result.ErrorMessage); } // ── NotificationService-009: credential secrets scrubbed from logs/results ── [Fact] public async Task Send_PermanentError_RedactsCredentialFromResultMessage() { // NS-009: a permanent-failure message echoing a credential fragment must be // scrubbed before it reaches the caller-facing NotificationResult. var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "svcuser:Hunter2Secret", TlsMode = "starttls" }; SetupHappyPathWithSmtp(cfg); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new SmtpPermanentException("550 rejected — password Hunter2Secret is invalid")); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.DoesNotContain("Hunter2Secret", result.ErrorMessage); } [Fact] public async Task Send_MalformedFromAddress_ReturnsCleanError_DoesNotThrow() { var cfg = new SmtpConfiguration("smtp.example.com", "basic", "@@bad-from@@") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls" }; SetupHappyPathWithSmtp(cfg); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.Contains("address", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); } }