fix(sitecallaudit): push StuckOnly filter into SQL; doc accuracy fixes
This commit is contained in:
@@ -271,6 +271,67 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
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<TrackedOperationId>();
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -293,6 +293,65 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
Assert.True(response.SiteCalls[0].IsStuck);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SiteCallQueryRequest_StuckOnly_PagesAreFull_NoEmptyPagesWithCursor()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
var actor = CreateActor(repo, new SiteCallAuditOptions { StuckAgeThreshold = TimeSpan.FromMinutes(10) });
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Three stuck rows interleaved (by CreatedAtUtc) with three non-stuck
|
||||
// rows: recent non-terminal and old-but-terminal. With the StuckOnly
|
||||
// filter pushed into SQL, a page-size-2 query must return exactly the
|
||||
// stuck rows two-per-page — never an under-filled page with a non-null
|
||||
// next cursor caused by post-filtering.
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), siteId, status: "Attempted",
|
||||
createdAtUtc: now.AddMinutes(-30 - i)));
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), siteId, status: "Attempted",
|
||||
createdAtUtc: now.AddMinutes(-2 - i)));
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), siteId, status: "Delivered",
|
||||
createdAtUtc: now.AddMinutes(-40 - i), terminal: true));
|
||||
}
|
||||
|
||||
actor.Tell(
|
||||
new SiteCallQueryRequest(
|
||||
"corr-stuck-p1", null, siteId, null, null, StuckOnly: true,
|
||||
null, null, null, null, PageSize: 2),
|
||||
TestActor);
|
||||
var page1 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(page1.Success);
|
||||
// Page is full — two stuck rows, both honestly stuck.
|
||||
Assert.Equal(2, page1.SiteCalls.Count);
|
||||
Assert.All(page1.SiteCalls, s => Assert.True(s.IsStuck));
|
||||
Assert.NotNull(page1.NextAfterCreatedAtUtc);
|
||||
|
||||
actor.Tell(
|
||||
new SiteCallQueryRequest(
|
||||
"corr-stuck-p2", null, siteId, null, null, StuckOnly: true,
|
||||
null, null, page1.NextAfterCreatedAtUtc, page1.NextAfterId,
|
||||
PageSize: 2),
|
||||
TestActor);
|
||||
var page2 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(page2.Success);
|
||||
// Final page — the third stuck row, the only remaining match.
|
||||
Assert.Single(page2.SiteCalls);
|
||||
Assert.All(page2.SiteCalls, s => Assert.True(s.IsStuck));
|
||||
|
||||
// No overlap, exactly the three stuck rows across both pages.
|
||||
var allIds = page1.SiteCalls.Concat(page2.SiteCalls)
|
||||
.Select(s => s.TrackedOperationId).ToHashSet();
|
||||
Assert.Equal(3, allIds.Count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SiteCallDetailRequest_KnownId_ReturnsFullDetail()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user