feat(sitecallaudit): query, KPI and detail backend for the Site Calls page

This commit is contained in:
Joseph Doherty
2026-05-21 04:14:49 -04:00
parent 6f0d2ca499
commit e3519fdb39
17 changed files with 1514 additions and 18 deletions

View File

@@ -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);
}
}

View File

@@ -0,0 +1,15 @@
namespace ScadaLink.SiteCallAudit.Tests;
public class SiteCallAuditOptionsTests
{
[Fact]
public void Defaults_AreExpectedValues()
{
var options = new SiteCallAuditOptions();
// Stuck threshold mirrors NotificationOutboxOptions.StuckAgeThreshold.
Assert.Equal(TimeSpan.FromMinutes(10), options.StuckAgeThreshold);
// KPI interval mirrors NotificationOutboxOptions.DeliveredKpiWindow.
Assert.Equal(TimeSpan.FromMinutes(1), options.KpiInterval);
}
}