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, true, 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, bool useTls, 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); } }