Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryKpiQueryShapeTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

130 lines
6.3 KiB
C#

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;
/// <summary>
/// Query-shape coverage for the Notification Outbox KPI snapshots (arch-review 04).
/// 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.
/// </summary>
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,
};
/// <summary>Verifies that all three KPI snapshots issue at most two queries and bound the oldest-pending lookup.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
// The aggregation must be PREDICATE-RESTRICTED, not an unrestricted
// scan. Every KPI here concerns the live queue, the parked backlog or the
// last delivery interval; historical Delivered/Discarded rows — which are
// the overwhelming bulk of a retained table — contribute to none of them.
// Without the WHERE the query's cost grows with retention rather than with
// the working set. (An index-restricted aggregation was already the shape
// of the per-site/per-node snapshots; the global one was the odd one out.)
Assert.All(
counter.Commands.Where(sql => sql.Contains("COUNT", StringComparison.OrdinalIgnoreCase)),
sql => Assert.Contains("WHERE", sql, 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);
}
/// <summary>
/// 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.
/// </summary>
private sealed class SelectCountingInterceptor : DbCommandInterceptor
{
private readonly List<string> _commands = new();
/// <summary>The text of every reader command recorded so far.</summary>
public IReadOnlyList<string> Commands => _commands;
/// <summary>The number of reader commands recorded so far.</summary>
public int Count => _commands.Count;
/// <summary>Clears all recorded commands.</summary>
public void Reset() => _commands.Clear();
private void Record(DbCommand command)
{
if (command.CommandText.Contains("Notifications", StringComparison.OrdinalIgnoreCase))
{
_commands.Add(command.CommandText);
}
}
/// <inheritdoc />
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
Record(command);
return base.ReaderExecuting(command, eventData, result);
}
/// <inheritdoc />
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
Record(command);
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}
}