feat(sitecallaudit): query, KPI and detail backend for the Site Calls page
This commit is contained in:
@@ -70,10 +70,12 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
};
|
||||
}
|
||||
|
||||
private IActorRef CreateActor(ISiteCallAuditRepository repository) =>
|
||||
private IActorRef CreateActor(
|
||||
ISiteCallAuditRepository repository, SiteCallAuditOptions? options = null) =>
|
||||
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
||||
repository,
|
||||
NullLogger<SiteCallAuditActor>.Instance)));
|
||||
NullLogger<SiteCallAuditActor>.Instance,
|
||||
options)));
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Receive_UpsertSiteCallCommand_Persists_Replies_Accepted()
|
||||
@@ -182,6 +184,291 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
Assert.Equal(healthyId, rows[0].TrackedOperationId);
|
||||
}
|
||||
|
||||
// ── Task 4: read-side (query / detail / KPI) handlers ──
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SiteCallQueryRequest_FilterBySourceSite_ReturnsMatchingSummaries()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
var actor = CreateActor(repo);
|
||||
|
||||
var t0 = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, status: "Attempted", createdAtUtc: t0));
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), siteId, status: "Delivered",
|
||||
createdAtUtc: t0.AddMinutes(1), terminal: true));
|
||||
|
||||
actor.Tell(
|
||||
new SiteCallQueryRequest(
|
||||
"corr-q1", StatusFilter: null, SourceSiteFilter: siteId, ChannelFilter: null,
|
||||
TargetKeyword: null, StuckOnly: false, FromUtc: null, ToUtc: null,
|
||||
AfterCreatedAtUtc: null, AfterId: null, PageSize: 50),
|
||||
TestActor);
|
||||
|
||||
var response = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(response.Success);
|
||||
Assert.Equal("corr-q1", response.CorrelationId);
|
||||
Assert.Equal(2, response.SiteCalls.Count);
|
||||
Assert.All(response.SiteCalls, s => Assert.Equal(siteId, s.SourceSite));
|
||||
// Newest first — ordered (CreatedAtUtc DESC).
|
||||
Assert.Equal("Delivered", response.SiteCalls[0].Status);
|
||||
// Cursor echoes the last (oldest) row of the page.
|
||||
Assert.Equal(t0, response.NextAfterCreatedAtUtc);
|
||||
Assert.Equal(response.SiteCalls[^1].TrackedOperationId, response.NextAfterId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SiteCallQueryRequest_KeysetPaging_AdvancesViaCursor()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
var actor = CreateActor(repo);
|
||||
|
||||
var t0 = new DateTime(2026, 5, 20, 13, 0, 0, DateTimeKind.Utc);
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, createdAtUtc: t0.AddMinutes(i)));
|
||||
}
|
||||
|
||||
actor.Tell(
|
||||
new SiteCallQueryRequest(
|
||||
"corr-q2", null, siteId, null, null, false, null, null, null, null, PageSize: 2),
|
||||
TestActor);
|
||||
var page1 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(2, page1.SiteCalls.Count);
|
||||
|
||||
actor.Tell(
|
||||
new SiteCallQueryRequest(
|
||||
"corr-q3", null, siteId, null, null, false, null, null,
|
||||
page1.NextAfterCreatedAtUtc, page1.NextAfterId, PageSize: 2),
|
||||
TestActor);
|
||||
var page2 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Single(page2.SiteCalls);
|
||||
|
||||
// No overlap across the two pages.
|
||||
var allIds = page1.SiteCalls.Concat(page2.SiteCalls)
|
||||
.Select(s => s.TrackedOperationId).ToHashSet();
|
||||
Assert.Equal(3, allIds.Count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SiteCallQueryRequest_StuckOnly_ReturnsOnlyOldNonTerminalRows()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
// 10-minute stuck threshold (the production default).
|
||||
var actor = CreateActor(repo, new SiteCallAuditOptions { StuckAgeThreshold = TimeSpan.FromMinutes(10) });
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Stuck: non-terminal (Attempted, TerminalAtUtc null), created 30 min ago.
|
||||
var stuckId = TrackedOperationId.New();
|
||||
await repo.UpsertAsync(NewRow(stuckId, siteId, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
|
||||
// Not stuck: non-terminal but recent.
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, status: "Attempted", createdAtUtc: now.AddMinutes(-2)));
|
||||
// Not stuck: old but terminal (Delivered, TerminalAtUtc set).
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), siteId, status: "Delivered",
|
||||
createdAtUtc: now.AddMinutes(-40), terminal: true));
|
||||
|
||||
actor.Tell(
|
||||
new SiteCallQueryRequest(
|
||||
"corr-stuck", null, siteId, null, null, StuckOnly: true,
|
||||
null, null, null, null, PageSize: 50),
|
||||
TestActor);
|
||||
|
||||
var response = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(response.Success);
|
||||
Assert.Single(response.SiteCalls);
|
||||
Assert.Equal(stuckId.Value, response.SiteCalls[0].TrackedOperationId);
|
||||
Assert.True(response.SiteCalls[0].IsStuck);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SiteCallDetailRequest_KnownId_ReturnsFullDetail()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
var actor = CreateActor(repo);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, siteId, status: "Attempted", retryCount: 2, lastError: "503"));
|
||||
|
||||
actor.Tell(new SiteCallDetailRequest("corr-d1", id.Value), TestActor);
|
||||
|
||||
var response = ExpectMsg<SiteCallDetailResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(response.Success);
|
||||
Assert.NotNull(response.Detail);
|
||||
Assert.Equal(id.Value, response.Detail!.TrackedOperationId);
|
||||
Assert.Equal("Attempted", response.Detail.Status);
|
||||
Assert.Equal(2, response.Detail.RetryCount);
|
||||
Assert.Equal("503", response.Detail.LastError);
|
||||
Assert.Equal(siteId, response.Detail.SourceSite);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SiteCallDetailRequest_UnknownId_RepliesNotFound()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
var actor = CreateActor(repo);
|
||||
|
||||
actor.Tell(new SiteCallDetailRequest("corr-d2", Guid.NewGuid()), TestActor);
|
||||
|
||||
var response = ExpectMsg<SiteCallDetailResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.False(response.Success);
|
||||
Assert.Null(response.Detail);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SiteCallKpiRequest_ComputesPointInTimeCounts()
|
||||
{
|
||||
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),
|
||||
KpiInterval = TimeSpan.FromHours(1),
|
||||
});
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Buffered (non-terminal Attempted) + stuck (created 30 min ago).
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
|
||||
// Buffered (non-terminal Attempted), not stuck.
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, status: "Attempted", createdAtUtc: now.AddMinutes(-2)));
|
||||
// Parked (terminal).
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), siteId, status: "Parked",
|
||||
createdAtUtc: now.AddMinutes(-5), terminal: true));
|
||||
// Delivered within the interval.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), siteId, status: "Delivered",
|
||||
createdAtUtc: now.AddMinutes(-3), updatedAtUtc: now.AddMinutes(-1), terminal: true));
|
||||
|
||||
actor.Tell(new SiteCallKpiRequest("corr-kpi"), TestActor);
|
||||
|
||||
var response = ExpectMsg<SiteCallKpiResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(response.Success);
|
||||
// Per-site rows are isolated by the unique siteId — but KPIs are global,
|
||||
// so assert the floor (>=) rather than exact counts: other tests' rows
|
||||
// may share the table.
|
||||
Assert.True(response.BufferedCount >= 2);
|
||||
Assert.True(response.ParkedCount >= 1);
|
||||
Assert.True(response.DeliveredLastInterval >= 1);
|
||||
Assert.True(response.StuckCount >= 1);
|
||||
Assert.NotNull(response.OldestPendingAge);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task PerSiteSiteCallKpiRequest_ScopesCountsToEachSite()
|
||||
{
|
||||
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),
|
||||
KpiInterval = TimeSpan.FromHours(1),
|
||||
});
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Non-terminal Attempted, created 30 min ago — buffered + stuck.
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
|
||||
// Terminal Parked.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), siteId, status: "Parked",
|
||||
createdAtUtc: now.AddMinutes(-5), terminal: true));
|
||||
|
||||
actor.Tell(new PerSiteSiteCallKpiRequest("corr-psk"), TestActor);
|
||||
|
||||
var response = ExpectMsg<PerSiteSiteCallKpiResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(response.Success);
|
||||
|
||||
var mySite = Assert.Single(response.Sites, s => s.SourceSite == siteId);
|
||||
Assert.Equal(1, mySite.BufferedCount);
|
||||
Assert.Equal(1, mySite.ParkedCount);
|
||||
Assert.Equal(1, mySite.StuckCount);
|
||||
Assert.NotNull(mySite.OldestPendingAge);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SiteCallQueryRequest_RepoThrows_RepliesFailure_ActorStaysAlive()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var realRepo = new SiteCallAuditRepository(context);
|
||||
var actor = CreateActor(new QueryThrowingRepository(realRepo));
|
||||
|
||||
actor.Tell(
|
||||
new SiteCallQueryRequest(
|
||||
"corr-fault", null, siteId, null, null, false, null, null, null, null, 50),
|
||||
TestActor);
|
||||
|
||||
var response = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.False(response.Success);
|
||||
Assert.Empty(response.SiteCalls);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
Assert.Equal("corr-fault", response.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test double whose <see cref="ISiteCallAuditRepository.QueryAsync"/> always
|
||||
/// throws — used to verify the query handler's failure projection produces a
|
||||
/// <c>Success=false</c> response without crashing the actor.
|
||||
/// </summary>
|
||||
private sealed class QueryThrowingRepository : ISiteCallAuditRepository
|
||||
{
|
||||
private readonly ISiteCallAuditRepository _inner;
|
||||
|
||||
public QueryThrowingRepository(ISiteCallAuditRepository inner)
|
||||
{
|
||||
_inner = inner;
|
||||
}
|
||||
|
||||
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
|
||||
_inner.UpsertAsync(siteCall, ct);
|
||||
|
||||
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
||||
_inner.GetAsync(id, ct);
|
||||
|
||||
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("simulated query failure");
|
||||
|
||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||
|
||||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tiny test double that delegates to a real repository but throws on a
|
||||
/// specified <see cref="TrackedOperationId"/>. Used to verify the actor's
|
||||
@@ -217,5 +504,13 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
|
||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||
|
||||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user