Files
scadalink-design/tests/ScadaLink.NotificationService.Tests/NotificationDeliveryServiceTests.cs

409 lines
16 KiB
C#

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&amp;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, true, 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_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);
}
}