using System.Net.Sockets; 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.Commons.Types.Enums; using ScadaLink.NotificationOutbox.Delivery; using ScadaLink.NotificationService; namespace ScadaLink.NotificationOutbox.Tests.Delivery; /// /// Task 12: Tests for the Email outbox delivery adapter — list/recipient/SMTP-config /// resolution, SMTP send, and the three-way (Success / Permanent / Transient) outcome /// classification. /// public class EmailNotificationDeliveryAdapterTests { private readonly INotificationRepository _repository = Substitute.For(); private readonly ISmtpClientWrapper _smtpClient = Substitute.For(); private EmailNotificationDeliveryAdapter CreateAdapter() { return new EmailNotificationDeliveryAdapter( _repository, () => _smtpClient, NullLogger.Instance); } private static Notification MakeNotification(string listName = "ops-team") { return new Notification( Guid.NewGuid().ToString(), NotificationType.Email, listName, "Subject", "Body", "site-1"); } 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 void Type_IsEmail() { Assert.Equal(NotificationType.Email, CreateAdapter().Type); } [Fact] public async Task Deliver_HappyPath_ReturnsSuccessWithResolvedTargets() { SetupHappyPath(); var adapter = CreateAdapter(); var outcome = await adapter.DeliverAsync(MakeNotification()); Assert.Equal(DeliveryResult.Success, outcome.Result); Assert.NotNull(outcome.ResolvedTargets); Assert.Contains("alice@example.com", outcome.ResolvedTargets); Assert.Contains("bob@example.com", outcome.ResolvedTargets); Assert.Null(outcome.Error); } [Fact] public async Task Deliver_ListNotFound_ReturnsPermanent() { _repository.GetListByNameAsync("missing").Returns((NotificationList?)null); var adapter = CreateAdapter(); var outcome = await adapter.DeliverAsync(MakeNotification("missing")); Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result); Assert.Contains("missing", outcome.Error); Assert.Contains("not found", outcome.Error); } [Fact] public async Task Deliver_NoRecipients_ReturnsPermanent() { var list = new NotificationList("ops-team") { Id = 1 }; _repository.GetListByNameAsync("ops-team").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(new List()); var adapter = CreateAdapter(); var outcome = await adapter.DeliverAsync(MakeNotification()); Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result); Assert.Contains("recipient", outcome.Error); } [Fact] public async Task Deliver_NoSmtpConfig_ReturnsPermanent() { var list = new NotificationList("ops-team") { Id = 1 }; _repository.GetListByNameAsync("ops-team").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 } }); _repository.GetAllSmtpConfigurationsAsync().Returns(new List()); var adapter = CreateAdapter(); var outcome = await adapter.DeliverAsync(MakeNotification()); Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result); Assert.Contains("SMTP", outcome.Error); } [Fact] public async Task Deliver_SmtpPermanentException_ReturnsPermanent() { SetupHappyPath(); _smtpClient .SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new SmtpPermanentException("550 mailbox unavailable")); var adapter = CreateAdapter(); var outcome = await adapter.DeliverAsync(MakeNotification()); Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result); Assert.Contains("mailbox unavailable", outcome.Error); } [Fact] public async Task Deliver_Smtp5xxCommandException_ReturnsPermanent() { SetupHappyPath(); _smtpClient .SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new SmtpCommandException( SmtpErrorCode.RecipientNotAccepted, SmtpStatusCode.MailboxUnavailable, "rejected")); var adapter = CreateAdapter(); var outcome = await adapter.DeliverAsync(MakeNotification()); Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result); } [Fact] public async Task Deliver_SocketException_ReturnsTransient() { SetupHappyPath(); _smtpClient .ConnectAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new SocketException((int)SocketError.ConnectionRefused)); var adapter = CreateAdapter(); var outcome = await adapter.DeliverAsync(MakeNotification()); Assert.Equal(DeliveryResult.TransientFailure, outcome.Result); Assert.NotNull(outcome.Error); } [Fact] public async Task Deliver_Smtp4xxCommandException_ReturnsTransient() { SetupHappyPath(); _smtpClient .SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new SmtpCommandException( SmtpErrorCode.MessageNotAccepted, SmtpStatusCode.MailboxBusy, "try later")); var adapter = CreateAdapter(); var outcome = await adapter.DeliverAsync(MakeNotification()); Assert.Equal(DeliveryResult.TransientFailure, outcome.Result); } [Fact] public async Task Deliver_UnclassifiedException_ReturnsPermanent() { SetupHappyPath(); _smtpClient .SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("token endpoint unreachable")); var adapter = CreateAdapter(); var outcome = await adapter.DeliverAsync(MakeNotification()); Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result); Assert.NotNull(outcome.Error); } [Fact] public async Task Deliver_MalformedRecipientAddress_ReturnsPermanent() { const string malformed = "bad-recipient@"; var list = new NotificationList("ops-team") { Id = 1 }; _repository.GetListByNameAsync("ops-team").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(new List { new("Mallory", malformed) { Id = 1, NotificationListId = 1 } }); _repository.GetAllSmtpConfigurationsAsync().Returns(new List { new("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls" } }); var adapter = CreateAdapter(); var outcome = await adapter.DeliverAsync(MakeNotification()); Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result); Assert.Contains(malformed, outcome.Error); } [Fact] public async Task Deliver_CancelledToken_ThrowsOperationCanceledException() { using var cts = new CancellationTokenSource(); cts.Cancel(); // List/recipient/SMTP-config resolution must succeed for any token so that // delivery proceeds far enough to observe the cancellation at the SMTP call. var list = new NotificationList("ops-team") { Id = 1 }; _repository.GetListByNameAsync("ops-team", Arg.Any()).Returns(list); _repository.GetRecipientsByListIdAsync(1, Arg.Any()) .Returns(new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 } }); _repository.GetAllSmtpConfigurationsAsync(Arg.Any()) .Returns(new List { new("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls" } }); // The connect call honours the cancelled token, mirroring a real SMTP client. _smtpClient .ConnectAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new OperationCanceledException(cts.Token)); var adapter = CreateAdapter(); await Assert.ThrowsAnyAsync( () => adapter.DeliverAsync(MakeNotification(), cts.Token)); } }