From 0440bae3cb36fcf5796193220ea09fe228bcecbb Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Thu, 14 May 2026 11:33:02 +0200 Subject: [PATCH 01/10] Share WebApplicationFactory across BackOffice tests via IClassFixture --- .../AcknowledgeBillingDriftTests.cs | 2 +- .../BackOffice/BackOfficeBlobProxyTests.cs | 15 +- .../BackOffice/BackOfficeEndpointBaseTest.cs | 139 +++----------- .../Tests/BackOffice/BackOfficeTestContext.cs | 17 ++ .../BackOfficeWebApplicationFactory.cs | 176 ++++++++++++++++++ .../GetDashboardMrrConsistencySummaryTests.cs | 2 +- .../GetUnsyncedSubscriptionsSummaryTests.cs | 2 +- .../Dashboard/GetDashboardKpisTests.cs | 2 +- .../Dashboard/GetDashboardMrrTrendTests.cs | 2 +- .../GetDashboardPlanDistributionTests.cs | 2 +- .../GetDashboardRecentSignupsTests.cs | 2 +- .../GetDashboardRecentStripeEventsTests.cs | 2 +- .../GetDashboardRevenueTrendTests.cs | 2 +- .../Dashboard/GetDashboardTrendsTests.cs | 2 +- .../GetBackOfficeBillingEventsTests.cs | 2 +- .../BackOffice/GetBackOfficeInvoicesTests.cs | 2 +- .../account/Tests/BackOffice/GetMeTests.cs | 2 +- .../Tests/BackOffice/MockLoginRouteTests.cs | 2 +- .../ReconcileTenantWithStripeTests.cs | 2 +- .../ReplayArchivedTenantStripeEventsTests.cs | 2 +- .../BackOffice/GetTenantActivityTests.cs | 2 +- .../BackOffice/GetTenantDetailTests.cs | 2 +- .../GetTenantPaymentHistoryTests.cs | 2 +- .../BackOffice/GetTenantUserCountsTests.cs | 2 +- .../Tenants/BackOffice/GetTenantUsersTests.cs | 2 +- .../Tenants/BackOffice/GetTenantsTests.cs | 2 +- .../GetBackOfficeUserDetailTests.cs | 2 +- .../GetBackOfficeUserLoginHistoryTests.cs | 2 +- .../GetBackOfficeUserSessionsTests.cs | 2 +- .../BackOffice/GetBackOfficeUsersTests.cs | 2 +- 30 files changed, 255 insertions(+), 144 deletions(-) create mode 100644 application/account/Tests/BackOffice/BackOfficeTestContext.cs create mode 100644 application/account/Tests/BackOffice/BackOfficeWebApplicationFactory.cs diff --git a/application/account/Tests/BackOffice/AcknowledgeBillingDriftTests.cs b/application/account/Tests/BackOffice/AcknowledgeBillingDriftTests.cs index e98078621f..eb355255fc 100644 --- a/application/account/Tests/BackOffice/AcknowledgeBillingDriftTests.cs +++ b/application/account/Tests/BackOffice/AcknowledgeBillingDriftTests.cs @@ -9,7 +9,7 @@ namespace Account.Tests.BackOffice; -public sealed class AcknowledgeBillingDriftTests : BackOfficeEndpointBaseTest +public sealed class AcknowledgeBillingDriftTests(BackOfficeWebApplicationFactory factory) : BackOfficeEndpointBaseTest(factory), IClassFixture { [Fact] public async Task AcknowledgeBillingDrift_WhenSubscriptionHasDrift_ShouldClearDrift() diff --git a/application/account/Tests/BackOffice/BackOfficeBlobProxyTests.cs b/application/account/Tests/BackOffice/BackOfficeBlobProxyTests.cs index b48f25cd42..fcfa891834 100644 --- a/application/account/Tests/BackOffice/BackOfficeBlobProxyTests.cs +++ b/application/account/Tests/BackOffice/BackOfficeBlobProxyTests.cs @@ -8,9 +8,10 @@ namespace Account.Tests.BackOffice; -public sealed class BackOfficeBlobProxyTests : BackOfficeEndpointBaseTest +public sealed class BackOfficeBlobProxyTests(BackOfficeBlobProxyFactory factory) + : BackOfficeEndpointBaseTest(factory), IClassFixture { - private readonly IBlobStorageClient _blobStorageClient = Substitute.For(); + private readonly IBlobStorageClient _blobStorageClient = factory.BlobStorageClient; [Fact] public async Task BackOfficeBlobProxy_WhenServingBlob_ShouldSetNoSniffHeader() @@ -30,10 +31,18 @@ public async Task BackOfficeBlobProxy_WhenServingBlob_ShouldSetNoSniffHeader() response.StatusCode.Should().Be(HttpStatusCode.OK); response.Headers.GetValues("X-Content-Type-Options").Should().Contain("nosniff"); } +} + +public sealed class BackOfficeBlobProxyFactory : BackOfficeWebApplicationFactory +{ + // Shared across every test in the class (IClassFixture lifetime). If more tests are added, + // call ClearSubstitute() / ClearReceivedCalls() at the top of each so configured behaviours + // and ReceivedCalls() do not leak between tests. + public IBlobStorageClient BlobStorageClient { get; } = Substitute.For(); protected override void ConfigureAdditionalTestServices(IServiceCollection services) { services.RemoveAll(typeof(IBlobStorageClient)); - services.AddKeyedSingleton("account-storage", _blobStorageClient); + services.AddKeyedSingleton("account-storage", BlobStorageClient); } } diff --git a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs index 4f9af40d4b..b61c5b3ac3 100644 --- a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs +++ b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs @@ -2,104 +2,51 @@ using Account.Integrations.Stripe; using Bogus; 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; using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using NSubstitute; using SharedKernel.Authentication.BackOfficeIdentity; using SharedKernel.Authentication.MockEasyAuth; -using SharedKernel.ExecutionContext; -using SharedKernel.Integrations.Email; -using SharedKernel.SinglePageApp; using SharedKernel.Telemetry; using SharedKernel.Tests.Telemetry; namespace Account.Tests.BackOffice; -// Base class for back-office endpoint tests. Configures the BackOffice host (so RequireHost matches) -// and provides helpers to build HTTP clients with the right Host header and X-MS-CLIENT-PRINCIPAL-* headers. +// Base class for back-office 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 BackOfficeTestContext routed through the +// fixture's AsyncLocal slot. public abstract class BackOfficeEndpointBaseTest : IDisposable { - protected const string BackOfficeHost = "back-office.test.localhost"; + protected const string BackOfficeHost = BackOfficeWebApplicationFactory.BackOfficeHost; - private const string TestPublicUrl = "https://localhost"; - - private static readonly Lock SpaShellLock = new(); protected readonly Faker Faker = new(); - private readonly WebApplicationFactory _webApplicationFactory; + private readonly BackOfficeWebApplicationFactory _factory; + private readonly IDisposable _testScope; - protected BackOfficeEndpointBaseTest() + protected BackOfficeEndpointBaseTest(BackOfficeWebApplicationFactory factory) { - Environment.SetEnvironmentVariable(SinglePageAppConfiguration.PublicUrlKey, TestPublicUrl); - Environment.SetEnvironmentVariable(SinglePageAppConfiguration.CdnUrlKey, $"{TestPublicUrl}/account"); - Environment.SetEnvironmentVariable( - "APPLICATIONINSIGHTS_CONNECTION_STRING", - "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost" - ); - Environment.SetEnvironmentVariable("Stripe__AllowMockProvider", "true"); - Environment.SetEnvironmentVariable("Stripe__PublishableKey", "pk_test_mock_publishable_key"); - - EnsureBackOfficeSpaShell(); - - TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); + _factory = factory; Connection = new SqliteConnection($"Data Source=TestDb_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"); Connection.Open(); - _webApplicationFactory = new WebApplicationFactory().WithWebHostBuilder(builder => + TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); + 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. + _testScope = factory.BeginTest(new BackOfficeTestContext { - builder.ConfigureLogging(logging => logging.AddFilter(_ => false)); - - builder.ConfigureAppConfiguration((_, configuration) => - { - var backOfficeSettings = new Dictionary - { - ["BackOffice:Host"] = BackOfficeHost, - // Match the AppHost wiring: mock admin identity carries this group id, so - // configuring it here lets BackOfficeAdminAuthorizationHandler and GetMe.IsAdmin - // resolve admin status the same way they do in dev. - ["BackOffice:AdminsGroupId"] = MockEasyAuthIdentities.MockAdminsGroupId, - // The user-facing SPA shell is scoped to Hostnames:App via UseHostScopedSinglePageAppFallback. - // Tests that target the user-facing host use app.test.localhost. - ["Hostnames:App"] = "app.test.localhost" - }; - - configuration.AddInMemoryCollection(backOfficeSettings); - } - ); - - builder.ConfigureTestServices(services => - { - services.Remove(services.Single(d => d.ServiceType == typeof(IDbContextOptionsConfiguration))); - services.AddDbContext(options => options.UseSqlite(Connection).UseSnakeCaseNamingConvention()); - - services.AddScoped(_ => TelemetryEventsCollectorSpy); - - services.Remove(services.Single(d => d.ServiceType == typeof(IEmailClient))); - services.AddTransient(_ => Substitute.For()); - - services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = Substitute.For() })); - services.AddScoped(); - - ConfigureAdditionalTestServices(services); - } - ); + Connection = Connection, + TelemetryCollector = TelemetryEventsCollectorSpy, + StripeState = StripeState } ); - using var scope = _webApplicationFactory.Services.CreateScope(); + using var scope = factory.Services.CreateScope(); scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); DatabaseSeeder = ActivatorUtilities.CreateInstance(scope.ServiceProvider); - - Environment.SetEnvironmentVariable("BypassAntiforgeryValidation", "true"); } protected SqliteConnection Connection { get; } @@ -108,7 +55,7 @@ protected BackOfficeEndpointBaseTest() protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy { get; } - protected MockStripeState StripeState => _webApplicationFactory.Services.GetRequiredService(); + protected MockStripeState StripeState { get; } public void Dispose() { @@ -116,47 +63,9 @@ public void Dispose() GC.SuppressFinalize(this); } - protected virtual void ConfigureAdditionalTestServices(IServiceCollection services) - { - } - - // SinglePageAppConfiguration.GetHtmlTemplate() reads BackOffice/dist/index.html on every SPA-shell - // request. Locally that file is generated by `rsbuild dev`; in CI the test step runs before any frontend - // build, so the file is missing and the fallback returns 500. The dist's index.html is just the public - // template plus rsbuild's bundle