diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/PartitionMaintenanceTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/PartitionMaintenanceTests.cs new file mode 100644 index 0000000..bd1a81c --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Integration/PartitionMaintenanceTests.cs @@ -0,0 +1,278 @@ +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); + } +}