using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Messages.Notification; using ScadaLink.Commons.Types.Enums; using ScadaLink.NotificationOutbox.Delivery; using ScadaLink.NotificationOutbox.Tests.TestSupport; namespace ScadaLink.NotificationOutbox.Tests; /// /// Task 13: Tests for the ingest path — building a /// from a , persisting it via /// , and acking the sender. /// public class NotificationOutboxActorIngestTests : TestKit { private readonly INotificationOutboxRepository _repository = Substitute.For(); 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.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(), Arg.Any()) .Returns(true); var submit = MakeSubmit(); var actor = CreateActor(); actor.Tell(submit, TestActor); var ack = ExpectMsg(); Assert.Equal(submit.NotificationId, ack.NotificationId); Assert.True(ack.Accepted); Assert.Null(ack.Error); _repository.Received(1).InsertIfNotExistsAsync( Arg.Is(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()); } [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(), Arg.Any()) .Returns(true); var executionId = Guid.NewGuid(); var submit = MakeSubmit(originExecutionId: executionId); var actor = CreateActor(); actor.Tell(submit, TestActor); ExpectMsg(); _repository.Received(1).InsertIfNotExistsAsync( Arg.Is(n => n.OriginExecutionId == executionId), Arg.Any()); } [Fact] public void NotificationSubmit_NullOriginExecutionId_PersistsNull() { _repository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) .Returns(true); var submit = MakeSubmit(originExecutionId: null); var actor = CreateActor(); actor.Tell(submit, TestActor); ExpectMsg(); _repository.Received(1).InsertIfNotExistsAsync( Arg.Is(n => n.OriginExecutionId == null), Arg.Any()); } [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(), Arg.Any()) .Returns(true); var parentExecutionId = Guid.NewGuid(); var submit = MakeSubmit(originParentExecutionId: parentExecutionId); var actor = CreateActor(); actor.Tell(submit, TestActor); ExpectMsg(); _repository.Received(1).InsertIfNotExistsAsync( Arg.Is(n => n.OriginParentExecutionId == parentExecutionId), Arg.Any()); } [Fact] public void NotificationSubmit_NullOriginParentExecutionId_PersistsNull() { _repository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) .Returns(true); var submit = MakeSubmit(originParentExecutionId: null); var actor = CreateActor(); actor.Tell(submit, TestActor); ExpectMsg(); _repository.Received(1).InsertIfNotExistsAsync( Arg.Is(n => n.OriginParentExecutionId == null), Arg.Any()); } [Fact] public void DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted() { _repository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) .Returns(false); var submit = MakeSubmit(); var actor = CreateActor(); actor.Tell(submit, TestActor); var ack = ExpectMsg(); Assert.Equal(submit.NotificationId, ack.NotificationId); Assert.True(ack.Accepted); Assert.Null(ack.Error); } [Fact] public void RepositoryThrows_AcksNotAcceptedWithError() { _repository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("database unavailable")); var submit = MakeSubmit(); var actor = CreateActor(); actor.Tell(submit, TestActor); var ack = ExpectMsg(); 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(), Arg.Any()) .Returns(true); var submit = MakeSubmit(sourceNode: "node-a"); var actor = CreateActor(); actor.Tell(submit, TestActor); ExpectMsg(); _repository.Received(1).InsertIfNotExistsAsync( Arg.Is(n => n.SourceNode == "node-a"), Arg.Any()); } [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(), Arg.Any()) .Returns(true); var submit = MakeSubmit(sourceNode: null); var actor = CreateActor(); actor.Tell(submit, TestActor); ExpectMsg(); _repository.Received(1).InsertIfNotExistsAsync( Arg.Is(n => n.SourceNode == null), Arg.Any()); } }