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:
@@ -1157,4 +1157,263 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
// The curated InstanceService failure message is still surfaced verbatim.
|
||||
Assert.Contains("99", response.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ManagementService-021: Transport (#24) bundle handler coverage
|
||||
//
|
||||
// The three bundle handlers (HandleExportBundle / HandlePreviewBundle /
|
||||
// HandleImportBundle) at ManagementActor.cs:1717-1897 previously had zero
|
||||
// tests. The cases below pin the load-bearing behaviours: role gating,
|
||||
// ExportBundle name resolution, ImportBundle blocker rejection, and the
|
||||
// ImportBundle (EntityType, Name) dedupe.
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Adds a substituted <see cref="Commons.Interfaces.Transport.IBundleExporter"/> and
|
||||
/// <see cref="Commons.Interfaces.Transport.IBundleImporter"/> to the test
|
||||
/// service collection plus minimal repositories the bundle handlers query
|
||||
/// from the export side. Returns both substitutes so a test can configure
|
||||
/// their behaviour.
|
||||
/// </summary>
|
||||
private (Commons.Interfaces.Transport.IBundleExporter Exporter,
|
||||
Commons.Interfaces.Transport.IBundleImporter Importer)
|
||||
AddBundleSubstitutes()
|
||||
{
|
||||
var exporter = Substitute.For<Commons.Interfaces.Transport.IBundleExporter>();
|
||||
var importer = Substitute.For<Commons.Interfaces.Transport.IBundleImporter>();
|
||||
_services.AddSingleton(exporter);
|
||||
_services.AddSingleton(importer);
|
||||
|
||||
// The repository fan-out HandleExportBundle does at the top of its body.
|
||||
// Tests that only exercise role gating still need these resolved.
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Template>());
|
||||
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.Scripts.SharedScript>());
|
||||
|
||||
var externalRepo = Substitute.For<IExternalSystemRepository>();
|
||||
externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.ExternalSystems.ExternalSystemDefinition>());
|
||||
externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.ExternalSystems.DatabaseConnectionDefinition>());
|
||||
_services.AddScoped(_ => externalRepo);
|
||||
|
||||
var notifRepo = Substitute.For<INotificationRepository>();
|
||||
notifRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.Notifications.NotificationList>());
|
||||
notifRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.Notifications.SmtpConfiguration>());
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var inboundRepo = Substitute.For<IInboundApiRepository>();
|
||||
inboundRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.InboundApi.ApiKey>());
|
||||
inboundRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.InboundApi.ApiMethod>());
|
||||
_services.AddScoped(_ => inboundRepo);
|
||||
|
||||
return (exporter, importer);
|
||||
}
|
||||
|
||||
private static ExportBundleCommand AllExportCommand() =>
|
||||
new(All: true,
|
||||
TemplateNames: null, SharedScriptNames: null,
|
||||
ExternalSystemNames: null, DatabaseConnectionNames: null,
|
||||
NotificationListNames: null, SmtpConfigurationNames: null,
|
||||
ApiKeyNames: null, ApiMethodNames: null,
|
||||
IncludeDependencies: false, Passphrase: null,
|
||||
SourceEnvironment: "test-env");
|
||||
|
||||
[Fact]
|
||||
public void ExportBundleCommand_WithAdminRole_ReturnsUnauthorized()
|
||||
{
|
||||
// ExportBundle requires the Design role; an Admin-only caller is rejected.
|
||||
AddBundleSubstitutes();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(AllExportCommand(), "Admin");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Design", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviewBundleCommand_WithDesignRole_ReturnsUnauthorized()
|
||||
{
|
||||
// PreviewBundle requires the Admin role (Design role isn't enough,
|
||||
// mirroring the Central UI gating — only Admin imports cross-cutting
|
||||
// configuration).
|
||||
AddBundleSubstitutes();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new PreviewBundleCommand("AA==", null), "Design");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Admin", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportBundleCommand_WithDesignRole_ReturnsUnauthorized()
|
||||
{
|
||||
AddBundleSubstitutes();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new ImportBundleCommand("AA==", null, "skip"), "Design");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Admin", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportBundleCommand_WithUnknownTemplateName_ReturnsManagementError()
|
||||
{
|
||||
// ResolveIds throws ManagementCommandException for unknown names; that
|
||||
// curated message must surface verbatim to the caller.
|
||||
AddBundleSubstitutes();
|
||||
// No templates in the repo; the export selection names one anyway.
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Template>());
|
||||
|
||||
var cmd = new ExportBundleCommand(
|
||||
All: false,
|
||||
TemplateNames: new[] { "DoesNotExist" },
|
||||
SharedScriptNames: null,
|
||||
ExternalSystemNames: null, DatabaseConnectionNames: null,
|
||||
NotificationListNames: null, SmtpConfigurationNames: null,
|
||||
ApiKeyNames: null, ApiMethodNames: null,
|
||||
IncludeDependencies: false, Passphrase: null,
|
||||
SourceEnvironment: "test-env");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(cmd, "Design");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
// The ManagementCommandException message surfaces verbatim — it names
|
||||
// the missing entity type and the missing name.
|
||||
Assert.Contains("template", response.Error, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("DoesNotExist", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportBundleCommand_WithBlockerRow_AbortsBeforeApply()
|
||||
{
|
||||
// A ConflictKind.Blocker in the preview must abort the import — the
|
||||
// handler throws ManagementCommandException before calling ApplyAsync.
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Manifest = null!,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
|
||||
});
|
||||
|
||||
var blockerItem = new Commons.Types.Transport.ImportPreviewItem(
|
||||
EntityType: "Template",
|
||||
Name: "BlockedTemplate",
|
||||
ExistingVersion: null,
|
||||
IncomingVersion: 1,
|
||||
Kind: Commons.Types.Transport.ConflictKind.Blocker,
|
||||
FieldDiffJson: null,
|
||||
BlockerReason: "FK to missing site");
|
||||
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Types.Transport.ImportPreview(
|
||||
sessionId,
|
||||
new[] { blockerItem }));
|
||||
|
||||
// A non-empty base64 payload that decodes — the handler does its own
|
||||
// base64 check before reaching the importer.
|
||||
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new ImportBundleCommand(payload, null, "skip"), "Admin");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("blocker", response.Error, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("BlockedTemplate", response.Error);
|
||||
|
||||
// Apply must NOT have been called — the handler aborts before it.
|
||||
importer.DidNotReceive().ApplyAsync(
|
||||
Arg.Any<Guid>(),
|
||||
Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportBundleCommand_DuplicatePreviewItems_DedupePerEntityTypeAndName()
|
||||
{
|
||||
// The handler dedupes by (EntityType, Name) before calling ApplyAsync —
|
||||
// last-write-wins, matching the Central UI's TransportImport behavior.
|
||||
// The preview here emits THREE rows for the same (Template, "Dup"):
|
||||
// an Identical then a Modified then an Identical. After dedupe, only
|
||||
// one resolution must reach the importer for that key.
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Manifest = null!,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
|
||||
});
|
||||
|
||||
Commons.Types.Transport.ImportPreviewItem Row(
|
||||
Commons.Types.Transport.ConflictKind kind) =>
|
||||
new("Template", "Dup", ExistingVersion: 1, IncomingVersion: 2,
|
||||
Kind: kind, FieldDiffJson: null, BlockerReason: null);
|
||||
|
||||
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Types.Transport.ImportPreview(
|
||||
sessionId,
|
||||
new[]
|
||||
{
|
||||
Row(Commons.Types.Transport.ConflictKind.Identical),
|
||||
Row(Commons.Types.Transport.ConflictKind.Modified),
|
||||
Row(Commons.Types.Transport.ConflictKind.Identical),
|
||||
}));
|
||||
|
||||
IReadOnlyList<Commons.Types.Transport.ImportResolution>? captured = null;
|
||||
importer.ApplyAsync(
|
||||
Arg.Any<Guid>(),
|
||||
Arg.Do<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(
|
||||
r => captured = r),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Types.Transport.ImportResult(
|
||||
BundleImportId: Guid.NewGuid(),
|
||||
Added: 0, Overwritten: 0, Skipped: 0, Renamed: 0,
|
||||
StaleInstanceIds: Array.Empty<int>(),
|
||||
AuditEventCorrelation: "correlation"));
|
||||
|
||||
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
|
||||
var actor = CreateActor();
|
||||
// "overwrite" policy so the final (Identical) row would otherwise differ
|
||||
// from the Modified row's action — proves the last-write-wins semantics.
|
||||
var envelope = Envelope(new ImportBundleCommand(payload, null, "overwrite"), "Admin");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.NotNull(captured);
|
||||
// Only ONE resolution survives for the (Template, "Dup") key.
|
||||
var dupResolutions = captured!
|
||||
.Where(r => r.EntityType == "Template" && r.Name == "Dup")
|
||||
.ToList();
|
||||
Assert.Single(dupResolutions);
|
||||
// Last-write-wins: the final Identical row's Skip action overrides the
|
||||
// earlier Modified row's Overwrite action.
|
||||
Assert.Equal(Commons.Types.Transport.ResolutionAction.Skip, dupResolutions[0].Action);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user