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; /// /// M6 (K2) coverage for over the in-memory SQLite /// harness. Exercises the bulk write, the single-series query (including the /// null-ScopeKey Global match), and the retention purge. /// 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()); } }