Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs
T

140 lines
6.1 KiB
C#

using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// M6 (K2) coverage for <see cref="KpiHistoryRepository"/> over the in-memory SQLite
/// harness. Exercises the bulk write, the single-series query (including the
/// null-ScopeKey Global match), and the retention purge.
/// </summary>
public class KpiHistoryRepositoryTests
{
// Fixed UTC base so the time assertions are deterministic.
private static readonly DateTime Base = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext();
private static KpiSample Sample(
string source, string metric, string scope, string? scopeKey, double value, DateTime capturedAtUtc) =>
new()
{
Source = source,
Metric = metric,
Scope = scope,
ScopeKey = scopeKey,
Value = value,
CapturedAtUtc = capturedAtUtc,
};
[Fact]
public async Task GetRawSeriesAsync_ReturnsOnlyMatchingSeries_InAscendingTimeOrder()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
// Two metrics ("queueDepth", "parkedCount") under one source; the target
// series ("queueDepth", Global, null key) has two points at different
// timestamps — inserted out of order to prove the OrderBy.
await repo.RecordSamplesAsync(new[]
{
// Target series — Global / null ScopeKey, two points (later first).
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 7, capturedAtUtc: Base.AddMinutes(10)),
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddMinutes(5)),
// Different metric — must be excluded.
Sample("NotificationOutbox", "parkedCount", "Global", null, value: 99, capturedAtUtc: Base.AddMinutes(5)),
// Same metric but a Site scope with a non-null key — must be excluded
// from the Global (null-key) query.
Sample("NotificationOutbox", "queueDepth", "Site", "plant-a", value: 42, capturedAtUtc: Base.AddMinutes(5)),
});
var series = await repo.GetRawSeriesAsync(
"NotificationOutbox", "queueDepth", "Global", scopeKey: null,
fromUtc: Base, toUtc: Base.AddMinutes(60));
// Only the two Global/null-key queueDepth points, ascending by capture time.
Assert.Equal(2, series.Count);
Assert.Equal(Base.AddMinutes(5), series[0].BucketStartUtc);
Assert.Equal(3, series[0].Value);
Assert.Equal(Base.AddMinutes(10), series[1].BucketStartUtc);
Assert.Equal(7, series[1].Value);
}
[Fact]
public async Task GetRawSeriesAsync_SiteScopedKey_MatchesOnlyThatKey()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
Sample("SiteCallAudit", "buffered", "Site", "plant-a", value: 5, capturedAtUtc: Base.AddMinutes(5)),
Sample("SiteCallAudit", "buffered", "Site", "plant-b", value: 8, capturedAtUtc: Base.AddMinutes(5)),
// A Global (null-key) row with the same metric must NOT leak into a
// site-keyed query.
Sample("SiteCallAudit", "buffered", "Global", null, value: 13, capturedAtUtc: Base.AddMinutes(5)),
});
var series = await repo.GetRawSeriesAsync(
"SiteCallAudit", "buffered", "Site", scopeKey: "plant-a",
fromUtc: Base, toUtc: Base.AddMinutes(60));
Assert.Single(series);
Assert.Equal(5, series[0].Value);
}
[Fact]
public async Task GetRawSeriesAsync_HonorsTimeWindowBounds()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
Sample("Health", "ageSeconds", "Global", null, value: 1, capturedAtUtc: Base),
Sample("Health", "ageSeconds", "Global", null, value: 2, capturedAtUtc: Base.AddMinutes(30)),
// Outside the [from, to] window — excluded.
Sample("Health", "ageSeconds", "Global", null, value: 3, capturedAtUtc: Base.AddMinutes(120)),
});
var series = await repo.GetRawSeriesAsync(
"Health", "ageSeconds", "Global", scopeKey: null,
fromUtc: Base, toUtc: Base.AddMinutes(60));
Assert.Equal(2, series.Count);
Assert.Equal(new[] { 1d, 2d }, series.Select(p => p.Value).ToArray());
}
[Fact]
public async Task PurgeOlderThanAsync_DeletesOnlyRowsOlderThanCutoff_AndReturnsCount()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
// Two rows strictly older than the cutoff — should be purged.
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 1, capturedAtUtc: Base.AddDays(-10)),
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 2, capturedAtUtc: Base.AddDays(-8)),
// One row AT the cutoff — strictly-older predicate keeps it.
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddDays(-7)),
// One row newer than the cutoff — kept.
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 4, capturedAtUtc: Base.AddDays(-1)),
});
var cutoff = Base.AddDays(-7);
var deleted = await repo.PurgeOlderThanAsync(cutoff);
Assert.Equal(2, deleted);
var remaining = await repo.GetRawSeriesAsync(
"NotificationOutbox", "queueDepth", "Global", scopeKey: null,
fromUtc: Base.AddDays(-30), toUtc: Base.AddDays(30));
// The cutoff row (==) and the newer row survive.
Assert.Equal(2, remaining.Count);
Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray());
}
}