From fbeeb8ca0931382c3542f7de3d054f43f4460f1d Mon Sep 17 00:00:00 2001 From: Vulthil Date: Fri, 2 Oct 2026 14:12:11 +0200 Subject: [PATCH] Give the test-host scope one owner: only the newest live test host runs its restartable services, the reset covers every live host, and duplicate connection string keys fail fast --- docs/articles/testing.md | 15 +- src/Vulthil.xUnit/BaseIntegrationTestCase.cs | 16 +- .../BaseWebApplicationFactory.cs | 124 ++---- src/Vulthil.xUnit/TestHostReset.cs | 86 +++- src/Vulthil.xUnit/TestHostScope.cs | 258 ++++++++++++ .../FixtureResetRoutingTests.cs | 13 +- .../RestartProbeWebApplicationFactory.cs | 14 +- .../Fixtures/RestartableProbe.cs | 12 +- .../LiveTestHostPauseTests.cs | 51 +++ .../Vulthil.xUnit.Tests/TestHostResetTests.cs | 19 +- .../Vulthil.xUnit.Tests/TestHostScopeTests.cs | 386 ++++++++++++++++++ 11 files changed, 837 insertions(+), 157 deletions(-) create mode 100644 src/Vulthil.xUnit/TestHostScope.cs create mode 100644 tests/Vulthil.IntegrationTests/LiveTestHostPauseTests.cs create mode 100644 tests/Vulthil.xUnit.Tests/TestHostScopeTests.cs diff --git a/docs/articles/testing.md b/docs/articles/testing.md index f70fa036..fcd46300 100644 --- a/docs/articles/testing.md +++ b/docs/articles/testing.md @@ -116,12 +116,15 @@ Key features: the test runs on a per-test copy of the host with that service replaced. Register under the type the application resolves (`Use(stub)`, not `Use(stub)`). A test that registers nothing runs on the shared host; registering after the host is built throws, so a test never silently runs against the real service. Outbound HTTP - has its own mock, see [below](#mocking-outbound-http-dependencies). + has its own mock, see [below](#mocking-outbound-http-dependencies). The per-test host uses the class's databases and + virtual hosts, so while it runs, the shared host pauses its restartable hosted services (see the reset below) and + resumes them after the test: the two hosts never compete for the same queue or the same database rows. - **Automatic database reset** – the database is reset with Respawn after each test, so tests sharing a factory start from a clean state. Hosted services implementing `IRestartableHostedService` (from - `Vulthil.Extensions.Hosting`) are stopped around the reset and restarted afterwards, so a database-polling relay such - as the outbox background service never contends with it, and the message consumers stop consuming until the reset - is done. Every stop, reset and restart step is bounded by its own + `Vulthil.Extensions.Hosting`) on every live host of the class are stopped around the reset and restarted afterwards, + so a database-polling relay such as the outbox background service never contends with it, and the message consumers + stop consuming until the reset is done. Only the newest live host runs those services at any time: a per-test host + pauses the hosts built before it until it stops. Every stop, reset and restart step is bounded by its own 30-second timeout rather than the test's cancellation token, a failing step never skips the remaining ones, and all failures are reported together — a test that timed out still leaves a clean fixture for the next one. - **Log capture** – application logs are routed to the currently running test automatically (via `TestContext`). The @@ -136,7 +139,7 @@ Key features: `Vulthil.xUnit` ships fixture base classes (in the `Vulthil.xUnit.Fixtures` namespace) that wrap [Testcontainers](https://testcontainers.com/) containers so you can spin up databases, message brokers, and other dependencies as Docker containers. There are three levels, depending on what the container needs to expose: - `TestContainerFixture` – a plain container with a managed lifecycle (`ITestContainer`). -- `TestContainerFixtureWithConnectionString` – adds a connection string that is injected into the host's configuration under `ConnectionStrings:{ConnectionStringKey}` (`ITestContainerWithConnectionString`). Give `ConnectionStringKey` the bare name (e.g. `"AppDb"`); the factory adds the `ConnectionStrings:` prefix. +- `TestContainerFixtureWithConnectionString` – adds a connection string that is injected into the host's configuration under `ConnectionStrings:{ConnectionStringKey}` (`ITestContainerWithConnectionString`). Give `ConnectionStringKey` the bare name (e.g. `"AppDb"`); the factory adds the `ConnectionStrings:` prefix. Every container a factory consumes needs its own key: when two consumed containers use the same key (compared case-insensitively, like configuration keys), the factory's `InitializeAsync` throws instead of letting one connection string overwrite the other. - `TestDatabaseContainerFixture` – adds EF Core migrations and Respawn-based data reset between tests (`ITestDatabaseContainer`). None of them needs constructor arguments. Pass an `IMessageSink` to route Testcontainers' own log output somewhere @@ -230,6 +233,8 @@ Every container on the host is consumed automatically, so containers are managed The scope identifier defaults to the factory type name plus a random suffix (override `CreateScopeId()` to change it), so two classes using the same factory type still get distinct databases and virtual hosts. +A scope lives as long as its factory: one test class. All tests of the class — and the per-test hosts they build — share its database and virtual host. The database is reset after each test, but the queues are not purged, so a message that one test leaves in a queue is delivered during the next test of the class. Wait for the messages a test publishes before the test ends. + ### Mocking outbound HTTP dependencies For a service that calls an external API through an `HttpClient` from `IHttpClientFactory`, register an in-process HTTP mock on the factory. It replaces that client's primary message handler, so the real client code runs (URL building, serialization, the delegating-handler pipeline) and only the wire is faked. Both **typed** clients (`AddHttpClient()`) and **named** clients (`AddHttpClient("name")`) are supported; for typed clients the implementation type does not need to be accessible: diff --git a/src/Vulthil.xUnit/BaseIntegrationTestCase.cs b/src/Vulthil.xUnit/BaseIntegrationTestCase.cs index afbb96ea..785e6309 100644 --- a/src/Vulthil.xUnit/BaseIntegrationTestCase.cs +++ b/src/Vulthil.xUnit/BaseIntegrationTestCase.cs @@ -19,8 +19,10 @@ namespace Vulthil.xUnit; /// Supply the factory as an (or collection fixture) so its containers are /// started once and shared across the tests in that scope; database state is reset after each test. Tests that /// register no services share the fixture's test host; a test that registers services runs on a derived host built -/// through , disposed after the test. Application -/// logs reach the currently running test through the factory's TestContext-routed logger. +/// through , disposed after the test. Both hosts use +/// the fixture's databases and virtual hosts, so while the derived host runs, the shared host pauses its restartable +/// hosted services (see ). Application logs reach the currently +/// running test through the factory's TestContext-routed logger. /// /// The application's entry point type, typically Program. public abstract class BaseIntegrationTestCase : BaseUnitTestCase @@ -123,7 +125,7 @@ protected HttpClient Client /// yourself (for example FactoryFixture.WithWebHostBuilder(...)) when the tests need other per-test host /// configuration; call from the builder's ConfigureTestServices to /// keep the registered doubles. A derived factory is disposed automatically after each test, and the post-test - /// reset always targets whichever factory this method returns, never an unrelated, never-built host. + /// reset covers every live host of the fixture without building one. /// /// The factory the current test should run against. protected virtual WebApplicationFactory CreateFactory() @@ -229,7 +231,7 @@ public override async ValueTask InitializeAsync() } /// - /// Resets the host this test ran on (restartable services paused, resettable resources cleared), disposes the + /// Resets the fixture's live hosts (restartable services paused, resettable resources cleared), disposes the /// scope, the client and any per-test derived factory, then disposes everything the auto-mocker holds. Override /// (calling the base implementation) for further cleanup. /// @@ -238,12 +240,10 @@ protected override async ValueTask Dispose() { try { - // Only the factory this test actually ran on (Factory, e.g. a WithWebHostBuilder(...) clone of - // FactoryFixture) has the running host whose restartable services need pausing around the reset; a test - // that never touched Factory never built any host, so there is nothing to reset. + // A test that never touched Factory changed nothing, so there is nothing to reset. if (_lazyFactory.IsValueCreated) { - await FactoryFixture.ResetAsync(_lazyFactory.Value.Services).ConfigureAwait(false); + await FactoryFixture.ResetAsync().ConfigureAwait(false); } } finally diff --git a/src/Vulthil.xUnit/BaseWebApplicationFactory.cs b/src/Vulthil.xUnit/BaseWebApplicationFactory.cs index 1c804f14..8aa22b2b 100644 --- a/src/Vulthil.xUnit/BaseWebApplicationFactory.cs +++ b/src/Vulthil.xUnit/BaseWebApplicationFactory.cs @@ -17,26 +17,33 @@ namespace Vulthil.xUnit; /// ensures EF Core migrations are applied during host startup. /// /// +/// /// Register the containers once on a assembly fixture and pass it to the constructor; the /// factory then consumes every host container (filter with ) through a per-factory /// scope view — an isolated database, virtual host, ... — so test classes run in parallel against shared containers /// without interfering. Use a derived factory as an (or collection fixture) so /// its scopes are provisioned once and shared across the tests in that scope, while /// resets database state between tests. +/// +/// /// Migrations run from an registered at the front of the host's hosted-service list, so the /// schema exists before the application's own background services start. The migration step only applies migrations that /// are still pending and tolerates a concurrent migrator, so an application that migrates itself on startup keeps /// ownership and the factory never interferes with the application's own migration logic. +/// +/// +/// Every host the factory builds — its own and each one derived through WithWebHostBuilder — uses the same scope +/// views, so the same databases and virtual hosts. Only the newest live host runs its +/// IRestartableHostedServices: while a derived host runs, the hosts before it pause their restartable services +/// (for example the outbox relay and the message consumers), and they resume when the derived host stops. +/// /// -public abstract class BaseWebApplicationFactory : WebApplicationFactory, IAsyncLifetime, ITestHostMigrator +public abstract class BaseWebApplicationFactory : WebApplicationFactory, IAsyncLifetime where TEntryPoint : class { - private readonly ContainerHost _containerHost; - private readonly HashSet _containers = []; + private readonly TestHostScope _scope; private readonly Dictionary _httpMocks = []; private readonly List> _httpClientConfigurations = []; - private readonly TestHostReset _reset = new(TimeProvider.System); - private bool _initialized; /// /// Initializes a factory that consumes the shared containers of . @@ -45,21 +52,9 @@ public abstract class BaseWebApplicationFactory : WebApplicationFac protected BaseWebApplicationFactory(ContainerHost containerHost) { ArgumentNullException.ThrowIfNull(containerHost); - _containerHost = containerHost; + _scope = new TestHostScope(containerHost, ShouldUseContainer, CreateScopeId, TimeProvider.System); } - private IEnumerable ContainersWithConnectionStrings => _containers - .OfType(); - - private IEnumerable DatabaseContainers => _containers - .OfType(); - - private IEnumerable ResettableResources => _containers - .OfType() - .Concat(_httpMocks.Values); - - private IEnumerable StartupResources => _containers.OfType(); - /// /// Registers an in-process HTTP mock for the named registered with /// AddHttpClient(""), and routes that client's outbound calls through it by replacing @@ -162,20 +157,19 @@ protected override sealed void ConfigureWebHost(IWebHostBuilder builder) IncludeScopes = true, }))); - foreach (var container in ContainersWithConnectionStrings) + foreach (var (key, connectionString) in _scope.ConnectionStrings) { - var connectionString = container.ConnectionString; - builder.UseSetting($"ConnectionStrings:{container.ConnectionStringKey}", connectionString); + builder.UseSetting(key, connectionString); } - foreach (var container in _containers) + foreach (var container in _scope.Containers) { container.ConfigureWebHost(builder); } builder.ConfigureTestServices(services => { - foreach (var container in _containers) + foreach (var container in _scope.Containers) { container.ConfigureServices(services); } @@ -185,8 +179,7 @@ protected override sealed void ConfigureWebHost(IWebHostBuilder builder) builder.ConfigureServices(services => { - services.Insert(0, ServiceDescriptor.Singleton( - sp => new TestMigrationHostedService(this, sp))); + services.Insert(0, ServiceDescriptor.Singleton(_scope.CreateHostLifecycle)); foreach (var configureHttpClient in _httpClientConfigurations) { @@ -200,38 +193,8 @@ protected override sealed void ConfigureWebHost(IWebHostBuilder builder) /// each of them in parallel. Invoked once by xUnit before the tests in scope run. /// /// A task representing the asynchronous startup work. - public async ValueTask InitializeAsync() - { - if (_initialized) - { - return; - } - - await AcquireHostContainers().ConfigureAwait(false); - await Parallel.ForEachAsync(_containers, (container, ct) => container.InitializeAsync()).ConfigureAwait(false); - _initialized = true; - } - - private async Task AcquireHostContainers() - { - var consumedContainers = _containerHost.Containers.Where(ShouldUseContainer).ToList(); - await Parallel.ForEachAsync(consumedContainers, async (container, ct) => await _containerHost.EnsureStartedAsync(container).ConfigureAwait(false)).ConfigureAwait(false); - - var scopeId = CreateScopeId(); - foreach (var container in consumedContainers) - { -#pragma warning disable CA2000 // Ownership transfers to _containers; scope views are disposed in DisposeAsync. - _containers.Add(CreateScopeView(container, scopeId)); -#pragma warning restore CA2000 - } - } - - private static ITestContainer CreateScopeView(ITestContainer container, string scopeId) => container switch - { - ITestContainerScopeProvider scopeProvider => scopeProvider.CreateScope(scopeId), - ITestContainerWithConnectionString withConnectionString => new TestContainerWithConnectionStringScope(withConnectionString), - _ => new TestContainerScope(container), - }; + /// Two consumed containers use the same connection string key. + public async ValueTask InitializeAsync() => await _scope.InitializeAsync().ConfigureAwait(false); /// public override async ValueTask DisposeAsync() @@ -242,48 +205,15 @@ public override async ValueTask DisposeAsync() // per-factory scopes. The containers themselves are owned by the ContainerHost and outlive the factory. await base.DisposeAsync().ConfigureAwait(false); - await Parallel.ForEachAsync(_containers, (container, ct) => container.DisposeAsync()).ConfigureAwait(false); - } - - async Task ITestHostMigrator.PrepareAsync(IServiceProvider serviceProvider) - { - var scope = serviceProvider.CreateAsyncScope(); - await using var _ = scope.ConfigureAwait(false); - await Parallel.ForEachAsync(DatabaseContainers, (container, ct) => container.MigrateDatabase(scope.ServiceProvider)).ConfigureAwait(false); - await Parallel.ForEachAsync(StartupResources, (resource, ct) => resource.InitializeAsync(serviceProvider)).ConfigureAwait(false); + await _scope.DisposeAsync().ConfigureAwait(false); } /// - /// Resets the given host's restartable services and this factory's registered resources. Accepts the host's - /// service provider explicitly so a caller running against a derived factory (for example one produced by - /// WithWebHostBuilder(...)) can pass that host's own — resetting always - /// targets the host the caller actually ran the test against, never an unrelated, never-built host. + /// Resets the factory's scope between tests: pauses the restartable services of every running host built by this + /// factory (its own and any derived through WithWebHostBuilder), resets the scope's resources and the HTTP + /// mocks, then resumes the paused services. Does nothing when no host is live, so a test that built no host never + /// builds one just to reset it. /// - /// The service provider of the host the test ran against. - internal Task ResetAsync(IServiceProvider hostServices) => _reset.ResetAsync(hostServices, [.. ResettableResources]); -} - -internal interface ITestHostMigrator -{ - Task PrepareAsync(IServiceProvider serviceProvider); -} - -internal sealed class TestMigrationHostedService(ITestHostMigrator migrator, IServiceProvider serviceProvider) : IHostedService -{ - private bool _completed; - - /// - public async Task StartAsync(CancellationToken cancellationToken) - { - if (_completed) - { - return; - } - - await migrator.PrepareAsync(serviceProvider).ConfigureAwait(false); - _completed = true; - } - - /// - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// A task that completes when every step has run. + internal Task ResetAsync() => _scope.ResetAsync([.. _httpMocks.Values]); } diff --git a/src/Vulthil.xUnit/TestHostReset.cs b/src/Vulthil.xUnit/TestHostReset.cs index 91f1da77..da749820 100644 --- a/src/Vulthil.xUnit/TestHostReset.cs +++ b/src/Vulthil.xUnit/TestHostReset.cs @@ -5,12 +5,12 @@ namespace Vulthil.xUnit; /// -/// Resets a test host between tests: stops every the host runs, resets the -/// given resources, then restarts exactly the services it stopped. Each step runs on its own -/// -bounded token, never the test's — a test that timed out must still leave a clean fixture -/// behind for the next one. A failing step never skips the remaining steps, so a healthy service is always restarted -/// and a database is reset even when a service misbehaves; every failure is reported together, naming the offending -/// service or resource. +/// Stops and starts the s of test hosts, and resets a test host between tests: +/// stops the given services, resets the given resources, then restarts exactly the services it stopped. Each step runs +/// on its own -bounded token, never the test's — a test that timed out must still leave a +/// clean fixture behind for the next one. A failing step never skips the remaining steps, so a healthy service is +/// always restarted and a database is reset even when a service misbehaves; every failure is reported together, +/// naming the offending service or resource. /// /// The clock the per-step timeouts run on. internal sealed class TestHostReset(TimeProvider timeProvider) @@ -19,19 +19,48 @@ internal sealed class TestHostReset(TimeProvider timeProvider) internal static readonly TimeSpan StepTimeout = TimeSpan.FromSeconds(30); /// - /// Runs the stop, reset, restart sequence against . + /// Returns the restartable hosted services of the host that owns . /// - /// The service provider of the host the test ran against. - /// The resources to reset while the restartable services are paused. + /// The root service provider of a test host. + /// The host's restartable hosted services, in registration order. + public static IReadOnlyList RestartableServicesOf(IServiceProvider hostServices) => + [.. hostServices.GetServices().OfType()]; + + /// + /// Runs the stop, reset, restart sequence. + /// + /// The services to pause while the resources are reset. + /// The resources to reset while the services are paused. + /// The service provider the resources resolve application services from. /// A task that completes when every step has run. /// One or more steps failed or timed out; every other step still ran. - public async Task ResetAsync(IServiceProvider hostServices, IReadOnlyCollection resources) + public async Task ResetAsync( + IReadOnlyCollection services, + IReadOnlyCollection resources, + IServiceProvider resourceServices) { - var restartableServices = hostServices.GetServices().OfType().ToList(); var failures = new List(); - var stoppedServices = new List(); + var stoppedServices = await StopAsync(services, failures).ConfigureAwait(false); + + var resetFailures = await Task.WhenAll(resources.Select(resource => TryResetAsync(resource, resourceServices))).ConfigureAwait(false); + failures.AddRange(resetFailures.OfType()); - foreach (var service in restartableServices) + await StartAsync(stoppedServices, failures).ConfigureAwait(false); + + ThrowIfFailed(failures, "Resetting the test host after the test failed; every remaining step still ran."); + } + + /// + /// Stops every service in turn and returns the services that stopped; a stop that fails or times out is added to + /// and the next service is still stopped. + /// + /// The services to stop. + /// Collects the failed and timed-out stops. + /// The services that stopped, in the order they stopped. + public async Task> StopAsync(IEnumerable services, List failures) + { + var stoppedServices = new List(); + foreach (var service in services) { if (await TryRunStepAsync(service.StopAsync, $"Stopping '{service.GetType().Name}'", failures).ConfigureAwait(false)) { @@ -39,18 +68,37 @@ public async Task ResetAsync(IServiceProvider hostServices, IReadOnlyCollection< } } - var resetFailures = await Task.WhenAll(resources.Select(resource => TryResetAsync(resource, hostServices))).ConfigureAwait(false); - failures.AddRange(resetFailures.OfType()); + return stoppedServices; + } - foreach (var service in stoppedServices) + /// + /// Starts every service in turn; a start that fails or times out is added to and the + /// next service is still started. + /// + /// The services to start. + /// Collects the failed and timed-out starts. + /// A task that completes when every service has been started. + public async Task StartAsync(IEnumerable services, List failures) + { + foreach (var service in services) { await TryRunStepAsync(service.StartAsync, $"Restarting '{service.GetType().Name}'", failures).ConfigureAwait(false); } + } + /// + /// Throws an with when + /// holds any failure. + /// + /// The failures collected by the steps. + /// What failed, for the exception message. + /// is not empty. + public static void ThrowIfFailed(List failures, string message) + { if (failures.Count > 0) { throw new AggregateException( - "Resetting the test host after the test failed; every remaining step still ran. See the inner exceptions for the failing services and resources.", + $"{message} See the inner exceptions for the failing services and resources.", failures); } } @@ -77,11 +125,11 @@ private async Task TryRunStepAsync(Func step, str } } - private static async Task TryResetAsync(IResettableResource resource, IServiceProvider hostServices) + private static async Task TryResetAsync(IResettableResource resource, IServiceProvider resourceServices) { try { - await resource.ResetAsync(hostServices).ConfigureAwait(false); + await resource.ResetAsync(resourceServices).ConfigureAwait(false); return null; } catch (Exception exception) diff --git a/src/Vulthil.xUnit/TestHostScope.cs b/src/Vulthil.xUnit/TestHostScope.cs new file mode 100644 index 00000000..fdeaa1bd --- /dev/null +++ b/src/Vulthil.xUnit/TestHostScope.cs @@ -0,0 +1,258 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Vulthil.Extensions.Hosting; +using Vulthil.xUnit.Fixtures; + +namespace Vulthil.xUnit; + +/// +/// Owns the test-host scope of one factory: the scope views of the shared containers it consumes, the connection +/// strings and the start-up order of every test host built against them, the set of live hosts, the reset between +/// tests, and the disposal of the views. +/// +/// +/// A test class's shared host and every per-test host derived from it use the same scope, and so the same databases, +/// virtual hosts and other namespaces. Only the newest live host runs its s: +/// when a host starts, the other live hosts' restartable services are paused, and when the newest host stops, the +/// host before it resumes. So two hosts never run background work — a relay polling the database, consumers reading +/// the same queues — against the shared state at once. +/// +internal sealed class TestHostScope : IAsyncDisposable +{ + private readonly ContainerHost _containerHost; + private readonly Func _shouldUseContainer; + private readonly Func _createScopeId; + private readonly TestHostReset _reset; + private readonly SemaphoreSlim _hostsGate = new(1, 1); + private readonly List _liveHosts = []; + private List _containers = []; + private bool _initialized; + + /// + /// Initializes a scope over the containers of . + /// + /// The assembly-level host whose containers the scope consumes. + /// Decides whether the scope consumes a host container. + /// Creates the identifier the scope's views are isolated under. + /// The clock the per-step timeouts of pauses and resets run on. + public TestHostScope(ContainerHost containerHost, Func shouldUseContainer, Func createScopeId, TimeProvider timeProvider) + { + _containerHost = containerHost; + _shouldUseContainer = shouldUseContainer; + _createScopeId = createScopeId; + _reset = new TestHostReset(timeProvider); + } + + /// + /// Gets the scope views of the consumed containers, in the order the container host registered them. + /// + public IReadOnlyList Containers => _containers; + + /// + /// Gets the configuration a test host needs to reach the consumed containers: one + /// ConnectionStrings:{key} entry per container with a connection string. + /// + public IEnumerable> ConnectionStrings => _containers + .OfType() + .Select(container => KeyValuePair.Create($"ConnectionStrings:{container.ConnectionStringKey}", container.ConnectionString)); + + /// + /// Starts the consumed host containers (once per test run, on the container host) and provisions this scope in + /// each of them in parallel. Runs once; later calls do nothing. + /// + /// A task representing the asynchronous startup work. + /// + /// Two consumed containers use the same connection string key; the check runs before the scope's namespaces are + /// provisioned. + /// + public async Task InitializeAsync() + { + if (_initialized) + { + return; + } + + var consumedContainers = _containerHost.Containers.Where(_shouldUseContainer).ToList(); + await Parallel.ForEachAsync(consumedContainers, async (container, ct) => await _containerHost.EnsureStartedAsync(container).ConfigureAwait(false)).ConfigureAwait(false); + + var scopeId = _createScopeId(); +#pragma warning disable CA2000 // Ownership transfers to _containers; scope views are disposed in DisposeAsync. + var views = consumedContainers.Select(container => (Container: container, View: CreateScopeView(container, scopeId))).ToList(); +#pragma warning restore CA2000 + ThrowOnSharedConnectionStringKey(views); + _containers = [.. views.Select(pair => pair.View)]; + await Parallel.ForEachAsync(_containers, (container, ct) => container.InitializeAsync()).ConfigureAwait(false); + _initialized = true; + } + + /// + /// Creates the hosted service that connects a test host to this scope. Register it first in the host, so its start + /// runs before the application's own hosted services and its stop runs after them. + /// + /// The root service provider of the host. + /// The hosted service to register. + public IHostedService CreateHostLifecycle(IServiceProvider hostServices) => new HostLifecycle(this, hostServices); + + /// + /// Resets the scope between tests: pauses the restartable services of every running live host, resets the scope's + /// resettable views and , and resumes the services it paused. Does nothing + /// when no host is live. Resources resolve application services from the newest live host — the one the test ran on. + /// + /// Resources the adapter owns, reset together with the scope's views. + /// A task that completes when every step has run. + /// One or more steps failed or timed out; every other step still ran. + public async Task ResetAsync(IReadOnlyCollection additionalResources) + { + await _hostsGate.WaitAsync().ConfigureAwait(false); + try + { + if (_liveHosts.Count == 0) + { + return; + } + + var runningServices = _liveHosts + .Where(host => !host.IsPaused) + .SelectMany(host => TestHostReset.RestartableServicesOf(host.Services)) + .ToList(); + var resources = _containers.OfType().Concat(additionalResources).ToList(); + await _reset.ResetAsync(runningServices, resources, _liveHosts[^1].Services).ConfigureAwait(false); + } + finally + { + _hostsGate.Release(); + } + } + + /// + /// Disposes the scope views, which removes the scope's namespaces from the shared containers. Dispose the hosts + /// first, so their graceful shutdown still has its infrastructure. + /// + /// A task representing the asynchronous dispose work. + public async ValueTask DisposeAsync() + { + await Parallel.ForEachAsync(_containers, (container, ct) => container.DisposeAsync()).ConfigureAwait(false); + _hostsGate.Dispose(); + } + + /// + /// Throws when two views would write the same connection string key, which the test host would otherwise resolve + /// silently to one of them. Runs before the views provision their namespaces, so a misconfigured scope creates no + /// database or virtual host. + /// + private static void ThrowOnSharedConnectionStringKey(IReadOnlyList<(ITestContainer Container, ITestContainer View)> views) + { + // Configuration keys are case-insensitive, so keys that differ only in case would still overwrite each other. + var sharedKey = views + .Where(pair => pair.View is ITestContainerWithConnectionString) + .GroupBy(pair => ((ITestContainerWithConnectionString)pair.View).ConnectionStringKey, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Skip(1).Any()); + + if (sharedKey is not null) + { + var containerNames = string.Join(", ", sharedKey.Select(pair => $"'{pair.Container.GetType().Name}'")); + throw new InvalidOperationException( + $"The containers {containerNames} all use the connection string key '{sharedKey.Key}', so only one of them would reach the test host. " + + "Give each consumed container its own ConnectionStringKey, or exclude the extra ones with ShouldUseContainer."); + } + } + + private static ITestContainer CreateScopeView(ITestContainer container, string scopeId) => container switch + { + ITestContainerScopeProvider scopeProvider => scopeProvider.CreateScope(scopeId), + ITestContainerWithConnectionString withConnectionString => new TestContainerWithConnectionStringScope(withConnectionString), + _ => new TestContainerScope(container), + }; + + /// + /// Registers a starting host: pauses the restartable services of every other running live host, applies pending + /// database migrations, and runs the one-time startup resources, all before the host's own hosted services start. + /// + private async Task HostStartingAsync(IServiceProvider hostServices) + { + await _hostsGate.WaitAsync().ConfigureAwait(false); + try + { + if (_liveHosts.Exists(host => ReferenceEquals(host.Services, hostServices))) + { + return; + } + + var failures = new List(); + foreach (var host in _liveHosts.Where(host => !host.IsPaused)) + { + host.PausedServices = await _reset.StopAsync(TestHostReset.RestartableServicesOf(host.Services), failures).ConfigureAwait(false); + } + + // The host joins the live set even when a pause failed, so its stop still resumes the hosts it paused. + _liveHosts.Add(new LiveHost(hostServices)); + TestHostReset.ThrowIfFailed(failures, "Pausing the other live test hosts before a new test host started failed."); + + await PrepareHostAsync(hostServices).ConfigureAwait(false); + } + finally + { + _hostsGate.Release(); + } + } + + /// + /// Removes a stopped host from the live set and resumes the newest remaining host when this host had paused it. + /// + private async Task HostStoppedAsync(IServiceProvider hostServices) + { + await _hostsGate.WaitAsync().ConfigureAwait(false); + try + { + if (_liveHosts.RemoveAll(host => ReferenceEquals(host.Services, hostServices)) == 0 || _liveHosts.Count == 0) + { + return; + } + + var newest = _liveHosts[^1]; + if (newest.PausedServices is not { } pausedServices) + { + return; + } + + newest.PausedServices = null; + var failures = new List(); + await _reset.StartAsync(pausedServices, failures).ConfigureAwait(false); + TestHostReset.ThrowIfFailed(failures, "Resuming the paused test host after a newer test host stopped failed."); + } + finally + { + _hostsGate.Release(); + } + } + + private async Task PrepareHostAsync(IServiceProvider hostServices) + { + var scope = hostServices.CreateAsyncScope(); + await using var _ = scope.ConfigureAwait(false); + await Parallel.ForEachAsync(_containers.OfType(), (container, ct) => container.MigrateDatabase(scope.ServiceProvider)).ConfigureAwait(false); + await Parallel.ForEachAsync(_containers.OfType(), (resource, ct) => resource.InitializeAsync(hostServices)).ConfigureAwait(false); + } + + /// + /// A host built against the scope, with the restartable services the scope paused for a newer host, if any. + /// + private sealed class LiveHost(IServiceProvider services) + { + public IServiceProvider Services { get; } = services; + + public IReadOnlyList? PausedServices { get; set; } + + public bool IsPaused => PausedServices is not null; + } + + /// + /// Connects one test host to the scope for the host's lifetime. + /// + private sealed class HostLifecycle(TestHostScope scope, IServiceProvider hostServices) : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) => scope.HostStartingAsync(hostServices); + + public Task StopAsync(CancellationToken cancellationToken) => scope.HostStoppedAsync(hostServices); + } +} diff --git a/tests/Vulthil.IntegrationTests/FixtureResetRoutingTests.cs b/tests/Vulthil.IntegrationTests/FixtureResetRoutingTests.cs index dca9a88a..93450868 100644 --- a/tests/Vulthil.IntegrationTests/FixtureResetRoutingTests.cs +++ b/tests/Vulthil.IntegrationTests/FixtureResetRoutingTests.cs @@ -28,15 +28,12 @@ public async Task ResettingAfterATestPausesAndResumesOnlyTheHostTheTestActuallyU // Act — runs the same reset dance xUnit would run automatically at the end of this test. await DisposeAsync(); - // Assert — the reset dance ran exactly once, on the one host this test built: an initial auto-start, then - // the reset's own stop/start pair. Trailing "stop" events beyond that come from the derived factory's own - // teardown disposing its host afterwards (unrelated to the reset) and are intentionally not asserted here. - // Exactly two "start" events total is the key signal: the pre-fix code additionally built (and - // auto-started) FactoryFixture's own, otherwise-unused host as a side effect of resetting through it - // directly instead of the host this test actually ran on. + // Assert — the reset ran on the one host this test built: an initial auto-start, then the reset's own + // stop/start pair; later events come from the derived factory's teardown. Every event belongs to that host, so + // resetting never builds FactoryFixture's own, otherwise-unused host. var events = ProbeFactory.Events.ToArray(); - events.Take(3).ShouldBe(["start", "stop", "start"]); - events.Count(e => e == "start").ShouldBe(2); + events.Take(3).ShouldBe(["start:host1", "stop:host1", "start:host1"]); + events.ShouldAllBe(e => e.EndsWith(":host1", StringComparison.Ordinal)); } /// diff --git a/tests/Vulthil.IntegrationTests/Fixtures/RestartProbeWebApplicationFactory.cs b/tests/Vulthil.IntegrationTests/Fixtures/RestartProbeWebApplicationFactory.cs index 60ec4393..d11e0133 100644 --- a/tests/Vulthil.IntegrationTests/Fixtures/RestartProbeWebApplicationFactory.cs +++ b/tests/Vulthil.IntegrationTests/Fixtures/RestartProbeWebApplicationFactory.cs @@ -12,15 +12,17 @@ namespace Vulthil.IntegrationTests.Fixtures; /// /// Boots the test host against the shared PostgreSQL server only (the broker transport is swapped for the in-memory /// harness, same as ), and additionally registers a -/// that records every start/stop it observes into . Used to verify -/// that resetting after a test pauses and resumes the host the test actually ran on, not an unrelated, never-built -/// clone of this fixture. +/// that records every start/stop it observes into , tagged with the +/// host: the first host this factory builds is host1, the next host2, and so on. Used to verify which +/// hosts a reset and a live-host pause stop and start. /// public sealed class RestartProbeWebApplicationFactory(IntegrationTestContainerHost containerHost) : BaseWebApplicationFactory(containerHost) { + private int _hostCount; + /// - /// Gets the shared log of "start"/"stop" events recorded by every this factory's - /// hosts (its own, or any WithWebHostBuilder clone of it) have registered. + /// Gets the shared log of start:hostN/stop:hostN events recorded by every + /// this factory's hosts (its own, or any WithWebHostBuilder clone of it) have registered. /// public ConcurrentQueue Events { get; } = new(); @@ -36,7 +38,7 @@ protected override void ConfigureCustomWebHost(IWebHostBuilder builder) builder.ConfigureServices(services => { services.ReplaceTransportWithTestHarness(); - services.AddSingleton(new RestartableProbe(Events)); + services.AddSingleton(new RestartableProbe(Events, $"host{Interlocked.Increment(ref _hostCount)}")); }); } } diff --git a/tests/Vulthil.IntegrationTests/Fixtures/RestartableProbe.cs b/tests/Vulthil.IntegrationTests/Fixtures/RestartableProbe.cs index cf8724e7..e29d2558 100644 --- a/tests/Vulthil.IntegrationTests/Fixtures/RestartableProbe.cs +++ b/tests/Vulthil.IntegrationTests/Fixtures/RestartableProbe.cs @@ -4,22 +4,22 @@ namespace Vulthil.IntegrationTests.Fixtures; /// -/// Restartable hosted service that records every start/stop into the given shared queue, so a test can verify -/// exactly which host instance a database reset paused and resumed — used to cover the fixture-vs-derived-factory -/// reset routing in . +/// Restartable hosted service that records every start/stop into the given shared queue, tagged with the host it runs +/// in, so a test can verify exactly which host instance a reset or a pause stopped and started. /// /// The queue this instance's start/stop events are recorded into. -internal sealed class RestartableProbe(ConcurrentQueue events) : IRestartableHostedService +/// The tag of the host this instance runs in, such as host1. +internal sealed class RestartableProbe(ConcurrentQueue events, string host) : IRestartableHostedService { public Task StartAsync(CancellationToken cancellationToken) { - events.Enqueue("start"); + events.Enqueue($"start:{host}"); return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { - events.Enqueue("stop"); + events.Enqueue($"stop:{host}"); return Task.CompletedTask; } } diff --git a/tests/Vulthil.IntegrationTests/LiveTestHostPauseTests.cs b/tests/Vulthil.IntegrationTests/LiveTestHostPauseTests.cs new file mode 100644 index 00000000..ad06cd99 --- /dev/null +++ b/tests/Vulthil.IntegrationTests/LiveTestHostPauseTests.cs @@ -0,0 +1,51 @@ +using Vulthil.IntegrationTests.Fixtures; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.xUnit; + +namespace Vulthil.IntegrationTests; + +/// +/// Covers the live-host rule of : while a test runs on its own +/// derived host, the class's shared host pauses its restartable hosted services, the reset leaves them paused, and they +/// resume once the derived host stops. +/// +public sealed class LiveTestHostPauseTests(RestartProbeWebApplicationFactory factory) + : BaseIntegrationTestCase(factory), IClassFixture +{ + private bool _disposedOnce; + + private RestartProbeWebApplicationFactory ProbeFactory => (RestartProbeWebApplicationFactory)FactoryFixture; + + [Fact] + public async Task TheSharedHostPausesWhileATestRunsOnItsOwnHostAndResumesAfterTheTest() + { + // Arrange + _ = FactoryFixture.Services; + GetMock(); + _ = Factory.Services; + var whileTheTestHostRuns = ProbeFactory.Events.ToArray(); + + // Act + await DisposeAsync(); + + // Assert + whileTheTestHostRuns.ShouldBe(["start:host1", "stop:host1", "start:host2"]); + ProbeFactory.Events.Where(e => e.EndsWith(":host1", StringComparison.Ordinal)).ShouldBe(["start:host1", "stop:host1", "start:host1"]); + var testHostEvents = ProbeFactory.Events.Where(e => e.EndsWith(":host2", StringComparison.Ordinal)).ToArray(); + testHostEvents.Take(3).ShouldBe(["start:host2", "stop:host2", "start:host2"]); + testHostEvents.Skip(3).ShouldNotBeEmpty(); + testHostEvents.Skip(3).ShouldAllBe(e => e == "stop:host2"); + } + + /// + protected override async ValueTask Dispose() + { + if (_disposedOnce) + { + return; + } + + _disposedOnce = true; + await base.Dispose(); + } +} diff --git a/tests/Vulthil.xUnit.Tests/TestHostResetTests.cs b/tests/Vulthil.xUnit.Tests/TestHostResetTests.cs index 05293d22..dcf825b7 100644 --- a/tests/Vulthil.xUnit.Tests/TestHostResetTests.cs +++ b/tests/Vulthil.xUnit.Tests/TestHostResetTests.cs @@ -30,6 +30,9 @@ private static ServiceProvider HostWith(params IHostedService[] services) private static readonly Func Throw = _ => throw new InvalidOperationException("boom"); + private Task ResetAsync(IServiceProvider host, IReadOnlyCollection resources) => + Target.ResetAsync(TestHostReset.RestartableServicesOf(host), resources, host); + public sealed class RecordingService( ICollection log, string name, @@ -81,7 +84,7 @@ public async Task ResetStopsEveryServiceBeforeResettingAndRestartsThemAfterwards await using var host = HostWith(new RecordingService(_log, "a"), new RecordingService(_log, "b")); // Act - await Target.ResetAsync(host, [new RecordingResource(_log, "db")]); + await ResetAsync(host, [new RecordingResource(_log, "db")]); // Assert _log.ShouldBe(["stop:a", "stop:b", "reset:db", "start:a", "start:b"]); @@ -94,7 +97,7 @@ public async Task AServiceThatIsNotRestartableIsLeftAlone() await using var host = HostWith(new PlainService(_log), new RecordingService(_log, "a")); // Act - await Target.ResetAsync(host, []); + await ResetAsync(host, []); // Assert _log.ShouldBe(["stop:a", "start:a"]); @@ -107,7 +110,7 @@ public async Task AFailingStopStillResetsTheResourcesAndRestartsOnlyTheServicesT await using var host = HostWith(new RecordingService(_log, "a", onStop: Throw), new RecordingService(_log, "b")); // Act - var exception = await Should.ThrowAsync(() => Target.ResetAsync(host, [new RecordingResource(_log, "db")])); + var exception = await Should.ThrowAsync(() => ResetAsync(host, [new RecordingResource(_log, "db")])); // Assert _log.ShouldBe(["stop:a", "stop:b", "reset:db", "start:b"]); @@ -123,7 +126,7 @@ public async Task AFailingRestartStillRestartsTheOtherServicesAndReportsTheFailu await using var host = HostWith(new RecordingService(_log, "a", onStart: Throw), new RecordingService(_log, "b")); // Act - var exception = await Should.ThrowAsync(() => Target.ResetAsync(host, [])); + var exception = await Should.ThrowAsync(() => ResetAsync(host, [])); // Assert _log.ShouldBe(["stop:a", "stop:b", "start:a", "start:b"]); @@ -142,7 +145,7 @@ public async Task AFailingResourceResetDoesNotSkipTheOtherResourcesOrTheRestarts ]; // Act - var exception = await Should.ThrowAsync(() => Target.ResetAsync(host, resources)); + var exception = await Should.ThrowAsync(() => ResetAsync(host, resources)); // Assert _log.ShouldContain("reset:broken"); @@ -161,7 +164,7 @@ public async Task AStopThatIgnoresItsTokenIsAbandonedAtTheStepTimeoutAndTheReset await using var host = HostWith(new RecordingService(_log, "stuck", onStop: _ => neverStops.Task), new RecordingService(_log, "b")); // Act - var reset = Target.ResetAsync(host, [new RecordingResource(_log, "db")]); + var reset = ResetAsync(host, [new RecordingResource(_log, "db")]); _timeProvider.Advance(TestHostReset.StepTimeout); var exception = await Should.ThrowAsync(() => reset); @@ -177,7 +180,7 @@ public async Task AStopThatHonoursItsTokenIsCancelledAtTheStepTimeout() await using var host = HostWith(new RecordingService(_log, "slow", onStop: token => Task.Delay(Timeout.InfiniteTimeSpan, token))); // Act - var reset = Target.ResetAsync(host, []); + var reset = ResetAsync(host, []); _timeProvider.Advance(TestHostReset.StepTimeout); var exception = await Should.ThrowAsync(() => reset); @@ -193,6 +196,6 @@ public async Task AHostWithoutRestartableServicesOrResourcesResetsWithoutError() await using var host = HostWith(); // Act & Assert - await Should.NotThrowAsync(() => Target.ResetAsync(host, [])); + await Should.NotThrowAsync(() => ResetAsync(host, [])); } } diff --git a/tests/Vulthil.xUnit.Tests/TestHostScopeTests.cs b/tests/Vulthil.xUnit.Tests/TestHostScopeTests.cs new file mode 100644 index 00000000..4f7437ab --- /dev/null +++ b/tests/Vulthil.xUnit.Tests/TestHostScopeTests.cs @@ -0,0 +1,386 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Vulthil.Extensions.Hosting; +using Vulthil.xUnit.Fixtures; + +namespace Vulthil.xUnit.Tests; + +public sealed class TestHostScopeTests : BaseUnitTestCase +{ + private const string ScopeId = "scope_1"; + + private readonly ConcurrentQueue _log = new(); + private readonly List _hostContainers = []; + private readonly HashSet _excludedContainers = []; + private readonly Lazy _lazyTarget; + + private TestHostScope Target => _lazyTarget.Value; + + public TestHostScopeTests() => + _lazyTarget = new(() => new TestHostScope( + new FakeContainerHost(_hostContainers), + container => !_excludedContainers.Contains(container), + () => ScopeId, + new FakeTimeProvider())); + + [Fact] + public async Task InitializeAsyncStartsOnlyTheConsumedContainersAndScopesThemUnderOneId() + { + // Arrange + var first = AddContainer("first", "FirstDb"); + var excluded = AddContainer("excluded", "ExcludedDb"); + var second = AddContainer("second", "SecondDb"); + _excludedContainers.Add(excluded); + + // Act + await Target.InitializeAsync(); + + // Assert + first.StartCount.ShouldBe(1); + second.StartCount.ShouldBe(1); + excluded.StartCount.ShouldBe(0); + first.ScopeIds.ShouldBe([ScopeId]); + second.ScopeIds.ShouldBe([ScopeId]); + Target.Containers.Count.ShouldBe(2); + _log.ShouldContain("init:first"); + _log.ShouldContain("init:second"); + } + + [Fact] + public async Task InitializeAsyncRunsOnce() + { + // Arrange + var container = AddContainer("db", "Db"); + + // Act + await Target.InitializeAsync(); + await Target.InitializeAsync(); + + // Assert + container.StartCount.ShouldBe(1); + container.ScopeIds.Count.ShouldBe(1); + } + + [Fact] + public async Task ConnectionStringsHoldOneEntryPerConsumedContainer() + { + // Arrange + AddContainer("orders", "Orders"); + AddContainer("broker", "Broker"); + + // Act + await Target.InitializeAsync(); + + // Assert + Target.ConnectionStrings.ToDictionary().ShouldBe(new Dictionary + { + ["ConnectionStrings:Orders"] = "orders-connection", + ["ConnectionStrings:Broker"] = "broker-connection", + }); + } + + [Fact] + public async Task TwoConsumedContainersWithTheSameKeyFailBeforeTheScopeIsProvisioned() + { + // Arrange + AddContainer("first", "AppDb"); + AddContainer("second", "appdb"); + + // Act + var exception = await Should.ThrowAsync(Target.InitializeAsync); + + // Assert + exception.Message.ShouldContain("'AppDb'"); + exception.Message.ShouldContain(nameof(FakeContainer)); + exception.Message.ShouldContain("ShouldUseContainer"); + _log.ShouldNotContain(entry => entry.StartsWith("init:", StringComparison.Ordinal)); + } + + [Fact] + public async Task AContainerThatSharesAKeyButIsNotConsumedIsAllowed() + { + // Arrange + AddContainer("consumed", "AppDb"); + _excludedContainers.Add(AddContainer("excluded", "AppDb")); + + // Act & Assert + await Should.NotThrowAsync(Target.InitializeAsync); + } + + [Fact] + public async Task AStartingHostRunsTheMigrationsBeforeTheStartupResources() + { + // Arrange + AddContainer("db", "Db"); + await Target.InitializeAsync(); + await using var host = HostWith(); + + // Act + await Target.CreateHostLifecycle(host).StartAsync(CancellationToken); + + // Assert + _log.Where(entry => entry.StartsWith("migrate:", StringComparison.Ordinal) || entry.StartsWith("startup:", StringComparison.Ordinal)) + .ShouldBe(["migrate:db", "startup:db"]); + } + + [Fact] + public async Task ASecondHostPausesTheFirstHostsRestartableServicesUntilItStops() + { + // Arrange + await Target.InitializeAsync(); + await using var sharedHost = HostWith(new RecordingService(_log, "shared")); + await using var testHost = HostWith(new RecordingService(_log, "test")); + await Target.CreateHostLifecycle(sharedHost).StartAsync(CancellationToken); + var testHostLifecycle = Target.CreateHostLifecycle(testHost); + + // Act + await testHostLifecycle.StartAsync(CancellationToken); + var whileTheTestHostRuns = ServiceEvents(); + await testHostLifecycle.StopAsync(CancellationToken); + + // Assert + whileTheTestHostRuns.ShouldBe(["stop:shared"]); + ServiceEvents().ShouldBe(["stop:shared", "start:shared"]); + } + + [Fact] + public async Task AnOlderHostThatStopsFirstIsNotResumedWhenTheNewerHostStops() + { + // Arrange + await Target.InitializeAsync(); + await using var sharedHost = HostWith(new RecordingService(_log, "shared")); + await using var testHost = HostWith(new RecordingService(_log, "test")); + var sharedHostLifecycle = Target.CreateHostLifecycle(sharedHost); + var testHostLifecycle = Target.CreateHostLifecycle(testHost); + await sharedHostLifecycle.StartAsync(CancellationToken); + await testHostLifecycle.StartAsync(CancellationToken); + + // Act + await sharedHostLifecycle.StopAsync(CancellationToken); + await testHostLifecycle.StopAsync(CancellationToken); + + // Assert + ServiceEvents().ShouldBe(["stop:shared"]); + } + + [Fact] + public async Task ResetPausesTheRunningHostAndResetsTheResourcesWithTheNewestHost() + { + // Arrange + var container = AddContainer("db", "Db"); + await Target.InitializeAsync(); + await using var sharedHost = HostWith(new RecordingService(_log, "shared")); + await using var testHost = HostWith(new RecordingService(_log, "test")); + await Target.CreateHostLifecycle(sharedHost).StartAsync(CancellationToken); + await Target.CreateHostLifecycle(testHost).StartAsync(CancellationToken); + + // Act + await Target.ResetAsync([new RecordingResource(_log, "http")]); + + // Assert + ServiceEvents().ShouldBe(["stop:shared", "stop:test", "start:test"]); + _log.ShouldContain("reset:db"); + _log.ShouldContain("reset:http"); + container.View!.ResetServices.ShouldHaveSingleItem().ShouldBeSameAs(testHost); + } + + [Fact] + public async Task ResetWithoutALiveHostDoesNothing() + { + // Arrange + AddContainer("db", "Db"); + await Target.InitializeAsync(); + + // Act + await Target.ResetAsync([new RecordingResource(_log, "http")]); + + // Assert + _log.ShouldNotContain(entry => entry.StartsWith("reset:", StringComparison.Ordinal)); + } + + [Fact] + public async Task AHostWhosePauseFailedStillJoinsTheLiveHosts() + { + // Arrange + await Target.InitializeAsync(); + await using var sharedHost = HostWith(new RecordingService(_log, "shared", onStop: () => throw new InvalidOperationException("stuck"))); + await using var testHost = HostWith(new RecordingService(_log, "test")); + await Target.CreateHostLifecycle(sharedHost).StartAsync(CancellationToken); + + // Act + var exception = await Should.ThrowAsync(() => Target.CreateHostLifecycle(testHost).StartAsync(CancellationToken)); + await Target.ResetAsync([]); + + // Assert + exception.Message.ShouldContain("Pausing"); + ServiceEvents().ShouldBe(["stop:shared", "stop:test", "start:test"]); + } + + [Fact] + public async Task DisposeAsyncDisposesTheScopeViews() + { + // Arrange + AddContainer("db", "Db"); + await Target.InitializeAsync(); + + // Act + await Target.DisposeAsync(); + + // Assert + _log.ShouldContain("dispose:db"); + } + + private FakeContainer AddContainer(string name, string connectionStringKey) + { + var container = new FakeContainer(_log, name, connectionStringKey); + _hostContainers.Add(container); + return container; + } + + private string[] ServiceEvents() => + [.. _log.Where(entry => entry.StartsWith("start:", StringComparison.Ordinal) || entry.StartsWith("stop:", StringComparison.Ordinal))]; + + private static ServiceProvider HostWith(params IHostedService[] services) + { + var collection = new ServiceCollection(); + foreach (var service in services) + { + collection.AddSingleton(service); + } + + return collection.BuildServiceProvider(); + } + + public sealed class FakeContainerHost : ContainerHost + { + public FakeContainerHost(IEnumerable containers) + { + foreach (var container in containers) + { + AddContainer(container); + } + } + } + + /// + /// A shared container that counts its starts and mints a per scope. + /// + public sealed class FakeContainer(ConcurrentQueue log, string name, string connectionStringKey) + : ITestContainerWithConnectionString, ITestContainerScopeProvider + { + private readonly List _scopeIds = []; + private int _startCount; + + public int StartCount => Volatile.Read(ref _startCount); + + public IReadOnlyList ScopeIds => _scopeIds; + + public FakeView? View { get; private set; } + + public string ConnectionString => $"{name}-host-connection"; + + public string ConnectionStringKey => connectionStringKey; + + public ITestContainer CreateScope(string scopeId) + { + _scopeIds.Add(scopeId); + View = new FakeView(log, name, connectionStringKey); + return View; + } + + public ValueTask InitializeAsync() + { + Interlocked.Increment(ref _startCount); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + public void ConfigureWebHost(IWebHostBuilder builder) + { + } + + public void ConfigureServices(IServiceCollection services) + { + } + } + + /// + /// A scope view that records its lifecycle, its migration, its startup setup and its resets. + /// + public sealed class FakeView(ConcurrentQueue log, string name, string connectionStringKey) + : ITestDatabaseContainer, IStartupResource + { + public ConcurrentQueue ResetServices { get; } = new(); + + public string ConnectionString => $"{name}-connection"; + + public string ConnectionStringKey => connectionStringKey; + + public ValueTask InitializeAsync() + { + log.Enqueue($"init:{name}"); + return ValueTask.CompletedTask; + } + + public ValueTask InitializeAsync(IServiceProvider serviceProvider) + { + log.Enqueue($"startup:{name}"); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + log.Enqueue($"dispose:{name}"); + return ValueTask.CompletedTask; + } + + public ValueTask MigrateDatabase(IServiceProvider serviceProvider) + { + log.Enqueue($"migrate:{name}"); + return ValueTask.CompletedTask; + } + + public ValueTask ResetAsync(IServiceProvider serviceProvider) + { + ResetServices.Enqueue(serviceProvider); + log.Enqueue($"reset:{name}"); + return ValueTask.CompletedTask; + } + + public void ConfigureWebHost(IWebHostBuilder builder) + { + } + + public void ConfigureServices(IServiceCollection services) + { + } + } + + public sealed class RecordingService(ConcurrentQueue log, string name, Action? onStop = null) : IRestartableHostedService + { + public Task StartAsync(CancellationToken cancellationToken) + { + log.Enqueue($"start:{name}"); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + log.Enqueue($"stop:{name}"); + onStop?.Invoke(); + return Task.CompletedTask; + } + } + + public sealed class RecordingResource(ConcurrentQueue log, string name) : IResettableResource + { + public ValueTask ResetAsync(IServiceProvider serviceProvider) + { + log.Enqueue($"reset:{name}"); + return ValueTask.CompletedTask; + } + } +}