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

@@ -356,6 +356,12 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
_inner.QueryAsync(filter, paging, ct);
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>
@@ -387,5 +393,11 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
_inner.QueryAsync(filter, paging, ct);
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,128 @@
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.Commons.Tests.Messages;
/// <summary>
/// Site Call Audit (#22): construction, value-equality and optionality tests
/// for the Site Calls UI query / KPI / detail message contracts. Mirrors the
/// Notification Outbox <c>NotificationMessagesTests</c> coverage of the read
/// side, scoped to the contracts the Site Calls page consumes.
/// </summary>
public class SiteCallQueriesTests
{
[Fact]
public void SiteCallQueryRequest_PositionalConstruction_SetsAllFields()
{
var afterCreated = DateTime.UtcNow;
var afterId = Guid.NewGuid();
var request = new SiteCallQueryRequest(
"corr-1", "Parked", "plant-a", "ApiOutbound", "ERP.GetOrder", true,
new DateTime(2026, 5, 1), new DateTime(2026, 5, 20), afterCreated, afterId, 50);
Assert.Equal("corr-1", request.CorrelationId);
Assert.Equal("Parked", request.StatusFilter);
Assert.Equal("plant-a", request.SourceSiteFilter);
Assert.Equal("ApiOutbound", request.ChannelFilter);
Assert.Equal("ERP.GetOrder", request.TargetKeyword);
Assert.True(request.StuckOnly);
Assert.Equal(new DateTime(2026, 5, 1), request.FromUtc);
Assert.Equal(new DateTime(2026, 5, 20), request.ToUtc);
Assert.Equal(afterCreated, request.AfterCreatedAtUtc);
Assert.Equal(afterId, request.AfterId);
Assert.Equal(50, request.PageSize);
}
[Fact]
public void SiteCallQueryRequest_AllowsNullOptionalFilters()
{
var request = new SiteCallQueryRequest(
"corr-2", null, null, null, null, false, null, null, null, null, 25);
Assert.Null(request.StatusFilter);
Assert.Null(request.SourceSiteFilter);
Assert.Null(request.ChannelFilter);
Assert.Null(request.TargetKeyword);
Assert.False(request.StuckOnly);
Assert.Null(request.FromUtc);
Assert.Null(request.AfterId);
}
[Fact]
public void SiteCallQueryResponse_ValueEquality_EqualWhenAllFieldsMatch()
{
var a = new SiteCallQueryResponse("c", true, null, Array.Empty<SiteCallSummary>(), null, null);
var b = new SiteCallQueryResponse("c", true, null, Array.Empty<SiteCallSummary>(), null, null);
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
[Fact]
public void SiteCallSummary_CarriesEntityColumnsAndStuckFlag()
{
var id = Guid.NewGuid();
var created = DateTime.UtcNow.AddMinutes(-30);
var summary = new SiteCallSummary(
id, "plant-a", "DbOutbound", "InventoryDb", "Retrying", 3,
"transient 503", 503, created, created.AddMinutes(1), null, IsStuck: true);
Assert.Equal(id, summary.TrackedOperationId);
Assert.Equal("DbOutbound", summary.Channel);
Assert.Equal("InventoryDb", summary.Target);
Assert.Equal("Retrying", summary.Status);
Assert.Equal(3, summary.RetryCount);
Assert.Equal(503, summary.HttpStatus);
Assert.Null(summary.TerminalAtUtc);
Assert.True(summary.IsStuck);
}
[Fact]
public void SiteCallDetailResponse_MissingRow_HasNullDetail()
{
var response = new SiteCallDetailResponse("c", false, "site call not found", null);
Assert.False(response.Success);
Assert.Null(response.Detail);
Assert.Equal("site call not found", response.ErrorMessage);
}
[Fact]
public void SiteCallKpiResponse_FailureShape_ZeroesKpiFields()
{
var response = new SiteCallKpiResponse(
"c", Success: false, ErrorMessage: "db down",
BufferedCount: 0, ParkedCount: 0, FailedLastInterval: 0,
DeliveredLastInterval: 0, OldestPendingAge: null, StuckCount: 0);
Assert.False(response.Success);
Assert.Equal("db down", response.ErrorMessage);
Assert.Equal(0, response.BufferedCount);
Assert.Null(response.OldestPendingAge);
}
[Fact]
public void PerSiteSiteCallKpiResponse_CarriesPerSiteSnapshots()
{
var response = new PerSiteSiteCallKpiResponse(
"c", true, null,
new[]
{
new SiteCallSiteKpiSnapshot("plant-a", 4, 1, 0, 9, TimeSpan.FromMinutes(15), 2),
});
Assert.True(response.Success);
var site = Assert.Single(response.Sites);
Assert.Equal("plant-a", site.SourceSite);
Assert.Equal(4, site.BufferedCount);
Assert.Equal(2, site.StuckCount);
Assert.Equal(TimeSpan.FromMinutes(15), site.OldestPendingAge);
}
[Fact]
public void SiteCallKpiSnapshot_OldestPendingAge_IsNullableForEmptyTable()
{
var snapshot = new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0);
Assert.Null(snapshot.OldestPendingAge);
}
}

View File

@@ -2,8 +2,10 @@ using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Notifications;
namespace ScadaLink.Communication.Tests;
@@ -236,6 +238,150 @@ public class CommunicationServiceTests : TestKit
Assert.Equal("plant-a", result.Sites[0].SourceSiteId);
}
// ── Site Call Audit: central-side audit actor calls ──
[Fact]
public async Task QuerySiteCallsAsync_BeforeSiteCallAuditSet_Throws()
{
var service = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.QuerySiteCallsAsync(new SiteCallQueryRequest(
"corr-1", null, null, null, null, false, null, null, null, null, 50)));
}
[Fact]
public async Task GetSiteCallKpisAsync_BeforeSiteCallAuditSet_Throws()
{
var service = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.GetSiteCallKpisAsync(new SiteCallKpiRequest("corr-1")));
}
[Fact]
public async Task GetSiteCallDetailAsync_BeforeSiteCallAuditSet_Throws()
{
var service = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.GetSiteCallDetailAsync(new SiteCallDetailRequest("corr-1", Guid.NewGuid())));
}
[Fact]
public async Task GetPerSiteSiteCallKpisAsync_BeforeSiteCallAuditSet_Throws()
{
var service = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.GetPerSiteSiteCallKpisAsync(new PerSiteSiteCallKpiRequest("corr-1")));
}
[Fact]
public async Task QuerySiteCallsAsync_AsksSiteCallAuditProxyDirectly()
{
// The Site Call Audit actor is central-local: the request must be Asked
// directly to its proxy (no SiteEnvelope wrapping).
var service = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var probe = CreateTestProbe();
service.SetSiteCallAudit(probe.Ref);
var request = new SiteCallQueryRequest(
"corr-q", "Parked", "plant-a", "ApiOutbound", "ERP.GetOrder", true,
null, null, null, null, 25);
var task = service.QuerySiteCallsAsync(request);
var received = probe.ExpectMsg<SiteCallQueryRequest>();
Assert.Same(request, received);
var reply = new SiteCallQueryResponse(
"corr-q", true, null, Array.Empty<SiteCallSummary>(), null, null);
probe.Reply(reply);
Assert.Same(reply, await task);
}
[Fact]
public async Task GetSiteCallDetailAsync_AsksSiteCallAuditProxyDirectly()
{
var service = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var probe = CreateTestProbe();
service.SetSiteCallAudit(probe.Ref);
var request = new SiteCallDetailRequest("corr-d", Guid.NewGuid());
var task = service.GetSiteCallDetailAsync(request);
var received = probe.ExpectMsg<SiteCallDetailRequest>();
Assert.Same(request, received);
var reply = new SiteCallDetailResponse("corr-d", false, "site call not found", null);
probe.Reply(reply);
var result = await task;
Assert.Same(reply, result);
Assert.False(result.Success);
}
[Fact]
public async Task GetSiteCallKpisAsync_AsksSiteCallAuditProxyDirectly()
{
var service = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var probe = CreateTestProbe();
service.SetSiteCallAudit(probe.Ref);
var request = new SiteCallKpiRequest("corr-k");
var task = service.GetSiteCallKpisAsync(request);
var received = probe.ExpectMsg<SiteCallKpiRequest>();
Assert.Same(request, received);
var reply = new SiteCallKpiResponse(
"corr-k", true, null, 4, 1, 2, 9, TimeSpan.FromMinutes(7), 1);
probe.Reply(reply);
var result = await task;
Assert.Same(reply, result);
Assert.Equal(4, result.BufferedCount);
Assert.Equal(1, result.StuckCount);
}
[Fact]
public async Task GetPerSiteSiteCallKpisAsync_AsksSiteCallAuditProxyDirectly()
{
var service = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var probe = CreateTestProbe();
service.SetSiteCallAudit(probe.Ref);
var request = new PerSiteSiteCallKpiRequest("corr-ps");
var task = service.GetPerSiteSiteCallKpisAsync(request);
var received = probe.ExpectMsg<PerSiteSiteCallKpiRequest>();
Assert.Same(request, received);
var reply = new PerSiteSiteCallKpiResponse(
"corr-ps", true, null,
new[] { new SiteCallSiteKpiSnapshot("plant-a", 3, 0, 0, 5, null, 0) });
probe.Reply(reply);
var result = await task;
Assert.Same(reply, result);
Assert.True(result.Success);
Assert.Single(result.Sites);
Assert.Equal("plant-a", result.Sites[0].SourceSite);
}
/// <summary>
/// Stand-in for CentralCommunicationActor: verifies the message is wrapped
/// in a SiteEnvelope targeting the requested site and replies with a typed

View File

@@ -338,6 +338,104 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.NotNull(await repo.GetAsync(recentTerminalId));
}
// --- 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);
}
// --- helpers ------------------------------------------------------------
private ScadaLinkDbContext CreateContext()

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