fix(configdb-tests): use Assert.SkipUnless + fast-fail connect timeout for MSSQL fixture (#23)

Reviewer of Bundle C (#23 M1) flagged two blockers in the
AddAuditLogTableMigration integration tests:

1. Tests used 'if (!await EnsureMigrationApplied()) return;' which made
   the xunit runner report them as Passed when the dev MSSQL container
   was absent — a CI false-positive risk. xunit 2.9.x does NOT ship the
   v3 Assert.Skip/SkipUnless/SkipWhen API surface (verified empirically
   against xunit.assert 2.9.3 — only v3.x exposes those static methods),
   so the canonical xunit-v2 equivalent is the Xunit.SkippableFact
   package. Replaced [Fact] with [SkippableFact] and the early-return
   pattern with 'Skip.IfNot(_fixture.Available, _fixture.SkipReason)' as
   the first statement of each of the 8 audit-log test methods. The
   runner now reports them as Skipped (not Passed) when MSSQL is down.

2. MsSqlMigrationFixture relied on SqlClient's 30s default connect
   timeout, so a no-container fixture construction hung ~30s. Added
   'Connect Timeout=3' to DefaultAdminConnectionString. Verified
   fail-fast under ~4s end-to-end with a bad host via env-var override.

Additional fixture cleanups:
- Migration is now applied once in the fixture constructor (was per-test
  via EnsureMigrationApplied for idempotency). Tests reach a fully-
  migrated database with no extra setup. Removed the now-unused
  EnsureMigrationApplied helper from the test class.
- Constructor narrowed its catch to SqlException + InvalidOperationException
  for the OpenAsync step (the only legitimate connect-failure surfaces);
  everything else (CREATE DATABASE, MigrateAsync) is treated as a hard
  fixture failure and bubbles up. Added a best-effort
  TryDropOrphanDatabase() pre-throw cleanup so partial construction
  cannot leak guid-suffixed databases.
- Stale doc comments referencing the (non-existent) xunit 2.9.x Skip
  shim removed; replaced with accurate notes about Xunit.SkippableFact.

Verified:
- dotnet build ScadaLink.slnx: clean (0 warnings, 0 errors).
- dotnet test ScadaLink.ConfigurationDatabase.Tests with MSSQL up:
  Passed 150 / Skipped 0 / Failed 0.
- Same suite with SCADALINK_MSSQL_TEST_CONN pointed at a closed port:
  the 8 AddAuditLogTableMigration tests report as Skipped (visible
  '[SKIP]' lines in runner output), total elapsed ~3s.

Files touched:
- Directory.Packages.props: added Xunit.SkippableFact 1.5.61.
- tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj:
  added the SkippableFact PackageReference.
- tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/MsSqlMigrationFixture.cs:
  Connect Timeout=3, constructor refactor, doc-comment fixes.
- tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs:
  [SkippableFact] + Skip.IfNot pattern across all 8 tests.

Untouched (per reviewer guidance):
- Migration file (Bundle C main artifact unchanged).
- Bundle B reconciliation (composite PK + UX_AuditLog_EventId).
- SqlClient VersionOverride 6.1.1 in the test csproj.
- infra/* (separate uncommitted local edits remain in working tree).
This commit is contained in:
Joseph Doherty
2026-05-20 10:49:49 -04:00
parent d9c99242a3
commit ce9d6301e3
4 changed files with 124 additions and 108 deletions

View File

@@ -1,5 +1,5 @@
using Microsoft.Data.SqlClient;
using Xunit.Abstractions;
using Xunit;
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
@@ -11,28 +11,26 @@ namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
/// indexes, and DB roles.
/// </summary>
/// <remarks>
/// Tests early-return with a clear test-output message when the
/// <see cref="MsSqlMigrationFixture"/> reports unavailable, so CI without
/// the dev MSSQL container still runs the suite green.
/// Tests use <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot(...)</c> from
/// the Xunit.SkippableFact package so the runner reports them as Skipped (not
/// Passed) when MSSQL is unreachable. xunit 2.9.x does not ship a native
/// <c>Assert.Skip</c>/<c>Assert.SkipUnless</c> — those land in xunit v3 — so
/// SkippableFact is the canonical equivalent for this project. The fixture
/// applies the migration once at construction time.
/// </remarks>
public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
private readonly ITestOutputHelper _output;
public AddAuditLogTableMigrationTests(MsSqlMigrationFixture fixture, ITestOutputHelper output)
public AddAuditLogTableMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
_output = output;
}
[Fact]
[SkippableFact]
public async Task AppliesMigration_CreatesAuditLogTable()
{
if (!await EnsureMigrationApplied())
{
return;
}
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var exists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " +
@@ -41,13 +39,10 @@ public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixtur
Assert.Equal(1, exists);
}
[Fact]
[SkippableFact]
public async Task AppliesMigration_CreatesPartitionFunction_pf_AuditLog_Month()
{
if (!await EnsureMigrationApplied())
{
return;
}
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var functionExists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.partition_functions WHERE name = 'pf_AuditLog_Month';");
@@ -63,26 +58,20 @@ public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixtur
$"Expected at least 24 monthly boundaries on pf_AuditLog_Month; got {boundaryCount}.");
}
[Fact]
[SkippableFact]
public async Task AppliesMigration_CreatesPartitionScheme_ps_AuditLog_Month()
{
if (!await EnsureMigrationApplied())
{
return;
}
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var schemeExists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month';");
Assert.Equal(1, schemeExists);
}
[Fact]
[SkippableFact]
public async Task AppliesMigration_TableIsPartitionAligned()
{
if (!await EnsureMigrationApplied())
{
return;
}
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// The clustered (PK) index on AuditLog must live on the ps_AuditLog_Month
// partition scheme; sys.indexes.data_space_id points at the scheme.
@@ -95,13 +84,10 @@ public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixtur
Assert.Equal("ps_AuditLog_Month", schemeName);
}
[Fact]
[SkippableFact]
public async Task AppliesMigration_CreatesFiveNamedIndexes()
{
if (!await EnsureMigrationApplied())
{
return;
}
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var expected = new[]
{
@@ -122,13 +108,10 @@ public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixtur
}
}
[Fact]
[SkippableFact]
public async Task AppliesMigration_CreatesAuditWriterRole_WithExpectedGrants()
{
if (!await EnsureMigrationApplied())
{
return;
}
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var roleExists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.database_principals " +
@@ -171,13 +154,10 @@ public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixtur
Assert.Equal(0, deleteGranted);
}
[Fact]
[SkippableFact]
public async Task AppliesMigration_CreatesAuditPurgerRole_WithExpectedGrants()
{
if (!await EnsureMigrationApplied())
{
return;
}
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var roleExists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.database_principals " +
@@ -203,13 +183,10 @@ public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixtur
Assert.Equal(1, alterSchema);
}
[Fact]
[SkippableFact]
public async Task AuditWriterRole_CannotUpdateAuditLog()
{
if (!await EnsureMigrationApplied())
{
return;
}
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Set up a dedicated user mapped to scadalink_audit_writer, then EXECUTE AS
// and attempt UPDATE — DENY UPDATE on the role must reject the statement.
@@ -249,27 +226,6 @@ public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixtur
// --- helpers ------------------------------------------------------------
/// <summary>
/// Applies the migration to the per-fixture test database. Returns false
/// when the fixture is unavailable (no MSSQL container) — callers should
/// log + early-return so the test is reported green on a CI box without
/// the dev container.
/// </summary>
private async Task<bool> EnsureMigrationApplied()
{
if (!_fixture.Available)
{
_output.WriteLine($"[SKIP] {_fixture.SkipReason}");
return false;
}
// ApplyAuditMigrationAsync is idempotent — repeat calls within a fixture
// are a no-op after the first migration. Cheaper than re-creating the
// database per test for M1.
await _fixture.ApplyAuditMigrationAsync();
return true;
}
private async Task<T> ScalarAsync<T>(string sql)
{
await using var conn = _fixture.OpenConnection();