183 lines
7.0 KiB
C#
183 lines
7.0 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ScadaLink.ConfigurationDatabase.Maintenance;
|
|
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
|
using Xunit;
|
|
|
|
namespace ScadaLink.ConfigurationDatabase.Tests.Maintenance;
|
|
|
|
/// <summary>
|
|
/// Bundle D (#23 M6-T5) integration tests for
|
|
/// <see cref="AuditLogPartitionMaintenance"/>. Uses the same
|
|
/// <see cref="MsSqlMigrationFixture"/> as the AuditLog migration / repository
|
|
/// tests so the ALTER PARTITION FUNCTION DDL runs against the actual seeded
|
|
/// <c>pf_AuditLog_Month</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
|
|
{
|
|
private readonly MsSqlMigrationFixture _fixture;
|
|
|
|
public AuditLogPartitionMaintenanceTests(MsSqlMigrationFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
private ScadaLinkDbContext CreateContext() =>
|
|
new(new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
|
.UseSqlServer(_fixture.ConnectionString).Options);
|
|
|
|
private AuditLogPartitionMaintenance NewMaintenance(ScadaLinkDbContext ctx) =>
|
|
new(ctx, NullLogger<AuditLogPartitionMaintenance>.Instance);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes the lookahead-in-months required to add exactly
|
|
/// <paramref name="extraBoundaries"/> new boundaries past the current max.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// EnsureLookaheadAsync defines horizon =
|
|
/// <c>NormalizeToFirstOfMonth(UtcNow) + lookaheadMonths</c>. The new
|
|
/// boundaries it issues are first-of-month values strictly greater than
|
|
/// max, up to and including horizon. So
|
|
/// <c>lookaheadMonths = monthsBetween(NormalizeToFirstOfMonth(UtcNow), max) + extraBoundaries</c>
|
|
/// is the exact value that lands horizon on <c>max + extraBoundaries</c>
|
|
/// months.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|