using Microsoft.Extensions.Options; 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; using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi; namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests.Kpi; /// /// Tests for — the M6 KPI sample source that /// snapshots the Notification Outbox delivery KPIs (global / per-site / per-node) into the /// central KPI-history store. /// 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()); Assert.Equal(KpiSources.NotificationOutbox, source.Source); } [Fact] public async Task CollectAsync_PassesCutoffsAnchoredOnCapturedAt() { var repository = Substitute.For(); 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()); await repository.Received(1).ComputePerSiteKpisAsync( expectedStuckCutoff, expectedDeliveredSince, Arg.Any()); await repository.Received(1).ComputePerNodeKpisAsync( expectedStuckCutoff, expectedDeliveredSince, Arg.Any()); } [Fact] public async Task CollectAsync_EmitsGlobalSiteAndNodeSamples_WithExpectedTuples() { var repository = Substitute.For(); repository.ComputeKpisAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new NotificationKpiSnapshot( QueueDepth: 5, StuckCount: 2, ParkedCount: 1, DeliveredLastInterval: 7, OldestPendingAge: TimeSpan.FromSeconds(90))); repository.ComputePerSiteKpisAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new[] { new SiteNotificationKpiSnapshot( SourceSiteId: "site-a", QueueDepth: 3, StuckCount: 1, ParkedCount: 0, DeliveredLastInterval: 4, OldestPendingAge: TimeSpan.FromSeconds(30)), }); repository.ComputePerNodeKpisAsync(Arg.Any(), Arg.Any(), Arg.Any()) .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(); repository.ComputeKpisAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new NotificationKpiSnapshot( QueueDepth: 0, StuckCount: 0, ParkedCount: 0, DeliveredLastInterval: 0, OldestPendingAge: null)); repository.ComputePerSiteKpisAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Array.Empty()); repository.ComputePerNodeKpisAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Array.Empty()); 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(); 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(), Arg.Any(), Arg.Any()) .Returns(new NotificationKpiSnapshot(0, 0, 0, 0, null)); repository.ComputePerSiteKpisAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Array.Empty()); repository.ComputePerNodeKpisAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Array.Empty()); } private static void AssertSample( IReadOnlyList 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); } }