Files
scadalink-design/tests/ScadaLink.NotificationService.Tests/SmtpErrorClassifierTests.cs
Joseph Doherty 5e80f64cd8 refactor(notification-outbox): share SMTP helpers between NotificationService and the Email adapter
FU1 of the Notification Outbox follow-ups. EmailNotificationDeliveryAdapter
carried verbatim private copies of credential redaction, SMTP error
classification, and address validation because the NotificationService
helpers were internal. This eliminates the divergence risk by promoting the
helpers to public and deleting the adapter's copies.

- CredentialRedactor: internal -> public.
- Extract SmtpErrorClassifier + SmtpErrorClass enum into a new public static
  class; NotificationDeliveryService now routes classification through it
  (behavior unchanged). Adds focused SmtpErrorClassifierTests.
- NotificationDeliveryService.ValidateAddresses: internal -> public; the
  adapter calls it directly.
- EmailNotificationDeliveryAdapter: deleted ScrubCredentials, ClassifySmtpError,
  SmtpErrorClass, IsTransientSmtpError and ValidateAddresses copies.

No InternalsVisibleTo hack — specific helpers promoted to public. Both test
suites green; full solution builds clean.
2026-05-19 03:34:22 -04:00

134 lines
4.5 KiB
C#

using System.Net.Sockets;
using MailKit;
using MailKit.Net.Smtp;
using MailKit.Security;
namespace ScadaLink.NotificationService.Tests;
/// <summary>
/// NS-002/NS-003: Tests for the shared SMTP error classification policy. This
/// policy is correctness-relevant — it decides whether a delivery failure is
/// retried (transient) or returned to the caller (permanent) — and is shared
/// between <see cref="NotificationDeliveryService"/> and the central outbox's
/// <c>EmailNotificationDeliveryAdapter</c>, so it deserves direct coverage.
/// </summary>
public class SmtpErrorClassifierTests
{
[Theory]
[InlineData(421)] // service not available
[InlineData(450)] // mailbox unavailable (busy)
[InlineData(451)] // local error in processing
[InlineData(452)] // insufficient system storage
public void Classify_Smtp4xxCommand_IsTransient(int statusCode)
{
var ex = new SmtpCommandException(
SmtpErrorCode.MessageNotAccepted, (SmtpStatusCode)statusCode, "rejected");
Assert.Equal(SmtpErrorClass.Transient, SmtpErrorClassifier.Classify(ex, CancellationToken.None));
}
[Theory]
[InlineData(500)] // syntax error
[InlineData(550)] // mailbox unavailable (rejected)
[InlineData(553)] // mailbox name not allowed
[InlineData(554)] // transaction failed
public void Classify_Smtp5xxCommand_IsPermanent(int statusCode)
{
var ex = new SmtpCommandException(
SmtpErrorCode.MessageNotAccepted, (SmtpStatusCode)statusCode, "rejected");
Assert.Equal(SmtpErrorClass.Permanent, SmtpErrorClassifier.Classify(ex, CancellationToken.None));
}
[Fact]
public void Classify_SmtpCommandWithUnusualCode_IsUnknown()
{
// A status code outside the 4xx/5xx bands is not classifiable.
var ex = new SmtpCommandException(
SmtpErrorCode.UnexpectedStatusCode, (SmtpStatusCode)250, "ok-ish");
Assert.Equal(SmtpErrorClass.Unknown, SmtpErrorClassifier.Classify(ex, CancellationToken.None));
}
[Fact]
public void Classify_SmtpProtocolException_IsTransient()
{
Assert.Equal(
SmtpErrorClass.Transient,
SmtpErrorClassifier.Classify(new SmtpProtocolException("protocol error"), CancellationToken.None));
}
[Fact]
public void Classify_ServiceNotConnectedException_IsTransient()
{
Assert.Equal(
SmtpErrorClass.Transient,
SmtpErrorClassifier.Classify(new ServiceNotConnectedException(), CancellationToken.None));
}
[Fact]
public void Classify_SocketException_IsTransient()
{
Assert.Equal(
SmtpErrorClass.Transient,
SmtpErrorClassifier.Classify(new SocketException(), CancellationToken.None));
}
[Fact]
public void Classify_TimeoutException_IsTransient()
{
Assert.Equal(
SmtpErrorClass.Transient,
SmtpErrorClassifier.Classify(new TimeoutException(), CancellationToken.None));
}
[Fact]
public void Classify_RequestedCancellation_IsUnknown()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.Equal(
SmtpErrorClass.Unknown,
SmtpErrorClassifier.Classify(new OperationCanceledException(), cts.Token));
}
[Fact]
public void Classify_OperationCanceledWithoutRequestedCancellation_IsUnknown()
{
// Not a recognised SMTP error, and cancellation was not requested.
Assert.Equal(
SmtpErrorClass.Unknown,
SmtpErrorClassifier.Classify(new OperationCanceledException(), CancellationToken.None));
}
[Fact]
public void Classify_UnrecognisedException_IsUnknown()
{
Assert.Equal(
SmtpErrorClass.Unknown,
SmtpErrorClassifier.Classify(new InvalidOperationException("bad credential triple"), CancellationToken.None));
}
[Theory]
[InlineData(450, true)]
[InlineData(550, false)]
[InlineData(250, false)]
public void IsTransient_MatchesClassification(int statusCode, bool expectedTransient)
{
var ex = new SmtpCommandException(
SmtpErrorCode.MessageNotAccepted, (SmtpStatusCode)statusCode, "x");
Assert.Equal(expectedTransient, SmtpErrorClassifier.IsTransient(ex, CancellationToken.None));
}
[Fact]
public void IsTransient_RequestedCancellation_IsFalse()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.False(SmtpErrorClassifier.IsTransient(new OperationCanceledException(), cts.Token));
}
}