Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -44,9 +42,11 @@ protected BackOfficeEndpointBaseTest(BackOfficeWebApplicationFactory factory)
}
);

using var scope = factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<AccountDbContext>().Database.EnsureCreated();
DatabaseSeeder = ActivatorUtilities.CreateInstance<DatabaseSeeder>(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; }
Expand Down
10 changes: 6 additions & 4 deletions application/account/Tests/EndpointBaseTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,13 @@ protected EndpointBaseTest(AccountWebApplicationFactory factory)
Services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = Substitute.For<ITelemetryChannel>() }));
Services.AddScoped<IExecutionContext, HttpExecutionContext>();

// Make sure the database is created
using var serviceScope = Provider!.CreateScope();
serviceScope.ServiceProvider.GetRequiredService<TContext>().Database.EnsureCreated();
DatabaseSeeder = serviceScope.ServiceProvider.GetRequiredService<DatabaseSeeder>();
// 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<AccessTokenGenerator>();

AnonymousHttpClient = factory.CreateClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,7 +36,7 @@ public abstract class ExternalAuthenticationTestBase : IDisposable
protected readonly Faker Faker = new();
protected readonly TimeProvider TimeProvider;
private readonly WebApplicationFactory<Program> _webApplicationFactory;
protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy;
protected readonly TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy;

protected ExternalAuthenticationTestBase()
{
Expand Down Expand Up @@ -68,27 +65,14 @@ protected ExternalAuthenticationTestBase()
command.ExecuteNonQuery();
}

var services = new ServiceCollection();
services.AddLogging();
services.AddTransient<DatabaseSeeder>();
services.AddDbContext<AccountDbContext>(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<ITelemetryEventsCollector>(_ => TelemetryEventsCollectorSpy);

var emailClient = Substitute.For<IEmailClient>();
services.AddScoped<IEmailClient>(_ => emailClient);

var telemetryChannel = Substitute.For<ITelemetryChannel>();
services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = telemetryChannel }));

services.AddScoped<IExecutionContext, HttpExecutionContext>();

using var serviceProvider = services.BuildServiceProvider();
using var serviceScope = serviceProvider.CreateScope();
serviceScope.ServiceProvider.GetRequiredService<AccountDbContext>().Database.EnsureCreated();
DatabaseSeeder = serviceScope.ServiceProvider.GetRequiredService<DatabaseSeeder>();

_webApplicationFactory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
Expand All @@ -109,7 +93,6 @@ protected ExternalAuthenticationTestBase()
testServices.Remove(testServices.Single(d => d.ServiceType == typeof(IDbContextOptionsConfiguration<AccountDbContext>)));
testServices.AddDbContext<AccountDbContext>(options => { options.UseSqlite(Connection).UseSnakeCaseNamingConvention(); });

TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector());
testServices.AddScoped<ITelemetryEventsCollector>(_ => TelemetryEventsCollectorSpy);

testServices.Remove(testServices.Single(d => d.ServiceType == typeof(IEmailClient)));
Expand Down
105 changes: 105 additions & 0 deletions application/account/Tests/SeededDatabaseTemplate.cs
Original file line number Diff line number Diff line change
@@ -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<DatabaseSeeder>();
services.AddDbContext<AccountDbContext>(options => options.UseSqlite(_template).UseSnakeCaseNamingConvention());
services.AddAccountServices();
services.AddScoped<ITelemetryEventsCollector>(_ => new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()));
services.AddScoped<IEmailClient>(_ => Substitute.For<IEmailClient>());
services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = Substitute.For<ITelemetryChannel>() }));
services.AddScoped<IExecutionContext, HttpExecutionContext>();

using var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
scope.ServiceProvider.GetRequiredService<AccountDbContext>().Database.EnsureCreated();
_seeder = scope.ServiceProvider.GetRequiredService<DatabaseSeeder>();
}

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();
}
}
Loading
Loading