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:
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
IActorRef siteCommunicationActor,
|
||||
string? sourceScript = null,
|
||||
Guid? executionId = null,
|
||||
Guid? parentExecutionId = null)
|
||||
Guid? parentExecutionId = null,
|
||||
string? sourceNode = null)
|
||||
{
|
||||
return new ScriptRuntimeContext.NotifyHelper(
|
||||
_saf,
|
||||
@@ -74,7 +75,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
NullLogger.Instance,
|
||||
executionId ?? Guid.NewGuid(),
|
||||
auditWriter: null,
|
||||
parentExecutionId: parentExecutionId);
|
||||
parentExecutionId: parentExecutionId,
|
||||
sourceNode: sourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -197,6 +199,45 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Null(payload!.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_StampsSourceNode_OnTheNotificationSubmitPayload()
|
||||
{
|
||||
// SourceNode-stamping (Task 13): when the helper is wired with the
|
||||
// local INodeIdentityProvider's NodeName, Notify.Send must stamp it
|
||||
// onto the NotificationSubmit so it rides inside the serialized S&F
|
||||
// payload to central, where NotificationOutboxActor persists it on
|
||||
// the Notifications row.
|
||||
var commProbe = CreateTestProbe();
|
||||
var notify = CreateHelper(commProbe.Ref, sourceNode: "node-a");
|
||||
|
||||
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
|
||||
|
||||
var buffered = await _saf.GetMessageByIdAsync(notificationId);
|
||||
Assert.NotNull(buffered);
|
||||
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("node-a", payload!.SourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_NoNodeIdentity_LeavesSourceNodeNull()
|
||||
{
|
||||
// Hosts that don't wire INodeIdentityProvider (legacy / tests) pass
|
||||
// null through. The NotificationSubmit payload's SourceNode stays
|
||||
// null so the central Notifications row persists NULL rather than
|
||||
// falling back to a placeholder.
|
||||
var commProbe = CreateTestProbe();
|
||||
var notify = CreateHelper(commProbe.Ref, sourceNode: null);
|
||||
|
||||
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
|
||||
|
||||
var buffered = await _saf.GetMessageByIdAsync(notificationId);
|
||||
Assert.NotNull(buffered);
|
||||
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Null(payload!.SourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user