using System.Data.Common; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.ConfigurationDatabase.Maintenance; using ScadaLink.ConfigurationDatabase.Tests.Migrations; using Xunit; namespace ScadaLink.ConfigurationDatabase.Tests.Maintenance; /// /// Bundle D (#23 M6-T5) integration tests for /// . Uses the same /// as the AuditLog migration / repository /// tests so the ALTER PARTITION FUNCTION DDL runs against the actual seeded /// pf_AuditLog_Month. /// /// /// The migration seeds boundaries for every month in 2026 and 2027 (Jan 2026 /// through Dec 2027). Tests pick a lookahead relative to the current /// max-boundary at test start (rather than a fixed-target date) so each test /// is robust against earlier tests in the class having added boundaries to /// the shared fixture DB. Tests run sequentially within the class via xunit's /// per-class collection serialisation. /// public class AuditLogPartitionMaintenanceTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; public AuditLogPartitionMaintenanceTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } private ScadaLinkDbContext CreateContext() => new(new DbContextOptionsBuilder() .UseSqlServer(_fixture.ConnectionString).Options); private AuditLogPartitionMaintenance NewMaintenance(ScadaLinkDbContext ctx) => new(ctx, NullLogger.Instance); /// /// Computes the lookahead-in-months required to fall strictly inside the /// already-covered boundary range. Picks something well below the /// distance from "now" to the current max — guaranteed not to need any /// new SPLIT. /// private static int LookaheadInsideExistingRange(DateTime max) { var now = DateTime.UtcNow; // (max - now) in whole months, minus a 1-month safety margin so we // never accidentally hit the boundary horizon edge case. var months = ((max.Year - now.Year) * 12) + max.Month - now.Month - 1; return Math.Max(1, months); } /// /// Computes the lookahead-in-months required to add exactly /// new boundaries past the current max. /// /// /// EnsureLookaheadAsync defines horizon = /// NormalizeToFirstOfMonth(UtcNow) + lookaheadMonths. The new /// boundaries it issues are first-of-month values strictly greater than /// max, up to and including horizon. So /// lookaheadMonths = monthsBetween(NormalizeToFirstOfMonth(UtcNow), max) + extraBoundaries /// is the exact value that lands horizon on max + extraBoundaries /// months. /// private static int LookaheadForExtraBoundaries(DateTime max, int extraBoundaries) { var nowFirstOfMonth = FirstOfNextMonth(DateTime.UtcNow); var monthsToMax = ((max.Year - nowFirstOfMonth.Year) * 12) + max.Month - nowFirstOfMonth.Month; return monthsToMax + extraBoundaries; } private static DateTime FirstOfNextMonth(DateTime instant) { var firstOfThisMonth = new DateTime(instant.Year, instant.Month, 1, 0, 0, 0, DateTimeKind.Utc); return firstOfThisMonth.AddMonths(1); } [SkippableFact] public async Task EnsureLookahead_AlreadyHasFutureRange_NoSplit_ReturnsEmpty() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var ctx = CreateContext(); var maintenance = NewMaintenance(ctx); var max = await maintenance.GetMaxBoundaryAsync(); Assert.NotNull(max); // Pick a lookahead small enough that horizon (NormalizeToFirstOfMonth(now) // + lookahead) lands well INSIDE the already-covered range — no SPLIT // should fire. var lookahead = LookaheadInsideExistingRange(max.Value); var added = await maintenance.EnsureLookaheadAsync(lookahead); Assert.Empty(added); // Sanity: the max boundary is unchanged after the no-op call. var maxAfter = await maintenance.GetMaxBoundaryAsync(); Assert.Equal(max, maxAfter); } [SkippableFact] public async Task EnsureLookahead_NeedsOneMoreBoundary_Splits_Returns1Boundary() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var ctx = CreateContext(); var maintenance = NewMaintenance(ctx); var maxBefore = await maintenance.GetMaxBoundaryAsync(); Assert.NotNull(maxBefore); var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 1); var expectedAdded = maxBefore.Value.AddMonths(1); var added = await maintenance.EnsureLookaheadAsync(lookahead); Assert.Single(added); Assert.Equal(expectedAdded, added[0]); var maxAfter = await maintenance.GetMaxBoundaryAsync(); Assert.Equal(expectedAdded, maxAfter); } [SkippableFact] public async Task EnsureLookahead_NeedsThreeBoundaries_Splits_Returns3Boundaries() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var ctx = CreateContext(); var maintenance = NewMaintenance(ctx); var maxBefore = await maintenance.GetMaxBoundaryAsync(); Assert.NotNull(maxBefore); var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 3); var added = await maintenance.EnsureLookaheadAsync(lookahead); Assert.Equal(3, added.Count); Assert.Equal(maxBefore.Value.AddMonths(1), added[0]); Assert.Equal(maxBefore.Value.AddMonths(2), added[1]); Assert.Equal(maxBefore.Value.AddMonths(3), added[2]); var maxAfter = await maintenance.GetMaxBoundaryAsync(); Assert.Equal(maxBefore.Value.AddMonths(3), maxAfter); } /// /// ConfigurationDatabase-024: CD-019 removed the try/catch around the per-month /// SPLIT call so a genuine SQL failure (deadlock, permission, log full, transient /// connection drop) now aborts the loop instead of leaving partition holes. This /// test pins that abort behaviour: with an interceptor that throws on the SECOND /// SPLIT, the call must propagate the exception AND the first SPLIT's boundary /// must already be persisted in pf_AuditLog_Month (visible to a fresh /// instance) — proof that the loop did /// commit boundary N before throwing, and that the next tick can resume from /// boundary N+1 at-least-once with no holes. /// [SkippableFact] public async Task EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted() { // STM: CD-024-SecondSplitThrowsAbortsLoop marker. Skip.IfNot(_fixture.Available, _fixture.SkipReason); // Baseline max-boundary observed via a clean context. await using var baselineCtx = CreateContext(); var maxBefore = await NewMaintenance(baselineCtx).GetMaxBoundaryAsync(); Assert.NotNull(maxBefore); var lookahead = LookaheadForExtraBoundaries(maxBefore!.Value, extraBoundaries: 3); var expectedFirst = maxBefore.Value.AddMonths(1); // Build a fresh context with an interceptor that throws on the 2nd ALTER // PARTITION FUNCTION SPLIT RANGE. EF Core surfaces the throw through // ExecuteSqlRawAsync exactly as a SqlException would — the loop has no // try/catch (CD-019), so the exception propagates after the first SPLIT // has already committed. var interceptor = new SecondSplitThrowsInterceptor(); var options = new DbContextOptionsBuilder() .UseSqlServer(_fixture.ConnectionString) .AddInterceptors(interceptor) .Options; await using var ctx = new ScadaLinkDbContext(options); var maintenance = NewMaintenance(ctx); await Assert.ThrowsAsync( () => maintenance.EnsureLookaheadAsync(lookahead)); // Verify exactly one ALTER PARTITION FUNCTION SPLIT RANGE actually ran // before the interceptor's throw: split #1 committed, split #2 threw, // split #3 was never attempted. Assert.Equal(1, interceptor.SuccessfulSplits); // And verify the first boundary IS now persisted — the loop aborted but // boundary N is durable so the next tick resumes from N+1 (no holes). await using var verifyCtx = CreateContext(); var maxAfter = await NewMaintenance(verifyCtx).GetMaxBoundaryAsync(); Assert.Equal(expectedFirst, maxAfter); } /// /// EF Core command interceptor: lets the first ALTER PARTITION FUNCTION /// pf_AuditLog_Month() SPLIT RANGE through and throws /// on the second one. Threads through synchronous + async + scalar + reader /// entry-points because ExecuteSqlRawAsync routes through the /// non-query async path but other code paths still go through the same /// interceptor pipeline. counts the splits /// that were allowed to run so the test can pin the abort-after-one /// behaviour. /// private sealed class SecondSplitThrowsInterceptor : DbCommandInterceptor { public int SuccessfulSplits { get; private set; } private bool IsTargetSplit(DbCommand command) => command.CommandText.Contains("SPLIT RANGE", StringComparison.OrdinalIgnoreCase) && command.CommandText.Contains("pf_AuditLog_Month", StringComparison.OrdinalIgnoreCase); public override InterceptionResult NonQueryExecuting( DbCommand command, CommandEventData eventData, InterceptionResult result) { ThrowIfSecondSplit(command); return base.NonQueryExecuting(command, eventData, result); } public override ValueTask> NonQueryExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { ThrowIfSecondSplit(command); return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); } public override int NonQueryExecuted( DbCommand command, CommandExecutedEventData eventData, int result) { if (IsTargetSplit(command)) { SuccessfulSplits++; } return base.NonQueryExecuted(command, eventData, result); } public override ValueTask NonQueryExecutedAsync( DbCommand command, CommandExecutedEventData eventData, int result, CancellationToken cancellationToken = default) { if (IsTargetSplit(command)) { SuccessfulSplits++; } return base.NonQueryExecutedAsync(command, eventData, result, cancellationToken); } private void ThrowIfSecondSplit(DbCommand command) { if (!IsTargetSplit(command)) { return; } // Allow the first SPLIT through; throw on the second so the loop's // post-CD-019 "let it propagate" behaviour can be asserted. if (SuccessfulSplits >= 1) { throw new InvalidOperationException( "Simulated SqlException on the second SPLIT RANGE — exercising CD-019's no-try/catch abort path."); } } } [SkippableFact] public async Task EnsureLookahead_BoundaryAlreadyExists_NoError_Idempotent() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var ctx1 = CreateContext(); var m1 = NewMaintenance(ctx1); var maxStart = await m1.GetMaxBoundaryAsync(); Assert.NotNull(maxStart); // First call: add one boundary. var lookahead = LookaheadForExtraBoundaries(maxStart.Value, extraBoundaries: 1); var firstAdded = await m1.EnsureLookaheadAsync(lookahead); Assert.Single(firstAdded); // Second call: the boundary just added is now part of pf_AuditLog_Month, // so the same lookahead value should be a no-op — no exception, no // duplicate SPLIT. await using var ctx2 = CreateContext(); var m2 = NewMaintenance(ctx2); var secondAdded = await m2.EnsureLookaheadAsync(lookahead); Assert.Empty(secondAdded); // The max boundary is unchanged across the second call. var maxAfter = await m2.GetMaxBoundaryAsync(); Assert.Equal(firstAdded[0], maxAfter); } }