Files
scadalink-design/tests/ScadaLink.AuditLog.Tests/Integration/PartitionMaintenanceTests.cs

279 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}