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); } }