using System.Data.Common; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; using Xunit; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories; /// /// Bundle B3 (#22, #23 M3) integration tests for . /// Uses the same as the Bundle B2 migration tests so /// the monotonic-upsert SQL executes against the real SiteCalls schema. Each test /// scopes its data by minting a fresh (or a per-test /// SourceSite suffix) so tests neither collide nor require teardown. /// public class SiteCallAuditRepositoryTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; public SiteCallAuditRepositoryTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } [SkippableFact] public async Task UpsertAsync_FreshId_InsertsOneRow() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var id = TrackedOperationId.New(); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); var row = NewRow(id, status: "Submitted", retryCount: 0); await repo.UpsertAsync(row); await using var readContext = CreateContext(); var loaded = await readContext.Set() .Where(s => s.TrackedOperationId == id) .ToListAsync(); Assert.Single(loaded); Assert.Equal("Submitted", loaded[0].Status); Assert.Equal(0, loaded[0].RetryCount); } [SkippableFact] public async Task UpsertAsync_AdvancedStatus_UpdatesRow() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var id = TrackedOperationId.New(); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); // Submitted (rank 0) → Forwarded (rank 1) → Attempted (rank 2) — every // step strictly advances the rank, so each upsert must mutate the row. await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0)); await repo.UpsertAsync(NewRow(id, status: "Forwarded", retryCount: 0)); await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "transient 503")); var loaded = await repo.GetAsync(id); Assert.NotNull(loaded); Assert.Equal("Attempted", loaded!.Status); Assert.Equal(1, loaded.RetryCount); Assert.Equal("transient 503", loaded.LastError); } [SkippableFact] public async Task UpsertAsync_OlderStatus_IsNoOp_RowUnchanged() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var id = TrackedOperationId.New(); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); // First land Attempted (rank 2). A late-arriving Submitted (rank 0) must // NOT roll the row back — silent no-op. await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "transient")); var attemptedSnapshot = await repo.GetAsync(id); await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0, lastError: null)); var afterStale = await repo.GetAsync(id); Assert.NotNull(afterStale); Assert.Equal("Attempted", afterStale!.Status); Assert.Equal(5, afterStale.RetryCount); Assert.Equal("transient", afterStale.LastError); // UpdatedAtUtc should not have moved when the stale write was rejected. Assert.Equal(attemptedSnapshot!.UpdatedAtUtc, afterStale.UpdatedAtUtc); } [SkippableFact] public async Task UpsertAsync_SameStatus_EqualUpdatedAt_IsNoOp() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var id = TrackedOperationId.New(); var stamp = new DateTime(2026, 6, 1, 9, 0, 0, DateTimeKind.Utc); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "first", updatedAtUtc: stamp)); var snapshot = await repo.GetAsync(id); // 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. await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "second", updatedAtUtc: stamp)); var afterDuplicate = await repo.GetAsync(id); Assert.NotNull(afterDuplicate); Assert.Equal("Attempted", afterDuplicate!.Status); Assert.Equal(1, afterDuplicate.RetryCount); Assert.Equal("first", afterDuplicate.LastError); Assert.Equal(snapshot!.UpdatedAtUtc, afterDuplicate.UpdatedAtUtc); } [SkippableFact] public async Task UpsertAsync_SameRank_NewerUpdatedAt_RefreshesProgressFields() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var id = TrackedOperationId.New(); var t0 = new DateTime(2026, 6, 1, 10, 0, 0, DateTimeKind.Utc); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); // 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). 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))); var row = await repo.GetAsync(id); Assert.NotNull(row); Assert.Equal(5, row!.RetryCount); Assert.Equal("timeout #5", row.LastError); Assert.Equal(t0.AddMinutes(5), row.UpdatedAtUtc); } [SkippableFact] public async Task UpsertAsync_SameRank_OlderUpdatedAt_IsNoOp() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var id = TrackedOperationId.New(); var t0 = new DateTime(2026, 6, 1, 11, 0, 0, DateTimeKind.Utc); 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 // 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)); var row = await repo.GetAsync(id); Assert.NotNull(row); Assert.Equal(5, row!.RetryCount); Assert.Equal("timeout #5", row.LastError); Assert.Equal(t0.AddMinutes(5), row.UpdatedAtUtc); } [SkippableFact] public async Task UpsertAsync_LowerRank_NewerUpdatedAt_IsStillNoOp() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var id = TrackedOperationId.New(); var t0 = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); // Terminal Delivered (rank 3), then a late lower-rank Attempted (rank 2) // with a NEWER stamp. Rank monotonicity dominates the freshness // tiebreaker — the row stays Delivered (regression-proof). await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 0, updatedAtUtc: t0, terminal: true, terminalAtUtc: t0)); await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 9, lastError: "late", updatedAtUtc: t0.AddMinutes(5))); var row = await repo.GetAsync(id); Assert.NotNull(row); Assert.Equal("Delivered", row!.Status); Assert.Equal(0, row.RetryCount); } [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 // 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. var id = TrackedOperationId.New(); var t0 = new DateTime(2026, 6, 1, 13, 0, 0, DateTimeKind.Utc); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); await repo.UpsertAsync(NewRow(id, status: "Parked", retryCount: 3, lastError: "parked-reason", updatedAtUtc: t0, terminal: true, terminalAtUtc: t0)); var afterPark = await repo.GetAsync(id); await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 4, lastError: null, updatedAtUtc: t0.AddMinutes(5), terminal: true, terminalAtUtc: t0.AddMinutes(5))); var afterDeliveredAttempt = await repo.GetAsync(id); Assert.NotNull(afterDeliveredAttempt); Assert.Equal("Parked", afterDeliveredAttempt!.Status); Assert.Equal("parked-reason", afterDeliveredAttempt.LastError); Assert.Equal(afterPark!.UpdatedAtUtc, afterDeliveredAttempt.UpdatedAtUtc); } [SkippableFact] public async Task UpsertAsync_ConcurrentInserts_SameId_OnlyOneRow() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // 50 parallel inserters with the same id. The IF NOT EXISTS … INSERT // pattern has a check-then-act race; concurrent losers must surface as // silent duplicate-key swallows, not thrown exceptions. Final row // count must be exactly 1. var id = TrackedOperationId.New(); var row = NewRow(id, status: "Submitted", retryCount: 0); await Parallel.ForEachAsync( Enumerable.Range(0, 50), new ParallelOptions { MaxDegreeOfParallelism = 50 }, async (_, ct) => { await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); await repo.UpsertAsync(row, ct); }); await using var readContext = CreateContext(); var count = await readContext.Set() .Where(s => s.TrackedOperationId == id) .CountAsync(); Assert.Equal(1, count); } [SkippableFact] public async Task GetAsync_KnownId_ReturnsRow() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var id = TrackedOperationId.New(); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0)); var loaded = await repo.GetAsync(id); Assert.NotNull(loaded); Assert.Equal(id, loaded!.TrackedOperationId); } [SkippableFact] public async Task GetAsync_UnknownId_ReturnsNull() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); var loaded = await repo.GetAsync(TrackedOperationId.New()); Assert.Null(loaded); } [SkippableFact] public async Task QueryAsync_FilterBySourceSite_ReturnsMatchingRows() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteA = NewSiteId(); var siteB = NewSiteId(); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); var t0 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc); await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteA, createdAtUtc: t0)); await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteA, createdAtUtc: t0.AddMinutes(1))); await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteB, createdAtUtc: t0.AddMinutes(2))); var rows = await repo.QueryAsync( new SiteCallQueryFilter(SourceSite: siteA), new SiteCallPaging(PageSize: 10)); Assert.Equal(2, rows.Count); Assert.All(rows, r => Assert.Equal(siteA, r.SourceSite)); } [SkippableFact] public async Task QueryAsync_KeysetPaging_NoOverlap() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var site = NewSiteId(); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); // Five rows with distinct CreatedAtUtc. Page-size 2 → page 1 returns // minutes 4,3; cursor (minutes 3) → page 2 returns minutes 2,1; cursor // (minutes 1) → page 3 returns minute 0. var t0 = new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc); for (var i = 0; i < 5; i++) { await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: site, createdAtUtc: t0.AddMinutes(i))); } var page1 = await repo.QueryAsync( new SiteCallQueryFilter(SourceSite: site), new SiteCallPaging(PageSize: 2)); Assert.Equal(2, page1.Count); Assert.Equal(t0.AddMinutes(4), page1[0].CreatedAtUtc); Assert.Equal(t0.AddMinutes(3), page1[1].CreatedAtUtc); var cursor1 = page1[^1]; var page2 = await repo.QueryAsync( new SiteCallQueryFilter(SourceSite: site), new SiteCallPaging( PageSize: 2, AfterCreatedAtUtc: cursor1.CreatedAtUtc, AfterId: cursor1.TrackedOperationId)); Assert.Equal(2, page2.Count); Assert.Equal(t0.AddMinutes(2), page2[0].CreatedAtUtc); Assert.Equal(t0.AddMinutes(1), page2[1].CreatedAtUtc); var cursor2 = page2[^1]; var page3 = await repo.QueryAsync( new SiteCallQueryFilter(SourceSite: site), new SiteCallPaging( PageSize: 2, AfterCreatedAtUtc: cursor2.CreatedAtUtc, AfterId: cursor2.TrackedOperationId)); Assert.Single(page3); Assert.Equal(t0.AddMinutes(0), page3[0].CreatedAtUtc); // No overlap across pages. var allIds = page1.Concat(page2).Concat(page3).Select(r => r.TrackedOperationId).ToHashSet(); Assert.Equal(5, allIds.Count); } [SkippableFact] public async Task QueryAsync_StuckCutoff_ComposesWithKeysetPaging_NoEmptyPages() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var site = NewSiteId(); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); // Three stuck rows (non-terminal, created before the cutoff) interleaved // by CreatedAtUtc with non-stuck rows: recent non-terminal rows and an // old-but-terminal row. The stuck predicate is pushed into the SQL WHERE // alongside the keyset cursor, so each page must come back full of stuck // rows — never under-filled by a post-filter. var t0 = new DateTime(2026, 5, 20, 8, 0, 0, DateTimeKind.Utc); var cutoff = t0.AddMinutes(10); var stuckIds = new List(); for (var i = 0; i < 3; i++) { var stuckId = TrackedOperationId.New(); stuckIds.Add(stuckId); // Stuck: non-terminal, created before the cutoff. await repo.UpsertAsync(NewRow( stuckId, sourceSite: site, status: "Attempted", createdAtUtc: t0.AddMinutes(i))); // Not stuck: non-terminal but created after the cutoff. await repo.UpsertAsync(NewRow( TrackedOperationId.New(), sourceSite: site, status: "Attempted", createdAtUtc: cutoff.AddMinutes(i + 1))); // Not stuck: created before the cutoff but terminal. await repo.UpsertAsync(NewRow( TrackedOperationId.New(), sourceSite: site, status: "Delivered", createdAtUtc: t0.AddMinutes(i), terminal: true, terminalAtUtc: t0.AddMinutes(i + 1))); } var filter = new SiteCallQueryFilter(SourceSite: site, StuckCutoffUtc: cutoff); var page1 = await repo.QueryAsync(filter, new SiteCallPaging(PageSize: 2)); Assert.Equal(2, page1.Count); Assert.All(page1, r => Assert.Null(r.TerminalAtUtc)); Assert.All(page1, r => Assert.True(r.CreatedAtUtc < cutoff)); var cursor1 = page1[^1]; var page2 = await repo.QueryAsync( filter, new SiteCallPaging( PageSize: 2, AfterCreatedAtUtc: cursor1.CreatedAtUtc, AfterId: cursor1.TrackedOperationId)); // Only the third stuck row remains — no empty trailing page. Assert.Single(page2); Assert.Null(page2[0].TerminalAtUtc); Assert.True(page2[0].CreatedAtUtc < cutoff); // Exactly the three stuck rows, no overlap, no non-stuck leakage. var returned = page1.Concat(page2).Select(r => r.TrackedOperationId).ToHashSet(); Assert.Equal(stuckIds.ToHashSet(), returned); } [SkippableFact] public async Task PurgeTerminalAsync_RemovesTerminalAndOld() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var site = NewSiteId(); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); // One row that's been Delivered for a long time (5 days ago) — should be purged. var oldId = TrackedOperationId.New(); var fiveDaysAgo = DateTime.UtcNow.AddDays(-5); await repo.UpsertAsync(NewRow( oldId, sourceSite: site, status: "Delivered", retryCount: 1, createdAtUtc: fiveDaysAgo.AddMinutes(-1), updatedAtUtc: fiveDaysAgo, terminal: true, terminalAtUtc: fiveDaysAgo)); var purged = await repo.PurgeTerminalAsync(DateTime.UtcNow.AddDays(-1)); Assert.True(purged >= 1, $"Expected at least one purged row; got {purged}."); Assert.Null(await repo.GetAsync(oldId)); } [SkippableFact] public async Task PurgeTerminalAsync_KeepsNonTerminalAndRecent() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var site = NewSiteId(); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); // Non-terminal row: never eligible. var activeId = TrackedOperationId.New(); await repo.UpsertAsync(NewRow( activeId, sourceSite: site, status: "Attempted", retryCount: 1, createdAtUtc: DateTime.UtcNow.AddDays(-10), updatedAtUtc: DateTime.UtcNow.AddDays(-10), terminal: false)); // Recent terminal row: TerminalAtUtc within the keep window. var recentTerminalId = TrackedOperationId.New(); await repo.UpsertAsync(NewRow( recentTerminalId, sourceSite: site, status: "Delivered", retryCount: 0, createdAtUtc: DateTime.UtcNow.AddHours(-2), updatedAtUtc: DateTime.UtcNow.AddHours(-1), terminal: true, terminalAtUtc: DateTime.UtcNow.AddHours(-1))); // Purge older than 1 day — both rows must survive. await repo.PurgeTerminalAsync(DateTime.UtcNow.AddDays(-1)); Assert.NotNull(await repo.GetAsync(activeId)); Assert.NotNull(await repo.GetAsync(recentTerminalId)); } [SkippableFact] public async Task PurgeTerminal_SlicesMultiDayBacklog_IntoBoundedDeletes() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var site = NewSiteId(); var now = DateTime.UtcNow; var cutoff = now.AddDays(-1); // Attach a DELETE-counting interceptor so we can prove the purge issues MORE THAN ONE // DELETE for a multi-day backlog (arch-review 04 round 2, R6 — time-sliced batching). var deleteCounter = new DeleteCountingCommandInterceptor(); var options = new DbContextOptionsBuilder() .UseSqlServer(_fixture.ConnectionString) .AddInterceptors(deleteCounter) .Options; await using var context = new ScadaBridgeDbContext(options); var repo = new SiteCallAuditRepository(context); // Three terminal rows on three DISTINCT days beyond the cutoff — a multi-day backlog. var oldIds = new List(); foreach (var days in new[] { 3, 4, 5 }) { var id = TrackedOperationId.New(); oldIds.Add(id); var terminalAt = now.AddDays(-days); await repo.UpsertAsync(NewRow( id, sourceSite: site, status: "Delivered", retryCount: 0, createdAtUtc: terminalAt.AddMinutes(-1), updatedAtUtc: terminalAt, terminal: true, terminalAtUtc: terminalAt)); } // A fresh terminal row inside the keep window — must survive. var recentTerminalId = TrackedOperationId.New(); await repo.UpsertAsync(NewRow( recentTerminalId, sourceSite: site, status: "Delivered", retryCount: 0, createdAtUtc: now.AddHours(-2), updatedAtUtc: now.AddHours(-1), terminal: true, terminalAtUtc: now.AddHours(-1))); // An OLD non-terminal row — non-terminal rows are NEVER purged on age. var oldActiveId = TrackedOperationId.New(); await repo.UpsertAsync(NewRow( oldActiveId, sourceSite: site, status: "Attempted", retryCount: 1, createdAtUtc: now.AddDays(-10), updatedAtUtc: now.AddDays(-10), terminal: false)); deleteCounter.Reset(); var purged = await repo.PurgeTerminalAsync(cutoff); // The three seeded backlog rows are gone; the fresh terminal + old non-terminal survive. Assert.True(purged >= 3, $"Expected at least the three backlog rows purged; got {purged}."); foreach (var id in oldIds) { Assert.Null(await repo.GetAsync(id)); } Assert.NotNull(await repo.GetAsync(recentTerminalId)); Assert.NotNull(await repo.GetAsync(oldActiveId)); // Slicing: a multi-day backlog is broken into MORE THAN ONE DELETE (one per day slice). Assert.True(deleteCounter.DeleteCount > 1, $"Expected the multi-day purge to slice into >1 DELETE; issued {deleteCounter.DeleteCount}."); } // --- KPI snapshot tests ------------------------------------------------- [SkippableFact] public async Task ComputeKpisAsync_CountsBufferedParkedFailedDeliveredAndStuck() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var site = NewSiteId(); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); var now = DateTime.UtcNow; var stuckCutoff = now.AddMinutes(-10); var intervalSince = now.AddHours(-1); // Buffered + stuck (non-terminal Attempted, created 30 min ago). await repo.UpsertAsync(NewRow( TrackedOperationId.New(), site, status: "Attempted", createdAtUtc: now.AddMinutes(-30))); // Buffered but NOT stuck (non-terminal Attempted, created 2 min ago). await repo.UpsertAsync(NewRow( TrackedOperationId.New(), site, status: "Attempted", createdAtUtc: now.AddMinutes(-2))); // Parked (terminal). await repo.UpsertAsync(NewRow( TrackedOperationId.New(), site, status: "Parked", createdAtUtc: now.AddMinutes(-5), updatedAtUtc: now.AddMinutes(-4), terminal: true, terminalAtUtc: now.AddMinutes(-4))); // Delivered within the interval. await repo.UpsertAsync(NewRow( TrackedOperationId.New(), site, status: "Delivered", createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1), terminal: true, terminalAtUtc: now.AddMinutes(-1))); // Failed within the interval. await repo.UpsertAsync(NewRow( TrackedOperationId.New(), site, status: "Failed", createdAtUtc: now.AddMinutes(-6), updatedAtUtc: now.AddMinutes(-2), terminal: true, terminalAtUtc: now.AddMinutes(-2))); // Delivered OUTSIDE the interval (2 hours ago) — must not count. await repo.UpsertAsync(NewRow( TrackedOperationId.New(), site, status: "Delivered", createdAtUtc: now.AddHours(-3), updatedAtUtc: now.AddHours(-2), terminal: true, terminalAtUtc: now.AddHours(-2))); var snapshot = await repo.ComputeKpisAsync(stuckCutoff, intervalSince); // Counts are global; assert the floor since the table is shared with // other tests. The OUTSIDE-interval Delivered row proves the window // bounds the throughput counts. Assert.True(snapshot.BufferedCount >= 2); Assert.True(snapshot.ParkedCount >= 1); Assert.True(snapshot.StuckCount >= 1); Assert.True(snapshot.DeliveredLastInterval >= 1); Assert.True(snapshot.FailedLastInterval >= 1); Assert.NotNull(snapshot.OldestPendingAge); Assert.True(snapshot.OldestPendingAge >= TimeSpan.FromMinutes(25)); } [SkippableFact] public async Task ComputePerSiteKpisAsync_ScopesCountsToEachSite() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteA = NewSiteId(); var siteB = NewSiteId(); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); var now = DateTime.UtcNow; var stuckCutoff = now.AddMinutes(-10); var intervalSince = now.AddHours(-1); // siteA: 2 buffered (one stuck), 1 parked. await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteA, status: "Attempted", createdAtUtc: now.AddMinutes(-30))); await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteA, status: "Attempted", createdAtUtc: now.AddMinutes(-2))); await repo.UpsertAsync(NewRow( TrackedOperationId.New(), siteA, status: "Parked", createdAtUtc: now.AddMinutes(-5), updatedAtUtc: now.AddMinutes(-4), terminal: true, terminalAtUtc: now.AddMinutes(-4))); // siteB: 1 delivered within interval only. await repo.UpsertAsync(NewRow( TrackedOperationId.New(), siteB, status: "Delivered", createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1), terminal: true, terminalAtUtc: now.AddMinutes(-1))); var perSite = await repo.ComputePerSiteKpisAsync(stuckCutoff, intervalSince); var a = Assert.Single(perSite, s => s.SourceSite == siteA); Assert.Equal(2, a.BufferedCount); Assert.Equal(1, a.ParkedCount); Assert.Equal(1, a.StuckCount); Assert.NotNull(a.OldestPendingAge); var b = Assert.Single(perSite, s => s.SourceSite == siteB); Assert.Equal(0, b.BufferedCount); Assert.Equal(1, b.DeliveredLastInterval); // siteB has no non-terminal rows — no oldest-pending age. Assert.Null(b.OldestPendingAge); } [SkippableFact] public async Task ComputePerNodeKpisAsync_ScopesCountsToEachNode() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // Use unique site + node combos to isolate from other tests running // concurrently on the shared MsSql fixture. var nodeId = "node-b3-" + Guid.NewGuid().ToString("N").Substring(0, 8); var nodeB = nodeId + "-b"; await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); var now = DateTime.UtcNow; var stuckCutoff = now.AddMinutes(-10); var intervalSince = now.AddHours(-1); // nodeId: 2 buffered (one stuck), 1 parked. await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted", createdAtUtc: now.AddMinutes(-30), sourceNode: nodeId)); await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted", createdAtUtc: now.AddMinutes(-2), sourceNode: nodeId)); await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Parked", createdAtUtc: now.AddMinutes(-5), terminal: true, sourceNode: nodeId)); // nodeB: 1 delivered within interval only. await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Delivered", createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1), terminal: true, terminalAtUtc: now.AddMinutes(-1), sourceNode: nodeB)); // Null SourceNode row — must NOT appear in per-node results. await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted", createdAtUtc: now.AddMinutes(-3), sourceNode: null)); var perNode = await repo.ComputePerNodeKpisAsync(stuckCutoff, intervalSince); var na = Assert.Single(perNode, n => n.SourceNode == nodeId); Assert.Equal(2, na.BufferedCount); Assert.Equal(1, na.ParkedCount); Assert.Equal(1, na.StuckCount); Assert.NotNull(na.OldestPendingAge); var nb = Assert.Single(perNode, n => n.SourceNode == nodeB); Assert.Equal(0, nb.BufferedCount); Assert.Equal(1, nb.DeliveredLastInterval); Assert.Null(nb.OldestPendingAge); // Null-node row must be absent. Assert.DoesNotContain(perNode, n => n.SourceNode is null); } // --- helpers ------------------------------------------------------------ private ScadaBridgeDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseSqlServer(_fixture.ConnectionString) .Options; return new ScadaBridgeDbContext(options); } private static string NewSiteId() => "site-b3-" + Guid.NewGuid().ToString("N").Substring(0, 8); private static SiteCall NewRow( TrackedOperationId id, string? sourceSite = null, string status = "Submitted", int retryCount = 0, string? lastError = null, int? httpStatus = null, DateTime? createdAtUtc = null, DateTime? updatedAtUtc = null, bool terminal = false, DateTime? terminalAtUtc = null, string? sourceNode = null) { var created = createdAtUtc ?? DateTime.UtcNow; var updated = updatedAtUtc ?? created; DateTime? terminalAt = terminal ? (terminalAtUtc ?? updated) : null; return new SiteCall { TrackedOperationId = id, Channel = "ApiOutbound", Target = "ERP.GetOrder", SourceSite = sourceSite ?? NewSiteId(), SourceNode = sourceNode, Status = status, RetryCount = retryCount, LastError = lastError, HttpStatus = httpStatus, CreatedAtUtc = created, UpdatedAtUtc = updated, TerminalAtUtc = terminalAt, IngestedAtUtc = DateTime.UtcNow, }; } // --- SourceNode-stamping (Task 14) -------------------------------------- [SkippableFact] public async Task UpsertAsync_PersistsSourceNode_OnFreshInsert() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // SourceNode-stamping (Task 14): 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(); await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a")); var loaded = await repo.GetAsync(id); Assert.NotNull(loaded); Assert.Equal("node-a", loaded!.SourceNode); } [SkippableFact] public async Task UpsertAsync_PreservesSourceNode_WhenLaterPacketCarriesNull() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // SourceNode-stamping (Task 14): 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. // Combined with the monotonic-rank guard the Status advances but the // SourceNode survives. // // Each step uses a fresh DbContext — raw-SQL UPDATEs bypass the // change tracker, so reusing a single context whose entity is already // tracked masks the post-UPDATE state on a follow-up FindAsync. var id = TrackedOperationId.New(); await using (var context = CreateContext()) { var repo = new SiteCallAuditRepository(context); // First packet: stamped Submit from node-a. await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a")); } await using (var context = CreateContext()) { var repo = new SiteCallAuditRepository(context); // Later packet: rank-advancing Attempted with null SourceNode. await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: null)); } await using (var readContext = CreateContext()) { var readRepo = new SiteCallAuditRepository(readContext); var loaded = await readRepo.GetAsync(id); Assert.NotNull(loaded); // SourceNode preserved despite the null on the later packet. Assert.Equal("node-a", loaded!.SourceNode); // Status advanced — proves the UPDATE branch actually ran. Assert.Equal("Attempted", loaded.Status); Assert.Equal(1, loaded.RetryCount); } } [SkippableFact] public async Task UpsertAsync_NonNullIncomingSourceNode_OverwritesPreviousValueOnRankAdvance() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // SourceNode-stamping (Task 14): 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 // packet. This is the "last-non-null-wins on advance" behaviour the // SQL operator literally implements — see the comment in // SiteCallAuditRepository.UpsertAsync. // // In practice both stamps within a single lifecycle SHOULD carry the // same value (same node, same execution); a divergence would imply a // mid-lifecycle node change (e.g. failover handing off to node-b) and // letting the latest stamp through is arguably the right call. This // test pins the actual behaviour so we notice if the SQL gets // inverted (to a true first-write-wins COALESCE(SourceNode, // @SourceNode)) inadvertently. var id = TrackedOperationId.New(); await using (var context = CreateContext()) { var repo = new SiteCallAuditRepository(context); await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a")); } await using (var context = CreateContext()) { var repo = new SiteCallAuditRepository(context); await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: "node-b")); } await using (var readContext = CreateContext()) { var readRepo = new SiteCallAuditRepository(readContext); var loaded = await readRepo.GetAsync(id); Assert.NotNull(loaded); // Incoming non-null wins — node-b replaces node-a on rank advance. Assert.Equal("node-b", loaded!.SourceNode); // Other monotonic fields advanced too — proves the UPDATE ran. Assert.Equal("Attempted", loaded.Status); } } [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 // 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 // the incoming value wins over the existing NULL. This is the // recovery path for an initially-unstamped lifecycle whose later // packets carry the node identity. // // The intermediate verification and final read use FRESH contexts — // FindAsync hits the change tracker first, so a cached entity from // an earlier read in the same context can mask a raw-SQL UPDATE. var id = TrackedOperationId.New(); await using (var context = CreateContext()) { var repo = new SiteCallAuditRepository(context); await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: null)); } // Verify the INSERT left SourceNode NULL via a fresh context. await using (var verifyContext = CreateContext()) { var verifyRepo = new SiteCallAuditRepository(verifyContext); var afterInsert = await verifyRepo.GetAsync(id); Assert.NotNull(afterInsert); Assert.Null(afterInsert!.SourceNode); } await using (var context = CreateContext()) { var repo = new SiteCallAuditRepository(context); await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: "node-a")); } await using (var readContext = CreateContext()) { var readRepo = new SiteCallAuditRepository(readContext); var loaded = await readRepo.GetAsync(id); Assert.NotNull(loaded); Assert.Equal("node-a", loaded!.SourceNode); Assert.Equal("Attempted", loaded.Status); } } /// /// EF command interceptor that counts the DELETE statements actually issued to the store, /// so a test can prove the terminal purge slices a multi-day backlog into several windowed /// DELETEs rather than one year-scale statement. ExecuteSqlInterpolatedAsync routes /// through the async non-query path. /// private sealed class DeleteCountingCommandInterceptor : DbCommandInterceptor { public int DeleteCount { get; private set; } public void Reset() => DeleteCount = 0; private void CountIfDelete(DbCommand command) { if (command.CommandText.Contains("DELETE", StringComparison.OrdinalIgnoreCase)) { DeleteCount++; } } public override InterceptionResult NonQueryExecuting( DbCommand command, CommandEventData eventData, InterceptionResult result) { CountIfDelete(command); return base.NonQueryExecuting(command, eventData, result); } public override ValueTask> NonQueryExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { CountIfDelete(command); return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); } } }