From 04e00d56c6ec1f0a0cc8f92c2f3f575a88427c14 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 19 May 2026 01:32:54 -0400 Subject: [PATCH] test(notification-outbox): cover Email adapter permanent/cancellation arms, align error casing --- .../EmailNotificationDeliveryAdapter.cs | 12 +-- .../EmailNotificationDeliveryAdapterTests.cs | 76 +++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/ScadaLink.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs b/src/ScadaLink.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs index d65d978..6158e6e 100644 --- a/src/ScadaLink.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs +++ b/src/ScadaLink.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs @@ -61,21 +61,21 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap if (list == null) { return DeliveryOutcome.Permanent( - $"notification list '{notification.ListName}' not found"); + $"Notification list '{notification.ListName}' not found"); } var recipients = await _repository.GetRecipientsByListIdAsync(list.Id, cancellationToken); if (recipients.Count == 0) { return DeliveryOutcome.Permanent( - $"notification list '{notification.ListName}' has no recipients"); + $"Notification list '{notification.ListName}' has no recipients"); } var smtpConfigs = await _repository.GetAllSmtpConfigurationsAsync(cancellationToken); var smtpConfig = smtpConfigs.FirstOrDefault(); if (smtpConfig == null) { - return DeliveryOutcome.Permanent("no SMTP configuration available"); + return DeliveryOutcome.Permanent("No SMTP configuration available"); } // An unknown TLS mode is a configuration error that retrying cannot fix — @@ -146,7 +146,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap _logger.LogError( "Unclassified failure delivering email to list '{List}' ({ExceptionType}): {Detail}", notification.ListName, ex.GetType().Name, detail); - return DeliveryOutcome.Permanent($"email delivery failed: {detail}"); + return DeliveryOutcome.Permanent($"Email delivery failed: {detail}"); } } @@ -227,7 +227,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap { if (!MailboxAddress.TryParse(fromAddress, out _)) { - return $"invalid sender (from) email address: '{fromAddress}'"; + return $"Invalid sender (from) email address: '{fromAddress}'"; } var invalid = recipients @@ -236,7 +236,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap .ToList(); return invalid.Count > 0 - ? $"invalid recipient email address(es): {string.Join(", ", invalid)}" + ? $"Invalid recipient email address(es): {string.Join(", ", invalid)}" : null; } diff --git a/tests/ScadaLink.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs index 2f6a2e3..37a5bc3 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs @@ -188,4 +188,80 @@ public class EmailNotificationDeliveryAdapterTests 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)); + } }