1034 lines
44 KiB
C#
1034 lines
44 KiB
C#
using System.Net;
|
|
using System.Text.Json;
|
|
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.StoreAndForward;
|
|
|
|
namespace ScadaLink.NotificationService.Tests;
|
|
|
|
/// <summary>
|
|
/// WP-11/12: Tests for notification delivery — SMTP delivery, error classification, S&F integration.
|
|
/// </summary>
|
|
public class NotificationDeliveryServiceTests
|
|
{
|
|
private readonly INotificationRepository _repository = Substitute.For<INotificationRepository>();
|
|
private readonly ISmtpClientWrapper _smtpClient = Substitute.For<ISmtpClientWrapper>();
|
|
|
|
private NotificationDeliveryService CreateService(StoreAndForward.StoreAndForwardService? sf = null)
|
|
{
|
|
return new NotificationDeliveryService(
|
|
_repository,
|
|
() => _smtpClient,
|
|
NullLogger<NotificationDeliveryService>.Instance,
|
|
tokenService: null,
|
|
storeAndForward: sf);
|
|
}
|
|
|
|
private void SetupHappyPath()
|
|
{
|
|
var list = new NotificationList("ops-team") { Id = 1 };
|
|
var recipients = new List<NotificationRecipient>
|
|
{
|
|
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<SmtpConfiguration> { 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<NotificationRecipient>());
|
|
|
|
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<NotificationRecipient>
|
|
{
|
|
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
|
|
};
|
|
_repository.GetListByNameAsync("test").Returns(list);
|
|
_repository.GetRecipientsByListIdAsync(1).Returns(recipients);
|
|
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration>());
|
|
|
|
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, SmtpTlsMode.StartTls, Arg.Any<int>(), Arg.Any<CancellationToken>());
|
|
await _smtpClient.Received().AuthenticateAsync("basic", "user:pass", Arg.Any<CancellationToken>());
|
|
await _smtpClient.Received().SendAsync(
|
|
"noreply@example.com",
|
|
Arg.Is<IEnumerable<string>>(bcc => bcc.Count() == 2),
|
|
"Alert",
|
|
"Body",
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_PermanentSmtpError_ReturnsErrorDirectly()
|
|
{
|
|
SetupHappyPath();
|
|
_smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.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<string>? capturedBcc = null;
|
|
_smtpClient.SendAsync(
|
|
Arg.Any<string>(),
|
|
Arg.Do<IEnumerable<string>>(bcc => capturedBcc = bcc),
|
|
Arg.Any<string>(),
|
|
Arg.Any<string>(),
|
|
Arg.Any<CancellationToken>())
|
|
.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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.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<StoreAndForward.StoreAndForwardStorage>.Instance);
|
|
await storage.InitializeAsync();
|
|
|
|
var sfOptions = new StoreAndForward.StoreAndForwardOptions();
|
|
var sfService = new StoreAndForward.StoreAndForwardService(
|
|
storage, sfOptions, NullLogger<StoreAndForward.StoreAndForwardService>.Instance);
|
|
|
|
var service = CreateService(sf: sfService);
|
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.True(result.Success);
|
|
Assert.True(result.WasBuffered);
|
|
}
|
|
|
|
// ── NotificationService-001: buffered-notification delivery handler ──
|
|
|
|
private static StoreAndForward.StoreAndForwardMessage BufferedNotification(string listName) =>
|
|
new()
|
|
{
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
Category = ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.Notification,
|
|
Target = listName,
|
|
PayloadJson = $$"""{"ListName":"{{listName}}","Subject":"Alert","Message":"Body"}""",
|
|
};
|
|
|
|
[Fact]
|
|
public async Task DeliverBuffered_HappyPath_ReturnsTrue()
|
|
{
|
|
SetupHappyPath();
|
|
var service = CreateService();
|
|
|
|
var delivered = await service.DeliverBufferedAsync(BufferedNotification("ops-team"));
|
|
|
|
Assert.True(delivered);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeliverBuffered_ListNoLongerExists_ReturnsFalseSoMessageParks()
|
|
{
|
|
_repository.GetListByNameAsync("gone-list").Returns((NotificationList?)null);
|
|
var service = CreateService();
|
|
|
|
var delivered = await service.DeliverBufferedAsync(BufferedNotification("gone-list"));
|
|
|
|
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_NotClassifiedAsPermanentSmtpError()
|
|
{
|
|
// 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 SMTP error" result. Classification now uses MailKit's
|
|
// typed exceptions only, so a non-SMTP exception is no longer misclassified.
|
|
// NS-015: that unclassified exception no longer escapes SendAsync either — it
|
|
// returns a clean generic "delivery failed" result (NOT "Permanent SMTP error").
|
|
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();
|
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.False(result.Success);
|
|
// It is reported as a generic delivery failure, not mistaken for a 5xx rejection.
|
|
Assert.DoesNotContain("Permanent SMTP error", result.ErrorMessage);
|
|
}
|
|
|
|
[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, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, 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);
|
|
}
|
|
|
|
// ── NotificationService-010: SMTP client is disconnected on the failure path ──
|
|
|
|
/// <summary>
|
|
/// An SMTP wrapper that records whether <see cref="DisconnectAsync"/> ran and
|
|
/// can be told to fail at a chosen stage of the delivery sequence.
|
|
/// </summary>
|
|
private sealed class DisconnectTrackingClient : ISmtpClientWrapper, IDisposable
|
|
{
|
|
private readonly Func<Exception>? _failOnSend;
|
|
private readonly Func<Exception>? _failOnAuthenticate;
|
|
|
|
public DisconnectTrackingClient(
|
|
Func<Exception>? failOnSend = null, Func<Exception>? failOnAuthenticate = null)
|
|
{
|
|
_failOnSend = failOnSend;
|
|
_failOnAuthenticate = failOnAuthenticate;
|
|
}
|
|
|
|
public bool Disconnected { get; private set; }
|
|
public bool Disposed { get; private set; }
|
|
|
|
public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
|
|
=> Task.CompletedTask;
|
|
|
|
public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
|
|
=> _failOnAuthenticate != null ? Task.FromException(_failOnAuthenticate()) : Task.CompletedTask;
|
|
|
|
public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
|
|
=> _failOnSend != null ? Task.FromException(_failOnSend()) : Task.CompletedTask;
|
|
|
|
public Task DisconnectAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
Disconnected = true;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public void Dispose() => Disposed = true;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_TransientFailureDuringSend_StillDisconnectsClient()
|
|
{
|
|
// NS-010: DisconnectAsync used to run only on the success path inside the
|
|
// try block. A failure in SendAsync left the authenticated connection open
|
|
// (the SMTP QUIT was never issued), leaking server connection slots under
|
|
// sustained transient failures.
|
|
SetupHappyPath();
|
|
var tracking = new DisconnectTrackingClient(
|
|
failOnSend: () => new SmtpProtocolException("protocol error"));
|
|
var service = new NotificationDeliveryService(
|
|
_repository, () => tracking, NullLogger<NotificationDeliveryService>.Instance);
|
|
|
|
await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.True(tracking.Disconnected, "DeliverAsync must disconnect the SMTP client even when the send fails");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_FailureDuringAuthenticate_StillDisconnectsClient()
|
|
{
|
|
// NS-010: an AuthenticateAsync failure must also tear the connection down.
|
|
SetupHappyPath();
|
|
var tracking = new DisconnectTrackingClient(
|
|
failOnAuthenticate: () => new SmtpProtocolException("auth handshake failed"));
|
|
var service = new NotificationDeliveryService(
|
|
_repository, () => tracking, NullLogger<NotificationDeliveryService>.Instance);
|
|
|
|
await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.True(tracking.Disconnected, "DeliverAsync must disconnect the SMTP client even when authentication fails");
|
|
}
|
|
|
|
// ── NotificationService-005: explicit TLS mode passed through to the wrapper ──
|
|
|
|
/// <summary>An SMTP wrapper that records the TLS mode and timeout it was connected with.</summary>
|
|
private sealed class RecordingTlsClient : ISmtpClientWrapper
|
|
{
|
|
public SmtpTlsMode? TlsMode { get; private set; }
|
|
public int ConnectionTimeoutSeconds { get; private set; }
|
|
public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
|
|
{
|
|
TlsMode = tlsMode;
|
|
ConnectionTimeoutSeconds = connectionTimeoutSeconds;
|
|
return 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;
|
|
}
|
|
|
|
private void SetupHappyPathWithSmtp(SmtpConfiguration smtpConfig)
|
|
{
|
|
var list = new NotificationList("ops-team") { Id = 1 };
|
|
var recipients = new List<NotificationRecipient>
|
|
{
|
|
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
|
|
};
|
|
_repository.GetListByNameAsync("ops-team").Returns(list);
|
|
_repository.GetRecipientsByListIdAsync(1).Returns(recipients);
|
|
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { smtpConfig });
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_TlsModeNone_DoesNotNegotiateTls()
|
|
{
|
|
// NS-005: TlsMode "none" must connect with SmtpTlsMode.None, not the old
|
|
// SecureSocketOptions.Auto (which let MailKit opportunistically negotiate TLS).
|
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 25, Credentials = "user:pass", TlsMode = "none"
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
var recording = new RecordingTlsClient();
|
|
var service = new NotificationDeliveryService(
|
|
_repository, () => recording, NullLogger<NotificationDeliveryService>.Instance);
|
|
|
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.True(result.Success);
|
|
Assert.Equal(SmtpTlsMode.None, recording.TlsMode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_TlsModeSsl_UsesImplicitSsl()
|
|
{
|
|
// NS-005: TlsMode "ssl" (port 465 implicit TLS) must be honoured, not
|
|
// collapsed into the same path as "none".
|
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 465, Credentials = "user:pass", TlsMode = "ssl"
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
var recording = new RecordingTlsClient();
|
|
var service = new NotificationDeliveryService(
|
|
_repository, () => recording, NullLogger<NotificationDeliveryService>.Instance);
|
|
|
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.True(result.Success);
|
|
Assert.Equal(SmtpTlsMode.Ssl, recording.TlsMode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_UnknownTlsMode_ReturnsErrorNotSilentFallback()
|
|
{
|
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "bogus"
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
var service = new NotificationDeliveryService(
|
|
_repository, () => new RecordingTlsClient(), NullLogger<NotificationDeliveryService>.Instance);
|
|
|
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("TLS mode", result.ErrorMessage);
|
|
}
|
|
|
|
// ── NotificationService-007: connection timeout passed through to the wrapper ──
|
|
|
|
[Fact]
|
|
public async Task Send_PassesConfiguredConnectionTimeoutToClient()
|
|
{
|
|
// NS-007: SmtpConfiguration.ConnectionTimeoutSeconds must reach the wrapper
|
|
// so SmtpClient.Timeout is set; it was previously dead configuration.
|
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls",
|
|
ConnectionTimeoutSeconds = 17
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
var recording = new RecordingTlsClient();
|
|
var service = new NotificationDeliveryService(
|
|
_repository, () => recording, NullLogger<NotificationDeliveryService>.Instance);
|
|
|
|
await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.Equal(17, recording.ConnectionTimeoutSeconds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_MaxConcurrentConnections_LimitsConcurrentDeliveries()
|
|
{
|
|
// NS-007: MaxConcurrentConnections must throttle concurrent SMTP deliveries.
|
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls",
|
|
MaxConcurrentConnections = 2
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
|
|
var inFlight = 0;
|
|
var maxObserved = 0;
|
|
var gate = new SemaphoreSlim(0);
|
|
var sync = new object();
|
|
|
|
var service = new NotificationDeliveryService(
|
|
_repository,
|
|
() => new BlockingSmtpClient(
|
|
onSend: async () =>
|
|
{
|
|
lock (sync)
|
|
{
|
|
inFlight++;
|
|
if (inFlight > maxObserved) maxObserved = inFlight;
|
|
}
|
|
await gate.WaitAsync();
|
|
lock (sync) { inFlight--; }
|
|
}),
|
|
NullLogger<NotificationDeliveryService>.Instance);
|
|
|
|
var sends = Enumerable.Range(0, 6)
|
|
.Select(_ => service.SendAsync("ops-team", "Alert", "Body"))
|
|
.ToList();
|
|
|
|
// Give the throttled sends time to reach the SMTP send call.
|
|
await Task.Delay(200);
|
|
gate.Release(6);
|
|
await Task.WhenAll(sends);
|
|
|
|
Assert.True(maxObserved <= 2, $"Expected at most 2 concurrent deliveries, observed {maxObserved}");
|
|
}
|
|
|
|
private sealed class BlockingSmtpClient : ISmtpClientWrapper, IDisposable
|
|
{
|
|
private readonly Func<Task> _onSend;
|
|
public BlockingSmtpClient(Func<Task> onSend) => _onSend = onSend;
|
|
public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, 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)
|
|
=> _onSend();
|
|
public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
|
public void Dispose() { }
|
|
}
|
|
|
|
// ── NotificationService-008: recipient address validation ──
|
|
|
|
[Fact]
|
|
public async Task Send_MalformedRecipientAddress_ReturnsCleanError_DoesNotThrow()
|
|
{
|
|
// NS-008: a malformed recipient address previously caused MailboxAddress.Parse
|
|
// to throw ParseException, which escaped SendAsync unhandled. It must now
|
|
// produce a clean NotificationResult failure.
|
|
var list = new NotificationList("ops-team") { Id = 1 };
|
|
var recipients = new List<NotificationRecipient>
|
|
{
|
|
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 },
|
|
new("Bad", "not a valid address @@") { Id = 2, NotificationListId = 1 }
|
|
};
|
|
var cfg = 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<SmtpConfiguration> { cfg });
|
|
|
|
var service = CreateService();
|
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("address", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("not a valid address @@", result.ErrorMessage);
|
|
}
|
|
|
|
// ── NotificationService-009: credential secrets scrubbed from logs/results ──
|
|
|
|
[Fact]
|
|
public async Task Send_PermanentError_RedactsCredentialFromResultMessage()
|
|
{
|
|
// NS-009: a permanent-failure message echoing a credential fragment must be
|
|
// scrubbed before it reaches the caller-facing NotificationResult.
|
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "svcuser:Hunter2Secret", TlsMode = "starttls"
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
_smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Throws(new SmtpPermanentException("550 rejected — password Hunter2Secret is invalid"));
|
|
|
|
var service = CreateService();
|
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.DoesNotContain("Hunter2Secret", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_MalformedFromAddress_ReturnsCleanError_DoesNotThrow()
|
|
{
|
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "@@bad-from@@")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls"
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
|
|
var service = CreateService();
|
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("address", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// ── NotificationService-012: OAuth2 delivery path coverage ──
|
|
|
|
/// <summary>An SMTP wrapper that records the auth type and credentials it received.</summary>
|
|
private sealed class RecordingAuthClient : ISmtpClientWrapper
|
|
{
|
|
public string? AuthType { get; private set; }
|
|
public string? Credentials { get; private set; }
|
|
public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
|
|
=> Task.CompletedTask;
|
|
public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
|
|
{
|
|
AuthType = authType;
|
|
Credentials = credentials;
|
|
return 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;
|
|
}
|
|
|
|
private static OAuth2TokenService CreateTokenService(string accessToken, int expiresIn = 3600)
|
|
{
|
|
var json = JsonSerializer.Serialize(new
|
|
{
|
|
access_token = accessToken,
|
|
expires_in = expiresIn,
|
|
token_type = "Bearer"
|
|
});
|
|
var factory = Substitute.For<IHttpClientFactory>();
|
|
factory.CreateClient(Arg.Any<string>())
|
|
.Returns(_ => new HttpClient(new StubHttpHandler(json)));
|
|
return new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
|
}
|
|
|
|
private sealed class StubHttpHandler : HttpMessageHandler
|
|
{
|
|
private readonly string _json;
|
|
public StubHttpHandler(string json) => _json = json;
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
=> Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(_json)
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_OAuth2Config_AuthenticatesWithResolvedAccessToken()
|
|
{
|
|
// NS-012: the OAuth2 delivery branch in DeliverAsync (token resolution during
|
|
// a send) was never exercised — every other test uses Basic Auth and a null
|
|
// token service. The credentials reaching the SMTP client must be the access
|
|
// token from OAuth2TokenService, not the raw tenant:client:secret triple.
|
|
var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls"
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
|
|
var recording = new RecordingAuthClient();
|
|
var service = new NotificationDeliveryService(
|
|
_repository,
|
|
() => recording,
|
|
NullLogger<NotificationDeliveryService>.Instance,
|
|
tokenService: CreateTokenService("oauth2-access-token-xyz"));
|
|
|
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.True(result.Success);
|
|
Assert.Equal("oauth2", recording.AuthType);
|
|
Assert.Equal("oauth2-access-token-xyz", recording.Credentials);
|
|
}
|
|
|
|
// ── NotificationService-015: unclassified exceptions must not escape SendAsync ──
|
|
|
|
/// <summary>
|
|
/// An <see cref="OAuth2TokenService"/> whose token endpoint returns a non-success
|
|
/// HTTP status, so <c>GetTokenAsync</c> throws <see cref="HttpRequestException"/>.
|
|
/// </summary>
|
|
private static OAuth2TokenService CreateFailingTokenService(
|
|
HttpStatusCode status = HttpStatusCode.Unauthorized)
|
|
{
|
|
var factory = Substitute.For<IHttpClientFactory>();
|
|
factory.CreateClient(Arg.Any<string>())
|
|
.Returns(_ => new HttpClient(new FailingHttpHandler(status)));
|
|
return new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
|
}
|
|
|
|
private sealed class FailingHttpHandler : HttpMessageHandler
|
|
{
|
|
private readonly System.Net.HttpStatusCode _status;
|
|
public FailingHttpHandler(System.Net.HttpStatusCode status) => _status = status;
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
=> Task.FromResult(new HttpResponseMessage(_status)
|
|
{
|
|
Content = new StringContent("error")
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_OAuth2TokenFetchFails_ReturnsCleanError_DoesNotThrow()
|
|
{
|
|
// NS-015: an OAuth2 token-fetch failure (HttpRequestException from
|
|
// EnsureSuccessStatusCode) is classified Unknown — it fell through all three
|
|
// catch clauses and escaped SendAsync as a raw exception to the calling
|
|
// script. It must instead produce a clean NotificationResult failure.
|
|
var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls"
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
|
|
var service = new NotificationDeliveryService(
|
|
_repository,
|
|
() => new RecordingAuthClient(),
|
|
NullLogger<NotificationDeliveryService>.Instance,
|
|
tokenService: CreateFailingTokenService());
|
|
|
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.NotNull(result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_OAuth2MalformedCredentials_ReturnsCleanError_DoesNotThrow()
|
|
{
|
|
// NS-015: a malformed tenant:client:secret triple makes GetTokenAsync throw
|
|
// InvalidOperationException — also Unknown-classified, also escaped SendAsync.
|
|
var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "no-colons-here", TlsMode = "starttls"
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
|
|
var service = new NotificationDeliveryService(
|
|
_repository,
|
|
() => new RecordingAuthClient(),
|
|
NullLogger<NotificationDeliveryService>.Instance,
|
|
tokenService: CreateTokenService("unused"));
|
|
|
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.NotNull(result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_UnclassifiedException_RedactsCredentialFromResult()
|
|
{
|
|
// NS-015: the catch-all result, like the permanent-error path (NS-009), must
|
|
// not leak credential fragments echoed in an exception message.
|
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "svcuser:Hunter2Secret", TlsMode = "starttls"
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
_smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Throws(new InvalidOperationException("internal failure exposing Hunter2Secret"));
|
|
|
|
var service = CreateService();
|
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.DoesNotContain("Hunter2Secret", result.ErrorMessage);
|
|
}
|
|
|
|
// ── NotificationService-014: unclassified exceptions must not escape DeliverBufferedAsync ──
|
|
|
|
[Fact]
|
|
public async Task DeliverBuffered_OAuth2MalformedCredentials_ReturnsFalseSoMessageParks()
|
|
{
|
|
// NS-014: DeliverBufferedAsync caught only SmtpPermanentException. An OAuth2
|
|
// InvalidOperationException (a permanent, unfixable misconfiguration) escaped
|
|
// the handler; the S&F engine reinterprets any thrown exception as transient
|
|
// and retries forever. A non-retryable cause must park (return false).
|
|
var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "no-colons-here", TlsMode = "starttls"
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
|
|
var service = new NotificationDeliveryService(
|
|
_repository,
|
|
() => new RecordingAuthClient(),
|
|
NullLogger<NotificationDeliveryService>.Instance,
|
|
tokenService: CreateTokenService("unused"));
|
|
|
|
var delivered = await service.DeliverBufferedAsync(BufferedNotification("ops-team"));
|
|
|
|
Assert.False(delivered); // parked — retrying cannot fix a malformed credential
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeliverBuffered_OAuth2TokenEndpoint401_ReturnsFalseSoMessageParks()
|
|
{
|
|
// NS-014: a 401 from the OAuth2 token endpoint is a permanent credential
|
|
// failure — it must park, not be retried on every sweep.
|
|
var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls"
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
|
|
var service = new NotificationDeliveryService(
|
|
_repository,
|
|
() => new RecordingAuthClient(),
|
|
NullLogger<NotificationDeliveryService>.Instance,
|
|
tokenService: CreateFailingTokenService(HttpStatusCode.Unauthorized));
|
|
|
|
var delivered = await service.DeliverBufferedAsync(BufferedNotification("ops-team"));
|
|
|
|
Assert.False(delivered); // parked — a 401 is a permanent credential failure
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeliverBuffered_OAuth2TokenEndpoint503_ThrowsSoEngineRetries()
|
|
{
|
|
// NS-014: a 5xx from the OAuth2 token endpoint is a transient outage — the
|
|
// handler must throw so the S&F engine retries on the next sweep.
|
|
var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls"
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
|
|
var service = new NotificationDeliveryService(
|
|
_repository,
|
|
() => new RecordingAuthClient(),
|
|
NullLogger<NotificationDeliveryService>.Instance,
|
|
tokenService: CreateFailingTokenService(HttpStatusCode.ServiceUnavailable));
|
|
|
|
await Assert.ThrowsAnyAsync<Exception>(
|
|
() => service.DeliverBufferedAsync(BufferedNotification("ops-team")));
|
|
}
|
|
|
|
// ── NotificationService-017: NotificationOptions used as fallback for unset SmtpConfiguration fields ──
|
|
|
|
[Fact]
|
|
public async Task Send_SmtpConfigTimeoutUnset_FallsBackToNotificationOptions()
|
|
{
|
|
// NS-017: NotificationOptions was bound from configuration but never read.
|
|
// It is now the documented fallback: when SmtpConfiguration.ConnectionTimeoutSeconds
|
|
// is non-positive (0 from a partially-deployed row) the NotificationOptions
|
|
// value is used instead of leaving the timeout unbounded.
|
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls",
|
|
ConnectionTimeoutSeconds = 0 // not configured on the row
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
var recording = new RecordingTlsClient();
|
|
|
|
var options = Microsoft.Extensions.Options.Options.Create(
|
|
new NotificationOptions { ConnectionTimeoutSeconds = 42, MaxConcurrentConnections = 5 });
|
|
var service = new NotificationDeliveryService(
|
|
_repository, () => recording, NullLogger<NotificationDeliveryService>.Instance,
|
|
options: options);
|
|
|
|
await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.Equal(42, recording.ConnectionTimeoutSeconds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_SmtpConfigTimeoutSet_OverridesNotificationOptions()
|
|
{
|
|
// NS-017: a value present on the SmtpConfiguration row still wins over the
|
|
// NotificationOptions fallback.
|
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls",
|
|
ConnectionTimeoutSeconds = 19
|
|
};
|
|
SetupHappyPathWithSmtp(cfg);
|
|
var recording = new RecordingTlsClient();
|
|
|
|
var options = Microsoft.Extensions.Options.Options.Create(
|
|
new NotificationOptions { ConnectionTimeoutSeconds = 42 });
|
|
var service = new NotificationDeliveryService(
|
|
_repository, () => recording, NullLogger<NotificationDeliveryService>.Instance,
|
|
options: options);
|
|
|
|
await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.Equal(19, recording.ConnectionTimeoutSeconds);
|
|
}
|
|
|
|
// ── NotificationService-018: concurrency limiter disposal ──
|
|
|
|
[Fact]
|
|
public async Task Service_Dispose_DisposesConcurrencyLimiter()
|
|
{
|
|
// NS-018: the lazily-created SemaphoreSlim was never disposed and the service
|
|
// did not implement IDisposable — a slow handle leak per scope. Disposing the
|
|
// service must dispose the limiter; using it afterwards must fault.
|
|
SetupHappyPath();
|
|
var service = CreateService();
|
|
|
|
// A send creates the limiter.
|
|
await service.SendAsync("ops-team", "Alert", "Body");
|
|
|
|
Assert.IsAssignableFrom<IDisposable>(service);
|
|
((IDisposable)service).Dispose();
|
|
|
|
// A second send after disposal must fail fast on the disposed semaphore
|
|
// rather than silently using a disposed object.
|
|
await Assert.ThrowsAnyAsync<ObjectDisposedException>(
|
|
() => service.SendAsync("ops-team", "Alert", "Body"));
|
|
}
|
|
}
|