Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs
T

365 lines
16 KiB
C#

using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
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
/// 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
/// never throws at runtime).
/// </summary>
public class CommandTreeTests
{
private static readonly Option<string> Url = new("--url") { Recursive = true };
private static readonly Option<string> Username = new("--username") { Recursive = true };
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
// NOTE: this list MUST stay in sync with the rootCommand.Add(...) calls in
// src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs. When a new command group is added (or one is
// removed/renamed), update this array and bump the count assertion in
// AllCommandGroups_Build_WithoutThrowing accordingly.
private static IEnumerable<Command> AllCommandGroups() => new[]
{
TemplateCommands.Build(Url, Format, Username, Password),
InstanceCommands.Build(Url, Format, Username, Password),
SiteCommands.Build(Url, Format, Username, Password),
DeployCommands.Build(Url, Format, Username, Password),
DataConnectionCommands.Build(Url, Format, Username, Password),
ExternalSystemCommands.Build(Url, Format, Username, Password),
NotificationCommands.Build(Url, Format, Username, Password),
SecurityCommands.Build(Url, Format, Username, Password),
AuditLogCommands.Build(Url, Format, Username, Password),
AuditCommands.Build(Url, Format, Username, Password),
HealthCommands.Build(Url, Format, Username, Password),
DebugCommands.Build(Url, Format, Username, Password),
SharedScriptCommands.Build(Url, Format, Username, Password),
DbConnectionCommands.Build(Url, Format, Username, Password),
ApiMethodCommands.Build(Url, Format, Username, Password),
BundleCommands.Build(Url, Format, Username, Password),
CachedCallCommands.Build(Url, Format, Username, Password),
};
private static IEnumerable<Command> LeafCommands(Command command)
{
if (command.Subcommands.Count == 0)
{
yield return command;
yield break;
}
foreach (var sub in command.Subcommands)
foreach (var leaf in LeafCommands(sub))
yield return leaf;
}
[Fact]
public void AllCommandGroups_Build_WithoutThrowing()
{
var groups = AllCommandGroups().ToList();
// CLI-022: bump this count whenever a new top-level command group is
// registered in Program.cs. Current registered groups (17):
// template, instance, site, deploy, data-connection, external-system,
// notification, security, audit-config, audit, health, debug,
// shared-script, db-connection, api-method, bundle, cached-call.
Assert.Equal(17, groups.Count);
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
}
[Fact]
public void AllCommandGroups_Contains_AuditAndBundle()
{
// CLI-022: 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();
Assert.Contains("audit", groupNames);
Assert.Contains("bundle", groupNames);
}
[Fact]
public void AuditCommandGroup_HasQueryExportAndVerifyChain()
{
// CLI-022: 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();
Assert.Contains("query", subNames);
Assert.Contains("export", subNames);
Assert.Contains("verify-chain", subNames);
}
[Fact]
public void BundleCommandGroup_HasExportPreviewAndImport()
{
// CLI-022: 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);
Assert.Contains("preview", subNames);
Assert.Contains("import", subNames);
}
[Fact]
public void EveryLeafCommand_HasAnAction()
{
// A leaf command with no action is dead wiring — invoking it would do nothing.
var leaves = AllCommandGroups().SelectMany(LeafCommands).ToList();
Assert.NotEmpty(leaves);
Assert.All(leaves, leaf =>
Assert.True(leaf.Action != null, $"Leaf command '{leaf.Name}' has no action."));
}
[Fact]
public void TemplateCompositionDelete_IsKeyedByIdOnly()
{
// CLI-015: 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);
var composition = template.Subcommands.Single(c => c.Name == "composition");
var delete = composition.Subcommands.Single(c => c.Name == "delete");
var optionNames = delete.Options.Select(o => o.Name).ToList();
Assert.Contains("--id", optionNames);
Assert.DoesNotContain("--template-id", optionNames);
Assert.DoesNotContain("--instance-name", optionNames);
}
[Fact]
public void TemplateNativeAlarmSource_HasAddListRemove()
{
var template = TemplateCommands.Build(Url, Format, Username, Password);
var group = template.Subcommands.Single(c => c.Name == "native-alarm-source");
var subNames = group.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("add", subNames);
Assert.Contains("list", subNames);
Assert.Contains("remove", subNames);
}
[Fact]
public void Deploy_HasSiteVerb_WithRequiredSiteId()
{
// WP2.5: 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);
var site = deploy.Subcommands.Single(c => c.Name == "site");
// --site-id is REQUIRED here, unlike `deploy artifacts` where omitting it
// means fleet-wide. A bulk instance deploy must never be accidentally
// fleet-wide.
Assert.NotEmpty(site.Parse([]).Errors);
Assert.Empty(site.Parse(["--site-id", "3"]).Errors);
}
[Fact]
public void InstanceNativeAlarmSource_HasSetAndClear()
{
var instance = InstanceCommands.Build(Url, Format, Username, Password);
var group = instance.Subcommands.Single(c => c.Name == "native-alarm-source");
var subNames = group.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("set", subNames);
Assert.Contains("clear", subNames);
}
[Fact]
public void TemplateFolder_HasFullManagementCommandParity()
{
// CLI/management parity follow-up (docs/plans/2026-05-11-templates-folder-hierarchy.md):
// the folder management commands shipped without any CLI surface. Pin the
// 1:1 verb set so a verb cannot silently disappear again.
var template = TemplateCommands.Build(Url, Format, Username, Password);
var folder = template.Subcommands.Single(c => c.Name == "folder");
var subNames = folder.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("list", subNames);
Assert.Contains("create", subNames);
Assert.Contains("rename", subNames);
Assert.Contains("move", subNames);
Assert.Contains("reorder", subNames);
Assert.Contains("delete", subNames);
}
[Fact]
public void TemplateFolderCreate_ParentIdIsOptional_SoRootIsReachable()
{
// Omitting --parent-id must parse cleanly — that is how a folder is created
// at the tree root (null ParentFolderId on CreateTemplateFolderCommand).
var template = TemplateCommands.Build(Url, Format, Username, Password);
var folder = template.Subcommands.Single(c => c.Name == "folder");
var create = folder.Subcommands.Single(c => c.Name == "create");
var parentOption = create.Options.Single(o => o.Name == "--parent-id");
var rootParse = create.Parse(new[] { "--name", "Dev" });
Assert.Empty(rootParse.Errors);
Assert.Null(rootParse.GetValue((Option<int?>)parentOption));
var nestedParse = create.Parse(new[] { "--name", "Sub", "--parent-id", "7" });
Assert.Empty(nestedParse.Errors);
Assert.Equal(7, nestedParse.GetValue((Option<int?>)parentOption));
}
[Fact]
public void TemplateFolderMove_ParentIdIsOptional_SoRootIsReachable()
{
var template = TemplateCommands.Build(Url, Format, Username, Password);
var folder = template.Subcommands.Single(c => c.Name == "folder");
var move = folder.Subcommands.Single(c => c.Name == "move");
var parentOption = move.Options.Single(o => o.Name == "--parent-id");
var parse = move.Parse(new[] { "--id", "3" });
Assert.Empty(parse.Errors);
Assert.Null(parse.GetValue((Option<int?>)parentOption));
}
[Theory]
[InlineData("up")]
[InlineData("down")]
public void TemplateFolderReorder_AcceptsUpAndDown(string direction)
{
var template = TemplateCommands.Build(Url, Format, Username, Password);
var folder = template.Subcommands.Single(c => c.Name == "folder");
var reorder = folder.Subcommands.Single(c => c.Name == "reorder");
var parse = reorder.Parse(new[] { "--id", "3", "--direction", direction });
Assert.Empty(parse.Errors);
}
[Theory]
[InlineData("sideways")]
// AcceptOnlyFromAmong is case-SENSITIVE (same contract as the audit
// --channel / --kind / --status options), so the literals are lowercase only.
[InlineData("Up")]
public void TemplateFolderReorder_RejectsAnythingElse(string direction)
{
var template = TemplateCommands.Build(Url, Format, Username, Password);
var folder = template.Subcommands.Single(c => c.Name == "folder");
var reorder = folder.Subcommands.Single(c => c.Name == "reorder");
var parse = reorder.Parse(new[] { "--id", "3", "--direction", direction });
Assert.NotEmpty(parse.Errors);
}
[Fact]
public void TemplateFolderReorder_RequiresDirection()
{
var template = TemplateCommands.Build(Url, Format, Username, Password);
var folder = template.Subcommands.Single(c => c.Name == "folder");
var reorder = folder.Subcommands.Single(c => c.Name == "reorder");
var parse = reorder.Parse(new[] { "--id", "3" });
Assert.NotEmpty(parse.Errors);
}
[Fact]
public void SiteArea_HasMoveVerb()
{
// CLI/management parity follow-up (docs/plans/2026-05-11-deployment-topology-page-design.md):
// AreaService.MoveAreaAsync had no management command and no CLI verb.
var site = SiteCommands.Build(Url, Format, Username, Password);
var area = site.Subcommands.Single(c => c.Name == "area");
var subNames = area.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("move", subNames);
}
[Fact]
public void SiteAreaMove_ParentIdIsOptional_SoSiteRootIsReachable()
{
// Omitting --parent-id is how an area is moved to the site root
// (null NewParentAreaId on MoveAreaCommand).
var site = SiteCommands.Build(Url, Format, Username, Password);
var area = site.Subcommands.Single(c => c.Name == "area");
var move = area.Subcommands.Single(c => c.Name == "move");
var parentOption = move.Options.Single(o => o.Name == "--parent-id");
var rootParse = move.Parse(new[] { "--id", "3" });
Assert.Empty(rootParse.Errors);
Assert.Null(rootParse.GetValue((Option<int?>)parentOption));
var reparentParse = move.Parse(new[] { "--id", "3", "--parent-id", "2" });
Assert.Empty(reparentParse.Errors);
Assert.Equal(2, reparentParse.GetValue((Option<int?>)parentOption));
}
[Fact]
public void SiteAreaMove_RequiresId()
{
var site = SiteCommands.Build(Url, Format, Username, Password);
var area = site.Subcommands.Single(c => c.Name == "area");
var move = area.Subcommands.Single(c => c.Name == "move");
var parse = move.Parse(Array.Empty<string>());
Assert.NotEmpty(parse.Errors);
}
[Fact]
public void TemplateList_HasSkipAndTakePagingOptions()
{
// arch-review P2: `template list` gained additive --skip/--take offset paging.
var template = TemplateCommands.Build(Url, Format, Username, Password);
var list = template.Subcommands.Single(c => c.Name == "list");
var optionNames = list.Options.Select(o => o.Name).ToList();
Assert.Contains("--skip", optionNames);
Assert.Contains("--take", optionNames);
}
[Fact]
public void InstanceList_HasSkipAndTakePagingOptions()
{
// arch-review P2: `instance list` gained additive --skip/--take offset paging.
var instance = InstanceCommands.Build(Url, Format, Username, Password);
var list = instance.Subcommands.Single(c => c.Name == "list");
var optionNames = list.Options.Select(o => o.Name).ToList();
Assert.Contains("--skip", optionNames);
Assert.Contains("--take", optionNames);
}
[Fact]
public void InstanceList_ParsesTakeOptionValue()
{
// Parse-level check that --take binds to an int value on the list command.
var instance = InstanceCommands.Build(Url, Format, Username, Password);
var list = instance.Subcommands.Single(c => c.Name == "list");
var takeOption = list.Options.Single(o => o.Name == "--take");
var parse = list.Parse(new[] { "--skip", "10", "--take", "5" });
Assert.Empty(parse.Errors);
Assert.Equal(5, parse.GetValue((Option<int?>)takeOption));
}
[Theory]
[InlineData(typeof(GetInstanceCommand))]
[InlineData(typeof(ListSitesCommand))]
[InlineData(typeof(CreateTemplateCommand))]
[InlineData(typeof(SetConnectionBindingsCommand))]
[InlineData(typeof(SetInstanceOverridesCommand))]
[InlineData(typeof(DebugSnapshotCommand))]
[InlineData(typeof(MgmtDeployInstanceCommand))]
[InlineData(typeof(MgmtDeploySiteCommand))]
[InlineData(typeof(QueryAuditLogCommand))]
[InlineData(typeof(ExportBundleCommand))]
[InlineData(typeof(PreviewBundleCommand))]
[InlineData(typeof(ImportBundleCommand))]
[InlineData(typeof(AddTemplateNativeAlarmSourceCommand))]
[InlineData(typeof(SetInstanceNativeAlarmSourceOverrideCommand))]
[InlineData(typeof(MoveAreaCommand))]
[InlineData(typeof(ListTemplateFoldersCommand))]
[InlineData(typeof(CreateTemplateFolderCommand))]
[InlineData(typeof(RenameTemplateFolderCommand))]
[InlineData(typeof(MoveTemplateFolderCommand))]
[InlineData(typeof(ReorderTemplateFolderCommand))]
[InlineData(typeof(DeleteTemplateFolderCommand))]
public void CommandPayloadTypes_ResolveViaRegistry(Type commandType)
{
// GetCommandName throws ArgumentException for an unregistered type — the CLI
// calls it for every command it sends, so each must round-trip.
var name = ManagementCommandRegistry.GetCommandName(commandType);
Assert.False(string.IsNullOrWhiteSpace(name));
Assert.Equal(commandType, ManagementCommandRegistry.Resolve(name));
}
}