docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)

Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -11,8 +11,8 @@ using ZB.MOM.WW.ScadaBridge.SiteCallAudit.Kpi;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests.Kpi;
/// <summary>
/// Unit tests for <see cref="SiteCallAuditKpiSampleSource"/> (M6 KPI History,
/// #K7). A hand-rolled <see cref="ISiteCallAuditRepository"/> fake (matching the
/// Unit tests for <see cref="SiteCallAuditKpiSampleSource"/> (KPI history).
/// A hand-rolled <see cref="ISiteCallAuditRepository"/> fake (matching the
/// project's existing test-double style — no mocking library) returns known
/// global + per-site + per-node snapshots; the assertions pin the exact
/// <c>(Metric, Scope, ScopeKey, Value)</c> tuples the recorder will persist, the
@@ -54,6 +54,7 @@ public class SiteCallAuditKpiSampleSourceTests
// 1. Source identifier is the canonical SiteCallAudit constant.
// ---------------------------------------------------------------------
/// <summary>Verifies that the source identifier is the SiteCallAudit constant.</summary>
[Fact]
public void Source_IsSiteCallAuditConstant()
{
@@ -65,6 +66,8 @@ public class SiteCallAuditKpiSampleSourceTests
// 2. Full snapshot: global + per-site + per-node, exact tuples.
// ---------------------------------------------------------------------
/// <summary>Verifies that the expected tuples are emitted for the global, per-site and per-node KPIs.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CollectAsync_EmitsExpectedTuples_ForGlobalSiteAndNode()
{
@@ -142,6 +145,8 @@ public class SiteCallAuditKpiSampleSourceTests
// 3. Null oldest-pending-age is omitted (not written as zero).
// ---------------------------------------------------------------------
/// <summary>Verifies that the oldest pending age is omitted when it is null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CollectAsync_OmitsOldestPendingAge_WhenNull()
{
@@ -173,6 +178,8 @@ public class SiteCallAuditKpiSampleSourceTests
// 4. Empty per-site/per-node: only the global six metrics emitted.
// ---------------------------------------------------------------------
/// <summary>Verifies that only global tuples are emitted when there are no per-site or per-node rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CollectAsync_GlobalOnly_WhenNoPerSiteOrPerNodeRows()
{
@@ -193,6 +200,8 @@ public class SiteCallAuditKpiSampleSourceTests
// 5. Cutoffs are anchored on capturedAtUtc using the options windows.
// ---------------------------------------------------------------------
/// <summary>Verifies that the cutoffs are derived from the options, anchored on the captured-at time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CollectAsync_DerivesCutoffs_FromOptionsAnchoredOnCapturedAt()
{
@@ -219,17 +228,19 @@ public class SiteCallAuditKpiSampleSourceTests
}
// ---------------------------------------------------------------------
// P5-5 (arch-review Low): failedLastInterval/deliveredLastInterval are rescaled onto
// Rescaling: failedLastInterval/deliveredLastInterval are rescaled onto
// KpiHistoryOptions.SampleInterval before recording, so the central hourly rollup fold's
// per-hour Sum stays correct regardless of how KpiInterval is tuned. Before the fix, this
// class recorded the RAW KpiInterval-windowed counts verbatim; with a 20-minute
// KpiInterval sampled every 1-minute SampleInterval, summing 60 overlapping 20-minute
// counts across an hour would over-count ~20x. Proven against the unfixed computation:
// temporarily short-circuiting IntervalScaleFactor to always return 1.0 (i.e. "record the
// raw count verbatim", the pre-P5-5 behavior) makes this test fail (40/1 != 2/0.05);
// raw count verbatim", the pre-fix behavior) makes this test fail (40/1 != 2/0.05);
// restoring the real scale factor passes it.
// ---------------------------------------------------------------------
/// <summary>Verifies that the failed and delivered last-interval counts are rescaled onto the KPI history sample interval.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CollectAsync_RescalesFailedAndDeliveredLastInterval_OntoKpiHistorySampleInterval()
{
@@ -264,6 +275,8 @@ public class SiteCallAuditKpiSampleSourceTests
// 6. Multiple sites/nodes are all emitted with distinct ScopeKeys.
// ---------------------------------------------------------------------
/// <summary>Verifies that tuples are emitted for all sites and nodes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CollectAsync_EmitsAllSitesAndNodes()
{
@@ -316,15 +329,20 @@ public class SiteCallAuditKpiSampleSourceTests
/// </summary>
private sealed class StubRepo : ISiteCallAuditRepository
{
/// <summary>Gets or sets the global KPI snapshot returned by the stub.</summary>
public SiteCallKpiSnapshot Global { get; set; } =
new(0, 0, 0, 0, null, 0);
/// <summary>Gets or sets the per-site KPI snapshots returned by the stub.</summary>
public IReadOnlyList<SiteCallSiteKpiSnapshot> PerSite { get; set; } =
Array.Empty<SiteCallSiteKpiSnapshot>();
/// <summary>Gets or sets the per-node KPI snapshots returned by the stub.</summary>
public IReadOnlyList<SiteCallNodeKpiSnapshot> PerNode { get; set; } =
Array.Empty<SiteCallNodeKpiSnapshot>();
/// <summary>Gets the recorded KPI calls in the order they were made.</summary>
public List<KpiCall> Calls { get; } = new();
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
@@ -332,6 +350,7 @@ public class SiteCallAuditKpiSampleSourceTests
return Task.FromResult(Global);
}
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
@@ -339,6 +358,7 @@ public class SiteCallAuditKpiSampleSourceTests
return Task.FromResult(PerSite);
}
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
@@ -347,16 +367,20 @@ public class SiteCallAuditKpiSampleSourceTests
}
// ── Inert non-KPI members ──
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
Task.CompletedTask;
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
Task.FromResult<SiteCall?>(null);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
Task.FromResult(0);
}
@@ -14,8 +14,8 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests;
/// <summary>
/// Bundle C1 (#22, #23 M3) tests for <see cref="SiteCallAuditActor"/>. Uses the
/// same <see cref="MsSqlMigrationFixture"/> as the Bundle B3 repository tests
/// Tests for <see cref="SiteCallAuditActor"/>. Uses the
/// same <see cref="MsSqlMigrationFixture"/> as the repository tests
/// so the actor exercises the real monotonic-upsert SQL end to end against the
/// <c>SiteCalls</c> schema. Each test scopes its data by minting a fresh
/// <see cref="TrackedOperationId"/> (and a per-test <c>SourceSite</c> suffix)
@@ -25,6 +25,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
/// <param name="fixture">The shared MSSQL migration fixture.</param>
public SiteCallAuditActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
@@ -87,6 +89,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
NullLogger<SiteCallAuditActor>.Instance)));
}
/// <summary>Verifies that an upsert command is persisted and acknowledged as accepted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_UpsertSiteCallCommand_Persists_Replies_Accepted()
{
@@ -116,6 +120,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal("Submitted", rows[0].Status);
}
/// <summary>Verifies that a duplicate upsert with an older status changes nothing yet is still acknowledged as accepted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_DuplicateUpsert_OlderStatus_NoOp_StillRepliesAccepted()
{
@@ -143,7 +149,7 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
var secondReply = ExpectMsg<UpsertSiteCallReply>(TimeSpan.FromSeconds(10));
Assert.True(secondReply.Accepted, "Stale upsert must still be acked (idempotent contract).");
// Storage must still show the rank-2 row, not rolled back.
// Storage must still show the rank 2 row, not rolled back.
await using var readContext = CreateContext();
var stored = await readContext.Set<SiteCall>()
.Where(s => s.TrackedOperationId == id)
@@ -154,6 +160,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal("first", stored[0].LastError);
}
/// <summary>Verifies that a transient repository failure replies not-accepted and leaves the actor alive.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_RepoThrowsTransient_RepliesAccepted_False_ActorStaysAlive()
{
@@ -194,8 +202,10 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal(healthyId, rows[0].TrackedOperationId);
}
// ── Task 4: read-side (query / detail / KPI) handlers ──
// ── Read-side (query / detail / KPI) handlers ──
/// <summary>Verifies that filtering a query by source site returns only the matching summaries.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SiteCallQueryRequest_FilterBySourceSite_ReturnsMatchingSummaries()
{
@@ -231,10 +241,12 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal(response.SiteCalls[^1].TrackedOperationId, response.NextAfterId);
}
/// <summary>Verifies that filtering a query by source node returns only the matching summaries.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SiteCallQueryRequest_FilterBySourceNode_ReturnsMatchingSummaries()
{
// Task 17: the new Node filter input pushes a string into
// The new Node filter input pushes a string into
// SiteCallQueryRequest.SourceNodeFilter — the actor must thread it
// onto SiteCallQueryFilter.SourceNode and the response summaries must
// mirror the row's SourceNode column.
@@ -268,10 +280,12 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal("Attempted", response.SiteCalls[0].Status);
}
/// <summary>Verifies that filtering a query by tracked operation id returns only that row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SiteCallQueryRequest_FilterByTrackedOperationId_ReturnsOnlyThatRow()
{
// Flow Debugger follow-ups Task 2: the ?correlationId= deep link pushes
// Flow Debugger follow-up: the ?correlationId= deep link pushes
// one operation id into SiteCallQueryRequest.TrackedOperationIdFilter —
// the actor must thread it onto SiteCallQueryFilter.TrackedOperationId
// so the page returns exactly that row and nothing else from the site.
@@ -307,6 +321,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal(rowA.TrackedOperationId.Value, response.NextAfterId);
}
/// <summary>Verifies that keyset paging advances through the results via the cursor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SiteCallQueryRequest_KeysetPaging_AdvancesViaCursor()
{
@@ -344,6 +360,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal(3, allIds.Count);
}
/// <summary>Verifies that the stuck-only filter returns only old, non-terminal rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SiteCallQueryRequest_StuckOnly_ReturnsOnlyOldNonTerminalRows()
{
@@ -379,6 +397,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.True(response.SiteCalls[0].IsStuck);
}
/// <summary>Verifies that stuck-only pages are full and never come back under-filled with a next cursor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SiteCallQueryRequest_StuckOnly_PagesAreFull_NoEmptyPagesWithCursor()
{
@@ -392,7 +412,7 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
var now = DateTime.UtcNow;
// Three stuck rows interleaved (by CreatedAtUtc) with three non-stuck
// rows: recent non-terminal and old-but-terminal. With the StuckOnly
// filter pushed into SQL, a page-size-2 query must return exactly the
// filter pushed into SQL, a page-size-of-two query must return exactly the
// stuck rows two-per-page — never an under-filled page with a non-null
// next cursor caused by post-filtering.
for (var i = 0; i < 3; i++)
@@ -438,13 +458,15 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal(3, allIds.Count);
}
/// <summary>Verifies that a stuck-only cursor sitting on a non-stuck row skips to the next stuck row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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
// 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 (a page size of 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
@@ -470,7 +492,7 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 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
// rows, so a page-size-of-one 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();
@@ -502,7 +524,7 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
TrackedOperationId.New(), siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-1)));
// Page-1: page-size=1, expect stuckA (newest stuck row).
// Page 1: page size of 1, expect stuckA (newest stuck row).
actor.Tell(
new SiteCallQueryRequest(
"corr-stuck-b1", null, siteId, null, null, StuckOnly: true,
@@ -515,7 +537,7 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
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
// 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(
@@ -530,7 +552,7 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
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
// Page 3: between stuckB and stuckC the non-stuck row at -30m sits at
// the cursor — again must be skipped.
actor.Tell(
new SiteCallQueryRequest(
@@ -553,6 +575,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal(stuckIds, visited);
}
/// <summary>Verifies that a detail request for a known id returns the full detail.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SiteCallDetailRequest_KnownId_ReturnsFullDetail()
{
@@ -582,6 +606,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal("node-a", response.Detail.SourceNode);
}
/// <summary>Verifies that a detail request for an unknown id replies not found.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SiteCallDetailRequest_UnknownId_RepliesNotFound()
{
@@ -599,6 +625,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.NotNull(response.ErrorMessage);
}
/// <summary>Verifies that a KPI request computes point-in-time counts.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SiteCallKpiRequest_ComputesPointInTimeCounts()
{
@@ -641,8 +669,10 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.NotNull(response.OldestPendingAge);
}
// ── Per-node KPI (T6: M5.2 per-node stuck-count KPIs) ──────────────────
// ── Per-node KPI (per-node stuck-count KPIs) ──────────────────
/// <summary>Verifies that the per-node KPI request scopes the counts to each node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task PerNodeSiteCallKpiRequest_ScopesCountsToEachNode()
{
@@ -678,6 +708,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.NotNull(myNode.OldestPendingAge);
}
/// <summary>Verifies that the per-site KPI request scopes the counts to each site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task PerSiteSiteCallKpiRequest_ScopesCountsToEachSite()
{
@@ -712,6 +744,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.NotNull(mySite.OldestPendingAge);
}
/// <summary>Verifies that a repository failure during a query replies with a failure and leaves the actor alive.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SiteCallQueryRequest_RepoThrows_RepliesFailure_ActorStaysAlive()
{
@@ -734,14 +768,16 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal("corr-fault", response.CorrelationId);
}
// ── SourceNode-stamping (Task 14): end-to-end actor → repo persistence ──
// ── SourceNode-stamping: end-to-end actor → repo persistence ──
/// <summary>Verifies that an upsert command persists its source node end to end.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertSiteCallCommand_PersistsSourceNode_EndToEnd()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// SourceNode-stamping (Task 14): an UpsertSiteCallCommand carrying
// SourceNode-stamping: an UpsertSiteCallCommand carrying
// SourceNode "node-a" must land in the SiteCalls row's SourceNode
// column unchanged — verifies the actor's mapping path does not
// strip the column AND the repository INSERT writes it.
@@ -765,6 +801,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal("node-a", stored[0].SourceNode);
}
/// <summary>Verifies that an upsert command with a null source node persists null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertSiteCallCommand_NullSourceNode_PersistsNull()
{
@@ -804,32 +842,41 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
{
private readonly ISiteCallAuditRepository _inner;
/// <summary>Initializes the repository wrapper.</summary>
/// <param name="inner">The real repository that every call except <c>QueryAsync</c> delegates to.</param>
public QueryThrowingRepository(ISiteCallAuditRepository inner)
{
_inner = inner;
}
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
_inner.UpsertAsync(siteCall, ct);
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
throw new InvalidOperationException("simulated query failure");
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct);
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
@@ -846,12 +893,16 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
private readonly ISiteCallAuditRepository _inner;
private readonly TrackedOperationId _poisonId;
/// <summary>Initializes the repository wrapper.</summary>
/// <param name="inner">The real repository that non-poisoned calls delegate to.</param>
/// <param name="poisonId">The tracked operation id whose upsert throws.</param>
public ThrowingRepository(ISiteCallAuditRepository inner, TrackedOperationId poisonId)
{
_inner = inner;
_poisonId = poisonId;
}
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
if (siteCall.TrackedOperationId == _poisonId)
@@ -861,24 +912,30 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
return _inner.UpsertAsync(siteCall, ct);
}
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct);
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
@@ -2,6 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests;
public class SiteCallAuditOptionsTests
{
/// <summary>Verifies that the defaults are the expected values.</summary>
[Fact]
public void Defaults_AreExpectedValues()
{
@@ -12,7 +13,7 @@ public class SiteCallAuditOptionsTests
// KPI interval mirrors NotificationOutboxOptions.DeliveredKpiWindow.
Assert.Equal(TimeSpan.FromMinutes(1), options.KpiInterval);
// Reconciliation tick cadence mirrors SiteAuditReconciliationOptions (#23).
// Reconciliation tick cadence mirrors SiteAuditReconciliationOptions.
Assert.Equal(TimeSpan.FromMinutes(5), options.ReconciliationInterval);
// Purge tick cadence mirrors AuditLogPurgeOptions.
Assert.Equal(TimeSpan.FromHours(24), options.PurgeInterval);
@@ -20,6 +21,7 @@ public class SiteCallAuditOptionsTests
Assert.Equal(365, options.RetentionDays);
}
/// <summary>Verifies that the resolved reconciliation interval defaults to the configured value.</summary>
[Fact]
public void ResolvedReconciliationInterval_DefaultsToConfiguredValue()
{
@@ -28,6 +30,8 @@ public class SiteCallAuditOptionsTests
Assert.Equal(options.ReconciliationInterval, options.ResolvedReconciliationInterval);
}
/// <summary>Verifies that the resolved reconciliation interval clamps a zero or negative value to the minimum.</summary>
/// <param name="configuredSeconds">The configured interval in seconds.</param>
[Theory]
[InlineData(0)]
[InlineData(-5)]
@@ -47,6 +51,7 @@ public class SiteCallAuditOptionsTests
Assert.Equal(TimeSpan.FromSeconds(1), options.ResolvedReconciliationInterval);
}
/// <summary>Verifies that an override of the reconciliation interval bypasses the clamp.</summary>
[Fact]
public void ResolvedReconciliationInterval_OverrideBypassesClamp()
{
@@ -62,6 +67,7 @@ public class SiteCallAuditOptionsTests
Assert.Equal(sub1Second, options.ResolvedReconciliationInterval);
}
/// <summary>Verifies that the resolved purge interval defaults to the configured value.</summary>
[Fact]
public void ResolvedPurgeInterval_DefaultsToConfiguredValue()
{
@@ -70,6 +76,8 @@ public class SiteCallAuditOptionsTests
Assert.Equal(options.PurgeInterval, options.ResolvedPurgeInterval);
}
/// <summary>Verifies that the resolved purge interval clamps a zero or negative value to the minimum.</summary>
/// <param name="configuredSeconds">The configured interval in seconds.</param>
[Theory]
[InlineData(0)]
[InlineData(-30)]
@@ -89,6 +97,7 @@ public class SiteCallAuditOptionsTests
Assert.Equal(TimeSpan.FromMinutes(1), options.ResolvedPurgeInterval);
}
/// <summary>Verifies that a purge interval below the one-minute floor is clamped to the minimum.</summary>
[Fact]
public void ResolvedPurgeInterval_BelowMinuteFloorClampsToMinimum()
{
@@ -101,6 +110,7 @@ public class SiteCallAuditOptionsTests
Assert.Equal(TimeSpan.FromMinutes(1), options.ResolvedPurgeInterval);
}
/// <summary>Verifies that an override of the purge interval bypasses the clamp.</summary>
[Fact]
public void ResolvedPurgeInterval_OverrideBypassesClamp()
{
@@ -16,6 +16,9 @@ public class SiteCallAuditOptionsValidatorTests
private static ValidateOptionsResult Validate(SiteCallAuditOptions options) =>
new SiteCallAuditOptionsValidator().Validate(Options.DefaultName, options);
/// <summary>Verifies that the reconciliation batch size is valid only up to the RPC ceiling.</summary>
/// <param name="size">The reconciliation batch size under test.</param>
/// <param name="valid">Whether the batch size is expected to pass validation.</param>
[Theory]
[InlineData(1, true)]
[InlineData(2000, true)]
@@ -24,6 +27,7 @@ public class SiteCallAuditOptionsValidatorTests
public void ReconciliationBatchSize_MatchesRpcCeiling(int size, bool valid)
=> Assert.Equal(valid, Validate(new SiteCallAuditOptions { ReconciliationBatchSize = size }).Succeeded);
/// <summary>Verifies that the default options are valid.</summary>
[Fact]
public void DefaultOptions_AreValid()
{
@@ -31,6 +35,7 @@ public class SiteCallAuditOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a zero stuck-age threshold is rejected.</summary>
[Fact]
public void ZeroStuckAgeThreshold_IsRejected()
{
@@ -39,6 +44,7 @@ public class SiteCallAuditOptionsValidatorTests
Assert.Contains("StuckAgeThreshold", result.FailureMessage);
}
/// <summary>Verifies that a zero KPI interval is rejected.</summary>
[Fact]
public void ZeroKpiInterval_IsRejected()
{
@@ -47,6 +53,7 @@ public class SiteCallAuditOptionsValidatorTests
Assert.Contains("KpiInterval", result.FailureMessage);
}
/// <summary>Verifies that a zero relay timeout is rejected.</summary>
[Fact]
public void ZeroRelayTimeout_IsRejected()
{
@@ -55,6 +62,7 @@ public class SiteCallAuditOptionsValidatorTests
Assert.Contains("RelayTimeout", result.FailureMessage);
}
/// <summary>Verifies that a zero reconciliation interval is rejected.</summary>
[Fact]
public void ZeroReconciliationInterval_IsRejected()
{
@@ -63,6 +71,7 @@ public class SiteCallAuditOptionsValidatorTests
Assert.Contains("ReconciliationInterval", result.FailureMessage);
}
/// <summary>Verifies that a zero reconciliation batch size is rejected.</summary>
[Fact]
public void ZeroReconciliationBatchSize_IsRejected()
{
@@ -71,6 +80,7 @@ public class SiteCallAuditOptionsValidatorTests
Assert.Contains("ReconciliationBatchSize", result.FailureMessage);
}
/// <summary>Verifies that a zero purge interval is rejected.</summary>
[Fact]
public void ZeroPurgeInterval_IsRejected()
{
@@ -79,6 +89,7 @@ public class SiteCallAuditOptionsValidatorTests
Assert.Contains("PurgeInterval", result.FailureMessage);
}
/// <summary>Verifies that zero retention days is rejected.</summary>
[Fact]
public void ZeroRetentionDays_IsRejected()
{
@@ -12,7 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests;
/// <summary>
/// Purge-scheduler tests for <see cref="SiteCallAuditActor"/> (#22, Piece B).
/// Purge-scheduler tests for <see cref="SiteCallAuditActor"/>.
/// Exercises the daily terminal-row purge tick in-memory — a recording
/// <see cref="ISiteCallAuditRepository"/> captures the
/// <see cref="ISiteCallAuditRepository.PurgeTerminalAsync"/> threshold the actor
@@ -34,6 +34,7 @@ public class SiteCallAuditPurgeTests : TestKit
/// <summary>Empty enumerator — the purge path never touches it, but it must be present to arm the scheduler.</summary>
private sealed class EmptyEnumerator : ISiteEnumerator
{
/// <inheritdoc />
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteEntry>>(Array.Empty<SiteEntry>());
}
@@ -41,6 +42,7 @@ public class SiteCallAuditPurgeTests : TestKit
/// <summary>No-op pull client — present only to arm the scheduler.</summary>
private sealed class NoOpPullClient : IPullSiteCallsClient
{
/// <inheritdoc />
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) =>
Task.FromResult(new PullSiteCallsResponse(Array.Empty<SiteCall>(), MoreAvailable: false));
@@ -52,32 +54,41 @@ public class SiteCallAuditPurgeTests : TestKit
/// </summary>
private sealed class RecordingRepo : ISiteCallAuditRepository
{
/// <summary>The cutoff thresholds passed to each purge call, in call order.</summary>
public List<DateTime> PurgeThresholds { get; } = new();
/// <summary>The row count each purge call reports as deleted.</summary>
public int RowsDeletedPerCall { get; set; }
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
{
PurgeThresholds.Add(olderThanUtc);
return Task.FromResult(RowsDeletedPerCall);
}
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => Task.CompletedTask;
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
Task.FromResult<SiteCall?>(null);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0));
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>());
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>());
@@ -88,17 +99,24 @@ public class SiteCallAuditPurgeTests : TestKit
{
public int PurgeCallCount;
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
{
Interlocked.Increment(ref PurgeCallCount);
throw new InvalidOperationException("simulated purge failure");
}
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => Task.CompletedTask;
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => Task.FromResult<SiteCall?>(null);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync(SiteCallQueryFilter f, SiteCallPaging p, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0));
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>());
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>());
}
@@ -115,7 +133,7 @@ public class SiteCallAuditPurgeTests : TestKit
}
/// <summary>
/// Row-82/row-95 house pattern: every test in this file arms a
/// House pattern: every test in this file arms a
/// <c>PurgeIntervalOverride = 100ms</c> ticker. Left running past the test's own
/// assertions it keeps firing against the recording repo — and, worse, against a
/// scope-per-tick DI container whose lifetime the test no longer controls — for the
@@ -132,6 +150,7 @@ public class SiteCallAuditPurgeTests : TestKit
// 1. PurgeTick_CallsPurgeTerminal_WithRetentionThreshold
// ---------------------------------------------------------------------
/// <summary>Verifies that purge tick, calls purge terminal async, with retention threshold.</summary>
[Fact]
public void PurgeTick_CallsPurgeTerminalAsync_WithRetentionThreshold()
{
@@ -165,6 +184,7 @@ public class SiteCallAuditPurgeTests : TestKit
// 2. PurgeTick_UsesDefaultRetention_365Days
// ---------------------------------------------------------------------
/// <summary>Verifies that purge tick, default retention, uses365 day threshold.</summary>
[Fact]
public void PurgeTick_DefaultRetention_Uses365DayThreshold()
{
@@ -193,6 +213,7 @@ public class SiteCallAuditPurgeTests : TestKit
// 3. PurgeTick_RepoThrows_ActorStaysAlive_RetriesNextTick (continue-on-error)
// ---------------------------------------------------------------------
/// <summary>Verifies that purge tick, purge throws, actor stays alive, retries next tick.</summary>
[Fact]
public void PurgeTick_PurgeThrows_ActorStaysAlive_RetriesNextTick()
{
@@ -216,13 +237,14 @@ public class SiteCallAuditPurgeTests : TestKit
}
// ---------------------------------------------------------------------
// 4. SiteCallAudit-007: purge timer arms even when the reconciliation
// 4. Purge timer arms even when the reconciliation
// collaborators are ABSENT (production ctor, no IPullSiteCallsClient /
// ISiteEnumerator registered). Proves the decoupling — a host that omits
// the reconciliation client still purges, so the central SiteCalls table
// cannot grow unbounded.
// ---------------------------------------------------------------------
/// <summary>Verifies that purge tick, production ctor, no reconciliation collaborators, still purges.</summary>
[Fact]
public void PurgeTick_ProductionCtor_NoReconciliationCollaborators_StillPurges()
{
@@ -232,7 +254,7 @@ public class SiteCallAuditPurgeTests : TestKit
// ctor resolves per-tick, but deliberately registers NEITHER
// IPullSiteCallsClient NOR ISiteEnumerator. GetService returns null for
// both, so the actor's reconciliation tick is gated off — but the purge
// tick must still arm (SiteCallAudit-007).
// tick must still arm.
var provider = new ServiceCollection()
.AddScoped<ISiteCallAuditRepository>(_ => repo)
.BuildServiceProvider();
@@ -12,7 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests;
/// <summary>
/// Reconciliation-tick tests for <see cref="SiteCallAuditActor"/> (#22, Piece A).
/// Reconciliation-tick tests for <see cref="SiteCallAuditActor"/> (Piece A).
/// These exercise the periodic per-site self-heal pull entirely in-memory —
/// fake <see cref="IPullSiteCallsClient"/> + <see cref="ISiteEnumerator"/> + a
/// recording <see cref="ISiteCallAuditRepository"/> — so they run in
@@ -63,7 +63,10 @@ public class SiteCallAuditReconciliationTests : TestKit
private sealed class StaticEnumerator : ISiteEnumerator
{
private readonly IReadOnlyList<SiteEntry> _sites;
/// <summary>Creates an enumerator that returns the given sites.</summary>
/// <param name="sites">The sites to return.</param>
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
/// <inheritdoc />
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites);
}
@@ -75,22 +78,32 @@ public class SiteCallAuditReconciliationTests : TestKit
/// </summary>
private sealed class ScriptedPullClient : IPullSiteCallsClient
{
/// <summary>Gets every pull invocation recorded so far.</summary>
public List<(string SiteId, DateTime SinceUtc, string? AfterId, int BatchSize)> Calls { get; } = new();
private readonly Dictionary<string, Queue<PullSiteCallsResponse>> _scripted = new();
private readonly Dictionary<string, Exception> _throwOnSite = new();
/// <summary>Queues the responses to return for the given site.</summary>
/// <param name="siteId">The site the responses are for.</param>
/// <param name="responses">The responses to return, in order.</param>
/// <returns>This client, for chaining.</returns>
public ScriptedPullClient Script(string siteId, params PullSiteCallsResponse[] responses)
{
_scripted[siteId] = new Queue<PullSiteCallsResponse>(responses);
return this;
}
/// <summary>Makes every pull for the given site throw.</summary>
/// <param name="siteId">The site whose pulls should fail.</param>
/// <param name="ex">The exception to throw.</param>
/// <returns>This client, for chaining.</returns>
public ScriptedPullClient ThrowFor(string siteId, Exception ex)
{
_throwOnSite[siteId] = ex;
return this;
}
/// <inheritdoc />
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
@@ -109,7 +122,7 @@ public class SiteCallAuditReconciliationTests : TestKit
}
/// <summary>
/// Keyset-honouring in-memory pull client (Task 16). Holds one static set of
/// Keyset-honouring in-memory pull client. Holds one static set of
/// rows and answers each pull as a real site would: returns the next
/// <c>batchSize</c> rows strictly greater than the composite
/// <c>(sinceUtc, afterId)</c> cursor under the deterministic
@@ -122,14 +135,18 @@ public class SiteCallAuditReconciliationTests : TestKit
private sealed class KeysetHonoringPullClient : IPullSiteCallsClient
{
private readonly IReadOnlyList<SiteCall> _rows;
/// <summary>Gets the number of pulls made so far.</summary>
public int CallCount { get; private set; }
/// <summary>Creates a client holding the given rows in keyset order.</summary>
/// <param name="rows">The rows the simulated site holds.</param>
public KeysetHonoringPullClient(IEnumerable<SiteCall> rows) =>
_rows = rows
.OrderBy(r => r.UpdatedAtUtc)
.ThenBy(r => r.TrackedOperationId.ToString(), StringComparer.Ordinal)
.ToList();
/// <inheritdoc />
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
@@ -148,7 +165,7 @@ public class SiteCallAuditReconciliationTests : TestKit
}
/// <summary>
/// Legacy pull client (Task 16) that IGNORES <c>afterId</c> and always
/// Legacy pull client that IGNORES <c>afterId</c> and always
/// returns the same saturated page (<c>MoreAvailable=true</c>) — models a
/// pre-keyset site the central actor cannot drain past. The composite cursor
/// cannot advance, so the actor must latch and publish
@@ -157,10 +174,14 @@ public class SiteCallAuditReconciliationTests : TestKit
private sealed class LegacyIgnoresAfterIdPullClient : IPullSiteCallsClient
{
private readonly IReadOnlyList<SiteCall> _rows;
/// <summary>Gets the number of pulls made so far.</summary>
public int CallCount { get; private set; }
/// <summary>Creates a client that always returns the given rows.</summary>
/// <param name="rows">The rows returned on every pull.</param>
public LegacyIgnoresAfterIdPullClient(IReadOnlyList<SiteCall> rows) => _rows = rows;
/// <inheritdoc />
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
@@ -172,7 +193,7 @@ public class SiteCallAuditReconciliationTests : TestKit
/// <summary>
/// Pull client that ALWAYS returns the same saturated response
/// (<c>MoreAvailable=true</c>) regardless of the <c>since</c> cursor —
/// simulates the SiteCallAudit-009 single-timestamp no-progress pin: a backlog
/// simulates the single-timestamp no-progress pin: a backlog
/// larger than the batch size all sharing one exact <c>UpdatedAtUtc</c>, so
/// the inclusive max-timestamp cursor never advances. Records every call so
/// the test can assert the within-tick drain is BOUNDED (the actor must not
@@ -181,10 +202,14 @@ public class SiteCallAuditReconciliationTests : TestKit
private sealed class SaturatedPinPullClient : IPullSiteCallsClient
{
private readonly IReadOnlyList<SiteCall> _rows;
/// <summary>Gets the number of pulls made so far.</summary>
public int CallCount { get; private set; }
/// <summary>Creates a client that always returns the given rows.</summary>
/// <param name="rows">The rows returned on every pull.</param>
public SaturatedPinPullClient(IReadOnlyList<SiteCall> rows) => _rows = rows;
/// <inheritdoc />
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
@@ -201,9 +226,12 @@ public class SiteCallAuditReconciliationTests : TestKit
/// </summary>
private sealed class RecordingRepo : ISiteCallAuditRepository
{
/// <summary>Gets the upserted rows keyed by tracked operation id.</summary>
public Dictionary<TrackedOperationId, SiteCall> Upserted { get; } = new();
/// <summary>Gets the number of upsert calls received.</summary>
public int UpsertCallCount { get; private set; }
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
UpsertCallCount++;
@@ -211,24 +239,30 @@ public class SiteCallAuditReconciliationTests : TestKit
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
Task.FromResult(Upserted.TryGetValue(id, out var row) ? row : null);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
Task.FromResult(0);
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0));
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>());
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>());
@@ -252,7 +286,7 @@ public class SiteCallAuditReconciliationTests : TestKit
}
/// <summary>
/// Row-82/row-95 house pattern: every actor built above arms a fast-repeating
/// House pattern: every actor built above arms a fast-repeating
/// reconciliation (and, via <see cref="SiteCallAuditActor"/>, purge) ticker — as fast
/// as 100ms in most of the tests below. Left running past the owning test it keeps
/// firing against test doubles the test has stopped controlling, stranding later
@@ -269,6 +303,7 @@ public class SiteCallAuditReconciliationTests : TestKit
// 1. AbsentRow_PulledFromSite_IsUpserted
// ---------------------------------------------------------------------
/// <summary>Verifies that a row absent centrally is upserted from the site pull.</summary>
[Fact]
public void ReconciliationTick_AbsentRow_IsUpsertedFromSitePull()
{
@@ -305,6 +340,7 @@ public class SiteCallAuditReconciliationTests : TestKit
// 2. Cursor_Advances_ToMaxUpdatedAtUtc_NoRePullOfOldRows
// ---------------------------------------------------------------------
/// <summary>Verifies that a second tick advances the cursor past the rows already pulled.</summary>
[Fact]
public void ReconciliationTick_SecondTick_AdvancesCursorPastAlreadyPulledRows()
{
@@ -348,6 +384,7 @@ public class SiteCallAuditReconciliationTests : TestKit
// 3. OneSiteThrows_OtherSitesStillProcessed (failure isolation)
// ---------------------------------------------------------------------
/// <summary>Verifies that other sites are still reconciled when the pull for one site throws.</summary>
[Fact]
public void ReconciliationTick_OneSiteThrows_OtherSitesStillReconciled()
{
@@ -389,6 +426,7 @@ public class SiteCallAuditReconciliationTests : TestKit
// start the reconciliation tick
// ---------------------------------------------------------------------
/// <summary>Verifies that no reconciliation tick starts when the collaborators are not registered.</summary>
[Fact]
public void NoReconciliationCollaboratorsRegistered_DoesNotStartReconciliationTick()
{
@@ -436,11 +474,12 @@ public class SiteCallAuditReconciliationTests : TestKit
}
// ---------------------------------------------------------------------
// 5. SiteCallAudit-009: MoreAvailable drives a within-tick continuation
// 5. MoreAvailable drives a within-tick continuation
// drain — a multi-page backlog whose timestamps advance is fully drained
// in ONE tick rather than one page per tick.
// ---------------------------------------------------------------------
/// <summary>Verifies that a multi-page backlog is drained within a single tick when more rows are available.</summary>
[Fact]
public void ReconciliationTick_MoreAvailable_DrainsMultiplePagesWithinOneTick()
{
@@ -455,7 +494,7 @@ public class SiteCallAuditReconciliationTests : TestKit
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteA:8083"));
// Page 1 saturates (MoreAvailable: true) → the actor continues pulling
// within the SAME tick; page 2 is the final page (MoreAvailable: false).
// The continuation pull's `since` must be t2 (page-1 max), proving the
// The continuation pull's `since` must be t2 (page 1 max), proving the
// cursor advanced page-to-page inside one tick rather than across ticks.
var client = new ScriptedPullClient().Script(siteId,
new PullSiteCallsResponse(new[] { p1a, p1b }, MoreAvailable: true),
@@ -477,7 +516,7 @@ public class SiteCallAuditReconciliationTests : TestKit
AwaitAssert(
() =>
{
// All three rows reconciled — including the page-2 row that only a
// All three rows reconciled — including the page 2 row that only a
// within-tick continuation pull could have fetched.
Assert.True(repo.Upserted.ContainsKey(p1a.TrackedOperationId));
Assert.True(repo.Upserted.ContainsKey(p1b.TrackedOperationId));
@@ -488,7 +527,7 @@ public class SiteCallAuditReconciliationTests : TestKit
interval: TimeSpan.FromMilliseconds(50));
// Exactly two pulls happened (page 1 + the continuation page 2) and the
// second pull's `since` cursor advanced to the page-1 max (t2).
// second pull's `since` cursor advanced to the page 1 max (t2).
Assert.True(client.Calls.Count >= 2, $"expected >= 2 pulls within the tick, got {client.Calls.Count}");
Assert.Equal(DateTime.MinValue, client.Calls[0].SinceUtc);
Assert.Equal(t2, client.Calls[1].SinceUtc);
@@ -500,12 +539,13 @@ public class SiteCallAuditReconciliationTests : TestKit
}
// ---------------------------------------------------------------------
// 6. SiteCallAudit-009: single-timestamp saturation pin does NOT spin —
// 6. Single-timestamp saturation pin does NOT spin —
// a saturated batch whose max UpdatedAtUtc never advances past `since`
// breaks the within-tick drain after one page (no unbounded re-pull),
// and still upserts the rows it saw.
// ---------------------------------------------------------------------
/// <summary>Verifies that a single-timestamp saturation does not spin the drain and makes no progress gracefully.</summary>
[Fact]
public void ReconciliationTick_SingleTimestampSaturation_DoesNotSpin_MakesNoProgressGracefully()
{
@@ -551,7 +591,7 @@ public class SiteCallAuditReconciliationTests : TestKit
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// Critical SiteCallAudit-009 invariant: the within-tick drain BROKE on the
// Critical invariant: the within-tick drain BROKE on the
// no-progress pin rather than looping to the 50-page ceiling. With a 2s
// tick interval, only the first tick has fired in the window, so the pull
// count reflects ONE tick's within-loop behaviour. A correct break yields
@@ -568,12 +608,13 @@ public class SiteCallAuditReconciliationTests : TestKit
}
// ---------------------------------------------------------------------
// 7. Task 16: a keyset-honouring site drains a single-timestamp burst
// 7. A keyset-honouring site drains a single-timestamp burst
// LARGER than the batch size in ONE tick — the composite (UpdatedAtUtc,
// TrackedOperationId) cursor advances by id even when every row shares
// one exact timestamp, so the tail no longer pins forever.
// ---------------------------------------------------------------------
/// <summary>Verifies that the keyset cursor drains a single-timestamp burst larger than the batch size.</summary>
[Fact]
public void ReconciliationTick_KeysetCursor_DrainsSingleTimestampBurstBeyondBatchSize()
{
@@ -625,11 +666,12 @@ public class SiteCallAuditReconciliationTests : TestKit
}
// ---------------------------------------------------------------------
// 8. Task 16: a LEGACY site that ignores after_id can never be drained, so
// 8. A LEGACY site that ignores after_id can never be drained, so
// the actor latches and publishes SiteCallReconciliationPinnedChanged
// instead of silently logging (the dead-end is now health-observable).
// ---------------------------------------------------------------------
/// <summary>Verifies that a legacy site ignoring the after-id cursor causes a pinned-changed event to be published.</summary>
[Fact]
public void ReconciliationTick_LegacySiteIgnoresAfterId_PublishesPinnedChanged()
{
@@ -665,7 +707,7 @@ public class SiteCallAuditReconciliationTests : TestKit
}
// ---------------------------------------------------------------------
// 9. WP2.2: the reconciliation drain runs OFF the mailbox, so ingest,
// 9. The reconciliation drain runs OFF the mailbox, so ingest,
// query and KPI messages are answered while a long post-outage
// catch-up is still pulling.
// ---------------------------------------------------------------------
@@ -684,8 +726,10 @@ public class SiteCallAuditReconciliationTests : TestKit
/// <summary>Completes once the drain has actually started pulling.</summary>
public Task Entered => _entered.Task;
/// <summary>Releases the blocked pull.</summary>
public void Release() => _release.TrySetResult();
/// <inheritdoc />
public async Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
@@ -695,6 +739,8 @@ public class SiteCallAuditReconciliationTests : TestKit
}
}
/// <summary>Verifies that an in-flight reconciliation drain does not block an ingest upsert.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReconciliationDrain_InFlight_DoesNotBlockIngestUpsert()
{
@@ -732,12 +778,14 @@ public class SiteCallAuditReconciliationTests : TestKit
{
// Unblock the drain FIRST so the actor is free to process the Stop rather
// than parked inside the blocked pull, then stop it deterministically
// rather than leaving its 100ms ticker running (row-95).
// rather than leaving its 100ms ticker running.
client.Release();
StopTicker(actor);
}
}
/// <summary>Verifies that reconciliation ticks do not overlap while a drain is in flight.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReconciliationTicks_DoNotOverlap_WhileADrainIsInFlight()
{
@@ -764,8 +812,7 @@ public class SiteCallAuditReconciliationTests : TestKit
finally
{
// Unblock the drain FIRST so the actor is free to process the Stop, then
// stop it deterministically rather than leaving its 100ms ticker running
// (row-95).
// stop it deterministically rather than leaving its 100ms ticker running.
client.Release();
StopTicker(actor);
}
@@ -780,6 +827,7 @@ public class SiteCallAuditReconciliationTests : TestKit
/// upsert arriving on the mailbox. (Production is unaffected: each message and
/// each pass resolves its own scope, hence its own context.)
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InjectedRepository_IsNeverCalledConcurrently_ByDrainAndMailbox()
{
@@ -835,10 +883,14 @@ public class SiteCallAuditReconciliationTests : TestKit
new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _callCount;
/// <summary>Creates a client that returns the given rows on its first pull only.</summary>
/// <param name="rows">The rows returned by the first pull.</param>
public OneBatchThenEmptyPullClient(SiteCall[] rows) => _rows = rows;
/// <summary>Gets a task that completes once the first pull has started.</summary>
public Task Entered => _entered.Task;
/// <inheritdoc />
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
@@ -861,24 +913,31 @@ public class SiteCallAuditReconciliationTests : TestKit
private int _maxObserved;
private int _upsertCount;
/// <summary>Creates a repository that holds each call open for the given time.</summary>
/// <param name="hold">How long each repository call stays in flight.</param>
public ConcurrencyDetectingRepo(TimeSpan hold) => _hold = hold;
/// <summary>Gets the peak number of overlapping repository calls.</summary>
public int MaxObservedConcurrency => Volatile.Read(ref _maxObserved);
/// <summary>Gets the number of upserts received.</summary>
public int UpsertCount => Volatile.Read(ref _upsertCount);
/// <inheritdoc />
public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
Interlocked.Increment(ref _upsertCount);
await TrackAsync().ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default)
{
await TrackAsync().ConfigureAwait(false);
return null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default)
{
@@ -886,12 +945,14 @@ public class SiteCallAuditReconciliationTests : TestKit
return Array.Empty<SiteCall>();
}
/// <inheritdoc />
public async Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
{
await TrackAsync().ConfigureAwait(false);
return 0;
}
/// <inheritdoc />
public async Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
@@ -899,6 +960,7 @@ public class SiteCallAuditReconciliationTests : TestKit
return new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
@@ -906,6 +968,7 @@ public class SiteCallAuditReconciliationTests : TestKit
return Array.Empty<SiteCallSiteKpiSnapshot>();
}
/// <inheritdoc />
public async Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
@@ -948,12 +1011,16 @@ public class SiteCallAuditReconciliationTests : TestKit
new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _callCount;
/// <summary>Gets a task that completes once the first pull has started.</summary>
public Task Entered => _entered.Task;
/// <summary>Gets the number of pulls started so far.</summary>
public int CallCount => Volatile.Read(ref _callCount);
/// <summary>Releases the blocked pull.</summary>
public void Release() => _release.TrySetResult();
/// <inheritdoc />
public async Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
@@ -12,7 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests;
/// <summary>
/// Task 5 (#22 Retry/Discard relay): tests for <see cref="SiteCallAuditActor"/>
/// Tests for <see cref="SiteCallAuditActor"/>
/// relaying operator Retry/Discard on a parked Site Call down to the owning
/// site. The relay routes a <see cref="RetryParkedOperation"/> /
/// <see cref="DiscardParkedOperation"/> command via a <see cref="SiteEnvelope"/>
@@ -30,27 +30,34 @@ public class SiteCallRelayTests : TestKit
/// </summary>
private sealed class ThrowingRepository : ISiteCallAuditRepository
{
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not write the SiteCalls row");
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not read the SiteCalls row");
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not query the SiteCalls table");
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not purge");
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not compute KPIs");
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not compute per-site KPIs");
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not compute per-node KPIs");
@@ -74,6 +81,7 @@ public class SiteCallRelayTests : TestKit
return actor;
}
/// <summary>Verifies that retrying a site call routes a RetryParkedOperation command to the owning site.</summary>
[Fact]
public void RetrySiteCall_RoutesRetryParkedOperation_ToOwningSite()
{
@@ -101,6 +109,7 @@ public class SiteCallRelayTests : TestKit
Assert.Null(response.ErrorMessage);
}
/// <summary>Verifies that discarding a site call routes a DiscardParkedOperation command to the owning site.</summary>
[Fact]
public void DiscardSiteCall_RoutesDiscardParkedOperation_ToOwningSite()
{
@@ -122,6 +131,7 @@ public class SiteCallRelayTests : TestKit
Assert.True(response.Success);
}
/// <summary>Verifies that a site reply of not applied is reported as the call not being parked.</summary>
[Fact]
public void RetrySiteCall_SiteRepliesNotApplied_ReportsNotParked()
{
@@ -141,6 +151,7 @@ public class SiteCallRelayTests : TestKit
Assert.True(response.SiteReachable);
}
/// <summary>Verifies that a site error reply is reported as a failed operation.</summary>
[Fact]
public void RetrySiteCall_SiteRepliesError_ReportsOperationFailed()
{
@@ -162,6 +173,7 @@ public class SiteCallRelayTests : TestKit
Assert.NotNull(response.ErrorMessage);
}
/// <summary>Verifies that a retry to a site that never replies is reported as site unreachable.</summary>
[Fact]
public void RetrySiteCall_SiteNeverReplies_ReportsSiteUnreachable()
{
@@ -183,6 +195,7 @@ public class SiteCallRelayTests : TestKit
Assert.NotNull(response.ErrorMessage);
}
/// <summary>Verifies that a discard to a site that never replies is reported as site unreachable.</summary>
[Fact]
public void DiscardSiteCall_SiteNeverReplies_ReportsSiteUnreachable()
{
@@ -198,6 +211,7 @@ public class SiteCallRelayTests : TestKit
Assert.False(response.SiteReachable);
}
/// <summary>Verifies that a retry issued before central communication is registered is reported as site unreachable.</summary>
[Fact]
public void RetrySiteCall_BeforeCentralCommunicationRegistered_ReportsSiteUnreachable()
{
@@ -217,13 +231,18 @@ public class SiteCallRelayTests : TestKit
Assert.False(response.SiteReachable);
}
// ── Task 14: operator-identity audit on a successful relay ──
// ── Operator-identity audit on a successful relay ──
/// <summary>Captures the central direct-write audit events emitted by the relay.</summary>
private sealed class RecordingCentralAuditWriter : ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter
{
/// <summary>The central direct-write audit rows captured so far.</summary>
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
/// <summary>Records the audit event for later assertions.</summary>
/// <param name="evt">The audit event to record.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent evt, CancellationToken ct = default)
{
lock (Events)
@@ -243,29 +262,38 @@ public class SiteCallRelayTests : TestKit
private sealed class RowReturningRepository : ISiteCallAuditRepository
{
private readonly SiteCall _row;
/// <summary>Creates the repository around the fixed row it returns from <c>GetAsync</c>.</summary>
/// <param name="row">The site call row returned for its own tracked operation ID.</param>
public RowReturningRepository(SiteCall row) { _row = row; }
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
Task.FromResult<SiteCall?>(_row.TrackedOperationId == id ? _row : null);
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not write the SiteCalls row");
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not query the SiteCalls table");
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not purge");
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not compute KPIs");
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not compute per-site KPIs");
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
throw new InvalidOperationException("relay must not compute per-node KPIs");
@@ -302,6 +330,7 @@ public class SiteCallRelayTests : TestKit
return (actor, writer);
}
/// <summary>Verifies that a retry that the site applied emits a cached-resolve submitted audit row that records the operator.</summary>
[Fact]
public void RetrySiteCall_WhenApplied_EmitsCachedResolveSubmitted_WithOperator()
{
@@ -330,6 +359,7 @@ public class SiteCallRelayTests : TestKit
});
}
/// <summary>Verifies that a retry that the site did not apply emits no audit row.</summary>
[Fact]
public void RetrySiteCall_WhenNotApplied_EmitsNoAuditRow()
{
@@ -350,6 +380,7 @@ public class SiteCallRelayTests : TestKit
lock (writer.Events) { Assert.Empty(writer.Events); }
}
/// <summary>Verifies that a discard that the site applied emits a cached-resolve discarded audit row that records the operator.</summary>
[Fact]
public void DiscardSiteCall_WhenApplied_EmitsCachedResolveDiscarded_WithOperator()
{
@@ -41,6 +41,7 @@ internal static class StubRepositoryProvider
/// False registers the repository unwrapped — for fixtures asserting on call ordering or
/// identity where the decorator would get in the way.
/// </param>
/// <returns>A service provider whose scopes resolve the configured repository and collaborators.</returns>
public static ServiceProvider Build(
ISiteCallAuditRepository repository,
ISiteEnumerator? siteEnumerator = null,
@@ -107,29 +108,38 @@ internal sealed class SerializingSiteCallAuditRepository : ISiteCallAuditReposit
private readonly ISiteCallAuditRepository _inner;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>Wraps the inner repository so every call is serialized.</summary>
/// <param name="inner">The repository to wrap.</param>
public SerializingSiteCallAuditRepository(ISiteCallAuditRepository inner) => _inner = inner;
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
RunAsync(() => _inner.UpsertAsync(siteCall, ct));
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
RunAsync(() => _inner.GetAsync(id, ct));
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
RunAsync(() => _inner.QueryAsync(filter, paging, ct));
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.PurgeTerminalAsync(olderThanUtc, ct));
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputePerSiteKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputePerNodeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));