using Microsoft.EntityFrameworkCore; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Types.Enums; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; namespace ScadaLink.ConfigurationDatabase.Tests; // Coverage for per-site KPI aggregation in the Notification Outbox repository // (Task 2 of the notifications-nav-group feature). public class NotificationOutboxRepositoryPerSiteKpiTests { private static ScadaLinkDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext(); private static Notification NewNotification( string sourceSiteId, NotificationStatus status, DateTimeOffset createdAt, DateTimeOffset? deliveredAt = null) { return new Notification( Guid.NewGuid().ToString(), NotificationType.Email, "Ops List", "Subject", "Body", sourceSiteId) { Status = status, CreatedAt = createdAt, DeliveredAt = deliveredAt, }; } [Fact] public async Task ComputePerSiteKpisAsync_AggregatesMetricsPerSite() { await using var ctx = NewContext(); var now = DateTimeOffset.UtcNow; // plant-a: 1 pending (stuck, created 20m ago), 1 parked ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending, createdAt: now.AddMinutes(-20))); ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Parked, createdAt: now.AddMinutes(-5))); // plant-b: 1 delivered in-window, 1 pending (fresh) ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Delivered, createdAt: now.AddHours(-2), deliveredAt: now.AddMinutes(-2))); ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Pending, createdAt: now.AddMinutes(-1))); // plant-c: 2 non-terminal rows of clearly different ages — pending 90m ago, // retrying 40m ago. Both predate the 10m stuck cutoff. Exercises the // in-memory g.Min(CreatedAt) oldest-age reduction and the Retrying branch // of the QueueDepth/StuckCount predicates. ctx.Notifications.Add(NewNotification("plant-c", NotificationStatus.Pending, createdAt: now.AddMinutes(-90))); ctx.Notifications.Add(NewNotification("plant-c", NotificationStatus.Retrying, createdAt: now.AddMinutes(-40))); await ctx.SaveChangesAsync(); var repo = new NotificationOutboxRepository(ctx); var result = await repo.ComputePerSiteKpisAsync( stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30)); var a = result.Single(s => s.SourceSiteId == "plant-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(s => s.SourceSiteId == "plant-b"); Assert.Equal(1, b.QueueDepth); Assert.Equal(0, b.StuckCount); Assert.Equal(1, b.DeliveredLastInterval); // plant-c: both the Pending and Retrying rows count toward QueueDepth; // both predate the stuck cutoff so both are stuck. OldestPendingAge must // reflect the older (90m) row, not the 10m Retrying one. var c = result.Single(s => s.SourceSiteId == "plant-c"); Assert.Equal(2, c.QueueDepth); Assert.Equal(2, c.StuckCount); Assert.Equal(0, c.ParkedCount); Assert.NotNull(c.OldestPendingAge); // Tolerant lower bound to absorb clock skew between seed time and the // `now` captured inside ComputePerSiteKpisAsync. Assert.True(c.OldestPendingAge >= TimeSpan.FromMinutes(85), $"expected OldestPendingAge >= 85m, got {c.OldestPendingAge}"); Assert.True(c.OldestPendingAge < TimeSpan.FromMinutes(95), $"expected OldestPendingAge < 95m, got {c.OldestPendingAge}"); } [Fact] public async Task ComputePerSiteKpisAsync_ReturnsEmpty_WhenNoNotifications() { await using var ctx = NewContext(); var repo = new NotificationOutboxRepository(ctx); var result = await repo.ComputePerSiteKpisAsync( DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(-30)); Assert.Empty(result); } }