235 lines
10 KiB
C#
235 lines
10 KiB
C#
using System.Data.Common;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
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 RecordSamplesAsync_EmptyBatch_IsNoOp_AndGetRawSeriesAsync_EmptyTable_ReturnsEmpty()
|
|
{
|
|
await using var ctx = NewContext();
|
|
var repo = new KpiHistoryRepository(ctx);
|
|
|
|
// Empty batch must not throw (early-return guard, no SaveChanges round-trip).
|
|
await repo.RecordSamplesAsync(Array.Empty<KpiSample>());
|
|
|
|
// With nothing written, GetRawSeriesAsync must return a non-null empty list.
|
|
var series = await repo.GetRawSeriesAsync(
|
|
"NotificationOutbox", "queueDepth", "Global", scopeKey: null,
|
|
fromUtc: Base, toUtc: Base.AddMinutes(60));
|
|
|
|
Assert.NotNull(series);
|
|
Assert.Empty(series);
|
|
}
|
|
|
|
[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());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PurgeOlderThanAsync_MultiWindow_IssuesMoreThanOneDelete_AndReturnsTotal()
|
|
{
|
|
// Rows span several days, each in its own hour-bucket, all older than the
|
|
// cutoff. The batched purge must slice them into per-hour DELETE windows
|
|
// (arch-review 04, P4) rather than one giant DELETE — so more than one
|
|
// DELETE statement is issued, and the return value still equals the total.
|
|
var counter = new DeleteCountingInterceptor();
|
|
await using var ctx = SqliteTestHelper.CreateInMemoryContext(counter);
|
|
var repo = new KpiHistoryRepository(ctx);
|
|
|
|
await repo.RecordSamplesAsync(new[]
|
|
{
|
|
// Three distinct hour-buckets strictly older than the cutoff — purged.
|
|
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 1, capturedAtUtc: Base.AddDays(-5)),
|
|
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 2, capturedAtUtc: Base.AddDays(-4)),
|
|
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddDays(-3)),
|
|
// Newer than the cutoff — kept, and never scanned into a delete window.
|
|
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 4, capturedAtUtc: Base.AddDays(-1)),
|
|
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 5, capturedAtUtc: Base),
|
|
});
|
|
|
|
// Only count DELETEs the purge itself issues (RecordSamples above emits none).
|
|
counter.Reset();
|
|
|
|
var deleted = await repo.PurgeOlderThanAsync(Base.AddDays(-2));
|
|
|
|
// All three old rows gone; the batched total is the sum across windows.
|
|
Assert.Equal(3, deleted);
|
|
Assert.True(counter.DeleteCount > 1,
|
|
$"expected the batched purge to issue more than one DELETE, got {counter.DeleteCount}");
|
|
|
|
var remaining = await repo.GetRawSeriesAsync(
|
|
"NotificationOutbox", "queueDepth", "Global", scopeKey: null,
|
|
fromUtc: Base.AddDays(-30), toUtc: Base.AddDays(30));
|
|
Assert.Equal(new[] { 4d, 5d }, remaining.Select(p => p.Value).ToArray());
|
|
}
|
|
|
|
/// <summary>
|
|
/// EF command interceptor that counts the DELETE statements actually issued
|
|
/// to the store, so a test can prove the purge is sliced into multiple
|
|
/// windowed DELETEs rather than one mega-statement. Threads both the sync and
|
|
/// async non-query entry points (<c>ExecuteDeleteAsync</c> routes through the
|
|
/// async path).
|
|
/// </summary>
|
|
private sealed class DeleteCountingInterceptor : DbCommandInterceptor
|
|
{
|
|
public int DeleteCount { get; private set; }
|
|
|
|
public void Reset() => DeleteCount = 0;
|
|
|
|
private void CountIfDelete(DbCommand command)
|
|
{
|
|
if (command.CommandText.Contains("DELETE", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
DeleteCount++;
|
|
}
|
|
}
|
|
|
|
public override InterceptionResult<int> NonQueryExecuting(
|
|
DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
|
|
{
|
|
CountIfDelete(command);
|
|
return base.NonQueryExecuting(command, eventData, result);
|
|
}
|
|
|
|
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
|
|
DbCommand command, CommandEventData eventData, InterceptionResult<int> result,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
CountIfDelete(command);
|
|
return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
|
|
}
|
|
}
|
|
}
|