docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
@@ -8,9 +8,8 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge audit backfill-source-node</c> subcommand
|
||||
/// (Audit Log #23 M5.6 T5): argument parsing, request-body construction,
|
||||
/// HTTP wiring, and CLI scaffold.
|
||||
/// Tests for the <c>scadabridge audit backfill-source-node</c> subcommand:
|
||||
/// argument parsing, request-body construction, HTTP wiring, and CLI scaffold.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class AuditBackfillCommandTests
|
||||
@@ -19,6 +18,7 @@ public class AuditBackfillCommandTests
|
||||
// BuildRequestBody
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that building a request body from default arguments contains the expected sentinel, before and batch size fields.</summary>
|
||||
[Fact]
|
||||
public void BuildRequestBody_DefaultArgs_ContainsExpectedFields()
|
||||
{
|
||||
@@ -38,6 +38,7 @@ public class AuditBackfillCommandTests
|
||||
Assert.Equal(5000, root.GetProperty("batchSize").GetInt32());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a custom sentinel and batch size are reflected in the request body JSON.</summary>
|
||||
[Fact]
|
||||
public void BuildRequestBody_CustomSentinelAndBatch_ReflectedInJson()
|
||||
{
|
||||
@@ -66,16 +67,23 @@ public class AuditBackfillCommandTests
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _responseBody;
|
||||
|
||||
/// <summary>Initializes a new handler that returns the given status and body for every request.</summary>
|
||||
/// <param name="status">The HTTP status code to return.</param>
|
||||
/// <param name="responseBody">The response body to return.</param>
|
||||
public CapturingHandler(HttpStatusCode status, string responseBody)
|
||||
{
|
||||
_status = status;
|
||||
_responseBody = responseBody;
|
||||
}
|
||||
|
||||
/// <summary>Gets the path and query of the last request received.</summary>
|
||||
public string? LastRequestUri { get; private set; }
|
||||
/// <summary>Gets the body of the last request received.</summary>
|
||||
public string? LastRequestBody { get; private set; }
|
||||
/// <summary>Gets the HTTP method of the last request received.</summary>
|
||||
public string? LastMethod { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -95,6 +103,8 @@ public class AuditBackfillCommandTests
|
||||
private static string SuccessBody(long rowsUpdated = 42, string sentinel = "unknown", string before = "2026-01-01T00:00:00.0000000Z")
|
||||
=> JsonSerializer.Serialize(new { rowsUpdated, sentinel, before });
|
||||
|
||||
/// <summary>Verifies that a successful backfill returns exit code zero and writes the response to the output.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunBackfill_Success_ReturnsZeroAndWritesOutput()
|
||||
{
|
||||
@@ -117,6 +127,8 @@ public class AuditBackfillCommandTests
|
||||
Assert.Contains("backfill complete", text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the backfill request is sent to the backfill endpoint path.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunBackfill_RequestUri_ContainsBackfillPath()
|
||||
{
|
||||
@@ -133,6 +145,8 @@ public class AuditBackfillCommandTests
|
||||
Assert.Equal("POST", handler.LastMethod);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the backfill request body contains the sentinel and the before timestamp.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunBackfill_RequestBody_ContainsSentinelAndBefore()
|
||||
{
|
||||
@@ -157,6 +171,8 @@ public class AuditBackfillCommandTests
|
||||
Assert.Equal(2000, doc.RootElement.GetProperty("batchSize").GetInt32());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an HTTP 403 response yields exit code 2.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunBackfill_Http403_ReturnsExitCode2()
|
||||
{
|
||||
@@ -173,6 +189,8 @@ public class AuditBackfillCommandTests
|
||||
Assert.Equal(2, exit);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an HTTP 500 response yields exit code 1.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunBackfill_Http500_ReturnsExitCode1()
|
||||
{
|
||||
@@ -193,6 +211,7 @@ public class AuditBackfillCommandTests
|
||||
// CLI parsing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the backfill-source-node subcommand exists in the audit command group.</summary>
|
||||
[Fact]
|
||||
public void BackfillSourceNode_Subcommand_ExistsInAuditCommandGroup()
|
||||
{
|
||||
@@ -201,6 +220,7 @@ public class AuditBackfillCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the before option is required on the backfill-source-node subcommand.</summary>
|
||||
[Fact]
|
||||
public void BackfillSourceNode_BeforeOption_IsRequired()
|
||||
{
|
||||
@@ -209,6 +229,7 @@ public class AuditBackfillCommandTests
|
||||
Assert.NotEqual(0, exit);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the help text describes the sentinel and before options.</summary>
|
||||
[Fact]
|
||||
public void BackfillSourceNode_HelpText_DescribesSentinelAndBefore()
|
||||
{
|
||||
@@ -223,6 +244,7 @@ public class AuditBackfillCommandTests
|
||||
Assert.Contains("before", text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the default sentinel value is unknown.</summary>
|
||||
[Fact]
|
||||
public void BackfillSourceNode_DefaultSentinel_IsUnknown()
|
||||
{
|
||||
|
||||
@@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
/// </summary>
|
||||
internal static class AuditCommandTestHarness
|
||||
{
|
||||
/// <summary>Builds a root command with the global options and the audit command tree attached.</summary>
|
||||
/// <returns>The configured root command.</returns>
|
||||
public static RootCommand BuildRoot()
|
||||
{
|
||||
var url = new Option<string>("--url") { Recursive = true };
|
||||
@@ -33,6 +35,9 @@ internal static class AuditCommandTestHarness
|
||||
/// the returned <c>Out</c>/<c>Err</c> strings. Callers must be in the <c>Console</c>
|
||||
/// xUnit collection so the global <see cref="Console"/> redirect is not racy.
|
||||
/// </summary>
|
||||
/// <param name="root">The root command to parse against.</param>
|
||||
/// <param name="args">The command-line arguments to parse and invoke.</param>
|
||||
/// <returns>The exit code and the captured standard output and error text.</returns>
|
||||
public static (int Exit, string Out, string Err) Invoke(RootCommand root, params string[] args)
|
||||
{
|
||||
var output = new StringWriter();
|
||||
|
||||
@@ -4,9 +4,9 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for the <c>scadabridge audit</c> command group (Audit Log #23 M8-T1).
|
||||
/// Scaffold tests for the <c>scadabridge audit</c> command group (Audit Log).
|
||||
/// Verifies the parent command exists with its subcommands and that every leaf
|
||||
/// has an action wired. Updated for M5.6 T5 to cover <c>backfill-source-node</c>.
|
||||
/// has an action wired. Also covers <c>backfill-source-node</c>.
|
||||
/// </summary>
|
||||
public class AuditCommandsScaffoldTests
|
||||
{
|
||||
@@ -18,6 +18,7 @@ public class AuditCommandsScaffoldTests
|
||||
private static Command BuildAudit()
|
||||
=> AuditCommands.Build(Url, Format, Username, Password);
|
||||
|
||||
/// <summary>Verifies that the command group is named <c>audit</c>.</summary>
|
||||
[Fact]
|
||||
public void Audit_Command_IsNamedAudit()
|
||||
{
|
||||
@@ -26,6 +27,7 @@ public class AuditCommandsScaffoldTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(audit.Description));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit group exposes the query, export, tree, verify-chain and backfill-source-node subcommands.</summary>
|
||||
[Fact]
|
||||
public void Audit_HasFiveSubcommands_QueryExportTreeVerifyChainBackfillSourceNode()
|
||||
{
|
||||
@@ -36,6 +38,7 @@ public class AuditCommandsScaffoldTests
|
||||
names);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit help text lists every subcommand.</summary>
|
||||
[Fact]
|
||||
public void Audit_HelpText_ListsAllSubcommands()
|
||||
{
|
||||
@@ -55,6 +58,7 @@ public class AuditCommandsScaffoldTests
|
||||
Assert.Contains("backfill-source-node", text);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that every leaf command in the audit group has an action wired.</summary>
|
||||
[Fact]
|
||||
public void Audit_EveryLeafCommand_HasAnAction()
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>audit-log</c> → <c>audit-config</c> rename (Audit Log #23 M8-T7):
|
||||
/// Tests for the <c>audit-log</c> → <c>audit-config</c> rename:
|
||||
/// the new name parses, the deprecated <c>audit-log</c> alias still resolves the full
|
||||
/// subcommand tree and emits a stderr deprecation warning, and the renamed group does
|
||||
/// not collide with the distinct <c>audit</c> group from Bundle A.
|
||||
@@ -28,6 +28,7 @@ public class AuditConfigDeprecationTests
|
||||
return root;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit-config query command works.</summary>
|
||||
[Fact]
|
||||
public void AuditConfig_Query_Works()
|
||||
{
|
||||
@@ -37,6 +38,7 @@ public class AuditConfigDeprecationTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the legacy audit-log query command still works but emits a deprecation warning to stderr.</summary>
|
||||
[Fact]
|
||||
public void AuditLog_Query_StillWorks_ButEmitsDeprecationWarning_ToStderr()
|
||||
{
|
||||
@@ -54,6 +56,7 @@ public class AuditConfigDeprecationTests
|
||||
Assert.Contains("audit-config", warning);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no deprecation warning is emitted when the new command name is used.</summary>
|
||||
[Fact]
|
||||
public void DeprecationWarning_NotEmitted_ForNewName()
|
||||
{
|
||||
@@ -64,6 +67,7 @@ public class AuditConfigDeprecationTests
|
||||
Assert.Equal("", stderr.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no deprecation warning is emitted for an unrelated command.</summary>
|
||||
[Fact]
|
||||
public void DeprecationWarning_NotEmitted_ForUnrelatedCommand()
|
||||
{
|
||||
@@ -73,6 +77,7 @@ public class AuditConfigDeprecationTests
|
||||
Assert.Equal("", stderr.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit and audit-config groups are distinct commands that do not conflict.</summary>
|
||||
[Fact]
|
||||
public void Audit_And_AuditConfig_AreDistinctCommands_NoConflict()
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge audit export</c> subcommand (Audit Log #23 M8-T3):
|
||||
/// Tests for the <c>scadabridge audit export</c> subcommand (Audit Log):
|
||||
/// required-flag enforcement, query-string construction, streaming the response body
|
||||
/// to the output file, and the parquet-not-implemented (501) path.
|
||||
/// </summary>
|
||||
@@ -17,6 +17,7 @@ public class AuditExportCommandTests
|
||||
{
|
||||
// ---- CLI parsing: required flags --------------------------------------
|
||||
|
||||
/// <summary>Verifies that omitting a required flag produces a parse error and a non-zero exit code.</summary>
|
||||
[Fact]
|
||||
public void Export_MissingRequiredFlag_ProducesParseErrorAndNonZeroExit()
|
||||
{
|
||||
@@ -28,6 +29,7 @@ public class AuditExportCommandTests
|
||||
Assert.Contains("--output", err);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the command parses without error when all required flags are present.</summary>
|
||||
[Fact]
|
||||
public void Export_AllRequiredFlagsPresent_ParsesWithoutError()
|
||||
{
|
||||
@@ -40,6 +42,7 @@ public class AuditExportCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an invalid format value is not rejected at the parse layer.</summary>
|
||||
[Fact]
|
||||
public void Export_InvalidFormat_IsNotRejectedAtTheParseLayer()
|
||||
{
|
||||
@@ -56,6 +59,8 @@ public class AuditExportCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the format value is accepted in any letter case.</summary>
|
||||
/// <param name="format">The format value to parse, in varying case.</param>
|
||||
[Theory]
|
||||
[InlineData("CSV")]
|
||||
[InlineData("Csv")]
|
||||
@@ -73,6 +78,7 @@ public class AuditExportCommandTests
|
||||
|
||||
// ---- Query string -----------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the query string includes the time window, the format and the filters.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_IncludesWindowFormatAndFilters()
|
||||
{
|
||||
@@ -96,6 +102,7 @@ public class AuditExportCommandTests
|
||||
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multi-value filters emit one query key per value.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue()
|
||||
{
|
||||
@@ -120,6 +127,7 @@ public class AuditExportCommandTests
|
||||
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multi-value filters that were not set are omitted from the query string.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_OmitsUnsetMultiValueFilters()
|
||||
{
|
||||
@@ -140,6 +148,7 @@ public class AuditExportCommandTests
|
||||
Assert.Null(parsed["sourceSiteId"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multiple channel values supplied in a single token are accepted.</summary>
|
||||
[Fact]
|
||||
public void Export_MultipleChannelValues_SingleToken_AreAccepted()
|
||||
{
|
||||
@@ -154,6 +163,7 @@ public class AuditExportCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multiple channel values supplied through a repeated flag are accepted.</summary>
|
||||
[Fact]
|
||||
public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted()
|
||||
{
|
||||
@@ -167,6 +177,7 @@ public class AuditExportCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a multi-value channel option still parses when one name is unrecognised.</summary>
|
||||
[Fact]
|
||||
public void Export_MultiValueChannel_WithOneUnrecognisedName_StillParses()
|
||||
{
|
||||
@@ -188,14 +199,19 @@ public class AuditExportCommandTests
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly Func<HttpContent> _content;
|
||||
/// <summary>Gets the path and query string of the last request the handler received.</summary>
|
||||
public string? RequestPathAndQuery { get; private set; }
|
||||
|
||||
/// <summary>Initializes a handler that answers every request with the given status and content.</summary>
|
||||
/// <param name="status">The HTTP status code to return.</param>
|
||||
/// <param name="content">A factory that creates the response content.</param>
|
||||
public BodyHandler(HttpStatusCode status, Func<HttpContent> content)
|
||||
{
|
||||
_status = status;
|
||||
_content = content;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -204,6 +220,8 @@ public class AuditExportCommandTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful export streams the response body to the output file.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunExport_Success_StreamsResponseToOutputFile()
|
||||
{
|
||||
@@ -234,11 +252,13 @@ public class AuditExportCommandTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an HTTP 403 response returns exit code 2.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunExport_Http403_ReturnsExitCode2()
|
||||
{
|
||||
// CLI-018: an HTTP 403 on /api/audit/export must produce exit code 2 per the
|
||||
// documented CLI contract — the legacy bare-1 return masked auth failures
|
||||
// An HTTP 403 on /api/audit/export must produce exit code 2 per the
|
||||
// documented CLI contract — the legacy plain exit code 1 masked auth failures
|
||||
// as generic command failures.
|
||||
var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv");
|
||||
try
|
||||
@@ -262,6 +282,8 @@ public class AuditExportCommandTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unauthorized error code on a response whose status is not 403 also returns exit code 2.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunExport_UnauthorizedCodeOnNon403_ReturnsExitCode2()
|
||||
{
|
||||
@@ -286,6 +308,8 @@ public class AuditExportCommandTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a parquet 501 response prints the server message and returns a non-zero exit code.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero()
|
||||
{
|
||||
@@ -312,6 +336,8 @@ public class AuditExportCommandTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a large response body is streamed to the file rather than fully buffered.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunExport_LargeBody_IsStreamedNotFullyBuffered()
|
||||
{
|
||||
@@ -351,18 +377,27 @@ public class AuditExportCommandTests
|
||||
private readonly byte _value;
|
||||
private long _remaining;
|
||||
|
||||
/// <summary>Initializes a stream that yields the given byte repeatedly.</summary>
|
||||
/// <param name="value">The byte value to repeat.</param>
|
||||
/// <param name="length">The total number of bytes the stream yields.</param>
|
||||
public RepeatingStream(byte value, long length)
|
||||
{
|
||||
_value = value;
|
||||
_remaining = length;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRead => true;
|
||||
/// <inheritdoc />
|
||||
public override bool CanSeek => false;
|
||||
/// <inheritdoc />
|
||||
public override bool CanWrite => false;
|
||||
/// <inheritdoc />
|
||||
public override long Length => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (_remaining <= 0) return 0;
|
||||
@@ -372,14 +407,18 @@ public class AuditExportCommandTests
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Flush() { }
|
||||
/// <inheritdoc />
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Deadline + Ctrl-C cancellation (arch-review 2026-08-19 P6-8: audit export
|
||||
// Deadline + Ctrl-C cancellation (audit export
|
||||
// was the one CLI HTTP path with NO deadline — a hung server hung the CLI
|
||||
// forever). Every test below bounds its own wait well under what the old,
|
||||
// deadline-less implementation would have done (indefinite hang) — a
|
||||
@@ -389,8 +428,10 @@ public class AuditExportCommandTests
|
||||
/// <summary>A handler that never completes the request until its <see cref="CancellationToken"/> fires — simulates a server that accepted the connection but never answers.</summary>
|
||||
private sealed class HangingHandler : HttpMessageHandler
|
||||
{
|
||||
/// <summary>Gets the number of requests the handler has received.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -410,17 +451,26 @@ public class AuditExportCommandTests
|
||||
private readonly byte[] _firstChunk;
|
||||
private bool _servedFirstChunk;
|
||||
|
||||
/// <summary>Initializes a stream that serves the given first chunk and then stalls.</summary>
|
||||
/// <param name="firstChunk">The bytes served before the stream goes silent.</param>
|
||||
public StallingStream(byte[] firstChunk) => _firstChunk = firstChunk;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRead => true;
|
||||
/// <inheritdoc />
|
||||
public override bool CanSeek => false;
|
||||
/// <inheritdoc />
|
||||
public override bool CanWrite => false;
|
||||
/// <inheritdoc />
|
||||
public override long Length => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(byte[] buffer, int offset, int count) =>
|
||||
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_servedFirstChunk)
|
||||
@@ -435,9 +485,13 @@ public class AuditExportCommandTests
|
||||
return 0; // unreachable — Task.Delay(Infinite) only returns via cancellation
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Flush() { }
|
||||
/// <inheritdoc />
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -447,17 +501,26 @@ public class AuditExportCommandTests
|
||||
private readonly byte[] _firstChunk;
|
||||
private bool _servedFirstChunk;
|
||||
|
||||
/// <summary>Initializes a stream that serves the given first chunk and then throws.</summary>
|
||||
/// <param name="firstChunk">The bytes served before the stream faults.</param>
|
||||
public FaultingStream(byte[] firstChunk) => _firstChunk = firstChunk;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRead => true;
|
||||
/// <inheritdoc />
|
||||
public override bool CanSeek => false;
|
||||
/// <inheritdoc />
|
||||
public override bool CanWrite => false;
|
||||
/// <inheritdoc />
|
||||
public override long Length => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(byte[] buffer, int offset, int count) =>
|
||||
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_servedFirstChunk)
|
||||
@@ -471,9 +534,13 @@ public class AuditExportCommandTests
|
||||
throw new IOException("connection reset by peer (simulated)");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Flush() { }
|
||||
/// <inheritdoc />
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -494,6 +561,8 @@ public class AuditExportCommandTests
|
||||
return capture.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a server that never sends response headers causes a timeout with a friendly error message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunExport_ServerNeverSendsHeaders_TimesOutWithFriendlyError()
|
||||
{
|
||||
@@ -521,6 +590,8 @@ public class AuditExportCommandTests
|
||||
Assert.False(File.Exists(path));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the inactivity timeout fires when the stream stalls mid-transfer.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunExport_StreamStallsMidTransfer_InactivityTimeoutFires()
|
||||
{
|
||||
@@ -562,6 +633,8 @@ public class AuditExportCommandTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an I/O exception during streaming produces a clean message rather than a stack trace.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunExport_MidStreamIOException_ProducesCleanMessageNotStackTrace()
|
||||
{
|
||||
@@ -596,6 +669,8 @@ public class AuditExportCommandTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Ctrl-C cancellation token cancels the export promptly with a clean message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunExport_CtrlCToken_CancelsPromptlyWithCleanMessage()
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge audit query</c> subcommand (Audit Log #23 M8-T2):
|
||||
/// Tests for the <c>scadabridge audit query</c> subcommand:
|
||||
/// time-spec resolution, query-string construction, formatter selection, error
|
||||
/// handling, and keyset-cursor paging via <c>--all</c>.
|
||||
/// </summary>
|
||||
@@ -17,6 +17,7 @@ public class AuditQueryCommandTests
|
||||
{
|
||||
// ---- Time-spec parsing -------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a relative hours time spec resolves to the current time minus that offset.</summary>
|
||||
[Fact]
|
||||
public void ResolveTimeSpec_RelativeHours_ResolvesToNowMinusOffset()
|
||||
{
|
||||
@@ -25,6 +26,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-20T11:00:00Z"), resolved);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a relative days time spec resolves to the current time minus that offset.</summary>
|
||||
[Fact]
|
||||
public void ResolveTimeSpec_RelativeDays_ResolvesToNowMinusOffset()
|
||||
{
|
||||
@@ -33,6 +35,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-13T12:00:00Z"), resolved);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an absolute ISO 8601 time spec is parsed verbatim.</summary>
|
||||
[Fact]
|
||||
public void ResolveTimeSpec_AbsoluteIso8601_ParsedVerbatim()
|
||||
{
|
||||
@@ -41,6 +44,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-01-02T03:04:05Z"), resolved);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unparseable time spec throws.</summary>
|
||||
[Fact]
|
||||
public void ResolveTimeSpec_Garbage_Throws()
|
||||
{
|
||||
@@ -50,6 +54,7 @@ public class AuditQueryCommandTests
|
||||
|
||||
// ---- Query string construction ----------------------------------------
|
||||
|
||||
/// <summary>Verifies that a fully populated flag set produces the expected query-string parameters.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_FullFlagSet_ProducesExpectedParameters()
|
||||
{
|
||||
@@ -90,6 +95,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the errors-only flag maps to the failed status filter.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_ErrorsOnly_MapsToFailedStatus()
|
||||
{
|
||||
@@ -100,6 +106,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal("Failed", parsed["status"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a multi-value channel filter emits one query key per value.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue()
|
||||
{
|
||||
@@ -119,6 +126,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the errors-only flag overrides any explicit status values.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues()
|
||||
{
|
||||
@@ -137,6 +145,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal(new[] { "Failed" }, parsed.GetValues("status"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the paging cursor is appended after the filter parameters.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_Cursor_AppendsAfterParameters()
|
||||
{
|
||||
@@ -149,6 +158,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal("2026-05-20T10:00:00.0000000+00:00", parsed["afterOccurredAtUtc"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unset filters are omitted from the query string.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_OmitsUnsetFilters()
|
||||
{
|
||||
@@ -165,6 +175,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal("100", parsed["pageSize"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an execution id filter emits the execution id parameter.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter()
|
||||
{
|
||||
@@ -176,6 +187,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a parent execution id filter emits the parent execution id parameter.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter()
|
||||
{
|
||||
@@ -192,13 +204,17 @@ public class AuditQueryCommandTests
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<string> _bodies;
|
||||
/// <summary>The request URIs received by the handler, in order.</summary>
|
||||
public List<string> RequestUris { get; } = new();
|
||||
|
||||
/// <summary>Creates a handler that replies with the given JSON bodies in sequence.</summary>
|
||||
/// <param name="bodies">The response bodies to return, one per request.</param>
|
||||
public RecordingHandler(params string[] bodies)
|
||||
{
|
||||
_bodies = new Queue<string>(bodies);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -211,6 +227,8 @@ public class AuditQueryCommandTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a single page of results is written as JSON lines.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunQuery_SinglePage_WritesEventsAsJsonLines()
|
||||
{
|
||||
@@ -231,6 +249,8 @@ public class AuditQueryCommandTests
|
||||
Assert.Single(handler.RequestUris);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the all flag follows the next cursor across pages.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunQuery_WithAll_FollowsNextCursorAcrossPages()
|
||||
{
|
||||
@@ -251,6 +271,8 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal(2, lines.Length);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that without the all flag only the first page is fetched even when a cursor is present.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunQuery_WithoutAll_StopsAfterFirstPageEvenWhenCursorPresent()
|
||||
{
|
||||
@@ -267,6 +289,8 @@ public class AuditQueryCommandTests
|
||||
Assert.Single(handler.RequestUris);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a server error returns a non-zero exit code.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunQuery_ServerError_ReturnsNonZeroExit()
|
||||
{
|
||||
@@ -281,12 +305,14 @@ public class AuditQueryCommandTests
|
||||
Assert.NotEqual(0, exit);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an HTTP 403 response returns exit code 2.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunQuery_Http403_ReturnsExitCode2()
|
||||
{
|
||||
// CLI-018: an authorization failure on /api/audit/query (HTTP 403) must
|
||||
// An authorization failure on /api/audit/query (HTTP 403) must
|
||||
// produce exit code 2 per the documented CLI exit-code contract — the
|
||||
// legacy bare-1 return masked auth failures as generic command failures.
|
||||
// legacy bare exit code 1 return masked auth failures as generic command failures.
|
||||
var handler = new StatusHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
@@ -298,11 +324,13 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal(2, exit);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an UNAUTHORIZED error code on a status other than 403 returns exit code 2.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunQuery_UnauthorizedCodeOnNon403_ReturnsExitCode2()
|
||||
{
|
||||
// The server may signal authorization failure via the error code on a
|
||||
// non-403 status (e.g. 400 + code=UNAUTHORIZED). Honour both channels.
|
||||
// status other than 403 (e.g. 400 + code=UNAUTHORIZED). Honour both channels.
|
||||
var handler = new StatusHandler(HttpStatusCode.BadRequest, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
@@ -314,10 +342,12 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal(2, exit);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a generic server error returns exit code 1.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunQuery_GenericServerError_ReturnsExitCode1()
|
||||
{
|
||||
// Authentication / internal errors (non-403, no auth code) must remain
|
||||
// Authentication / internal errors (status other than 403, no auth code) must remain
|
||||
// exit code 1 — exit 2 is reserved for authorization failures.
|
||||
var handler = new StatusHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
@@ -332,6 +362,7 @@ public class AuditQueryCommandTests
|
||||
|
||||
private sealed class ErrorHandler : HttpMessageHandler
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
@@ -344,7 +375,11 @@ public class AuditQueryCommandTests
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
/// <summary>Creates a handler that always replies with the given status and body.</summary>
|
||||
/// <param name="status">The HTTP status code to return.</param>
|
||||
/// <param name="body">The response body to return.</param>
|
||||
public StatusHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
|
||||
@@ -352,6 +387,7 @@ public class AuditQueryCommandTests
|
||||
|
||||
// ---- CLI parsing -------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that an unknown flag produces a parse error and a non-zero exit code.</summary>
|
||||
[Fact]
|
||||
public void Query_UnknownFlag_ProducesParseErrorAndNonZeroExit()
|
||||
{
|
||||
@@ -361,6 +397,7 @@ public class AuditQueryCommandTests
|
||||
Assert.NotEqual("", err);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the table output format is accepted.</summary>
|
||||
[Fact]
|
||||
public void Query_FormatTable_IsAccepted()
|
||||
{
|
||||
@@ -369,6 +406,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the execution id option is accepted.</summary>
|
||||
[Fact]
|
||||
public void Query_ExecutionIdOption_IsAccepted()
|
||||
{
|
||||
@@ -381,6 +419,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the parent execution id option is accepted.</summary>
|
||||
[Fact]
|
||||
public void Query_ParentExecutionIdOption_IsAccepted()
|
||||
{
|
||||
@@ -395,6 +434,7 @@ public class AuditQueryCommandTests
|
||||
|
||||
// ---- Enum-name validation (fast-fail) ----------------------------------
|
||||
|
||||
/// <summary>Verifies that a channel option using a real enum name is accepted.</summary>
|
||||
[Fact]
|
||||
public void Query_ChannelWithRealEnumName_IsAccepted()
|
||||
{
|
||||
@@ -403,6 +443,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multiple channel values in a single token are accepted.</summary>
|
||||
[Fact]
|
||||
public void Query_MultipleChannelValues_SingleToken_AreAccepted()
|
||||
{
|
||||
@@ -412,6 +453,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multiple channel values given by repeating the flag are accepted.</summary>
|
||||
[Fact]
|
||||
public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted()
|
||||
{
|
||||
@@ -438,6 +480,9 @@ public class AuditQueryCommandTests
|
||||
// carried through to the request rather than rejected locally.
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>Verifies that an unrecognised filter value is not rejected at the parse layer.</summary>
|
||||
/// <param name="option">The filter option name.</param>
|
||||
/// <param name="value">The filter value to pass.</param>
|
||||
[Theory]
|
||||
[InlineData("--channel", "OutboundApi")] // old (non-existent) name; the real enum is "ApiOutbound"
|
||||
[InlineData("--kind", "CachedCall")]
|
||||
@@ -449,6 +494,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a multi-value channel filter containing one unrecognised name still parses.</summary>
|
||||
[Fact]
|
||||
public void Query_MultiValueChannel_WithOneUnrecognisedName_StillParses()
|
||||
{
|
||||
@@ -459,6 +505,9 @@ public class AuditQueryCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a filter value in any letter case is accepted.</summary>
|
||||
/// <param name="option">The filter option name.</param>
|
||||
/// <param name="value">The filter value to pass.</param>
|
||||
[Theory]
|
||||
[InlineData("--channel", "apioutbound")]
|
||||
[InlineData("--kind", "apicall")]
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>table</c> output formatter of the <c>scadabridge audit query</c>
|
||||
/// subcommand (Audit Log #23 M8-T6): header rendering, long-field truncation, the
|
||||
/// subcommand: header rendering, long-field truncation, the
|
||||
/// empty-result-set case, and null-actor handling.
|
||||
/// </summary>
|
||||
public class AuditTableFormatterTests
|
||||
@@ -18,6 +18,7 @@ public class AuditTableFormatterTests
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the table renders a header row with the expected columns.</summary>
|
||||
[Fact]
|
||||
public void Table_RendersHeaderRow_WithExpectedColumns()
|
||||
{
|
||||
@@ -38,6 +39,7 @@ public class AuditTableFormatterTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a long target is truncated with an ellipsis.</summary>
|
||||
[Fact]
|
||||
public void Table_TruncatesLongTarget_WithEllipsis()
|
||||
{
|
||||
@@ -57,6 +59,7 @@ public class AuditTableFormatterTests
|
||||
Assert.DoesNotContain(longTarget, text);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty result set renders only the header or a no-rows message.</summary>
|
||||
[Fact]
|
||||
public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage()
|
||||
{
|
||||
@@ -73,6 +76,7 @@ public class AuditTableFormatterTests
|
||||
Assert.Contains("OccurredAtUtc", lines[0]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null actor renders as a blank cell.</summary>
|
||||
[Fact]
|
||||
public void Table_NullActor_RendersBlank()
|
||||
{
|
||||
@@ -93,6 +97,7 @@ public class AuditTableFormatterTests
|
||||
Assert.Contains("InboundApi", lines[1]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the header is emitted once per page and the data rows are aligned.</summary>
|
||||
[Fact]
|
||||
public void Table_HeaderEmittedOncePerPage_DataRowsAligned()
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge audit tree</c> subcommand (Audit Log #23 M5.1-T8):
|
||||
/// Tests for the <c>scadabridge audit tree</c> subcommand:
|
||||
/// tree rendering (table format), JSON output, error handling, and CLI parsing.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
@@ -58,6 +58,7 @@ public class AuditTreeCommandTests
|
||||
// ParseNodes
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that parsing a valid JSON array returns the matching node DTOs.</summary>
|
||||
[Fact]
|
||||
public void ParseNodes_ValidArray_ReturnsDtos()
|
||||
{
|
||||
@@ -75,6 +76,7 @@ public class AuditTreeCommandTests
|
||||
Assert.Equal(3, nodes[0].RowCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing an empty JSON array returns an empty list.</summary>
|
||||
[Fact]
|
||||
public void ParseNodes_EmptyArray_ReturnsEmpty()
|
||||
{
|
||||
@@ -82,6 +84,7 @@ public class AuditTreeCommandTests
|
||||
Assert.Empty(nodes);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing invalid JSON returns an empty list.</summary>
|
||||
[Fact]
|
||||
public void ParseNodes_InvalidJson_ReturnsEmpty()
|
||||
{
|
||||
@@ -93,6 +96,7 @@ public class AuditTreeCommandTests
|
||||
// WriteTable — ASCII tree rendering
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that writing a table for no nodes prints the fallback message.</summary>
|
||||
[Fact]
|
||||
public void WriteTable_EmptyNodes_PrintsFallbackMessage()
|
||||
{
|
||||
@@ -101,6 +105,7 @@ public class AuditTreeCommandTests
|
||||
Assert.Contains("no execution tree found", output.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing a table for a single root node prints it with no indent.</summary>
|
||||
[Fact]
|
||||
public void WriteTable_SingleRootNode_PrintsWithNoIndent()
|
||||
{
|
||||
@@ -117,6 +122,7 @@ public class AuditTreeCommandTests
|
||||
Assert.Contains("[*]", line); // queried node marked
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing a table for a multi-level tree indents each child level correctly.</summary>
|
||||
[Fact]
|
||||
public void WriteTable_MultiLevelTree_IndentsChildrenCorrectly()
|
||||
{
|
||||
@@ -144,6 +150,7 @@ public class AuditTreeCommandTests
|
||||
Assert.StartsWith(" ", grandLine);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing a table marks the queried node and leaves the others unmarked.</summary>
|
||||
[Fact]
|
||||
public void WriteTable_QueriedNodeIsMarked_OthersAreNot()
|
||||
{
|
||||
@@ -167,6 +174,7 @@ public class AuditTreeCommandTests
|
||||
// WriteJson
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that writing JSON for valid nodes emits a valid JSON array.</summary>
|
||||
[Fact]
|
||||
public void WriteJson_ValidNodes_EmitsValidJsonArray()
|
||||
{
|
||||
@@ -183,6 +191,7 @@ public class AuditTreeCommandTests
|
||||
Assert.Equal(2, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing JSON for no nodes emits an empty array.</summary>
|
||||
[Fact]
|
||||
public void WriteJson_EmptyNodes_EmitsEmptyArray()
|
||||
{
|
||||
@@ -204,14 +213,19 @@ public class AuditTreeCommandTests
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
|
||||
/// <summary>Initializes a new instance of the <c>FixedHandler</c> class.</summary>
|
||||
/// <param name="status">The status.</param>
|
||||
/// <param name="body">The body.</param>
|
||||
public FixedHandler(HttpStatusCode status, string body)
|
||||
{
|
||||
_status = status;
|
||||
_body = body;
|
||||
}
|
||||
|
||||
/// <summary>Gets the last request uri value.</summary>
|
||||
public string? LastRequestUri { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -223,6 +237,8 @@ public class AuditTreeCommandTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful tree run returns exit code zero and writes the output.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunTree_Success_ReturnsZeroAndWritesOutput()
|
||||
{
|
||||
@@ -239,6 +255,8 @@ public class AuditTreeCommandTests
|
||||
Assert.Contains(rootId, output.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a tree run with an empty response returns exit code zero and prints the fallback message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunTree_EmptyResponse_ReturnsZeroWithFallbackMessage()
|
||||
{
|
||||
@@ -253,6 +271,8 @@ public class AuditTreeCommandTests
|
||||
Assert.Contains("no execution tree found", output.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a tree run with the JSON format emits valid JSON.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunTree_JsonFormat_EmitsValidJson()
|
||||
{
|
||||
@@ -269,6 +289,8 @@ public class AuditTreeCommandTests
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a tree run that receives HTTP 403 returns exit code 2.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunTree_Http403_ReturnsExitCode2()
|
||||
{
|
||||
@@ -282,6 +304,8 @@ public class AuditTreeCommandTests
|
||||
Assert.Equal(2, exit);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a tree run that receives HTTP 500 returns exit code 1.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunTree_Http500_ReturnsExitCode1()
|
||||
{
|
||||
@@ -295,6 +319,8 @@ public class AuditTreeCommandTests
|
||||
Assert.Equal(1, exit);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tree request URL contains the execution id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunTree_RequestUrlContainsExecutionId()
|
||||
{
|
||||
@@ -313,6 +339,7 @@ public class AuditTreeCommandTests
|
||||
// CLI parsing — audit tree subcommand
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the tree subcommand exists in the audit command group.</summary>
|
||||
[Fact]
|
||||
public void Tree_Subcommand_ExistsInAuditCommandGroup()
|
||||
{
|
||||
@@ -322,6 +349,7 @@ public class AuditTreeCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tree execution id option is required.</summary>
|
||||
[Fact]
|
||||
public void Tree_ExecutionIdOption_IsRequired()
|
||||
{
|
||||
@@ -332,6 +360,7 @@ public class AuditTreeCommandTests
|
||||
Assert.NotEqual(0, exit);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tree help text describes the execution id.</summary>
|
||||
[Fact]
|
||||
public void Tree_HelpText_DescribesExecutionId()
|
||||
{
|
||||
|
||||
@@ -3,13 +3,14 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge audit verify-chain</c> subcommand (Audit Log #23 M8-T4).
|
||||
/// Tests for the <c>scadabridge audit verify-chain</c> subcommand.
|
||||
/// v1 is a no-op stub: a valid <c>--month</c> prints the documented "not enabled"
|
||||
/// message and exits 0; a malformed month or a missing <c>--month</c> exits non-zero.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class AuditVerifyChainCommandTests
|
||||
{
|
||||
/// <summary>Verifies that verifying the chain for a valid month exits with zero and prints the documented message.</summary>
|
||||
[Fact]
|
||||
public void VerifyChain_ValidMonth_ExitsZeroWithDocumentedMessage()
|
||||
{
|
||||
@@ -22,6 +23,7 @@ public class AuditVerifyChainCommandTests
|
||||
Assert.Contains("Component-AuditLog.md", output);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a malformed month value makes the command exit with a non-zero code.</summary>
|
||||
[Fact]
|
||||
public void VerifyChain_MalformedMonth_ExitsNonZero()
|
||||
{
|
||||
@@ -32,6 +34,7 @@ public class AuditVerifyChainCommandTests
|
||||
Assert.NotEqual(0, exit);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that omitting the month option produces the required-option error.</summary>
|
||||
[Fact]
|
||||
public void VerifyChain_MissingMonth_ProducesRequiredFlagError()
|
||||
{
|
||||
@@ -42,6 +45,9 @@ public class AuditVerifyChainCommandTests
|
||||
Assert.Contains("--month", err);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that month validation accepts only values in <c>yyyy-MM</c> format.</summary>
|
||||
/// <param name="month">The month string to validate.</param>
|
||||
/// <param name="expected">Whether the month is expected to be valid.</param>
|
||||
[Theory]
|
||||
[InlineData("2026-05", true)]
|
||||
[InlineData("2026-01", true)]
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI-019 regression tests for <see cref="BundleCommands.StreamBase64ToFile"/>.
|
||||
/// Regression tests for <see cref="BundleCommands.StreamBase64ToFile"/>.
|
||||
/// The pre-fix code did <c>Convert.FromBase64String(...) → File.WriteAllBytes(...)</c>,
|
||||
/// doubling the bundle's bytes onto the LOH and writing synchronously. The new
|
||||
/// streaming helper decodes the base64 string in fixed-size chunks straight into
|
||||
@@ -15,11 +15,13 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
{
|
||||
private readonly string _tempPath;
|
||||
|
||||
/// <summary>Creates a unique temporary output path for each test.</summary>
|
||||
public BundleCommandsStreamingTests()
|
||||
{
|
||||
_tempPath = Path.Combine(Path.GetTempPath(), $"bundle-stream-test-{Guid.NewGuid():N}.bin");
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources used by this instance.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_tempPath))
|
||||
@@ -28,6 +30,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a small payload is written to the file and read back unchanged.</summary>
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_SmallPayload_RoundTrips()
|
||||
{
|
||||
@@ -41,6 +44,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Equal(bytes, roundTripped);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a payload spanning several chunks, with a short final chunk, round-trips unchanged.</summary>
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_PayloadCrossesChunkBoundary_RoundTrips()
|
||||
{
|
||||
@@ -60,6 +64,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Equal(bytes, roundTripped);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty base64 string produces an empty file.</summary>
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_EmptyString_WritesEmptyFile()
|
||||
{
|
||||
@@ -70,6 +75,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Empty(File.ReadAllBytes(_tempPath));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalid base64 text throws a <see cref="FormatException"/>.</summary>
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_InvalidBase64_ThrowsFormatException()
|
||||
{
|
||||
@@ -81,20 +87,23 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Throws<FormatException>(() => BundleCommands.StreamBase64ToFile(invalid, _tempPath));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null base64 string throws an <see cref="ArgumentNullException"/>.</summary>
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_NullBase64_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => BundleCommands.StreamBase64ToFile(null!, _tempPath));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty output path throws an <see cref="ArgumentException"/>.</summary>
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_EmptyOutputPath_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty));
|
||||
}
|
||||
|
||||
// ---- M8 (B4): --sites / --instances comma-split + flag parsing ----------
|
||||
// ---- --sites / --instances comma-split + flag parsing ----------
|
||||
|
||||
/// <summary>Verifies that a comma-separated list is split and each name is trimmed.</summary>
|
||||
[Fact]
|
||||
public void ParseNameList_CommaSeparated_SplitsAndTrims()
|
||||
{
|
||||
@@ -103,6 +112,8 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Equal(new[] { "North-01", "East-02", "West-03" }, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null or blank token yields a null list.</summary>
|
||||
/// <param name="token">The raw option value to parse.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -112,6 +123,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Null(BundleCommands.ParseNameList(token));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that empty and whitespace-only entries are dropped from the list.</summary>
|
||||
[Fact]
|
||||
public void ParseNameList_EmptyEntries_AreDropped()
|
||||
{
|
||||
@@ -119,6 +131,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Equal(new[] { "a", "b", "c" }, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the export command accepts the sites and instances flags without parse errors.</summary>
|
||||
[Fact]
|
||||
public void BundleExport_SitesAndInstancesFlags_ParseWithoutError()
|
||||
{
|
||||
@@ -138,14 +151,16 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
// ---- M8 (D3): --map-site / --map-connection spec parsing ----------------
|
||||
// ---- --map-site / --map-connection spec parsing ----------------
|
||||
|
||||
/// <summary>Verifies that a null mapping list yields no site mappings.</summary>
|
||||
[Fact]
|
||||
public void ParseSiteMappings_Null_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(BundleCommands.ParseSiteMappings(null));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a source=target spec maps the source site to an existing target site.</summary>
|
||||
[Fact]
|
||||
public void ParseSiteMappings_SrcEqualsDst_MapsToExisting()
|
||||
{
|
||||
@@ -155,6 +170,8 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Equal("PLANT-A", spec.TargetSiteIdentifier);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a spec with no target means the site should be created new.</summary>
|
||||
/// <param name="token">The raw site mapping spec.</param>
|
||||
[Theory]
|
||||
[InlineData("NORTH-01")] // no '=' part -> create-new
|
||||
[InlineData("NORTH-01=")] // empty rhs -> create-new
|
||||
@@ -167,6 +184,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Null(spec.TargetSiteIdentifier);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that repeated mapping specs accumulate into one list.</summary>
|
||||
[Fact]
|
||||
public void ParseSiteMappings_Repeated_AccumulatesAll()
|
||||
{
|
||||
@@ -176,12 +194,14 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Null(specs[1].TargetSiteIdentifier);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a spec with an empty source throws a <see cref="FormatException"/>.</summary>
|
||||
[Fact]
|
||||
public void ParseSiteMappings_EmptySource_Throws()
|
||||
{
|
||||
Assert.Throws<FormatException>(() => BundleCommands.ParseSiteMappings(new[] { "=PLANT-A" }));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site/name=target spec maps the source connection to an existing target connection.</summary>
|
||||
[Fact]
|
||||
public void ParseConnectionMappings_SrcEqualsDst_MapsToExisting()
|
||||
{
|
||||
@@ -192,6 +212,8 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Equal("OpcLive", spec.TargetConnectionName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a connection spec with no target means the connection should be created new.</summary>
|
||||
/// <param name="token">The raw connection mapping spec.</param>
|
||||
[Theory]
|
||||
[InlineData("NORTH-01/OpcA")] // no '=' part -> create-new
|
||||
[InlineData("NORTH-01/OpcA=")] // empty rhs -> create-new
|
||||
@@ -204,6 +226,8 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Null(spec.TargetConnectionName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a connection spec with a malformed source throws a <see cref="FormatException"/>.</summary>
|
||||
/// <param name="token">The malformed connection mapping spec.</param>
|
||||
[Theory]
|
||||
[InlineData("OpcA")] // missing site/name shape
|
||||
[InlineData("NORTH-01/")] // empty connection name
|
||||
@@ -213,6 +237,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Throws<FormatException>(() => BundleCommands.ParseConnectionMappings(new[] { token }));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the import command accepts the map and create-missing flags without parse errors.</summary>
|
||||
[Fact]
|
||||
public void BundleImport_MapAndCreateMissingFlags_ParseWithoutError()
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ public class CachedCallCommandsTests
|
||||
|
||||
// ── retry ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the retry command with a site id and message id produces the expected management command.</summary>
|
||||
[Fact]
|
||||
public void Retry_WithSiteIdAndMessageId_ProducesCorrectCommand()
|
||||
{
|
||||
@@ -37,6 +38,7 @@ public class CachedCallCommandsTests
|
||||
Assert.Equal("11111111-2222-3333-4444-555555555555", cmd.MessageId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the retry command requires both the site id and the tracked operation id.</summary>
|
||||
[Fact]
|
||||
public void Retry_SiteIdAndTrackedOperationId_AreRequired()
|
||||
{
|
||||
@@ -50,6 +52,7 @@ public class CachedCallCommandsTests
|
||||
Assert.True(opId.Required, "--tracked-operation-id must be required.");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the retry command reports parse errors when its options are missing.</summary>
|
||||
[Fact]
|
||||
public void Retry_MissingOptions_ProducesParseErrors()
|
||||
{
|
||||
@@ -63,6 +66,7 @@ public class CachedCallCommandsTests
|
||||
|
||||
// ── discard ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the discard command with a site id and message id produces the expected management command.</summary>
|
||||
[Fact]
|
||||
public void Discard_WithSiteIdAndMessageId_ProducesCorrectCommand()
|
||||
{
|
||||
@@ -79,6 +83,7 @@ public class CachedCallCommandsTests
|
||||
Assert.Equal("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", cmd.MessageId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the discard command requires both the site id and the tracked operation id.</summary>
|
||||
[Fact]
|
||||
public void Discard_SiteIdAndTrackedOperationId_AreRequired()
|
||||
{
|
||||
@@ -92,6 +97,7 @@ public class CachedCallCommandsTests
|
||||
Assert.True(opId.Required, "--tracked-operation-id must be required.");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the discard command reports parse errors when its options are missing.</summary>
|
||||
[Fact]
|
||||
public void Discard_MissingOptions_ProducesParseErrors()
|
||||
{
|
||||
@@ -105,6 +111,7 @@ public class CachedCallCommandsTests
|
||||
|
||||
// ── group shape ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the cached-call command group contains the retry and discard subcommands.</summary>
|
||||
[Fact]
|
||||
public void CachedCallGroup_HasRetryAndDiscard()
|
||||
{
|
||||
@@ -115,6 +122,7 @@ public class CachedCallCommandsTests
|
||||
Assert.Contains("discard", subNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the cached-call command group is named cached-call.</summary>
|
||||
[Fact]
|
||||
public void CachedCallGroup_Name_IsCachedCall()
|
||||
{
|
||||
@@ -124,6 +132,7 @@ public class CachedCallCommandsTests
|
||||
|
||||
// ── registry round-trip ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the retry-parked-message command resolves through the command registry.</summary>
|
||||
[Fact]
|
||||
public void RetryParkedMessageCommand_ResolvesViaRegistry()
|
||||
{
|
||||
@@ -132,6 +141,7 @@ public class CachedCallCommandsTests
|
||||
Assert.Equal(typeof(RetryParkedMessageCommand), ManagementCommandRegistry.Resolve(name));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the discard-parked-message command resolves through the command registry.</summary>
|
||||
[Fact]
|
||||
public void DiscardParkedMessageCommand_ResolvesViaRegistry()
|
||||
{
|
||||
|
||||
@@ -26,6 +26,8 @@ public class ExternalSystemRetryOptionTests
|
||||
private static Option? FindOption(Command command, string name)
|
||||
=> command.Options.SingleOrDefault(o => o.Name == name);
|
||||
|
||||
/// <summary>Verifies that the create and update subcommands expose the optional retry-policy options.</summary>
|
||||
/// <param name="subcommand">The name of the subcommand under test.</param>
|
||||
[Theory]
|
||||
[InlineData("create")]
|
||||
[InlineData("update")]
|
||||
@@ -42,6 +44,7 @@ public class ExternalSystemRetryOptionTests
|
||||
Assert.False(retryDelay!.Required);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the create subcommand parses the supplied retry-policy values.</summary>
|
||||
[Fact]
|
||||
public void Create_ParsesRetryPolicyValues()
|
||||
{
|
||||
@@ -56,6 +59,7 @@ public class ExternalSystemRetryOptionTests
|
||||
Assert.Equal(60, parse.GetValue<int?>("--retry-delay-seconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the update subcommand parses the supplied retry-policy values.</summary>
|
||||
[Fact]
|
||||
public void Update_ParsesRetryPolicyValues()
|
||||
{
|
||||
@@ -70,6 +74,7 @@ public class ExternalSystemRetryOptionTests
|
||||
Assert.Equal(10, parse.GetValue<int?>("--retry-delay-seconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the retry-delay option alias is accepted by the parser.</summary>
|
||||
[Fact]
|
||||
public void RetryDelayAliasIsAccepted()
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// PLAN-06 Task 11: <c>external-system create</c> and <c>external-system update</c>
|
||||
/// <c>external-system create</c> and <c>external-system update</c>
|
||||
/// must expose an optional <c>--timeout-seconds</c> (<see cref="int"/>?) flag that
|
||||
/// carries the per-system ESG HTTP timeout. Omitting it preserves the existing value
|
||||
/// (update) / uses the gateway default (create).
|
||||
@@ -21,6 +21,7 @@ public class ExternalSystemTimeoutOptionTests
|
||||
private static Option? FindOption(Command command, string name)
|
||||
=> command.Options.SingleOrDefault(o => o.Name == name);
|
||||
|
||||
/// <summary>Verifies that the create command exposes an optional timeout-seconds option.</summary>
|
||||
[Fact]
|
||||
public void Create_HasOptionalTimeoutSecondsOption()
|
||||
{
|
||||
@@ -30,6 +31,7 @@ public class ExternalSystemTimeoutOptionTests
|
||||
Assert.False(option!.Required);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the update command exposes an optional timeout-seconds option.</summary>
|
||||
[Fact]
|
||||
public void Update_HasOptionalTimeoutSecondsOption()
|
||||
{
|
||||
@@ -39,6 +41,7 @@ public class ExternalSystemTimeoutOptionTests
|
||||
Assert.False(option!.Required);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the create command parses a supplied timeout-seconds value.</summary>
|
||||
[Fact]
|
||||
public void Create_ParsesTimeoutSecondsValue()
|
||||
{
|
||||
@@ -52,6 +55,7 @@ public class ExternalSystemTimeoutOptionTests
|
||||
Assert.Equal(15, parse.GetValue<int?>("--timeout-seconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the update command parses a supplied timeout-seconds value.</summary>
|
||||
[Fact]
|
||||
public void Update_ParsesTimeoutSecondsValue()
|
||||
{
|
||||
@@ -65,6 +69,7 @@ public class ExternalSystemTimeoutOptionTests
|
||||
Assert.Equal(20, parse.GetValue<int?>("--timeout-seconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that omitting timeout-seconds on create parses without error.</summary>
|
||||
[Fact]
|
||||
public void Create_OmittedTimeoutSeconds_ParsesWithoutError()
|
||||
{
|
||||
|
||||
@@ -40,6 +40,7 @@ public class NotificationSmsCommandTests
|
||||
|
||||
// ---- list create/update: --type + --phones ----------------------------
|
||||
|
||||
/// <summary>Verifies that creating an SMS list with phones builds an SMS command carrying the recipient phones.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
|
||||
{
|
||||
@@ -55,6 +56,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Empty(cmd.RecipientEmails);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the list type option is matched case-insensitively.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_TypeIsCaseInsensitive()
|
||||
{
|
||||
@@ -66,6 +68,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Equal(NotificationType.Sms, cmd.Type);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a list created without a type defaults to email.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_DefaultType_IsEmail()
|
||||
{
|
||||
@@ -79,6 +82,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Null(cmd.RecipientPhones);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an email list given phone numbers is rejected.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_EmailTypeWithPhones_Throws()
|
||||
{
|
||||
@@ -90,6 +94,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Contains("--phones", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS list with no phone numbers is rejected.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_SmsTypeWithoutPhones_Throws()
|
||||
{
|
||||
@@ -100,6 +105,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Contains("--phones", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an email list with no email addresses is rejected.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_EmailTypeWithoutEmails_Throws()
|
||||
{
|
||||
@@ -110,6 +116,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Contains("--emails", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS list given email addresses is rejected.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_SmsTypeWithEmails_Throws()
|
||||
{
|
||||
@@ -121,6 +128,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Contains("--emails", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown list type is rejected.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_UnknownType_Throws()
|
||||
{
|
||||
@@ -131,6 +139,7 @@ public class NotificationSmsCommandTests
|
||||
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating an SMS list with phones builds an SMS command carrying the recipient phones.</summary>
|
||||
[Fact]
|
||||
public void ListUpdate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
|
||||
{
|
||||
@@ -146,6 +155,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Empty(cmd.RecipientEmails);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating an email list with phone numbers is rejected.</summary>
|
||||
[Fact]
|
||||
public void ListUpdate_EmailTypeWithPhones_Throws()
|
||||
{
|
||||
@@ -160,6 +170,7 @@ public class NotificationSmsCommandTests
|
||||
|
||||
// ---- notification sms group -------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the notification command has an SMS group with list and update subcommands.</summary>
|
||||
[Fact]
|
||||
public void Notification_HasSmsGroupWithListAndUpdate()
|
||||
{
|
||||
@@ -168,6 +179,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Contains("update", subNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS update with every flag produces a command carrying all of them.</summary>
|
||||
[Fact]
|
||||
public void SmsUpdate_WithAllFlags_ProducesCommandCarryingThem()
|
||||
{
|
||||
@@ -187,6 +199,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Equal("new-secret", cmd.AuthToken);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that omitting the optional flags produces a command with null values for them.</summary>
|
||||
[Fact]
|
||||
public void SmsUpdate_WithoutOptionalFlags_ProducesCommandWithNulls()
|
||||
{
|
||||
@@ -204,6 +217,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Null(cmd.AuthToken);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the optional SMS update flags are not marked as required.</summary>
|
||||
[Fact]
|
||||
public void SmsUpdate_OptionalFlags_AreNotRequired()
|
||||
{
|
||||
@@ -214,6 +228,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.False(update.Options.Single(o => o.Name == "--auth-token").Required);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS update with only a messaging service SID and no from number builds successfully.</summary>
|
||||
[Fact]
|
||||
public void SmsUpdate_MessagingServiceSidOnly_NoFromNumber_Builds()
|
||||
{
|
||||
@@ -229,6 +244,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Equal("MGonly", cmd.MessagingServiceSid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS update with neither a from number nor a messaging service SID is rejected.</summary>
|
||||
[Fact]
|
||||
public void SmsUpdate_NeitherFromNorMessagingService_Throws()
|
||||
{
|
||||
@@ -239,6 +255,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Throws<ArgumentException>(() => NotificationCommands.BuildUpdateSmsConfigCommand(parse));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that both SMS commands resolve through the management command registry.</summary>
|
||||
[Fact]
|
||||
public void SmsCommands_ResolveViaRegistry()
|
||||
{
|
||||
@@ -262,6 +279,7 @@ public class NotificationSmsCommandTests
|
||||
[Collection("Console")]
|
||||
public class NotificationSmsAuthTokenRenderingTests
|
||||
{
|
||||
/// <summary>Verifies that a rendered SMS configuration list never contains the auth token value.</summary>
|
||||
[Fact]
|
||||
public void SmsConfigListResponse_RenderedAsJson_DoesNotContainAuthToken()
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// PLAN-07 Task 17 (arch-review P3): <c>secured-write list</c> must expose <c>--skip</c>
|
||||
/// <c>secured-write list</c> must expose <c>--skip</c>
|
||||
/// / <c>--take</c> offset-paging options alongside the <c>--status</c> / <c>--site</c>
|
||||
/// filters, and map them onto <see cref="Commons.Messages.Management.ListSecuredWritesCommand"/>.
|
||||
/// Omitting the paging flags preserves the historical unpaged default (skip 0, take 200).
|
||||
@@ -23,6 +23,7 @@ public class SecuredWriteListCommandTests
|
||||
private static Option? FindOption(Command command, string name)
|
||||
=> command.Options.SingleOrDefault(o => o.Name == name);
|
||||
|
||||
/// <summary>Verifies that the list command exposes the skip and take options.</summary>
|
||||
[Fact]
|
||||
public void List_HasSkipAndTakeOptions()
|
||||
{
|
||||
@@ -31,6 +32,7 @@ public class SecuredWriteListCommandTests
|
||||
Assert.NotNull(FindOption(list, "--take"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the list command parses skip, take and filters and forwards them to the command.</summary>
|
||||
[Fact]
|
||||
public void List_ParsesSkipTakeAndFilters_ForwardsToCommand()
|
||||
{
|
||||
@@ -47,6 +49,7 @@ public class SecuredWriteListCommandTests
|
||||
Assert.Equal(25, command.Take);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that omitting the paging options uses the unpaged defaults.</summary>
|
||||
[Fact]
|
||||
public void List_OmittedPaging_UsesUnpagedDefaults()
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ public class SmtpUpdateCommandTests
|
||||
private static ParseResult ParseUpdate(params string[] args)
|
||||
=> SmtpUpdateCommand().Parse(args);
|
||||
|
||||
/// <summary>Verifies that an update with a TLS mode and credentials produces a command carrying both.</summary>
|
||||
[Fact]
|
||||
public void Update_WithTlsModeAndCredentials_ProducesCommandCarryingThem()
|
||||
{
|
||||
@@ -49,6 +50,7 @@ public class SmtpUpdateCommandTests
|
||||
Assert.Equal("user:pass", cmd.Credentials);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an update without a TLS mode or credentials produces a command with null values for them.</summary>
|
||||
[Fact]
|
||||
public void Update_WithoutTlsModeAndCredentials_ProducesCommandWithNulls()
|
||||
{
|
||||
@@ -64,6 +66,8 @@ public class SmtpUpdateCommandTests
|
||||
Assert.Null(cmd.Credentials);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the TLS mode option accepts each canonical value.</summary>
|
||||
/// <param name="value">The TLS mode value supplied on the command line.</param>
|
||||
[Theory]
|
||||
[InlineData("None")]
|
||||
[InlineData("StartTLS")]
|
||||
@@ -78,6 +82,8 @@ public class SmtpUpdateCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the TLS mode option rejects values outside the canonical set.</summary>
|
||||
/// <param name="value">The non-canonical TLS mode value supplied on the command line.</param>
|
||||
[Theory]
|
||||
[InlineData("Bogus")]
|
||||
[InlineData("tls")]
|
||||
@@ -92,6 +98,7 @@ public class SmtpUpdateCommandTests
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an update with the EWS transport produces a command carrying that transport.</summary>
|
||||
[Fact]
|
||||
public void Update_WithTransportEws_ProducesCommandCarryingIt()
|
||||
{
|
||||
@@ -106,6 +113,7 @@ public class SmtpUpdateCommandTests
|
||||
Assert.Equal("Ews", cmd.Transport);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an update without a transport produces a command with a null transport.</summary>
|
||||
[Fact]
|
||||
public void Update_WithoutTransport_ProducesCommandWithNullTransport()
|
||||
{
|
||||
@@ -119,6 +127,8 @@ public class SmtpUpdateCommandTests
|
||||
Assert.Null(cmd.Transport);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the transport option rejects values outside the canonical set.</summary>
|
||||
/// <param name="value">The non-canonical transport value supplied on the command line.</param>
|
||||
[Theory]
|
||||
[InlineData("Graph")]
|
||||
[InlineData("ews")] // AcceptOnlyFromAmong is case-sensitive: constrain to canonical spelling
|
||||
@@ -132,6 +142,7 @@ public class SmtpUpdateCommandTests
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the TLS mode and credentials options are not required.</summary>
|
||||
[Fact]
|
||||
public void Update_TlsModeAndCredentials_AreNotRequired()
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// MV-11: the <c>template attribute add</c> / <c>update</c> commands must support
|
||||
/// The <c>template attribute add</c> / <c>update</c> commands must support
|
||||
/// structured multi-value (List) attributes — a new <c>--element-type</c> option,
|
||||
/// a JSON-array <c>--value</c>, client-side element-type validation, and
|
||||
/// <see cref="AddTemplateAttributeCommand.ElementDataType"/> /
|
||||
@@ -25,6 +25,7 @@ public class TemplateAttributeListTests
|
||||
|
||||
// ---- option surface ----
|
||||
|
||||
/// <summary>Verifies that the attribute add command exposes an element-type option.</summary>
|
||||
[Fact]
|
||||
public void AttributeAdd_HasElementTypeOption()
|
||||
{
|
||||
@@ -32,6 +33,7 @@ public class TemplateAttributeListTests
|
||||
Assert.Contains("--element-type", add.Options.Select(o => o.Name));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the attribute update command exposes an element-type option.</summary>
|
||||
[Fact]
|
||||
public void AttributeUpdate_HasElementTypeOption()
|
||||
{
|
||||
@@ -41,6 +43,8 @@ public class TemplateAttributeListTests
|
||||
|
||||
// ---- client-side element-type validation (both directions) ----
|
||||
|
||||
/// <summary>Verifies that a List attribute with a valid scalar element type passes validation.</summary>
|
||||
/// <param name="elementType">The scalar element type name under test.</param>
|
||||
[Theory]
|
||||
[InlineData("String")]
|
||||
[InlineData("Int32")]
|
||||
@@ -55,6 +59,7 @@ public class TemplateAttributeListTests
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that element-type validation for a List attribute is case-insensitive.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithValidScalar_CaseInsensitive()
|
||||
{
|
||||
@@ -63,6 +68,7 @@ public class TemplateAttributeListTests
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a List attribute without an element type fails validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithoutElementType_Error()
|
||||
{
|
||||
@@ -71,6 +77,7 @@ public class TemplateAttributeListTests
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a List attribute with a blank element type fails validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithBlankElementType_Error()
|
||||
{
|
||||
@@ -79,6 +86,7 @@ public class TemplateAttributeListTests
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a List attribute with an invalid scalar element type fails validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithInvalidScalar_Error()
|
||||
{
|
||||
@@ -87,6 +95,7 @@ public class TemplateAttributeListTests
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a List attribute with a binary element type fails validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithBinaryScalar_Error()
|
||||
{
|
||||
@@ -96,6 +105,7 @@ public class TemplateAttributeListTests
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a scalar attribute with an element type fails validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ScalarWithElementType_Error()
|
||||
{
|
||||
@@ -104,6 +114,7 @@ public class TemplateAttributeListTests
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a scalar attribute without an element type passes validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ScalarWithoutElementType_Ok()
|
||||
{
|
||||
@@ -114,6 +125,7 @@ public class TemplateAttributeListTests
|
||||
|
||||
// ---- payload wiring: the raw JSON value + ElementDataType flow into the command ----
|
||||
|
||||
/// <summary>Verifies that building an add command for a List attribute carries the element type and the raw JSON value.</summary>
|
||||
[Fact]
|
||||
public void BuildAddCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
|
||||
{
|
||||
@@ -134,6 +146,7 @@ public class TemplateAttributeListTests
|
||||
Assert.Equal("""["WO-1","WO-2"]""", cmd.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that building an update command for a List attribute carries the element type and the raw JSON value.</summary>
|
||||
[Fact]
|
||||
public void BuildUpdateCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
|
||||
{
|
||||
@@ -153,6 +166,7 @@ public class TemplateAttributeListTests
|
||||
Assert.Equal("""["A","B"]""", cmd.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that building an add command for a scalar attribute leaves the element type null.</summary>
|
||||
[Fact]
|
||||
public void BuildAddCommand_ScalarAttribute_LeavesElementTypeNull()
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// #54: <c>template script add/update</c> must expose <c>--min-time-between-runs</c>
|
||||
/// <c>template script add/update</c> must expose <c>--min-time-between-runs</c>
|
||||
/// (a duration with ms/s/min units) and <c>--execution-timeout-seconds</c> (an int)
|
||||
/// so the per-script throttle/re-fire interval and the execution-timeout override —
|
||||
/// previously settable only via Transport bundle import — are CLI-authorable. These
|
||||
@@ -24,6 +24,7 @@ public class TemplateScriptTimingTests
|
||||
|
||||
// ---- option surface ----
|
||||
|
||||
/// <summary>Verifies that <c>template script add</c> exposes the timing options.</summary>
|
||||
[Fact]
|
||||
public void ScriptAdd_HasTimingOptions()
|
||||
{
|
||||
@@ -33,6 +34,7 @@ public class TemplateScriptTimingTests
|
||||
Assert.Contains("--execution-timeout-seconds", names);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that <c>template script update</c> exposes the timing options.</summary>
|
||||
[Fact]
|
||||
public void ScriptUpdate_HasTimingOptions()
|
||||
{
|
||||
@@ -44,6 +46,7 @@ public class TemplateScriptTimingTests
|
||||
|
||||
// ---- --min-time-between-runs parsing ----
|
||||
|
||||
/// <summary>Verifies that an absent minimum-time value parses as unset with no error.</summary>
|
||||
[Fact]
|
||||
public void MinTime_Absent_IsUnsetNoError()
|
||||
{
|
||||
@@ -52,6 +55,7 @@ public class TemplateScriptTimingTests
|
||||
Assert.Null(err);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a blank minimum-time value parses as unset with no error.</summary>
|
||||
[Fact]
|
||||
public void MinTime_Blank_IsUnsetNoError()
|
||||
{
|
||||
@@ -60,6 +64,9 @@ public class TemplateScriptTimingTests
|
||||
Assert.Null(err);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a millisecond duration string is parsed into the expected time span.</summary>
|
||||
/// <param name="value">The duration string with a millisecond suffix.</param>
|
||||
/// <param name="expectedMs">The expected number of milliseconds.</param>
|
||||
[Theory]
|
||||
[InlineData("500ms", 500)]
|
||||
[InlineData("250MS", 250)]
|
||||
@@ -70,6 +77,8 @@ public class TemplateScriptTimingTests
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(expectedMs), d);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a seconds value, including a bare number, is parsed as seconds.</summary>
|
||||
/// <param name="value">The duration string to parse.</param>
|
||||
[Theory]
|
||||
[InlineData("5")] // bare number → seconds
|
||||
[InlineData("5s")]
|
||||
@@ -82,6 +91,8 @@ public class TemplateScriptTimingTests
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), d);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a minutes duration string is parsed as minutes.</summary>
|
||||
/// <param name="value">The duration string with a minutes suffix.</param>
|
||||
[Theory]
|
||||
[InlineData("2m")]
|
||||
[InlineData("2min")]
|
||||
@@ -93,6 +104,8 @@ public class TemplateScriptTimingTests
|
||||
Assert.Equal(TimeSpan.FromMinutes(2), d);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero duration is treated as unset, meaning no throttle.</summary>
|
||||
/// <param name="value">A zero duration string.</param>
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("0s")]
|
||||
@@ -105,6 +118,8 @@ public class TemplateScriptTimingTests
|
||||
Assert.Null(d);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an invalid duration string is rejected with an error.</summary>
|
||||
/// <param name="value">The invalid duration string.</param>
|
||||
[Theory]
|
||||
[InlineData("abc")]
|
||||
[InlineData("5h")] // hours not supported
|
||||
@@ -122,6 +137,7 @@ public class TemplateScriptTimingTests
|
||||
/// An absurdly large value (16-digit millisecond count) would overflow long/TimeSpan
|
||||
/// without the overflow guard. The parser must return false cleanly — not throw.
|
||||
/// </summary>
|
||||
/// <param name="value">An absurdly large duration string in milliseconds, seconds or minutes.</param>
|
||||
[Theory]
|
||||
[InlineData("9999999999999999ms")] // 16-digit ms: far exceeds TimeSpan.MaxValue (~922337203685477ms)
|
||||
[InlineData("9999999999999999s")] // 16-digit seconds: even larger when scaled
|
||||
|
||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// #257: <c>--trigger-kind</c> is written into the trigger-config JSON by
|
||||
/// <c>--trigger-kind</c> is written into the trigger-config JSON by
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.CLI.TriggerConfigJson.InjectAnalysisKind"/>, which
|
||||
/// returns null (dropping the kind) when no <c>--trigger-config</c> is supplied. Passing
|
||||
/// <c>--trigger-kind</c> without <c>--trigger-config</c> was therefore a silent no-op.
|
||||
@@ -12,18 +12,21 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
/// </summary>
|
||||
public class TemplateTriggerKindIgnoredTests
|
||||
{
|
||||
/// <summary>Verifies that a trigger kind supplied without a trigger config is reported as ignored.</summary>
|
||||
[Fact]
|
||||
public void KindWithoutConfig_WillBeIgnored()
|
||||
{
|
||||
Assert.True(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: null, triggerKind: "strict"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a trigger kind supplied with a blank trigger config is reported as ignored.</summary>
|
||||
[Fact]
|
||||
public void KindWithBlankConfig_WillBeIgnored()
|
||||
{
|
||||
Assert.True(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: " ", triggerKind: "strict"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a trigger kind supplied with a trigger config is not reported as ignored.</summary>
|
||||
[Fact]
|
||||
public void KindWithConfig_NotIgnored()
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public class TemplateTriggerKindIgnoredTests
|
||||
triggerConfig: "{\"expression\":\"x > 0\"}", triggerKind: "strict"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that omitting the trigger kind is not reported as ignored.</summary>
|
||||
[Fact]
|
||||
public void NoKind_NotIgnored()
|
||||
{
|
||||
@@ -39,6 +43,7 @@ public class TemplateTriggerKindIgnoredTests
|
||||
Assert.False(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: null, triggerKind: " "));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a trigger config supplied without a trigger kind is not reported as ignored.</summary>
|
||||
[Fact]
|
||||
public void NoKindWithConfig_NotIgnored()
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// M9-T28b: <c>template alarm add/update</c> and <c>template script add/update</c>
|
||||
/// <c>template alarm add/update</c> and <c>template script add/update</c>
|
||||
/// must expose a <c>--trigger-kind</c> option (advisory|strict) that writes
|
||||
/// <c>"analysisKind":"Strict"</c> into the trigger-config JSON for expression
|
||||
/// triggers, matching the T28a backend contract exactly.
|
||||
@@ -17,6 +17,7 @@ public class TemplateTriggerKindTests
|
||||
// alarm-update / script-add / script-update ──────────────────────────────
|
||||
|
||||
// Inject strict into an Expression config that has no prior analysisKind
|
||||
/// <summary>Verifies that injecting the Strict analysis kind adds the analysis kind key to the trigger config.</summary>
|
||||
[Fact]
|
||||
public void InjectAnalysisKind_Strict_AddsKey()
|
||||
{
|
||||
@@ -29,6 +30,7 @@ public class TemplateTriggerKindTests
|
||||
}
|
||||
|
||||
// Inject advisory (null) into config that had Strict → key removed
|
||||
/// <summary>Verifies that injecting the Advisory analysis kind removes the analysis kind key from the trigger config.</summary>
|
||||
[Fact]
|
||||
public void InjectAnalysisKind_Advisory_RemovesKey()
|
||||
{
|
||||
@@ -41,6 +43,7 @@ public class TemplateTriggerKindTests
|
||||
}
|
||||
|
||||
// Inject strict into config that already had Strict → still Strict (idempotent)
|
||||
/// <summary>Verifies that injecting the Strict analysis kind twice yields the same config as injecting it once.</summary>
|
||||
[Fact]
|
||||
public void InjectAnalysisKind_Strict_Idempotent()
|
||||
{
|
||||
@@ -53,6 +56,7 @@ public class TemplateTriggerKindTests
|
||||
}
|
||||
|
||||
// Null config → returns null (no config to inject into)
|
||||
/// <summary>Verifies that injecting an analysis kind into a null config returns null.</summary>
|
||||
[Fact]
|
||||
public void InjectAnalysisKind_NullConfig_ReturnsNull()
|
||||
{
|
||||
@@ -61,6 +65,7 @@ public class TemplateTriggerKindTests
|
||||
}
|
||||
|
||||
// Case-insensitive: "STRICT" → canonical "Strict"
|
||||
/// <summary>Verifies that the Strict analysis kind is recognized regardless of letter casing.</summary>
|
||||
[Fact]
|
||||
public void InjectAnalysisKind_Strict_CaseInsensitive()
|
||||
{
|
||||
@@ -73,6 +78,7 @@ public class TemplateTriggerKindTests
|
||||
}
|
||||
|
||||
// Other keys preserved when injecting
|
||||
/// <summary>Verifies that injecting an analysis kind leaves the other trigger config keys unchanged.</summary>
|
||||
[Fact]
|
||||
public void InjectAnalysisKind_OtherKeysPreserved()
|
||||
{
|
||||
@@ -88,6 +94,7 @@ public class TemplateTriggerKindTests
|
||||
|
||||
// ── Option surface: alarm add + update have --trigger-kind ───────────────
|
||||
|
||||
/// <summary>Verifies that the alarm add command exposes a trigger-kind option.</summary>
|
||||
[Fact]
|
||||
public void AlarmAdd_HasTriggerKindOption()
|
||||
{
|
||||
@@ -95,6 +102,7 @@ public class TemplateTriggerKindTests
|
||||
Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the alarm update command exposes a trigger-kind option.</summary>
|
||||
[Fact]
|
||||
public void AlarmUpdate_HasTriggerKindOption()
|
||||
{
|
||||
@@ -104,6 +112,7 @@ public class TemplateTriggerKindTests
|
||||
|
||||
// ── Option surface: script add + update have --trigger-kind ─────────────
|
||||
|
||||
/// <summary>Verifies that the script add command exposes a trigger-kind option.</summary>
|
||||
[Fact]
|
||||
public void ScriptAdd_HasTriggerKindOption()
|
||||
{
|
||||
@@ -111,6 +120,7 @@ public class TemplateTriggerKindTests
|
||||
Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the script update command exposes a trigger-kind option.</summary>
|
||||
[Fact]
|
||||
public void ScriptUpdate_HasTriggerKindOption()
|
||||
{
|
||||
@@ -120,6 +130,7 @@ public class TemplateTriggerKindTests
|
||||
|
||||
// ── AlarmTriggerConfigJson.Build — analysisKind injection ────────────────
|
||||
|
||||
/// <summary>Verifies that building an expression alarm config with the Strict kind injects an analysis kind of Strict.</summary>
|
||||
[Fact]
|
||||
public void AlarmConfigBuild_Expression_StrictKind_InjectsAnalysisKindStrict()
|
||||
{
|
||||
@@ -139,6 +150,7 @@ public class TemplateTriggerKindTests
|
||||
Assert.Equal("Strict", doc.RootElement.GetProperty("analysisKind").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that building an expression alarm config with the Advisory kind omits the analysis kind key.</summary>
|
||||
[Fact]
|
||||
public void AlarmConfigBuild_Expression_AdvisoryKind_OmitsAnalysisKindKey()
|
||||
{
|
||||
@@ -158,6 +170,7 @@ public class TemplateTriggerKindTests
|
||||
Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that building an expression alarm config with no kind omits the analysis kind key.</summary>
|
||||
[Fact]
|
||||
public void AlarmConfigBuild_Expression_NullKind_OmitsAnalysisKindKey()
|
||||
{
|
||||
@@ -177,6 +190,7 @@ public class TemplateTriggerKindTests
|
||||
Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-expression alarm config does not emit an analysis kind even when the Strict kind is given.</summary>
|
||||
[Fact]
|
||||
public void AlarmConfigBuild_NonExpression_StrictKind_DoesNotEmitAnalysisKind()
|
||||
{
|
||||
@@ -196,6 +210,7 @@ public class TemplateTriggerKindTests
|
||||
Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Strict kind is recognized case-insensitively when building an expression alarm config.</summary>
|
||||
[Fact]
|
||||
public void AlarmConfigBuild_Expression_StrictKind_CaseInsensitive()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user