Files
ScadaBridge/tests/ScadaLink.ConfigurationDatabase.Tests/Maintenance/AuditLogPartitionMaintenanceTests.cs
T
Joseph Doherty d190345ef0 test(coverage): close Theme 8 — 13 test-coverage findings, +35 tests
13 well-bounded test-coverage gaps closed across 11 test projects.
Net +35 regression tests; no production code changes except the
SiteEventLogger src reference unchanged (W3 redacted only test code).

Test additions:
- CLI-022: CommandTreeTests pinned-count assertion bumped 14→16 and
  3 InlineData rows added for the audit + bundle command groups.
- Commons-020: new TransportRecordsTests covers BundleManifest /
  ExportSelection / ImportPreview / ImportResolution / ImportResult —
  ctor + System.Text.Json round-trip + record-equality (14 tests).
- CD-024: SPLIT-RANGE failure-continuation now under
  EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted
  (Skippable MS-SQL fixture); production-shape rowversion delete
  asserted by DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds.
- CentralUI-033: new QueryStringDrillInTests with 4 bUnit cases for
  Transport + SiteCalls drill-in / query-string handling.
- DM-024: probe actors (ReconcileProbeActor, SerializationProbeActor,
  ArtifactProbeActor) refactored from static fields to per-test instances
  (Interlocked on counter) — all 31 callers updated; no production
  changes required.
- HM-022: real-time PeriodicTimer test flake fixed by replacing
  fixed-budget Task.Delay with a RunLoopUntil poll-until-condition
  helper (5s/25ms). Production loop untouched.
- InboundAPI-023: new EndpointExtensionsTests covers the
  POST /api/{methodName} composition wiring via TestServer (7 cases:
  happy path, missing key 401, unknown method 403, invalid JSON 400,
  missing param 400, script-throws 500 sanitised, AuditActorItemKey
  stash invariant).
- MgmtSvc-021: 6 new ManagementActorTests cover the Transport bundle
  handlers (role gate for Export/Preview/Import, unknown-name
  ManagementCommandException, blocker-rejection, dedupe last-write-wins).
- SCA-006: SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow
  pins the missing boundary case.
- SEL-023: stress-test `bool stop` promoted to `volatile bool` for
  cross-thread visibility under release/JIT.

Verify-only resolutions:
- NS-024: closed by NS-019 (commit ac96b83 deletion of
  NotificationDeliveryService + its test file). No edits needed.
- NotifOutbox-008: FallbackMaxRetries/FallbackRetryDelay are private
  forward-compat constants returned only when no SMTP-config row exists
  (in which case EmailNotificationDeliveryAdapter returns Permanent,
  bypassing the values entirely). Marked Resolved with note.
- Transport-010: Overwrite child-collection sync covered by the T-001/
  T-002 tests added in commit e3ca9af; per-IP throttle by
  BundleUnlockRateLimiterTests; failed-session retention by
  BundleSessionStoreTests; T-009 closed structurally via AsyncLocal.
  Marked Resolved by reference.

Build clean; all 11 affected test suites green. README regenerated:
33 open (was 46).
2026-05-28 08:21:03 -04:00

317 lines
13 KiB
C#

using System.Data.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
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);
}
/// <summary>
/// ConfigurationDatabase-024: CD-019 removed the try/catch around the per-month
/// SPLIT call so a genuine SQL failure (deadlock, permission, log full, transient
/// connection drop) now aborts the loop instead of leaving partition holes. This
/// test pins that abort behaviour: with an interceptor that throws on the SECOND
/// SPLIT, the call must propagate the exception AND the first SPLIT's boundary
/// must already be persisted in <c>pf_AuditLog_Month</c> (visible to a fresh
/// <see cref="AuditLogPartitionMaintenance"/> instance) — proof that the loop did
/// commit boundary N before throwing, and that the next tick can resume from
/// boundary N+1 at-least-once with no holes.
/// </summary>
[SkippableFact]
public async Task EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted()
{
// STM: CD-024-SecondSplitThrowsAbortsLoop marker.
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Baseline max-boundary observed via a clean context.
await using var baselineCtx = CreateContext();
var maxBefore = await NewMaintenance(baselineCtx).GetMaxBoundaryAsync();
Assert.NotNull(maxBefore);
var lookahead = LookaheadForExtraBoundaries(maxBefore!.Value, extraBoundaries: 3);
var expectedFirst = maxBefore.Value.AddMonths(1);
// Build a fresh context with an interceptor that throws on the 2nd ALTER
// PARTITION FUNCTION SPLIT RANGE. EF Core surfaces the throw through
// ExecuteSqlRawAsync exactly as a SqlException would — the loop has no
// try/catch (CD-019), so the exception propagates after the first SPLIT
// has already committed.
var interceptor = new SecondSplitThrowsInterceptor();
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.AddInterceptors(interceptor)
.Options;
await using var ctx = new ScadaLinkDbContext(options);
var maintenance = NewMaintenance(ctx);
await Assert.ThrowsAsync<InvalidOperationException>(
() => maintenance.EnsureLookaheadAsync(lookahead));
// Verify exactly one ALTER PARTITION FUNCTION SPLIT RANGE actually ran
// before the interceptor's throw: split #1 committed, split #2 threw,
// split #3 was never attempted.
Assert.Equal(1, interceptor.SuccessfulSplits);
// And verify the first boundary IS now persisted — the loop aborted but
// boundary N is durable so the next tick resumes from N+1 (no holes).
await using var verifyCtx = CreateContext();
var maxAfter = await NewMaintenance(verifyCtx).GetMaxBoundaryAsync();
Assert.Equal(expectedFirst, maxAfter);
}
/// <summary>
/// EF Core command interceptor: lets the first <c>ALTER PARTITION FUNCTION
/// pf_AuditLog_Month() SPLIT RANGE</c> through and throws <see cref="InvalidOperationException"/>
/// on the second one. Threads through synchronous + async + scalar + reader
/// entry-points because <c>ExecuteSqlRawAsync</c> routes through the
/// non-query async path but other code paths still go through the same
/// interceptor pipeline. <see cref="SuccessfulSplits"/> counts the splits
/// that were allowed to run so the test can pin the abort-after-one
/// behaviour.
/// </summary>
private sealed class SecondSplitThrowsInterceptor : DbCommandInterceptor
{
public int SuccessfulSplits { get; private set; }
private bool IsTargetSplit(DbCommand command) =>
command.CommandText.Contains("SPLIT RANGE", StringComparison.OrdinalIgnoreCase)
&& command.CommandText.Contains("pf_AuditLog_Month", StringComparison.OrdinalIgnoreCase);
public override InterceptionResult<int> NonQueryExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<int> result)
{
ThrowIfSecondSplit(command);
return base.NonQueryExecuting(command, eventData, result);
}
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
ThrowIfSecondSplit(command);
return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
}
public override int NonQueryExecuted(
DbCommand command,
CommandExecutedEventData eventData,
int result)
{
if (IsTargetSplit(command))
{
SuccessfulSplits++;
}
return base.NonQueryExecuted(command, eventData, result);
}
public override ValueTask<int> NonQueryExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
if (IsTargetSplit(command))
{
SuccessfulSplits++;
}
return base.NonQueryExecutedAsync(command, eventData, result, cancellationToken);
}
private void ThrowIfSecondSplit(DbCommand command)
{
if (!IsTargetSplit(command))
{
return;
}
// Allow the first SPLIT through; throw on the second so the loop's
// post-CD-019 "let it propagate" behaviour can be asserted.
if (SuccessfulSplits >= 1)
{
throw new InvalidOperationException(
"Simulated SqlException on the second SPLIT RANGE — exercising CD-019's no-try/catch abort path.");
}
}
}
[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);
}
}