test(auditlog): partition maintenance roll-forward end-to-end (#23 M6)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user