diff --git a/CLAUDE.md b/CLAUDE.md index 11d54006..6ac5437d 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 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 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..23b4b837 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/IntegrationTestBase.cs @@ -27,13 +27,38 @@ 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; /// - /// Fetches the entity with the given id from the supplied DbSet and asserts - /// that its Value and NullableValue match the expected values. + /// 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 static async Task AssertEntity( + protected async Task AssertEntity(Guid id, T expectedValue, TNullable expectedNullableValue) + { + await AssertEntity(PgSet, id, expectedValue, expectedNullableValue); + if (SqlServerAvailable) + { + 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."); + + private static async Task AssertEntity( DbSet set, Guid id, T expectedValue, diff --git a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs index 36b2cb38..009a646c 100644 --- a/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs +++ b/src/StrongTypes.Api.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs @@ -1,9 +1,11 @@ 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 +13,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 +/// host that cannot run the amd64-only mssql/server image (e.g. an ARM64 +/// dev box), where it can be skipped via an explicit opt-in. See +/// for what callers must guard, and the +/// 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 entirely (the container is never started). + private const string SkipSqlServerEnvVar = "STRONGTYPES_SKIP_SQLSERVER"; + private static readonly TimeSpan ContainerStartTimeout = TimeSpan.FromSeconds(45); private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder() @@ -30,13 +44,32 @@ 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 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() { - // 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")); + // 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. + var startups = new List { StartContainerAsync(_pgContainer, "PostgreSQL") }; + if (SqlServerAvailable) + { + 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(); @@ -45,19 +78,19 @@ await Task.WhenAll( await sp.GetRequiredService().Database.EnsureCreatedAsync(); } - 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 { 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. " + - "On ARM64 hosts this can also happen when the image has no native ARM build, as the emulated process may crash on startup."); + throw new Exception( + $"The {name} test container failed to start — check the container logs." + + (skipHint is null ? "" : " " + skipHint), + e); } } @@ -67,10 +100,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..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 AssertEntity(SqlSet, 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 AssertEntity(SqlSet, created.Id, Create(FirstValid), NullNullable); - await AssertEntity(PgSet, created.Id, Create(FirstValid), NullNullable); + await AssertEntity(created.Id, Create(FirstValid), NullNullable); } // ── Create: invalid (non-null) ─────────────────────────────────────── @@ -180,10 +178,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 +201,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,8 +222,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 AssertEntity(PgSet, created.Id, updated, ToNullable(updated)); + await AssertEntity(created.Id, updated, ToNullable(updated)); } [Fact] @@ -228,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 AssertEntity(SqlSet, 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] @@ -238,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 AssertEntity(SqlSet, created.Id, Create(FirstValid), NullNullable); - await AssertEntity(PgSet, created.Id, Create(FirstValid), NullNullable); + await AssertEntity(created.Id, Create(FirstValid), NullNullable); } // ── Patch ──────────────────────────────────────────────────────────── @@ -258,8 +259,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 AssertEntity(PgSet, created.Id, expected, ToNullable(expected)); + await AssertEntity(created.Id, expected, ToNullable(expected)); } [Fact] @@ -270,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 AssertEntity(SqlSet, 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] @@ -284,8 +283,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 AssertEntity(PgSet, created.Id, expected, ToNullable(expected)); + await AssertEntity(created.Id, expected, ToNullable(expected)); } [Fact] @@ -296,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 AssertEntity(SqlSet, 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] @@ -308,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 AssertEntity(SqlSet, created.Id, Create(FirstValid), NullNullable); - await AssertEntity(PgSet, created.Id, Create(FirstValid), NullNullable); + await AssertEntity(created.Id, Create(FirstValid), NullNullable); } [Fact] @@ -320,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 AssertEntity(SqlSet, created.Id, Create(FirstValid), NullNullable); - await AssertEntity(PgSet, created.Id, Create(FirstValid), NullNullable); + await AssertEntity(created.Id, Create(FirstValid), NullNullable); } [Fact] @@ -332,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 AssertEntity(SqlSet, 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/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..62488131 100644 --- a/testing.md +++ b/testing.md @@ -91,6 +91,32 @@ 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, 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 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 `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 +the PostgreSQL container is up. + ## OpenAPI integration tests — `StrongTypes.OpenApi.IntegrationTests` Verifies the schema each type produces in **both** supported OpenAPI