88638d774a
Two verified-absent parity gaps between the service layer and the CLI / ManagementActor command surface, both left as follow-ups by the 2026-05-11 design plans. (1) area move. AreaService.MoveAreaAsync had existed since the deployment topology page shipped but was reachable only from the Blazor UI. Adds MoveAreaCommand(AreaId, NewParentAreaId?) to Commons, a ManagementActor dispatch arm delegating straight to AreaService.MoveAreaAsync (not-found / self-parent / descendant-cycle / cross-site / name-collision all surface as the standard curated ManagementCommandException failure response; the service writes its own "Move" audit row), and the CLI verb `site area move --id [--parent-id]`. Omitting --parent-id moves the area to the site root, matching the command's nullable NewParentAreaId. The command carries the SAME any-of [Designer, Deployer] gate as CreateArea/UpdateArea/DeleteArea (arch-review C6): re-parenting is the same structural authoring act, exposed on the same two surfaces. Placed under the existing `site area` group rather than a new top-level `area` group, alongside its create/update/delete siblings. (2) template folder verbs. The five folder management commands have been handled by ManagementActor since the folder-hierarchy plan, but the promised CLI surface was never written. Adds `template folder list|create|rename|move|reorder|delete` mapping 1:1 onto ListTemplateFolders / CreateTemplateFolder / RenameTemplateFolder / MoveTemplateFolder / ReorderTemplateFolder / DeleteTemplateFolder. --parent-id is omitted to target the tree root; --direction takes the lowercase literals up/down, validated at parse time by AcceptOnlyFromAmong (same case-sensitive contract as the audit --channel/--kind/--status options). Follow-on updates: the frozen authorization matrix gains its MoveArea entry (reflection-driven, so a missing entry would have failed CI); CommandTreeTests pins both new verb sets plus the omit-parent-id-means-root parse behaviour and registry round-trips; ManagementActorTests covers the MoveArea role gate and the delegate-to-service success/root/cycle/not-found paths; the CLI README and Component-ManagementService.md document the new surface (the latter also gained the previously-undocumented ReorderTemplateFolder); both plan docs' follow-up lines are marked done.
348 lines
15 KiB
C#
348 lines
15 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 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(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));
|
|
}
|
|
}
|