From befe4db989ec2819914f876c37e3246980e9ff0b Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 23 Jun 2026 00:42:31 +0200 Subject: [PATCH] Seed backend endpoint test databases from a binary snapshot and align main with account --- .../BackOffice/BackOfficeEndpointBaseTest.cs | 12 +- application/account/Tests/EndpointBaseTest.cs | 10 +- .../ExternalAuthenticationTestBase.cs | 29 +---- .../account/Tests/SeededDatabaseTemplate.cs | 105 ++++++++++++++++ application/main/Tests/EndpointBaseTest.cs | 119 ++++++------------ application/main/Tests/MainTestContext.cs | 17 +++ .../main/Tests/MainWebApplicationFactory.cs | 111 ++++++++++++++++ .../main/Tests/SeededDatabaseTemplate.cs | 105 ++++++++++++++++ 8 files changed, 397 insertions(+), 111 deletions(-) create mode 100644 application/account/Tests/SeededDatabaseTemplate.cs create mode 100644 application/main/Tests/MainTestContext.cs create mode 100644 application/main/Tests/MainWebApplicationFactory.cs create mode 100644 application/main/Tests/SeededDatabaseTemplate.cs diff --git a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs index b61c5b3ac3..2e3c706371 100644 --- a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs +++ b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs @@ -1,10 +1,8 @@ -using Account.Database; using Account.Integrations.Stripe; using Bogus; using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Data.Sqlite; -using Microsoft.Extensions.DependencyInjection; using SharedKernel.Authentication.BackOfficeIdentity; using SharedKernel.Authentication.MockEasyAuth; using SharedKernel.Telemetry; @@ -35,7 +33,7 @@ protected BackOfficeEndpointBaseTest(BackOfficeWebApplicationFactory factory) StripeState = new MockStripeState(); // BeginTest must run before any service resolution so the host's startup hosted services - // (PlatformCurrencyStartupResolver) and the EnsureCreated call below see the per-test state. + // (PlatformCurrencyStartupResolver) see the per-test state. _testScope = factory.BeginTest(new BackOfficeTestContext { Connection = Connection, @@ -44,9 +42,11 @@ protected BackOfficeEndpointBaseTest(BackOfficeWebApplicationFactory factory) } ); - using var scope = factory.Services.CreateScope(); - scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); - DatabaseSeeder = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + // Fill this test's database from the seeded template with a fast binary copy instead of + // recreating the schema and reseeding per test. The shared seeder's entity references match the + // rows copied into this connection. + DatabaseSeeder = SeededDatabaseTemplate.EnsureSeeded(); + SeededDatabaseTemplate.RestoreInto(Connection); } protected SqliteConnection Connection { get; } diff --git a/application/account/Tests/EndpointBaseTest.cs b/application/account/Tests/EndpointBaseTest.cs index 1553bed049..9fadaee2bb 100644 --- a/application/account/Tests/EndpointBaseTest.cs +++ b/application/account/Tests/EndpointBaseTest.cs @@ -91,11 +91,13 @@ protected EndpointBaseTest(AccountWebApplicationFactory factory) Services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = Substitute.For() })); Services.AddScoped(); - // Make sure the database is created - using var serviceScope = Provider!.CreateScope(); - serviceScope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); - DatabaseSeeder = serviceScope.ServiceProvider.GetRequiredService(); + // Fill this test's database from the seeded template with a fast binary copy instead of + // recreating the schema and reseeding per test. The shared seeder's entity references match the + // rows copied into this connection. + DatabaseSeeder = SeededDatabaseTemplate.EnsureSeeded(); + SeededDatabaseTemplate.RestoreInto(Connection); + using var serviceScope = Provider!.CreateScope(); AccessTokenGenerator = serviceScope.ServiceProvider.GetRequiredService(); AnonymousHttpClient = factory.CreateClient(); diff --git a/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs b/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs index 25a8b10ed6..7b6c0b76ce 100644 --- a/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs +++ b/application/account/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs @@ -9,9 +9,6 @@ using Bogus; using FluentAssertions; using JetBrains.Annotations; -using Microsoft.ApplicationInsights; -using Microsoft.ApplicationInsights.Channel; -using Microsoft.ApplicationInsights.Extensibility; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; @@ -39,7 +36,7 @@ public abstract class ExternalAuthenticationTestBase : IDisposable protected readonly Faker Faker = new(); protected readonly TimeProvider TimeProvider; private readonly WebApplicationFactory _webApplicationFactory; - protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy; + protected readonly TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy; protected ExternalAuthenticationTestBase() { @@ -68,27 +65,14 @@ protected ExternalAuthenticationTestBase() command.ExecuteNonQuery(); } - var services = new ServiceCollection(); - services.AddLogging(); - services.AddTransient(); - services.AddDbContext(options => { options.UseSqlite(Connection).UseSnakeCaseNamingConvention(); }); - services.AddAccountServices(); + // Fill this test's database from the seeded template with a fast binary copy instead of + // recreating the schema and reseeding per test. The shared seeder's entity references match the + // rows copied into this connection. + DatabaseSeeder = SeededDatabaseTemplate.EnsureSeeded(); + SeededDatabaseTemplate.RestoreInto(Connection); TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); - services.AddScoped(_ => TelemetryEventsCollectorSpy); - var emailClient = Substitute.For(); - services.AddScoped(_ => emailClient); - - var telemetryChannel = Substitute.For(); - services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = telemetryChannel })); - - services.AddScoped(); - - using var serviceProvider = services.BuildServiceProvider(); - using var serviceScope = serviceProvider.CreateScope(); - serviceScope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); - DatabaseSeeder = serviceScope.ServiceProvider.GetRequiredService(); _webApplicationFactory = new WebApplicationFactory().WithWebHostBuilder(builder => { @@ -109,7 +93,6 @@ protected ExternalAuthenticationTestBase() testServices.Remove(testServices.Single(d => d.ServiceType == typeof(IDbContextOptionsConfiguration))); testServices.AddDbContext(options => { options.UseSqlite(Connection).UseSnakeCaseNamingConvention(); }); - TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); testServices.AddScoped(_ => TelemetryEventsCollectorSpy); testServices.Remove(testServices.Single(d => d.ServiceType == typeof(IEmailClient))); diff --git a/application/account/Tests/SeededDatabaseTemplate.cs b/application/account/Tests/SeededDatabaseTemplate.cs new file mode 100644 index 0000000000..61e0dc8828 --- /dev/null +++ b/application/account/Tests/SeededDatabaseTemplate.cs @@ -0,0 +1,105 @@ +using Account.Database; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using SharedKernel.ExecutionContext; +using SharedKernel.Integrations.Email; +using SharedKernel.Telemetry; +using SharedKernel.Tests.Telemetry; + +namespace Account.Tests; + +// Seeds the schema and fixtures once into an in-memory template database, then fills each test's +// connection with a fast binary copy via SqliteConnection.BackupDatabase instead of running +// EnsureCreated and reseeding for every test. Because every test database is a binary copy of the +// template, the shared DatabaseSeeder's entity references (ids, emails) match the rows in every test's +// connection, even though the seeder generates fresh random ids on each run. +internal static class SeededDatabaseTemplate +{ + // Fixed-name shared-cache in-memory database holding the seeded template. The keep-alive connection + // below keeps it alive for the process lifetime; every test copies its schema and data from here. + private const string TemplateConnectionString = "Data Source=AccountTestTemplate;Mode=Memory;Cache=Shared"; + + // Guards one-time seeding and serializes the binary copies: the single template connection is the + // copy source, so only one BackupDatabase can read it at a time. + private static readonly Lock SyncLock = new(); + + private static SqliteConnection? _template; + + private static DatabaseSeeder? _seeder; + + // Seeds the template on first use and returns the shared seeder whose entity references match the + // rows copied into every test's connection. + public static DatabaseSeeder EnsureSeeded() + { + lock (SyncLock) + { + if (_seeder is null) + { + Seed(); + } + + return _seeder!; + } + } + + // Copies the seeded schema and data into a test's connection. BackupDatabase is a binary page copy, + // far cheaper than recreating the schema and reseeding per test. + public static void RestoreInto(SqliteConnection destination) + { + lock (SyncLock) + { + _template!.BackupDatabase(destination); + } + } + + private static void Seed() + { + _template = new SqliteConnection(TemplateConnectionString); + _template.Open(); + ApplyPragmas(_template); + + // Seed through the real dependency injection graph so Entity Framework interceptors behave like + // production. The substitutes stand in for dependencies the seeding path never exercises. + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTransient(); + services.AddDbContext(options => options.UseSqlite(_template).UseSnakeCaseNamingConvention()); + services.AddAccountServices(); + services.AddScoped(_ => new TelemetryEventsCollectorSpy(new TelemetryEventsCollector())); + services.AddScoped(_ => Substitute.For()); + services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = Substitute.For() })); + services.AddScoped(); + + using var serviceProvider = services.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + _seeder = scope.ServiceProvider.GetRequiredService(); + } + + private static void ApplyPragmas(SqliteConnection connection) + { + // Configure SQLite to behave more like PostgreSQL while the template schema is created. + using var command = connection.CreateCommand(); + + // Enable foreign key constraints (PostgreSQL has this by default) + command.CommandText = "PRAGMA foreign_keys = ON;"; + command.ExecuteNonQuery(); + + // Enable recursive triggers (PostgreSQL supports nested triggers) + command.CommandText = "PRAGMA recursive_triggers = ON;"; + command.ExecuteNonQuery(); + + // Enforce CHECK constraints (PostgreSQL enforces these by default) + command.CommandText = "PRAGMA ignore_check_constraints = OFF;"; + command.ExecuteNonQuery(); + + // Use more strict query parsing + command.CommandText = "PRAGMA trusted_schema = OFF;"; + command.ExecuteNonQuery(); + } +} diff --git a/application/main/Tests/EndpointBaseTest.cs b/application/main/Tests/EndpointBaseTest.cs index 50c3c7a1be..e05d4d921d 100644 --- a/application/main/Tests/EndpointBaseTest.cs +++ b/application/main/Tests/EndpointBaseTest.cs @@ -1,56 +1,39 @@ using System.Net.Http.Headers; using Bogus; using JetBrains.Annotations; -using Mapster; using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.Channel; using Microsoft.ApplicationInsights.Extensibility; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.TestHost; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; using NSubstitute; -using SharedKernel.Authentication; using SharedKernel.Authentication.TokenGeneration; using SharedKernel.ExecutionContext; using SharedKernel.Integrations.Email; -using SharedKernel.SinglePageApp; using SharedKernel.Telemetry; using SharedKernel.Tests.Telemetry; namespace Main.Tests; +// Base class for Main API endpoint tests. Each derived class declares +// IClassFixture (or a subclass) to share a single host across its tests; +// per-test isolation is preserved by the MainTestContext routed through the fixture's AsyncLocal slot. public abstract class EndpointBaseTest : IDisposable where TContext : DbContext { - // Tests use the in-memory test server (WebApplicationFactory); no real listener is bound. - // SinglePageAppConfiguration only consumes this as a URI for CSP construction. - private const string TestPublicUrl = "https://localhost"; protected readonly AccessTokenGenerator AccessTokenGenerator; protected readonly IEmailClient EmailClient; protected readonly Faker Faker = new(); protected readonly ServiceCollection Services; [UsedImplicitly] protected readonly TimeProvider TimeProvider; - private readonly WebApplicationFactory _webApplicationFactory; - protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy; + private readonly MainWebApplicationFactory _factory; + private readonly IDisposable _testScope; - protected EndpointBaseTest() + protected EndpointBaseTest(MainWebApplicationFactory factory) { - Environment.SetEnvironmentVariable(SinglePageAppConfiguration.PublicUrlKey, TestPublicUrl); - Environment.SetEnvironmentVariable(SinglePageAppConfiguration.CdnUrlKey, $"{TestPublicUrl}/main"); - Environment.SetEnvironmentVariable( - "APPLICATIONINSIGHTS_CONNECTION_STRING", - "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost" - ); - - Services = new ServiceCollection(); + _factory = factory; TimeProvider = TimeProvider.System; - Services.AddLogging(); - Services.AddTransient(); - // Create connection using shared cache mode so isolated connections can access the same in-memory database Connection = new SqliteConnection($"Data Source=TestDb_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"); Connection.Open(); @@ -75,75 +58,57 @@ protected EndpointBaseTest() command.ExecuteNonQuery(); } - Services.AddDbContext(options => { options.UseSqlite(Connection).UseSnakeCaseNamingConvention(); }); - - Services.AddMainServices(); - TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); - Services.AddScoped(_ => TelemetryEventsCollectorSpy); - EmailClient = Substitute.For(); - Services.AddScoped(_ => EmailClient); - var telemetryChannel = Substitute.For(); - Services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = telemetryChannel })); + // BeginTest must run before any service resolution on the shared host so per-request DI lookups + // see the per-test state. + _testScope = factory.BeginTest(new MainTestContext + { + Connection = Connection, + TelemetryCollector = TelemetryEventsCollectorSpy, + EmailClient = EmailClient + } + ); + // The local Services collection is unit-test scaffolding (not part of the WAF). Tests can + // resolve handlers and repositories directly via Provider without going through HTTP. + Services = new ServiceCollection(); + Services.AddLogging(); + Services.AddTransient(); + Services.AddDbContext(options => options.UseSqlite(Connection).UseSnakeCaseNamingConvention()); + Services.AddMainServices(); + Services.AddScoped(_ => TelemetryEventsCollectorSpy); + Services.AddScoped(_ => EmailClient); + Services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = Substitute.For() })); Services.AddScoped(); - // Make sure the database is created - using var serviceScope = Provider!.CreateScope(); - serviceScope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); - DatabaseSeeder = serviceScope.ServiceProvider.GetRequiredService(); + // Fill this test's database from the seeded template with a fast binary copy instead of + // recreating the schema and reseeding per test. The shared seeder's entity references match the + // rows copied into this connection. + DatabaseSeeder = SeededDatabaseTemplate.EnsureSeeded(); + SeededDatabaseTemplate.RestoreInto(Connection); + using var serviceScope = Provider!.CreateScope(); AccessTokenGenerator = serviceScope.ServiceProvider.GetRequiredService(); - _webApplicationFactory = new WebApplicationFactory().WithWebHostBuilder(builder => - { - builder.ConfigureLogging(logging => - { - logging.AddFilter(_ => false); // Suppress all logs during tests - } - ); - - builder.ConfigureTestServices(services => - { - // Replace the default DbContext in the WebApplication to use an in-memory SQLite database - services.Remove(services.Single(d => d.ServiceType == typeof(IDbContextOptionsConfiguration))); - services.AddDbContext(options => { options.UseSqlite(Connection).UseSnakeCaseNamingConvention(); }); - - TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); - services.AddScoped(_ => TelemetryEventsCollectorSpy); - - services.Remove(services.Single(d => d.ServiceType == typeof(IEmailClient))); - services.AddTransient(_ => EmailClient); - - RegisterMockLoggers(services); - - services.AddScoped(); - } - ); - } - ); - - AnonymousHttpClient = _webApplicationFactory.CreateClient(); + AnonymousHttpClient = factory.CreateClient(); var ownerAccessToken = AccessTokenGenerator.Generate(DatabaseSeeder.Tenant1Owner); - AuthenticatedOwnerHttpClient = _webApplicationFactory.CreateClient(); + AuthenticatedOwnerHttpClient = factory.CreateClient(); AuthenticatedOwnerHttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", ownerAccessToken); - var memberAccessToken = AccessTokenGenerator.Generate(DatabaseSeeder.Tenant1Member.Adapt()); - AuthenticatedMemberHttpClient = _webApplicationFactory.CreateClient(); + var memberAccessToken = AccessTokenGenerator.Generate(DatabaseSeeder.Tenant1Member); + AuthenticatedMemberHttpClient = factory.CreateClient(); AuthenticatedMemberHttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", memberAccessToken); - - // Set the environment variable to bypass antiforgery validation on the server. ASP.NET uses a cryptographic - // double-submit pattern that encrypts the user's ClaimUid in the token, which is complex to replicate in tests - Environment.SetEnvironmentVariable("BypassAntiforgeryValidation", "true"); } protected SqliteConnection Connection { get; } protected DatabaseSeeder DatabaseSeeder { get; } + protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy { get; } + protected ServiceProvider Provider { get @@ -160,16 +125,14 @@ protected ServiceProvider Provider protected HttpClient AuthenticatedMemberHttpClient { get; } + protected IServiceProvider WebApplicationServices => _factory.Services; + public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } - protected virtual void RegisterMockLoggers(IServiceCollection services) - { - } - // SonarLint complains if the virtual keyword is missing, as it is required to correctly implement the dispose pattern. [UsedImplicitly] protected virtual void Dispose(bool disposing) @@ -177,6 +140,6 @@ protected virtual void Dispose(bool disposing) if (!disposing) return; Provider.Dispose(); Connection.Close(); - _webApplicationFactory.Dispose(); + _testScope.Dispose(); } } diff --git a/application/main/Tests/MainTestContext.cs b/application/main/Tests/MainTestContext.cs new file mode 100644 index 0000000000..7d10d178e5 --- /dev/null +++ b/application/main/Tests/MainTestContext.cs @@ -0,0 +1,17 @@ +using Microsoft.Data.Sqlite; +using SharedKernel.Integrations.Email; +using SharedKernel.Tests.Telemetry; + +namespace Main.Tests; + +// Per-test state surfaced to the shared MainWebApplicationFactory via AsyncLocal so that each test sees +// its own database, telemetry collector, and email client substitute while the host stays shared across +// the test class. +public sealed class MainTestContext +{ + public required SqliteConnection Connection { get; init; } + + public required TelemetryEventsCollectorSpy TelemetryCollector { get; init; } + + public required IEmailClient EmailClient { get; init; } +} diff --git a/application/main/Tests/MainWebApplicationFactory.cs b/application/main/Tests/MainWebApplicationFactory.cs new file mode 100644 index 0000000000..7dfa937ce2 --- /dev/null +++ b/application/main/Tests/MainWebApplicationFactory.cs @@ -0,0 +1,111 @@ +using Main.Database; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using SharedKernel.ExecutionContext; +using SharedKernel.Integrations.Email; +using SharedKernel.SinglePageApp; +using SharedKernel.Telemetry; + +namespace Main.Tests; + +// Shared host for all Main API endpoint tests in a class. Constructed once via xUnit's IClassFixture, +// the host wires its DbContext, telemetry collector, and email client to a per-test MainTestContext +// stored in an AsyncLocal — so the same host can serve every test in the class while each test still +// has isolated state. +public class MainWebApplicationFactory : WebApplicationFactory +{ + // Tests use the in-memory test server (WebApplicationFactory); no real listener is bound. + // SinglePageAppConfiguration only consumes this as a URI for CSP construction. + private const string TestPublicUrl = "https://localhost"; + + private readonly AsyncLocal _currentContext = new(); + + public MainWebApplicationFactory() + { + Environment.SetEnvironmentVariable(SinglePageAppConfiguration.PublicUrlKey, TestPublicUrl); + Environment.SetEnvironmentVariable(SinglePageAppConfiguration.CdnUrlKey, $"{TestPublicUrl}/main"); + Environment.SetEnvironmentVariable( + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost" + ); + // ASP.NET uses a cryptographic double-submit antiforgery pattern that encrypts the user's + // ClaimUid in the token, which is complex to replicate in tests; the middleware honors this + // env var to bypass validation. + Environment.SetEnvironmentVariable("BypassAntiforgeryValidation", "true"); + } + + private MainTestContext CurrentContext => _currentContext.Value + ?? throw new InvalidOperationException("MainTestContext is not set. Call BeginTest before resolving services."); + + // Sets the per-test context for the calling logical-call context. The returned scope clears the + // context on Dispose so the AsyncLocal does not leak past the test instance lifetime. + public IDisposable BeginTest(MainTestContext context) + { + _currentContext.Value = context; + return new TestScope(this); + } + + // TestServer.PreserveExecutionContext defaults to false, which means the calling test's + // ExecutionContext (and therefore the AsyncLocal-stored MainTestContext) does not flow into request + // handling. Enabling it preserves the flow so per-request DI resolutions see the current test's + // context. + protected override IHost CreateHost(IHostBuilder builder) + { + var host = base.CreateHost(builder); + if (host.Services.GetRequiredService() is TestServer testServer) + { + testServer.PreserveExecutionContext = true; + } + + return host; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureLogging(logging => logging.AddFilter(_ => false)); + + builder.ConfigureTestServices(services => + { + services.Remove(services.Single(d => d.ServiceType == typeof(IDbContextOptionsConfiguration))); + services.AddDbContext((_, options) => options.UseSqlite(CurrentContext.Connection).UseSnakeCaseNamingConvention()); + + services.AddScoped(_ => CurrentContext.TelemetryCollector); + + services.Remove(services.Single(d => d.ServiceType == typeof(IEmailClient))); + services.AddTransient(_ => CurrentContext.EmailClient); + + services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = Substitute.For() })); + services.AddScoped(); + + ConfigureAdditionalTestServices(services); + } + ); + } + + protected virtual void ConfigureAdditionalTestServices(IServiceCollection services) + { + } + + private void EndTest() + { + _currentContext.Value = null; + } + + private sealed class TestScope(MainWebApplicationFactory factory) : IDisposable + { + public void Dispose() + { + factory.EndTest(); + } + } +} diff --git a/application/main/Tests/SeededDatabaseTemplate.cs b/application/main/Tests/SeededDatabaseTemplate.cs new file mode 100644 index 0000000000..aae26b7690 --- /dev/null +++ b/application/main/Tests/SeededDatabaseTemplate.cs @@ -0,0 +1,105 @@ +using Main.Database; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using SharedKernel.ExecutionContext; +using SharedKernel.Integrations.Email; +using SharedKernel.Telemetry; +using SharedKernel.Tests.Telemetry; + +namespace Main.Tests; + +// Seeds the schema and fixtures once into an in-memory template database, then fills each test's +// connection with a fast binary copy via SqliteConnection.BackupDatabase instead of running +// EnsureCreated and reseeding for every test. Because every test database is a binary copy of the +// template, the shared DatabaseSeeder's entity references (ids, emails) match the rows in every test's +// connection, even though the seeder generates fresh random ids on each run. +internal static class SeededDatabaseTemplate +{ + // Fixed-name shared-cache in-memory database holding the seeded template. The keep-alive connection + // below keeps it alive for the process lifetime; every test copies its schema and data from here. + private const string TemplateConnectionString = "Data Source=MainTestTemplate;Mode=Memory;Cache=Shared"; + + // Guards one-time seeding and serializes the binary copies: the single template connection is the + // copy source, so only one BackupDatabase can read it at a time. + private static readonly Lock SyncLock = new(); + + private static SqliteConnection? _template; + + private static DatabaseSeeder? _seeder; + + // Seeds the template on first use and returns the shared seeder whose entity references match the + // rows copied into every test's connection. + public static DatabaseSeeder EnsureSeeded() + { + lock (SyncLock) + { + if (_seeder is null) + { + Seed(); + } + + return _seeder!; + } + } + + // Copies the seeded schema and data into a test's connection. BackupDatabase is a binary page copy, + // far cheaper than recreating the schema and reseeding per test. + public static void RestoreInto(SqliteConnection destination) + { + lock (SyncLock) + { + _template!.BackupDatabase(destination); + } + } + + private static void Seed() + { + _template = new SqliteConnection(TemplateConnectionString); + _template.Open(); + ApplyPragmas(_template); + + // Seed through the real dependency injection graph so Entity Framework interceptors behave like + // production. The substitutes stand in for dependencies the seeding path never exercises. + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTransient(); + services.AddDbContext(options => options.UseSqlite(_template).UseSnakeCaseNamingConvention()); + services.AddMainServices(); + services.AddScoped(_ => new TelemetryEventsCollectorSpy(new TelemetryEventsCollector())); + services.AddScoped(_ => Substitute.For()); + services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = Substitute.For() })); + services.AddScoped(); + + using var serviceProvider = services.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + _seeder = scope.ServiceProvider.GetRequiredService(); + } + + private static void ApplyPragmas(SqliteConnection connection) + { + // Configure SQLite to behave more like PostgreSQL while the template schema is created. + using var command = connection.CreateCommand(); + + // Enable foreign key constraints (PostgreSQL has this by default) + command.CommandText = "PRAGMA foreign_keys = ON;"; + command.ExecuteNonQuery(); + + // Enable recursive triggers (PostgreSQL supports nested triggers) + command.CommandText = "PRAGMA recursive_triggers = ON;"; + command.ExecuteNonQuery(); + + // Enforce CHECK constraints (PostgreSQL enforces these by default) + command.CommandText = "PRAGMA ignore_check_constraints = OFF;"; + command.ExecuteNonQuery(); + + // Use more strict query parsing + command.CommandText = "PRAGMA trusted_schema = OFF;"; + command.ExecuteNonQuery(); + } +}