Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Kpi/NotificationOutboxKpiSampleSourceTests.cs
T

183 lines
8.6 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 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;
/// <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);
}
}