183 lines
8.6 KiB
C#
183 lines
8.6 KiB
C#
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);
|
||
}
|
||
}
|