using System.CommandLine; using System.Text.Json; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.CLI.Commands; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; 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.CLI.Tests; /// /// Tests for the flow-debug command group (Flow Debugger design 2026-09-05 ยง7.1): /// the command tree shape, --params parsing, and the table projection that turns a /// into one row per step. /// public class FlowDebugCommandsTests { private static readonly Option Url = new("--url") { Recursive = true }; private static readonly Option Username = new("--username") { Recursive = true }; private static readonly Option Password = new("--password") { Recursive = true }; private static readonly Option Format = CliOptions.CreateFormatOption(); private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; [Fact] public void Build_RegistersTheFourSubcommands_WithTheirRequiredOptions() { var group = FlowDebugCommands.Build(Url, Format, Username, Password); Assert.Equal("flow-debug", group.Name); var names = group.Subcommands.Select(c => c.Name).ToList(); Assert.Equal(["run-method", "run-script", "run-value-change", "replay"], names); var runMethod = group.Subcommands.Single(c => c.Name == "run-method"); Assert.Contains(runMethod.Options, o => o.Name == "--method-id" && o.Required); Assert.Contains(runMethod.Options, o => o.Name == "--params" && !o.Required); Assert.Contains(runMethod.Options, o => o.Name == "--timeout-seconds" && !o.Required); Assert.DoesNotContain(runMethod.Options, o => o.Name == "--wait-seconds"); var runScript = group.Subcommands.Single(c => c.Name == "run-script"); Assert.Contains(runScript.Options, o => o.Name == "--instance-id" && o.Required); Assert.Contains(runScript.Options, o => o.Name == "--script" && o.Required); Assert.Contains(runScript.Options, o => o.Name == "--params" && !o.Required); Assert.Contains(runScript.Options, o => o.Name == "--value" && !o.Required); Assert.Contains(runScript.Options, o => o.Name == "--wait-seconds" && !o.Required); Assert.Contains(runScript.Options, o => o.Name == "--timeout-seconds" && !o.Required); var runValueChange = group.Subcommands.Single(c => c.Name == "run-value-change"); Assert.Contains(runValueChange.Options, o => o.Name == "--instance-id" && o.Required); Assert.Contains(runValueChange.Options, o => o.Name == "--attribute" && o.Required); Assert.Contains(runValueChange.Options, o => o.Name == "--value" && o.Required); Assert.Contains(runValueChange.Options, o => o.Name == "--wait-seconds" && !o.Required); Assert.DoesNotContain(runValueChange.Options, o => o.Name == "--timeout-seconds"); var replay = group.Subcommands.Single(c => c.Name == "replay"); Assert.Contains(replay.Options, o => o.Name == "--execution-id" && o.Required); } [Fact] public void RunScript_Defaults_WaitSeconds45_TimeoutSeconds120() { var group = FlowDebugCommands.Build(Url, Format, Username, Password); var parse = group.Parse("run-script --instance-id 7 --script Manual --params {\"n\":3}"); Assert.Empty(parse.Errors); var cmd = group.Subcommands.Single(c => c.Name == "run-script"); Assert.Equal(45, parse.GetValue((Option)cmd.Options.Single(o => o.Name == "--wait-seconds"))); Assert.Equal(120, parse.GetValue((Option)cmd.Options.Single(o => o.Name == "--timeout-seconds"))); } [Fact] public async Task RunScript_WaitSecondsZero_IsRejectedWithInvalidArgument() { var group = FlowDebugCommands.Build(Url, Format, Username, Password); var exitCode = await group.Parse("run-script --instance-id 7 --script Manual --wait-seconds 0").InvokeAsync(); Assert.Equal(1, exitCode); } [Fact] public async Task RunScript_TimeoutSecondsZero_IsRejectedWithInvalidArgument() { var group = FlowDebugCommands.Build(Url, Format, Username, Password); var exitCode = await group.Parse("run-script --instance-id 7 --script Manual --timeout-seconds 0").InvokeAsync(); Assert.Equal(1, exitCode); } [Fact] public void RunMethod_TimeoutSecondsDefaultsTo120() { var group = FlowDebugCommands.Build(Url, Format, Username, Password); var parse = group.Parse("run-method --method-id 4"); Assert.Empty(parse.Errors); var timeoutOption = (Option)parse.CommandResult.Command.Options.Single(o => o.Name == "--timeout-seconds"); Assert.Equal(120, parse.GetValue(timeoutOption)); } [Fact] public async Task RunMethod_TimeoutSecondsZero_IsRejectedWithInvalidArgument() { var group = FlowDebugCommands.Build(Url, Format, Username, Password); var exitCode = await group.Parse("run-method --method-id 4 --timeout-seconds 0").InvokeAsync(); Assert.Equal(1, exitCode); } [Fact] public void RunValueChange_WaitSecondsDefaultsTo45_MatchingTheCommandRecord() { var group = FlowDebugCommands.Build(Url, Format, Username, Password); var parse = group.Parse("run-value-change --instance-id 7 --attribute Speed --value 42"); Assert.Empty(parse.Errors); var waitOption = (Option)parse.CommandResult.Command.Options.Single(o => o.Name == "--wait-seconds"); Assert.Equal(new FlowDebugRunValueChangeCommand(7, "Speed", "42").WaitSeconds, parse.GetValue(waitOption)); } [Fact] public void FlowDebugCommandRecords_ResolveInTheManagementCommandRegistry() { // The CLI derives the wire command name from the record type; a rename on either // side would throw here rather than at runtime. Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunMethodCommand)))); Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunScriptCommand)))); Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunValueChangeCommand)))); Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugReplayCommand)))); } [Fact] public void ParseParameters_Blank_IsNull_AndObject_IsCloned() { Assert.Null(FlowDebugCommands.ParseParameters(null)); Assert.Null(FlowDebugCommands.ParseParameters(" ")); var parsed = FlowDebugCommands.ParseParameters("""{"code":"Z28061","qty":3}"""); Assert.NotNull(parsed); Assert.Equal(JsonValueKind.Object, parsed!.Value.ValueKind); Assert.Equal("Z28061", parsed.Value.GetProperty("code").GetString()); } [Theory] [InlineData("not json")] [InlineData("[1,2]")] [InlineData("\"scalar\"")] public void ParseParameters_RejectsMalformedOrNonObjectInput(string input) { Assert.ThrowsAny(() => FlowDebugCommands.ParseParameters(input)); } /// /// The real server shape, serialised from the REAL /// and canonical types with the server's camelCase policy, so /// a rename on either fails this test instead of silently blanking a column. /// private static string RealPayload(bool completed, string? errorCode, string? error, bool truncated) { var root = Guid.NewGuid(); var child = Guid.NewGuid(); var t0 = new DateTime(2026, 9, 5, 14, 3, 7, 120, DateTimeKind.Utc); var events = new List { ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, occurredAtUtc: t0, actor: "flow-debugger", target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 812), ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, occurredAtUtc: t0.AddMilliseconds(5), actor: "inbound-api", target: "DelmiaSim01.ProcessRecipeDownload", executionId: root, durationMs: 790), ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: t0.AddMilliseconds(20), target: "ProcessRecipeDownload", executionId: child, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", sourceScript: "ProcessRecipeDownload", durationMs: 700), ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWait, AuditStatus.Failed, occurredAtUtc: t0.AddMilliseconds(40), target: "DelmiaSim01.RecipeAck", executionId: child, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 600, errorMessage: "timed out after 600 ms"), }; var result = new FlowDebugRunResult(root, completed, error, errorCode, events, truncated, new DateTimeOffset(t0), new DateTimeOffset(t0.AddSeconds(1))); return JsonSerializer.Serialize(result, CamelCase); } [Fact] public void Projection_EmitsOneRowPerStep_WithKindLaneTargetDurationStatus() { var projected = FlowDebugTableProjection.ProjectEvents(RealPayload(completed: true, null, null, truncated: false)); using var doc = JsonDocument.Parse(projected); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); var rows = doc.RootElement.EnumerateArray().ToList(); Assert.Equal(4, rows.Count); Assert.Equal("14:03:07.120", rows[0].GetProperty("occurred").GetString()); Assert.Equal("InboundRequest", rows[0].GetProperty("kind").GetString()); Assert.Equal("Central", rows[0].GetProperty("lane").GetString()); Assert.Equal("POST /api/DelmiaRecipeDownload", rows[0].GetProperty("target").GetString()); Assert.Equal(812, rows[0].GetProperty("durationMs").GetInt32()); Assert.Equal("Delivered", rows[0].GetProperty("status").GetString()); Assert.Equal("RouteCall", rows[1].GetProperty("kind").GetString()); Assert.Equal("Central", rows[1].GetProperty("lane").GetString()); Assert.Equal("ScriptRun", rows[2].GetProperty("kind").GetString()); Assert.Equal("Site", rows[2].GetProperty("lane").GetString()); Assert.Equal("DelmiaSim01.ProcessRecipeDownload", rows[2].GetProperty("target").GetString()); Assert.Equal("AttributeWait", rows[3].GetProperty("kind").GetString()); Assert.Equal("Site", rows[3].GetProperty("lane").GetString()); // waits sit on the Site lane; writes on Tags Assert.Equal("Failed", rows[3].GetProperty("status").GetString()); Assert.Equal("timed out after 600 ms", rows[3].GetProperty("error").GetString()); // A completed, untruncated run has no trailing note row. Assert.All(rows, r => Assert.False(r.TryGetProperty("note", out _))); } [Fact] public void Projection_IncompleteRun_AppendsANoteRowWithTheErrorCodeVerbatim() { var projected = FlowDebugTableProjection.ProjectEvents( RealPayload(completed: false, "STANDBY_NODE", "This node is standby; run on the active central node.", truncated: false)); using var doc = JsonDocument.Parse(projected); var last = doc.RootElement.EnumerateArray().Last(); var note = last.GetProperty("note").GetString(); Assert.StartsWith("STANDBY_NODE: ", note); Assert.Contains("standby", note); } [Fact] public void Projection_TruncatedRun_NotesThatTheAuditRowsRemainComplete() { var projected = FlowDebugTableProjection.ProjectEvents(RealPayload(completed: true, null, null, truncated: true)); using var doc = JsonDocument.Parse(projected); var last = doc.RootElement.EnumerateArray().Last(); Assert.Contains("truncated", last.GetProperty("note").GetString()); Assert.Contains("audit tree", last.GetProperty("note").GetString()); } [Fact] public void Projection_LeavesNonMatchingBodiesUntouched() { Assert.Equal("proxy", FlowDebugTableProjection.ProjectEvents("proxy")); Assert.Equal("""{"error":"x"}""", FlowDebugTableProjection.ProjectEvents("""{"error":"x"}""")); Assert.Equal("[1]", FlowDebugTableProjection.ProjectEvents("[1]")); } [Fact] public void Projection_FoldsAnOutboundLifecycle_IntoOneExternalLaneRow() { var root = Guid.NewGuid(); var child = Guid.NewGuid(); var corr = Guid.NewGuid(); var t0 = new DateTime(2026, 9, 6, 9, 0, 0, 0, DateTimeKind.Utc); var events = new List { ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, occurredAtUtc: t0, target: "DelmiaSim01.OutboundDemo", executionId: root, durationMs: 300, extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()), ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: t0.AddMilliseconds(10), target: "OutboundDemo", executionId: child, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 250), ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, occurredAtUtc: t0.AddMilliseconds(20), target: "RigRest.Echo", executionId: child, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 40), ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, occurredAtUtc: t0.AddMilliseconds(70), target: "RigDb", correlationId: corr, executionId: child, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01"), ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWriteCached, AuditStatus.Attempted, occurredAtUtc: t0.AddMilliseconds(90), target: "RigDb", correlationId: corr, executionId: child, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 15), ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.CachedResolve, AuditStatus.Delivered, occurredAtUtc: t0.AddMilliseconds(120), target: "RigDb", correlationId: corr, executionId: child, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01"), }; var result = new FlowDebugRunResult(root, true, null, null, events, false, new DateTimeOffset(t0), new DateTimeOffset(t0.AddSeconds(1))); var projected = FlowDebugTableProjection.ProjectEvents(JsonSerializer.Serialize(result, CamelCase)); using var doc = JsonDocument.Parse(projected); var rows = doc.RootElement.EnumerateArray().ToList(); Assert.Equal(4, rows.Count); // route, run, sync api call, ONE folded db write Assert.Equal("ApiCall", rows[2].GetProperty("kind").GetString()); Assert.Equal("External", rows[2].GetProperty("lane").GetString()); Assert.Equal("RigRest.Echo", rows[2].GetProperty("target").GetString()); Assert.Equal("1 row", rows[2].GetProperty("lifecycle").GetString()); Assert.Equal(40, rows[2].GetProperty("durationMs").GetInt32()); Assert.Equal("DbCall", rows[3].GetProperty("kind").GetString()); Assert.Equal("External", rows[3].GetProperty("lane").GetString()); Assert.Equal("RigDb", rows[3].GetProperty("target").GetString()); Assert.Equal("3 rows", rows[3].GetProperty("lifecycle").GetString()); Assert.Equal(corr.ToString("D"), rows[3].GetProperty("correlation").GetString()); Assert.Equal("Delivered", rows[3].GetProperty("status").GetString()); Assert.Equal(50, rows[3].GetProperty("durationMs").GetInt32()); Assert.Equal("09:00:00.070", rows[3].GetProperty("occurred").GetString()); Assert.Equal("DelmiaSim01.OutboundDemo", rows[1].GetProperty("target").GetString()); Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("lifecycle").ValueKind); Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("correlation").ValueKind); } }