feat(kpi): K2 — KpiSample EF mapping + KpiHistoryRepository + AddKpiSampleTable migration
This commit is contained in:
+139
@@ -0,0 +1,139 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user