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:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -41,6 +41,7 @@ public class AuditConfigTableProjectionTests
totalCount),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
/// <summary>Verifies that projecting entries unwraps the envelope into rows.</summary>
[Fact]
public void ProjectEntries_UnwrapsTheEnvelopeIntoRows()
{
@@ -68,6 +69,7 @@ public class AuditConfigTableProjectionTests
Assert.Equal("MES", second.GetProperty("entityName").GetString());
}
/// <summary>Verifies that projecting entries drops the after-state JSON.</summary>
[Fact]
public void ProjectEntries_DropsAfterStateJson()
{
@@ -80,6 +82,7 @@ public class AuditConfigTableProjectionTests
Assert.DoesNotContain("afterStateJson", compact, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that a truncated page appends a note with the total count.</summary>
[Fact]
public void ProjectEntries_TruncatedPage_AppendsTotalCountNote()
{
@@ -96,6 +99,7 @@ public class AuditConfigTableProjectionTests
Assert.False(root[2].TryGetProperty("id", out _));
}
/// <summary>Verifies that a result set returned in full emits no note column.</summary>
[Fact]
public void ProjectEntries_WholeResultSet_EmitsNoNoteColumn()
{
@@ -108,6 +112,7 @@ public class AuditConfigTableProjectionTests
Assert.DoesNotContain("note", compact, StringComparison.Ordinal);
}
/// <summary>Verifies that an empty result stays an empty array.</summary>
[Fact]
public void ProjectEntries_EmptyResult_StaysAnEmptyArray()
{
@@ -124,6 +129,7 @@ public class AuditConfigTableProjectionTests
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
/// <summary>Verifies that a bare array is still projected.</summary>
[Fact]
public void ProjectEntries_BareArray_IsStillProjected()
{
@@ -143,6 +149,7 @@ public class AuditConfigTableProjectionTests
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
}
/// <summary>Verifies that an unrecognised JSON object is returned verbatim.</summary>
[Fact]
public void ProjectEntries_UnrecognisedObject_ReturnedVerbatim()
{
@@ -152,6 +159,7 @@ public class AuditConfigTableProjectionTests
Assert.Equal(other, AuditConfigTableProjection.ProjectEntries(other));
}
/// <summary>Verifies that non-JSON input is returned verbatim.</summary>
[Fact]
public void ProjectEntries_NonJson_ReturnedVerbatim()
{
@@ -159,6 +167,7 @@ public class AuditConfigTableProjectionTests
Assert.Equal(notJson, AuditConfigTableProjection.ProjectEntries(notJson));
}
/// <summary>Verifies that the projection is substantially smaller than the raw payload.</summary>
[Fact]
public void ProjectEntries_IsSubstantiallySmallerThanTheRawPayload()
{
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Environment")]
public class CliConfigTests
{
/// <summary>Verifies that loading configuration with no overrides defaults the output format to JSON.</summary>
[Fact]
public void Load_DefaultFormat_IsJson()
{
@@ -28,6 +29,7 @@ public class CliConfigTests
}
}
/// <summary>Verifies that the management URL is read from the environment.</summary>
[Fact]
public void Load_ManagementUrl_FromEnvironment()
{
@@ -46,6 +48,7 @@ public class CliConfigTests
}
}
/// <summary>Verifies that the output format is read from the environment.</summary>
[Fact]
public void Load_Format_FromEnvironment()
{
@@ -65,7 +68,7 @@ public class CliConfigTests
}
/// <summary>
/// CLI-021 regression: a malformed ~/.scadabridge/config.json must NOT abort the
/// Regression: a malformed ~/.scadabridge/config.json must NOT abort the
/// CLI before any command runs — Load() must warn (to stderr) and return a
/// usable default config so command-line overrides (--url, --username, etc.)
/// and env vars can still take effect.
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")]
public class CommandHelpersTests
{
/// <summary>Verifies that a successful response in JSON format returns exit code zero.</summary>
[Fact]
public void HandleResponse_Success_JsonFormat_ReturnsZero()
{
@@ -21,6 +22,7 @@ public class CommandHelpersTests
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}
/// <summary>Verifies that a successful response with array data in table format returns exit code zero.</summary>
[Fact]
public void HandleResponse_Success_TableFormat_ArrayData_ReturnsZero()
{
@@ -41,6 +43,7 @@ public class CommandHelpersTests
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}
/// <summary>Verifies that a successful response with object data in table format returns exit code zero.</summary>
[Fact]
public void HandleResponse_Success_TableFormat_ObjectData_ReturnsZero()
{
@@ -61,6 +64,7 @@ public class CommandHelpersTests
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}
/// <summary>Verifies that an empty array in table format reports that there are no results.</summary>
[Fact]
public void HandleResponse_Success_TableFormat_EmptyArray_ShowsNoResults()
{
@@ -76,6 +80,7 @@ public class CommandHelpersTests
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}
/// <summary>Verifies that an error response returns exit code one.</summary>
[Fact]
public void HandleResponse_Error_ReturnsOne()
{
@@ -91,6 +96,7 @@ public class CommandHelpersTests
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
}
/// <summary>Verifies that an unauthorized response returns exit code two.</summary>
[Fact]
public void HandleResponse_Unauthorized_ReturnsTwo()
{
@@ -106,6 +112,7 @@ public class CommandHelpersTests
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
}
/// <summary>Verifies that an authentication failure response returns exit code one.</summary>
[Fact]
public void HandleResponse_AuthFailure_ReturnsOne()
{
@@ -121,6 +128,7 @@ public class CommandHelpersTests
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
}
/// <summary>Verifies that a connection failure response returns exit code one.</summary>
[Fact]
public void HandleResponse_ConnectionFailure_ReturnsOne()
{
@@ -136,6 +144,7 @@ public class CommandHelpersTests
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
}
/// <summary>Verifies that a timeout response returns exit code one.</summary>
[Fact]
public void HandleResponse_Timeout_ReturnsOne()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Regression tests for CLI-013 — the command-tree wiring was untested. These tests
/// Regression tests for the command-tree wiring, which was previously untested. These tests
/// build every command group and assert the tree is well-formed (every leaf has an
/// action, no group is empty), and that every management command record the CLI sends
/// resolves via <see cref="ManagementCommandRegistry"/> (so command-name derivation
@@ -21,6 +21,7 @@ public class CommandTreeTests
// Exercise the production root: a separately maintained group list can drift.
private static IEnumerable<Command> AllCommandGroups() => CliApplication.BuildRoot().Subcommands;
/// <summary>Verifies that the registered operational command groups include the secured-writes and connection tools.</summary>
[Fact]
public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools()
{
@@ -47,6 +48,7 @@ public class CommandTreeTests
yield return leaf;
}
/// <summary>Verifies that every command group builds without throwing.</summary>
[Fact]
public void AllCommandGroups_Build_WithoutThrowing()
{
@@ -55,10 +57,11 @@ public class CommandTreeTests
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
}
/// <summary>Verifies that the set of command groups contains the audit and bundle groups.</summary>
[Fact]
public void AllCommandGroups_Contains_AuditAndBundle()
{
// CLI-022: explicit group-presence assertion so the harness does not
// Explicit group-presence assertion so the harness does not
// silently drift back to excluding new groups. Use names because that
// is what users actually type at the prompt.
var groupNames = AllCommandGroups().Select(g => g.Name).ToHashSet();
@@ -66,10 +69,11 @@ public class CommandTreeTests
Assert.Contains("bundle", groupNames);
}
/// <summary>Verifies that the audit command group exposes the query, export and verify-chain subcommands.</summary>
[Fact]
public void AuditCommandGroup_HasQueryExportAndVerifyChain()
{
// CLI-022: pin the audit sub-command surface so a rename / accidental
// Pin the audit sub-command surface so a rename / accidental
// removal of one of these is caught.
var audit = AuditCommands.Build(Url, Format, Username, Password);
var subNames = audit.Subcommands.Select(c => c.Name).ToHashSet();
@@ -78,10 +82,11 @@ public class CommandTreeTests
Assert.Contains("verify-chain", subNames);
}
/// <summary>Verifies that the bundle command group exposes the export, preview and import subcommands.</summary>
[Fact]
public void BundleCommandGroup_HasExportPreviewAndImport()
{
// CLI-022: pin the bundle sub-command surface.
// Pin the bundle sub-command surface.
var bundle = BundleCommands.Build(Url, Format, Username, Password);
var subNames = bundle.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("export", subNames);
@@ -89,6 +94,7 @@ public class CommandTreeTests
Assert.Contains("import", subNames);
}
/// <summary>Verifies that every leaf command in the tree has an action attached.</summary>
[Fact]
public void EveryLeafCommand_HasAnAction()
{
@@ -100,10 +106,11 @@ public class CommandTreeTests
Assert.True(leaf.Action != null, $"Leaf command '{leaf.Name}' has no action."));
}
/// <summary>Verifies that template composition delete is keyed by the composition id alone.</summary>
[Fact]
public void TemplateCompositionDelete_IsKeyedByIdOnly()
{
// CLI-015: the in-repo README documented `template composition delete` with
// The in-repo README documented `template composition delete` with
// --template-id / --instance-name, but the implementation keys deletion by the
// composition's own integer ID via a single --id option. Pin the real surface.
var template = TemplateCommands.Build(Url, Format, Username, Password);
@@ -116,6 +123,7 @@ public class CommandTreeTests
Assert.DoesNotContain("--instance-name", optionNames);
}
/// <summary>Verifies that the template native alarm source group has add, list and remove subcommands.</summary>
[Fact]
public void TemplateNativeAlarmSource_HasAddListRemove()
{
@@ -127,10 +135,11 @@ public class CommandTreeTests
Assert.Contains("remove", subNames);
}
/// <summary>Verifies that the deploy group has a site verb that requires a site id.</summary>
[Fact]
public void Deploy_HasSiteVerb_WithRequiredSiteId()
{
// WP2.5: bulk site deploy. Named `deploy site` to sit alongside
// Bulk site deploy. Named `deploy site` to sit alongside
// `deploy instance` / `deploy artifacts` — the group's convention is that
// the verb names the SCOPE of the deploy.
var deploy = DeployCommands.Build(Url, Format, Username, Password);
@@ -143,10 +152,11 @@ public class CommandTreeTests
Assert.Empty(site.Parse(["--site-id", "3"]).Errors);
}
/// <summary>Verifies that deploy site has a force flag that defaults to the non-destructive behaviour.</summary>
[Fact]
public void DeploySite_HasForceFlag_DefaultingToTheNonDestructiveBehaviour()
{
// P7-6: bulk deploy skips instances whose config is unchanged, because a
// Bulk deploy skips instances whose config is unchanged, because a
// redeploy resets their static overrides and native alarm state at the
// site. --force is the operator escape hatch (restored volume, rebuilt
// node) and must be a FLAG — absent means skip-unchanged, which is the
@@ -165,6 +175,7 @@ public class CommandTreeTests
Assert.True(withForce.GetValue(forceOption));
}
/// <summary>Verifies that the instance native alarm source group has set and clear subcommands.</summary>
[Fact]
public void InstanceNativeAlarmSource_HasSetAndClear()
{
@@ -175,6 +186,7 @@ public class CommandTreeTests
Assert.Contains("clear", subNames);
}
/// <summary>Verifies that the template folder group covers every folder management command.</summary>
[Fact]
public void TemplateFolder_HasFullManagementCommandParity()
{
@@ -192,6 +204,7 @@ public class CommandTreeTests
Assert.Contains("delete", subNames);
}
/// <summary>Verifies that the parent id is optional on template folder create so the root folder is reachable.</summary>
[Fact]
public void TemplateFolderCreate_ParentIdIsOptional_SoRootIsReachable()
{
@@ -211,6 +224,7 @@ public class CommandTreeTests
Assert.Equal(7, nestedParse.GetValue((Option<int?>)parentOption));
}
/// <summary>Verifies that the parent id is optional on template folder move so the root folder is reachable.</summary>
[Fact]
public void TemplateFolderMove_ParentIdIsOptional_SoRootIsReachable()
{
@@ -224,6 +238,8 @@ public class CommandTreeTests
Assert.Null(parse.GetValue((Option<int?>)parentOption));
}
/// <summary>Verifies that template folder reorder accepts the up and down directions.</summary>
/// <param name="direction">The reorder direction under test.</param>
[Theory]
[InlineData("up")]
[InlineData("down")]
@@ -237,6 +253,8 @@ public class CommandTreeTests
Assert.Empty(parse.Errors);
}
/// <summary>Verifies that the folder reorder command rejects any direction other than the accepted literals.</summary>
/// <param name="direction">The rejected direction value.</param>
[Theory]
[InlineData("sideways")]
// AcceptOnlyFromAmong is case-SENSITIVE, so the literals are lowercase only.
@@ -245,6 +263,8 @@ public class CommandTreeTests
// case-sensitivity rejected values the server accepts. --direction keeps it: it is
// a pure client-side literal that never reaches a server-side parse, so there is no
// authoritative check behind it to defer to.
/// <summary>Verifies that template folder reorder rejects any direction other than up or down.</summary>
/// <param name="direction">The invalid reorder direction under test.</param>
[InlineData("Up")]
public void TemplateFolderReorder_RejectsAnythingElse(string direction)
{
@@ -256,6 +276,7 @@ public class CommandTreeTests
Assert.NotEmpty(parse.Errors);
}
/// <summary>Verifies that template folder reorder requires a direction.</summary>
[Fact]
public void TemplateFolderReorder_RequiresDirection()
{
@@ -278,6 +299,8 @@ public class CommandTreeTests
// treatment row 88 gave `deploy status`). These tests fail before that change.
// ========================================================================
/// <summary>Provides the audit filter option names and sample values used by the theory data.</summary>
/// <returns>Theory data of subcommand, option and value triples.</returns>
public static TheoryData<string, string, string> AuditFilterValues() => new()
{
// subcommand, option, value — lowercase, uppercase and mixed case of a real member.
@@ -290,6 +313,10 @@ public class CommandTreeTests
{ "export", "--kind", "dbwrite" },
};
/// <summary>Verifies that audit filter options accept values in any letter case.</summary>
/// <param name="subcommand">The audit subcommand under test.</param>
/// <param name="option">The filter option name.</param>
/// <param name="value">The option value whose casing varies.</param>
[Theory]
[MemberData(nameof(AuditFilterValues))]
public void AuditFilterOptions_AcceptValuesInAnyCase(string subcommand, string option, string value)
@@ -308,6 +335,8 @@ public class CommandTreeTests
Assert.Empty(parse.Errors);
}
/// <summary>Verifies that the audit export format option accepts values in any letter case.</summary>
/// <param name="format">The export format value under test.</param>
[Theory]
[InlineData("csv")]
[InlineData("CSV")]
@@ -328,6 +357,7 @@ public class CommandTreeTests
Assert.Empty(parse.Errors);
}
/// <summary>Verifies that an unrecognised audit query filter value is not rejected on the client side.</summary>
[Fact]
public void AuditQuery_UnrecognisedFilterValue_IsNotRejectedClientSide()
{
@@ -342,6 +372,7 @@ public class CommandTreeTests
Assert.Empty(parse.Errors);
}
/// <summary>Verifies that the site area group has a move verb.</summary>
[Fact]
public void SiteArea_HasMoveVerb()
{
@@ -353,6 +384,7 @@ public class CommandTreeTests
Assert.Contains("move", subNames);
}
/// <summary>Verifies that the parent id is optional on site area move so the site root is reachable.</summary>
[Fact]
public void SiteAreaMove_ParentIdIsOptional_SoSiteRootIsReachable()
{
@@ -372,6 +404,7 @@ public class CommandTreeTests
Assert.Equal(2, reparentParse.GetValue((Option<int?>)parentOption));
}
/// <summary>Verifies that site area move requires an area id.</summary>
[Fact]
public void SiteAreaMove_RequiresId()
{
@@ -383,6 +416,7 @@ public class CommandTreeTests
Assert.NotEmpty(parse.Errors);
}
/// <summary>Verifies that template list has skip and take paging options.</summary>
[Fact]
public void TemplateList_HasSkipAndTakePagingOptions()
{
@@ -394,6 +428,7 @@ public class CommandTreeTests
Assert.Contains("--take", optionNames);
}
/// <summary>Verifies that instance list has skip and take paging options.</summary>
[Fact]
public void InstanceList_HasSkipAndTakePagingOptions()
{
@@ -405,6 +440,7 @@ public class CommandTreeTests
Assert.Contains("--take", optionNames);
}
/// <summary>Verifies that instance list parses the value of the take option.</summary>
[Fact]
public void InstanceList_ParsesTakeOptionValue()
{
@@ -418,6 +454,8 @@ public class CommandTreeTests
Assert.Equal(5, parse.GetValue((Option<int?>)takeOption));
}
/// <summary>Verifies that each command payload type resolves through the management command registry.</summary>
/// <param name="commandType">The command payload type under test.</param>
[Theory]
[InlineData(typeof(GetInstanceCommand))]
[InlineData(typeof(ListSitesCommand))]
@@ -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()
{
@@ -45,6 +45,8 @@ public class ConnectionBrowseCommandsTests
return (root, captured);
}
/// <summary>Verifies that the browse command builds a browse-node command addressed by site identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Browse_BuildsBrowseNodeCommand_WithSiteIdentifier()
{
@@ -61,6 +63,8 @@ public class ConnectionBrowseCommandsTests
Assert.Equal("ns=2;s=X", cmd.ParentNodeId);
}
/// <summary>Verifies that browsing without a node id starts from the address-space root.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Browse_WithoutNodeId_BrowsesFromRoot()
{
@@ -76,6 +80,8 @@ public class ConnectionBrowseCommandsTests
Assert.Null(cmd.ParentNodeId);
}
/// <summary>Verifies that the search command builds a search-address-space command addressed by site identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Search_BuildsSearchAddressSpaceCommand_WithSiteIdentifier()
{
@@ -98,6 +104,8 @@ public class ConnectionBrowseCommandsTests
Assert.Equal(50, cmd.MaxResults);
}
/// <summary>Verifies that verify-endpoint reads the configuration JSON from a file and addresses the site by identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task VerifyEndpoint_ReadsConfigJsonFromFile_WithSiteIdentifier()
{
@@ -128,6 +136,8 @@ public class ConnectionBrowseCommandsTests
}
}
/// <summary>Verifies that certs list builds a list-server-certs command addressed by site identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CertsList_BuildsListServerCertsCommand_WithSiteIdentifier()
{
@@ -142,6 +152,8 @@ public class ConnectionBrowseCommandsTests
Assert.Equal("SITE1", cmd.SiteIdentifier);
}
/// <summary>Verifies that certs trust base64-encodes the DER certificate file and addresses the site by identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CertsTrust_Base64EncodesDerFile_WithSiteIdentifier()
{
@@ -173,6 +185,8 @@ public class ConnectionBrowseCommandsTests
}
}
/// <summary>Verifies that certs remove builds a remove-server-cert command addressed by site identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CertsRemove_BuildsRemoveServerCertCommand_WithSiteIdentifier()
{
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.CLI;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Regression tests for CLI-006 — credentials could only be supplied via the
/// Regression tests for environment-based credential supply — credentials could only be supplied via the
/// <c>--password</c> command-line option, which leaks into process listings and
/// shell history. A <c>SCADABRIDGE_PASSWORD</c> / <c>SCADABRIDGE_USERNAME</c> environment
/// fallback gives CI/CD a safer alternative.
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Environment")]
public class CredentialResolutionTests
{
/// <summary>Verifies that the password is loaded from the environment variable.</summary>
[Fact]
public void Load_Password_FromEnvironment()
{
@@ -29,6 +30,7 @@ public class CredentialResolutionTests
}
}
/// <summary>Verifies that the username is loaded from the environment variable.</summary>
[Fact]
public void Load_Username_FromEnvironment()
{
@@ -47,6 +49,7 @@ public class CredentialResolutionTests
}
}
/// <summary>Verifies that loading with no credential environment variables leaves the credentials null.</summary>
[Fact]
public void Load_NoCredentialEnvVars_LeavesCredentialsNull()
{
@@ -5,13 +5,14 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Regression tests for the testable pieces of <c>DebugCommands.StreamDebugAsync</c>:
/// CLI-010 (Ctrl+C during connect misreported as a connection failure) and
/// CLI-012 (non-deterministic exit code after stream termination).
/// Ctrl+C during connect being misreported as a connection failure, and
/// non-deterministic exit codes after stream termination.
/// </summary>
public class DebugStreamTests
{
// --- CLI-010: connect-failure classification --------------------------------------
// --- connect-failure classification --------------------------------------
/// <summary>Verifies that an <see cref="OperationCanceledException"/> raised after cancellation was requested is treated as a graceful cancellation.</summary>
[Fact]
public void ClassifyConnectFailure_OperationCanceled_IsTreatedAsCancellation()
{
@@ -24,6 +25,7 @@ public class DebugStreamTests
Assert.Equal(0, result.ExitCode);
}
/// <summary>Verifies that a <see cref="TaskCanceledException"/> raised after cancellation was requested is treated as a cancellation.</summary>
[Fact]
public void ClassifyConnectFailure_TaskCanceled_WhenCancelRequested_IsCancellation()
{
@@ -34,6 +36,7 @@ public class DebugStreamTests
Assert.Equal(0, result.ExitCode);
}
/// <summary>Verifies that a genuine connection exception is classified as a connection failure with exit code 1.</summary>
[Fact]
public void ClassifyConnectFailure_RealException_IsConnectionFailure()
{
@@ -44,6 +47,7 @@ public class DebugStreamTests
Assert.Equal(1, result.ExitCode);
}
/// <summary>Verifies that a cancellation exception without a user cancel request is classified as a connection failure.</summary>
[Fact]
public void ClassifyConnectFailure_CanceledExceptionButNoCancelRequested_IsConnectionFailure()
{
@@ -56,8 +60,10 @@ public class DebugStreamTests
Assert.Equal(1, result.ExitCode);
}
// --- CLI-012: deterministic exit-code resolution ----------------------------------
// --- deterministic exit-code resolution ----------------------------------
/// <summary>Verifies that a result already set by stream termination is preferred when resolving the exit code.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveStreamExitCodeAsync_TerminationResultSet_PrefersThatResult()
{
@@ -70,6 +76,8 @@ public class DebugStreamTests
Assert.Equal(1, code);
}
/// <summary>Verifies that the exit code resolves to zero when no termination result was ever set.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveStreamExitCodeAsync_NoResult_ReturnsZero()
{
@@ -81,6 +89,8 @@ public class DebugStreamTests
Assert.Equal(0, code);
}
/// <summary>Verifies that a result arriving during the grace period is observed when resolving the exit code.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveStreamExitCodeAsync_ResultArrivesDuringGrace_IsObserved()
{
@@ -22,6 +22,11 @@ public class DeployArtifactsExitCodeTests
"{\"siteId\":\"site-b\",\"siteName\":\"B\",\"success\":false,\"errorMessage\":\"Secret 'k' ... missing\"}]," +
"\"successCount\":1,\"failureCount\":1}";
/// <summary>Verifies that the artifact deploy command exits with a non-zero code exactly when a site failed or the response is unrecognized.</summary>
/// <param name="group">The deploy command group under test (<c>site</c> or <c>deploy</c>).</param>
/// <param name="response">The JSON response the management endpoint returns.</param>
/// <param name="expected">The expected process exit code.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("site", AllSucceeded, 0)]
[InlineData("site", OneFailed, 1)]
@@ -8,6 +8,10 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
public class DeploySiteExitCodeTests
{
/// <summary>Verifies that the actual deploy command returns an exit code derived from the success and failure counts in the response.</summary>
/// <param name="response">The JSON response body served by the fake management endpoint.</param>
/// <param name="expected">The expected process exit code.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("{\"successCount\":2,\"failureCount\":0}", 0)]
[InlineData("{\"successCount\":1,\"failureCount\":1}", 1)]
@@ -10,6 +10,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")]
public class DeploymentDeadlineParityTests
{
/// <summary>Verifies that every single-deployment CLI alias waits for a server reply beyond the ordinary 30-second deadline.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EverySingleDeploymentAlias_WaitsForServerBeyondOrdinaryDeadline()
{
@@ -35,6 +35,7 @@ public class DeploymentStatusTableProjectionTests
PageSize: rowCount),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
/// <summary>Verifies that projection: unwraps the envelope to a row array.</summary>
[Fact]
public void Projection_UnwrapsTheEnvelopeToARowArray()
{
@@ -47,6 +48,7 @@ public class DeploymentStatusTableProjectionTests
Assert.Equal("Line3.Pump1", doc.RootElement[0].GetProperty("instanceUniqueName").GetString());
}
/// <summary>Verifies that projection, when page is a strict subset, appends a total note.</summary>
[Fact]
public void Projection_WhenPageIsAStrictSubset_AppendsATotalNote()
{
@@ -59,6 +61,7 @@ public class DeploymentStatusTableProjectionTests
Assert.Contains("--page-size", note);
}
/// <summary>Verifies that projection, when page is the whole result, emits no note column.</summary>
[Fact]
public void Projection_WhenPageIsTheWholeResult_EmitsNoNoteColumn()
{
@@ -69,6 +72,7 @@ public class DeploymentStatusTableProjectionTests
e => Assert.False(e.TryGetProperty("note", out _)));
}
/// <summary>Verifies that projection, when accepts a bare row array, from a pre-envelope server.</summary>
[Fact]
public void Projection_AcceptsABareRowArray_FromAPreEnvelopeServer()
{
@@ -79,6 +83,8 @@ public class DeploymentStatusTableProjectionTests
.GetProperty("deploymentId").GetString());
}
/// <summary>Verifies that projection: leaves unrecognised bodies untouched.</summary>
/// <param name="body">The body.</param>
[Theory]
[InlineData("not json at all")]
[InlineData("""{"error":"boom"}""")]
@@ -24,6 +24,11 @@ public class EditingParityTests
return root;
}
/// <summary>Verifies that each command sends the exact management envelope with the expected command name and payload.</summary>
/// <param name="arguments">The command-line arguments to run.</param>
/// <param name="command">The expected management command name.</param>
/// <param name="payload">The expected JSON payload.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("template resolved-members --id 12", "GetResolvedTemplateMembers", "{\"templateId\":12}")]
[InlineData("template move --id 12 --folder-id 3", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":3}")]
@@ -74,6 +79,8 @@ public class EditingParityTests
finally { listener.Stop(); }
}
/// <summary>Verifies that missing or malformed arguments are rejected.</summary>
/// <param name="arguments">The invalid command-line arguments to run.</param>
[Theory]
[InlineData("template resolved-members")]
[InlineData("template move --id 12 --folder-id invalid")]
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Regression tests for CLI-009 — the design doc defines exit code 2 as "authorization
/// Regression tests for the exit-code contract: the design doc defines exit code 2 as "authorization
/// failure". The previous implementation keyed exit 2 solely off HTTP 403, so an
/// authorization failure the server signalled with an error <c>code</c> of
/// <c>UNAUTHORIZED</c>/<c>FORBIDDEN</c> but a different HTTP status was misreported as a
@@ -27,12 +27,15 @@ public class ExitCodeTests
}
}
/// <summary>Verifies that an HTTP 403 response returns exit code 2.</summary>
[Fact]
public void HandleResponse_Http403_ReturnsTwo()
{
Assert.Equal(2, HandleQuietly(new ManagementResponse(403, null, "Forbidden", "FORBIDDEN")));
}
/// <summary>Verifies that an authorization error code with a status other than 403 still returns exit code 2.</summary>
/// <param name="code">The error code returned by the server.</param>
[Theory]
[InlineData("UNAUTHORIZED")]
[InlineData("FORBIDDEN")]
@@ -40,16 +43,18 @@ public class ExitCodeTests
public void HandleResponse_AuthorizationCode_NonForbiddenStatus_ReturnsTwo(string code)
{
// The server signalled an authorization failure via the error code but with a
// non-403 HTTP status; per the documented exit-code table this is still exit 2.
// HTTP status other than 403 status; per the documented exit-code table this is still exit 2.
Assert.Equal(2, HandleQuietly(new ManagementResponse(400, null, "Access denied", code)));
}
/// <summary>Verifies that a generic error returns exit code 1.</summary>
[Fact]
public void HandleResponse_GenericError_ReturnsOne()
{
Assert.Equal(1, HandleQuietly(new ManagementResponse(400, null, "Validation failed", "INVALID_ARGUMENT")));
}
/// <summary>Verifies that an authentication failure returns exit code 1.</summary>
[Fact]
public void HandleResponse_AuthenticationFailure_ReturnsOne()
{
@@ -25,6 +25,7 @@ public class FlowDebugCommandsTests
private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
/// <summary>Verifies that building the command registers the four subcommands with their required options.</summary>
[Fact]
public void Build_RegistersTheFourSubcommands_WithTheirRequiredOptions()
{
@@ -59,6 +60,7 @@ public class FlowDebugCommandsTests
Assert.Contains(replay.Options, o => o.Name == "--execution-id" && o.Required);
}
/// <summary>Verifies that run-script defaults to a 45-second wait and a 120-second timeout.</summary>
[Fact]
public void RunScript_Defaults_WaitSeconds45_TimeoutSeconds120()
{
@@ -70,6 +72,8 @@ public class FlowDebugCommandsTests
Assert.Equal(120, parse.GetValue((Option<int>)cmd.Options.Single(o => o.Name == "--timeout-seconds")));
}
/// <summary>Verifies that run-script rejects a wait of zero seconds as an invalid argument.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunScript_WaitSecondsZero_IsRejectedWithInvalidArgument()
{
@@ -80,6 +84,8 @@ public class FlowDebugCommandsTests
Assert.Equal(1, exitCode);
}
/// <summary>Verifies that run-script rejects a timeout of zero seconds as an invalid argument.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunScript_TimeoutSecondsZero_IsRejectedWithInvalidArgument()
{
@@ -90,6 +96,7 @@ public class FlowDebugCommandsTests
Assert.Equal(1, exitCode);
}
/// <summary>Verifies that run-method defaults to a 120-second timeout.</summary>
[Fact]
public void RunMethod_TimeoutSecondsDefaultsTo120()
{
@@ -101,6 +108,8 @@ public class FlowDebugCommandsTests
Assert.Equal(120, parse.GetValue(timeoutOption));
}
/// <summary>Verifies that run-method rejects a timeout of zero seconds as an invalid argument.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunMethod_TimeoutSecondsZero_IsRejectedWithInvalidArgument()
{
@@ -111,6 +120,7 @@ public class FlowDebugCommandsTests
Assert.Equal(1, exitCode);
}
/// <summary>Verifies that run-value-change defaults to a 45-second wait, matching the command record.</summary>
[Fact]
public void RunValueChange_WaitSecondsDefaultsTo45_MatchingTheCommandRecord()
{
@@ -122,6 +132,7 @@ public class FlowDebugCommandsTests
Assert.Equal(new FlowDebugRunValueChangeCommand(7, "Speed", "42").WaitSeconds, parse.GetValue(waitOption));
}
/// <summary>Verifies that the flow-debug command records resolve in the management command registry.</summary>
[Fact]
public void FlowDebugCommandRecords_ResolveInTheManagementCommandRegistry()
{
@@ -133,6 +144,7 @@ public class FlowDebugCommandsTests
Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugReplayCommand))));
}
/// <summary>Verifies that parsing blank parameters yields null and that a JSON object is cloned.</summary>
[Fact]
public void ParseParameters_Blank_IsNull_AndObject_IsCloned()
{
@@ -145,6 +157,8 @@ public class FlowDebugCommandsTests
Assert.Equal("Z28061", parsed.Value.GetProperty("code").GetString());
}
/// <summary>Verifies that parsing parameters rejects malformed or non-object input.</summary>
/// <param name="input">The raw parameter text to parse.</param>
[Theory]
[InlineData("not json")]
[InlineData("[1,2]")]
@@ -185,6 +199,7 @@ public class FlowDebugCommandsTests
return JsonSerializer.Serialize(result, CamelCase);
}
/// <summary>Verifies that the projection emits one row per step with its kind, lane, target, duration and status.</summary>
[Fact]
public void Projection_EmitsOneRowPerStep_WithKindLaneTargetDurationStatus()
{
@@ -218,6 +233,7 @@ public class FlowDebugCommandsTests
Assert.All(rows, r => Assert.False(r.TryGetProperty("note", out _)));
}
/// <summary>Verifies that the projection of an incomplete run appends a note row carrying the error code verbatim.</summary>
[Fact]
public void Projection_IncompleteRun_AppendsANoteRowWithTheErrorCodeVerbatim()
{
@@ -231,6 +247,7 @@ public class FlowDebugCommandsTests
Assert.Contains("standby", note);
}
/// <summary>Verifies that the projection of a truncated run notes that the audit rows remain complete.</summary>
[Fact]
public void Projection_TruncatedRun_NotesThatTheAuditRowsRemainComplete()
{
@@ -242,10 +259,11 @@ public class FlowDebugCommandsTests
Assert.Contains("audit tree", last.GetProperty("note").GetString());
}
/// <summary>Verifies that the projection of a run with truncated logs notes that a replay pulls the remainder.</summary>
[Fact]
public void Projection_LogsTruncatedRun_NotesThatReplayPullsTheRest()
{
// Final review M8: `logsTruncated` is a SEPARATE signal from `truncated` (the inline
// `logsTruncated` is a SEPARATE signal from `truncated` (the inline
// collector sheds low-priority ScriptLog rows before it sheds step rows). The page shows
// it; the CLI used to drop it silently.
var projected = FlowDebugTableProjection.ProjectEvents(
@@ -257,6 +275,7 @@ public class FlowDebugCommandsTests
Assert.Equal("inline log lines capped — replay pulls the rest", rows[^1].GetProperty("note").GetString());
}
/// <summary>Verifies that the projection reports both truncation flags together.</summary>
[Fact]
public void Projection_BothTruncationFlags_AreReportedTogether()
{
@@ -269,6 +288,7 @@ public class FlowDebugCommandsTests
Assert.Contains("inline log lines capped — replay pulls the rest", note);
}
/// <summary>Verifies that the projection leaves response bodies that do not match the expected shape untouched.</summary>
[Fact]
public void Projection_LeavesNonMatchingBodiesUntouched()
{
@@ -277,6 +297,7 @@ public class FlowDebugCommandsTests
Assert.Equal("[1]", FlowDebugTableProjection.ProjectEvents("[1]"));
}
/// <summary>Verifies that the projection folds an outbound call lifecycle into a single external-lane row.</summary>
[Fact]
public void Projection_FoldsAnOutboundLifecycle_IntoOneExternalLaneRow()
{
@@ -333,7 +354,7 @@ public class FlowDebugCommandsTests
Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("correlation").ValueKind);
}
/// <summary>Builds a run whose script step carries two <c>ScriptLog</c> rows and fails with an exception detail (step-logs plan 2026-09-09, Task 14).</summary>
/// <summary>Builds a run whose script step carries two <c>ScriptLog</c> rows and fails with an exception detail.</summary>
private static string StepLogsPayload(out DateTime log1Time, out DateTime log2Time)
{
var root = Guid.NewGuid();
@@ -362,6 +383,7 @@ public class FlowDebugCommandsTests
return JsonSerializer.Serialize(result, CamelCase);
}
/// <summary>Verifies that the logs column counts the log lines attached to a step.</summary>
[Fact]
public void Projection_LogsColumn_CountsAttachedLogLines()
{
@@ -376,6 +398,7 @@ public class FlowDebugCommandsTests
Assert.All(rows, r => Assert.False(r.TryGetProperty("note", out _)));
}
/// <summary>Verifies that showing logs prints indented log lines and exception detail beneath the step row.</summary>
[Fact]
public void Projection_ShowLogsTrue_PrintsIndentedLogLinesAndExceptionDetail_UnderTheStepRow()
{
@@ -400,6 +423,7 @@ public class FlowDebugCommandsTests
Assert.DoesNotContain("at Z", projected);
}
/// <summary>Verifies that hiding logs omits the log and exception detail lines.</summary>
[Fact]
public void Projection_ShowLogsFalse_OmitsLogAndExceptionDetailLines()
{
@@ -412,10 +436,10 @@ public class FlowDebugCommandsTests
/// <summary>
/// Drives the real table renderer (<see cref="CommandHelpers.HandleResponse"/>'s table
/// branch, which is what every <c>flow-debug</c> command actually prints to the terminal)
/// rather than only inspecting the intermediate JSON — a code review of Task 14 found the
/// rather than only inspecting the intermediate JSON — a code review found the
/// generic column-union renderer padded the log/exception <c>note</c> rows into a far-right
/// "note" column instead of printing them as raw indented lines under their step, which the
/// JSON-level assertions above could not have caught (Task 14 review, 2026-09-09).
/// JSON-level assertions above could not have caught.
/// </summary>
[Fact]
public void HandleResponse_TableFormat_ShowLogsTrue_PrintsLogLineRawUnderScriptRunRow_NoNoteHeader()
@@ -4,13 +4,15 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Regression tests for CLI-008 — the <c>--format</c> option previously accepted any
/// Regression tests for the <c>--format</c> option, which previously accepted any
/// string, so a typo like <c>--format tabel</c> silently fell through to JSON output
/// with no feedback. The option must reject values outside {json, table} with a parse
/// error.
/// </summary>
public class FormatOptionValidationTests
{
/// <summary>Verifies that the format option accepts each supported value.</summary>
/// <param name="value">The format value supplied to the option.</param>
[Theory]
[InlineData("json")]
[InlineData("table")]
@@ -25,6 +27,8 @@ public class FormatOptionValidationTests
Assert.Empty(result.Errors);
}
/// <summary>Verifies that the format option rejects unsupported values.</summary>
/// <param name="value">The unsupported format value supplied to the option.</param>
[Theory]
[InlineData("tabel")]
[InlineData("xml")]
@@ -14,6 +14,7 @@ public class FormatResolutionTests
return (formatOption, root);
}
/// <summary>Verifies that an explicit format flag overrides the configured default format.</summary>
[Fact]
public void ResolveFormat_ExplicitFlag_OverridesConfig()
{
@@ -26,10 +27,11 @@ public class FormatResolutionTests
Assert.Equal("table", format);
}
/// <summary>Verifies that the configured default format is used when the format flag is absent.</summary>
[Fact]
public void ResolveFormat_FlagAbsent_UsesConfigDefaultFormat()
{
// Regression for CLI-001: when --format is not supplied, the config-file /
// Regression: when --format is not supplied, the config-file /
// env-var DefaultFormat must be honoured instead of always falling back to "json".
var (formatOption, root) = BuildHarness();
var result = root.Parse(Array.Empty<string>());
@@ -40,6 +42,7 @@ public class FormatResolutionTests
Assert.Equal("table", format);
}
/// <summary>Verifies that the format defaults to JSON when the flag and the configuration are both absent.</summary>
[Fact]
public void ResolveFormat_FlagAbsent_AndNoConfig_DefaultsToJson()
{
@@ -13,6 +13,7 @@ public class ImportNativeAlarmSourceOverridesTests
{
// ── error path ───────────────────────────────────────────────────────────
/// <summary>Verifies that CSV input without a header row produces errors and no rows.</summary>
[Fact]
public void CsvWithMissingHeader_ProducesErrors()
{
@@ -22,6 +23,7 @@ public class ImportNativeAlarmSourceOverridesTests
Assert.Empty(result.Rows);
}
/// <summary>Verifies that a CSV row with a blank source name produces an error and no rows.</summary>
[Fact]
public void CsvWithBlankSourceName_ProducesError()
{
@@ -34,6 +36,7 @@ public class ImportNativeAlarmSourceOverridesTests
// ── happy path ───────────────────────────────────────────────────────────
/// <summary>Verifies that valid CSV input produces the expected list of override entries.</summary>
[Fact]
public void ValidCsv_ProducesCorrectEntryList()
{
@@ -12,6 +12,7 @@ public class ImportOverridesTests
{
// ── error path ───────────────────────────────────────────────────────────
/// <summary>Verifies that a CSV with a missing header produces errors.</summary>
[Fact]
public void CsvWithMissingHeader_ProducesErrors()
{
@@ -21,6 +22,7 @@ public class ImportOverridesTests
Assert.Empty(result.Rows);
}
/// <summary>Verifies that a CSV row with a blank attribute name produces an error.</summary>
[Fact]
public void CsvWithBlankAttributeName_ProducesError()
{
@@ -32,6 +34,7 @@ public class ImportOverridesTests
Assert.Empty(result.Rows);
}
/// <summary>Verifies that a CSV row with the wrong column count produces an error.</summary>
[Fact]
public void CsvWithWrongColumnCount_ProducesError()
{
@@ -44,6 +47,7 @@ public class ImportOverridesTests
// ── happy path ───────────────────────────────────────────────────────────
/// <summary>Verifies that a valid CSV produces the expected overrides dictionary.</summary>
[Fact]
public void ValidCsv_ProducesCorrectOverridesDictionary()
{
@@ -67,6 +71,7 @@ public class ImportOverridesTests
Assert.Equal("Hello", overrides["Label"]);
}
/// <summary>Verifies that the element type column of a valid CSV is ignored when building the overrides dictionary.</summary>
[Fact]
public void ValidCsvWithElementTypeColumn_OverridesDictIgnoresElementType()
{
@@ -5,11 +5,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Regression tests for CLI-005 — malformed <c>--bindings</c> / <c>--overrides</c> JSON
/// Regression tests: malformed <c>--bindings</c> / <c>--overrides</c> JSON
/// previously threw unhandled exceptions instead of producing a clean validation error.
/// </summary>
public class InstanceArgumentParsingTests
{
/// <summary>Verifies that valid bindings JSON is parsed into attribute and connection id pairs.</summary>
[Fact]
public void ParseBindings_ValidJson_ReturnsPairs()
{
@@ -23,6 +24,7 @@ public class InstanceArgumentParsingTests
Assert.Equal(new ConnectionBinding("Mode", 7), bindings[1]);
}
/// <summary>Verifies that malformed bindings JSON yields an error message instead of throwing an exception.</summary>
[Fact]
public void ParseBindings_MalformedJson_ReturnsErrorNotException()
{
@@ -33,6 +35,7 @@ public class InstanceArgumentParsingTests
Assert.NotNull(error);
}
/// <summary>Verifies that a binding pair with too few elements yields an error instead of throwing.</summary>
[Fact]
public void ParseBindings_ShortPair_ReturnsErrorNotException()
{
@@ -44,6 +47,7 @@ public class InstanceArgumentParsingTests
Assert.NotNull(error);
}
/// <summary>Verifies that binding elements of the wrong JSON types yield an error instead of throwing.</summary>
[Fact]
public void ParseBindings_WrongElementTypes_ReturnsErrorNotException()
{
@@ -55,6 +59,7 @@ public class InstanceArgumentParsingTests
Assert.NotNull(error);
}
/// <summary>Verifies that a bindings document of JSON null yields an error instead of throwing.</summary>
[Fact]
public void ParseBindings_JsonNull_ReturnsErrorNotException()
{
@@ -65,11 +70,12 @@ public class InstanceArgumentParsingTests
Assert.NotNull(error);
}
/// <summary>Verifies that the optional third binding element is captured as a data source reference override.</summary>
[Fact]
public void ParseBindings_ThreeElementForm_CapturesReferenceOverride()
{
// The optional 3rd element carries the per-instance data-source reference
// override (followup #4) so the CLI can SET it instead of always wiping it.
// override so the CLI can SET it instead of always wiping it.
var ok = InstanceCommands.TryParseBindings(
"[[\"Speed\", 5, \"ns=2;s=Spd\"], [\"Mode\", 7]]", out var bindings, out var error);
@@ -82,6 +88,7 @@ public class InstanceArgumentParsingTests
Assert.Null(bindings[1].DataSourceReferenceOverride);
}
/// <summary>Verifies that an explicit null third element leaves the reference override unset.</summary>
[Fact]
public void ParseBindings_ThirdElementExplicitNull_LeavesOverrideUnset()
{
@@ -94,6 +101,7 @@ public class InstanceArgumentParsingTests
Assert.Null(bindings![0].DataSourceReferenceOverride);
}
/// <summary>Verifies that a third binding element of the wrong type yields an error instead of throwing.</summary>
[Fact]
public void ParseBindings_ThirdElementWrongType_ReturnsErrorNotException()
{
@@ -106,6 +114,7 @@ public class InstanceArgumentParsingTests
Assert.NotNull(error);
}
/// <summary>Verifies that a binding with four elements yields an error instead of throwing.</summary>
[Fact]
public void ParseBindings_FourElements_ReturnsErrorNotException()
{
@@ -117,6 +126,7 @@ public class InstanceArgumentParsingTests
Assert.NotNull(error);
}
/// <summary>Verifies that valid overrides JSON is parsed into a dictionary.</summary>
[Fact]
public void ParseOverrides_ValidJson_ReturnsDictionary()
{
@@ -130,6 +140,7 @@ public class InstanceArgumentParsingTests
Assert.Null(overrides["Mode"]);
}
/// <summary>Verifies that malformed overrides JSON yields an error instead of throwing.</summary>
[Fact]
public void ParseOverrides_MalformedJson_ReturnsErrorNotException()
{
@@ -140,6 +151,7 @@ public class InstanceArgumentParsingTests
Assert.NotNull(error);
}
/// <summary>Verifies that an overrides document of JSON null yields an error instead of throwing.</summary>
[Fact]
public void ParseOverrides_JsonNull_ReturnsErrorNotException()
{
@@ -152,6 +164,7 @@ public class InstanceArgumentParsingTests
// --- instance bind-module (grouped connection bindings, 2026-08-20) ---
/// <summary>Verifies that a module rewrite changes only the named module's bindings and applies the requested connection.</summary>
[Fact]
public void BuildModuleRewrite_RewritesOnlyTheModule_AndKeepsConnection()
{
@@ -169,6 +182,7 @@ public class InstanceArgumentParsingTests
Assert.DoesNotContain(rewritten, b => b.AttributeName.StartsWith("Left"));
}
/// <summary>Verifies that the instance-level group matches attribute names that contain no dot.</summary>
[Fact]
public void BuildModuleRewrite_InstanceGroup_MatchesDotlessNames()
{
@@ -178,6 +192,7 @@ public class InstanceArgumentParsingTests
Assert.Equal("Z28063.ChlorineFlow", b.DataSourceReferenceOverride);
}
/// <summary>Verifies that an empty object path clears the data source reference override.</summary>
[Fact]
public void BuildModuleRewrite_EmptyObject_ClearsOverride()
{
@@ -188,6 +203,7 @@ public class InstanceArgumentParsingTests
Assert.Equal(2, b.DataConnectionId);
}
/// <summary>Verifies that surrounding whitespace and a trailing dot on the object path are trimmed.</summary>
[Fact]
public void BuildModuleRewrite_TrailingDotOnObject_IsTrimmed()
{
@@ -196,6 +212,7 @@ public class InstanceArgumentParsingTests
Assert.Equal("X.A", Assert.Single(r!).DataSourceReferenceOverride);
}
/// <summary>Verifies that an unknown module name produces an error listing the available modules.</summary>
[Fact]
public void BuildModuleRewrite_UnknownModule_ListsAvailable()
{
@@ -207,6 +224,7 @@ public class InstanceArgumentParsingTests
Assert.Contains("(instance)", error);
}
/// <summary>Verifies that existing bindings are read from camelCase instance JSON.</summary>
[Fact]
public void ParseExistingBindings_ReadsCamelCaseInstanceJson()
{
@@ -224,6 +242,7 @@ public class InstanceArgumentParsingTests
Assert.Equal(new ConnectionBinding("NoOverrideProp", 5), bindings[2]);
}
/// <summary>Verifies that instance JSON without a bindings array yields an empty list.</summary>
[Fact]
public void ParseExistingBindings_MissingArray_ReturnsEmpty()
{
@@ -231,6 +250,7 @@ public class InstanceArgumentParsingTests
Assert.Empty(InstanceCommands.ParseExistingBindings("""{"id":7,"connectionBindings":null}"""));
}
/// <summary>Verifies that the bind-module command parses its options and rejects a missing object path.</summary>
[Fact]
public void BindModule_ParsesOptions()
{
@@ -7,8 +7,8 @@ using ZB.MOM.WW.ScadaBridge.CLI;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Regression tests for CLI-013 — <see cref="ManagementHttpClient.SendCommandAsync"/>
/// (success, error-body parsing, connection-failure, and timeout paths) was untested.
/// Regression tests for <see cref="ManagementHttpClient.SendCommandAsync"/>
/// (success, error-body parsing, connection-failure, and timeout paths), which were previously untested.
/// Uses a stub <see cref="HttpMessageHandler"/> so no live server is required.
/// </summary>
public class ManagementHttpClientTests
@@ -17,6 +17,9 @@ public class ManagementHttpClientTests
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _responder;
/// <summary>Creates a handler that always answers with the given status code and JSON body.</summary>
/// <param name="status">The HTTP status code to return.</param>
/// <param name="body">The JSON response body to return.</param>
public StubHandler(HttpStatusCode status, string body)
: this((_, _) => Task.FromResult(new HttpResponseMessage(status)
{
@@ -25,11 +28,14 @@ public class ManagementHttpClientTests
{
}
/// <summary>Creates a handler that delegates every request to the supplied responder.</summary>
/// <param name="responder">The delegate that produces the response for each request.</param>
public StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
{
_responder = responder;
}
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> _responder(request, cancellationToken);
@@ -38,6 +44,8 @@ public class ManagementHttpClientTests
private static ManagementHttpClient ClientWith(StubHandler handler)
=> new(new HttpClient(handler), "http://localhost:9001", "user", "pass");
/// <summary>Verifies that a successful response is returned as a success result carrying the JSON data.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SendCommandAsync_Success_ReturnsJsonData()
{
@@ -51,6 +59,8 @@ public class ManagementHttpClientTests
Assert.Null(response.ErrorCode);
}
/// <summary>Verifies that an error response body is parsed into the error message and error code.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SendCommandAsync_ErrorBody_ParsesErrorAndCode()
{
@@ -65,6 +75,8 @@ public class ManagementHttpClientTests
Assert.Equal("INVALID_ARGUMENT", response.ErrorCode);
}
/// <summary>Verifies that a non-JSON error body falls back to the raw body text as the error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SendCommandAsync_NonJsonErrorBody_FallsBackToRawBody()
{
@@ -78,6 +90,8 @@ public class ManagementHttpClientTests
Assert.Null(response.ErrorCode);
}
/// <summary>Verifies that a connection failure returns a result with status code zero.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SendCommandAsync_ConnectionFailure_ReturnsStatusZero()
{
@@ -91,6 +105,8 @@ public class ManagementHttpClientTests
Assert.Contains("connection refused", response.Error);
}
/// <summary>Verifies that a timed-out call returns a 504 status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SendCommandAsync_Timeout_Returns504()
{
@@ -125,6 +141,7 @@ public class ManagementHttpClientTimeoutTests
{
private const string EnvVar = "SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS";
/// <summary>Verifies that the default constructor leaves the underlying client timeout infinite.</summary>
[Fact]
public void DefaultConstructor_LeavesClientTimeoutInfinite()
{
@@ -133,6 +150,7 @@ public class ManagementHttpClientTimeoutTests
Assert.Equal(Timeout.InfiniteTimeSpan, client.EffectiveTimeout);
}
/// <summary>Verifies that the connect timeout defaults to thirty seconds when the environment variable is unset.</summary>
[Fact]
public void ConnectTimeout_DefaultsToThirtySeconds_WhenEnvVarUnset()
{
@@ -150,6 +168,8 @@ public class ManagementHttpClientTimeoutTests
}
}
/// <summary>Verifies that an invalid or non-positive environment value falls back to the default connect timeout.</summary>
/// <param name="value">The environment variable value to test.</param>
[Theory]
[InlineData("0")]
[InlineData("-5")]
@@ -170,6 +190,7 @@ public class ManagementHttpClientTimeoutTests
}
}
/// <summary>Verifies that a positive environment variable value overrides the default connect timeout.</summary>
[Fact]
public void PositiveEnvValue_OverridesDefaultConnectTimeout()
{
@@ -195,6 +216,7 @@ public class ManagementHttpClientTimeoutTests
/// real socket (not the stub handler) so the connect + send path is exercised
/// end to end, and sub-second deadlines so the test stays fast.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PerCallTimeoutLongerThanTheOldClientCap_IsHonored()
{
@@ -18,6 +18,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")]
public class ManagementSurfaceParityTests
{
/// <summary>Verifies that every HTTP management contract is sent by the production CLI.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EveryHttpManagementContract_IsSentByTheProductionCli()
{
@@ -32,6 +34,8 @@ public class ManagementSurfaceParityTests
Assert.Empty(sent.Keys.Except(registered));
}
/// <summary>Verifies that removing a production schema group is detected by the parity probe.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RemovingProductionSchemaGroup_IsDetectedByParityProbe()
{
@@ -22,6 +22,12 @@ public class NotificationParityTests
return root;
}
/// <summary>Verifies that each invocation sends the dedicated command contract with the expected payload property.</summary>
/// <param name="arguments">The command-line arguments to invoke.</param>
/// <param name="command">The expected command type name.</param>
/// <param name="property">The payload property to check.</param>
/// <param name="expected">The expected value of that property.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("rename --id 7 --name Operators", "RenameNotificationListCommand", "notificationListId", "7")]
[InlineData("recipient add --list-id 7 --name Jane --email jane@example.com", "AddNotificationRecipientCommand", "name", "Jane")]
@@ -50,6 +56,9 @@ public class NotificationParityTests
}
}
/// <summary>Verifies that explicitly supplied SMS policy values, including zero, are forwarded.</summary>
/// <param name="verb">The sms subcommand verb under test.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("create --auth-token secret")]
[InlineData("update --id 8")]
@@ -62,6 +71,8 @@ public class NotificationParityTests
Assert.Equal(JsonValueKind.Null, payload.GetProperty("fromNumber").ValueKind);
}
/// <summary>Verifies that SMTP create forwards all optional fields.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SmtpCreateForwardsAllOptionalFields()
{
@@ -72,6 +83,8 @@ public class NotificationParityTests
Assert.Equal("Smtp", payload.GetProperty("transport").GetString());
}
/// <summary>Verifies that omitted SMTP create options are sent as null so the server applies its defaults.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SmtpCreateOmittedOptionsUseServerDefaults()
{
@@ -80,6 +93,9 @@ public class NotificationParityTests
Assert.Equal(JsonValueKind.Null, payload.GetProperty(name).ValueKind);
}
/// <summary>Verifies that an omitted SMS policy and omitted secrets remain null in the payload.</summary>
/// <param name="verb">The sms subcommand verb under test.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("create --auth-token secret")]
[InlineData("update --id 8")]
@@ -91,6 +107,8 @@ public class NotificationParityTests
if (verb.StartsWith("update")) Assert.Equal(JsonValueKind.Null, payload.GetProperty("authToken").ValueKind);
}
/// <summary>Verifies that invalid input fails argument parsing.</summary>
/// <param name="arguments">The invalid command-line arguments.</param>
[Theory]
[InlineData("sms create --account-sid AC00000000000000000000000000000000 --from-number +15551234567")]
[InlineData("sms update --id 8 --account-sid AC00000000000000000000000000000000 --from-number +15551234567 --max-retries -1")]
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")]
public class OutputFormatterTests
{
/// <summary>Verifies that JSON output is written with indentation.</summary>
[Fact]
public void WriteJson_WritesIndentedJson()
{
@@ -20,6 +21,7 @@ public class OutputFormatterTests
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}
/// <summary>Verifies that an error message is written to standard error.</summary>
[Fact]
public void WriteError_WritesToStdErr()
{
@@ -35,6 +37,7 @@ public class OutputFormatterTests
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
}
/// <summary>Verifies that a table render includes the headers and all rows.</summary>
[Fact]
public void WriteTable_RendersHeadersAndRows()
{
@@ -61,6 +64,7 @@ public class OutputFormatterTests
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}
/// <summary>Verifies that a table with no rows renders only its headers.</summary>
[Fact]
public void WriteTable_EmptyRows_ShowsHeadersOnly()
{
@@ -77,6 +81,7 @@ public class OutputFormatterTests
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}
/// <summary>Verifies that table column widths adjust to the widest cell content.</summary>
[Fact]
public void WriteTable_ColumnWidthsAdjustToContent()
{
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
public class ProductionRootTests
{
/// <summary>Verifies that the production root command exposes the shared-schema command and the secured-write lifecycle subcommands.</summary>
[Fact]
public void ProductionRoot_ExposesSchemaLibraryAndSecuredWriteLifecycle()
{
@@ -4,16 +4,17 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Regression tests for CLI-002 (empty success body) and CLI-003 (non-JSON success
/// Regression tests for an empty success body and a non-JSON success
/// body) — both previously crashed table rendering with an unhandled exception.
/// </summary>
[Collection("Console")]
public class ResponseRenderingTests
{
/// <summary>Verifies that an empty response body in table format does not throw and yields exit code zero.</summary>
[Fact]
public void HandleResponse_EmptyBody_TableFormat_DoesNotThrow_ReturnsZero()
{
// CLI-002: a 200/204 with an empty body must be treated as "succeeded, no output".
// A 200/204 with an empty body must be treated as "succeeded, no output".
var writer = new StringWriter();
Console.SetOut(writer);
@@ -30,6 +31,7 @@ public class ResponseRenderingTests
}
}
/// <summary>Verifies that a whitespace-only response body in JSON format does not throw and yields exit code zero.</summary>
[Fact]
public void HandleResponse_EmptyBody_JsonFormat_DoesNotThrow_ReturnsZero()
{
@@ -49,10 +51,11 @@ public class ResponseRenderingTests
}
}
/// <summary>Verifies that a non-JSON success body in table format falls back to the raw body and yields exit code zero.</summary>
[Fact]
public void HandleResponse_NonJsonBody_TableFormat_FallsBackToRaw_ReturnsZero()
{
// CLI-003: a success status with a non-JSON body (e.g. proxy HTML error page)
// A success status with a non-JSON body (e.g. proxy HTML error page)
// must not crash; it should print the raw body verbatim.
var writer = new StringWriter();
Console.SetOut(writer);
@@ -62,6 +62,10 @@ public class SchemaSecuredParityTests
return (exit, captured.Item1, captured.headers);
}
/// <summary>Verifies that schema read and delete commands use the registered contract.</summary>
/// <param name="verb">The verb.</param>
/// <param name="command">The command.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("list", "ListSharedSchemas")]
[InlineData("get", "GetSharedSchema")]
@@ -75,6 +79,11 @@ public class SchemaSecuredParityTests
if (verb != "list") Assert.Equal(42, result.Envelope.GetProperty("payload").GetProperty("sharedSchemaId").GetInt32());
}
/// <summary>Verifies that schema write commands read the JSON file and preserve the contract.</summary>
/// <param name="verb">The verb.</param>
/// <param name="command">The command.</param>
/// <param name="includeScope">The include scope.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("create", "CreateSharedSchema", true)]
[InlineData("update", "UpdateSharedSchema", true)]
@@ -101,6 +110,8 @@ public class SchemaSecuredParityTests
finally { File.Delete(path); }
}
/// <summary>Verifies that a secured-write submit sends the value as JSON and leaves identity to the server.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Submit_UsesValueJsonAndServerOwnedIdentity()
{
@@ -117,6 +128,10 @@ public class SchemaSecuredParityTests
Assert.False(payload.TryGetProperty("operatorUser", out _));
}
/// <summary>Verifies that approve and reject decisions preserve the 64-bit identity and the comment.</summary>
/// <param name="verb">The verb.</param>
/// <param name="command">The command.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("approve", "ApproveSecuredWrite")]
[InlineData("reject", "RejectSecuredWrite")]
@@ -132,6 +147,12 @@ public class SchemaSecuredParityTests
Assert.False(payload.TryGetProperty("verifierUser", out _));
}
/// <summary>Verifies that server denials are returned without any local approval being performed.</summary>
/// <param name="verb">The verb.</param>
/// <param name="status">The status.</param>
/// <param name="code">The code.</param>
/// <param name="expected">The expected.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("submit", 403, "Forbidden", 2)]
[InlineData("approve", 403, "Forbidden", 2)]
@@ -145,6 +166,8 @@ public class SchemaSecuredParityTests
Assert.Equal(expected, result.Exit);
}
/// <summary>Verifies that the secured-write list preserves its filters and pagination.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SecuredList_PreservesFiltersAndPagination()
{
@@ -158,6 +181,10 @@ public class SchemaSecuredParityTests
Assert.Equal(25, payload.GetProperty("take").GetInt32());
}
/// <summary>Verifies that schema server errors map to the documented exit codes.</summary>
/// <param name="status">The status.</param>
/// <param name="expected">The expected.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(403, 2)]
[InlineData(404, 1)]
@@ -168,6 +195,8 @@ public class SchemaSecuredParityTests
Assert.Equal(expected, result.Exit);
}
/// <summary>Verifies that a malformed submit value returns a JSON error before any HTTP call is made.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MalformedSubmitValue_ReturnsJsonErrorBeforeHttp()
{
@@ -183,6 +212,11 @@ public class SchemaSecuredParityTests
finally { Console.SetError(original); }
}
/// <summary>Verifies that the approve exit code is derived from the server execution outcome.</summary>
/// <param name="status">The status.</param>
/// <param name="expected">The expected.</param>
/// <param name="format">The format.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("Executed", 0, "json")]
[InlineData("Failed", 1, "json")]
@@ -204,6 +238,9 @@ public class SchemaSecuredParityTests
finally { Console.SetOut(original); }
}
/// <summary>Verifies that an invalid server outcome in an approve response is reported as an error.</summary>
/// <param name="response">The response.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("{}")]
[InlineData("{\"status\":\"Approved\"}")]
@@ -216,6 +253,9 @@ public class SchemaSecuredParityTests
Assert.Equal(1, result.Exit);
}
/// <summary>Verifies that the parser rejects commands that omit required arguments.</summary>
/// <param name="group">The group.</param>
/// <param name="verb">The verb.</param>
[Theory]
[InlineData("shared-schema", "update")]
[InlineData("secured-write", "submit")]
@@ -223,6 +263,9 @@ public class SchemaSecuredParityTests
public void RequiredArguments_AreRejectedByParser(string group, string verb)
=> Assert.NotEmpty(BuildRoot().Parse([group, verb]).Errors);
/// <summary>Verifies that failures reading the schema file return an error before any HTTP call is made.</summary>
/// <param name="malformed">The malformed.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -4,13 +4,14 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Regression tests for CLI-016 — <c>WriteAsTable</c> previously derived the table
/// Regression tests for the table header union: <c>WriteAsTable</c> previously derived the table
/// header set from the first array element only, so any property unique to a later
/// element was silently dropped from the rendered table.
/// </summary>
[Collection("Console")]
public class TableHeaderUnionTests
{
/// <summary>Verifies that a table rendered from a heterogeneous array includes all columns.</summary>
[Fact]
public void HandleResponse_TableFormat_HeterogeneousArray_IncludesAllColumns()
{
@@ -37,6 +38,7 @@ public class TableHeaderUnionTests
}
}
/// <summary>Verifies that a table rendered from a heterogeneous array preserves the first-seen column order.</summary>
[Fact]
public void HandleResponse_TableFormat_HeterogeneousArray_PreservesFirstSeenColumnOrder()
{
@@ -63,6 +65,7 @@ public class TableHeaderUnionTests
}
}
/// <summary>Verifies that a table still renders all rows when the first element has an extra column.</summary>
[Fact]
public void HandleResponse_TableFormat_FirstElementHasExtraColumn_StillRendersAllRows()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Tests for the compact <c>template list</c>/<c>get</c> table projection (followup #6):
/// Tests for the compact <c>template list</c>/<c>get</c> table projection:
/// the full per-template attribute/alarm/script dumps are collapsed to counts so table
/// output is usable in a terminal, while the array/object shape is preserved.
///
@@ -48,6 +48,7 @@ public class TemplateTableProjectionTests
]
""";
/// <summary>Verifies that projecting an array drops the per-member arrays and keeps their counts.</summary>
[Fact]
public void ProjectSummary_Array_DropsMemberArraysAndKeepsCounts()
{
@@ -82,6 +83,7 @@ public class TemplateTableProjectionTests
Assert.Equal(JsonValueKind.Null, second.GetProperty("description").ValueKind);
}
/// <summary>Verifies that projecting a single object produces a compact object.</summary>
[Fact]
public void ProjectSummary_SingleObject_ProducesCompactObject()
{
@@ -167,6 +169,7 @@ public class TemplateTableProjectionTests
Assert.Equal(42, doc.RootElement.GetProperty("#attrs").GetInt32());
}
/// <summary>Verifies that non-JSON input is returned verbatim.</summary>
[Fact]
public void ProjectSummary_NonJson_ReturnedVerbatim()
{
@@ -174,6 +177,7 @@ public class TemplateTableProjectionTests
Assert.Equal(notJson, TemplateTableProjection.ProjectSummary(notJson));
}
/// <summary>Verifies that the summary projection is substantially smaller than the full dump.</summary>
[Fact]
public void ProjectSummary_IsSubstantiallySmallerThanFullDump()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Regression tests for CLI-014. The <c>Update*Command</c> records in Commons carry
/// Regression tests for the update commands. The <c>Update*Command</c> records in Commons carry
/// non-nullable "core" fields (e.g. <c>string Name</c>, <c>string Protocol</c>,
/// <c>string Script</c>) — an update is a <em>whole-entity replace</em>, not a sparse
/// patch. The CLI must therefore mark those core flags as <c>Required</c>: making them
@@ -37,34 +37,42 @@ public class UpdateCommandContractTests
}
}
/// <summary>Verifies that the template update command marks its core fields as required.</summary>
[Fact]
public void TemplateUpdate_CoreFieldsRequired()
=> AssertRequired(UpdateCommand(TemplateCommands.Build(Url, Format, Username, Password), "update"), "--name");
/// <summary>Verifies that the template attribute update command marks its core fields as required.</summary>
[Fact]
public void TemplateAttributeUpdate_CoreFieldsRequired()
=> AssertRequired(UpdateCommand(TemplateCommands.Build(Url, Format, Username, Password), "attribute", "update"), "--name", "--data-type");
/// <summary>Verifies that the template alarm update command marks its core fields as required.</summary>
[Fact]
public void TemplateAlarmUpdate_CoreFieldsRequired()
=> AssertRequired(UpdateCommand(TemplateCommands.Build(Url, Format, Username, Password), "alarm", "update"), "--name", "--trigger-type", "--priority");
/// <summary>Verifies that the template script update command marks its core fields as required.</summary>
[Fact]
public void TemplateScriptUpdate_CoreFieldsRequired()
=> AssertRequired(UpdateCommand(TemplateCommands.Build(Url, Format, Username, Password), "script", "update"), "--name", "--code");
/// <summary>Verifies that the site update command marks its core fields as required.</summary>
[Fact]
public void SiteUpdate_CoreFieldsRequired()
=> AssertRequired(UpdateCommand(SiteCommands.Build(Url, Format, Username, Password), "update"), "--name");
/// <summary>Verifies that the data connection update command marks its core fields as required.</summary>
[Fact]
public void DataConnectionUpdate_CoreFieldsRequired()
=> AssertRequired(UpdateCommand(DataConnectionCommands.Build(Url, Format, Username, Password), "update"), "--name", "--protocol");
/// <summary>Verifies that the external system update command marks its core fields as required.</summary>
[Fact]
public void ExternalSystemUpdate_CoreFieldsRequired()
=> AssertRequired(UpdateCommand(ExternalSystemCommands.Build(Url, Format, Username, Password), "update"), "--name", "--endpoint-url", "--auth-type");
/// <summary>Verifies that the notification update command requires only the name and leaves the recipient options conditionally validated.</summary>
[Fact]
public void NotificationUpdate_CoreFieldsRequired()
{
@@ -83,6 +91,7 @@ public class UpdateCommandContractTests
}
}
/// <summary>Verifies that the SMS update command requires only the id and account SID and leaves the sender and token options optional.</summary>
[Fact]
public void SmsUpdate_CoreFieldsRequired()
{
@@ -101,10 +110,12 @@ public class UpdateCommandContractTests
}
}
/// <summary>Verifies that the API method update command marks its script as required.</summary>
[Fact]
public void ApiMethodUpdate_CoreFieldsRequired()
=> AssertRequired(UpdateCommand(ApiMethodCommands.Build(Url, Format, Username, Password), "update"), "--script");
/// <summary>Verifies that the external system method update command keeps all of its fields optional because it is a sparse update.</summary>
[Fact]
public void ExternalSystemMethodUpdate_IsGenuinelySparse_CoreFieldsOptional()
{
@@ -3,12 +3,14 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Regression tests for CLI-004 — a malformed <c>--url</c> previously reached
/// Regression tests for URL validation: a malformed <c>--url</c> previously reached
/// <c>new Uri(...)</c> in the <see cref="ZB.MOM.WW.ScadaBridge.CLI.ManagementHttpClient"/> constructor
/// and threw an unhandled <see cref="UriFormatException"/>.
/// </summary>
public class UrlValidationTests
{
/// <summary>Verifies that absolute HTTP and HTTPS URLs are accepted as management URLs.</summary>
/// <param name="url">The candidate management URL.</param>
[Theory]
[InlineData("http://localhost:9001")]
[InlineData("https://central-host:5000/")]
@@ -18,6 +20,8 @@ public class UrlValidationTests
Assert.True(CommandHelpers.IsValidManagementUrl(url));
}
/// <summary>Verifies that malformed or non-HTTP URLs are rejected as management URLs.</summary>
/// <param name="url">The candidate management URL.</param>
[Theory]
[InlineData("localhost:9001")] // no scheme
[InlineData("")] // empty