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
@@ -391,6 +391,121 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal(3, allIds.Count);
}
[SkippableFact]
public async Task SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow()
{
// SiteCallAudit-006 boundary regression. The earlier paging test
// interleaves stuck/non-stuck rows but the cursor between page-1 and
// page-2 always lands on a stuck row. This test forces the cursor to
// sit on a NON-stuck row (page-size=1 over a strict
// stuck-not_stuck-stuck-not_stuck-stuck-not_stuck pattern oldest-first)
// so the SQL-side composition of the stuck predicate AND the keyset
// cursor predicate (CreatedAtUtc < cursor OR =cursor AND id < ...) must
// honestly skip the non-stuck rows between each page. Each page must
// return exactly one stuck row, with no overlap and all three stuck
// rows visited across three pages.
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
var actor = CreateActor(repo, new SiteCallAuditOptions { StuckAgeThreshold = TimeSpan.FromMinutes(10) });
var now = DateTime.UtcNow;
var stuckIds = new List<Guid>();
// Insert pattern (relative-newest first, the order the actor returns
// them in DESC-by-CreatedAtUtc):
// t=-1m (non-stuck — Attempted but only 1m old, < 10m threshold),
// t=-15m (stuck — Attempted, 15m > 10m: stuckA),
// t=-20m (non-stuck — terminal Delivered between stuckA & stuckB),
// t=-25m (stuck — Attempted, 25m > 10m: stuckB),
// t=-30m (non-stuck — terminal Delivered between stuckB & stuckC),
// t=-35m (stuck — Attempted, 35m > 10m: stuckC),
// t=-40m (non-stuck — terminal Delivered, oldest of all).
// The non-stuck rows at -20m and -30m sit between consecutive stuck
// rows, so the page-size-1 cursor lands ON a non-stuck row between
// pages — exactly the boundary the predicate composition must skip.
var stuckA = TrackedOperationId.New();
var stuckB = TrackedOperationId.New();
var stuckC = TrackedOperationId.New();
// Expected DESC-by-CreatedAtUtc order: A (-15m newest), B (-25m), C (-35m oldest).
stuckIds.Add(stuckA.Value);
stuckIds.Add(stuckB.Value);
stuckIds.Add(stuckC.Value);
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Delivered",
createdAtUtc: now.AddMinutes(-40), terminal: true));
await repo.UpsertAsync(NewRow(
stuckC, siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-35)));
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Delivered",
createdAtUtc: now.AddMinutes(-30), terminal: true));
await repo.UpsertAsync(NewRow(
stuckB, siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-25)));
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Delivered",
createdAtUtc: now.AddMinutes(-20), terminal: true));
await repo.UpsertAsync(NewRow(
stuckA, siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-15)));
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-1)));
// Page-1: page-size=1, expect stuckA (newest stuck row).
actor.Tell(
new SiteCallQueryRequest(
"corr-stuck-b1", null, siteId, null, null, StuckOnly: true,
null, null, null, null, PageSize: 1),
TestActor);
var page1 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
Assert.True(page1.Success);
Assert.Single(page1.SiteCalls);
Assert.True(page1.SiteCalls[0].IsStuck);
Assert.Equal(stuckA.Value, page1.SiteCalls[0].TrackedOperationId);
Assert.NotNull(page1.NextAfterCreatedAtUtc);
// Page-2: between stuckA and stuckB the non-stuck terminal row at -20m
// sits at the cursor — the SQL must skip it, NOT return it.
actor.Tell(
new SiteCallQueryRequest(
"corr-stuck-b2", null, siteId, null, null, StuckOnly: true,
null, null, page1.NextAfterCreatedAtUtc, page1.NextAfterId,
PageSize: 1),
TestActor);
var page2 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
Assert.True(page2.Success);
Assert.Single(page2.SiteCalls);
Assert.True(page2.SiteCalls[0].IsStuck);
Assert.Equal(stuckB.Value, page2.SiteCalls[0].TrackedOperationId);
Assert.NotNull(page2.NextAfterCreatedAtUtc);
// Page-3: between stuckB and stuckC the non-stuck row at -30m sits at
// the cursor — again must be skipped.
actor.Tell(
new SiteCallQueryRequest(
"corr-stuck-b3", null, siteId, null, null, StuckOnly: true,
null, null, page2.NextAfterCreatedAtUtc, page2.NextAfterId,
PageSize: 1),
TestActor);
var page3 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
Assert.True(page3.Success);
Assert.Single(page3.SiteCalls);
Assert.True(page3.SiteCalls[0].IsStuck);
Assert.Equal(stuckC.Value, page3.SiteCalls[0].TrackedOperationId);
// All three stuck rows visited exactly once, in DESC order, with no
// non-stuck rows leaking through despite the cursor sitting on them.
var visited = new[] { page1, page2, page3 }
.SelectMany(p => p.SiteCalls)
.Select(s => s.TrackedOperationId)
.ToList();
Assert.Equal(stuckIds, visited);
}
[SkippableFact]
public async Task SiteCallDetailRequest_KnownId_ReturnsFullDetail()
{