Skip to content

Commit 0440bae

Browse files
committed
Share WebApplicationFactory across BackOffice tests via IClassFixture
1 parent 1da4403 commit 0440bae

30 files changed

Lines changed: 255 additions & 144 deletions

application/account/Tests/BackOffice/AcknowledgeBillingDriftTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
namespace Account.Tests.BackOffice;
1111

12-
public sealed class AcknowledgeBillingDriftTests : BackOfficeEndpointBaseTest
12+
public sealed class AcknowledgeBillingDriftTests(BackOfficeWebApplicationFactory factory) : BackOfficeEndpointBaseTest(factory), IClassFixture<BackOfficeWebApplicationFactory>
1313
{
1414
[Fact]
1515
public async Task AcknowledgeBillingDrift_WhenSubscriptionHasDrift_ShouldClearDrift()

application/account/Tests/BackOffice/BackOfficeBlobProxyTests.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88

99
namespace Account.Tests.BackOffice;
1010

11-
public sealed class BackOfficeBlobProxyTests : BackOfficeEndpointBaseTest
11+
public sealed class BackOfficeBlobProxyTests(BackOfficeBlobProxyFactory factory)
12+
: BackOfficeEndpointBaseTest(factory), IClassFixture<BackOfficeBlobProxyFactory>
1213
{
13-
private readonly IBlobStorageClient _blobStorageClient = Substitute.For<IBlobStorageClient>();
14+
private readonly IBlobStorageClient _blobStorageClient = factory.BlobStorageClient;
1415

1516
[Fact]
1617
public async Task BackOfficeBlobProxy_WhenServingBlob_ShouldSetNoSniffHeader()
@@ -30,10 +31,18 @@ public async Task BackOfficeBlobProxy_WhenServingBlob_ShouldSetNoSniffHeader()
3031
response.StatusCode.Should().Be(HttpStatusCode.OK);
3132
response.Headers.GetValues("X-Content-Type-Options").Should().Contain("nosniff");
3233
}
34+
}
35+
36+
public sealed class BackOfficeBlobProxyFactory : BackOfficeWebApplicationFactory
37+
{
38+
// Shared across every test in the class (IClassFixture lifetime). If more tests are added,
39+
// call ClearSubstitute() / ClearReceivedCalls() at the top of each so configured behaviours
40+
// and ReceivedCalls() do not leak between tests.
41+
public IBlobStorageClient BlobStorageClient { get; } = Substitute.For<IBlobStorageClient>();
3342

3443
protected override void ConfigureAdditionalTestServices(IServiceCollection services)
3544
{
3645
services.RemoveAll(typeof(IBlobStorageClient));
37-
services.AddKeyedSingleton("account-storage", _blobStorageClient);
46+
services.AddKeyedSingleton("account-storage", BlobStorageClient);
3847
}
3948
}

application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs

Lines changed: 24 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -2,104 +2,51 @@
22
using Account.Integrations.Stripe;
33
using Bogus;
44
using JetBrains.Annotations;
5-
using Microsoft.ApplicationInsights;
6-
using Microsoft.ApplicationInsights.Channel;
7-
using Microsoft.ApplicationInsights.Extensibility;
8-
using Microsoft.AspNetCore.Hosting;
95
using Microsoft.AspNetCore.Mvc.Testing;
10-
using Microsoft.AspNetCore.TestHost;
116
using Microsoft.Data.Sqlite;
12-
using Microsoft.EntityFrameworkCore;
13-
using Microsoft.EntityFrameworkCore.Infrastructure;
14-
using Microsoft.Extensions.Configuration;
157
using Microsoft.Extensions.DependencyInjection;
16-
using NSubstitute;
178
using SharedKernel.Authentication.BackOfficeIdentity;
189
using SharedKernel.Authentication.MockEasyAuth;
19-
using SharedKernel.ExecutionContext;
20-
using SharedKernel.Integrations.Email;
21-
using SharedKernel.SinglePageApp;
2210
using SharedKernel.Telemetry;
2311
using SharedKernel.Tests.Telemetry;
2412

2513
namespace Account.Tests.BackOffice;
2614

27-
// Base class for back-office endpoint tests. Configures the BackOffice host (so RequireHost matches)
28-
// and provides helpers to build HTTP clients with the right Host header and X-MS-CLIENT-PRINCIPAL-* headers.
15+
// Base class for back-office endpoint tests. Each derived class declares
16+
// IClassFixture<BackOfficeWebApplicationFactory> (or a subclass) to share a single host across
17+
// its tests; per-test isolation is preserved by the BackOfficeTestContext routed through the
18+
// fixture's AsyncLocal slot.
2919
public abstract class BackOfficeEndpointBaseTest : IDisposable
3020
{
31-
protected const string BackOfficeHost = "back-office.test.localhost";
21+
protected const string BackOfficeHost = BackOfficeWebApplicationFactory.BackOfficeHost;
3222

33-
private const string TestPublicUrl = "https://localhost";
34-
35-
private static readonly Lock SpaShellLock = new();
3623
protected readonly Faker Faker = new();
37-
private readonly WebApplicationFactory<Program> _webApplicationFactory;
24+
private readonly BackOfficeWebApplicationFactory _factory;
25+
private readonly IDisposable _testScope;
3826

39-
protected BackOfficeEndpointBaseTest()
27+
protected BackOfficeEndpointBaseTest(BackOfficeWebApplicationFactory factory)
4028
{
41-
Environment.SetEnvironmentVariable(SinglePageAppConfiguration.PublicUrlKey, TestPublicUrl);
42-
Environment.SetEnvironmentVariable(SinglePageAppConfiguration.CdnUrlKey, $"{TestPublicUrl}/account");
43-
Environment.SetEnvironmentVariable(
44-
"APPLICATIONINSIGHTS_CONNECTION_STRING",
45-
"InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost"
46-
);
47-
Environment.SetEnvironmentVariable("Stripe__AllowMockProvider", "true");
48-
Environment.SetEnvironmentVariable("Stripe__PublishableKey", "pk_test_mock_publishable_key");
49-
50-
EnsureBackOfficeSpaShell();
51-
52-
TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector());
29+
_factory = factory;
5330

5431
Connection = new SqliteConnection($"Data Source=TestDb_{Guid.NewGuid():N};Mode=Memory;Cache=Shared");
5532
Connection.Open();
5633

57-
_webApplicationFactory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
34+
TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector());
35+
StripeState = new MockStripeState();
36+
37+
// BeginTest must run before any service resolution so the host's startup hosted services
38+
// (PlatformCurrencyStartupResolver) and the EnsureCreated call below see the per-test state.
39+
_testScope = factory.BeginTest(new BackOfficeTestContext
5840
{
59-
builder.ConfigureLogging(logging => logging.AddFilter(_ => false));
60-
61-
builder.ConfigureAppConfiguration((_, configuration) =>
62-
{
63-
var backOfficeSettings = new Dictionary<string, string?>
64-
{
65-
["BackOffice:Host"] = BackOfficeHost,
66-
// Match the AppHost wiring: mock admin identity carries this group id, so
67-
// configuring it here lets BackOfficeAdminAuthorizationHandler and GetMe.IsAdmin
68-
// resolve admin status the same way they do in dev.
69-
["BackOffice:AdminsGroupId"] = MockEasyAuthIdentities.MockAdminsGroupId,
70-
// The user-facing SPA shell is scoped to Hostnames:App via UseHostScopedSinglePageAppFallback.
71-
// Tests that target the user-facing host use app.test.localhost.
72-
["Hostnames:App"] = "app.test.localhost"
73-
};
74-
75-
configuration.AddInMemoryCollection(backOfficeSettings);
76-
}
77-
);
78-
79-
builder.ConfigureTestServices(services =>
80-
{
81-
services.Remove(services.Single(d => d.ServiceType == typeof(IDbContextOptionsConfiguration<AccountDbContext>)));
82-
services.AddDbContext<AccountDbContext>(options => options.UseSqlite(Connection).UseSnakeCaseNamingConvention());
83-
84-
services.AddScoped<ITelemetryEventsCollector>(_ => TelemetryEventsCollectorSpy);
85-
86-
services.Remove(services.Single(d => d.ServiceType == typeof(IEmailClient)));
87-
services.AddTransient<IEmailClient>(_ => Substitute.For<IEmailClient>());
88-
89-
services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = Substitute.For<ITelemetryChannel>() }));
90-
services.AddScoped<IExecutionContext, HttpExecutionContext>();
91-
92-
ConfigureAdditionalTestServices(services);
93-
}
94-
);
41+
Connection = Connection,
42+
TelemetryCollector = TelemetryEventsCollectorSpy,
43+
StripeState = StripeState
9544
}
9645
);
9746

98-
using var scope = _webApplicationFactory.Services.CreateScope();
47+
using var scope = factory.Services.CreateScope();
9948
scope.ServiceProvider.GetRequiredService<AccountDbContext>().Database.EnsureCreated();
10049
DatabaseSeeder = ActivatorUtilities.CreateInstance<DatabaseSeeder>(scope.ServiceProvider);
101-
102-
Environment.SetEnvironmentVariable("BypassAntiforgeryValidation", "true");
10350
}
10451

10552
protected SqliteConnection Connection { get; }
@@ -108,55 +55,17 @@ protected BackOfficeEndpointBaseTest()
10855

10956
protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy { get; }
11057

111-
protected MockStripeState StripeState => _webApplicationFactory.Services.GetRequiredService<MockStripeState>();
58+
protected MockStripeState StripeState { get; }
11259

11360
public void Dispose()
11461
{
11562
Dispose(true);
11663
GC.SuppressFinalize(this);
11764
}
11865

119-
protected virtual void ConfigureAdditionalTestServices(IServiceCollection services)
120-
{
121-
}
122-
123-
// SinglePageAppConfiguration.GetHtmlTemplate() reads BackOffice/dist/index.html on every SPA-shell
124-
// request. Locally that file is generated by `rsbuild dev`; in CI the test step runs before any frontend
125-
// build, so the file is missing and the fallback returns 500. The dist's index.html is just the public
126-
// template plus rsbuild's bundle <script> injection — we don't need that here, so seed dist/index.html
127-
// from public/index.html when it is missing or when a previous failed `rsbuild dev` left a broken
128-
// artifact (no <body id="back-office">). The static Lock serializes parallel test class constructors;
129-
// File.Copy opens the destination with FileShare.None and concurrent writers hit IOException.
130-
private static void EnsureBackOfficeSpaShell()
131-
{
132-
// Walk up looking for the account folder (the parent of both Tests/BackOffice and BackOffice).
133-
// Matching just on "BackOffice" would stop at Tests/BackOffice, which is this test fixture's
134-
// own folder, not the SPA bundle.
135-
var directory = new DirectoryInfo(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!);
136-
while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "BackOffice", "public")))
137-
{
138-
directory = directory.Parent;
139-
}
140-
141-
if (directory is null) return;
142-
143-
var distDirectory = Path.Combine(directory.FullName, "BackOffice", "dist");
144-
var distIndexPath = Path.Combine(distDirectory, "index.html");
145-
var publicIndexPath = Path.Combine(directory.FullName, "BackOffice", "public", "index.html");
146-
147-
lock (SpaShellLock)
148-
{
149-
if (File.Exists(distIndexPath) && File.ReadAllText(distIndexPath).Contains("id=\"back-office\"", StringComparison.Ordinal)) return;
150-
if (!File.Exists(publicIndexPath)) return;
151-
152-
Directory.CreateDirectory(distDirectory);
153-
File.Copy(publicIndexPath, distIndexPath, true);
154-
}
155-
}
156-
15766
protected HttpClient CreateBackOfficeClient(string? clientPrincipalName = null, string? clientPrincipalId = null, string? clientPrincipalPayload = null)
15867
{
159-
var client = _webApplicationFactory.CreateClient(new WebApplicationFactoryClientOptions
68+
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
16069
{
16170
BaseAddress = new Uri($"https://{BackOfficeHost}"),
16271
AllowAutoRedirect = false
@@ -171,7 +80,7 @@ protected HttpClient CreateBackOfficeClient(string? clientPrincipalName = null,
17180

17281
protected HttpClient CreateBackOfficeClientForIdentity(MockEasyAuthIdentity identity)
17382
{
174-
var client = _webApplicationFactory.CreateClient(new WebApplicationFactoryClientOptions
83+
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
17584
{
17685
BaseAddress = new Uri($"https://{BackOfficeHost}"),
17786
AllowAutoRedirect = false
@@ -186,7 +95,7 @@ protected HttpClient CreateBackOfficeClientForIdentity(MockEasyAuthIdentity iden
18695

18796
protected HttpClient CreateClientForHost(string host)
18897
{
189-
var client = _webApplicationFactory.CreateClient(new WebApplicationFactoryClientOptions
98+
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
19099
{
191100
BaseAddress = new Uri($"https://{host}"),
192101
AllowAutoRedirect = false
@@ -201,6 +110,6 @@ protected virtual void Dispose(bool disposing)
201110
{
202111
if (!disposing) return;
203112
Connection.Close();
204-
_webApplicationFactory.Dispose();
113+
_testScope.Dispose();
205114
}
206115
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using Account.Integrations.Stripe;
2+
using Microsoft.Data.Sqlite;
3+
using SharedKernel.Tests.Telemetry;
4+
5+
namespace Account.Tests.BackOffice;
6+
7+
// Per-test state surfaced to the shared BackOfficeWebApplicationFactory via AsyncLocal so that
8+
// each test sees its own database, telemetry collector, and Stripe state while the host stays
9+
// shared across the test class.
10+
public sealed class BackOfficeTestContext
11+
{
12+
public required SqliteConnection Connection { get; init; }
13+
14+
public required TelemetryEventsCollectorSpy TelemetryCollector { get; init; }
15+
16+
public required MockStripeState StripeState { get; init; }
17+
}

0 commit comments

Comments
 (0)