feat(commons): project outbound API/DB/notification rows onto the External lane

ResolveKind maps ApiOutbound/DbOutbound/Notification to ApiCall/DbCall/Notify;
ResolveLane puts the three on FlowLane.External; outbound outcomes map
Delivered→Ok, Failed→Failed, Parked/Discarded→Parked, else Pending; subtitles
'{status} · sync|cached', '{op} · n rows' from a DbWrite Extra, '{status}' for
Notify. New FlowStepProjector.IsOutbound helper.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 01:38:41 -04:00
parent cc1bbf3975
commit e26616bebc
3 changed files with 163 additions and 5 deletions
@@ -1,4 +1,6 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
@@ -47,4 +49,103 @@ public class FlowStepProjectorTests
Assert.True((edge with { Async = true }).Async);
Assert.True(new FlowEdge(a, b, false, false, Async: false, Spawn: true).Spawn);
}
// ---- Task 1: outbound rows project onto the External lane ---------------------------------
[Fact]
public void SyncApiCall_Delivered_IsApiCall_External_Ok_SyncSubtitle()
{
var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered,
occurredAtUtc: T0.UtcDateTime, actor: "AckRecipe", target: "Delmia.AckRecipe", sourceNode: "node-a",
executionId: Guid.NewGuid(), sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 212,
requestSummary: "{\"id\":1}", responseSummary: "{\"ok\":true}");
var step = FlowStepProjector.Project(evt);
Assert.Equal(FlowStepKind.ApiCall, step.Kind);
Assert.Equal(FlowLane.External, step.Lane);
Assert.Equal(FlowStepOutcome.Ok, step.Outcome);
Assert.Equal("Delmia.AckRecipe", step.Title);
Assert.Equal("Delivered · sync", step.Subtitle);
Assert.Equal("{\"id\":1}", step.InputJson);
Assert.Equal("{\"ok\":true}", step.OutputJson);
Assert.Equal(212, step.DurationMs);
}
[Fact]
public void CachedSubmit_InDbOutbound_IsDbCall_Pending_CachedSubtitle()
{
var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
occurredAtUtc: T0.UtcDateTime, target: "MesDb", correlationId: Guid.NewGuid(), executionId: Guid.NewGuid());
var step = FlowStepProjector.Project(evt);
Assert.Equal(FlowStepKind.DbCall, step.Kind);
Assert.Equal(FlowLane.External, step.Lane);
Assert.Equal(FlowStepOutcome.Pending, step.Outcome);
Assert.Equal("Submitted · cached", step.Subtitle);
}
[Fact]
public void DbWrite_WithRowsAffectedExtra_SubtitleIsOpAndRows()
{
var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered,
occurredAtUtc: T0.UtcDateTime, target: "MesDb", executionId: Guid.NewGuid(),
extra: "{\"op\":\"write\",\"rowsAffected\":3}");
var step = FlowStepProjector.Project(evt);
Assert.Equal(FlowStepKind.DbCall, step.Kind);
Assert.Equal(FlowStepOutcome.Ok, step.Outcome);
Assert.Equal("write · 3 rows", step.Subtitle);
var read = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered,
occurredAtUtc: T0.UtcDateTime, target: "MesDb", extra: "{\"op\":\"read\",\"rowsReturned\":1}");
Assert.Equal("read · 1 row", FlowStepProjector.Project(read).Subtitle);
}
[Fact]
public void NotifySend_Submitted_IsNotify_Pending()
{
var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted,
occurredAtUtc: T0.UtcDateTime, target: "Planners", correlationId: Guid.NewGuid(), executionId: Guid.NewGuid());
var step = FlowStepProjector.Project(evt);
Assert.Equal(FlowStepKind.Notify, step.Kind);
Assert.Equal(FlowLane.External, step.Lane);
Assert.Equal(FlowStepOutcome.Pending, step.Outcome);
Assert.Equal("Planners", step.Title);
Assert.Equal("Submitted", step.Subtitle);
}
[Theory]
[InlineData(AuditStatus.Parked, FlowStepOutcome.Parked)]
[InlineData(AuditStatus.Discarded, FlowStepOutcome.Parked)]
[InlineData(AuditStatus.Failed, FlowStepOutcome.Failed)]
[InlineData(AuditStatus.Forwarded, FlowStepOutcome.Pending)]
[InlineData(AuditStatus.Attempted, FlowStepOutcome.Pending)]
[InlineData(AuditStatus.Delivered, FlowStepOutcome.Ok)]
public void OutboundStatus_MapsToOutcome(AuditStatus status, FlowStepOutcome expected)
{
var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, status,
occurredAtUtc: T0.UtcDateTime, target: "Delmia.AckRecipe", correlationId: Guid.NewGuid(),
errorMessage: status == AuditStatus.Failed ? "boom" : null);
var step = FlowStepProjector.Project(evt);
Assert.Equal(expected, step.Outcome);
Assert.Equal($"{status} · cached", step.Subtitle);
}
[Fact]
public void ScriptTraceOutcome_Unchanged_FailedStaysFailed_OtherStatusesOk()
{
var ok = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
occurredAtUtc: T0.UtcDateTime, target: "A");
var failed = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Failed,
occurredAtUtc: T0.UtcDateTime, target: "A", errorMessage: "rejected");
Assert.Equal(FlowStepOutcome.Ok, FlowStepProjector.Project(ok).Outcome);
Assert.Equal(FlowStepOutcome.Failed, FlowStepProjector.Project(failed).Outcome);
}
}