22using Account . Integrations . Stripe ;
33using Bogus ;
44using JetBrains . Annotations ;
5- using Microsoft . ApplicationInsights ;
6- using Microsoft . ApplicationInsights . Channel ;
7- using Microsoft . ApplicationInsights . Extensibility ;
8- using Microsoft . AspNetCore . Hosting ;
95using Microsoft . AspNetCore . Mvc . Testing ;
10- using Microsoft . AspNetCore . TestHost ;
116using Microsoft . Data . Sqlite ;
12- using Microsoft . EntityFrameworkCore ;
13- using Microsoft . EntityFrameworkCore . Infrastructure ;
14- using Microsoft . Extensions . Configuration ;
157using Microsoft . Extensions . DependencyInjection ;
16- using NSubstitute ;
178using SharedKernel . Authentication . BackOfficeIdentity ;
189using SharedKernel . Authentication . MockEasyAuth ;
19- using SharedKernel . ExecutionContext ;
20- using SharedKernel . Integrations . Email ;
21- using SharedKernel . SinglePageApp ;
2210using SharedKernel . Telemetry ;
2311using SharedKernel . Tests . Telemetry ;
2412
2513namespace 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.
2919public 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}
0 commit comments