Skip to content
Merged
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,38 @@ public abstract class IntegrationTestBase<TEntity, T, TNullable>(TestWebApplicat
protected DbSet<TEntity> SqlSet => SqlDb.Set<TEntity>();
protected DbSet<TEntity> PgSet => PgDb.Set<TEntity>();

/// <summary>
/// Whether the SQL Server provider is backed by a real SQL Server on this host.
/// <see langword="false"/> only on a local host that opted into skipping SQL Server.
/// Guard every SQL-Server-specific assertion with this.
/// </summary>
protected bool SqlServerAvailable => factory.SqlServerAvailable;

protected static CancellationToken Ct => TestContext.Current.CancellationToken;

/// <summary>
/// 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.
/// </summary>
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);
}
}

/// <summary>
/// Skips a provider-parametrized test for the <c>sql-server</c> provider when
/// SQL Server is unavailable on this host; a no-op for any other provider.
/// </summary>
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<TEntity> set,
Guid id,
T expectedValue,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,39 @@
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;

namespace StrongTypes.Api.IntegrationTests.Infrastructure;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// PostgreSQL is required everywhere. SQL Server is required too — except on a
/// host that cannot run the amd64-only <c>mssql/server</c> image (e.g. an ARM64
/// dev box), where it can be skipped via an explicit opt-in. See
/// <see cref="SqlServerAvailable"/> for what callers must guard, and the
/// <c>STRONGTYPES_SKIP_SQLSERVER</c> 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.
/// </remarks>
public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>, 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()
Expand All @@ -30,13 +44,32 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>,
.WithLabel(DockerGroupLabel, DockerGroupName)
.Build();

/// <summary>
/// Whether the SQL Server container is up and backed by a real SQL Server.
/// <see langword="false"/> 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 <see langword="false"/>; the
/// in-memory stub that keeps the dual-write API booting does not exercise
/// the real SQL Server wire path.
/// </summary>
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<Task> { 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();
Expand All @@ -45,19 +78,19 @@ await Task.WhenAll(
await sp.GetRequiredService<PostgreSqlDbContext>().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);
}
}

Expand All @@ -67,10 +100,33 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<SqlServerDbContext>(options => options
.UseInMemoryDatabase("sqlserver-unavailable")
.UseStrongTypes());
});
}
}

public override async ValueTask DisposeAsync()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="17.14.2" />
<PackageReference Include="Testcontainers.MsSql" Version="4.0.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.0.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) ───────────────────────────────────────
Expand Down Expand Up @@ -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());
Expand All @@ -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);
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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 ────────────────────────────────────────────────────────────
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ private async Task<Guid> 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");
Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand Down
Loading
Loading