Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs
T
Joseph Doherty b34ec10f8b fix(deploymentmanager): bulk site deploy skips up-to-date instances and un-poisons the shared DbContext (arch-review Low P7-5 + P7-6)
P7-6 (behaviour fix, plan decision 1): DeploySiteAsync redeployed EVERY
deployable instance unconditionally. A redeploy is not idempotent at the site —
it resets static attribute overrides and clears native alarm state by design — so
one site-wide push destroyed live operational state on every unchanged instance
in the batch, which is normally most of them. Bulk deploy now pre-filters with
IStaleInstanceProbe (the same seam the bundle importer and Deployments page use;
its memo timing was fixed by the Medium wave's P7-4): an instance whose freshly
computed revision hash still matches its DeployedConfigSnapshot is reported in the
existing additive SkippedInstances list with reason "up-to-date" and never
flattened, staged or sent. The filter is deliberately conservative — every
uncertainty deploys: only an Enabled instance qualifies, a missing snapshot
disqualifies, a prior record that ShouldQuerySiteBeforeRedeploy flags goes through
prepare so the reconcile that HEALS it still runs, and a null/faulting probe means
"indeterminate". New additive MgmtDeploySiteCommand.Force (default false = the new
filtered behaviour) plus CLI `deploy site --force` restores the unconditional push
for the case central cannot see (restored volume, rebuilt node). Composes with the
Medium wave's P7-2: Disabled wins — force suppresses the up-to-date filter only,
never the Disabled skip.

P7-5 (verified, then fixed): the cascade REPRODUCES. All three bulk phases share
one scoped DbContext, and a failed SaveChanges does not untrack the offending
entity — EF re-attempts it on the next save, including MarkDeploymentFailedAsync's
cleanup write. Measured on a real SQLite-backed ScadaBridgeDbContext: a permanent
save failure on instance 1's post-success side effects failed instances 2, 3 and 4
and left all three deployment records stranded InProgress. MarkDeploymentFailedAsync
now detaches the pending changes (new IDeploymentManagerRepository.DetachPendingChanges)
when the cleanup save fails and retries the Failed write once on the clean tracker,
so later instances save cleanly and the record reaches a terminal status. The
DeployFailed audit row moved outside the status-write try (best-effort, must not be
lost because the status write failed).

Tests: 5 new DeploySiteAsyncTests (skip + reason + no site round-trip; --force
deploys; Disabled skipped under both force values; the three not-enough-to-skip
guards), 2 new IntegrationTests over a real EF context (the poisoned-context
cascade, and the narrow EF tracking fact the fix rests on), CLI --force flag
parse test, and a MgmtDeploySite wire-default test. Proof of failure: both P7-6
behaviour tests and both P7-5 assertions fail against the unfixed code.
2026-08-19 16:41:04 -04:00

151 lines
5.9 KiB
C#

using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.ManagementService;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Tests for <see cref="ManagementEndpoints"/> request-body parsing
/// (findings ManagementService-006 / -013).
/// </summary>
public class ManagementEndpointsTests
{
[Fact]
public void ParseCommand_WithExplicitPayload_DeserializesIntoCommandType()
{
var json = """{ "command": "CreateSite", "payload": { "name": "Site1", "siteIdentifier": "SITE1", "description": "Desc" } }""";
var result = ManagementEndpoints.ParseCommand(json);
Assert.True(result.Success);
var command = Assert.IsType<CreateSiteCommand>(result.Command);
Assert.Equal("Site1", command.Name);
Assert.Equal("SITE1", command.SiteIdentifier);
Assert.Equal("Desc", command.Description);
}
/// <summary>
/// P7-6: <c>MgmtDeploySiteCommand.Force</c> is an ADDITIVE field, so a client
/// that predates it — every CLI older than this change, and every scripted
/// HTTP caller — must keep working and must land on the SAFE default. Absent
/// means <c>false</c> means "skip instances whose config is unchanged", which
/// is the non-destructive behaviour; only an explicit <c>true</c> restores the
/// unconditional redeploy.
/// </summary>
[Theory]
[InlineData("""{ "command": "MgmtDeploySite", "payload": { "siteId": 7 } }""", false)]
[InlineData("""{ "command": "MgmtDeploySite", "payload": { "siteId": 7, "force": false } }""", false)]
[InlineData("""{ "command": "MgmtDeploySite", "payload": { "siteId": 7, "force": true } }""", true)]
public void ParseCommand_DeploySite_ForceDefaultsToTheNonDestructiveBehaviour(string json, bool expectedForce)
{
var result = ManagementEndpoints.ParseCommand(json);
Assert.True(result.Success);
var command = Assert.IsType<MgmtDeploySiteCommand>(result.Command);
Assert.Equal(7, command.SiteId);
Assert.Equal(expectedForce, command.Force);
}
[Fact]
public void ParseCommand_WithMissingPayload_DeserializesParameterlessCommand()
{
// No "payload" field at all -- the fallback must not allocate a throwaway
// JsonDocument and must still produce a valid parameterless command.
var json = """{ "command": "ListTemplates" }""";
var result = ManagementEndpoints.ParseCommand(json);
Assert.True(result.Success);
Assert.IsType<ListTemplatesCommand>(result.Command);
}
[Fact]
public void ParseCommand_WithInvalidJson_ReturnsFailure()
{
var result = ManagementEndpoints.ParseCommand("{ not json");
Assert.False(result.Success);
Assert.Equal("BAD_REQUEST", result.ErrorCode);
}
[Fact]
public void ParseCommand_WithMissingCommandField_ReturnsFailure()
{
var result = ManagementEndpoints.ParseCommand("""{ "payload": {} }""");
Assert.False(result.Success);
Assert.Equal("BAD_REQUEST", result.ErrorCode);
}
[Fact]
public void ParseCommand_WithUnknownCommand_ReturnsFailure()
{
var result = ManagementEndpoints.ParseCommand("""{ "command": "NoSuchCommand" }""");
Assert.False(result.Success);
Assert.Equal("BAD_REQUEST", result.ErrorCode);
}
// ========================================================================
// Command-timeout configuration (finding ManagementService-010)
//
// ManagementServiceOptions.CommandTimeout must actually drive the Ask
// timeout instead of a hard-coded 30s constant.
// ========================================================================
[Fact]
public void ResolveAskTimeout_UsesConfiguredCommandTimeout()
{
var options = new ManagementServiceOptions { CommandTimeout = TimeSpan.FromSeconds(75) };
var timeout = ManagementEndpoints.ResolveAskTimeout(options);
Assert.Equal(TimeSpan.FromSeconds(75), timeout);
}
[Fact]
public void ResolveAskTimeout_WithNullOptions_FallsBackToDefault()
{
var timeout = ManagementEndpoints.ResolveAskTimeout(null);
Assert.Equal(TimeSpan.FromSeconds(30), timeout);
}
[Fact]
public void ResolveAskTimeout_WithNonPositiveTimeout_FallsBackToDefault()
{
// A misconfigured zero/negative timeout would make every Ask fail
// immediately; fall back to the safe default instead.
var options = new ManagementServiceOptions { CommandTimeout = TimeSpan.Zero };
var timeout = ManagementEndpoints.ResolveAskTimeout(options);
Assert.Equal(TimeSpan.FromSeconds(30), timeout);
}
// ========================================================================
// Per-command long-running Ask timeout (arch-review S1)
//
// Transport/deploy commands legitimately run for minutes; they must use the
// LongRunningCommandTimeout (default 5 min) instead of the 30 s default.
// ========================================================================
[Theory]
[InlineData(typeof(ImportBundleCommand))]
[InlineData(typeof(ExportBundleCommand))]
[InlineData(typeof(PreviewBundleCommand))]
[InlineData(typeof(MgmtDeployArtifactsCommand))]
[InlineData(typeof(MgmtDeployInstanceCommand))]
public void ResolveAskTimeout_LongRunningCommand_UsesLongTimeout(Type cmd)
=> Assert.Equal(TimeSpan.FromMinutes(5), ManagementEndpoints.ResolveAskTimeout(null, cmd));
[Fact]
public void ResolveAskTimeout_OrdinaryCommand_UsesDefault()
=> Assert.Equal(TimeSpan.FromSeconds(30), ManagementEndpoints.ResolveAskTimeout(null, typeof(ListTemplatesCommand)));
[Fact]
public void ResolveAskTimeout_ConfiguredLongTimeout_Honored()
=> Assert.Equal(TimeSpan.FromMinutes(10), ManagementEndpoints.ResolveAskTimeout(
new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromMinutes(10) },
typeof(ImportBundleCommand)));
}