test(auditlog): partition maintenance roll-forward end-to-end (#23 M6)

This commit is contained in:
Joseph Doherty
2026-05-20 19:38:07 -04:00
parent 2138534581
commit eb5fa8f2bc

View File

@@ -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;
/// <summary>
/// Bundle F (#23 M6-T12) end-to-end tests for the
/// <see cref="AuditLogPartitionMaintenanceService"/> hosted service running
/// the real EF/MSSQL <see cref="AuditLogPartitionMaintenance"/> against the
/// per-class <see cref="MsSqlMigrationFixture"/>. 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.
/// </summary>
/// <remarks>
/// 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 <c>AuditLogPartitionMaintenanceTests</c> in
/// <c>ScadaLink.ConfigurationDatabase.Tests</c>.
/// </remarks>
public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public PartitionMaintenanceTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private ScadaLinkDbContext CreateContext() =>
new(new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options);
/// <summary>
/// Builds the central-side DI graph for the hosted service: scoped EF
/// context + scoped <see cref="IPartitionMaintenance"/> matching how
/// <c>AddConfigurationDatabase</c> wires the production composition root.
/// </summary>
private ServiceProvider BuildProvider()
{
var services = new ServiceCollection();
services.AddDbContext<ScadaLinkDbContext>(
opts => opts.UseSqlServer(_fixture.ConnectionString),
ServiceLifetime.Scoped);
services.AddScoped<IPartitionMaintenance, AuditLogPartitionMaintenance>();
return services.BuildServiceProvider();
}
private static async Task<DateTime?> ReadMaxBoundaryAsync(IServiceProvider sp)
{
await using var scope = sp.CreateAsyncScope();
var maintenance = scope.ServiceProvider.GetRequiredService<IPartitionMaintenance>();
return await maintenance.GetMaxBoundaryAsync();
}
/// <summary>
/// Mirrors the helper in
/// <c>AuditLogPartitionMaintenanceTests.LookaheadForExtraBoundaries</c>:
/// the smallest lookahead value that lands the SPLIT horizon exactly
/// <paramref name="extraBoundaries"/> months past the current max.
/// </summary>
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);
}
/// <summary>
/// Awaits one full tick of the hosted service. The service runs an
/// eager startup tick inside <see cref="AuditLogPartitionMaintenanceService.StartAsync"/>'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.
/// </summary>
private async Task StartAndAwaitStartupTickAsync(
AuditLogPartitionMaintenanceService svc,
Func<Task<bool>> 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<IServiceScopeFactory>(),
opts,
NullLogger<AuditLogPartitionMaintenanceService>.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<IServiceScopeFactory>(),
opts,
NullLogger<AuditLogPartitionMaintenanceService>.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<IServiceScopeFactory>(),
opts,
NullLogger<AuditLogPartitionMaintenanceService>.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<IServiceScopeFactory>(),
opts,
NullLogger<AuditLogPartitionMaintenanceService>.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);
}
}