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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user