feat(auditlog): NotifyDeliver rows carry the originating ExecutionId

This commit is contained in:
Joseph Doherty
2026-05-21 15:35:40 -04:00
parent 705ae95404
commit 85bb61a1f3
16 changed files with 2020 additions and 8 deletions

View File

@@ -94,7 +94,8 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
private static Notification MakeNotification(
Guid? notificationId = null,
string sourceSite = "site-1",
int retryCount = 0)
int retryCount = 0,
Guid? originExecutionId = null)
{
return new Notification(
(notificationId ?? Guid.NewGuid()).ToString("D"),
@@ -108,6 +109,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
CreatedAt = DateTimeOffset.UtcNow,
SourceInstanceId = "instance-42",
SourceScript = "AlarmScript",
OriginExecutionId = originExecutionId,
};
}
@@ -162,6 +164,49 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
});
}
[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_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
{

View File

@@ -39,7 +39,8 @@ public class NotificationOutboxActorIngestTests : TestKit
NullLogger<NotificationOutboxActor>.Instance)));
}
private static NotificationSubmit MakeSubmit(string? notificationId = null)
private static NotificationSubmit MakeSubmit(
string? notificationId = null, Guid? originExecutionId = null)
{
return new NotificationSubmit(
NotificationId: notificationId ?? Guid.NewGuid().ToString(),
@@ -49,7 +50,8 @@ public class NotificationOutboxActorIngestTests : TestKit
SourceSiteId: "site-1",
SourceInstanceId: "instance-42",
SourceScript: "AlarmScript",
SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero));
SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero),
OriginExecutionId: originExecutionId);
}
[Fact]
@@ -83,6 +85,42 @@ public class NotificationOutboxActorIngestTests : TestKit
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 DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted()
{

View File

@@ -87,7 +87,8 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
private static Notification MakeNotification(
NotificationStatus status = NotificationStatus.Pending,
int retryCount = 0,
Guid? notificationId = null)
Guid? notificationId = null,
Guid? originExecutionId = null)
{
return new Notification(
(notificationId ?? Guid.NewGuid()).ToString("D"),
@@ -100,6 +101,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
Status = status,
RetryCount = retryCount,
CreatedAt = DateTimeOffset.UtcNow,
OriginExecutionId = originExecutionId,
};
}
@@ -145,6 +147,50 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
});
}
[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_Parked_OnPermanentFailure_EmitsEvent_StatusParked()
{