fix(cli): resolve CLI-008..013 — format validation, exit-code semantics, debug-stream cancellation/disposal, test coverage
This commit is contained in:
88
tests/ScadaLink.CLI.Tests/CommandTreeTests.cs
Normal file
88
tests/ScadaLink.CLI.Tests/CommandTreeTests.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.CommandLine;
|
||||
using ScadaLink.CLI.Commands;
|
||||
using ScadaLink.Commons.Messages.Management;
|
||||
|
||||
namespace ScadaLink.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();
|
||||
|
||||
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),
|
||||
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),
|
||||
};
|
||||
|
||||
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();
|
||||
Assert.Equal(14, groups.Count);
|
||||
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
|
||||
}
|
||||
|
||||
[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."));
|
||||
}
|
||||
|
||||
[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))]
|
||||
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));
|
||||
}
|
||||
}
|
||||
100
tests/ScadaLink.CLI.Tests/DebugStreamTests.cs
Normal file
100
tests/ScadaLink.CLI.Tests/DebugStreamTests.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System.Threading.Tasks;
|
||||
using ScadaLink.CLI.Commands;
|
||||
|
||||
namespace ScadaLink.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).
|
||||
/// </summary>
|
||||
public class DebugStreamTests
|
||||
{
|
||||
// --- CLI-010: connect-failure classification --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ClassifyConnectFailure_OperationCanceled_IsTreatedAsCancellation()
|
||||
{
|
||||
// Ctrl+C while StartAsync is still establishing the connection throws
|
||||
// OperationCanceledException — this is a graceful cancellation, not a failure.
|
||||
var result = DebugStreamHelpers.ClassifyConnectFailure(
|
||||
new OperationCanceledException(), cancellationRequested: true);
|
||||
|
||||
Assert.True(result.IsCancellation);
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyConnectFailure_TaskCanceled_WhenCancelRequested_IsCancellation()
|
||||
{
|
||||
var result = DebugStreamHelpers.ClassifyConnectFailure(
|
||||
new TaskCanceledException(), cancellationRequested: true);
|
||||
|
||||
Assert.True(result.IsCancellation);
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyConnectFailure_RealException_IsConnectionFailure()
|
||||
{
|
||||
var result = DebugStreamHelpers.ClassifyConnectFailure(
|
||||
new HttpRequestException("connection refused"), cancellationRequested: false);
|
||||
|
||||
Assert.False(result.IsCancellation);
|
||||
Assert.Equal(1, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyConnectFailure_CanceledExceptionButNoCancelRequested_IsConnectionFailure()
|
||||
{
|
||||
// A cancellation that did not originate from the user (e.g. a server-side abort)
|
||||
// is still a real connection failure.
|
||||
var result = DebugStreamHelpers.ClassifyConnectFailure(
|
||||
new OperationCanceledException(), cancellationRequested: false);
|
||||
|
||||
Assert.False(result.IsCancellation);
|
||||
Assert.Equal(1, result.ExitCode);
|
||||
}
|
||||
|
||||
// --- CLI-012: deterministic exit-code resolution ----------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveStreamExitCodeAsync_TerminationResultSet_PrefersThatResult()
|
||||
{
|
||||
// OnStreamTerminated set exitTcs to 1 — that must win even on the Ctrl+C path.
|
||||
var tcs = new TaskCompletionSource<int>();
|
||||
tcs.SetResult(1);
|
||||
|
||||
var code = await DebugStreamHelpers.ResolveStreamExitCodeAsync(tcs.Task);
|
||||
|
||||
Assert.Equal(1, code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveStreamExitCodeAsync_NoResult_ReturnsZero()
|
||||
{
|
||||
// Pure Ctrl+C: exitTcs never completed — graceful shutdown, exit 0.
|
||||
var tcs = new TaskCompletionSource<int>();
|
||||
|
||||
var code = await DebugStreamHelpers.ResolveStreamExitCodeAsync(tcs.Task);
|
||||
|
||||
Assert.Equal(0, code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveStreamExitCodeAsync_ResultArrivesDuringGrace_IsObserved()
|
||||
{
|
||||
// A stream termination racing with Ctrl+C: the result lands shortly after the
|
||||
// wait was cancelled. The grace period must let it be observed deterministically.
|
||||
var tcs = new TaskCompletionSource<int>();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(20);
|
||||
tcs.TrySetResult(1);
|
||||
});
|
||||
|
||||
var code = await DebugStreamHelpers.ResolveStreamExitCodeAsync(tcs.Task);
|
||||
|
||||
Assert.Equal(1, code);
|
||||
}
|
||||
}
|
||||
59
tests/ScadaLink.CLI.Tests/ExitCodeTests.cs
Normal file
59
tests/ScadaLink.CLI.Tests/ExitCodeTests.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using ScadaLink.CLI;
|
||||
using ScadaLink.CLI.Commands;
|
||||
|
||||
namespace ScadaLink.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CLI-009 — 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
|
||||
/// generic error (exit 1). Exit code 2 now keys off either signal.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class ExitCodeTests
|
||||
{
|
||||
private static int HandleQuietly(ManagementResponse response)
|
||||
{
|
||||
var errWriter = new StringWriter();
|
||||
Console.SetError(errWriter);
|
||||
try
|
||||
{
|
||||
return CommandHelpers.HandleResponse(response, "json");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_Http403_ReturnsTwo()
|
||||
{
|
||||
Assert.Equal(2, HandleQuietly(new ManagementResponse(403, null, "Forbidden", "FORBIDDEN")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("UNAUTHORIZED")]
|
||||
[InlineData("FORBIDDEN")]
|
||||
[InlineData("unauthorized")]
|
||||
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.
|
||||
Assert.Equal(2, HandleQuietly(new ManagementResponse(400, null, "Access denied", code)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_GenericError_ReturnsOne()
|
||||
{
|
||||
Assert.Equal(1, HandleQuietly(new ManagementResponse(400, null, "Validation failed", "INVALID_ARGUMENT")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_AuthenticationFailure_ReturnsOne()
|
||||
{
|
||||
// Authentication failure (bad credentials) is exit 1, distinct from authorization.
|
||||
Assert.Equal(1, HandleQuietly(new ManagementResponse(401, null, "Invalid credentials", "AUTH_FAILED")));
|
||||
}
|
||||
}
|
||||
44
tests/ScadaLink.CLI.Tests/FormatOptionValidationTests.cs
Normal file
44
tests/ScadaLink.CLI.Tests/FormatOptionValidationTests.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.CommandLine;
|
||||
using ScadaLink.CLI.Commands;
|
||||
|
||||
namespace ScadaLink.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CLI-008 — the <c>--format</c> option 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
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("json")]
|
||||
[InlineData("table")]
|
||||
public void FormatOption_AcceptsValidValues(string value)
|
||||
{
|
||||
var formatOption = CliOptions.CreateFormatOption();
|
||||
var root = new RootCommand();
|
||||
root.Add(formatOption);
|
||||
|
||||
var result = root.Parse(new[] { "--format", value });
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("tabel")]
|
||||
[InlineData("xml")]
|
||||
[InlineData("yaml")]
|
||||
[InlineData("")]
|
||||
[InlineData("JSON")] // case-sensitive: documented values are lowercase
|
||||
public void FormatOption_RejectsInvalidValues(string value)
|
||||
{
|
||||
var formatOption = CliOptions.CreateFormatOption();
|
||||
var root = new RootCommand();
|
||||
root.Add(formatOption);
|
||||
|
||||
var result = root.Parse(new[] { "--format", value });
|
||||
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
}
|
||||
106
tests/ScadaLink.CLI.Tests/ManagementHttpClientTests.cs
Normal file
106
tests/ScadaLink.CLI.Tests/ManagementHttpClientTests.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using ScadaLink.CLI;
|
||||
|
||||
namespace ScadaLink.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CLI-013 — <see cref="ManagementHttpClient.SendCommandAsync"/>
|
||||
/// (success, error-body parsing, connection-failure, and timeout paths) was untested.
|
||||
/// Uses a stub <see cref="HttpMessageHandler"/> so no live server is required.
|
||||
/// </summary>
|
||||
public class ManagementHttpClientTests
|
||||
{
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _responder;
|
||||
|
||||
public StubHandler(HttpStatusCode status, string body)
|
||||
: this((_, _) => Task.FromResult(new HttpResponseMessage(status)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
}))
|
||||
{
|
||||
}
|
||||
|
||||
public StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
|
||||
{
|
||||
_responder = responder;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> _responder(request, cancellationToken);
|
||||
}
|
||||
|
||||
private static ManagementHttpClient ClientWith(StubHandler handler)
|
||||
=> new(new HttpClient(handler), "http://localhost:9001", "user", "pass");
|
||||
|
||||
[Fact]
|
||||
public async Task SendCommandAsync_Success_ReturnsJsonData()
|
||||
{
|
||||
using var client = ClientWith(new StubHandler(HttpStatusCode.OK, "{\"id\":1}"));
|
||||
|
||||
var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
Assert.Equal("{\"id\":1}", response.JsonData);
|
||||
Assert.Null(response.Error);
|
||||
Assert.Null(response.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendCommandAsync_ErrorBody_ParsesErrorAndCode()
|
||||
{
|
||||
using var client = ClientWith(new StubHandler(
|
||||
HttpStatusCode.BadRequest, "{\"error\":\"Bad input\",\"code\":\"INVALID_ARGUMENT\"}"));
|
||||
|
||||
var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(400, response.StatusCode);
|
||||
Assert.Null(response.JsonData);
|
||||
Assert.Equal("Bad input", response.Error);
|
||||
Assert.Equal("INVALID_ARGUMENT", response.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendCommandAsync_NonJsonErrorBody_FallsBackToRawBody()
|
||||
{
|
||||
using var client = ClientWith(new StubHandler(
|
||||
HttpStatusCode.BadGateway, "<html>Bad Gateway</html>"));
|
||||
|
||||
var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(502, response.StatusCode);
|
||||
Assert.Equal("<html>Bad Gateway</html>", response.Error);
|
||||
Assert.Null(response.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendCommandAsync_ConnectionFailure_ReturnsStatusZero()
|
||||
{
|
||||
using var client = ClientWith(new StubHandler((_, _) =>
|
||||
throw new HttpRequestException("connection refused")));
|
||||
|
||||
var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(0, response.StatusCode);
|
||||
Assert.Equal("CONNECTION_FAILED", response.ErrorCode);
|
||||
Assert.Contains("connection refused", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendCommandAsync_Timeout_Returns504()
|
||||
{
|
||||
using var client = ClientWith(new StubHandler(async (_, ct) =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}));
|
||||
|
||||
var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Equal(504, response.StatusCode);
|
||||
Assert.Equal("TIMEOUT", response.ErrorCode);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user