using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Central; using ScadaLink.Commons.Interfaces; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Maintenance; using ScadaLink.ConfigurationDatabase.Tests.Migrations; namespace ScadaLink.AuditLog.Tests.Integration; /// /// Bundle F (#23 M6-T12) end-to-end tests for the /// hosted service running /// the real EF/MSSQL against the /// per-class . The migration seeds /// boundaries for every month Jan 2026 – Dec 2027, so the eager startup tick /// can be exercised both for the "future covered" no-op case and for the /// "lookahead larger than covered" SPLIT case. /// /// /// Tests within this class share one fixture DB — boundaries added by one /// test persist across the next. Each test reads the max boundary at the /// start and computes its lookahead relative to it, mirroring the pattern /// used by the per-component AuditLogPartitionMaintenanceTests in /// ScadaLink.ConfigurationDatabase.Tests. /// public class PartitionMaintenanceTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; public PartitionMaintenanceTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } private ScadaLinkDbContext CreateContext() => new(new DbContextOptionsBuilder() .UseSqlServer(_fixture.ConnectionString).Options); /// /// Builds the central-side DI graph for the hosted service: scoped EF /// context + scoped matching how /// AddConfigurationDatabase wires the production composition root. /// private ServiceProvider BuildProvider() { var services = new ServiceCollection(); services.AddDbContext( opts => opts.UseSqlServer(_fixture.ConnectionString), ServiceLifetime.Scoped); services.AddScoped(); return services.BuildServiceProvider(); } private static async Task ReadMaxBoundaryAsync(IServiceProvider sp) { await using var scope = sp.CreateAsyncScope(); var maintenance = scope.ServiceProvider.GetRequiredService(); return await maintenance.GetMaxBoundaryAsync(); } /// /// Mirrors the helper in /// AuditLogPartitionMaintenanceTests.LookaheadForExtraBoundaries: /// the smallest lookahead value that lands the SPLIT horizon exactly /// months past the current max. /// private static int LookaheadForExtraBoundaries(DateTime max, int extraBoundaries) { var nowFirstOfNextMonth = FirstOfNextMonth(DateTime.UtcNow); var monthsToMax = ((max.Year - nowFirstOfNextMonth.Year) * 12) + max.Month - nowFirstOfNextMonth.Month; return monthsToMax + extraBoundaries; } private static int LookaheadInsideExistingRange(DateTime max) { var now = DateTime.UtcNow; var months = ((max.Year - now.Year) * 12) + max.Month - now.Month - 1; return Math.Max(1, months); } private static DateTime FirstOfNextMonth(DateTime instant) { var firstOfThisMonth = new DateTime(instant.Year, instant.Month, 1, 0, 0, 0, DateTimeKind.Utc); return firstOfThisMonth.AddMonths(1); } /// /// Awaits one full tick of the hosted service. The service runs an /// eager startup tick inside 's /// continuation, but the continuation is dispatched on a background /// Task.Run — so we poll the side effect (the boundary count or /// max-boundary value) until it changes. /// private async Task StartAndAwaitStartupTickAsync( AuditLogPartitionMaintenanceService svc, Func> awaitCondition, TimeSpan timeout) { await svc.StartAsync(CancellationToken.None); var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (await awaitCondition()) { return; } await Task.Delay(50); } } // --------------------------------------------------------------------- // 1. EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered // --------------------------------------------------------------------- [SkippableFact] public async Task EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var sp = BuildProvider(); // The migration seeds boundaries through Dec 2027. With default // lookahead = 1 and today = ~2026-05-20, horizon = // NormalizeToFirstOfMonth(now) + 1 = 2026-07-01, well within the // seeded range, so the startup tick should issue zero SPLITs. var maxBefore = await ReadMaxBoundaryAsync(sp); Assert.NotNull(maxBefore); // Skip if the fixture DB already has boundaries past Dec 2027 from // a prior test in this class — the lookahead-already-covered path // is what we want to exercise, regardless of how far past Dec 2027 // the boundary may be. var opts = Options.Create(new AuditLogPartitionMaintenanceOptions { IntervalSeconds = 60, // long enough that only the startup tick fires inside the test window LookaheadMonths = 1, }); var svc = new AuditLogPartitionMaintenanceService( sp.GetRequiredService(), opts, NullLogger.Instance); // Drive the startup tick. There is no public completion handle; // poll until either (a) the max boundary changes (which would be a // failure for this test) or (b) the polling window expires (success). await svc.StartAsync(CancellationToken.None); await Task.Delay(TimeSpan.FromSeconds(2)); await svc.StopAsync(CancellationToken.None); svc.Dispose(); // Assert the max boundary is unchanged: no SPLIT was issued. var maxAfter = await ReadMaxBoundaryAsync(sp); Assert.Equal(maxBefore, maxAfter); } // --------------------------------------------------------------------- // 2. EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries // --------------------------------------------------------------------- [SkippableFact] public async Task EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var sp = BuildProvider(); var maxBefore = await ReadMaxBoundaryAsync(sp); Assert.NotNull(maxBefore); // Pick a lookahead that adds exactly two new boundaries past the // current max. The expected new boundaries are max+1mo and max+2mo. var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 2); var expectedFirstNew = maxBefore.Value.AddMonths(1); var expectedSecondNew = maxBefore.Value.AddMonths(2); var opts = Options.Create(new AuditLogPartitionMaintenanceOptions { IntervalSeconds = 60, LookaheadMonths = lookahead, }); var svc = new AuditLogPartitionMaintenanceService( sp.GetRequiredService(), opts, NullLogger.Instance); // Drive the startup tick. Wait until max boundary moves forward by // the expected amount; SPLIT against MSSQL can take a second or two // on a busy dev container. await StartAndAwaitStartupTickAsync( svc, async () => { var current = await ReadMaxBoundaryAsync(sp); return current == expectedSecondNew; }, timeout: TimeSpan.FromSeconds(15)); await svc.StopAsync(CancellationToken.None); svc.Dispose(); var maxAfter = await ReadMaxBoundaryAsync(sp); // Two new boundaries should be present after the startup tick. The // hosted service does not surface the added-list directly (it logs // only at Information), so we assert via the max-boundary delta. Assert.Equal(expectedSecondNew, maxAfter); // Sanity: the intermediate boundary was also added (the loop // SPLITs every month from max+1 up to horizon, in order). Assert.True(expectedFirstNew < expectedSecondNew); } // --------------------------------------------------------------------- // 3. EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns // --------------------------------------------------------------------- [SkippableFact] public async Task EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var sp = BuildProvider(); var maxBefore = await ReadMaxBoundaryAsync(sp); Assert.NotNull(maxBefore); // Add exactly one new boundary on the first run. var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 1); var expectedAdded = maxBefore.Value.AddMonths(1); var opts = Options.Create(new AuditLogPartitionMaintenanceOptions { IntervalSeconds = 60, LookaheadMonths = lookahead, }); // First run. var svc1 = new AuditLogPartitionMaintenanceService( sp.GetRequiredService(), opts, NullLogger.Instance); await StartAndAwaitStartupTickAsync( svc1, async () => { var current = await ReadMaxBoundaryAsync(sp); return current == expectedAdded; }, timeout: TimeSpan.FromSeconds(15)); await svc1.StopAsync(CancellationToken.None); svc1.Dispose(); var maxAfterFirst = await ReadMaxBoundaryAsync(sp); Assert.Equal(expectedAdded, maxAfterFirst); // Second run with the SAME lookahead value. Because the boundary // is already covered, the EnsureLookaheadAsync call must be a // no-op — max boundary is unchanged AND no exception is thrown. var svc2 = new AuditLogPartitionMaintenanceService( sp.GetRequiredService(), opts, NullLogger.Instance); await svc2.StartAsync(CancellationToken.None); // Wait long enough that the startup tick would have fired and // logged any boundary addition; the boundary state must remain // unchanged after the wait. await Task.Delay(TimeSpan.FromSeconds(2)); await svc2.StopAsync(CancellationToken.None); svc2.Dispose(); var maxAfterSecond = await ReadMaxBoundaryAsync(sp); Assert.Equal(maxAfterFirst, maxAfterSecond); } }