feat(audit): M5.2 per-node stuck-count KPIs (T6) — repo per-node aggregation, actor message pair, CentralUI tiles

This commit is contained in:
Joseph Doherty
2026-06-16 21:34:14 -04:00
parent a07ff28f10
commit 209f368cb5
25 changed files with 840 additions and 6 deletions
@@ -0,0 +1,128 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
// Coverage for per-node KPI aggregation in the Notification Outbox repository
// (T6: M5.2 per-node stuck-count KPIs).
public class NotificationOutboxRepositoryPerNodeKpiTests
{
private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext();
private static Notification NewNotification(
string sourceSiteId,
NotificationStatus status,
DateTimeOffset createdAt,
DateTimeOffset? deliveredAt = null,
string? sourceNode = null)
{
return new Notification(
Guid.NewGuid().ToString(), NotificationType.Email, "Ops List", "Subject", "Body", sourceSiteId)
{
Status = status,
CreatedAt = createdAt,
DeliveredAt = deliveredAt,
SourceNode = sourceNode,
};
}
[Fact]
public async Task ComputePerNodeKpisAsync_AggregatesMetricsPerNode()
{
await using var ctx = NewContext();
var now = DateTimeOffset.UtcNow;
// node-a: 1 pending (stuck, created 20m ago), 1 parked
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending,
createdAt: now.AddMinutes(-20), sourceNode: "node-a"));
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Parked,
createdAt: now.AddMinutes(-5), sourceNode: "node-a"));
// node-b: 1 delivered in-window, 1 pending (fresh)
ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Delivered,
createdAt: now.AddHours(-2), deliveredAt: now.AddMinutes(-2), sourceNode: "node-b"));
ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Pending,
createdAt: now.AddMinutes(-1), sourceNode: "node-b"));
// NULL SourceNode — must be excluded from per-node results
ctx.Notifications.Add(NewNotification("plant-c", NotificationStatus.Pending,
createdAt: now.AddMinutes(-5), sourceNode: null));
await ctx.SaveChangesAsync();
var repo = new NotificationOutboxRepository(ctx);
var result = await repo.ComputePerNodeKpisAsync(
stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30));
// Only node-a and node-b — the null-node row is excluded.
Assert.Equal(2, result.Count);
var a = result.Single(n => n.SourceNode == "node-a");
Assert.Equal(1, a.QueueDepth);
Assert.Equal(1, a.StuckCount);
Assert.Equal(1, a.ParkedCount);
Assert.Equal(0, a.DeliveredLastInterval);
Assert.NotNull(a.OldestPendingAge);
var b = result.Single(n => n.SourceNode == "node-b");
Assert.Equal(1, b.QueueDepth);
Assert.Equal(0, b.StuckCount);
Assert.Equal(0, b.ParkedCount);
Assert.Equal(1, b.DeliveredLastInterval);
Assert.NotNull(b.OldestPendingAge);
}
[Fact]
public async Task ComputePerNodeKpisAsync_ExcludesNullSourceNode()
{
await using var ctx = NewContext();
var now = DateTimeOffset.UtcNow;
// Only null-node rows — result must be empty.
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending,
createdAt: now.AddMinutes(-5), sourceNode: null));
await ctx.SaveChangesAsync();
var repo = new NotificationOutboxRepository(ctx);
var result = await repo.ComputePerNodeKpisAsync(
stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30));
Assert.Empty(result);
}
[Fact]
public async Task ComputePerNodeKpisAsync_ReturnsEmpty_WhenNoNotifications()
{
await using var ctx = NewContext();
var repo = new NotificationOutboxRepository(ctx);
var result = await repo.ComputePerNodeKpisAsync(
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(-30));
Assert.Empty(result);
}
[Fact]
public async Task ComputePerNodeKpisAsync_OldestPendingAge_ReflectsOlderRow()
{
await using var ctx = NewContext();
var now = DateTimeOffset.UtcNow;
// node-a: pending 90m ago, retrying 40m ago.
// OldestPendingAge must reflect the 90m row.
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending,
createdAt: now.AddMinutes(-90), sourceNode: "node-a"));
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Retrying,
createdAt: now.AddMinutes(-40), sourceNode: "node-a"));
await ctx.SaveChangesAsync();
var repo = new NotificationOutboxRepository(ctx);
var result = await repo.ComputePerNodeKpisAsync(
stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30));
var a = result.Single(n => n.SourceNode == "node-a");
Assert.Equal(2, a.QueueDepth);
Assert.Equal(2, a.StuckCount);
Assert.NotNull(a.OldestPendingAge);
Assert.True(a.OldestPendingAge >= TimeSpan.FromMinutes(85),
$"expected OldestPendingAge >= 85m, got {a.OldestPendingAge}");
Assert.True(a.OldestPendingAge < TimeSpan.FromMinutes(95),
$"expected OldestPendingAge < 95m, got {a.OldestPendingAge}");
}
}
@@ -497,6 +497,54 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(b.OldestPendingAge);
}
[SkippableFact]
public async Task ComputePerNodeKpisAsync_ScopesCountsToEachNode()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Use unique site + node combos to isolate from other tests running
// concurrently on the shared MsSql fixture.
var nodeId = "node-b3-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var nodeB = nodeId + "-b";
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
var now = DateTime.UtcNow;
var stuckCutoff = now.AddMinutes(-10);
var intervalSince = now.AddHours(-1);
// nodeId: 2 buffered (one stuck), 1 parked.
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted",
createdAtUtc: now.AddMinutes(-30), sourceNode: nodeId));
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted",
createdAtUtc: now.AddMinutes(-2), sourceNode: nodeId));
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Parked",
createdAtUtc: now.AddMinutes(-5), terminal: true, sourceNode: nodeId));
// nodeB: 1 delivered within interval only.
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Delivered",
createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1),
terminal: true, terminalAtUtc: now.AddMinutes(-1), sourceNode: nodeB));
// Null SourceNode row — must NOT appear in per-node results.
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted",
createdAtUtc: now.AddMinutes(-3), sourceNode: null));
var perNode = await repo.ComputePerNodeKpisAsync(stuckCutoff, intervalSince);
var na = Assert.Single(perNode, n => n.SourceNode == nodeId);
Assert.Equal(2, na.BufferedCount);
Assert.Equal(1, na.ParkedCount);
Assert.Equal(1, na.StuckCount);
Assert.NotNull(na.OldestPendingAge);
var nb = Assert.Single(perNode, n => n.SourceNode == nodeB);
Assert.Equal(0, nb.BufferedCount);
Assert.Equal(1, nb.DeliveredLastInterval);
Assert.Null(nb.OldestPendingAge);
// Null-node row must be absent.
Assert.DoesNotContain(perNode, n => n.SourceNode is null);
}
// --- helpers ------------------------------------------------------------
private ScadaBridgeDbContext CreateContext()