using System.CommandLine; using ScadaLink.CLI.Commands; using ScadaLink.Commons.Messages.Management; namespace ScadaLink.CLI.Tests; /// /// 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 (so command-name derivation /// never throws at runtime). /// public class CommandTreeTests { private static readonly Option Url = new("--url") { Recursive = true }; private static readonly Option Username = new("--username") { Recursive = true }; private static readonly Option Password = new("--password") { Recursive = true }; private static readonly Option Format = CliOptions.CreateFormatOption(); // NOTE: this list MUST stay in sync with the rootCommand.Add(...) calls in // src/ScadaLink.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 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), }; private static IEnumerable 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 (16): // template, instance, site, deploy, data-connection, external-system, // notification, security, audit-config, audit, health, debug, // shared-script, db-connection, api-method, bundle. Assert.Equal(16, 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); } [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))] 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)); } }