feat(notif-outbox): carry + persist SourceNode end-to-end via NotificationSubmit

Site: inject INodeIdentityProvider where NotificationSubmit is built; stamp
SourceNode = NodeName at construction.

Central: NotificationOutboxActor.HandleSubmit copies submit.SourceNode onto
the Notification row; the repository INSERT persists it (EF tracked-entity
insert flows it through automatically; raw-SQL extension if not).

After this commit, every Notifications row carries the originating site
node-a/node-b in SourceNode. Existing notifications submitted pre-feature
remain NULL.
This commit is contained in:
Joseph Doherty
2026-05-23 17:28:23 -04:00
parent e6341580b3
commit d1fcab490c
5 changed files with 166 additions and 11 deletions

View File

@@ -42,7 +42,8 @@ public class NotificationOutboxActorIngestTests : TestKit
private static NotificationSubmit MakeSubmit(
string? notificationId = null,
Guid? originExecutionId = null,
Guid? originParentExecutionId = null)
Guid? originParentExecutionId = null,
string? sourceNode = null)
{
return new NotificationSubmit(
NotificationId: notificationId ?? Guid.NewGuid().ToString(),
@@ -54,7 +55,8 @@ public class NotificationOutboxActorIngestTests : TestKit
SourceScript: "AlarmScript",
SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero),
OriginExecutionId: originExecutionId,
OriginParentExecutionId: originParentExecutionId);
OriginParentExecutionId: originParentExecutionId,
SourceNode: sourceNode);
}
[Fact]
@@ -192,4 +194,43 @@ public class NotificationOutboxActorIngestTests : TestKit
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>());
}
}