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