refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,36 @@
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery;
public class DeliveryOutcomeTests
{
[Fact]
public void Success_SetsResolvedTargets_AndNoError()
{
var outcome = DeliveryOutcome.Success("targets");
Assert.Equal(DeliveryResult.Success, outcome.Result);
Assert.Equal("targets", outcome.ResolvedTargets);
Assert.Null(outcome.Error);
}
[Fact]
public void Transient_SetsError_AndNoResolvedTargets()
{
var outcome = DeliveryOutcome.Transient("e");
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
Assert.Equal("e", outcome.Error);
Assert.Null(outcome.ResolvedTargets);
}
[Fact]
public void Permanent_SetsError_AndNoResolvedTargets()
{
var outcome = DeliveryOutcome.Permanent("e");
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
Assert.Equal("e", outcome.Error);
Assert.Null(outcome.ResolvedTargets);
}
}
@@ -0,0 +1,267 @@
using System.Net.Sockets;
using MailKit;
using MailKit.Net.Smtp;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationService;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery;
/// <summary>
/// Task 12: Tests for the Email outbox delivery adapter — list/recipient/SMTP-config
/// resolution, SMTP send, and the three-way (Success / Permanent / Transient) outcome
/// classification.
/// </summary>
public class EmailNotificationDeliveryAdapterTests
{
private readonly INotificationRepository _repository = Substitute.For<INotificationRepository>();
private readonly ISmtpClientWrapper _smtpClient = Substitute.For<ISmtpClientWrapper>();
private EmailNotificationDeliveryAdapter CreateAdapter()
{
return new EmailNotificationDeliveryAdapter(
_repository,
() => _smtpClient,
NullLogger<EmailNotificationDeliveryAdapter>.Instance);
}
private static Notification MakeNotification(string listName = "ops-team")
{
return new Notification(
Guid.NewGuid().ToString(),
NotificationType.Email,
listName,
"Subject",
"Body",
"site-1");
}
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 void Type_IsEmail()
{
Assert.Equal(NotificationType.Email, CreateAdapter().Type);
}
[Fact]
public async Task Deliver_HappyPath_ReturnsSuccessWithResolvedTargets()
{
SetupHappyPath();
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.Success, outcome.Result);
Assert.NotNull(outcome.ResolvedTargets);
Assert.Contains("alice@example.com", outcome.ResolvedTargets);
Assert.Contains("bob@example.com", outcome.ResolvedTargets);
Assert.Null(outcome.Error);
}
[Fact]
public async Task Deliver_ListNotFound_ReturnsPermanent()
{
_repository.GetListByNameAsync("missing").Returns((NotificationList?)null);
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification("missing"));
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
Assert.Contains("missing", outcome.Error);
Assert.Contains("not found", outcome.Error);
}
[Fact]
public async Task Deliver_NoRecipients_ReturnsPermanent()
{
var list = new NotificationList("ops-team") { Id = 1 };
_repository.GetListByNameAsync("ops-team").Returns(list);
_repository.GetRecipientsByListIdAsync(1).Returns(new List<NotificationRecipient>());
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
Assert.Contains("recipient", outcome.Error);
}
[Fact]
public async Task Deliver_NoSmtpConfig_ReturnsPermanent()
{
var list = new NotificationList("ops-team") { Id = 1 };
_repository.GetListByNameAsync("ops-team").Returns(list);
_repository.GetRecipientsByListIdAsync(1).Returns(new List<NotificationRecipient>
{
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
});
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration>());
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
Assert.Contains("SMTP", outcome.Error);
}
[Fact]
public async Task Deliver_SmtpPermanentException_ReturnsPermanent()
{
SetupHappyPath();
_smtpClient
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new SmtpPermanentException("550 mailbox unavailable"));
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
Assert.Contains("mailbox unavailable", outcome.Error);
}
[Fact]
public async Task Deliver_Smtp5xxCommandException_ReturnsPermanent()
{
SetupHappyPath();
_smtpClient
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new SmtpCommandException(
SmtpErrorCode.RecipientNotAccepted, SmtpStatusCode.MailboxUnavailable, "rejected"));
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
}
[Fact]
public async Task Deliver_SocketException_ReturnsTransient()
{
SetupHappyPath();
_smtpClient
.ConnectAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<SmtpTlsMode>(),
Arg.Any<int>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new SocketException((int)SocketError.ConnectionRefused));
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
Assert.NotNull(outcome.Error);
}
[Fact]
public async Task Deliver_Smtp4xxCommandException_ReturnsTransient()
{
SetupHappyPath();
_smtpClient
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new SmtpCommandException(
SmtpErrorCode.MessageNotAccepted, SmtpStatusCode.MailboxBusy, "try later"));
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
}
[Fact]
public async Task Deliver_UnclassifiedException_ReturnsPermanent()
{
SetupHappyPath();
_smtpClient
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<NotificationRecipient>
{
new("Mallory", malformed) { Id = 1, NotificationListId = 1 }
});
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration>
{
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<CancellationToken>()).Returns(list);
_repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<NotificationRecipient>
{
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
});
_repository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<SmtpConfiguration>
{
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<string>(), Arg.Any<int>(), Arg.Any<SmtpTlsMode>(),
Arg.Any<int>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new OperationCanceledException(cts.Token));
var adapter = CreateAdapter();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => adapter.DeliverAsync(MakeNotification(), cts.Token));
}
}
@@ -0,0 +1,343 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
/// <summary>
/// M4 Bundle B (B2) — verifies the <see cref="NotificationOutboxActor"/>
/// dispatcher loop emits exactly ONE
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
/// audit row with <see cref="AuditStatus.Attempted"/> per attempt regardless of
/// the delivery outcome (success, transient, permanent). Terminal-state
/// emission is covered separately in
/// <see cref="NotificationOutboxActorTerminalEmissionTests"/>.
/// </summary>
public class NotificationOutboxActorAttemptEmissionTests : TestKit
{
private readonly INotificationOutboxRepository _outboxRepository =
Substitute.For<INotificationOutboxRepository>();
private readonly INotificationRepository _notificationRepository =
Substitute.For<INotificationRepository>();
private readonly RecordingCentralAuditWriter _auditWriter = new();
/// <summary>
/// Recording writer so each test can assert on the events captured during
/// one dispatch tick without depending on a concrete implementation.
/// </summary>
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
{
public List<AuditEvent> Events { get; } = new();
public Func<AuditEvent, Task>? OnWrite { get; set; }
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (Events)
{
Events.Add(evt);
}
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
}
}
private IServiceProvider BuildServiceProvider(IEnumerable<INotificationDeliveryAdapter> adapters)
{
var services = new ServiceCollection();
services.AddScoped(_ => _outboxRepository);
services.AddScoped(_ => _notificationRepository);
foreach (var adapter in adapters)
{
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
}
return services.BuildServiceProvider();
}
private sealed class StubAdapter : INotificationDeliveryAdapter
{
private readonly Func<DeliveryOutcome> _outcome;
public int CallCount;
public StubAdapter(Func<DeliveryOutcome> outcome) { _outcome = outcome; }
public NotificationType Type => NotificationType.Email;
public Task<DeliveryOutcome> DeliverAsync(
Notification notification, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref CallCount);
return Task.FromResult(_outcome());
}
}
private IActorRef CreateActor(IEnumerable<INotificationDeliveryAdapter> adapters)
{
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
BuildServiceProvider(adapters),
new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
(ICentralAuditWriter)_auditWriter,
NullLogger<NotificationOutboxActor>.Instance)));
}
private static Notification MakeNotification(
Guid? notificationId = null,
string sourceSite = "site-1",
int retryCount = 0,
Guid? originExecutionId = null,
Guid? originParentExecutionId = null)
{
return new Notification(
(notificationId ?? Guid.NewGuid()).ToString("D"),
NotificationType.Email,
"ops-team",
"Tank overflow",
"Tank 3 level critical",
sourceSite)
{
RetryCount = retryCount,
CreatedAt = DateTimeOffset.UtcNow,
SourceInstanceId = "instance-42",
SourceScript = "AlarmScript",
OriginExecutionId = originExecutionId,
OriginParentExecutionId = originParentExecutionId,
};
}
private void SetupSmtpRetryPolicy(int maxRetries, TimeSpan retryDelay)
{
var config = new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
{
MaxRetries = maxRetries,
RetryDelay = retryDelay,
};
_notificationRepository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new[] { config });
}
private List<AuditEvent> EventsByStatus(AuditStatus status)
{
lock (_auditWriter.Events)
{
return _auditWriter.Events.Where(e => e.Status == status).ToList();
}
}
[Fact]
public void Attempt_Success_EmitsOneEvent_KindNotifyDeliver_StatusAttempted()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var id = Guid.NewGuid();
var notification = MakeNotification(notificationId: id, sourceSite: "site-alpha");
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var attempted = EventsByStatus(AuditStatus.Attempted);
Assert.Single(attempted);
var evt = attempted[0];
Assert.Equal(AuditChannel.Notification, evt.Channel);
Assert.Equal(AuditKind.NotifyDeliver, evt.Kind);
Assert.Equal(id, evt.CorrelationId);
Assert.Equal("ops-team", evt.Target);
Assert.Equal("site-alpha", evt.SourceSiteId);
Assert.Equal("instance-42", evt.SourceInstanceId);
Assert.Equal("AlarmScript", evt.SourceScript);
// Central dispatch: Actor is the system identity (no per-call user).
Assert.Equal("system", evt.Actor);
// Successful attempt: no error message.
Assert.Null(evt.ErrorMessage);
});
}
[Fact]
public void Attempt_CarriesOriginExecutionId_AsExecutionId()
{
// Audit Log #23: the Attempted NotifyDeliver row must echo the
// notification's OriginExecutionId so all rows for one run share an id.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var executionId = Guid.NewGuid();
var notification = MakeNotification(originExecutionId: executionId);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var attempted = EventsByStatus(AuditStatus.Attempted);
Assert.Single(attempted);
Assert.Equal(executionId, attempted[0].ExecutionId);
});
}
[Fact]
public void Attempt_NullOriginExecutionId_HasNullExecutionId()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(originExecutionId: null);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var attempted = EventsByStatus(AuditStatus.Attempted);
Assert.Single(attempted);
Assert.Null(attempted[0].ExecutionId);
});
}
[Fact]
public void Attempt_CarriesOriginParentExecutionId_AsParentExecutionId()
{
// Audit Log ParentExecutionId: the Attempted NotifyDeliver row must echo
// the notification's OriginParentExecutionId so the central dispatcher's
// rows carry the routed run's parent id.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var parentExecutionId = Guid.NewGuid();
var notification = MakeNotification(originParentExecutionId: parentExecutionId);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var attempted = EventsByStatus(AuditStatus.Attempted);
Assert.Single(attempted);
Assert.Equal(parentExecutionId, attempted[0].ParentExecutionId);
});
}
[Fact]
public void Attempt_NullOriginParentExecutionId_HasNullParentExecutionId()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(originParentExecutionId: null);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var attempted = EventsByStatus(AuditStatus.Attempted);
Assert.Single(attempted);
Assert.Null(attempted[0].ParentExecutionId);
});
}
[Fact]
public void Attempt_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(retryCount: 1);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var attempted = EventsByStatus(AuditStatus.Attempted);
Assert.Single(attempted);
Assert.Equal(AuditKind.NotifyDeliver, attempted[0].Kind);
Assert.Equal("smtp timeout", attempted[0].ErrorMessage);
});
}
[Fact]
public void Attempt_PermanentFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Permanent("invalid recipient address"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var attempted = EventsByStatus(AuditStatus.Attempted);
Assert.Single(attempted);
Assert.Equal(AuditKind.NotifyDeliver, attempted[0].Kind);
Assert.Equal("invalid recipient address", attempted[0].ErrorMessage);
});
}
[Fact]
public void AuditWriter_Throws_DeliveryStateUpdate_StillSucceeds()
{
// Audit failure must NEVER abort the user-facing action: the delivery
// outcome must still be persisted via UpdateAsync.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
_auditWriter.OnWrite = _ => throw new InvalidOperationException("audit dead");
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
// Update of the notification row must still happen.
AwaitAssert(() =>
{
_outboxRepository.Received(1).UpdateAsync(
Arg.Is<Notification>(n => n.Status == NotificationStatus.Delivered),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void Attempt_RecordsOccurredAtUtc_AsUtc()
{
// The OccurredAtUtc on the emitted event must be UTC (all timestamps
// are UTC throughout the system).
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var attempted = EventsByStatus(AuditStatus.Attempted);
Assert.Single(attempted);
Assert.Equal(DateTimeKind.Utc, attempted[0].OccurredAtUtc.Kind);
});
}
}
@@ -0,0 +1,79 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
/// <summary>
/// M4 Bundle B (B1) — verifies <see cref="NotificationOutboxActor"/> accepts an
/// <see cref="ICentralAuditWriter"/> at construction so subsequent bundle tasks
/// (B2/B3) can route attempt + terminal lifecycle events through the central
/// direct-write audit path.
/// </summary>
public class NotificationOutboxActorAuditInjectionTests : TestKit
{
private static IServiceProvider BuildEmptyProvider()
{
var services = new ServiceCollection();
services.AddScoped(_ => Substitute.For<INotificationOutboxRepository>());
services.AddScoped(_ => Substitute.For<INotificationRepository>());
return services.BuildServiceProvider();
}
/// <summary>
/// Inline NoOp writer that records calls — used to assert later tasks emit
/// events without depending on a concrete CentralAuditWriter.
/// </summary>
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
{
public List<AuditEvent> Events { get; } = new();
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (Events)
{
Events.Add(evt);
}
return Task.CompletedTask;
}
}
[Fact]
public void Actor_ConstructedWith_ICentralAuditWriter_NoException()
{
var writer = new RecordingCentralAuditWriter();
// Long dispatch interval so PreStart's timer never fires during the test.
var options = new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) };
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
BuildEmptyProvider(),
options,
writer,
NullLogger<NotificationOutboxActor>.Instance)));
Assert.NotNull(actor);
// No event has been emitted yet — the writer is purely injected at this stage.
lock (writer.Events)
{
Assert.Empty(writer.Events);
}
}
[Fact]
public void Actor_NullAuditWriter_Throws()
{
var options = new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) };
Assert.Throws<ArgumentNullException>(() => new NotificationOutboxActor(
BuildEmptyProvider(),
options,
auditWriter: null!,
NullLogger<NotificationOutboxActor>.Instance));
}
}
@@ -0,0 +1,477 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
/// <summary>
/// Task 14: Tests for the <see cref="NotificationOutboxActor"/> dispatcher loop — the
/// periodic sweep that claims due notifications via
/// <see cref="INotificationOutboxRepository.GetDueAsync"/>, delivers each through the
/// matching <see cref="INotificationDeliveryAdapter"/>, and applies the resulting status
/// transition with <see cref="INotificationOutboxRepository.UpdateAsync"/>.
/// </summary>
public class NotificationOutboxActorDispatchTests : TestKit
{
private readonly INotificationOutboxRepository _outboxRepository =
Substitute.For<INotificationOutboxRepository>();
private readonly INotificationRepository _notificationRepository =
Substitute.For<INotificationRepository>();
private IServiceProvider BuildServiceProvider(
IEnumerable<INotificationDeliveryAdapter> adapters)
{
var services = new ServiceCollection();
services.AddScoped(_ => _outboxRepository);
services.AddScoped(_ => _notificationRepository);
// The actor resolves the channel adapters from its per-sweep DI scope; register
// each stub adapter under the INotificationDeliveryAdapter service.
foreach (var adapter in adapters)
{
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
}
return services.BuildServiceProvider();
}
/// <summary>
/// Stub adapter whose <see cref="DeliverAsync"/> returns a configurable outcome and
/// optionally blocks for a delay — used to exercise the overlapping-tick guard.
/// </summary>
private sealed class StubAdapter : INotificationDeliveryAdapter
{
private readonly Func<DeliveryOutcome> _outcome;
private readonly TimeSpan _delay;
public StubAdapter(Func<DeliveryOutcome> outcome, TimeSpan? delay = null)
{
_outcome = outcome;
_delay = delay ?? TimeSpan.Zero;
}
public int CallCount;
public NotificationType Type => NotificationType.Email;
public async Task<DeliveryOutcome> DeliverAsync(
Notification notification, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref CallCount);
if (_delay > TimeSpan.Zero)
{
await Task.Delay(_delay, cancellationToken);
}
return _outcome();
}
}
private IActorRef CreateActor(
IEnumerable<INotificationDeliveryAdapter> adapters,
NotificationOutboxOptions? options = null)
{
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
BuildServiceProvider(adapters),
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
new NoOpCentralAuditWriter(),
NullLogger<NotificationOutboxActor>.Instance)));
}
private static Notification MakeNotification(
NotificationType type = NotificationType.Email, int retryCount = 0)
{
return new Notification(
Guid.NewGuid().ToString(), type, "ops-team", "Subject", "Body", "site-1")
{
RetryCount = retryCount,
CreatedAt = DateTimeOffset.UtcNow,
};
}
private void SetupSmtpRetryPolicy(int maxRetries, TimeSpan retryDelay)
{
var config = new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
{
MaxRetries = maxRetries,
RetryDelay = retryDelay,
};
_notificationRepository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new[] { config });
}
[Fact]
public void DispatchTick_ClaimsDueNotifications_AndInvokesAdapter()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
_outboxRepository.Received(1).GetDueAsync(
Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
Assert.Equal(1, adapter.CallCount);
});
}
[Fact]
public void Success_MarksNotificationDelivered_WithResolvedTargets()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
_outboxRepository.Received(1).UpdateAsync(
Arg.Is<Notification>(n =>
n.Status == NotificationStatus.Delivered &&
n.DeliveredAt != null &&
n.LastAttemptAt != null &&
n.ResolvedTargets == "ops@example.com" &&
n.LastError == null),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void TransientFailure_BelowMaxRetries_MarksRetrying_AndSchedulesNextAttempt()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(3));
var notification = MakeNotification(retryCount: 1);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
_outboxRepository.Received(1).UpdateAsync(
Arg.Is<Notification>(n =>
n.Status == NotificationStatus.Retrying &&
n.RetryCount == 2 &&
n.NextAttemptAt != null &&
n.LastError == "smtp timeout" &&
n.LastAttemptAt != null),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void TransientFailure_ReachingMaxRetries_MarksParked()
{
SetupSmtpRetryPolicy(maxRetries: 3, retryDelay: TimeSpan.FromMinutes(1));
// RetryCount starts at max-1; the failed attempt increments it to max.
var notification = MakeNotification(retryCount: 2);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
_outboxRepository.Received(1).UpdateAsync(
Arg.Is<Notification>(n =>
n.Status == NotificationStatus.Parked &&
n.RetryCount == 3),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void PermanentFailure_MarksParked_WithLastError()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Permanent("invalid recipient address"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
_outboxRepository.Received(1).UpdateAsync(
Arg.Is<Notification>(n =>
n.Status == NotificationStatus.Parked &&
n.LastError == "invalid recipient address" &&
n.LastAttemptAt != null),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void NoAdapterForType_MarksParked_WithExplanatoryError()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
// No adapters registered: none resolves for the notification's type.
var actor = CreateActor([]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
_outboxRepository.Received(1).UpdateAsync(
Arg.Is<Notification>(n =>
n.Status == NotificationStatus.Parked &&
n.LastError != null &&
n.LastError.Contains("no delivery adapter") &&
n.LastAttemptAt != null),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void FaultedDispatchPass_ClearsInFlightGuard_SoNextTickStillRuns()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
// GetDueAsync throws on every call: the dispatch pass's task could fault if the
// failure were not handled, which would leave _dispatching stuck true forever.
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<Notification>>(_ => throw new InvalidOperationException("db down"));
var actor = CreateActor([]);
// First tick: the pass faults internally but must still clear the in-flight guard.
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() => _outboxRepository.Received(1).GetDueAsync(
Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
// Second tick after the first completes: if the guard had wedged, this would be
// dropped and GetDueAsync would still show only one call.
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() => _outboxRepository.Received(2).GetDueAsync(
Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
}
[Fact]
public void TransientFailure_WithZeroMaxRetries_RetriesUsingFallback_DoesNotParkImmediately()
{
// NO-002: SmtpConfiguration.MaxRetries=0 used to satisfy 1 >= 0 on the very first
// transient failure and park the row without a single retry. ResolveRetryPolicyAsync
// now clamps non-positive MaxRetries to the FallbackMaxRetries (10) so transient
// failures actually retry before parking.
SetupSmtpRetryPolicy(maxRetries: 0, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(retryCount: 0);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
_outboxRepository.Received(1).UpdateAsync(
Arg.Is<Notification>(n =>
n.Status == NotificationStatus.Retrying &&
n.RetryCount == 1 &&
n.NextAttemptAt != null &&
n.LastError == "smtp timeout"),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void TransientFailure_WithNegativeMaxRetries_RetriesUsingFallback_DoesNotParkImmediately()
{
// NO-002: a negative MaxRetries reaches ResolveRetryPolicyAsync just as -1 — same
// park-immediately bug. Clamp to FallbackMaxRetries.
SetupSmtpRetryPolicy(maxRetries: -1, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(retryCount: 0);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
_outboxRepository.Received(1).UpdateAsync(
Arg.Is<Notification>(n =>
n.Status == NotificationStatus.Retrying &&
n.RetryCount == 1 &&
n.NextAttemptAt != null &&
n.LastError == "smtp timeout"),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void TransientFailure_WithNonPositiveRetryDelay_UsesFallbackDelay_NotZero()
{
// NO-002: a non-positive RetryDelay would burn-loop the dispatcher because
// NextAttemptAt would equal now. Clamp to FallbackRetryDelay (1 min) so the
// schedule actually advances.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.Zero);
var before = DateTimeOffset.UtcNow;
var notification = MakeNotification(retryCount: 0);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
_outboxRepository.Received(1).UpdateAsync(
Arg.Is<Notification>(n =>
n.Status == NotificationStatus.Retrying &&
n.NextAttemptAt != null &&
n.NextAttemptAt > before + TimeSpan.FromSeconds(30)),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void PostStop_CancelsInFlightDelivery_LeavesRowNonTerminal()
{
// NO-003: the dispatcher used to drop the CancellationToken on its way into
// the channel adapter, so a coordinated shutdown had to wait the full SMTP
// connect/auth/send timeout per in-flight notification before the sweep
// finished. The actor now passes a lifecycle-scoped token; cancelling it on
// PostStop must abort the in-flight Task.Delay (standing in for an SMTP
// send) and the row must NOT be updated to a terminal state — the next
// active node picks it back up.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
// Long delay simulates a slow SMTP send; the test triggers PostStop before
// the delay would naturally elapse, so the only way the delay completes is
// if the token wired through.
var adapter = new StubAdapter(
() => DeliveryOutcome.Success("ops@example.com"),
delay: TimeSpan.FromSeconds(30));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
// Wait until the adapter is actually in flight before stopping.
AwaitAssert(() => Assert.Equal(1, adapter.CallCount));
var start = DateTimeOffset.UtcNow;
Sys.Stop(actor);
// The sweep should observe cancellation promptly (well under the 30s delay).
AwaitAssert(
() =>
{
// No UpdateAsync was issued — the row is untouched and will be re-claimed
// by the next active node.
_outboxRepository.DidNotReceive().UpdateAsync(
Arg.Any<Notification>(), Arg.Any<CancellationToken>());
},
duration: TimeSpan.FromSeconds(5));
Assert.True(DateTimeOffset.UtcNow - start < TimeSpan.FromSeconds(5),
"PostStop did not cancel the in-flight delivery promptly.");
}
// ── NotificationOutbox-006: adapter dictionary cached for the actor's lifetime ──
[Fact]
public void Dispatch_ResolvesAdaptersOnce_AcrossMultipleSweeps()
{
// NotificationOutbox-006: adapter registration is static per process lifetime,
// so the NotificationType -> adapter lookup must be built ONCE for the actor's
// lifetime, not per dispatch sweep. The cache is paired with an actor-lifetime
// DI scope (see _adaptersScope) so scoped adapter instances are reused safely.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
// Isolated substitutes for this test — we replace the dispatcher's per-sweep
// INotificationOutboxRepository registration with a private counting factory,
// so we don't mutate the shared _outboxRepository field that other tests in
// this class configure differently.
var outboxRepository = Substitute.For<INotificationOutboxRepository>();
outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(_ => new[] { MakeNotification() });
// Counting factory: increments each time the DI container resolves an
// INotificationDeliveryAdapter. Pre-fix this would have ticked once per
// sweep; post-fix it ticks exactly once for the actor's lifetime.
var resolutionCount = 0;
var services = new ServiceCollection();
services.AddScoped(_ => outboxRepository);
services.AddScoped(_ => _notificationRepository);
services.AddScoped<INotificationDeliveryAdapter>(_ =>
{
Interlocked.Increment(ref resolutionCount);
return new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
});
var sp = services.BuildServiceProvider();
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
sp,
new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
new NoOpCentralAuditWriter(),
NullLogger<NotificationOutboxActor>.Instance)));
// Fire three sweeps end-to-end. Each waits on the previous via the
// in-flight guard, so the UpdateAsync count climbs monotonically.
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() => outboxRepository.Received(1).UpdateAsync(
Arg.Any<Notification>(), Arg.Any<CancellationToken>()));
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() => outboxRepository.Received(2).UpdateAsync(
Arg.Any<Notification>(), Arg.Any<CancellationToken>()));
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() => outboxRepository.Received(3).UpdateAsync(
Arg.Any<Notification>(), Arg.Any<CancellationToken>()));
// The adapter resolution must have happened EXACTLY ONCE despite three
// dispatch sweeps. Pre-fix this would have been 3 (or more).
Assert.Equal(1, resolutionCount);
}
[Fact]
public void OverlappingTicks_WhileDispatchInFlight_DoNotClaimConcurrently()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
// Slow adapter keeps the first sweep in flight while the second tick arrives.
var adapter = new StubAdapter(
() => DeliveryOutcome.Success("ops@example.com"),
delay: TimeSpan.FromMilliseconds(800));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
actor.Tell(InternalMessages.DispatchTick.Instance);
// Second tick is dropped by the in-flight guard: only one sweep runs.
AwaitAssert(
() => _outboxRepository.Received(1).GetDueAsync(
Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>()),
duration: TimeSpan.FromSeconds(2));
}
}
@@ -0,0 +1,236 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
/// <summary>
/// Task 13: Tests for the <see cref="NotificationOutboxActor"/> ingest path — building a
/// <see cref="Notification"/> from a <see cref="NotificationSubmit"/>, persisting it via
/// <see cref="INotificationOutboxRepository.InsertIfNotExistsAsync"/>, and acking the sender.
/// </summary>
public class NotificationOutboxActorIngestTests : TestKit
{
private readonly INotificationOutboxRepository _repository =
Substitute.For<INotificationOutboxRepository>();
private IServiceProvider BuildServiceProvider()
{
var services = new ServiceCollection();
services.AddScoped(_ => _repository);
return services.BuildServiceProvider();
}
private IActorRef CreateActor()
{
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
BuildServiceProvider(),
new NotificationOutboxOptions(),
new NoOpCentralAuditWriter(),
NullLogger<NotificationOutboxActor>.Instance)));
}
private static NotificationSubmit MakeSubmit(
string? notificationId = null,
Guid? originExecutionId = null,
Guid? originParentExecutionId = null,
string? sourceNode = null)
{
return new NotificationSubmit(
NotificationId: notificationId ?? Guid.NewGuid().ToString(),
ListName: "ops-team",
Subject: "Tank overflow",
Body: "Tank 3 level critical",
SourceSiteId: "site-1",
SourceInstanceId: "instance-42",
SourceScript: "AlarmScript",
SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero),
OriginExecutionId: originExecutionId,
OriginParentExecutionId: originParentExecutionId,
SourceNode: sourceNode);
}
[Fact]
public void NotificationSubmit_PersistsMappedNotification_AndAcksAccepted()
{
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.Returns(true);
var submit = MakeSubmit();
var actor = CreateActor();
actor.Tell(submit, TestActor);
var ack = ExpectMsg<NotificationSubmitAck>();
Assert.Equal(submit.NotificationId, ack.NotificationId);
Assert.True(ack.Accepted);
Assert.Null(ack.Error);
_repository.Received(1).InsertIfNotExistsAsync(
Arg.Is<Notification>(n =>
n.NotificationId == submit.NotificationId &&
n.Type == NotificationType.Email &&
n.ListName == submit.ListName &&
n.Subject == submit.Subject &&
n.Body == submit.Body &&
n.SourceSiteId == submit.SourceSiteId &&
n.SourceInstanceId == submit.SourceInstanceId &&
n.SourceScript == submit.SourceScript &&
n.SiteEnqueuedAt == submit.SiteEnqueuedAt &&
n.Status == NotificationStatus.Pending &&
n.CreatedAt != default),
Arg.Any<CancellationToken>());
}
[Fact]
public void NotificationSubmit_CopiesOriginExecutionId_OntoPersistedNotification()
{
// Audit Log #23: the originating script execution's id rides on the
// NotificationSubmit and must be persisted on the Notification row so
// the dispatcher can later echo it onto NotifyDeliver audit rows.
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.Returns(true);
var executionId = Guid.NewGuid();
var submit = MakeSubmit(originExecutionId: executionId);
var actor = CreateActor();
actor.Tell(submit, TestActor);
ExpectMsg<NotificationSubmitAck>();
_repository.Received(1).InsertIfNotExistsAsync(
Arg.Is<Notification>(n => n.OriginExecutionId == executionId),
Arg.Any<CancellationToken>());
}
[Fact]
public void NotificationSubmit_NullOriginExecutionId_PersistsNull()
{
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.Returns(true);
var submit = MakeSubmit(originExecutionId: null);
var actor = CreateActor();
actor.Tell(submit, TestActor);
ExpectMsg<NotificationSubmitAck>();
_repository.Received(1).InsertIfNotExistsAsync(
Arg.Is<Notification>(n => n.OriginExecutionId == null),
Arg.Any<CancellationToken>());
}
[Fact]
public void NotificationSubmit_CopiesOriginParentExecutionId_OntoPersistedNotification()
{
// Audit Log ParentExecutionId: the routed run's parent ExecutionId rides
// on the NotificationSubmit and must be persisted on the Notification row
// so the dispatcher can later echo it onto NotifyDeliver audit rows.
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.Returns(true);
var parentExecutionId = Guid.NewGuid();
var submit = MakeSubmit(originParentExecutionId: parentExecutionId);
var actor = CreateActor();
actor.Tell(submit, TestActor);
ExpectMsg<NotificationSubmitAck>();
_repository.Received(1).InsertIfNotExistsAsync(
Arg.Is<Notification>(n => n.OriginParentExecutionId == parentExecutionId),
Arg.Any<CancellationToken>());
}
[Fact]
public void NotificationSubmit_NullOriginParentExecutionId_PersistsNull()
{
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.Returns(true);
var submit = MakeSubmit(originParentExecutionId: null);
var actor = CreateActor();
actor.Tell(submit, TestActor);
ExpectMsg<NotificationSubmitAck>();
_repository.Received(1).InsertIfNotExistsAsync(
Arg.Is<Notification>(n => n.OriginParentExecutionId == null),
Arg.Any<CancellationToken>());
}
[Fact]
public void DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted()
{
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.Returns(false);
var submit = MakeSubmit();
var actor = CreateActor();
actor.Tell(submit, TestActor);
var ack = ExpectMsg<NotificationSubmitAck>();
Assert.Equal(submit.NotificationId, ack.NotificationId);
Assert.True(ack.Accepted);
Assert.Null(ack.Error);
}
[Fact]
public void RepositoryThrows_AcksNotAcceptedWithError()
{
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("database unavailable"));
var submit = MakeSubmit();
var actor = CreateActor();
actor.Tell(submit, TestActor);
var ack = ExpectMsg<NotificationSubmitAck>();
Assert.Equal(submit.NotificationId, ack.NotificationId);
Assert.False(ack.Accepted);
Assert.NotNull(ack.Error);
Assert.Contains("database unavailable", ack.Error);
}
[Fact]
public void NotificationSubmit_CopiesSourceNode_OntoPersistedNotification()
{
// SourceNode-stamping (Task 13): the originating site's node name (node-a/node-b)
// rides on the NotificationSubmit and must be persisted on the Notification row so
// central observers (KPIs, audit drill-ins, ops dashboards) can see which node
// emitted the notification.
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.Returns(true);
var submit = MakeSubmit(sourceNode: "node-a");
var actor = CreateActor();
actor.Tell(submit, TestActor);
ExpectMsg<NotificationSubmitAck>();
_repository.Received(1).InsertIfNotExistsAsync(
Arg.Is<Notification>(n => n.SourceNode == "node-a"),
Arg.Any<CancellationToken>());
}
[Fact]
public void NotificationSubmit_NullSourceNode_PersistsNull()
{
// Submissions from a host that didn't wire INodeIdentityProvider, or from
// pre-SourceNode-stamping clients, carry null SourceNode — the central row must
// persist NULL rather than fall back to a placeholder.
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.Returns(true);
var submit = MakeSubmit(sourceNode: null);
var actor = CreateActor();
actor.Tell(submit, TestActor);
ExpectMsg<NotificationSubmitAck>();
_repository.Received(1).InsertIfNotExistsAsync(
Arg.Is<Notification>(n => n.SourceNode == null),
Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,108 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
/// <summary>
/// Task 16: Tests for the <see cref="NotificationOutboxActor"/> daily purge job — the
/// periodic sweep that bulk-deletes terminal notification rows older than
/// <see cref="NotificationOutboxOptions.TerminalRetention"/> via
/// <see cref="INotificationOutboxRepository.DeleteTerminalOlderThanAsync"/>.
/// </summary>
public class NotificationOutboxActorPurgeTests : TestKit
{
private readonly INotificationOutboxRepository _outboxRepository =
Substitute.For<INotificationOutboxRepository>();
private readonly INotificationRepository _notificationRepository =
Substitute.For<INotificationRepository>();
private IServiceProvider BuildServiceProvider()
{
var services = new ServiceCollection();
services.AddScoped(_ => _outboxRepository);
services.AddScoped(_ => _notificationRepository);
return services.BuildServiceProvider();
}
/// <summary>
/// Creates the actor with both the dispatch and purge timers set to a long interval so
/// neither periodic timer fires during a test — purge ticks are sent manually instead.
/// </summary>
private IActorRef CreateActor(NotificationOutboxOptions? options = null)
{
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
BuildServiceProvider(),
options ?? new NotificationOutboxOptions
{
DispatchInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromHours(1),
},
new NoOpCentralAuditWriter(),
NullLogger<NotificationOutboxActor>.Instance)));
}
[Fact]
public void PurgeTick_DeletesTerminalRows_WithCutoffAtUtcNowMinusTerminalRetention()
{
var retention = TimeSpan.FromDays(30);
var options = new NotificationOutboxOptions
{
DispatchInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromHours(1),
TerminalRetention = retention,
};
_outboxRepository
.DeleteTerminalOlderThanAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(3);
var actor = CreateActor(options);
var expectedCutoff = DateTimeOffset.UtcNow - retention;
actor.Tell(InternalMessages.PurgeTick.Instance);
AwaitAssert(() =>
_outboxRepository.Received(1).DeleteTerminalOlderThanAsync(
Arg.Is<DateTimeOffset>(cutoff =>
(cutoff - expectedCutoff).Duration() < TimeSpan.FromMinutes(1)),
Arg.Any<CancellationToken>()));
}
[Fact]
public void FaultedPurge_DoesNotCrashActor_AndSubsequentPurgeStillRuns()
{
var calls = 0;
_outboxRepository
.DeleteTerminalOlderThanAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(_ =>
{
calls++;
// First purge faults; the second returns normally to prove the actor lives on.
if (calls == 1)
{
throw new InvalidOperationException("db down");
}
return Task.FromResult(0);
});
var actor = CreateActor();
// First tick: the purge faults internally but must be handled and not kill the actor.
actor.Tell(InternalMessages.PurgeTick.Instance);
AwaitAssert(() => _outboxRepository.Received(1).DeleteTerminalOlderThanAsync(
Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>()));
// Second tick: if the faulted purge had crashed the actor, this would never run.
actor.Tell(InternalMessages.PurgeTick.Instance);
AwaitAssert(() => _outboxRepository.Received(2).DeleteTerminalOlderThanAsync(
Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>()));
}
}
@@ -0,0 +1,498 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
/// <summary>
/// Task 15: Tests for the <see cref="NotificationOutboxActor"/> query surface — the
/// paginated outbox query, single-notification status query, manual retry and discard
/// of parked notifications, and the KPI snapshot.
/// </summary>
public class NotificationOutboxActorQueryTests : TestKit
{
private readonly INotificationOutboxRepository _repository =
Substitute.For<INotificationOutboxRepository>();
private IServiceProvider BuildServiceProvider()
{
var services = new ServiceCollection();
services.AddScoped(_ => _repository);
return services.BuildServiceProvider();
}
private IActorRef CreateActor(NotificationOutboxOptions? options = null)
{
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
BuildServiceProvider(),
// A long dispatch interval keeps the dispatch loop from interfering with these tests.
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
new NoOpCentralAuditWriter(),
NullLogger<NotificationOutboxActor>.Instance)));
}
private static Notification MakeNotification(
NotificationStatus status = NotificationStatus.Pending,
DateTimeOffset? createdAt = null,
int retryCount = 0,
string? lastError = null,
DateTimeOffset? deliveredAt = null)
{
return new Notification(
Guid.NewGuid().ToString(), NotificationType.Email, "ops-team", "Subject", "Body", "site-1")
{
Status = status,
CreatedAt = createdAt ?? DateTimeOffset.UtcNow,
RetryCount = retryCount,
LastError = lastError,
DeliveredAt = deliveredAt,
SourceInstanceId = "instance-42",
};
}
[Fact]
public void Query_PassesFilterFromRequest_AndMapsRowsToSummaries()
{
var now = DateTimeOffset.UtcNow;
var staleRow = MakeNotification(
status: NotificationStatus.Pending, createdAt: now - TimeSpan.FromHours(1));
var freshRow = MakeNotification(
status: NotificationStatus.Pending, createdAt: now);
_repository.QueryAsync(
Arg.Any<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(((IReadOnlyList<Notification>)new[] { staleRow, freshRow }, 2));
var actor = CreateActor();
actor.Tell(
new NotificationOutboxQueryRequest(
CorrelationId: "corr-1",
StatusFilter: null,
TypeFilter: null,
SourceSiteFilter: "site-1",
ListNameFilter: "ops-team",
StuckOnly: false,
SubjectKeyword: "tank",
From: null,
To: null,
PageNumber: 2,
PageSize: 25),
TestActor);
var response = ExpectMsg<NotificationOutboxQueryResponse>();
Assert.Equal("corr-1", response.CorrelationId);
Assert.True(response.Success);
Assert.Null(response.ErrorMessage);
Assert.Equal(2, response.TotalCount);
Assert.Equal(2, response.Notifications.Count);
_repository.Received(1).QueryAsync(
Arg.Is<NotificationOutboxFilter>(f =>
f.SourceSiteId == "site-1" &&
f.ListName == "ops-team" &&
f.SubjectKeyword == "tank" &&
f.Status == null &&
f.Type == null &&
f.StuckOnly == false),
2, 25, Arg.Any<CancellationToken>());
// IsStuck: the hour-old Pending row is stuck, the just-created one is not.
var staleSummary = response.Notifications.Single(s => s.NotificationId == staleRow.NotificationId);
var freshSummary = response.Notifications.Single(s => s.NotificationId == freshRow.NotificationId);
Assert.True(staleSummary.IsStuck);
Assert.False(freshSummary.IsStuck);
Assert.Equal("Pending", staleSummary.Status);
Assert.Equal("Email", staleSummary.Type);
Assert.Equal("site-1", staleSummary.SourceSiteId);
}
[Fact]
public void Query_WithStatusFilterString_ParsesToEnumOnFilter()
{
_repository.QueryAsync(
Arg.Any<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(((IReadOnlyList<Notification>)Array.Empty<Notification>(), 0));
var actor = CreateActor();
actor.Tell(
new NotificationOutboxQueryRequest(
CorrelationId: "corr-2",
StatusFilter: "Parked",
TypeFilter: "Email",
SourceSiteFilter: null,
ListNameFilter: null,
StuckOnly: true,
SubjectKeyword: null,
From: null,
To: null,
PageNumber: 1,
PageSize: 50),
TestActor);
ExpectMsg<NotificationOutboxQueryResponse>();
_repository.Received(1).QueryAsync(
Arg.Is<NotificationOutboxFilter>(f =>
f.Status == NotificationStatus.Parked &&
f.Type == NotificationType.Email &&
f.StuckOnly == true &&
f.StuckCutoff != null),
1, 50, Arg.Any<CancellationToken>());
}
[Fact]
public void Query_PassesSourceNodeFilter_AndProjectsSourceNodeOntoSummary()
{
// Task 16: the Notifications page's new Node filter input pushes a
// value into NotificationOutboxQueryRequest.SourceNodeFilter; the actor
// must thread it onto NotificationOutboxFilter.SourceNode AND mirror
// the row's SourceNode column onto the response summaries.
var row = MakeNotification(status: NotificationStatus.Pending);
row.SourceNode = "central-a";
_repository.QueryAsync(
Arg.Any<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(((IReadOnlyList<Notification>)new[] { row }, 1));
var actor = CreateActor();
actor.Tell(
new NotificationOutboxQueryRequest(
CorrelationId: "corr-node",
StatusFilter: null,
TypeFilter: null,
SourceSiteFilter: null,
ListNameFilter: null,
StuckOnly: false,
SubjectKeyword: null,
From: null,
To: null,
PageNumber: 1,
PageSize: 50,
SourceNodeFilter: "central-a"),
TestActor);
var response = ExpectMsg<NotificationOutboxQueryResponse>();
Assert.True(response.Success);
Assert.Equal("central-a", response.Notifications.Single().SourceNode);
_repository.Received(1).QueryAsync(
Arg.Is<NotificationOutboxFilter>(f => f.SourceNode == "central-a"),
1, 50, Arg.Any<CancellationToken>());
}
[Fact]
public void Query_RepositoryThrows_RepliesFailureWithEmptyList()
{
_repository.QueryAsync(
Arg.Any<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("db down"));
var actor = CreateActor();
actor.Tell(
new NotificationOutboxQueryRequest(
"corr-err", null, null, null, null, false, null, null, null, 1, 50),
TestActor);
var response = ExpectMsg<NotificationOutboxQueryResponse>();
Assert.Equal("corr-err", response.CorrelationId);
Assert.False(response.Success);
Assert.NotNull(response.ErrorMessage);
Assert.Contains("db down", response.ErrorMessage);
Assert.Empty(response.Notifications);
}
[Fact]
public void StatusQuery_Found_RepliesWithRowDetail()
{
var row = MakeNotification(
status: NotificationStatus.Retrying, retryCount: 3, lastError: "smtp timeout");
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
var actor = CreateActor();
actor.Tell(new NotificationStatusQuery("corr-3", row.NotificationId), TestActor);
var response = ExpectMsg<NotificationStatusResponse>();
Assert.Equal("corr-3", response.CorrelationId);
Assert.True(response.Found);
Assert.Equal("Retrying", response.Status);
Assert.Equal(3, response.RetryCount);
Assert.Equal("smtp timeout", response.LastError);
}
[Fact]
public void StatusQuery_NotFound_RepliesFoundFalse()
{
_repository.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((Notification?)null);
var actor = CreateActor();
actor.Tell(new NotificationStatusQuery("corr-4", "missing-id"), TestActor);
var response = ExpectMsg<NotificationStatusResponse>();
Assert.Equal("corr-4", response.CorrelationId);
Assert.False(response.Found);
Assert.Equal(string.Empty, response.Status);
Assert.Equal(0, response.RetryCount);
}
[Fact]
public void Retry_ParkedNotification_ResetsToPending_AndSucceeds()
{
var row = MakeNotification(status: NotificationStatus.Parked, retryCount: 10, lastError: "gave up");
row.NextAttemptAt = DateTimeOffset.UtcNow;
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
var actor = CreateActor();
actor.Tell(new RetryNotificationRequest("corr-5", row.NotificationId), TestActor);
var response = ExpectMsg<RetryNotificationResponse>();
Assert.Equal("corr-5", response.CorrelationId);
Assert.True(response.Success);
Assert.Null(response.ErrorMessage);
_repository.Received(1).UpdateAsync(
Arg.Is<Notification>(n =>
n.NotificationId == row.NotificationId &&
n.Status == NotificationStatus.Pending &&
n.RetryCount == 0 &&
n.NextAttemptAt == null &&
n.LastError == null),
Arg.Any<CancellationToken>());
}
[Fact]
public void Retry_NonParkedNotification_Fails()
{
var row = MakeNotification(status: NotificationStatus.Delivered);
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
var actor = CreateActor();
actor.Tell(new RetryNotificationRequest("corr-6", row.NotificationId), TestActor);
var response = ExpectMsg<RetryNotificationResponse>();
Assert.False(response.Success);
Assert.NotNull(response.ErrorMessage);
_repository.DidNotReceive().UpdateAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
}
[Fact]
public void Retry_MissingNotification_Fails()
{
_repository.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((Notification?)null);
var actor = CreateActor();
actor.Tell(new RetryNotificationRequest("corr-7", "missing-id"), TestActor);
var response = ExpectMsg<RetryNotificationResponse>();
Assert.False(response.Success);
Assert.NotNull(response.ErrorMessage);
Assert.Contains("not found", response.ErrorMessage);
}
[Fact]
public void Discard_ParkedNotification_MarksDiscarded_AndSucceeds()
{
var row = MakeNotification(status: NotificationStatus.Parked);
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
var actor = CreateActor();
actor.Tell(new DiscardNotificationRequest("corr-8", row.NotificationId), TestActor);
var response = ExpectMsg<DiscardNotificationResponse>();
Assert.Equal("corr-8", response.CorrelationId);
Assert.True(response.Success);
_repository.Received(1).UpdateAsync(
Arg.Is<Notification>(n => n.Status == NotificationStatus.Discarded),
Arg.Any<CancellationToken>());
}
[Fact]
public void Discard_NonParkedNotification_Fails()
{
var row = MakeNotification(status: NotificationStatus.Pending);
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
var actor = CreateActor();
actor.Tell(new DiscardNotificationRequest("corr-9", row.NotificationId), TestActor);
var response = ExpectMsg<DiscardNotificationResponse>();
Assert.False(response.Success);
Assert.NotNull(response.ErrorMessage);
_repository.DidNotReceive().UpdateAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
}
[Fact]
public void Discard_MissingNotification_Fails()
{
_repository.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((Notification?)null);
var actor = CreateActor();
actor.Tell(new DiscardNotificationRequest("corr-10", "missing-id"), TestActor);
var response = ExpectMsg<DiscardNotificationResponse>();
Assert.False(response.Success);
Assert.Contains("not found", response.ErrorMessage);
}
[Fact]
public void DetailRequest_KnownId_ReturnsFullDetail_WithBodyAndResolvedTargets()
{
var row = MakeNotification(
status: NotificationStatus.Delivered, retryCount: 2, lastError: "transient blip");
row.Body = "Tank-7 has exceeded its high-level setpoint.";
row.ResolvedTargets = "[\"ops@example.com\",\"oncall@example.com\"]";
row.TypeData = "{\"priority\":\"high\"}";
row.SourceScript = "HighLevelAlarm.csx";
row.SourceNode = "node-a";
row.SiteEnqueuedAt = DateTimeOffset.UtcNow.AddMinutes(-5);
row.DeliveredAt = DateTimeOffset.UtcNow;
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
var actor = CreateActor();
actor.Tell(new NotificationDetailRequest("corr-d1", row.NotificationId), TestActor);
var response = ExpectMsg<NotificationDetailResponse>();
Assert.Equal("corr-d1", response.CorrelationId);
Assert.True(response.Success);
Assert.Null(response.ErrorMessage);
Assert.NotNull(response.Detail);
var detail = response.Detail!;
Assert.Equal(row.NotificationId, detail.NotificationId);
Assert.Equal("Email", detail.Type);
Assert.Equal("Delivered", detail.Status);
Assert.Equal("Tank-7 has exceeded its high-level setpoint.", detail.Body);
Assert.Equal("[\"ops@example.com\",\"oncall@example.com\"]", detail.ResolvedTargets);
Assert.Equal("{\"priority\":\"high\"}", detail.TypeData);
Assert.Equal("HighLevelAlarm.csx", detail.SourceScript);
Assert.Equal("instance-42", detail.SourceInstanceId);
Assert.Equal(2, detail.RetryCount);
Assert.Equal("transient blip", detail.LastError);
// SourceNode flows through the detail projection so the report detail
// modal binds uniformly to the detail record (was previously read off
// the summary).
Assert.Equal("node-a", detail.SourceNode);
}
[Fact]
public void DetailRequest_UnknownId_ReturnsNotFound()
{
_repository.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((Notification?)null);
var actor = CreateActor();
actor.Tell(new NotificationDetailRequest("corr-d2", "missing-id"), TestActor);
var response = ExpectMsg<NotificationDetailResponse>();
Assert.Equal("corr-d2", response.CorrelationId);
Assert.False(response.Success);
Assert.Null(response.Detail);
Assert.NotNull(response.ErrorMessage);
Assert.Contains("not found", response.ErrorMessage);
}
[Fact]
public void KpiRequest_ComputesKpis_AndMapsSnapshot()
{
var snapshot = new NotificationKpiSnapshot(
QueueDepth: 7,
StuckCount: 2,
ParkedCount: 3,
DeliveredLastInterval: 12,
OldestPendingAge: TimeSpan.FromMinutes(4));
_repository.ComputeKpisAsync(
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(snapshot);
var actor = CreateActor();
actor.Tell(new NotificationKpiRequest("corr-11"), TestActor);
var response = ExpectMsg<NotificationKpiResponse>();
Assert.Equal("corr-11", response.CorrelationId);
Assert.True(response.Success);
Assert.Null(response.ErrorMessage);
Assert.Equal(7, response.QueueDepth);
Assert.Equal(2, response.StuckCount);
Assert.Equal(3, response.ParkedCount);
Assert.Equal(12, response.DeliveredLastInterval);
Assert.Equal(TimeSpan.FromMinutes(4), response.OldestPendingAge);
_repository.Received(1).ComputeKpisAsync(
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
}
[Fact]
public void KpiRequest_RepositoryThrows_RepliesFailureResponse()
{
_repository.ComputeKpisAsync(
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("kpi db down"));
var actor = CreateActor();
actor.Tell(new NotificationKpiRequest("corr-12"), TestActor);
// A repository fault yields a failure NotificationKpiResponse, not a Status.Failure.
var response = ExpectMsg<NotificationKpiResponse>();
Assert.Equal("corr-12", response.CorrelationId);
Assert.False(response.Success);
Assert.NotNull(response.ErrorMessage);
Assert.Contains("kpi db down", response.ErrorMessage);
Assert.Equal(0, response.QueueDepth);
Assert.Equal(0, response.StuckCount);
Assert.Equal(0, response.ParkedCount);
Assert.Equal(0, response.DeliveredLastInterval);
Assert.Null(response.OldestPendingAge);
}
[Fact]
public void PerSiteKpiRequest_RepliesWithPerSiteSnapshots()
{
_repository.ComputePerSiteKpisAsync(
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new List<SiteNotificationKpiSnapshot>
{
new("plant-a", 4, 1, 0, 9, TimeSpan.FromMinutes(7)),
});
var actor = CreateActor();
actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor);
var response = ExpectMsg<PerSiteNotificationKpiResponse>();
Assert.True(response.Success);
Assert.Null(response.ErrorMessage);
Assert.Equal("corr-ps", response.CorrelationId);
Assert.Single(response.Sites);
Assert.Equal("plant-a", response.Sites[0].SourceSiteId);
_repository.Received(1).ComputePerSiteKpisAsync(
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
}
[Fact]
public void PerSiteKpiRequest_RepositoryFault_RepliesUnsuccessful()
{
_repository.ComputePerSiteKpisAsync(
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("db down"));
var actor = CreateActor();
actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor);
var response = ExpectMsg<PerSiteNotificationKpiResponse>();
Assert.False(response.Success);
Assert.Equal("corr-ps", response.CorrelationId);
Assert.NotNull(response.ErrorMessage);
Assert.Contains("db down", response.ErrorMessage);
Assert.Empty(response.Sites);
}
}
@@ -0,0 +1,400 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
/// <summary>
/// M4 Bundle B (B3) — verifies the <see cref="NotificationOutboxActor"/>
/// emits a second
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
/// audit row carrying the terminal status (Delivered, Parked, Discarded) on
/// every terminal-state transition. The B2 Attempted row is still emitted
/// alongside the terminal one — these tests assert ONLY the terminal row
/// presence and status.
/// </summary>
public class NotificationOutboxActorTerminalEmissionTests : TestKit
{
private readonly INotificationOutboxRepository _outboxRepository =
Substitute.For<INotificationOutboxRepository>();
private readonly INotificationRepository _notificationRepository =
Substitute.For<INotificationRepository>();
private readonly RecordingCentralAuditWriter _auditWriter = new();
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
{
public List<AuditEvent> Events { get; } = new();
public Func<AuditEvent, Task>? OnWrite { get; set; }
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (Events)
{
Events.Add(evt);
}
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
}
}
private IServiceProvider BuildServiceProvider(IEnumerable<INotificationDeliveryAdapter> adapters)
{
var services = new ServiceCollection();
services.AddScoped(_ => _outboxRepository);
services.AddScoped(_ => _notificationRepository);
foreach (var adapter in adapters)
{
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
}
return services.BuildServiceProvider();
}
private sealed class StubAdapter : INotificationDeliveryAdapter
{
private readonly Func<DeliveryOutcome> _outcome;
public StubAdapter(Func<DeliveryOutcome> outcome) { _outcome = outcome; }
public NotificationType Type => NotificationType.Email;
public Task<DeliveryOutcome> DeliverAsync(
Notification notification, CancellationToken cancellationToken = default)
=> Task.FromResult(_outcome());
}
private IActorRef CreateActor(IEnumerable<INotificationDeliveryAdapter> adapters)
{
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
BuildServiceProvider(adapters),
new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
(ICentralAuditWriter)_auditWriter,
NullLogger<NotificationOutboxActor>.Instance)));
}
private static Notification MakeNotification(
NotificationStatus status = NotificationStatus.Pending,
int retryCount = 0,
Guid? notificationId = null,
Guid? originExecutionId = null,
Guid? originParentExecutionId = null)
{
return new Notification(
(notificationId ?? Guid.NewGuid()).ToString("D"),
NotificationType.Email,
"ops-team",
"Tank overflow",
"Tank 3 level critical",
"site-1")
{
Status = status,
RetryCount = retryCount,
CreatedAt = DateTimeOffset.UtcNow,
OriginExecutionId = originExecutionId,
OriginParentExecutionId = originParentExecutionId,
};
}
private void SetupSmtpRetryPolicy(int maxRetries, TimeSpan retryDelay)
{
var config = new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
{
MaxRetries = maxRetries,
RetryDelay = retryDelay,
};
_notificationRepository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new[] { config });
}
private List<AuditEvent> EventsByStatus(AuditStatus status)
{
lock (_auditWriter.Events)
{
return _auditWriter.Events.Where(e => e.Status == status).ToList();
}
}
[Fact]
public void Terminal_Delivered_EmitsEvent_StatusDelivered()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var delivered = EventsByStatus(AuditStatus.Delivered);
Assert.Single(delivered);
var evt = delivered[0];
Assert.Equal(AuditChannel.Notification, evt.Channel);
Assert.Equal(AuditKind.NotifyDeliver, evt.Kind);
Assert.Equal("ops-team", evt.Target);
});
}
[Fact]
public void Terminal_Delivered_CarriesOriginExecutionId_AsExecutionId()
{
// Audit Log #23: the terminal NotifyDeliver row must echo the
// notification's OriginExecutionId so it shares the per-run id with
// the site-emitted NotifySend row.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var executionId = Guid.NewGuid();
var notification = MakeNotification(originExecutionId: executionId);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var delivered = EventsByStatus(AuditStatus.Delivered);
Assert.Single(delivered);
Assert.Equal(executionId, delivered[0].ExecutionId);
});
}
[Fact]
public void Terminal_Delivered_NullOriginExecutionId_HasNullExecutionId()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(originExecutionId: null);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var delivered = EventsByStatus(AuditStatus.Delivered);
Assert.Single(delivered);
Assert.Null(delivered[0].ExecutionId);
});
}
[Fact]
public void Terminal_Delivered_CarriesOriginParentExecutionId_AsParentExecutionId()
{
// Audit Log ParentExecutionId: the terminal NotifyDeliver row must echo
// the notification's OriginParentExecutionId so the central dispatcher's
// rows carry the routed run's parent id.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var parentExecutionId = Guid.NewGuid();
var notification = MakeNotification(originParentExecutionId: parentExecutionId);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var delivered = EventsByStatus(AuditStatus.Delivered);
Assert.Single(delivered);
Assert.Equal(parentExecutionId, delivered[0].ParentExecutionId);
});
}
[Fact]
public void Terminal_Delivered_NullOriginParentExecutionId_HasNullParentExecutionId()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(originParentExecutionId: null);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var delivered = EventsByStatus(AuditStatus.Delivered);
Assert.Single(delivered);
Assert.Null(delivered[0].ParentExecutionId);
});
}
[Fact]
public void Terminal_Parked_OnPermanentFailure_EmitsEvent_StatusParked()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Permanent("invalid recipient address"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var parked = EventsByStatus(AuditStatus.Parked);
Assert.Single(parked);
Assert.Equal(AuditKind.NotifyDeliver, parked[0].Kind);
Assert.Equal("invalid recipient address", parked[0].ErrorMessage);
});
}
[Fact]
public void Terminal_Parked_CarriesOriginExecutionId_AsExecutionId()
{
// Audit Log #23: the Parked terminal NotifyDeliver row flows through the
// same BuildNotifyDeliverEvent path as the Delivered row, so it must
// likewise echo the notification's OriginExecutionId — sharing the
// per-run id with the site-emitted NotifySend row.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var executionId = Guid.NewGuid();
var notification = MakeNotification(originExecutionId: executionId);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Permanent("invalid recipient address"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var parked = EventsByStatus(AuditStatus.Parked);
Assert.Single(parked);
Assert.Equal(executionId, parked[0].ExecutionId);
});
}
[Fact]
public void Terminal_Parked_OnTransientReachingMaxRetries_EmitsEvent_StatusParked()
{
SetupSmtpRetryPolicy(maxRetries: 3, retryDelay: TimeSpan.FromMinutes(1));
// RetryCount starts at max-1; the failed attempt increments it to max
// which triggers the Parked terminal transition.
var notification = MakeNotification(retryCount: 2);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var parked = EventsByStatus(AuditStatus.Parked);
Assert.Single(parked);
Assert.Equal(AuditKind.NotifyDeliver, parked[0].Kind);
});
}
[Fact]
public void Terminal_Parked_OnMissingAdapter_EmitsEvent_StatusParked()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
// No adapters registered: the missing-adapter park path runs.
var actor = CreateActor([]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var parked = EventsByStatus(AuditStatus.Parked);
Assert.Single(parked);
Assert.Equal(AuditKind.NotifyDeliver, parked[0].Kind);
Assert.Contains("no delivery adapter", parked[0].ErrorMessage!);
});
}
[Fact]
public void Transient_BelowMaxRetries_DoesNotEmitTerminalRow()
{
// A transient failure that does not reach max-retries leaves the row
// in Retrying — non-terminal, so no terminal audit row should be
// emitted (only the Attempted row from B2).
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(retryCount: 0);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
// Wait for the Attempted row to land so we know dispatch has run.
AwaitAssert(() => Assert.Single(EventsByStatus(AuditStatus.Attempted)));
// No terminal rows of any kind.
Assert.Empty(EventsByStatus(AuditStatus.Delivered));
Assert.Empty(EventsByStatus(AuditStatus.Parked));
Assert.Empty(EventsByStatus(AuditStatus.Discarded));
}
[Fact]
public void Terminal_Discarded_OnManualDiscard_EmitsEvent_StatusDiscarded()
{
// Wire the actor with a parked row that GetByIdAsync returns; the
// discard handler must emit a terminal Discarded audit row.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(status: NotificationStatus.Parked);
_outboxRepository.GetByIdAsync(notification.NotificationId, Arg.Any<CancellationToken>())
.Returns(notification);
var actor = CreateActor([]);
actor.Tell(new DiscardNotificationRequest(
CorrelationId: "test-corr", NotificationId: notification.NotificationId));
// First wait for the discard handler to reply (handshake), then assert
// the audit row landed.
ExpectMsg<DiscardNotificationResponse>(r => r.Success);
AwaitAssert(() =>
{
var discarded = EventsByStatus(AuditStatus.Discarded);
Assert.Single(discarded);
Assert.Equal(AuditKind.NotifyDeliver, discarded[0].Kind);
});
}
[Fact]
public void AuditWriter_Throws_TerminalUpdate_StillSucceeds()
{
// Audit failure NEVER aborts the user-facing action: the dispatcher
// must still persist the Delivered status via UpdateAsync.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification();
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
_auditWriter.OnWrite = _ => throw new InvalidOperationException("audit dead");
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
_outboxRepository.Received(1).UpdateAsync(
Arg.Is<Notification>(n => n.Status == NotificationStatus.Delivered),
Arg.Any<CancellationToken>());
});
}
}
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
public class NotificationOutboxOptionsTests
{
[Fact]
public void Defaults_AreExpectedValues()
{
var options = new NotificationOutboxOptions();
Assert.Equal(TimeSpan.FromSeconds(10), options.DispatchInterval);
Assert.Equal(100, options.DispatchBatchSize);
Assert.Equal(TimeSpan.FromMinutes(10), options.StuckAgeThreshold);
Assert.Equal(TimeSpan.FromDays(365), options.TerminalRetention);
Assert.Equal(TimeSpan.FromDays(1), options.PurgeInterval);
Assert.Equal(TimeSpan.FromMinutes(1), options.DeliveredKpiWindow);
}
}
@@ -0,0 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
public class ProjectSmokeTest
{
[Fact]
public void ProjectCompiles() => Assert.True(true);
}
@@ -0,0 +1,113 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationService;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
/// <summary>
/// Task 17: Tests for <see cref="ServiceCollectionExtensions.AddNotificationOutbox"/> — the
/// DI registration extension for the Notification Outbox component. The extension binds
/// <see cref="NotificationOutboxOptions"/> from the <c>ScadaBridge:NotificationOutbox</c>
/// configuration section and registers the channel delivery adapter(s).
///
/// The Host wires both <c>AddNotificationService</c> and <c>AddNotificationOutbox</c> on the
/// central node; these tests do the same so the
/// adapter's SMTP dependencies (<c>Func&lt;ISmtpClientWrapper&gt;</c>, <c>OAuth2TokenService</c>,
/// <c>NotificationOptions</c>) are satisfied. <see cref="INotificationRepository"/> — which the
/// <see cref="EmailNotificationDeliveryAdapter"/> takes directly and is registered scoped by the
/// Configuration Database component — is supplied here by a lightweight stub.
/// </summary>
public class ServiceRegistrationTests
{
private static ServiceProvider BuildProvider()
{
var services = new ServiceCollection();
// Empty configuration: the options binding must still succeed and yield the
// documented NotificationOutboxOptions defaults.
var configuration = new ConfigurationBuilder().Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddLogging();
// INotificationRepository is registered scoped by the Configuration Database
// component in production; a no-op stub stands in for it here.
services.AddScoped<INotificationRepository>(_ => Substitute.For<INotificationRepository>());
services.AddNotificationService();
services.AddNotificationOutbox();
return services.BuildServiceProvider();
}
[Fact]
public void AddNotificationOutbox_RegistersNotificationOutboxOptions_WithDefaults()
{
using var provider = BuildProvider();
var options = provider.GetRequiredService<IOptions<NotificationOutboxOptions>>().Value;
Assert.NotNull(options);
Assert.Equal(TimeSpan.FromSeconds(10), options.DispatchInterval);
Assert.Equal(100, options.DispatchBatchSize);
}
[Fact]
public void AddNotificationOutbox_OptionsSection_IsTheNotificationOutboxConfigPath()
{
Assert.Equal(
"ScadaBridge:NotificationOutbox",
NotificationOutbox.ServiceCollectionExtensions.OptionsSection);
}
[Fact]
public void AddNotificationOutbox_BindsNotificationOutboxOptions_FromConfiguration()
{
var services = new ServiceCollection();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:NotificationOutbox:DispatchBatchSize"] = "250",
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddLogging();
services.AddScoped<INotificationRepository>(_ => Substitute.For<INotificationRepository>());
services.AddNotificationService();
services.AddNotificationOutbox();
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<NotificationOutboxOptions>>().Value;
Assert.Equal(250, options.DispatchBatchSize);
}
[Fact]
public void AddNotificationOutbox_RegistersEmailDeliveryAdapter()
{
using var provider = BuildProvider();
using var scope = provider.CreateScope();
var adapter = scope.ServiceProvider.GetRequiredService<EmailNotificationDeliveryAdapter>();
Assert.NotNull(adapter);
Assert.Equal(NotificationType.Email, adapter.Type);
}
[Fact]
public void AddNotificationOutbox_RegistersEmailAdapter_AsINotificationDeliveryAdapter()
{
using var provider = BuildProvider();
using var scope = provider.CreateScope();
var adapters = scope.ServiceProvider.GetServices<INotificationDeliveryAdapter>().ToList();
var email = Assert.Single(adapters);
Assert.IsType<EmailNotificationDeliveryAdapter>(email);
Assert.Equal(NotificationType.Email, email.Type);
}
}
@@ -0,0 +1,15 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport;
/// <summary>
/// Test-only no-op <see cref="ICentralAuditWriter"/>. Used by existing
/// NotificationOutboxActor TestKit fixtures whose tests pre-date the M4 Bundle B
/// audit-writer injection — they don't care about audit emission, they just
/// need a non-null collaborator so the actor's constructor succeeds.
/// </summary>
internal sealed class NoOpCentralAuditWriter : ICentralAuditWriter
{
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj" />
</ItemGroup>
</Project>