using System.CommandLine; using System.Net; using System.Text; using System.Text.Json; using ZB.MOM.WW.ScadaBridge.CLI; using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// /// Tests for the scadabridge audit tree subcommand (Audit Log #23 M5.1-T8): /// tree rendering (table format), JSON output, error handling, and CLI parsing. /// [Collection("Console")] public class AuditTreeCommandTests { // ───────────────────────────────────────────────────────────────────── // JSON parsing helpers // ───────────────────────────────────────────────────────────────────── private static string NodeJson( string executionId, string? parentId = null, int rowCount = 3, string[]? channels = null, string[]? statuses = null, string? siteId = "plant-a", string? instanceId = "inst-1", string? first = "2026-05-20T10:00:00Z", string? last = "2026-05-20T10:01:00Z") { var parentStr = parentId != null ? $"\"{parentId}\"" : "null"; var channelArr = channels is { Length: > 0 } ? "[" + string.Join(",", channels.Select(c => $"\"{c}\"")) + "]" : "[\"ApiOutbound\"]"; var statusArr = statuses is { Length: > 0 } ? "[" + string.Join(",", statuses.Select(s => $"\"{s}\"")) + "]" : "[\"Delivered\"]"; var siteStr = siteId != null ? $"\"{siteId}\"" : "null"; var instanceStr = instanceId != null ? $"\"{instanceId}\"" : "null"; var firstStr = first != null ? $"\"{first}\"" : "null"; var lastStr = last != null ? $"\"{last}\"" : "null"; return $@"{{ ""executionId"":""{executionId}"", ""parentExecutionId"":{parentStr}, ""rowCount"":{rowCount}, ""channels"":{channelArr}, ""statuses"":{statusArr}, ""sourceSiteId"":{siteStr}, ""sourceInstanceId"":{instanceStr}, ""firstOccurredAtUtc"":{firstStr}, ""lastOccurredAtUtc"":{lastStr} }}"; } // ───────────────────────────────────────────────────────────────────── // ParseNodes // ───────────────────────────────────────────────────────────────────── [Fact] public void ParseNodes_ValidArray_ReturnsDtos() { var root = "11111111-1111-1111-1111-111111111111"; var child = "22222222-2222-2222-2222-222222222222"; var json = $"[{NodeJson(root)},{NodeJson(child, parentId: root)}]"; var nodes = AuditTreeHelpers.ParseNodes(json); Assert.Equal(2, nodes.Length); Assert.Equal(Guid.Parse(root), nodes[0].ExecutionId); Assert.Null(nodes[0].ParentExecutionId); Assert.Equal(Guid.Parse(child), nodes[1].ExecutionId); Assert.Equal(Guid.Parse(root), nodes[1].ParentExecutionId); Assert.Equal(3, nodes[0].RowCount); } [Fact] public void ParseNodes_EmptyArray_ReturnsEmpty() { var nodes = AuditTreeHelpers.ParseNodes("[]"); Assert.Empty(nodes); } [Fact] public void ParseNodes_InvalidJson_ReturnsEmpty() { var nodes = AuditTreeHelpers.ParseNodes("not-json"); Assert.Empty(nodes); } // ───────────────────────────────────────────────────────────────────── // WriteTable — ASCII tree rendering // ───────────────────────────────────────────────────────────────────── [Fact] public void WriteTable_EmptyNodes_PrintsFallbackMessage() { var output = new StringWriter(); AuditTreeHelpers.WriteTable(Array.Empty(), Guid.NewGuid(), output); Assert.Contains("no execution tree found", output.ToString()); } [Fact] public void WriteTable_SingleRootNode_PrintsWithNoIndent() { var rootId = Guid.Parse("11111111-1111-1111-1111-111111111111"); var nodes = AuditTreeHelpers.ParseNodes($"[{NodeJson(rootId.ToString())}]"); var output = new StringWriter(); AuditTreeHelpers.WriteTable(nodes, rootId, output); var text = output.ToString(); // Root node printed at column 0 (no leading spaces). var line = text.Split('\n', StringSplitOptions.RemoveEmptyEntries).First(); Assert.StartsWith(rootId.ToString("D"), line); Assert.Contains("[*]", line); // queried node marked } [Fact] public void WriteTable_MultiLevelTree_IndentsChildrenCorrectly() { var rootId = "11111111-1111-1111-1111-111111111111"; var childId = "22222222-2222-2222-2222-222222222222"; var grandChildId = "33333333-3333-3333-3333-333333333333"; var json = $"[{NodeJson(rootId)},{NodeJson(childId, parentId: rootId)},{NodeJson(grandChildId, parentId: childId)}]"; var nodes = AuditTreeHelpers.ParseNodes(json); var output = new StringWriter(); AuditTreeHelpers.WriteTable(nodes, Guid.Parse(rootId), output); var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries); // Root: no indent. Assert.True(lines[0].StartsWith(rootId, StringComparison.OrdinalIgnoreCase) || lines[0].StartsWith(rootId.ToUpper(), StringComparison.OrdinalIgnoreCase)); // Child: 2-space indent (exactly 2, not 4+). var childLine = lines.First(l => l.Contains(childId)); Assert.StartsWith(" ", childLine); Assert.False(childLine.StartsWith(" ", StringComparison.Ordinal), "child should be indented exactly 2, not 4+"); // Grandchild: 4-space indent. var grandLine = lines.First(l => l.Contains(grandChildId)); Assert.StartsWith(" ", grandLine); } [Fact] public void WriteTable_QueriedNodeIsMarked_OthersAreNot() { var rootId = Guid.Parse("11111111-1111-1111-1111-111111111111"); var childId = Guid.Parse("22222222-2222-2222-2222-222222222222"); var json = $"[{NodeJson(rootId.ToString())},{NodeJson(childId.ToString(), parentId: rootId.ToString())}]"; var nodes = AuditTreeHelpers.ParseNodes(json); // Query via child ID — child should be marked, root should not. var output = new StringWriter(); AuditTreeHelpers.WriteTable(nodes, childId, output); var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries); var childLine = lines.First(l => l.Contains(childId.ToString("D"))); var rootLine = lines.First(l => l.Contains(rootId.ToString("D"))); Assert.Contains("[*]", childLine); Assert.DoesNotContain("[*]", rootLine); } // ───────────────────────────────────────────────────────────────────── // WriteJson // ───────────────────────────────────────────────────────────────────── [Fact] public void WriteJson_ValidNodes_EmitsValidJsonArray() { var rootId = "11111111-1111-1111-1111-111111111111"; var childId = "22222222-2222-2222-2222-222222222222"; var nodes = AuditTreeHelpers.ParseNodes($"[{NodeJson(rootId)},{NodeJson(childId, parentId: rootId)}]"); var output = new StringWriter(); AuditTreeHelpers.WriteJson(nodes, output); var text = output.ToString(); using var doc = JsonDocument.Parse(text); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(2, doc.RootElement.GetArrayLength()); } [Fact] public void WriteJson_EmptyNodes_EmitsEmptyArray() { var output = new StringWriter(); AuditTreeHelpers.WriteJson(Array.Empty(), output); var text = output.ToString().Trim(); using var doc = JsonDocument.Parse(text); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(0, doc.RootElement.GetArrayLength()); } // ───────────────────────────────────────────────────────────────────── // RunTreeAsync — HTTP execution // ───────────────────────────────────────────────────────────────────── private sealed class FixedHandler : HttpMessageHandler { private readonly HttpStatusCode _status; private readonly string _body; public FixedHandler(HttpStatusCode status, string body) { _status = status; _body = body; } public string? LastRequestUri { get; private set; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { LastRequestUri = request.RequestUri!.PathAndQuery; return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body, Encoding.UTF8, "application/json"), }); } } [Fact] public async Task RunTree_Success_ReturnsZeroAndWritesOutput() { var rootId = "11111111-1111-1111-1111-111111111111"; var json = $"[{NodeJson(rootId)}]"; var handler = new FixedHandler(HttpStatusCode.OK, json); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); var exit = await AuditTreeHelpers.RunTreeAsync( client, Guid.Parse(rootId), "table", output); Assert.Equal(0, exit); Assert.Contains(rootId, output.ToString()); } [Fact] public async Task RunTree_EmptyResponse_ReturnsZeroWithFallbackMessage() { var handler = new FixedHandler(HttpStatusCode.OK, "[]"); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); var exit = await AuditTreeHelpers.RunTreeAsync( client, Guid.NewGuid(), "table", output); Assert.Equal(0, exit); Assert.Contains("no execution tree found", output.ToString()); } [Fact] public async Task RunTree_JsonFormat_EmitsValidJson() { var rootId = "11111111-1111-1111-1111-111111111111"; var handler = new FixedHandler(HttpStatusCode.OK, $"[{NodeJson(rootId)}]"); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); var exit = await AuditTreeHelpers.RunTreeAsync( client, Guid.Parse(rootId), "json", output); Assert.Equal(0, exit); using var doc = JsonDocument.Parse(output.ToString()); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); } [Fact] public async Task RunTree_Http403_ReturnsExitCode2() { var handler = new FixedHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}"); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); var exit = await AuditTreeHelpers.RunTreeAsync( client, Guid.NewGuid(), "table", output); Assert.Equal(2, exit); } [Fact] public async Task RunTree_Http500_ReturnsExitCode1() { var handler = new FixedHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}"); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); var exit = await AuditTreeHelpers.RunTreeAsync( client, Guid.NewGuid(), "table", output); Assert.Equal(1, exit); } [Fact] public async Task RunTree_RequestUrlContainsExecutionId() { var id = Guid.Parse("11111111-1111-1111-1111-111111111111"); var handler = new FixedHandler(HttpStatusCode.OK, "[]"); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); await AuditTreeHelpers.RunTreeAsync(client, id, "table", output); Assert.Contains("11111111-1111-1111-1111-111111111111", handler.LastRequestUri); Assert.Contains("executionId", handler.LastRequestUri); } // ───────────────────────────────────────────────────────────────────── // CLI parsing — audit tree subcommand // ───────────────────────────────────────────────────────────────────── [Fact] public void Tree_Subcommand_ExistsInAuditCommandGroup() { var root = AuditCommandTestHarness.BuildRoot(); var parse = root.Parse(new[] { "audit", "tree", "--help" }); // --help is never an error, exit 0. Assert.Empty(parse.Errors); } [Fact] public void Tree_ExecutionIdOption_IsRequired() { // Invoking without --execution-id must produce an error (the option is Required). var root = AuditCommandTestHarness.BuildRoot(); var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "tree"); // System.CommandLine returns non-zero for a missing required option. Assert.NotEqual(0, exit); } [Fact] public void Tree_HelpText_DescribesExecutionId() { var root = AuditCommandTestHarness.BuildRoot(); var output = new StringWriter(); var exit = root.Parse(new[] { "audit", "tree", "--help" }) .Invoke(new InvocationConfiguration { Output = output }); Assert.Equal(0, exit); Assert.Contains("execution-id", output.ToString()); } }