From 269adc5c424e7af813dec9acf43fbcdbe8397ec2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:32:50 +0000 Subject: [PATCH 01/10] Make non-SQL-Server API tests runnable without SQL Server (#103) The shared TestWebApplicationFactory booted both a SQL Server and a PostgreSQL container before any test ran, so on a host that cannot run the amd64-only mssql/server image (e.g. ARM64) the whole StrongTypes.Api.IntegrationTests project was unrunnable. Keep a single project (the issue's smaller "Alternative") and gate SQL Server on availability: - PostgreSQL is always required. SQL Server is required too, but a local, non-CI host may opt into skipping it via STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE. The opt-in is ignored under CI (CI / GITHUB_ACTIONS / TF_BUILD), and the default is to require it, so a missing or stray flag always fails safe toward a hard crash, never a silent skip. In CI a SQL Server that fails to start is always fatal. - When skipped, swap SQL Server for an in-memory stub so the dual-write endpoints still boot and PostgreSQL round-trips keep running. The stub is not the real wire path, so SQL-Server-specific assertions are skipped (AssertSqlServerEntity / SkipIfSqlServerUnavailable / SqlServerAvailable) rather than run green against it. No-DB tests (BindingTests, collection-JSON) run on any host. Docs updated in testing.md and CLAUDE.md. https://claude.ai/code/session_01KyMdBJWRXSd4CuYNsUeqG3 --- CLAUDE.md | 7 ++ .../Infrastructure/IntegrationTestBase.cs | 28 ++++++ .../TestWebApplicationFactory.cs | 92 +++++++++++++++++-- .../StrongTypes.Api.IntegrationTests.csproj | 1 + .../Tests/ApiTests/EntityTests.cs | 44 +++++---- .../Emails/MailAddressFilterTests.cs | 8 ++ .../Numeric/NumericUnwrapFilterTests.cs | 8 ++ .../Strings/NonEmptyStringFilterTests.cs | 18 ++++ testing.md | 38 ++++++++ 9 files changed, 217 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 11d54006..9b48db98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,6 +141,13 @@ PostgreSQL (via Testcontainers). Every write endpoint persists to both `DbContext`s and every read endpoint reads from one specific provider, so tests can assert the full wire-to-DB path on each. +Because writes hit both `DbContext`s, the harness needs both providers +present. PostgreSQL is always required; SQL Server is required too, except +that a local (non-CI) host may opt into skipping it when the amd64-only +image won't start — see the "SQL Server availability and skipping" section +of [`testing.md`](testing.md). CI never skips: a SQL Server that fails to +start is always a hard failure there, never a silent skip. + Current state: uses plain `string` / `string?`. Strong-type converters (EF Core value converters, JSON converters) will be wired in once the parallel work on those lands. diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs index edcf81ff..8c19bfeb 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs @@ -27,8 +27,36 @@ public abstract class IntegrationTestBase(TestWebApplicat protected DbSet SqlSet => SqlDb.Set(); protected DbSet PgSet => PgDb.Set(); + /// + /// Whether the SQL Server provider is backed by a real SQL Server on this host. + /// only on a local host that opted into skipping SQL Server. + /// Guard every SQL-Server-specific assertion with this. + /// + protected bool SqlServerAvailable => factory.SqlServerAvailable; + protected static CancellationToken Ct => TestContext.Current.CancellationToken; + /// + /// Asserts the SQL Server row matches when SQL Server is available; a no-op + /// otherwise. The in-memory stub used when SQL Server is skipped does not + /// exercise the real wire path, so asserting against it would be a false pass. + /// + protected async Task AssertSqlServerEntity(Guid id, T expectedValue, TNullable expectedNullableValue) + { + if (!SqlServerAvailable) + { + return; + } + await AssertEntity(SqlSet, id, expectedValue, expectedNullableValue); + } + + /// + /// Skips a provider-parametrized test for the sql-server provider when + /// SQL Server is unavailable on this host; a no-op for any other provider. + /// + protected void SkipIfSqlServerUnavailable(string provider) => + Assert.SkipWhen(provider == "sql-server" && !SqlServerAvailable, "SQL Server is not available on this host."); + /// /// Fetches the entity with the given id from the supplied DbSet and asserts /// that its Value and NullableValue match the expected values. diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs index 36b2cb38..6af9bb6f 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs @@ -1,9 +1,10 @@ using DotNet.Testcontainers.Containers; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using StrongTypes.Api.Data; +using StrongTypes.EfCore; using Testcontainers.MsSql; using Testcontainers.PostgreSql; using Xunit; @@ -11,15 +12,27 @@ namespace StrongTypes.Api.IntegrationTests.Infrastructure; /// -/// Shared fixture that boots both database containers once per test collection, +/// Shared fixture that boots the database containers once per test collection, /// overrides the connection strings via configuration (so Program.cs's DbContext /// registrations pick them up unchanged), and creates the schema via EnsureCreated. /// +/// +/// PostgreSQL is required everywhere. SQL Server is required too — except on a +/// local host that cannot run the amd64-only mssql/server image (e.g. an +/// ARM64 dev box), where it may be skipped via an explicit opt-in. See +/// for what callers must guard, and the +/// STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE env var below for the gate. +/// CI never takes the skip path: there, a SQL Server that fails to start is a +/// hard failure of the whole test run, never a silent skip. +/// public sealed class TestWebApplicationFactory : WebApplicationFactory, IAsyncLifetime { private const string DockerGroupLabel = "com.docker.compose.project"; private const string DockerGroupName = "StrongTypes"; + // Local, non-CI opt-in to skip SQL Server; the gate is SqlServerSkipPermitted. + private const string SkipSqlServerEnvVar = "STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE"; + private static readonly TimeSpan ContainerStartTimeout = TimeSpan.FromSeconds(45); private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder() @@ -30,13 +43,38 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory, .WithLabel(DockerGroupLabel, DockerGroupName) .Build(); + /// + /// Whether the SQL Server container is up and backed by a real SQL Server. + /// only on a local host that opted into skipping and + /// where SQL Server could not start — in CI this is always + /// or the fixture throws. Tests must skip every SQL-Server-specific assertion + /// when this is ; the in-memory stub that keeps the + /// dual-write API booting does not exercise the real SQL Server wire path. + /// + public bool SqlServerAvailable { get; private set; } + async ValueTask IAsyncLifetime.InitializeAsync() { - // Start both containers in parallel; containers must be up before the - // host is built so that ConfigureAppConfiguration can read their connection strings. - await Task.WhenAll( - StartContainerAsync(_sqlContainer, "SQL Server"), - StartContainerAsync(_pgContainer, "PostgreSQL")); + // PostgreSQL is mandatory on every host; a start failure always throws. + await StartContainerAsync(_pgContainer, "PostgreSQL"); + + try + { + await StartContainerAsync(_sqlContainer, "SQL Server"); + SqlServerAvailable = true; + } + catch (Exception ex) + { + if (!SqlServerSkipPermitted) + { + throw new InvalidOperationException( + "The SQL Server test container failed to start and skipping is not permitted in this environment. " + + $"Set {SkipSqlServerEnvVar}=1 to allow skipping the SQL-Server-backed tests on a local, non-CI host " + + "(e.g. an ARM64 box that cannot run the amd64-only mssql/server image). CI always requires SQL Server.", + ex); + } + SqlServerAvailable = false; + } // Accessing Services triggers the lazy host build. using var scope = Services.CreateScope(); @@ -45,6 +83,21 @@ await Task.WhenAll( await sp.GetRequiredService().Database.EnsureCreatedAsync(); } + // Opt-in set AND not CI. Default and any CI env both forbid skipping, so a + // missing or stray flag fails safe toward a hard crash rather than a skip. + private static bool SqlServerSkipPermitted => SkipOptIn && !RunningInCi; + + private static bool SkipOptIn => EnvFlag(SkipSqlServerEnvVar); + + private static bool RunningInCi => + EnvFlag("CI") + || Environment.GetEnvironmentVariable("GITHUB_ACTIONS") is { Length: > 0 } + || Environment.GetEnvironmentVariable("TF_BUILD") is { Length: > 0 }; + + private static bool EnvFlag(string name) => + Environment.GetEnvironmentVariable(name) is { } value + && (value == "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase)); + private static async Task StartContainerAsync(IContainer container, string name) { using var cts = new CancellationTokenSource(ContainerStartTimeout); @@ -67,10 +120,33 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { config.AddInMemoryCollection(new Dictionary { - ["ConnectionStrings:SqlServer"] = _sqlContainer.GetConnectionString(), ["ConnectionStrings:PostgreSql"] = _pgContainer.GetConnectionString(), + ["ConnectionStrings:SqlServer"] = SqlServerAvailable ? _sqlContainer.GetConnectionString() : "unused", }); }); + + if (!SqlServerAvailable) + { + // Swap SQL Server for an in-memory stub so the dual-write endpoints + // still boot. Drop every registration keyed by SqlServerDbContext + // first — otherwise the original UseSqlServer call survives in its + // options-configuration service and collides with the stub provider. + builder.ConfigureServices(services => + { + var stale = services + .Where(d => d.ServiceType == typeof(SqlServerDbContext) + || (d.ServiceType.IsGenericType + && d.ServiceType.GetGenericArguments().Contains(typeof(SqlServerDbContext)))) + .ToList(); + foreach (var descriptor in stale) + { + services.Remove(descriptor); + } + services.AddDbContext(options => options + .UseInMemoryDatabase("sqlserver-unavailable") + .UseStrongTypes()); + }); + } } public override async ValueTask DisposeAsync() diff --git a/src/StrongTypes.Api.IntegrationTests/StrongTypes.Api.IntegrationTests.csproj b/src/StrongTypes.Api.IntegrationTests/StrongTypes.Api.IntegrationTests.csproj index d2d7ecf0..76bb6497 100644 --- a/src/StrongTypes.Api.IntegrationTests/StrongTypes.Api.IntegrationTests.csproj +++ b/src/StrongTypes.Api.IntegrationTests/StrongTypes.Api.IntegrationTests.csproj @@ -13,6 +13,7 @@ + diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.cs index 891d22d9..1b080ecf 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.cs @@ -123,7 +123,7 @@ public async Task ValidInput_PersistsInBothDatabases(TWire value) { var created = await Post(CreateEndpoint, new { value, nullableValue = value }); var expected = Create(value); - await AssertEntity(SqlSet, created.Id, expected, ToNullable(expected)); + await AssertSqlServerEntity(created.Id, expected, ToNullable(expected)); await AssertEntity(PgSet, created.Id, expected, ToNullable(expected)); } @@ -131,7 +131,7 @@ public async Task ValidInput_PersistsInBothDatabases(TWire value) public async Task ValidValueWithNullNullable_PersistsInBothDatabases() { var created = await Post(CreateEndpoint, new { value = (object?)FirstValid, nullableValue = (object?)null }); - await AssertEntity(SqlSet, created.Id, Create(FirstValid), NullNullable); + await AssertSqlServerEntity(created.Id, Create(FirstValid), NullNullable); await AssertEntity(PgSet, created.Id, Create(FirstValid), NullNullable); } @@ -180,10 +180,13 @@ public async Task Get_ReturnsEntityWithCamelCaseJsonFromBothDatabases() await SqlDb.SaveChangesAsync(Ct); await PgDb.SaveChangesAsync(Ct); - var sqlJson = await Get(SqlServerGetEndpoint(entity.Id)); - Assert.Equal(entity.Id, sqlJson.GetProperty("id").GetGuid()); - AssertJsonEquals(sqlJson.GetProperty("value"), FirstValid); - AssertJsonEquals(sqlJson.GetProperty("nullableValue"), FirstValid); + if (SqlServerAvailable) + { + var sqlJson = await Get(SqlServerGetEndpoint(entity.Id)); + Assert.Equal(entity.Id, sqlJson.GetProperty("id").GetGuid()); + AssertJsonEquals(sqlJson.GetProperty("value"), FirstValid); + AssertJsonEquals(sqlJson.GetProperty("nullableValue"), FirstValid); + } var pgJson = await Get(PostgreSqlGetEndpoint(entity.Id)); Assert.Equal(entity.Id, pgJson.GetProperty("id").GetGuid()); @@ -200,9 +203,12 @@ public async Task Get_SerializesNullNullableValueAsJsonNullFromBothDatabases() await SqlDb.SaveChangesAsync(Ct); await PgDb.SaveChangesAsync(Ct); - var sqlJson = await Get(SqlServerGetEndpoint(entity.Id)); - Assert.Equal(JsonValueKind.Null, sqlJson.GetProperty("nullableValue").ValueKind); - AssertJsonEquals(sqlJson.GetProperty("value"), FirstValid); + if (SqlServerAvailable) + { + var sqlJson = await Get(SqlServerGetEndpoint(entity.Id)); + Assert.Equal(JsonValueKind.Null, sqlJson.GetProperty("nullableValue").ValueKind); + AssertJsonEquals(sqlJson.GetProperty("value"), FirstValid); + } var pgJson = await Get(PostgreSqlGetEndpoint(entity.Id)); Assert.Equal(JsonValueKind.Null, pgJson.GetProperty("nullableValue").ValueKind); @@ -218,7 +224,7 @@ public async Task Update_PersistsNewValueAndNullableValueInBothDatabases() await Put(UpdateEndpoint(created.Id), new { value = UpdatedValid, nullableValue = UpdatedValid }); var updated = Create(UpdatedValid); - await AssertEntity(SqlSet, created.Id, updated, ToNullable(updated)); + await AssertSqlServerEntity(created.Id, updated, ToNullable(updated)); await AssertEntity(PgSet, created.Id, updated, ToNullable(updated)); } @@ -228,7 +234,7 @@ public async Task Update_SetsNullableValueFromNullToValueInBothDatabases() var created = await Post(CreateEndpoint, new { value = (object?)FirstValid, nullableValue = (object?)null }); await Put(UpdateEndpoint(created.Id), new { value = (object?)FirstValid, nullableValue = (object?)UpdatedValid }); - await AssertEntity(SqlSet, created.Id, Create(FirstValid), ToNullable(Create(UpdatedValid))); + await AssertSqlServerEntity(created.Id, Create(FirstValid), ToNullable(Create(UpdatedValid))); await AssertEntity(PgSet, created.Id, Create(FirstValid), ToNullable(Create(UpdatedValid))); } @@ -238,7 +244,7 @@ public async Task Update_ClearsNullableValueToNullInBothDatabases() var created = await Post(CreateEndpoint, new { value = FirstValid, nullableValue = FirstValid }); await Put(UpdateEndpoint(created.Id), new { value = (object?)FirstValid, nullableValue = (object?)null }); - await AssertEntity(SqlSet, created.Id, Create(FirstValid), NullNullable); + await AssertSqlServerEntity(created.Id, Create(FirstValid), NullNullable); await AssertEntity(PgSet, created.Id, Create(FirstValid), NullNullable); } @@ -258,7 +264,7 @@ public async Task Patch_EmptyBody_LeavesBothFieldsUnchanged() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var expected = Create(FirstValid); - await AssertEntity(SqlSet, created.Id, expected, ToNullable(expected)); + await AssertSqlServerEntity(created.Id, expected, ToNullable(expected)); await AssertEntity(PgSet, created.Id, expected, ToNullable(expected)); } @@ -270,7 +276,7 @@ public async Task Patch_ValueOnly_UpdatesValueLeavesNullableValueUnchanged() var response = await Patch(PatchEndpoint(created.Id), new { value = UpdatedValid }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - await AssertEntity(SqlSet, created.Id, Create(UpdatedValid), ToNullable(Create(FirstValid))); + await AssertSqlServerEntity(created.Id, Create(UpdatedValid), ToNullable(Create(FirstValid))); await AssertEntity(PgSet, created.Id, Create(UpdatedValid), ToNullable(Create(FirstValid))); } @@ -284,7 +290,7 @@ public async Task Patch_ExplicitNullValue_LeavesValueUnchanged() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var expected = Create(FirstValid); - await AssertEntity(SqlSet, created.Id, expected, ToNullable(expected)); + await AssertSqlServerEntity(created.Id, expected, ToNullable(expected)); await AssertEntity(PgSet, created.Id, expected, ToNullable(expected)); } @@ -296,7 +302,7 @@ public async Task Patch_NullableValueSome_UpdatesNullableValueLeavesValueUnchang var response = await Patch(PatchEndpoint(created.Id), new { nullableValue = new { Value = UpdatedValid } }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - await AssertEntity(SqlSet, created.Id, Create(FirstValid), ToNullable(Create(UpdatedValid))); + await AssertSqlServerEntity(created.Id, Create(FirstValid), ToNullable(Create(UpdatedValid))); await AssertEntity(PgSet, created.Id, Create(FirstValid), ToNullable(Create(UpdatedValid))); } @@ -308,7 +314,7 @@ public async Task Patch_NullableValueEmptyObject_ClearsNullableValue() var response = await Patch(PatchEndpoint(created.Id), new { nullableValue = new { } }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - await AssertEntity(SqlSet, created.Id, Create(FirstValid), NullNullable); + await AssertSqlServerEntity(created.Id, Create(FirstValid), NullNullable); await AssertEntity(PgSet, created.Id, Create(FirstValid), NullNullable); } @@ -320,7 +326,7 @@ public async Task Patch_NullableValueWithExplicitNullInner_ClearsNullableValue() var response = await Patch(PatchEndpoint(created.Id), new { nullableValue = new { Value = (object?)null } }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - await AssertEntity(SqlSet, created.Id, Create(FirstValid), NullNullable); + await AssertSqlServerEntity(created.Id, Create(FirstValid), NullNullable); await AssertEntity(PgSet, created.Id, Create(FirstValid), NullNullable); } @@ -332,7 +338,7 @@ public async Task Patch_UpdatesBothFieldsIndependently() var response = await Patch(PatchEndpoint(created.Id), new { value = UpdatedValid, nullableValue = new { Value = UpdatedValid } }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - await AssertEntity(SqlSet, created.Id, Create(UpdatedValid), ToNullable(Create(UpdatedValid))); + await AssertSqlServerEntity(created.Id, Create(UpdatedValid), ToNullable(Create(UpdatedValid))); await AssertEntity(PgSet, created.Id, Create(UpdatedValid), ToNullable(Create(UpdatedValid))); } diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Emails/MailAddressFilterTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Emails/MailAddressFilterTests.cs index a90cf3cf..5735c089 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Emails/MailAddressFilterTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Emails/MailAddressFilterTests.cs @@ -41,6 +41,8 @@ private async Task Seed(string localPart, MailAddress? nullableValue) [Theory, MemberData(nameof(Providers))] public async Task EqualTo_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var a = await Seed("alpha", null); var b = await Seed("beta", null); var needle = MailAddress.Create($"{Prefix}alpha@example.com"); @@ -54,6 +56,8 @@ public async Task EqualTo_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task UnwrapStartsWith_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var match = await Seed("prefix-match", null); var noMatch = await Seed("other", null); @@ -69,6 +73,8 @@ public async Task UnwrapStartsWith_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task UnwrapContains_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var match = await Seed("needle-haystack", null); var noMatch = await Seed("other", null); @@ -86,6 +92,8 @@ public async Task UnwrapContains_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task EfFunctionsLike_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var match = await Seed("apple", null); var noMatch = await Seed("banana", null); diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Numeric/NumericUnwrapFilterTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Numeric/NumericUnwrapFilterTests.cs index 47c7ec18..6bdb43fd 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Numeric/NumericUnwrapFilterTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Numeric/NumericUnwrapFilterTests.cs @@ -28,6 +28,8 @@ public sealed class NumericUnwrapFilterTests(TestWebApplicationFactory factory) [Theory, MemberData(nameof(Providers))] public async Task Positive_UnwrapArithmetic_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var small = PositiveIntEntity.Create(Positive.Create(3), null); var large = PositiveIntEntity.Create(Positive.Create(7), null); SqlDb.Add(small); SqlDb.Add(large); @@ -49,6 +51,8 @@ public async Task Positive_UnwrapArithmetic_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task NonNegative_UnwrapArithmetic_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var zero = NonNegativeIntEntity.Create(NonNegative.Create(0), null); var big = NonNegativeIntEntity.Create(NonNegative.Create(10), null); SqlDb.Add(zero); SqlDb.Add(big); @@ -70,6 +74,8 @@ public async Task NonNegative_UnwrapArithmetic_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task Negative_UnwrapArithmetic_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var small = NegativeIntEntity.Create(Negative.Create(-1), null); var large = NegativeIntEntity.Create(Negative.Create(-100), null); SqlDb.Add(small); SqlDb.Add(large); @@ -91,6 +97,8 @@ public async Task Negative_UnwrapArithmetic_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task NonPositive_UnwrapArithmetic_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var zero = NonPositiveIntEntity.Create(NonPositive.Create(0), null); var negative = NonPositiveIntEntity.Create(NonPositive.Create(-5), null); SqlDb.Add(zero); SqlDb.Add(negative); diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Strings/NonEmptyStringFilterTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Strings/NonEmptyStringFilterTests.cs index 2ca2795a..6e8b0d6a 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Strings/NonEmptyStringFilterTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ConverterTests/Strings/NonEmptyStringFilterTests.cs @@ -40,6 +40,8 @@ private async Task Seed(string value, NonEmptyString? nullableValue) [Theory, MemberData(nameof(Providers))] public async Task EqualTo_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var a = await Seed(Prefix + "alpha", null); var b = await Seed(Prefix + "beta", null); var needle = NonEmptyString.Create(Prefix + "alpha"); @@ -53,6 +55,8 @@ public async Task EqualTo_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task NotEqualTo_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var a = await Seed(Prefix + "alpha", null); var b = await Seed(Prefix + "beta", null); var needle = NonEmptyString.Create(Prefix + "alpha"); @@ -69,6 +73,8 @@ public async Task NotEqualTo_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task NullNullable_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var withNull = await Seed(Prefix + "null", null); var withValue = await Seed(Prefix + "nonnull", NonEmptyString.Create("x")); @@ -84,6 +90,8 @@ public async Task NullNullable_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task NotNullNullable_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var withNull = await Seed(Prefix + "null", null); var withValue = await Seed(Prefix + "nonnull", NonEmptyString.Create("x")); @@ -99,6 +107,8 @@ public async Task NotNullNullable_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task OrderBy_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var c = await Seed(Prefix + "c", null); var a = await Seed(Prefix + "a", null); var b = await Seed(Prefix + "b", null); @@ -115,6 +125,8 @@ public async Task OrderBy_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task UnwrapContains_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var match = await Seed(Prefix + "needle-haystack", null); var noMatch = await Seed(Prefix + "other", null); @@ -130,6 +142,8 @@ public async Task UnwrapContains_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task UnwrapStartsWith_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var match = await Seed(Prefix + "prefix-match", null); var noMatch = await Seed(Prefix + "other", null); @@ -145,6 +159,8 @@ public async Task UnwrapStartsWith_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task UnwrapEndsWith_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var suffix = $"-{Guid.NewGuid():N}-tail"; var match = await Seed(Prefix + "m" + suffix, null); var noMatch = await Seed(Prefix + "other", null); @@ -163,6 +179,8 @@ public async Task UnwrapEndsWith_TranslatesToSql(string provider) [Theory, MemberData(nameof(Providers))] public async Task EfFunctionsLike_TranslatesToSql(string provider) { + SkipIfSqlServerUnavailable(provider); + var match = await Seed(Prefix + "apple", null); var noMatch = await Seed(Prefix + "banana", null); diff --git a/testing.md b/testing.md index 5ce1b0d0..46dedcc0 100644 --- a/testing.md +++ b/testing.md @@ -91,6 +91,44 @@ persisted state on both `SqlSet` and `PgSet`), invalid payloads returning `400`, and `null` handling for the nullable variant. If the type has a custom JSON converter, add converter-only tests under `Tests/ConverterTests`. +### SQL Server availability and skipping + +The `mcr.microsoft.com/mssql/server` image is amd64-only; on an ARM64 host +(e.g. a Snapdragon dev box) it starts under emulation and `sqlservr` +segfaults, so the container never becomes ready. PostgreSQL has native ARM +images and is **always required**. SQL Server is required too, with one +deliberately narrow escape hatch: + +- **By default — and always in CI — a SQL Server that fails to start is a + hard failure.** The fixture throws and the whole run goes red. There is + no warning-and-continue path. +- **Skipping is opt-in and local-only.** Set + `STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE=1` to let a host fall back to + skipping the SQL-Server-backed assertions when the container can't start. + The opt-in is **ignored under CI** (`CI`, `GITHUB_ACTIONS`, or `TF_BUILD` + set), so a stray value can never downgrade a CI failure into a silent + skip. The default (no opt-in) and the CI guard both fail safe — toward a + crash, never a skip. + +When SQL Server is skipped, the fixture swaps it for an in-memory stub so +the dual-write endpoints still boot and PostgreSQL round-trips keep +running. The stub does **not** exercise the real SQL Server wire path, so +its assertions are *skipped*, never run green against it. Guard SQL-Server +work accordingly: + +- In a test deriving from `IntegrationTestBase`, assert the SQL Server row + via `AssertSqlServerEntity(id, value, nullableValue)` (a no-op when SQL + Server is unavailable) rather than `AssertEntity(SqlSet, …)`; PostgreSQL + assertions stay unconditional via `AssertEntity(PgSet, …)`. +- In a provider-parametrized test (`[Theory]` over `Providers`), call + `SkipIfSqlServerUnavailable(provider)` as the first statement. +- For any other SQL-Server-only assertion, gate it on the + `SqlServerAvailable` property. + +Tests that touch no database (the `BindingTests` and the collection-JSON +round-trips) need neither provider's assertions and run on any host once +the PostgreSQL container is up. + ## OpenAPI integration tests — `StrongTypes.OpenApi.IntegrationTests` Verifies the schema each type produces in **both** supported OpenAPI From 5b8d9110f682a0709c84be4dccb1e40979c09327 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 06:11:56 +0000 Subject: [PATCH 02/10] Restore Microsoft.AspNetCore.Mvc.Testing using for WebApplicationFactory Dropped while rewriting the fixture; WebApplicationFactory lives there, so the test project failed to compile. https://claude.ai/code/session_01KyMdBJWRXSd4CuYNsUeqG3 --- .../Infrastructure/TestWebApplicationFactory.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs index 6af9bb6f..51102463 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs @@ -1,5 +1,6 @@ using DotNet.Testcontainers.Containers; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; From d4c2968b67bf371f00b5aa2f41f93ace684c2b22 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 29 May 2026 09:10:55 +0200 Subject: [PATCH 03/10] Collapse dual-provider entity asserts into one helper Replace AssertSqlServerEntity with a single AssertEntity(id, value, nullableValue) on IntegrationTestBase that asserts PostgreSQL unconditionally and SQL Server only when available, folding the skip logic out of the test bodies. Update the 12 paired call sites in EntityTests and the testing.md guidance. Co-Authored-By: Claude Opus 4.8 --- .../Infrastructure/IntegrationTestBase.cs | 21 +++++------ .../Tests/ApiTests/EntityTests.cs | 36 +++++++------------ testing.md | 9 ++--- 3 files changed, 26 insertions(+), 40 deletions(-) diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs index 8c19bfeb..23b4b837 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs @@ -37,17 +37,18 @@ public abstract class IntegrationTestBase(TestWebApplicat protected static CancellationToken Ct => TestContext.Current.CancellationToken; /// - /// Asserts the SQL Server row matches when SQL Server is available; a no-op - /// otherwise. The in-memory stub used when SQL Server is skipped does not - /// exercise the real wire path, so asserting against it would be a false pass. + /// Asserts the persisted row matches in both providers: PostgreSQL always, and + /// SQL Server only when it is available on this host. When SQL Server is skipped + /// its in-memory stub is not asserted against — it does not exercise the real + /// wire path, so a match there would be a false pass. /// - protected async Task AssertSqlServerEntity(Guid id, T expectedValue, TNullable expectedNullableValue) + protected async Task AssertEntity(Guid id, T expectedValue, TNullable expectedNullableValue) { - if (!SqlServerAvailable) + await AssertEntity(PgSet, id, expectedValue, expectedNullableValue); + if (SqlServerAvailable) { - return; + await AssertEntity(SqlSet, id, expectedValue, expectedNullableValue); } - await AssertEntity(SqlSet, id, expectedValue, expectedNullableValue); } /// @@ -57,11 +58,7 @@ protected async Task AssertSqlServerEntity(Guid id, T expectedValue, TNullable e protected void SkipIfSqlServerUnavailable(string provider) => Assert.SkipWhen(provider == "sql-server" && !SqlServerAvailable, "SQL Server is not available on this host."); - /// - /// Fetches the entity with the given id from the supplied DbSet and asserts - /// that its Value and NullableValue match the expected values. - /// - protected static async Task AssertEntity( + private static async Task AssertEntity( DbSet set, Guid id, T expectedValue, diff --git a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.cs b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.cs index 1b080ecf..3fe6799c 100644 --- a/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.cs +++ b/src/StrongTypes.Api.IntegrationTests/Tests/ApiTests/EntityTests.cs @@ -123,16 +123,14 @@ public async Task ValidInput_PersistsInBothDatabases(TWire value) { var created = await Post(CreateEndpoint, new { value, nullableValue = value }); var expected = Create(value); - await AssertSqlServerEntity(created.Id, expected, ToNullable(expected)); - await AssertEntity(PgSet, created.Id, expected, ToNullable(expected)); + await AssertEntity(created.Id, expected, ToNullable(expected)); } [Fact] public async Task ValidValueWithNullNullable_PersistsInBothDatabases() { var created = await Post(CreateEndpoint, new { value = (object?)FirstValid, nullableValue = (object?)null }); - await AssertSqlServerEntity(created.Id, Create(FirstValid), NullNullable); - await AssertEntity(PgSet, created.Id, Create(FirstValid), NullNullable); + await AssertEntity(created.Id, Create(FirstValid), NullNullable); } // ── Create: invalid (non-null) ─────────────────────────────────────── @@ -224,8 +222,7 @@ public async Task Update_PersistsNewValueAndNullableValueInBothDatabases() await Put(UpdateEndpoint(created.Id), new { value = UpdatedValid, nullableValue = UpdatedValid }); var updated = Create(UpdatedValid); - await AssertSqlServerEntity(created.Id, updated, ToNullable(updated)); - await AssertEntity(PgSet, created.Id, updated, ToNullable(updated)); + await AssertEntity(created.Id, updated, ToNullable(updated)); } [Fact] @@ -234,8 +231,7 @@ public async Task Update_SetsNullableValueFromNullToValueInBothDatabases() var created = await Post(CreateEndpoint, new { value = (object?)FirstValid, nullableValue = (object?)null }); await Put(UpdateEndpoint(created.Id), new { value = (object?)FirstValid, nullableValue = (object?)UpdatedValid }); - await AssertSqlServerEntity(created.Id, Create(FirstValid), ToNullable(Create(UpdatedValid))); - await AssertEntity(PgSet, created.Id, Create(FirstValid), ToNullable(Create(UpdatedValid))); + await AssertEntity(created.Id, Create(FirstValid), ToNullable(Create(UpdatedValid))); } [Fact] @@ -244,8 +240,7 @@ public async Task Update_ClearsNullableValueToNullInBothDatabases() var created = await Post(CreateEndpoint, new { value = FirstValid, nullableValue = FirstValid }); await Put(UpdateEndpoint(created.Id), new { value = (object?)FirstValid, nullableValue = (object?)null }); - await AssertSqlServerEntity(created.Id, Create(FirstValid), NullNullable); - await AssertEntity(PgSet, created.Id, Create(FirstValid), NullNullable); + await AssertEntity(created.Id, Create(FirstValid), NullNullable); } // ── Patch ──────────────────────────────────────────────────────────── @@ -264,8 +259,7 @@ public async Task Patch_EmptyBody_LeavesBothFieldsUnchanged() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var expected = Create(FirstValid); - await AssertSqlServerEntity(created.Id, expected, ToNullable(expected)); - await AssertEntity(PgSet, created.Id, expected, ToNullable(expected)); + await AssertEntity(created.Id, expected, ToNullable(expected)); } [Fact] @@ -276,8 +270,7 @@ public async Task Patch_ValueOnly_UpdatesValueLeavesNullableValueUnchanged() var response = await Patch(PatchEndpoint(created.Id), new { value = UpdatedValid }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - await AssertSqlServerEntity(created.Id, Create(UpdatedValid), ToNullable(Create(FirstValid))); - await AssertEntity(PgSet, created.Id, Create(UpdatedValid), ToNullable(Create(FirstValid))); + await AssertEntity(created.Id, Create(UpdatedValid), ToNullable(Create(FirstValid))); } [Fact] @@ -290,8 +283,7 @@ public async Task Patch_ExplicitNullValue_LeavesValueUnchanged() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var expected = Create(FirstValid); - await AssertSqlServerEntity(created.Id, expected, ToNullable(expected)); - await AssertEntity(PgSet, created.Id, expected, ToNullable(expected)); + await AssertEntity(created.Id, expected, ToNullable(expected)); } [Fact] @@ -302,8 +294,7 @@ public async Task Patch_NullableValueSome_UpdatesNullableValueLeavesValueUnchang var response = await Patch(PatchEndpoint(created.Id), new { nullableValue = new { Value = UpdatedValid } }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - await AssertSqlServerEntity(created.Id, Create(FirstValid), ToNullable(Create(UpdatedValid))); - await AssertEntity(PgSet, created.Id, Create(FirstValid), ToNullable(Create(UpdatedValid))); + await AssertEntity(created.Id, Create(FirstValid), ToNullable(Create(UpdatedValid))); } [Fact] @@ -314,8 +305,7 @@ public async Task Patch_NullableValueEmptyObject_ClearsNullableValue() var response = await Patch(PatchEndpoint(created.Id), new { nullableValue = new { } }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - await AssertSqlServerEntity(created.Id, Create(FirstValid), NullNullable); - await AssertEntity(PgSet, created.Id, Create(FirstValid), NullNullable); + await AssertEntity(created.Id, Create(FirstValid), NullNullable); } [Fact] @@ -326,8 +316,7 @@ public async Task Patch_NullableValueWithExplicitNullInner_ClearsNullableValue() var response = await Patch(PatchEndpoint(created.Id), new { nullableValue = new { Value = (object?)null } }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - await AssertSqlServerEntity(created.Id, Create(FirstValid), NullNullable); - await AssertEntity(PgSet, created.Id, Create(FirstValid), NullNullable); + await AssertEntity(created.Id, Create(FirstValid), NullNullable); } [Fact] @@ -338,8 +327,7 @@ public async Task Patch_UpdatesBothFieldsIndependently() var response = await Patch(PatchEndpoint(created.Id), new { value = UpdatedValid, nullableValue = new { Value = UpdatedValid } }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - await AssertSqlServerEntity(created.Id, Create(UpdatedValid), ToNullable(Create(UpdatedValid))); - await AssertEntity(PgSet, created.Id, Create(UpdatedValid), ToNullable(Create(UpdatedValid))); + await AssertEntity(created.Id, Create(UpdatedValid), ToNullable(Create(UpdatedValid))); } [Fact] diff --git a/testing.md b/testing.md index 46dedcc0..98c58d4c 100644 --- a/testing.md +++ b/testing.md @@ -116,10 +116,11 @@ running. The stub does **not** exercise the real SQL Server wire path, so its assertions are *skipped*, never run green against it. Guard SQL-Server work accordingly: -- In a test deriving from `IntegrationTestBase`, assert the SQL Server row - via `AssertSqlServerEntity(id, value, nullableValue)` (a no-op when SQL - Server is unavailable) rather than `AssertEntity(SqlSet, …)`; PostgreSQL - assertions stay unconditional via `AssertEntity(PgSet, …)`. +- In a test deriving from `IntegrationTestBase`, assert the persisted row + via `AssertEntity(id, value, nullableValue)`. It checks PostgreSQL + unconditionally and SQL Server only when it is available on this host, so + a single call covers both providers without leaking the skip logic into + the test body. - In a provider-parametrized test (`[Theory]` over `Providers`), call `SkipIfSqlServerUnavailable(provider)` as the first statement. - For any other SQL-Server-only assertion, gate it on the From 54fd4d3ab15d67e23e01e8f9b5d91e358ff2d53b Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 29 May 2026 09:19:04 +0200 Subject: [PATCH 04/10] Remove CI guard from SQL Server skip gate Skipping is now governed solely by the STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE opt-in; drop the RunningInCi check (CI/GITHUB_ACTIONS/TF_BUILD) that previously suppressed the flag. The flag is honoured wherever it is set, so it must not be set in CI. Update CLAUDE.md and testing.md to drop the "CI never skips" guarantee. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 8 ++-- .../TestWebApplicationFactory.cs | 39 ++++++++----------- testing.md | 19 +++++---- 3 files changed, 29 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b48db98..6ac5437d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,10 +143,10 @@ so tests can assert the full wire-to-DB path on each. Because writes hit both `DbContext`s, the harness needs both providers present. PostgreSQL is always required; SQL Server is required too, except -that a local (non-CI) host may opt into skipping it when the amd64-only -image won't start — see the "SQL Server availability and skipping" section -of [`testing.md`](testing.md). CI never skips: a SQL Server that fails to -start is always a hard failure there, never a silent skip. +that a host may opt into skipping it when the amd64-only image won't start +— see the "SQL Server availability and skipping" section of +[`testing.md`](testing.md). Absent the opt-in, a SQL Server that fails to +start is always a hard failure, never a silent skip. Current state: uses plain `string` / `string?`. Strong-type converters (EF Core value converters, JSON converters) will be wired in once the diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs index 51102463..dcc79b27 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs @@ -19,19 +19,19 @@ namespace StrongTypes.Api.IntegrationTests.Infrastructure; /// /// /// PostgreSQL is required everywhere. SQL Server is required too — except on a -/// local host that cannot run the amd64-only mssql/server image (e.g. an -/// ARM64 dev box), where it may be skipped via an explicit opt-in. See +/// host that cannot run the amd64-only mssql/server image (e.g. an ARM64 +/// dev box), where it may be skipped via an explicit opt-in. See /// for what callers must guard, and the /// STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE env var below for the gate. -/// CI never takes the skip path: there, a SQL Server that fails to start is a -/// hard failure of the whole test run, never a silent skip. +/// Absent the opt-in, a SQL Server that fails to start is a hard failure of the +/// whole test run, never a silent skip. /// public sealed class TestWebApplicationFactory : WebApplicationFactory, IAsyncLifetime { private const string DockerGroupLabel = "com.docker.compose.project"; private const string DockerGroupName = "StrongTypes"; - // Local, non-CI opt-in to skip SQL Server; the gate is SqlServerSkipPermitted. + // Opt-in to skip SQL Server when it can't start; the gate is SqlServerSkipPermitted. private const string SkipSqlServerEnvVar = "STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE"; private static readonly TimeSpan ContainerStartTimeout = TimeSpan.FromSeconds(45); @@ -46,11 +46,11 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory, /// /// Whether the SQL Server container is up and backed by a real SQL Server. - /// only on a local host that opted into skipping and - /// where SQL Server could not start — in CI this is always - /// or the fixture throws. Tests must skip every SQL-Server-specific assertion - /// when this is ; the in-memory stub that keeps the - /// dual-write API booting does not exercise the real SQL Server wire path. + /// only on a host that opted into skipping and where + /// SQL Server could not start — without the opt-in the fixture throws instead. + /// Tests must skip every SQL-Server-specific assertion when this is + /// ; the in-memory stub that keeps the dual-write API + /// booting does not exercise the real SQL Server wire path. /// public bool SqlServerAvailable { get; private set; } @@ -69,9 +69,9 @@ async ValueTask IAsyncLifetime.InitializeAsync() if (!SqlServerSkipPermitted) { throw new InvalidOperationException( - "The SQL Server test container failed to start and skipping is not permitted in this environment. " + - $"Set {SkipSqlServerEnvVar}=1 to allow skipping the SQL-Server-backed tests on a local, non-CI host " + - "(e.g. an ARM64 box that cannot run the amd64-only mssql/server image). CI always requires SQL Server.", + "The SQL Server test container failed to start and skipping is not permitted. " + + $"Set {SkipSqlServerEnvVar}=1 to allow skipping the SQL-Server-backed tests on a host " + + "that cannot run the amd64-only mssql/server image (e.g. an ARM64 box).", ex); } SqlServerAvailable = false; @@ -84,16 +84,9 @@ async ValueTask IAsyncLifetime.InitializeAsync() await sp.GetRequiredService().Database.EnsureCreatedAsync(); } - // Opt-in set AND not CI. Default and any CI env both forbid skipping, so a - // missing or stray flag fails safe toward a hard crash rather than a skip. - private static bool SqlServerSkipPermitted => SkipOptIn && !RunningInCi; - - private static bool SkipOptIn => EnvFlag(SkipSqlServerEnvVar); - - private static bool RunningInCi => - EnvFlag("CI") - || Environment.GetEnvironmentVariable("GITHUB_ACTIONS") is { Length: > 0 } - || Environment.GetEnvironmentVariable("TF_BUILD") is { Length: > 0 }; + // Skipping is opt-in via the env flag. Absent the flag, a SQL Server that + // fails to start is a hard crash — the default fails safe toward a crash. + private static bool SqlServerSkipPermitted => EnvFlag(SkipSqlServerEnvVar); private static bool EnvFlag(string name) => Environment.GetEnvironmentVariable(name) is { } value diff --git a/testing.md b/testing.md index 98c58d4c..502f7864 100644 --- a/testing.md +++ b/testing.md @@ -99,16 +99,15 @@ segfaults, so the container never becomes ready. PostgreSQL has native ARM images and is **always required**. SQL Server is required too, with one deliberately narrow escape hatch: -- **By default — and always in CI — a SQL Server that fails to start is a - hard failure.** The fixture throws and the whole run goes red. There is - no warning-and-continue path. -- **Skipping is opt-in and local-only.** Set - `STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE=1` to let a host fall back to - skipping the SQL-Server-backed assertions when the container can't start. - The opt-in is **ignored under CI** (`CI`, `GITHUB_ACTIONS`, or `TF_BUILD` - set), so a stray value can never downgrade a CI failure into a silent - skip. The default (no opt-in) and the CI guard both fail safe — toward a - crash, never a skip. +- **By default a SQL Server that fails to start is a hard failure.** The + fixture throws and the whole run goes red. There is no + warning-and-continue path. +- **Skipping is opt-in.** Set `STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE=1` + (or `true`) to let a host fall back to skipping the SQL-Server-backed + assertions when the container can't start. Without the opt-in the fixture + fails safe — toward a crash, never a skip. The flag is honoured wherever + it is set, so do not set it in CI: there is no separate CI guard, and a + stray value will silently downgrade a CI failure into a skip. When SQL Server is skipped, the fixture swaps it for an in-memory stub so the dual-write endpoints still boot and PostgreSQL round-trips keep From ad298f3add7a9821ee7513fd10044163a01c4fe5 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 29 May 2026 09:22:26 +0200 Subject: [PATCH 05/10] Inline the SQL Server skip flag check Fold the single-use EnvFlag helper into SqlServerSkipPermitted and accept only "1" (drop the "true" alias). Update testing.md accordingly. Co-Authored-By: Claude Opus 4.8 --- .../Infrastructure/TestWebApplicationFactory.cs | 6 +----- testing.md | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs index dcc79b27..40f08ef2 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs @@ -86,11 +86,7 @@ async ValueTask IAsyncLifetime.InitializeAsync() // Skipping is opt-in via the env flag. Absent the flag, a SQL Server that // fails to start is a hard crash — the default fails safe toward a crash. - private static bool SqlServerSkipPermitted => EnvFlag(SkipSqlServerEnvVar); - - private static bool EnvFlag(string name) => - Environment.GetEnvironmentVariable(name) is { } value - && (value == "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase)); + private static bool SqlServerSkipPermitted => Environment.GetEnvironmentVariable(SkipSqlServerEnvVar) == "1"; private static async Task StartContainerAsync(IContainer container, string name) { diff --git a/testing.md b/testing.md index 502f7864..94120e48 100644 --- a/testing.md +++ b/testing.md @@ -103,8 +103,8 @@ deliberately narrow escape hatch: fixture throws and the whole run goes red. There is no warning-and-continue path. - **Skipping is opt-in.** Set `STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE=1` - (or `true`) to let a host fall back to skipping the SQL-Server-backed - assertions when the container can't start. Without the opt-in the fixture + to let a host fall back to skipping the SQL-Server-backed assertions when + the container can't start. Without the opt-in the fixture fails safe — toward a crash, never a skip. The flag is honoured wherever it is set, so do not set it in CI: there is no separate CI guard, and a stray value will silently downgrade a CI failure into a skip. From f6e1cd461fa39bf70a889e683abf83611727396d Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 29 May 2026 09:27:49 +0200 Subject: [PATCH 06/10] Skip SQL Server up front and start containers in parallel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decide the SQL Server skip from the env flag before touching containers, so an opt-in never starts the container at all — no more try/catch that inspects the flag only after a start failure. Rename the flag to STRONGTYPES_SKIP_SQLSERVER to match the unconditional "skip entirely" semantics. Start PostgreSQL and SQL Server concurrently via Task.WhenAll, and surface the skip hint through the timeout exception message. Co-Authored-By: Claude Opus 4.8 --- .../TestWebApplicationFactory.cs | 62 ++++++++----------- testing.md | 18 +++--- 2 files changed, 36 insertions(+), 44 deletions(-) diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs index 40f08ef2..c26c0ded 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs @@ -20,19 +20,19 @@ namespace StrongTypes.Api.IntegrationTests.Infrastructure; /// /// PostgreSQL is required everywhere. SQL Server is required too — except on a /// host that cannot run the amd64-only mssql/server image (e.g. an ARM64 -/// dev box), where it may be skipped via an explicit opt-in. See +/// dev box), where it can be skipped via an explicit opt-in. See /// for what callers must guard, and the -/// STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE env var below for the gate. -/// Absent the opt-in, a SQL Server that fails to start is a hard failure of the -/// whole test run, never a silent skip. +/// STRONGTYPES_SKIP_SQLSERVER env var below for the gate. Absent the +/// opt-in the container is started, and any failure to start is a hard failure +/// of the whole test run, never a silent skip. /// public sealed class TestWebApplicationFactory : WebApplicationFactory, IAsyncLifetime { private const string DockerGroupLabel = "com.docker.compose.project"; private const string DockerGroupName = "StrongTypes"; - // Opt-in to skip SQL Server when it can't start; the gate is SqlServerSkipPermitted. - private const string SkipSqlServerEnvVar = "STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE"; + // Opt-in to skip SQL Server entirely (the container is never started). + private const string SkipSqlServerEnvVar = "STRONGTYPES_SKIP_SQLSERVER"; private static readonly TimeSpan ContainerStartTimeout = TimeSpan.FromSeconds(45); @@ -46,36 +46,28 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory, /// /// Whether the SQL Server container is up and backed by a real SQL Server. - /// only on a host that opted into skipping and where - /// SQL Server could not start — without the opt-in the fixture throws instead. - /// Tests must skip every SQL-Server-specific assertion when this is - /// ; the in-memory stub that keeps the dual-write API - /// booting does not exercise the real SQL Server wire path. + /// only on a host that opted into skipping via the + /// env var, where the container is never started. Tests must skip every + /// SQL-Server-specific assertion when this is ; the + /// in-memory stub that keeps the dual-write API booting does not exercise + /// the real SQL Server wire path. /// public bool SqlServerAvailable { get; private set; } async ValueTask IAsyncLifetime.InitializeAsync() { - // PostgreSQL is mandatory on every host; a start failure always throws. - await StartContainerAsync(_pgContainer, "PostgreSQL"); + SqlServerAvailable = !SqlServerSkipped; - try - { - await StartContainerAsync(_sqlContainer, "SQL Server"); - SqlServerAvailable = true; - } - catch (Exception ex) + // PostgreSQL is mandatory on every host; SQL Server too unless skipped. + // Start them concurrently — a start failure or timeout on either throws. + var startups = new List { StartContainerAsync(_pgContainer, "PostgreSQL") }; + if (SqlServerAvailable) { - if (!SqlServerSkipPermitted) - { - throw new InvalidOperationException( - "The SQL Server test container failed to start and skipping is not permitted. " + - $"Set {SkipSqlServerEnvVar}=1 to allow skipping the SQL-Server-backed tests on a host " + - "that cannot run the amd64-only mssql/server image (e.g. an ARM64 box).", - ex); - } - SqlServerAvailable = false; + startups.Add(StartContainerAsync(_sqlContainer, "SQL Server", + $"If this host cannot run the amd64-only mssql/server image (e.g. an ARM64 box), " + + $"set {SkipSqlServerEnvVar}=1 to skip the SQL-Server-backed tests.")); } + await Task.WhenAll(startups); // Accessing Services triggers the lazy host build. using var scope = Services.CreateScope(); @@ -84,11 +76,11 @@ async ValueTask IAsyncLifetime.InitializeAsync() await sp.GetRequiredService().Database.EnsureCreatedAsync(); } - // Skipping is opt-in via the env flag. Absent the flag, a SQL Server that - // fails to start is a hard crash — the default fails safe toward a crash. - private static bool SqlServerSkipPermitted => Environment.GetEnvironmentVariable(SkipSqlServerEnvVar) == "1"; + // Skipping SQL Server is opt-in; absent the flag, the container is started + // and any start failure or timeout is a hard crash. + private static bool SqlServerSkipped => Environment.GetEnvironmentVariable(SkipSqlServerEnvVar) == "1"; - private static async Task StartContainerAsync(IContainer container, string name) + private static async Task StartContainerAsync(IContainer container, string name, string? skipHint = null) { using var cts = new CancellationTokenSource(ContainerStartTimeout); try @@ -98,9 +90,9 @@ private static async Task StartContainerAsync(IContainer container, string name) catch (OperationCanceledException) when (cts.IsCancellationRequested) { throw new TimeoutException( - $"The {name} test container did not start within {ContainerStartTimeout.TotalSeconds:0}s. " + - "It either failed to start or never began accepting connections — check the container logs. " + - "On ARM64 hosts this can also happen when the image has no native ARM build, as the emulated process may crash on startup."); + $"The {name} test container did not start within {ContainerStartTimeout.TotalSeconds:0}s. " + + "It either failed to start or never began accepting connections — check the container logs." + + (skipHint is null ? "" : " " + skipHint)); } } diff --git a/testing.md b/testing.md index 94120e48..c31e856c 100644 --- a/testing.md +++ b/testing.md @@ -99,15 +99,15 @@ segfaults, so the container never becomes ready. PostgreSQL has native ARM images and is **always required**. SQL Server is required too, with one deliberately narrow escape hatch: -- **By default a SQL Server that fails to start is a hard failure.** The - fixture throws and the whole run goes red. There is no - warning-and-continue path. -- **Skipping is opt-in.** Set `STRONGTYPES_SKIP_SQLSERVER_IF_UNAVAILABLE=1` - to let a host fall back to skipping the SQL-Server-backed assertions when - the container can't start. Without the opt-in the fixture - fails safe — toward a crash, never a skip. The flag is honoured wherever - it is set, so do not set it in CI: there is no separate CI guard, and a - stray value will silently downgrade a CI failure into a skip. +- **By default the SQL Server container is started and any failure to start + is a hard failure.** The fixture throws and the whole run goes red. There + is no warning-and-continue path. +- **Skipping is opt-in.** Set `STRONGTYPES_SKIP_SQLSERVER=1` to skip SQL + Server entirely — the container is never started and the SQL-Server-backed + assertions are skipped. Without the opt-in the fixture fails safe — toward + a crash, never a skip. The flag is honoured wherever it is set, so do not + set it in CI: there is no separate CI guard, and a stray value will + silently downgrade a CI failure into a skip. When SQL Server is skipped, the fixture swaps it for an in-memory stub so the dual-write endpoints still boot and PostgreSQL round-trips keep From cd4c674c9abfa78e4381656e8b7d5b9ac46a9130 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 29 May 2026 09:56:18 +0200 Subject: [PATCH 07/10] Catch all container start failures and wrap with guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testcontainers throws a bare TimeoutException from its own wait-strategy timers (e.g. the 15s port-binding wait), independent of our CancellationTokenSource — so a catch filtered on OperationCanceledException or gated on cts.IsCancellationRequested let it escape unwrapped. Catch any exception from StartAsync, wrap it with the container name and skip hint, and pass the original as the inner exception. Co-Authored-By: Claude Opus 4.8 --- .../Infrastructure/TestWebApplicationFactory.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs index c26c0ded..5dc62fdc 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs @@ -34,7 +34,7 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory, // Opt-in to skip SQL Server entirely (the container is never started). private const string SkipSqlServerEnvVar = "STRONGTYPES_SKIP_SQLSERVER"; - private static readonly TimeSpan ContainerStartTimeout = TimeSpan.FromSeconds(45); + private static readonly TimeSpan ContainerStartTimeout = TimeSpan.FromSeconds(30); private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder() .WithLabel(DockerGroupLabel, DockerGroupName) @@ -87,12 +87,12 @@ private static async Task StartContainerAsync(IContainer container, string name, { await container.StartAsync(cts.Token); } - catch (OperationCanceledException) when (cts.IsCancellationRequested) + catch (Exception e) { - throw new TimeoutException( - $"The {name} test container did not start within {ContainerStartTimeout.TotalSeconds:0}s. " - + "It either failed to start or never began accepting connections — check the container logs." - + (skipHint is null ? "" : " " + skipHint)); + throw new Exception( + $"The {name} test container failed to start — check the container logs." + + (skipHint is null ? "" : " " + skipHint), + e); } } From 1baf1dc2d6e7071c5455b40257ae63166775809f Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 29 May 2026 10:00:10 +0200 Subject: [PATCH 08/10] Inline the single-use SqlServerSkipped check Fold the SqlServerSkipped property into the one InitializeAsync call site. Co-Authored-By: Claude Opus 4.8 --- .../Infrastructure/TestWebApplicationFactory.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs index 5dc62fdc..76abd377 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs @@ -56,7 +56,9 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory, async ValueTask IAsyncLifetime.InitializeAsync() { - SqlServerAvailable = !SqlServerSkipped; + // Skipping SQL Server is opt-in; absent the flag the container is started + // and any failure to start is a hard crash (see StartContainerAsync). + SqlServerAvailable = Environment.GetEnvironmentVariable(SkipSqlServerEnvVar) != "1"; // PostgreSQL is mandatory on every host; SQL Server too unless skipped. // Start them concurrently — a start failure or timeout on either throws. @@ -76,10 +78,6 @@ async ValueTask IAsyncLifetime.InitializeAsync() await sp.GetRequiredService().Database.EnsureCreatedAsync(); } - // Skipping SQL Server is opt-in; absent the flag, the container is started - // and any start failure or timeout is a hard crash. - private static bool SqlServerSkipped => Environment.GetEnvironmentVariable(SkipSqlServerEnvVar) == "1"; - private static async Task StartContainerAsync(IContainer container, string name, string? skipHint = null) { using var cts = new CancellationTokenSource(ContainerStartTimeout); From 5d0fc90c899c06c4e712e6b7e8ff6bd20b387414 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 29 May 2026 10:03:15 +0200 Subject: [PATCH 09/10] Compact the SQL Server availability section in testing.md Co-Authored-By: Claude Opus 4.8 --- testing.md | 42 +++++++++++++++--------------------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/testing.md b/testing.md index c31e856c..62488131 100644 --- a/testing.md +++ b/testing.md @@ -93,37 +93,25 @@ custom JSON converter, add converter-only tests under `Tests/ConverterTests`. ### SQL Server availability and skipping -The `mcr.microsoft.com/mssql/server` image is amd64-only; on an ARM64 host -(e.g. a Snapdragon dev box) it starts under emulation and `sqlservr` -segfaults, so the container never becomes ready. PostgreSQL has native ARM -images and is **always required**. SQL Server is required too, with one -deliberately narrow escape hatch: - -- **By default the SQL Server container is started and any failure to start - is a hard failure.** The fixture throws and the whole run goes red. There - is no warning-and-continue path. -- **Skipping is opt-in.** Set `STRONGTYPES_SKIP_SQLSERVER=1` to skip SQL - Server entirely — the container is never started and the SQL-Server-backed - assertions are skipped. Without the opt-in the fixture fails safe — toward - a crash, never a skip. The flag is honoured wherever it is set, so do not - set it in CI: there is no separate CI guard, and a stray value will - silently downgrade a CI failure into a skip. - -When SQL Server is skipped, the fixture swaps it for an in-memory stub so -the dual-write endpoints still boot and PostgreSQL round-trips keep -running. The stub does **not** exercise the real SQL Server wire path, so -its assertions are *skipped*, never run green against it. Guard SQL-Server -work accordingly: +The `mcr.microsoft.com/mssql/server` image is amd64-only, so on an ARM64 +host (e.g. a Snapdragon dev box) `sqlservr` segfaults under emulation and +the container never starts. PostgreSQL has native ARM images and is +**always required**; SQL Server is too, with one narrow escape hatch: set +`STRONGTYPES_SKIP_SQLSERVER=1` to skip it entirely (the container is never +started). Without the opt-in, a SQL Server that fails to start is a hard +failure — the fixture throws and the run goes red. The flag is honoured +wherever it is set, so don't set it in CI: there is no separate CI guard. + +When skipped, the fixture swaps in an in-memory stub so the dual-write +endpoints still boot, but it does **not** exercise the real SQL Server wire +path — guard every SQL-Server assertion accordingly: - In a test deriving from `IntegrationTestBase`, assert the persisted row - via `AssertEntity(id, value, nullableValue)`. It checks PostgreSQL - unconditionally and SQL Server only when it is available on this host, so - a single call covers both providers without leaking the skip logic into - the test body. + via `AssertEntity(id, value, nullableValue)` — it checks PostgreSQL + unconditionally and SQL Server only when available. - In a provider-parametrized test (`[Theory]` over `Providers`), call `SkipIfSqlServerUnavailable(provider)` as the first statement. -- For any other SQL-Server-only assertion, gate it on the - `SqlServerAvailable` property. +- For any other SQL-Server-only assertion, gate it on `SqlServerAvailable`. Tests that touch no database (the `BindingTests` and the collection-JSON round-trips) need neither provider's assertions and run on any host once From 199cd8f088e2fcb70166c0db32ca1aed670b3c6b Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 29 May 2026 10:07:07 +0200 Subject: [PATCH 10/10] Increased timeout duration --- .../Infrastructure/TestWebApplicationFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs index 76abd377..009a646c 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs @@ -34,7 +34,7 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory, // Opt-in to skip SQL Server entirely (the container is never started). private const string SkipSqlServerEnvVar = "STRONGTYPES_SKIP_SQLSERVER"; - private static readonly TimeSpan ContainerStartTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan ContainerStartTimeout = TimeSpan.FromSeconds(45); private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder() .WithLabel(DockerGroupLabel, DockerGroupName)