Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Kpi/NotificationOutboxKpiSampleSourceTests.cs
T
Joseph Doherty fd618cf1dc fix(review): full code-review remediation — 5 High + Medium/Low across 16 modules
Remediation from the full per-module code review at 4307c381 (findings recorded
separately in code-reviews/).

Highs fixed:
- DeploymentManager-025/SiteRuntime-031: stop broadcasting notification lists + SMTP
  configs (incl. credentials) to sites; site purges already-persisted rows on apply
  (enforces the central-only delivery design; clears plaintext SMTP creds at rest).
- DataConnectionLayer-023: guard the native-alarm subscribe path against the
  mid-flight-unsubscribe adapter-feed leak (mirrors the DCL-021 tag-path fix).
- SiteEventLogging-024: normalize From/To query bounds to UTC (the -016 fix the
  audit trail claimed but never committed).
- KpiHistory-001: add an in-flight guard to the recorder sample tick.
- ScriptAnalysis-001: harden the trust analyzer's TPA-absent fallback (resolve
  forbidden anchors in the minimal reference set; warn on degraded mode) — anchors
  added to validation references only, never the compile gate.
(InboundAPI-026 left to the feat/ipsen-movein effort per owner decision.)

Medium/Low: DM-026 deterministic deploy-status tiebreaker; SR-027/028/029/030
native-alarm leak/phantom-active/delete-during-redeploy fixes; AL-013/014/016;
TE-024 (folder-mutation audit rows now persisted)/025; SF-025 gauge-provider
clear-on-stop; ESG-025/026; SEC-023/024/025; SCA-007/008/009; plus doc/test
accuracy COM-023/024, HOST-025/026, HM-024/025, NS-027/028.

Full-solution build 0 warnings; ~3560 tests across 18 touched suites green.
2026-06-20 17:55:12 -04:00

181 lines
8.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Kpi;
/// <summary>
/// Tests for <see cref="NotificationOutboxKpiSampleSource"/> — the M6 KPI sample source that
/// snapshots the Notification Outbox delivery KPIs (global / per-site / per-node) into the
/// central KPI-history store.
/// </summary>
public class NotificationOutboxKpiSampleSourceTests
{
private static readonly DateTime CapturedAt = new(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc);
private static readonly NotificationOutboxOptions Options = new()
{
StuckAgeThreshold = TimeSpan.FromMinutes(10),
DeliveredKpiWindow = TimeSpan.FromMinutes(1),
};
private static NotificationOutboxKpiSampleSource CreateSource(INotificationOutboxRepository repository) =>
new(repository, Microsoft.Extensions.Options.Options.Create(Options));
[Fact]
public void Source_IsNotificationOutbox()
{
var source = CreateSource(Substitute.For<INotificationOutboxRepository>());
Assert.Equal(KpiSources.NotificationOutbox, source.Source);
}
[Fact]
public async Task CollectAsync_PassesCutoffsAnchoredOnCapturedAt()
{
var repository = Substitute.For<INotificationOutboxRepository>();
StubEmptySnapshots(repository);
var source = CreateSource(repository);
await source.CollectAsync(CapturedAt);
var expectedStuckCutoff = new DateTimeOffset(CapturedAt, TimeSpan.Zero) - Options.StuckAgeThreshold;
var expectedDeliveredSince = new DateTimeOffset(CapturedAt, TimeSpan.Zero) - Options.DeliveredKpiWindow;
await repository.Received(1).ComputeKpisAsync(
expectedStuckCutoff, expectedDeliveredSince, Arg.Any<CancellationToken>());
await repository.Received(1).ComputePerSiteKpisAsync(
expectedStuckCutoff, expectedDeliveredSince, Arg.Any<CancellationToken>());
await repository.Received(1).ComputePerNodeKpisAsync(
expectedStuckCutoff, expectedDeliveredSince, Arg.Any<CancellationToken>());
}
[Fact]
public async Task CollectAsync_EmitsGlobalSiteAndNodeSamples_WithExpectedTuples()
{
var repository = Substitute.For<INotificationOutboxRepository>();
repository.ComputeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new NotificationKpiSnapshot(
QueueDepth: 5,
StuckCount: 2,
ParkedCount: 1,
DeliveredLastInterval: 7,
OldestPendingAge: TimeSpan.FromSeconds(90)));
repository.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new[]
{
new SiteNotificationKpiSnapshot(
SourceSiteId: "site-a",
QueueDepth: 3,
StuckCount: 1,
ParkedCount: 0,
DeliveredLastInterval: 4,
OldestPendingAge: TimeSpan.FromSeconds(30)),
});
repository.ComputePerNodeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new[]
{
new NodeNotificationKpiSnapshot(
SourceNode: "node-a",
QueueDepth: 2,
StuckCount: 1,
ParkedCount: 1,
DeliveredLastInterval: 3,
OldestPendingAge: TimeSpan.FromSeconds(60)),
});
var source = CreateSource(repository);
var samples = await source.CollectAsync(CapturedAt);
// 3 scopes × 5 metrics (all ages non-null) = 15 samples.
Assert.Equal(15, samples.Count);
Assert.All(samples, s => Assert.Equal(KpiSources.NotificationOutbox, s.Source));
Assert.All(samples, s => Assert.Equal(CapturedAt, s.CapturedAtUtc));
// Global — null ScopeKey.
AssertSample(samples, "queueDepth", KpiScopes.Global, null, 5);
AssertSample(samples, "stuckCount", KpiScopes.Global, null, 2);
AssertSample(samples, "parkedCount", KpiScopes.Global, null, 1);
AssertSample(samples, "deliveredLastInterval", KpiScopes.Global, null, 7);
AssertSample(samples, "oldestPendingAgeSeconds", KpiScopes.Global, null, 90);
// Site — ScopeKey == site id.
AssertSample(samples, "queueDepth", KpiScopes.Site, "site-a", 3);
AssertSample(samples, "stuckCount", KpiScopes.Site, "site-a", 1);
AssertSample(samples, "parkedCount", KpiScopes.Site, "site-a", 0);
AssertSample(samples, "deliveredLastInterval", KpiScopes.Site, "site-a", 4);
AssertSample(samples, "oldestPendingAgeSeconds", KpiScopes.Site, "site-a", 30);
// Node — ScopeKey == node name.
AssertSample(samples, "queueDepth", KpiScopes.Node, "node-a", 2);
AssertSample(samples, "stuckCount", KpiScopes.Node, "node-a", 1);
AssertSample(samples, "parkedCount", KpiScopes.Node, "node-a", 1);
AssertSample(samples, "deliveredLastInterval", KpiScopes.Node, "node-a", 3);
AssertSample(samples, "oldestPendingAgeSeconds", KpiScopes.Node, "node-a", 60);
}
[Fact]
public async Task CollectAsync_OmitsOldestPendingAge_WhenNull()
{
var repository = Substitute.For<INotificationOutboxRepository>();
repository.ComputeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new NotificationKpiSnapshot(
QueueDepth: 0,
StuckCount: 0,
ParkedCount: 0,
DeliveredLastInterval: 0,
OldestPendingAge: null));
repository.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<SiteNotificationKpiSnapshot>());
repository.ComputePerNodeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<NodeNotificationKpiSnapshot>());
var source = CreateSource(repository);
var samples = await source.CollectAsync(CapturedAt);
// Only the global snapshot, age omitted -> the four count metrics.
Assert.Equal(4, samples.Count);
Assert.DoesNotContain(samples, s => s.Metric == "oldestPendingAgeSeconds");
AssertSample(samples, "queueDepth", KpiScopes.Global, null, 0);
AssertSample(samples, "stuckCount", KpiScopes.Global, null, 0);
AssertSample(samples, "parkedCount", KpiScopes.Global, null, 0);
AssertSample(samples, "deliveredLastInterval", KpiScopes.Global, null, 0);
}
[Fact]
public async Task CollectAsync_ReturnsEmptyList_NeverNull_WhenNothingToReport()
{
// ComputeKpisAsync always returns a global snapshot; the only way the list is empty is
// a guard that produces no samples. Confirm an all-zero global with null age still yields
// the four count metrics (i.e. the list is never null even at idle).
var repository = Substitute.For<INotificationOutboxRepository>();
StubEmptySnapshots(repository);
var source = CreateSource(repository);
var samples = await source.CollectAsync(CapturedAt);
Assert.NotNull(samples);
}
private static void StubEmptySnapshots(INotificationOutboxRepository repository)
{
repository.ComputeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new NotificationKpiSnapshot(0, 0, 0, 0, null));
repository.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<SiteNotificationKpiSnapshot>());
repository.ComputePerNodeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<NodeNotificationKpiSnapshot>());
}
private static void AssertSample(
IReadOnlyList<KpiSample> samples, string metric, string scope, string? scopeKey, double value)
{
var match = Assert.Single(
samples, s => s.Metric == metric && s.Scope == scope && s.ScopeKey == scopeKey);
Assert.Equal(value, match.Value);
}
}