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
@@ -12,7 +12,7 @@ using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// Bundle D (#23 M1) integration tests for <see cref="AuditLogRepository"/>. Uses
/// Integration tests for <see cref="AuditLogRepository"/>. Uses
/// the same <see cref="MsSqlMigrationFixture"/> as the Bundle C migration tests so
/// raw-SQL paths (the IF NOT EXISTS insert, partition switch) execute against a
/// real partitioned schema. Tests scope all queries by a per-test
@@ -23,11 +23,15 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Creates the test class with the shared migrated SQL Server fixture.</summary>
/// <param name="fixture">The shared migration fixture.</param>
public AuditLogRepositoryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>Verifies that inserting a fresh event writes one row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_FreshEvent_WritesOneRow()
{
@@ -51,6 +55,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(evt.EventId, loaded[0].EventId);
}
/// <summary>Verifies that inserting an event persists its source node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_PersistsSourceNode()
{
@@ -75,6 +81,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("central-a", loaded[0].SourceNode);
}
/// <summary>Verifies that inserting an event with no source node persists a null source node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_PersistsNullSourceNode()
{
@@ -101,6 +109,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(loaded[0].SourceNode);
}
/// <summary>Verifies that inserting a duplicate event id is a no-op that neither throws nor duplicates the row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_DuplicateEventId_IsNoOp_NoExceptionNoDuplicate()
{
@@ -115,7 +125,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(first);
// Same EventId, different payload — first-write-wins, the second call is silently a no-op.
// C3 (Task 2.5): ErrorMessage rides in DetailsJson on the canonical record, so rebuild
// ErrorMessage rides in DetailsJson on the canonical record, so rebuild
// a sibling row carrying the same EventId via the factory (rather than a top-level `with`).
var second = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
@@ -133,11 +143,13 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
.ToListAsync();
Assert.Single(loaded);
// C5 (Task 2.5): ErrorMessage rides in DetailsJson now — decode it to assert
// ErrorMessage rides in DetailsJson — decode it to assert
// first-write-wins kept the original payload.
Assert.Equal("first", AuditDetailsCodec.Deserialize(loaded[0].DetailsJson).ErrorMessage);
}
/// <summary>Verifies that a query returns rows in descending occurred-time order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_ReturnsRowsInOccurredDescOrder()
{
@@ -161,6 +173,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.True(rows[1].OccurredAtUtc > rows[2].OccurredAtUtc);
}
/// <summary>Verifies that a query can filter by channel.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByChannel()
{
@@ -185,6 +199,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.AsRow().Channel));
}
/// <summary>Verifies that filtering by several channels returns their union.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByMultipleChannels_ReturnsUnion()
{
@@ -212,6 +228,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.DoesNotContain(rows, r => r.AsRow().Channel == AuditChannel.DbOutbound);
}
/// <summary>Verifies that filtering by several statuses returns their union.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByMultipleStatuses_ReturnsUnion()
{
@@ -238,6 +256,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.DoesNotContain(rows, r => r.AsRow().Status == AuditStatus.Delivered);
}
/// <summary>Verifies that filtering by several source site ids returns their union.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByMultipleSourceSiteIds_ReturnsUnion()
{
@@ -263,6 +283,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.DoesNotContain(rows, r => r.AsRow().SourceSiteId == siteC);
}
/// <summary>Verifies that an empty channel list does not constrain the query.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_EmptyChannelList_DoesNotConstrain()
{
@@ -286,6 +308,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(2, rows.Count);
}
/// <summary>Verifies that a query can filter by source site id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterBySourceSiteId()
{
@@ -310,12 +334,14 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
// ──────────────────────────────────────────────────────────────────────
// Task 15: SourceNodes filter + GetDistinctSourceNodesAsync. Pins the new
// SourceNodes filter + GetDistinctSourceNodesAsync. Pins the new
// Node multi-select contract — non-empty list → SQL IN (…); NULL
// SourceNode rows are excluded when the filter is set; the distinct
// enumeration omits nulls and orders ascending.
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that filtering by source node returns the matching rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterBySourceNode_ReturnsMatchingRows()
{
@@ -340,6 +366,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("central-a", rows[0].SourceNode);
}
/// <summary>Verifies that filtering by several source nodes returns their union.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByMultipleSourceNodes_ReturnsUnion()
{
@@ -364,6 +392,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Contains(r.SourceNode, new[] { "central-a", "central-b" }));
}
/// <summary>Verifies that the distinct source nodes are returned without nulls and in ascending order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetDistinctSourceNodesAsync_ReturnsDistinctNonNullValues_Ordered()
{
@@ -394,6 +424,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(nodes.OrderBy(n => n, StringComparer.Ordinal), nodes);
}
/// <summary>Verifies that filtering by execution id returns the matching rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows()
{
@@ -422,6 +454,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Equal(executionId, r.AsRow().ExecutionId));
}
/// <summary>Verifies that filtering by parent execution id returns the matching rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByParentExecutionId_ReturnsMatchingRows()
{
@@ -451,6 +485,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Equal(parentExecutionId, r.AsRow().ParentExecutionId));
}
/// <summary>Verifies that a query can filter by time range.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByTimeRange()
{
@@ -476,6 +512,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(t0.AddMinutes(30), rows[0].OccurredAtUtc);
}
/// <summary>Verifies that the next keyset page starts after the cursor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_Keyset_NextPageStartsAfterCursor()
{
@@ -525,6 +563,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(t0.AddMinutes(0), page3[0].OccurredAtUtc);
}
/// <summary>Verifies that concurrent duplicate inserts produce exactly one row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_ConcurrentDuplicateInserts_ProduceExactlyOneRow()
{
@@ -561,9 +601,11 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
// ------------------------------------------------------------------------
// WP2.2: set-based InsertManyIfNotExistsAsync
// Set-based InsertManyIfNotExistsAsync
// ------------------------------------------------------------------------
/// <summary>Verifies that a batch insert writes every distinct event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_WritesEveryDistinctEvent()
{
@@ -593,6 +635,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
rows.Select(r => r.EventId).OrderBy(g => g).ToArray());
}
/// <summary>Verifies that duplicate event ids within one batch collapse to a single row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_DuplicateEventIdsWithinOnePacket_CollapseToOneRow()
{
@@ -638,6 +682,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(AuditDetailsCodec.Deserialize(stored.DetailsJson).ErrorMessage);
}
/// <summary>Verifies that duplicate event ids across batches are inserted idempotently.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_DuplicateEventIdsAcrossPackets_AreIdempotent()
{
@@ -675,6 +721,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(6, rows.Count);
}
/// <summary>Verifies that a batch larger than the SQL parameter ceiling is inserted in chunks.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_ChunksBeyondTheParameterCeiling()
{
@@ -718,6 +766,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
/// server raise 2628/8152. Reject-everywhere is the contract; this test pins
/// both halves of it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_TargetLongerThanColumn_IsRejected_NotTruncated()
{
@@ -736,7 +785,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var batchRepo = new AuditLogRepository(batchContext);
// 2628 = "String or binary data would be truncated in table …" (SQL 2019+),
// 8152 = its pre-2019 predecessor. Either proves the server saw the full value.
// 8152 = its predecessor on SQL Server versions before 2019. Either proves the server saw the full value.
var batchEx = await Assert.ThrowsAsync<SqlException>(
() => batchRepo.InsertManyIfNotExistsAsync(new[] { evt }));
Assert.Contains(batchEx.Number, new[] { 2628, 8152 });
@@ -761,6 +810,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
/// The companion to the rejection test: a value that FITS must still round-trip
/// through the set-based path byte for byte, at the exact column boundary.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_TargetAtColumnLimit_RoundTripsIntact()
{
@@ -786,6 +836,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(boundaryTarget, loaded[0].Target);
}
/// <summary>Verifies that inserting an empty batch is a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_EmptyBatch_IsANoOp()
{
@@ -797,6 +849,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(0, await repo.InsertManyIfNotExistsAsync(Array.Empty<AuditEvent>()));
}
/// <summary>Verifies that keyset paging breaks ties on event id when rows share an occurred time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_Keyset_SameOccurredAtUtc_TiebreaksOnEventId()
{
@@ -855,12 +909,12 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
// ------------------------------------------------------------------------
// M6-T4 Bundle C: SwitchOutPartitionAsync drop-and-rebuild integration tests
// SwitchOutPartitionAsync drop-and-rebuild integration tests
// ------------------------------------------------------------------------
//
// The partition-switch path replaces M1's NotSupportedException stub with
// The partition-switch path replaces the earlier NotSupportedException stub with
// the production CREATE-staging → SWITCH PARTITION → DROP-staging batch
// documented in alog.md §4. WP2.2 removed the index drop/rebuild that used to
// documented in alog.md §4. The design removed the index drop/rebuild that used to
// bracket it: uniqueness rides the partition-ALIGNED clustered PK
// (EventId, OccurredAtUtc), so SWITCH has nothing to object to. These tests
// verify the side effects an outsider can observe:
@@ -871,6 +925,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// after a switch (the aligned key really does enforce it)
// * a thrown SqlException leaves the table intact with no orphaned staging
/// <summary>Verifies that switching out an old partition removes its rows and keeps newer partitions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SwitchOutPartitionAsync_OldPartition_RemovesRows_NewPartitionsKept()
{
@@ -906,6 +962,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Contains(remaining, e => e.EventId == marEvt.EventId);
}
/// <summary>Verifies that a partition switch leaves no non-aligned unique index behind.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SwitchOutPartitionAsync_LeavesNoNonAlignedUniqueIndex_AfterSwitch()
{
@@ -918,7 +976,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// the fixture's MSSQL database) don't tread on each other.
await repo.SwitchOutPartitionAsync(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc));
// WP2.2: the switch must NOT recreate UX_AuditLog_EventId. Re-adding it
// The switch must NOT recreate UX_AuditLog_EventId. Re-adding it
// would reinstate the offline drop/rebuild the purge was freed from and
// block the next SWITCH until something dropped it again.
await using var verifyContext = CreateContext();
@@ -937,6 +995,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(1, pkExists);
}
/// <summary>Verifies that first-write-wins insertion is still enforced after a partition switch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SwitchOutPartitionAsync_InsertIfNotExistsAsync_StillEnforcesFirstWriteWins_AfterSwitch()
{
@@ -959,7 +1019,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// column instead of a dedicated unique index; if the switch had disturbed
// that key, this insert would silently produce a duplicate row and the
// count assertion below would catch it.
// C3 (Task 2.5): rebuild a sibling row with the same EventId via the factory
// Rebuild a sibling row with the same EventId via the factory
// (ErrorMessage rides in DetailsJson, so a top-level `with` no longer applies).
var dup = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
@@ -978,11 +1038,13 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Single(rows);
Assert.Equal(preExisting.EventId, rows[0].EventId);
// First-write-wins: the original ErrorMessage (null) survives. C5 (Task 2.5):
// First-write-wins: the original ErrorMessage (null) survives.
// ErrorMessage rides in DetailsJson — decode it to assert.
Assert.Null(AuditDetailsCodec.Deserialize(rows[0].DetailsJson).ErrorMessage);
}
/// <summary>Verifies that a partial partition-switch failure raises an exception and leaves no orphaned staging table.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SwitchOutPartitionAsync_PartialFailure_RaisesException_LeavesNoOrphanedStaging()
{
@@ -1045,8 +1107,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
"SELECT COUNT(*) FROM sys.tables WHERE name LIKE 'AuditLog\\_Staging\\_%' ESCAPE '\\';");
Assert.Equal(0, orphanedStaging);
// And it must NOT have (re)created the non-aligned unique index: WP2.2
// deleted that rebuild entirely, so a failed switch cannot reintroduce
// And it must NOT have (re)created the non-aligned unique index: the
// design deleted that rebuild entirely, so a failed switch cannot reintroduce
// the object that blocks the NEXT switch.
var indexExists = await ScalarAsync<int>(
verifyContext,
@@ -1056,9 +1118,11 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
// ------------------------------------------------------------------------
// M6-T4 Bundle C: GetPartitionBoundariesOlderThanAsync
// GetPartitionBoundariesOlderThanAsync
// ------------------------------------------------------------------------
/// <summary>Verifies that only partition boundaries whose newest row is older than the threshold are returned.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetPartitionBoundariesOlderThanAsync_ReturnsBoundaries_WithMaxOccurredOlderThanThreshold()
{
@@ -1087,7 +1151,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
// ------------------------------------------------------------------------
// M7-T13 Bundle E: GetKpiSnapshotAsync — Health-dashboard Audit KPI tiles
// GetKpiSnapshotAsync — Health-dashboard Audit KPI tiles
// ------------------------------------------------------------------------
//
// The dashboard's "Audit volume" tile reads TotalEventsLastHour and the
@@ -1102,6 +1166,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// narrow band centred on that anchor — no other test in this class seeds
// there, so the global count equals the seeded count for that band.
/// <summary>Verifies that the KPI snapshot reports correct totals and error counts for rows of mixed status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetKpiSnapshotAsync_WithMixedStatusRows_ReturnsCorrectTotalsAndErrors()
{
@@ -1141,6 +1207,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(nowUtc, snapshot.AsOfUtc);
}
/// <summary>Verifies that the KPI snapshot of an empty window reports zero totals.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetKpiSnapshotAsync_EmptyWindow_ReturnsZeroTotals()
{
@@ -1163,7 +1231,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
// ------------------------------------------------------------------------
// Audit Log ParentExecutionId (Task 8): GetExecutionTreeAsync
// Audit Log ParentExecutionId: GetExecutionTreeAsync
// ------------------------------------------------------------------------
//
// GetExecutionTreeAsync walks UP from any node to the chain root, then walks
@@ -1175,6 +1243,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// * pathological cyclic data is bounded by the MAXRECURSION guard and
// surfaces a SqlException rather than hanging
/// <summary>Verifies that the execution tree resolves a multi-level parent chain.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetExecutionTree_MultiLevelChain()
{
@@ -1267,6 +1337,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
}
/// <summary>Verifies that the execution tree edge scan is bounded to the root window.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ExecutionTree_Bounds_EdgeScan_To_Root_Window()
{
@@ -1310,6 +1382,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(2, tree.Count);
}
/// <summary>Verifies that the execution tree includes a stub node for a parent with no audit rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetExecutionTree_StubParentNode()
{
@@ -1351,6 +1425,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(stubParentExec, child.ParentExecutionId);
}
/// <summary>Verifies that the execution tree walk respects the maximum recursion depth.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetExecutionTree_RespectsMaxRecursion()
{
@@ -1413,7 +1489,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
private static string NewSiteId() =>
"test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8);
// C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
// Build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
// factory; the repository's transitional shim decomposes it into the 24-column
// AuditLogRow on INSERT and recomposes the canonical record on QUERY.
private static AuditEvent NewEvent(
@@ -17,11 +17,15 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared SQL Server migration fixture.</summary>
/// <param name="fixture">The shared SQL Server migration fixture.</param>
public AuditLogRepositoryTraceEventsTests(MsSqlMigrationFixture fixture) => _fixture = fixture;
private ScadaBridgeDbContext CreateContext() => new(
new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlServer(_fixture.ConnectionString).Options);
/// <summary>Verifies that scoped tree and replay reads exclude other sites' rows and retain central rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ScopedTreeAndReplay_ExcludeOtherSite_AndRetainCentralRows()
{
@@ -40,6 +44,10 @@ public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFi
Assert.DoesNotContain(replay, row => row.DetailsJson?.Contains("sensitive-sql-values") == true);
}
/// <summary>Verifies that replay bounds the number of rows and their UTF-8 byte size while keeping the root row.</summary>
/// <param name="count">The number of audit rows to seed.</param>
/// <param name="payloadLength">The length of each row's details payload.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableTheory]
[InlineData(600, 0)]
[InlineData(200, 10000)]
@@ -64,6 +72,8 @@ public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFi
Assert.True(batch.Sum(e => System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(e).Length) <= 1024 * 1024);
}
/// <summary>Verifies that the tree read returns the inbound row plus chain trace rows in order from any entry point.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ReturnsInboundPlusChainTraceRows_Ordered_FromAnyEntry()
{
@@ -118,10 +128,11 @@ public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFi
}
/// <summary>
/// Outbound-calls plan (2026-09-06, D6): replay returns the chain's ScriptTrace, ApiOutbound,
/// Replay returns the chain's ScriptTrace, ApiOutbound,
/// DbOutbound and Notification rows plus the InboundRequest row, in (OccurredAtUtc, EventId)
/// order; SecuredWrite, Cluster and InboundAuthFailure rows in the same tree stay excluded.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task IncludesOutboundApiDbAndNotificationRows_ExcludesOtherChannels()
{
@@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
public class AuditPurgeBudgetTests
{
/// <summary>Verifies that a backlogged channel purge stops at the row budget, including a partial final batch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BackloggedChannel_StopsAtBudgetIncludingPartialFinalBatch()
{
@@ -24,32 +26,53 @@ public class AuditPurgeBudgetTests
private sealed class BackloggedConnection : DbConnection
{
/// <summary>Gets the batch sizes requested by the purge, in order.</summary>
public List<int> RequestedBatches { get; } = new();
private ConnectionState _state;
/// <inheritdoc />
[AllowNull] public override string ConnectionString { get; set; } = "Server=localhost;Database=test";
/// <inheritdoc />
public override string Database => "test";
/// <inheritdoc />
public override string DataSource => "localhost";
/// <inheritdoc />
public override string ServerVersion => "16.0";
/// <inheritdoc />
public override ConnectionState State => _state;
/// <inheritdoc />
public override void Open() => _state = ConnectionState.Open;
/// <inheritdoc />
public override void Close() => _state = ConnectionState.Closed;
/// <inheritdoc />
public override void ChangeDatabase(string databaseName) => throw new NotSupportedException();
/// <inheritdoc />
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => throw new NotSupportedException();
/// <inheritdoc />
protected override DbCommand CreateDbCommand() => new BackloggedCommand(this);
}
private sealed class BackloggedCommand(BackloggedConnection owner) : DbCommand
{
private readonly SqlCommand _parameters = new();
/// <inheritdoc />
[AllowNull] public override string CommandText { get; set; } = "";
/// <inheritdoc />
public override int CommandTimeout { get; set; }
/// <inheritdoc />
public override CommandType CommandType { get; set; }
/// <inheritdoc />
public override bool DesignTimeVisible { get; set; }
/// <inheritdoc />
public override UpdateRowSource UpdatedRowSource { get; set; }
/// <inheritdoc />
protected override DbConnection? DbConnection { get; set; }
/// <inheritdoc />
protected override DbTransaction? DbTransaction { get; set; }
/// <inheritdoc />
protected override DbParameterCollection DbParameterCollection => _parameters.Parameters;
/// <inheritdoc />
protected override DbParameter CreateDbParameter() => new SqlParameter();
/// <inheritdoc />
public override int ExecuteNonQuery()
{
// A server with more eligible rows than any tick is allowed to delete.
@@ -60,10 +83,15 @@ public class AuditPurgeBudgetTests
owner.RequestedBatches.Add(requested);
return requested;
}
/// <inheritdoc />
public override object? ExecuteScalar() => throw new NotSupportedException();
/// <inheritdoc />
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => throw new NotSupportedException();
/// <inheritdoc />
public override void Cancel() { }
/// <inheritdoc />
public override void Prepare() { }
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing) _parameters.Dispose();
@@ -15,6 +15,8 @@ public class BundleUnlockAttemptRepositoryTests
private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext();
/// <summary>Verifies that counting attempts includes only the given bucket within the window.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CountAttemptsSinceAsync_CountsOnlyTheGivenBucketInsideTheWindow()
{
@@ -32,6 +34,8 @@ public class BundleUnlockAttemptRepositoryTests
Assert.Equal(2, count);
}
/// <summary>Verifies that counting attempts is inclusive at the window floor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CountAttemptsSinceAsync_IsInclusiveAtTheWindowFloor()
{
@@ -46,6 +50,8 @@ public class BundleUnlockAttemptRepositoryTests
Assert.Equal(1, await repo.CountAttemptsSinceAsync("10.0.0.1", Base.AddHours(-1)));
}
/// <summary>Verifies that counting attempts for an unknown bucket returns zero.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CountAttemptsSinceAsync_UnknownBucket_ReturnsZero()
{
@@ -55,6 +61,8 @@ public class BundleUnlockAttemptRepositoryTests
Assert.Equal(0, await repo.CountAttemptsSinceAsync("<unattributed>", Base.AddHours(-1)));
}
/// <summary>Verifies that pruning deletes only aged rows and respects the batch bound.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PruneAttemptsOlderThanAsync_DeletesOnlyAgedRows_AndIsBounded()
{
@@ -78,6 +86,8 @@ public class BundleUnlockAttemptRepositoryTests
Assert.Equal(0, await repo.PruneAttemptsOlderThanAsync(Base.AddHours(-2), maxRows: 3));
}
/// <summary>Verifies that pruning rejects a non-positive batch size.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PruneAttemptsOlderThanAsync_RejectsANonPositiveBatchSize()
{
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// M6 (K2) coverage for <see cref="KpiHistoryRepository"/> over the in-memory SQLite
/// Coverage for <see cref="KpiHistoryRepository"/> over the in-memory SQLite
/// harness. Exercises the bulk write, the single-series query (including the
/// null-ScopeKey Global match), and the retention purge.
/// </summary>
@@ -31,6 +31,8 @@ public class KpiHistoryRepositoryTests
CapturedAtUtc = capturedAtUtc,
};
/// <summary>Verifies that the raw series query returns only the matching series in ascending time order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetRawSeriesAsync_ReturnsOnlyMatchingSeries_InAscendingTimeOrder()
{
@@ -64,6 +66,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(7, series[1].Value);
}
/// <summary>Verifies that a site-scoped raw series query matches only rows with that scope key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetRawSeriesAsync_SiteScopedKey_MatchesOnlyThatKey()
{
@@ -87,6 +91,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(5, series[0].Value);
}
/// <summary>Verifies that the raw series query honors the time window bounds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetRawSeriesAsync_HonorsTimeWindowBounds()
{
@@ -109,6 +115,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(new[] { 1d, 2d }, series.Select(p => p.Value).ToArray());
}
/// <summary>Verifies that recording an empty batch is a no-op and querying an empty table returns an empty series.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RecordSamplesAsync_EmptyBatch_IsNoOp_AndGetRawSeriesAsync_EmptyTable_ReturnsEmpty()
{
@@ -127,6 +135,8 @@ public class KpiHistoryRepositoryTests
Assert.Empty(series);
}
/// <summary>Verifies that the purge deletes only rows older than the cutoff and returns the deleted count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeOlderThanAsync_DeletesOnlyRowsOlderThanCutoff_AndReturnsCount()
{
@@ -158,6 +168,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray());
}
/// <summary>Verifies that a multi-window purge issues more than one delete and returns the total deleted count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeOlderThanAsync_MultiWindow_IssuesMoreThanOneDelete_AndReturnsTotal()
{
@@ -196,8 +208,10 @@ public class KpiHistoryRepositoryTests
Assert.Equal(new[] { 4d, 5d }, remaining.Select(p => p.Value).ToArray());
}
// ---- Hourly rollup fold / query / purge (plan #22 T3) ------------------------------
// ---- Hourly rollup fold / query / purge ------------------------------
/// <summary>Verifies that a gauge metric folds to its last value per hour.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_GaugeMetric_FoldsToLastValuePerHour()
{
@@ -224,6 +238,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(9, row.Value); // last-value (max CapturedAtUtc)
}
/// <summary>Verifies that a rate metric folds to its sum per hour along with the minimum, maximum and count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_RateMetric_FoldsToSumPerHour_WithMinMaxCount()
{
@@ -249,6 +265,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(3, rollup.SampleCount);
}
/// <summary>Verifies that the fold splits global-scope samples into separate hour buckets.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_SplitsIntoSeparateHourBuckets_ForGlobalScope()
{
@@ -277,6 +295,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(2, series[1].Value); // hour 1 last-value
}
/// <summary>Verifies that the fold excludes an incomplete hour at the exclusive upper bound.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_ExcludesIncompleteHour_AtExclusiveUpperBound()
{
@@ -303,6 +323,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(3, row.Value);
}
/// <summary>Verifies that repeated folds over the same window are idempotent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_IsIdempotent_AcrossRepeatedRunsOverSameWindow()
{
@@ -325,10 +347,12 @@ public class KpiHistoryRepositoryTests
Assert.Equal(2, row.SampleCount);
}
/// <summary>Verifies that the fold preloads existing rollups instead of probing once per series and hour.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_PreloadsExistingRollups_WithoutAPerSeriesHourProbe()
{
// WP2.2: the fold used to issue one FirstOrDefaultAsync existence probe per
// The fold used to issue one FirstOrDefaultAsync existence probe per
// (series, hour) group before writing anything — an N+1 that scaled with
// the metric catalogue times the lookback window. It now preloads the whole
// window in a single query and resolves each group from a dictionary.
@@ -376,6 +400,8 @@ public class KpiHistoryRepositoryTests
Assert.All(rollups, r => Assert.Equal(1, r.SampleCount));
}
/// <summary>Verifies that the hourly series query returns rows in ascending order and distinguishes a null scope key from a site scope key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetHourlySeriesAsync_ReturnsAscending_AndHonorsNullVsSiteScopeKey()
{
@@ -409,6 +435,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(42, Assert.Single(site).Value);
}
/// <summary>Verifies that the rollup purge deletes only rollups older than the cutoff.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeRollupsOlderThanAsync_DeletesOnlyRollupsOlderThanCutoff()
{
@@ -437,6 +465,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray());
}
/// <summary>Verifies that the fold does not leave the sample entities tracked by the change tracker.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollups_DoesNotTrack_KpiSampleEntities()
{
@@ -457,6 +487,8 @@ public class KpiHistoryRepositoryTests
Assert.Empty(ctx.ChangeTracker.Entries<KpiSample>());
}
/// <summary>Verifies that the latest rollup hour is null when no rollups exist.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetLatestRollupHour_ReturnsNull_WhenNoRollupsExist()
{
@@ -465,6 +497,8 @@ public class KpiHistoryRepositoryTests
Assert.Null(await repo.GetLatestRollupHourAsync());
}
/// <summary>Verifies that the latest rollup hour is the newest hour start across all series.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetLatestRollupHour_ReturnsNewestHourStart_AcrossAllSeries()
{
@@ -480,8 +514,10 @@ public class KpiHistoryRepositoryTests
Assert.Equal(Base.AddHours(3), await repo.GetLatestRollupHourAsync());
}
// ---- Task 9: failover fold-race failure grain is the pass, not the row (R5) ----
// ---- Failover fold-race failure grain is the pass, not the row (R5) ----
/// <summary>Verifies that a fold that loses a failover upsert race does not throw and the next fold self-heals.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Fold_LosingFailoverUpsertRace_DoesNotThrow_AndNextFoldSelfHeals()
{
@@ -556,12 +592,16 @@ public class KpiHistoryRepositoryTests
private readonly Func<KpiRollupHourly> _rowFactory;
private bool _fired;
/// <summary>Initializes the interceptor.</summary>
/// <param name="connection">The shared database connection used for the conflicting side write.</param>
/// <param name="rowFactory">Creates the conflicting rollup row to insert.</param>
public InsertConflictingRollupOnFirstSave(DbConnection connection, Func<KpiRollupHourly> rowFactory)
{
_connection = connection;
_rowFactory = rowFactory;
}
/// <inheritdoc />
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
@@ -584,13 +624,6 @@ public class KpiHistoryRepositoryTests
}
}
/// <summary>
/// EF command interceptor that counts the DELETE statements actually issued
/// to the store, so a test can prove the purge is sliced into multiple
/// windowed DELETEs rather than one mega-statement. Threads both the sync and
/// async non-query entry points (<c>ExecuteDeleteAsync</c> routes through the
/// async path).
/// </summary>
/// <summary>
/// Counts reader commands that SELECT from <c>KpiRollupHourly</c>, so a test
/// can prove the hourly fold resolves existing rows from a single preload
@@ -598,8 +631,10 @@ public class KpiHistoryRepositoryTests
/// </summary>
private sealed class RollupSelectCountingInterceptor : DbCommandInterceptor
{
/// <summary>Number of reader commands that selected from the rollup table.</summary>
public int RollupSelectCount { get; private set; }
/// <summary>Resets the select counter to zero.</summary>
public void Reset() => RollupSelectCount = 0;
private void CountIfRollupSelect(DbCommand command)
@@ -611,6 +646,7 @@ public class KpiHistoryRepositoryTests
}
}
/// <inheritdoc />
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
@@ -618,6 +654,7 @@ public class KpiHistoryRepositoryTests
return base.ReaderExecuting(command, eventData, result);
}
/// <inheritdoc />
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
@@ -627,10 +664,19 @@ public class KpiHistoryRepositoryTests
}
}
/// <summary>
/// EF command interceptor that counts the DELETE statements actually issued
/// to the store, so a test can prove the purge is sliced into multiple
/// windowed DELETEs rather than one mega-statement. Threads both the sync and
/// async non-query entry points (<c>ExecuteDeleteAsync</c> routes through the
/// async path).
/// </summary>
private sealed class DeleteCountingInterceptor : DbCommandInterceptor
{
/// <summary>Number of DELETE statements issued so far.</summary>
public int DeleteCount { get; private set; }
/// <summary>Resets the delete counter to zero.</summary>
public void Reset() => DeleteCount = 0;
private void CountIfDelete(DbCommand command)
@@ -641,6 +687,7 @@ public class KpiHistoryRepositoryTests
}
}
/// <inheritdoc />
public override InterceptionResult<int> NonQueryExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
{
@@ -648,6 +695,7 @@ public class KpiHistoryRepositoryTests
return base.NonQueryExecuting(command, eventData, result);
}
/// <inheritdoc />
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
@@ -10,7 +10,7 @@ using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// CD-015 race-fix integration tests for
/// Race-fix integration tests for
/// <see cref="NotificationOutboxRepository.InsertIfNotExistsAsync"/>. The method
/// is raw-SQL (<c>IF NOT EXISTS … INSERT</c>) matching the AuditLog and SiteCalls
/// idempotent-insert pattern; it must execute against a real SQL Server schema,
@@ -21,11 +21,15 @@ public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlM
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes a new instance of the <c>NotificationOutboxRepositoryIntegrationTests</c> class.</summary>
/// <param name="fixture">The shared database fixture.</param>
public NotificationOutboxRepositoryIntegrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>Verifies that inserting a fresh id inserts the row and returns true.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_FreshId_InsertsAndReturnsTrue()
{
@@ -45,6 +49,8 @@ public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlM
Assert.Equal("Subject", loaded!.Subject);
}
/// <summary>Verifies that inserting a duplicate id returns false and leaves the existing row untouched.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_DuplicateId_ReturnsFalseAndLeavesExistingRow()
{
@@ -70,12 +76,14 @@ public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlM
Assert.Equal("Original", loaded!.Subject);
}
/// <summary>Verifies that concurrent inserts of the same id produce exactly one row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_ConcurrentInserts_SameId_OnlyOneRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// CD-015 race coverage. The IF NOT EXISTS … INSERT pattern has a
// Race coverage. The IF NOT EXISTS … INSERT pattern has a
// check-then-act window: two concurrent sessions can both pass the
// EXISTS check and both attempt the INSERT — the loser surfaces as a
// SqlException with Number 2601/2627. The site→central handoff is
@@ -102,6 +110,8 @@ public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlM
Assert.Equal(1, count);
}
/// <summary>Verifies that filtering by notification id returns exactly that row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_NotificationIdFilter_ReturnsExactlyThatRow()
{
@@ -25,11 +25,15 @@ public class PendingDeploymentRepositoryIntegrationTests : IClassFixture<MsSqlMi
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared SQL Server migration fixture.</summary>
/// <param name="fixture">The shared SQL Server migration fixture.</param>
public PendingDeploymentRepositoryIntegrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>Verifies that staging over an expired row with the same deployment id re-stages it without a unique violation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task StagePendingIfAbsent_ExpiredRowSameDeploymentId_ReStages_NoUniqueViolation()
{
@@ -74,6 +78,8 @@ public class PendingDeploymentRepositoryIntegrationTests : IClassFixture<MsSqlMi
}
}
/// <summary>Verifies on a real SQL Server that a new deploy with a different id supersedes the prior pending row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task AddPendingDeployment_NewDeployDifferentId_SupersedesPriorRow_OnRealSqlServer()
{
@@ -12,8 +12,8 @@ using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// Bundle B3 (#22, #23 M3) integration tests for <see cref="SiteCallAuditRepository"/>.
/// Uses the same <see cref="MsSqlMigrationFixture"/> as the Bundle B2 migration tests so
/// Integration tests for <see cref="SiteCallAuditRepository"/>.
/// Uses the same <see cref="MsSqlMigrationFixture"/> as the migration tests so
/// the monotonic-upsert SQL executes against the real <c>SiteCalls</c> schema. Each test
/// scopes its data by minting a fresh <see cref="TrackedOperationId"/> (or a per-test
/// <c>SourceSite</c> suffix) so tests neither collide nor require teardown.
@@ -22,11 +22,15 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
/// <param name="fixture">The shared MS SQL migration fixture.</param>
public SiteCallAuditRepositoryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>Verifies that an existing operation cannot be advanced by a packet from another site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task CM9_ExistingOperationCannotBeAdvancedByAnotherSite()
{
@@ -46,6 +50,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(0, stored.RetryCount);
}
/// <summary>Verifies that upserting a fresh id inserts exactly one row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_FreshId_InsertsOneRow()
{
@@ -68,6 +74,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(0, loaded[0].RetryCount);
}
/// <summary>Verifies that upserting an advanced status updates the existing row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_AdvancedStatus_UpdatesRow()
{
@@ -90,6 +98,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("transient 503", loaded.LastError);
}
/// <summary>Verifies that upserting an older status is a no-op and leaves the row unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_OlderStatus_IsNoOp_RowUnchanged()
{
@@ -115,6 +125,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(attemptedSnapshot!.UpdatedAtUtc, afterStale.UpdatedAtUtc);
}
/// <summary>Verifies that an upsert rejected by the monotonic rule does not fall through to an insert.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_RejectedMonotonicUpdate_DoesNotFallThroughToInsert()
{
@@ -161,6 +173,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
/// any row existed and then skipped its INSERT, dropping the packet's
/// Status/RetryCount/HttpStatus/TerminalAtUtc on the floor.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_RowAlreadyCreatedByAnotherWriter_StillAppliesThisPacketsState()
{
@@ -215,6 +228,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
/// test itself is not flaky — only its coverage of the rare branch is
/// probabilistic.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_TwoConcurrentFirstWrites_NewerStateSurvives()
{
@@ -256,6 +270,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
}
/// <summary>Verifies that upserting the same status with an equal update time is a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_SameStatus_EqualUpdatedAt_IsNoOp()
{
@@ -272,7 +288,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// Same rank (2) AND an EQUAL UpdatedAtUtc — an idempotent replay of the
// same telemetry packet. The same-rank freshness tiebreaker only fires
// for a STRICTLY-newer stamp, so a true duplicate stays a no-op (no
// fields move). Task 11: newest-within-rank wins, equal stamps are inert.
// fields move). Newest-within-rank wins, equal stamps are inert.
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "second", updatedAtUtc: stamp));
var afterDuplicate = await repo.GetAsync(id);
@@ -283,6 +299,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(snapshot!.UpdatedAtUtc, afterDuplicate.UpdatedAtUtc);
}
/// <summary>Verifies that a same-rank upsert with a newer update time refreshes the progress fields.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_SameRank_NewerUpdatedAt_RefreshesProgressFields()
{
@@ -295,7 +313,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// Two Attempted packets (rank 2) from one retrying call; the second
// carries a strictly-newer UpdatedAtUtc, so its live RetryCount/LastError
// must land — the row no longer freezes at first-Attempted (Task 11).
// must land — the row no longer freezes at first-Attempted.
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "timeout #1", updatedAtUtc: t0));
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "timeout #5", updatedAtUtc: t0.AddMinutes(5)));
@@ -306,6 +324,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(t0.AddMinutes(5), row.UpdatedAtUtc);
}
/// <summary>Verifies that a same-rank upsert with an older update time is a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_SameRank_OlderUpdatedAt_IsNoOp()
{
@@ -316,7 +336,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
// Land retry 5 (stamp t0+5), then an out-of-order retry-2 packet with an
// Land retry 5 (stamp t0+5), then an out-of-order packet for retry 2 with an
// OLDER stamp (t0). Same rank, older UpdatedAtUtc → rejected; stays at 5.
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "timeout #5", updatedAtUtc: t0.AddMinutes(5)));
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "timeout #2", updatedAtUtc: t0));
@@ -328,6 +348,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(t0.AddMinutes(5), row.UpdatedAtUtc);
}
/// <summary>Verifies that a lower-rank upsert is still a no-op even with a newer update time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_LowerRank_NewerUpdatedAt_IsStillNoOp()
{
@@ -350,13 +372,15 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(0, row.RetryCount);
}
/// <summary>Verifies that one terminal status cannot overwrite another terminal status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_TerminalOverTerminal_IsNoOp()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Bundle B3 plan: terminal statuses share rank 3 and are mutually
// exclusive — Delivered cannot overwrite Parked. Task 11 note: the
// Terminal statuses share rank 3 and are mutually
// exclusive — Delivered cannot overwrite Parked. Note that the
// Delivered packet carries a STRICTLY-NEWER UpdatedAtUtc, yet the row
// stays Parked — the same-rank freshness tiebreaker is scoped to
// NON-terminal ranks (< 3), so terminal immutability is preserved.
@@ -377,6 +401,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(afterPark!.UpdatedAtUtc, afterDeliveredAttempt.UpdatedAtUtc);
}
/// <summary>Verifies that concurrent inserts of the same id leave only one row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_ConcurrentInserts_SameId_OnlyOneRow()
{
@@ -406,6 +432,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(1, count);
}
/// <summary>Verifies that getting a known id returns its row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetAsync_KnownId_ReturnsRow()
{
@@ -422,6 +450,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(id, loaded!.TrackedOperationId);
}
/// <summary>Verifies that getting an unknown id returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetAsync_UnknownId_ReturnsNull()
{
@@ -434,6 +464,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(loaded);
}
/// <summary>Verifies that filtering by source site returns only the matching rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterBySourceSite_ReturnsMatchingRows()
{
@@ -457,10 +489,12 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Equal(siteA, r.SourceSite));
}
/// <summary>Verifies that filtering by tracked operation id returns exactly that row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByTrackedOperationId_ReturnsExactlyThatRow()
{
// Flow Debugger follow-ups Task 2: SiteCallQueryFilter.TrackedOperationId is
// SiteCallQueryFilter.TrackedOperationId is
// an exact PK match (the ?correlationId= deep link). The column is varchar(36)
// holding the "D"-format GUID string, so the predicate must compare as a
// string — a uniqueidentifier parameter would convert the column side and
@@ -491,6 +525,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Empty(none);
}
/// <summary>Verifies that keyset paging returns consecutive pages with no overlap.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_KeysetPaging_NoOverlap()
{
@@ -542,6 +578,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(5, allIds.Count);
}
/// <summary>Verifies that the stuck cutoff composes with keyset paging without producing empty pages.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_StuckCutoff_ComposesWithKeysetPaging_NoEmptyPages()
{
@@ -603,6 +641,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(stuckIds.ToHashSet(), returned);
}
/// <summary>Verifies that the purge removes rows that are both terminal and old.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task PurgeTerminalAsync_RemovesTerminalAndOld()
{
@@ -631,6 +671,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(await repo.GetAsync(oldId));
}
/// <summary>Verifies that the purge keeps non-terminal and recent rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task PurgeTerminalAsync_KeepsNonTerminalAndRecent()
{
@@ -670,6 +712,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.NotNull(await repo.GetAsync(recentTerminalId));
}
/// <summary>Verifies that the purge slices a multi-day backlog into several bounded deletes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task PurgeTerminal_SlicesMultiDayBacklog_IntoBoundedDeletes()
{
@@ -735,6 +779,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// --- KPI snapshot tests -------------------------------------------------
/// <summary>Verifies that the KPI computation counts buffered, parked, failed, delivered and stuck rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ComputeKpisAsync_CountsBufferedParkedFailedDeliveredAndStuck()
{
@@ -789,6 +835,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.True(snapshot.OldestPendingAge >= TimeSpan.FromMinutes(25));
}
/// <summary>Verifies that the per-site KPI computation scopes counts to each site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ComputePerSiteKpisAsync_ScopesCountsToEachSite()
{
@@ -831,6 +879,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(b.OldestPendingAge);
}
/// <summary>Verifies that the per-node KPI computation scopes counts to each node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ComputePerNodeKpisAsync_ScopesCountsToEachNode()
{
@@ -930,14 +980,16 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
};
}
// --- SourceNode-stamping (Task 14) --------------------------------------
// --- SourceNode-stamping --------------------------------------
/// <summary>Verifies that a fresh insert persists the source node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_PersistsSourceNode_OnFreshInsert()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// SourceNode-stamping (Task 14): a fresh INSERT must persist the
// SourceNode stamping: a fresh INSERT must persist the
// SourceNode column verbatim — the central row carries the originating
// site node name end-to-end.
var id = TrackedOperationId.New();
@@ -951,12 +1003,14 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("node-a", loaded!.SourceNode);
}
/// <summary>Verifies that a later packet carrying a null source node does not blank the stored value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_PreservesSourceNode_WhenLaterPacketCarriesNull()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// SourceNode-stamping (Task 14): the UPDATE uses
// SourceNode stamping: the UPDATE uses
// COALESCE(@SourceNode, SourceNode) so a subsequent packet that does
// NOT carry a SourceNode (legacy / reconciliation pull from an
// unstamped node) MUST NOT blank out the value the first packet set.
@@ -993,12 +1047,14 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
}
/// <summary>Verifies that a non-null incoming source node overwrites the previous value when the rank advances.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_NonNullIncomingSourceNode_OverwritesPreviousValueOnRankAdvance()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// SourceNode-stamping (Task 14): per the COALESCE(@SourceNode,
// SourceNode stamping: per the COALESCE(@SourceNode,
// SourceNode) semantics the column protects against a *null*
// incoming value blanking a previously-stamped one, but a non-null
// incoming value DOES replace the existing value on a rank-advancing
@@ -1037,12 +1093,14 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
}
/// <summary>Verifies that a later packet fills the source node when the insert left it null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_FillsSourceNode_WhenInsertWasNullAndLaterPacketCarriesValue()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// SourceNode-stamping (Task 14): when the column was left NULL by an
// SourceNode stamping: when the column was left NULL by an
// earlier unstamped packet, a later rank-advancing packet with a
// non-null SourceNode fills it — the COALESCE(@SourceNode, SourceNode)
// SQL operator returns @SourceNode when @SourceNode is non-null, so
@@ -1093,8 +1151,10 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
/// </summary>
private sealed class DeleteCountingCommandInterceptor : DbCommandInterceptor
{
/// <summary>Gets the number of DELETE statements issued since the last reset.</summary>
public int DeleteCount { get; private set; }
/// <summary>Resets the DELETE count to zero.</summary>
public void Reset() => DeleteCount = 0;
private void CountIfDelete(DbCommand command)
@@ -1105,6 +1165,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
}
/// <inheritdoc />
public override InterceptionResult<int> NonQueryExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
{
@@ -1112,6 +1173,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
return base.NonQueryExecuting(command, eventData, result);
}
/// <inheritdoc />
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)