using System.Data.Common; using Microsoft.EntityFrameworkCore.Diagnostics; 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; /// /// Query-shape coverage for the Notification Outbox KPI snapshots (arch-review 04, /// Task 20). Each snapshot must be a single-query conditional aggregation with a /// bounded oldest-pending — the old shape issued 5-7 sequential COUNT round trips /// and materialized the whole live queue to find the oldest row. /// public class NotificationOutboxRepositoryKpiQueryShapeTests { private static Notification NewNotification( string sourceSiteId, string? sourceNode, NotificationStatus status, DateTimeOffset createdAt, DateTimeOffset? deliveredAt = null) => new(Guid.NewGuid().ToString(), NotificationType.Email, "Ops List", "Subject", "Body", sourceSiteId) { Status = status, CreatedAt = createdAt, DeliveredAt = deliveredAt, SourceNode = sourceNode, }; [Fact] public async Task AllThreeKpiSnapshots_IssueAtMostTwoQueries_AndBoundTheOldestLookup() { var counter = new SelectCountingInterceptor(); await using var ctx = SqliteTestHelper.CreateInMemoryContext(counter); var now = DateTimeOffset.UtcNow; // Three non-terminal rows for one site/node — the "live queue" the old code // pulled into memory to find the oldest. All older than the stuck cutoff. ctx.Notifications.Add(NewNotification("plant-a", "node-a", NotificationStatus.Pending, now.AddMinutes(-30))); ctx.Notifications.Add(NewNotification("plant-a", "node-a", NotificationStatus.Retrying, now.AddMinutes(-20))); ctx.Notifications.Add(NewNotification("plant-a", "node-a", NotificationStatus.Pending, now.AddMinutes(-10))); await ctx.SaveChangesAsync(); var repo = new NotificationOutboxRepository(ctx); var stuckCutoff = now.AddMinutes(-5); var deliveredSince = now.AddMinutes(-60); // Global: one aggregation query + one bounded oldest query. counter.Reset(); var global = await repo.ComputeKpisAsync(stuckCutoff, deliveredSince); Assert.True(counter.Count <= 2, $"ComputeKpisAsync issued {counter.Count} queries against Notifications; expected <= 2"); // The oldest lookup must be bounded (LIMIT/TOP), never a full non-terminal SELECT. Assert.Contains(counter.Commands, sql => sql.Contains("LIMIT", StringComparison.OrdinalIgnoreCase)); Assert.Equal(3, global.QueueDepth); Assert.NotNull(global.OldestPendingAge); // Per-site: a single grouped conditional-aggregation query (counts + MIN). counter.Reset(); var perSite = await repo.ComputePerSiteKpisAsync(stuckCutoff, deliveredSince); Assert.True(counter.Count <= 2, $"ComputePerSiteKpisAsync issued {counter.Count} queries against Notifications; expected <= 2"); var siteA = Assert.Single(perSite); Assert.Equal(3, siteA.QueueDepth); Assert.NotNull(siteA.OldestPendingAge); // Per-node: same single grouped aggregation, keyed by SourceNode. counter.Reset(); var perNode = await repo.ComputePerNodeKpisAsync(stuckCutoff, deliveredSince); Assert.True(counter.Count <= 2, $"ComputePerNodeKpisAsync issued {counter.Count} queries against Notifications; expected <= 2"); var nodeA = Assert.Single(perNode); Assert.Equal(3, nodeA.QueueDepth); Assert.NotNull(nodeA.OldestPendingAge); } /// /// Records the text of every reader command touching the Notifications table so a /// test can count the round trips a KPI snapshot makes and inspect their shape. /// private sealed class SelectCountingInterceptor : DbCommandInterceptor { private readonly List _commands = new(); public IReadOnlyList Commands => _commands; public int Count => _commands.Count; public void Reset() => _commands.Clear(); private void Record(DbCommand command) { if (command.CommandText.Contains("Notifications", StringComparison.OrdinalIgnoreCase)) { _commands.Add(command.CommandText); } } public override InterceptionResult ReaderExecuting( DbCommand command, CommandEventData eventData, InterceptionResult result) { Record(command); return base.ReaderExecuting(command, eventData, result); } public override ValueTask> ReaderExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { Record(command); return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); } } }