test(coverage): close Theme 8 — 13 test-coverage findings, +35 tests

13 well-bounded test-coverage gaps closed across 11 test projects.
Net +35 regression tests; no production code changes except the
SiteEventLogger src reference unchanged (W3 redacted only test code).

Test additions:
- CLI-022: CommandTreeTests pinned-count assertion bumped 14→16 and
  3 InlineData rows added for the audit + bundle command groups.
- Commons-020: new TransportRecordsTests covers BundleManifest /
  ExportSelection / ImportPreview / ImportResolution / ImportResult —
  ctor + System.Text.Json round-trip + record-equality (14 tests).
- CD-024: SPLIT-RANGE failure-continuation now under
  EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted
  (Skippable MS-SQL fixture); production-shape rowversion delete
  asserted by DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds.
- CentralUI-033: new QueryStringDrillInTests with 4 bUnit cases for
  Transport + SiteCalls drill-in / query-string handling.
- DM-024: probe actors (ReconcileProbeActor, SerializationProbeActor,
  ArtifactProbeActor) refactored from static fields to per-test instances
  (Interlocked on counter) — all 31 callers updated; no production
  changes required.
- HM-022: real-time PeriodicTimer test flake fixed by replacing
  fixed-budget Task.Delay with a RunLoopUntil poll-until-condition
  helper (5s/25ms). Production loop untouched.
- InboundAPI-023: new EndpointExtensionsTests covers the
  POST /api/{methodName} composition wiring via TestServer (7 cases:
  happy path, missing key 401, unknown method 403, invalid JSON 400,
  missing param 400, script-throws 500 sanitised, AuditActorItemKey
  stash invariant).
- MgmtSvc-021: 6 new ManagementActorTests cover the Transport bundle
  handlers (role gate for Export/Preview/Import, unknown-name
  ManagementCommandException, blocker-rejection, dedupe last-write-wins).
- SCA-006: SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow
  pins the missing boundary case.
- SEL-023: stress-test `bool stop` promoted to `volatile bool` for
  cross-thread visibility under release/JIT.

Verify-only resolutions:
- NS-024: closed by NS-019 (commit ac96b83 deletion of
  NotificationDeliveryService + its test file). No edits needed.
- NotifOutbox-008: FallbackMaxRetries/FallbackRetryDelay are private
  forward-compat constants returned only when no SMTP-config row exists
  (in which case EmailNotificationDeliveryAdapter returns Permanent,
  bypassing the values entirely). Marked Resolved with note.
- Transport-010: Overwrite child-collection sync covered by the T-001/
  T-002 tests added in commit e3ca9af; per-IP throttle by
  BundleUnlockRateLimiterTests; failed-session retention by
  BundleSessionStoreTests; T-009 closed structurally via AsyncLocal.
  Marked Resolved by reference.

Build clean; all 11 affected test suites green. README regenerated:
33 open (was 46).
This commit is contained in:
Joseph Doherty
2026-05-28 08:21:03 -04:00
parent 46cb6965ac
commit d190345ef0
26 changed files with 1725 additions and 155 deletions
@@ -0,0 +1,296 @@
using System.Text.Json;
using ScadaLink.Commons.Types.Transport;
namespace ScadaLink.Commons.Tests.Types.Transport;
/// <summary>
/// Commons-020: focused shape / round-trip tests for the Transport (#24) record DTOs
/// — <see cref="BundleManifest"/>, <see cref="ExportSelection"/>,
/// <see cref="ImportPreview"/>, <see cref="ImportResolution"/>, and
/// <see cref="ImportResult"/>. These records cross the Central UI ⇆ bundle file boundary
/// via System.Text.Json, so a positional/tuple slip would break bundles in the field.
/// EncryptionMetadata has its own focused tests under EncryptionMetadataTests.cs
/// (Commons-015) and is reused here only to populate manifest fixtures.
/// </summary>
public sealed class TransportRecordsTests
{
// STM: TransportRecordsTests-Commons-020 marker — used by grep verification.
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = false,
};
// --------------------------------------------------------------
// BundleManifest
// --------------------------------------------------------------
[Fact]
public void BundleManifest_Constructor_RoundTripsAllFields()
{
var summary = new BundleSummary(
Templates: 2, TemplateFolders: 1, SharedScripts: 3,
ExternalSystems: 1, DbConnections: 0, NotificationLists: 1,
SmtpConfigs: 1, ApiKeys: 0, ApiMethods: 4);
var contents = new List<ManifestContentEntry>
{
new("Template", "Pump", 1, new List<string> { "Shared.Helpers" }),
new("Template", "Valve", 2, Array.Empty<string>()),
};
var manifest = new BundleManifest(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero),
SourceEnvironment: "cli",
ExportedBy: "alice",
ScadaLinkVersion: "0.9.0",
ContentHash: "sha256:deadbeef",
Encryption: null,
Summary: summary,
Contents: contents);
Assert.Equal(1, manifest.BundleFormatVersion);
Assert.Equal("1.0", manifest.SchemaVersion);
Assert.Equal("cli", manifest.SourceEnvironment);
Assert.Equal("alice", manifest.ExportedBy);
Assert.Equal("0.9.0", manifest.ScadaLinkVersion);
Assert.Equal("sha256:deadbeef", manifest.ContentHash);
Assert.Null(manifest.Encryption);
Assert.Equal(summary, manifest.Summary);
Assert.Equal(2, manifest.Contents.Count);
Assert.Equal("Pump", manifest.Contents[0].Name);
}
[Fact]
public void BundleManifest_JsonRoundTrip_PreservesAllFields()
{
var encryption = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "c2FsdA==",
IvB64: "aXY=");
var summary = new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0);
var manifest = new BundleManifest(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero),
SourceEnvironment: "ui",
ExportedBy: "bob",
ScadaLinkVersion: "0.9.0",
ContentHash: "sha256:abc",
Encryption: encryption,
Summary: summary,
Contents: new List<ManifestContentEntry>
{
new("Template", "Pump", 7, new List<string> { "dep-a" }),
});
var json = JsonSerializer.Serialize(manifest, JsonOpts);
var rt = JsonSerializer.Deserialize<BundleManifest>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(manifest.SourceEnvironment, rt!.SourceEnvironment);
Assert.Equal(manifest.ContentHash, rt.ContentHash);
Assert.Equal(manifest.Summary, rt.Summary);
Assert.Single(rt.Contents);
Assert.Equal("Pump", rt.Contents[0].Name);
Assert.Equal(7, rt.Contents[0].Version);
Assert.NotNull(rt.Encryption);
Assert.Equal("AES-256-GCM", rt.Encryption!.Algorithm);
Assert.Equal(600_000, rt.Encryption.Iterations);
}
// --------------------------------------------------------------
// ExportSelection
// --------------------------------------------------------------
[Fact]
public void ExportSelection_Constructor_PreservesAllIdLists()
{
var sel = new ExportSelection(
TemplateIds: new[] { 1, 2, 3 },
SharedScriptIds: new[] { 10 },
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: new[] { 20, 21 },
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: new[] { 30 },
ApiKeyIds: new[] { 40, 41 },
ApiMethodIds: new[] { 50 },
IncludeDependencies: true);
Assert.Equal(new[] { 1, 2, 3 }, sel.TemplateIds);
Assert.Single(sel.SharedScriptIds);
Assert.Empty(sel.ExternalSystemIds);
Assert.Equal(2, sel.DatabaseConnectionIds.Count);
Assert.True(sel.IncludeDependencies);
}
[Fact]
public void ExportSelection_JsonRoundTrip_PreservesIncludeDependenciesAndIds()
{
var sel = new ExportSelection(
TemplateIds: new[] { 1, 2 },
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: new[] { 5 },
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
var json = JsonSerializer.Serialize(sel, JsonOpts);
var rt = JsonSerializer.Deserialize<ExportSelection>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(sel.TemplateIds, rt!.TemplateIds);
Assert.Equal(sel.ExternalSystemIds, rt.ExternalSystemIds);
Assert.False(rt.IncludeDependencies);
}
// --------------------------------------------------------------
// ImportPreview
// --------------------------------------------------------------
[Fact]
public void ImportPreview_Constructor_AllowsAllConflictKinds()
{
var sessionId = Guid.NewGuid();
var items = new List<ImportPreviewItem>
{
new("Template", "Pump", ExistingVersion: 1, IncomingVersion: 1, Kind: ConflictKind.Identical, FieldDiffJson: null, BlockerReason: null),
new("Template", "Valve", ExistingVersion: 1, IncomingVersion: 2, Kind: ConflictKind.Modified, FieldDiffJson: "{\"name\":\"Valve\"}", BlockerReason: null),
new("Template", "New", ExistingVersion: null, IncomingVersion: 1, Kind: ConflictKind.New, FieldDiffJson: null, BlockerReason: null),
new("Template", "Bad", ExistingVersion: 1, IncomingVersion: 5, Kind: ConflictKind.Blocker, FieldDiffJson: null, BlockerReason: "Parameters property mismatch"),
};
var preview = new ImportPreview(sessionId, items);
Assert.Equal(sessionId, preview.SessionId);
Assert.Equal(4, preview.Items.Count);
Assert.Equal(ConflictKind.Identical, preview.Items[0].Kind);
Assert.Equal(ConflictKind.Modified, preview.Items[1].Kind);
Assert.Equal(ConflictKind.New, preview.Items[2].Kind);
Assert.Equal(ConflictKind.Blocker, preview.Items[3].Kind);
Assert.Equal("Parameters property mismatch", preview.Items[3].BlockerReason);
}
[Fact]
public void ImportPreview_JsonRoundTrip_PreservesConflictKindAndOptionalFields()
{
var preview = new ImportPreview(
SessionId: Guid.NewGuid(),
Items: new List<ImportPreviewItem>
{
new("Template", "X", 1, 2, ConflictKind.Modified, "{}", null),
new("Template", "Y", null, 1, ConflictKind.New, null, null),
});
var json = JsonSerializer.Serialize(preview, JsonOpts);
var rt = JsonSerializer.Deserialize<ImportPreview>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(preview.SessionId, rt!.SessionId);
Assert.Equal(2, rt.Items.Count);
Assert.Equal(ConflictKind.Modified, rt.Items[0].Kind);
Assert.Equal(ConflictKind.New, rt.Items[1].Kind);
Assert.Null(rt.Items[1].ExistingVersion);
}
// --------------------------------------------------------------
// ImportResolution
// --------------------------------------------------------------
[Theory]
[InlineData(ResolutionAction.Add, null)]
[InlineData(ResolutionAction.Overwrite, null)]
[InlineData(ResolutionAction.Skip, null)]
[InlineData(ResolutionAction.Rename, "NewName")]
public void ImportResolution_Constructor_PreservesAllActions(ResolutionAction action, string? renameTo)
{
var res = new ImportResolution("Template", "Pump", action, renameTo);
Assert.Equal("Template", res.EntityType);
Assert.Equal("Pump", res.Name);
Assert.Equal(action, res.Action);
Assert.Equal(renameTo, res.RenameTo);
}
[Fact]
public void ImportResolution_JsonRoundTrip_PreservesRenameTo()
{
var res = new ImportResolution("Template", "Pump", ResolutionAction.Rename, "Pump_v2");
var json = JsonSerializer.Serialize(res, JsonOpts);
var rt = JsonSerializer.Deserialize<ImportResolution>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(ResolutionAction.Rename, rt!.Action);
Assert.Equal("Pump_v2", rt.RenameTo);
}
// --------------------------------------------------------------
// ImportResult
// --------------------------------------------------------------
[Fact]
public void ImportResult_Constructor_PreservesAllCountersAndStaleIds()
{
var bundleImportId = Guid.NewGuid();
var result = new ImportResult(
BundleImportId: bundleImportId,
Added: 3,
Overwritten: 1,
Skipped: 2,
Renamed: 1,
StaleInstanceIds: new List<int> { 100, 200, 300 },
AuditEventCorrelation: "audit-corr-001");
Assert.Equal(bundleImportId, result.BundleImportId);
Assert.Equal(3, result.Added);
Assert.Equal(1, result.Overwritten);
Assert.Equal(2, result.Skipped);
Assert.Equal(1, result.Renamed);
Assert.Equal(new[] { 100, 200, 300 }, result.StaleInstanceIds);
Assert.Equal("audit-corr-001", result.AuditEventCorrelation);
}
[Fact]
public void ImportResult_JsonRoundTrip_PreservesCountsAndCorrelation()
{
var result = new ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 5,
Overwritten: 0,
Skipped: 0,
Renamed: 0,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: "audit-corr-xyz");
var json = JsonSerializer.Serialize(result, JsonOpts);
var rt = JsonSerializer.Deserialize<ImportResult>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(result.BundleImportId, rt!.BundleImportId);
Assert.Equal(5, rt.Added);
Assert.Empty(rt.StaleInstanceIds);
Assert.Equal("audit-corr-xyz", rt.AuditEventCorrelation);
}
// --------------------------------------------------------------
// Record equality sanity (catches positional/tuple slip)
// --------------------------------------------------------------
[Fact]
public void TransportRecords_RecordValueEquality()
{
var a = new ImportResolution("Template", "Pump", ResolutionAction.Add, null);
var b = new ImportResolution("Template", "Pump", ResolutionAction.Add, null);
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
var c = a with { Action = ResolutionAction.Overwrite };
Assert.NotEqual(a, c);
}
}