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 backfill-source-node subcommand /// (Audit Log #23 M5.6 T5): argument parsing, request-body construction, /// HTTP wiring, and CLI scaffold. /// [Collection("Console")] public class AuditBackfillCommandTests { // ───────────────────────────────────────────────────────────────────── // BuildRequestBody // ───────────────────────────────────────────────────────────────────── [Fact] public void BuildRequestBody_DefaultArgs_ContainsExpectedFields() { var args = new AuditBackfillSourceNodeArgs { Sentinel = "unknown", Before = "2026-01-01T00:00:00Z", BatchSize = 5000, }; var body = AuditBackfillHelpers.BuildRequestBody(args); using var doc = JsonDocument.Parse(body); var root = doc.RootElement; Assert.Equal("unknown", root.GetProperty("sentinel").GetString()); Assert.Equal("2026-01-01T00:00:00Z", root.GetProperty("before").GetString()); Assert.Equal(5000, root.GetProperty("batchSize").GetInt32()); } [Fact] public void BuildRequestBody_CustomSentinelAndBatch_ReflectedInJson() { var args = new AuditBackfillSourceNodeArgs { Sentinel = "pre-feature", Before = "2026-06-01T00:00:00Z", BatchSize = 1000, }; var body = AuditBackfillHelpers.BuildRequestBody(args); using var doc = JsonDocument.Parse(body); var root = doc.RootElement; Assert.Equal("pre-feature", root.GetProperty("sentinel").GetString()); Assert.Equal("2026-06-01T00:00:00Z", root.GetProperty("before").GetString()); Assert.Equal(1000, root.GetProperty("batchSize").GetInt32()); } // ───────────────────────────────────────────────────────────────────── // RunBackfillAsync — HTTP execution // ───────────────────────────────────────────────────────────────────── private sealed class CapturingHandler : HttpMessageHandler { private readonly HttpStatusCode _status; private readonly string _responseBody; public CapturingHandler(HttpStatusCode status, string responseBody) { _status = status; _responseBody = responseBody; } public string? LastRequestUri { get; private set; } public string? LastRequestBody { get; private set; } public string? LastMethod { get; private set; } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { LastRequestUri = request.RequestUri!.PathAndQuery; LastMethod = request.Method.Method; if (request.Content != null) { LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); } return new HttpResponseMessage(_status) { Content = new StringContent(_responseBody, Encoding.UTF8, "application/json"), }; } } private static string SuccessBody(long rowsUpdated = 42, string sentinel = "unknown", string before = "2026-01-01T00:00:00.0000000Z") => JsonSerializer.Serialize(new { rowsUpdated, sentinel, before }); [Fact] public async Task RunBackfill_Success_ReturnsZeroAndWritesOutput() { var handler = new CapturingHandler(HttpStatusCode.OK, SuccessBody(rowsUpdated: 42)); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); var args = new AuditBackfillSourceNodeArgs { Sentinel = "unknown", Before = "2026-01-01T00:00:00Z", BatchSize = 5000, }; var exit = await AuditBackfillHelpers.RunBackfillAsync(client, args, output); Assert.Equal(0, exit); var text = output.ToString(); Assert.Contains("42", text); Assert.Contains("backfill complete", text, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task RunBackfill_RequestUri_ContainsBackfillPath() { var handler = new CapturingHandler(HttpStatusCode.OK, SuccessBody()); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); await AuditBackfillHelpers.RunBackfillAsync( client, new AuditBackfillSourceNodeArgs { Sentinel = "unknown", Before = "2026-01-01T00:00:00Z" }, output); Assert.Contains("backfill-source-node", handler.LastRequestUri); Assert.Equal("POST", handler.LastMethod); } [Fact] public async Task RunBackfill_RequestBody_ContainsSentinelAndBefore() { var handler = new CapturingHandler(HttpStatusCode.OK, SuccessBody()); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); await AuditBackfillHelpers.RunBackfillAsync( client, new AuditBackfillSourceNodeArgs { Sentinel = "pre-feature", Before = "2026-01-01T00:00:00Z", BatchSize = 2000, }, output); Assert.NotNull(handler.LastRequestBody); using var doc = JsonDocument.Parse(handler.LastRequestBody!); Assert.Equal("pre-feature", doc.RootElement.GetProperty("sentinel").GetString()); Assert.Equal("2026-01-01T00:00:00Z", doc.RootElement.GetProperty("before").GetString()); Assert.Equal(2000, doc.RootElement.GetProperty("batchSize").GetInt32()); } [Fact] public async Task RunBackfill_Http403_ReturnsExitCode2() { var handler = new CapturingHandler(HttpStatusCode.Forbidden, "{\"error\":\"Permission required.\",\"code\":\"UNAUTHORIZED\"}"); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); var exit = await AuditBackfillHelpers.RunBackfillAsync( client, new AuditBackfillSourceNodeArgs { Sentinel = "unknown", Before = "2026-01-01T00:00:00Z" }, output); Assert.Equal(2, exit); } [Fact] public async Task RunBackfill_Http500_ReturnsExitCode1() { var handler = new CapturingHandler(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 AuditBackfillHelpers.RunBackfillAsync( client, new AuditBackfillSourceNodeArgs { Sentinel = "unknown", Before = "2026-01-01T00:00:00Z" }, output); Assert.Equal(1, exit); } // ───────────────────────────────────────────────────────────────────── // CLI parsing // ───────────────────────────────────────────────────────────────────── [Fact] public void BackfillSourceNode_Subcommand_ExistsInAuditCommandGroup() { var root = AuditCommandTestHarness.BuildRoot(); var parse = root.Parse(new[] { "audit", "backfill-source-node", "--help" }); Assert.Empty(parse.Errors); } [Fact] public void BackfillSourceNode_BeforeOption_IsRequired() { var root = AuditCommandTestHarness.BuildRoot(); var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "backfill-source-node"); Assert.NotEqual(0, exit); } [Fact] public void BackfillSourceNode_HelpText_DescribesSentinelAndBefore() { var root = AuditCommandTestHarness.BuildRoot(); var output = new StringWriter(); var exit = root.Parse(new[] { "audit", "backfill-source-node", "--help" }) .Invoke(new InvocationConfiguration { Output = output }); Assert.Equal(0, exit); var text = output.ToString(); Assert.Contains("sentinel", text, StringComparison.OrdinalIgnoreCase); Assert.Contains("before", text, StringComparison.OrdinalIgnoreCase); } [Fact] public void BackfillSourceNode_DefaultSentinel_IsUnknown() { // Verify the default sentinel value is "unknown" as documented. var url = new Option("--url") { Recursive = true }; var username = new Option("--username") { Recursive = true }; var password = new Option("--password") { Recursive = true }; var format = CliOptions.CreateFormatOption(); var auditGroup = AuditCommands.Build(url, format, username, password); var backfillCmd = auditGroup.Subcommands .FirstOrDefault(c => c.Name == "backfill-source-node"); Assert.NotNull(backfillCmd); // The subcommand exists and its description mentions maintenance/sentinel. Assert.False(string.IsNullOrWhiteSpace(backfillCmd!.Description)); } }