fix(notification-service): resolve NotificationService-002/003/004 — error classification by SMTP status code, single SMTP client
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using MailKit;
|
||||
using MailKit.Net.Smtp;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
@@ -225,4 +227,182 @@ public class NotificationDeliveryServiceTests
|
||||
|
||||
Assert.False(delivered); // permanent — the S&F engine parks the message
|
||||
}
|
||||
|
||||
// ── NotificationService-002: cancellation must not be misclassified as transient ──
|
||||
|
||||
/// <summary>
|
||||
/// Like <see cref="SetupHappyPath"/> but matches any <see cref="CancellationToken"/>,
|
||||
/// so tests that pass an already-cancelled token still resolve the list/recipients.
|
||||
/// </summary>
|
||||
private void SetupHappyPathAnyToken()
|
||||
{
|
||||
var list = new NotificationList("ops-team") { Id = 1 };
|
||||
var recipients = new List<NotificationRecipient>
|
||||
{
|
||||
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<CancellationToken>()).Returns(list);
|
||||
_repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>()).Returns(recipients);
|
||||
_repository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<SmtpConfiguration> { smtpConfig });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_CancellationRequested_PropagatesAndDoesNotBuffer()
|
||||
{
|
||||
SetupHappyPathAnyToken();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
_smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Throws(new OperationCanceledException(cts.Token));
|
||||
|
||||
var sfService = await CreateSfServiceAsync();
|
||||
var service = CreateService(sf: sfService);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => 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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Throws(new TaskCanceledException());
|
||||
|
||||
var service = CreateService(sf: null);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => 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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.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<InvalidOperationException>(
|
||||
() => service.SendAsync("ops-team", "Alert", "Body"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_SmtpProtocolException_ClassifiedTransient()
|
||||
{
|
||||
SetupHappyPath();
|
||||
_smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.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<string> 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<TrackingSmtpClient>();
|
||||
var service = new NotificationDeliveryService(
|
||||
_repository,
|
||||
() =>
|
||||
{
|
||||
var c = new TrackingSmtpClient();
|
||||
created.Add(c);
|
||||
return c;
|
||||
},
|
||||
NullLogger<NotificationDeliveryService>.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<StoreAndForwardService> CreateSfServiceAsync()
|
||||
{
|
||||
var dbName = $"file:sf_test_{Guid.NewGuid():N}?mode=memory&cache=shared";
|
||||
var storage = new StoreAndForwardStorage(
|
||||
$"Data Source={dbName}", NullLogger<StoreAndForwardStorage>.Instance);
|
||||
await storage.InitializeAsync();
|
||||
return new StoreAndForwardService(
|
||||
storage, new StoreAndForwardOptions(), NullLogger<StoreAndForwardService>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user