279 lines
11 KiB
C#
279 lines
11 KiB
C#
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);
|
||
}
|
||
}
|