using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories; /// /// Register row 52 coverage for over the /// in-memory SQLite harness — the fleet-wide bundle-unlock throttle window's persistence /// layer. Exercises the windowed count (including the bucket-isolation and window-boundary /// cases the throttle depends on) and the bounded prune. /// public class BundleUnlockAttemptRepositoryTests { // Fixed UTC base so the window assertions are deterministic. private static readonly DateTime Base = new(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc); private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext(); [Fact] public async Task CountAttemptsSinceAsync_CountsOnlyTheGivenBucketInsideTheWindow() { await using var ctx = NewContext(); var repo = new BundleUnlockAttemptRepository(ctx); // Two inside the trailing hour, one aged out, one from a different client. await repo.RecordAttemptAsync("10.0.0.1", Base.AddMinutes(-10)); await repo.RecordAttemptAsync("10.0.0.1", Base.AddMinutes(-50)); await repo.RecordAttemptAsync("10.0.0.1", Base.AddMinutes(-90)); await repo.RecordAttemptAsync("10.0.0.2", Base.AddMinutes(-5)); var count = await repo.CountAttemptsSinceAsync("10.0.0.1", Base.AddHours(-1)); Assert.Equal(2, count); } [Fact] public async Task CountAttemptsSinceAsync_IsInclusiveAtTheWindowFloor() { // The floor is inclusive, matching the in-memory limiter's prune predicate // (entries at exactly the cutoff still count). Pinned because an off-by-one here // would silently hand every client one extra attempt per window. await using var ctx = NewContext(); var repo = new BundleUnlockAttemptRepository(ctx); await repo.RecordAttemptAsync("10.0.0.1", Base.AddHours(-1)); Assert.Equal(1, await repo.CountAttemptsSinceAsync("10.0.0.1", Base.AddHours(-1))); } [Fact] public async Task CountAttemptsSinceAsync_UnknownBucket_ReturnsZero() { await using var ctx = NewContext(); var repo = new BundleUnlockAttemptRepository(ctx); Assert.Equal(0, await repo.CountAttemptsSinceAsync("", Base.AddHours(-1))); } [Fact] public async Task PruneAttemptsOlderThanAsync_DeletesOnlyAgedRows_AndIsBounded() { await using var ctx = NewContext(); var repo = new BundleUnlockAttemptRepository(ctx); for (var i = 0; i < 5; i++) { await repo.RecordAttemptAsync("10.0.0.1", Base.AddHours(-5).AddMinutes(i)); } await repo.RecordAttemptAsync("10.0.0.1", Base.AddMinutes(-1)); // Bounded: only 3 of the 5 eligible rows go in this pass. var deleted = await repo.PruneAttemptsOlderThanAsync(Base.AddHours(-2), maxRows: 3); Assert.Equal(3, deleted); // The recent row is untouched, and a second pass clears the remainder. Assert.Equal(1, await repo.CountAttemptsSinceAsync("10.0.0.1", Base.AddHours(-1))); Assert.Equal(2, await repo.PruneAttemptsOlderThanAsync(Base.AddHours(-2), maxRows: 3)); Assert.Equal(0, await repo.PruneAttemptsOlderThanAsync(Base.AddHours(-2), maxRows: 3)); } [Fact] public async Task PruneAttemptsOlderThanAsync_RejectsANonPositiveBatchSize() { await using var ctx = NewContext(); var repo = new BundleUnlockAttemptRepository(ctx); await Assert.ThrowsAsync( () => repo.PruneAttemptsOlderThanAsync(Base, maxRows: 0)); } }