diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs index 93428cc8..10febffb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs @@ -174,11 +174,12 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers private void HandleSubmit(NotificationSubmit msg) { var sender = Sender; - var notification = BuildNotification(msg); // The success projection fires for both a fresh insert and an existing row; - // only a thrown repository error reaches the failure projection. - PersistAsync(notification).PipeTo( + // only a thrown repository error reaches the failure projection. The list + // lookup that stamps the channel Type happens inside PersistAsync so it can + // share the same DI scope as the insert. + PersistAsync(msg).PipeTo( Self, success: () => new InternalMessages.IngestPersisted( msg.NotificationId, sender, Succeeded: true, Error: null), @@ -187,16 +188,31 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers } /// - /// Resolves a scoped and inserts the - /// notification if a row with the same id does not already exist. The boolean result - /// of InsertIfNotExistsAsync is intentionally ignored: an existing row is an - /// idempotent re-submission and is acked just like a fresh insert so the site can - /// clear its forward buffer. Only a thrown error must surface to the caller. + /// Resolves the target notification list to stamp the delivery channel + /// on a fresh row, then inserts the notification if a row + /// with the same id does not already exist. The boolean result of + /// InsertIfNotExistsAsync is intentionally ignored: an existing row is an + /// idempotent re-submission and is acked just like a fresh insert (and its persisted + /// Type is left untouched) so the site can clear its forward buffer. Only a thrown + /// error must surface to the caller. /// - private async Task PersistAsync(Notification notification) + private async Task PersistAsync(NotificationSubmit msg) { using var scope = _serviceProvider.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); + + // The list's Type is the authoritative delivery channel. Resolve it here so a + // fresh row is stamped with the right channel (Email/Sms/...). GetService (not + // GetRequiredService) so the lookup is optional: a host without INotification + // Repository, or a missing list, falls back to Email — the notification then + // parks at delivery with "list not found", which is the unchanged behaviour. + var listRepository = scope.ServiceProvider.GetService(); + var list = listRepository is null + ? null + : await listRepository.GetListByNameAsync(msg.ListName); + var type = list?.Type ?? NotificationType.Email; + + var notification = BuildNotification(msg, type); await repository.InsertIfNotExistsAsync(notification); } @@ -1139,12 +1155,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers return Enum.TryParse(value, ignoreCase: true, out var parsed) ? parsed : null; } - private static Notification BuildNotification(NotificationSubmit msg) + private static Notification BuildNotification(NotificationSubmit msg, NotificationType type) { - // All current notifications are email; NotificationType has only the Email member. + // The delivery channel is taken from the target list's Type (resolved by the + // caller); it defaults to Email when the list cannot be resolved. return new Notification( msg.NotificationId, - NotificationType.Email, + type, msg.ListName, msg.Subject, msg.Body, diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Ingest/NotificationIngestTypeStampingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Ingest/NotificationIngestTypeStampingTests.cs new file mode 100644 index 00000000..36eefa58 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Ingest/NotificationIngestTypeStampingTests.cs @@ -0,0 +1,129 @@ +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.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport; + +namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Ingest; + +/// +/// SMS Notifications (S4): at ingest the stamps the +/// new 's from the target +/// (the authoritative delivery channel), defaulting to +/// when the list cannot be resolved. The idempotent +/// insert-if-not-exists / ack-after-persist semantics are unchanged. +/// +public class NotificationIngestTypeStampingTests : TestKit +{ + private readonly INotificationOutboxRepository _outboxRepository = + Substitute.For(); + + private readonly INotificationRepository _listRepository = + Substitute.For(); + + public NotificationIngestTypeStampingTests() + { + // Default: a fresh insert succeeds. Individual tests configure the list lookup. + _outboxRepository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) + .Returns(true); + } + + private IServiceProvider BuildServiceProvider() + { + var services = new ServiceCollection(); + services.AddScoped(_ => _outboxRepository); + services.AddScoped(_ => _listRepository); + 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 listName = "ops-team") + { + return new NotificationSubmit( + NotificationId: Guid.NewGuid().ToString(), + ListName: listName, + 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: null, + OriginParentExecutionId: null, + SourceNode: "node-a"); + } + + private void ArrangeList(string name, NotificationType type) + { + _listRepository.GetListByNameAsync(name, Arg.Any()) + .Returns(new NotificationList(name) { Type = type }); + } + + [Fact] + public void Ingest_EmailTypedList_StampsEmail() + { + ArrangeList("ops-team", NotificationType.Email); + var submit = MakeSubmit(); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + var ack = ExpectMsg(); + Assert.True(ack.Accepted); + _outboxRepository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => + n.NotificationId == submit.NotificationId && + n.Type == NotificationType.Email), + Arg.Any()); + } + + [Fact] + public void Ingest_SmsTypedList_StampsSms() + { + ArrangeList("sms-oncall", NotificationType.Sms); + var submit = MakeSubmit("sms-oncall"); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + var ack = ExpectMsg(); + Assert.True(ack.Accepted); + _outboxRepository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => + n.NotificationId == submit.NotificationId && + n.Type == NotificationType.Sms), + Arg.Any()); + } + + [Fact] + public void Ingest_MissingList_StampsEmailDefault() + { + // GetListByNameAsync returns null (substitute default) — the row is stamped Email + // and will later park at delivery with "list not found" (unchanged behaviour). + var submit = MakeSubmit("does-not-exist"); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + var ack = ExpectMsg(); + Assert.True(ack.Accepted); + _outboxRepository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => + n.NotificationId == submit.NotificationId && + n.Type == NotificationType.Email), + Arg.Any()); + } +}