feat(sitecallaudit): query, KPI and detail backend for the Site Calls page
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
128
tests/ScadaLink.Commons.Tests/Messages/SiteCallQueriesTests.cs
Normal file
128
tests/ScadaLink.Commons.Tests/Messages/SiteCallQueriesTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user