From bd158bd99f5afb81bfa1a6363b28374156de7862 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Wed, 4 Nov 2026 09:24:06 +0530 Subject: [PATCH] chore(tests): move the test projects to xUnit v3 (#1457) All 16 test projects move from xunit 2.9.3 to xunit.v3.mtp-off 4.0.2 and become executables. mtp-off keeps VSTest, so dotnet test, the coverlet collector and coverage.runsettings work unchanged, and scaffolded projects need no global.json test-runner setting. IAsyncLifetime returns ValueTask in v3; both WebApplicationFactory fixtures now override DisposeAsync instead of hiding it. xUnit1051 is off for src/Tests: its code fix makes NSubstitute setups and Received() checks expect the test's cancellation token. v3's different test order exposed four hidden dependencies, fixed in the harness: - Derived WithWebHostBuilder hosts left JobStorage.Current and the static Audit publisher pointing at their disposed storage and channel. PrimaryHostStaticsRestorer repoints both at the shared host on stop. - Provisioning-failure tests left unreachable-database tenants behind; they now remove them via FshWebApplicationFactory.RemoveTenantsAsync. - The audit tenant-isolation poll now excludes EntityChange seed rows, which can fill more than the first page. - The within-limit rate-limit test uses its own client IP instead of the partition the 429 test exhausts. --- .agents/rules/integration-testing.md | 2 ++ .agents/rules/testing.md | 8 ++++- src/.editorconfig | 7 ++++ src/Directory.Packages.props | 4 ++- .../Architecture.Tests.csproj | 3 +- .../Auditing.Tests/Auditing.Tests.csproj | 3 +- src/Tests/Billing.Tests/Billing.Tests.csproj | 3 +- src/Tests/Caching.Tests/Caching.Tests.csproj | 3 +- src/Tests/Catalog.Tests/Catalog.Tests.csproj | 3 +- src/Tests/Chat.Tests/Chat.Tests.csproj | 3 +- src/Tests/Files.Tests/Files.Tests.csproj | 3 +- .../Framework.Tests/Framework.Tests.csproj | 3 +- src/Tests/Generic.Tests/Generic.Tests.csproj | 3 +- .../Identity.Tests/Identity.Tests.csproj | 3 +- .../MiddlewareWebApplicationFactory.cs | 4 +-- .../Integration.Middleware.Tests.csproj | 3 +- .../Tests/RateLimitingTests.cs | 30 ++++++++++++++--- .../FshWebApplicationFactory.cs | 30 +++++++++++++++-- .../PrimaryHostStaticsRestorer.cs | 33 +++++++++++++++++++ .../Integration.Tests.csproj | 3 +- .../AuditQueryTenantIsolationTests.cs | 6 +++- .../Tests/Auditing/AuditTestHelper.cs | 5 +-- .../Tests/Caching/HybridCacheRedisTests.cs | 4 +-- .../TenantMigrationsHealthCheckTests.cs | 4 +-- .../Tests/Impersonation/ImpersonationTests.cs | 4 +-- .../RetryTenantProvisioningTests.cs | 14 +++++++- .../Multitenancy/TenantHeaderOverrideTests.cs | 4 +-- .../TenantProvisioningFailureTests.cs | 14 +++++++- .../Tests/Multitenancy/TenantThemeTests.cs | 4 +-- .../Multitenancy.Tests.csproj | 3 +- .../Notifications.Tests.csproj | 3 +- src/Tests/README.md | 2 +- src/Tests/Tickets.Tests/Tickets.Tests.csproj | 3 +- .../Webhooks.Tests/Webhooks.Tests.csproj | 3 +- 34 files changed, 184 insertions(+), 43 deletions(-) create mode 100644 src/Tests/Integration.Tests/Infrastructure/PrimaryHostStaticsRestorer.cs diff --git a/.agents/rules/integration-testing.md b/.agents/rules/integration-testing.md index 6f980f9646..56a88770b9 100644 --- a/.agents/rules/integration-testing.md +++ b/.agents/rules/integration-testing.md @@ -14,6 +14,8 @@ - **Storage is wired eagerly.** `AddHeroStorage` reads `Storage:Provider` before the test config overlay, so it picks `LocalStorageService`. The factory **removes the `IStorageService`/`LocalStorageService`/`S3StorageService` descriptors post-registration and re-registers the S3 stack** at RustFS. Follow that when a test needs real object storage. (See `storage.md`.) - **SignalR tests force long-polling** — TestServer has no WebSocket. Configure the client transport accordingly. - **Rate limiting is read eagerly** — `Integration.Middleware.Tests` sets `RateLimitingOptions:Enabled` via env var **before** host build, since flipping it after has no effect. +- **Test order is not guaranteed** (xUnit v3 orders differently from v2, and it shifted several hidden dependencies). Clean up shared state you create. Tenants pointing at an unreachable database go through `_factory.RemoveTenantsAsync(ids)` in `DisposeAsync`, or all-tenant jobs run by later tests count them as failures. Don't assume a new tenant's audit rows fit on page 1, because provisioning writes its seed data as `EntityChange` rows. +- **`WithWebHostBuilder` hosts share two statics with the shared host**: Hangfire's `JobStorage.Current` and the static `Audit` publisher. `PrimaryHostStaticsRestorer` points both back at the shared host when a derived host stops. Always dispose derived factories (`await using`). ## Coverage diff --git a/.agents/rules/testing.md b/.agents/rules/testing.md index b19aebb128..4ed0b03bd1 100644 --- a/.agents/rules/testing.md +++ b/.agents/rules/testing.md @@ -4,7 +4,13 @@ Read before writing or changing tests. ## Stack -xUnit · Shouldly (`result.ShouldBe(...)`) · NSubstitute (`Substitute.For()`) · AutoFixture (`_fixture.Create()`) · NetArchTest (architecture rules) · Testcontainers (integration). +xUnit v3 (`xunit.v3.mtp-off`, runs on VSTest) · Shouldly (`result.ShouldBe(...)`) · NSubstitute (`Substitute.For()`) · AutoFixture (`_fixture.Create()`) · NetArchTest (architecture rules) · Testcontainers (integration). + +## xUnit v3 notes + +- Test projects are executables (`Exe`). A new test project needs that plus the `xunit.v3.mtp-off`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk` and `coverlet.collector` references; copy an existing csproj. +- `IAsyncLifetime` returns `ValueTask` and extends `IAsyncDisposable`: `public async ValueTask InitializeAsync()` / `public ValueTask DisposeAsync()`. A `WebApplicationFactory` fixture **overrides** `DisposeAsync()`, calls `base.DisposeAsync()` first, then disposes its containers. +- `xUnit1051` (pass `TestContext.Current.CancellationToken`) is off for `src/Tests` in `src/.editorconfig`. Its code fix also adds the token to NSubstitute setups and `Received()` checks, which changes what the mock matches. Pass the token by hand to long-running IO in a test when it should honour cancellation. ## Naming & shape diff --git a/src/.editorconfig b/src/.editorconfig index 1d809f0742..9f6b01ed58 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -269,3 +269,10 @@ dotnet_diagnostic.MSG0005.severity = none [**/Migrations.PostgreSQL/**/*.cs] dotnet_diagnostic.CA1062.severity = none dotnet_diagnostic.CA1861.severity = none + +# xUnit v3 (#1457): xUnit1051 asks every async call in a test to take TestContext.Current.CancellationToken. +# Off on purpose: its code fix adds the token to NSubstitute setups and Received() checks, which changes what +# the mock matches (handlers are called with CancellationToken.None), and the suite uses no test timeouts that +# the token would serve. Pass the token by hand where a test does long-running IO and should honour cancellation. +[Tests/**/*.cs] +dotnet_diagnostic.xUnit1051.severity = none diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 1060d3208a..166031e0e3 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -123,7 +123,9 @@ - + + diff --git a/src/Tests/Architecture.Tests/Architecture.Tests.csproj b/src/Tests/Architecture.Tests/Architecture.Tests.csproj index 14572d8b25..10b5c8480c 100644 --- a/src/Tests/Architecture.Tests/Architecture.Tests.csproj +++ b/src/Tests/Architecture.Tests/Architecture.Tests.csproj @@ -1,6 +1,7 @@ net10.0 + Exe false false enable @@ -15,7 +16,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Tests/Auditing.Tests/Auditing.Tests.csproj b/src/Tests/Auditing.Tests/Auditing.Tests.csproj index f84f37fa4e..2bd15ee80e 100644 --- a/src/Tests/Auditing.Tests/Auditing.Tests.csproj +++ b/src/Tests/Auditing.Tests/Auditing.Tests.csproj @@ -1,6 +1,7 @@ net10.0 + Exe false false enable @@ -13,7 +14,7 @@ all - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Tests/Billing.Tests/Billing.Tests.csproj b/src/Tests/Billing.Tests/Billing.Tests.csproj index b75a98c4df..7630822c5f 100644 --- a/src/Tests/Billing.Tests/Billing.Tests.csproj +++ b/src/Tests/Billing.Tests/Billing.Tests.csproj @@ -1,6 +1,7 @@ + Exe Billing.Tests Billing.Tests false @@ -13,7 +14,7 @@ - + diff --git a/src/Tests/Caching.Tests/Caching.Tests.csproj b/src/Tests/Caching.Tests/Caching.Tests.csproj index 63ad564cbd..440d63fbd9 100644 --- a/src/Tests/Caching.Tests/Caching.Tests.csproj +++ b/src/Tests/Caching.Tests/Caching.Tests.csproj @@ -1,6 +1,7 @@ net10.0 + Exe false false enable @@ -16,7 +17,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Tests/Catalog.Tests/Catalog.Tests.csproj b/src/Tests/Catalog.Tests/Catalog.Tests.csproj index d151b0d4c0..461607146c 100644 --- a/src/Tests/Catalog.Tests/Catalog.Tests.csproj +++ b/src/Tests/Catalog.Tests/Catalog.Tests.csproj @@ -1,6 +1,7 @@ + Exe Catalog.Tests Catalog.Tests false @@ -13,7 +14,7 @@ - + diff --git a/src/Tests/Chat.Tests/Chat.Tests.csproj b/src/Tests/Chat.Tests/Chat.Tests.csproj index 0bee4a9447..20e0f4691f 100644 --- a/src/Tests/Chat.Tests/Chat.Tests.csproj +++ b/src/Tests/Chat.Tests/Chat.Tests.csproj @@ -1,6 +1,7 @@ + Exe Chat.Tests Chat.Tests false @@ -13,7 +14,7 @@ - + diff --git a/src/Tests/Files.Tests/Files.Tests.csproj b/src/Tests/Files.Tests/Files.Tests.csproj index 30403ec652..cd33a2e6ce 100644 --- a/src/Tests/Files.Tests/Files.Tests.csproj +++ b/src/Tests/Files.Tests/Files.Tests.csproj @@ -1,6 +1,7 @@ + Exe Files.Tests Files.Tests false @@ -13,7 +14,7 @@ - + diff --git a/src/Tests/Framework.Tests/Framework.Tests.csproj b/src/Tests/Framework.Tests/Framework.Tests.csproj index 8bc68c1bd3..a91bcb73fe 100644 --- a/src/Tests/Framework.Tests/Framework.Tests.csproj +++ b/src/Tests/Framework.Tests/Framework.Tests.csproj @@ -1,6 +1,7 @@ + Exe Framework.Tests Framework.Tests false @@ -14,7 +15,7 @@ - + diff --git a/src/Tests/Generic.Tests/Generic.Tests.csproj b/src/Tests/Generic.Tests/Generic.Tests.csproj index 6b28c1e96a..664c7c0662 100644 --- a/src/Tests/Generic.Tests/Generic.Tests.csproj +++ b/src/Tests/Generic.Tests/Generic.Tests.csproj @@ -1,6 +1,7 @@ net10.0 + Exe false false enable @@ -14,7 +15,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Tests/Identity.Tests/Identity.Tests.csproj b/src/Tests/Identity.Tests/Identity.Tests.csproj index 6b9d2742c9..da494a2a16 100644 --- a/src/Tests/Identity.Tests/Identity.Tests.csproj +++ b/src/Tests/Identity.Tests/Identity.Tests.csproj @@ -1,6 +1,7 @@ net10.0 + Exe false false enable @@ -13,7 +14,7 @@ all - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs index 30edc1df6f..b5f3d74adf 100644 --- a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs +++ b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs @@ -81,7 +81,7 @@ public MiddlewareWebApplicationFactory() Environment.SetEnvironmentVariable("SecurityHeadersOptions__Enabled", "true"); } - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { await Task.WhenAll(_postgres.StartAsync(), _s3.StartAsync()); await CreateS3BucketAsync(); @@ -102,7 +102,7 @@ public async Task InitializeAsync() } } - public new async Task DisposeAsync() + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); await _postgres.DisposeAsync(); diff --git a/src/Tests/Integration.Middleware.Tests/Integration.Middleware.Tests.csproj b/src/Tests/Integration.Middleware.Tests/Integration.Middleware.Tests.csproj index 6d760bdceb..896a7a5017 100644 --- a/src/Tests/Integration.Middleware.Tests/Integration.Middleware.Tests.csproj +++ b/src/Tests/Integration.Middleware.Tests/Integration.Middleware.Tests.csproj @@ -2,6 +2,7 @@ net10.0 + Exe false false enable @@ -24,7 +25,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Tests/Integration.Middleware.Tests/Tests/RateLimitingTests.cs b/src/Tests/Integration.Middleware.Tests/Tests/RateLimitingTests.cs index 69a6c17ef7..16aa2562b2 100644 --- a/src/Tests/Integration.Middleware.Tests/Tests/RateLimitingTests.cs +++ b/src/Tests/Integration.Middleware.Tests/Tests/RateLimitingTests.cs @@ -1,5 +1,7 @@ +using System.Net; using System.Text.Json; using Integration.Middleware.Tests.Infrastructure; +using Microsoft.AspNetCore.Http; namespace Integration.Middleware.Tests.Tests; @@ -21,6 +23,22 @@ public RateLimitingTests(MiddlewareWebApplicationFactory factory) _factory = factory; } + private async Task IssueBadTokenFromAsync(IPAddress clientIp) + { + var body = JsonSerializer.SerializeToUtf8Bytes(new { email = "nobody@example.com", password = "wrong-password" }); + var context = await _factory.Server.SendAsync(c => + { + c.Connection.RemoteIpAddress = clientIp; + c.Request.Method = HttpMethods.Post; + c.Request.Path = $"{TestConstants.IdentityBasePath}/token/issue"; + c.Request.Headers["tenant"] = TestConstants.RootTenantId; + c.Request.ContentType = "application/json"; + c.Request.ContentLength = body.Length; + c.Request.Body = new MemoryStream(body); + }); + return context.Response.StatusCode; + } + private static async Task IssueBadTokenAsync(HttpClient client) { var request = new HttpRequestMessage(HttpMethod.Post, $"{TestConstants.IdentityBasePath}/token/issue"); @@ -34,15 +52,17 @@ private static async Task IssueBadTokenAsync(HttpClient cli [Fact] public async Task TokenIssue_Should_NotRateLimit_When_RequestsAreWithinTheAuthLimit() { - // Arrange - using var client = _factory.CreateClient(); + // Arrange: a client IP of its own. TestServer leaves RemoteIpAddress null, so requests from + // CreateClient() share the "unknown IP" partition with the 429 test below. If that test ran + // first, this one would start on an exhausted window. + var clientIp = IPAddress.Parse("203.0.113.10"); // Act + Assert: the first PermitLimit requests must NOT be rejected with 429. for (int i = 0; i < AuthPermitLimit; i++) { - using var response = await IssueBadTokenAsync(client); - response.StatusCode.ShouldNotBe( - HttpStatusCode.TooManyRequests, + var statusCode = await IssueBadTokenFromAsync(clientIp); + statusCode.ShouldNotBe( + (int)HttpStatusCode.TooManyRequests, $"request #{i + 1} within the auth limit should not be rate limited"); } } diff --git a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs index 006d255ab2..cd08815147 100644 --- a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs +++ b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs @@ -12,6 +12,7 @@ using System.Text; using FSH.Modules.Multitenancy.Data; using FSH.Framework.Web.Modules; +using FSH.Modules.Auditing; using Hangfire; using Hangfire.InMemory; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -33,6 +34,12 @@ public sealed class FshWebApplicationFactory : WebApplicationFactory, I internal const string S3SecretKey = "rustfsadmin"; internal const string S3Bucket = "fsh-integration-test-uploads"; + /// The shared host's Hangfire storage; see . + internal static JobStorage? PrimaryJobStorage { get; private set; } + + /// The shared host's audit configurator; see . + internal static AuditingConfigurator? PrimaryAuditingConfigurator { get; private set; } + private static readonly SemaphoreSlim _migrationLock = new(1, 1); private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:18-alpine") .WithDatabase("fsh_integration_tests") @@ -53,13 +60,15 @@ public sealed class FshWebApplicationFactory : WebApplicationFactory, I .WithCleanUp(true) .Build(); - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { await Task.WhenAll(_postgres.StartAsync(), _s3.StartAsync()); await CreateS3BucketAsync(); // Force host creation via the Server property (no leaked HttpClient) _ = Server; + PrimaryJobStorage = Services.GetRequiredService(); + PrimaryAuditingConfigurator = Services.GetServices().OfType().Single(); // Migrate + seed the root tenant. The semaphore stops test classes sharing the DB // from migrating simultaneously. @@ -74,13 +83,29 @@ public async Task InitializeAsync() } } - public new async Task DisposeAsync() + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); await _postgres.DisposeAsync(); await _s3.DisposeAsync(); } + /// + /// Removes tenants a test created from the shared tenant store. Use it for tenants that point at an + /// unreachable database: later tests that walk every tenant (the legacy public-key migration job, for + /// one) would otherwise count them as failures. Test order is not guaranteed, so clean up after yourself. + /// + public async Task RemoveTenantsAsync(IEnumerable tenantIds) + { + ArgumentNullException.ThrowIfNull(tenantIds); + using var scope = Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService>(); + foreach (var tenantId in tenantIds) + { + await store.RemoveAsync(tenantId); + } + } + /// The S3 (RustFS) endpoint URL exposed to the host configuration; useful for tests that need to PUT bytes directly. public string S3ServiceUrl => $"http://{_s3.Hostname}:{_s3.GetMappedPublicPort(S3Port)}"; @@ -209,6 +234,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) } services.AddHangfire(config => config.UseInMemoryStorage()); + services.AddHostedService(); services.AddHangfireServer(options => { options.SchedulePollingInterval = TimeSpan.FromSeconds(1); diff --git a/src/Tests/Integration.Tests/Infrastructure/PrimaryHostStaticsRestorer.cs b/src/Tests/Integration.Tests/Infrastructure/PrimaryHostStaticsRestorer.cs new file mode 100644 index 0000000000..6ac1ea23de --- /dev/null +++ b/src/Tests/Integration.Tests/Infrastructure/PrimaryHostStaticsRestorer.cs @@ -0,0 +1,33 @@ +using FSH.Modules.Auditing; +using Hangfire; +using Microsoft.Extensions.Hosting; + +namespace Integration.Tests.Infrastructure; + +/// +/// Two pieces of state are process-wide rather than per host: Hangfire's +/// (HangfireService enqueues through the static BackgroundJob API) and the static Audit +/// publisher (set by on start). A derived host built with +/// WithWebHostBuilder points both at its own storage and audit channel, and when it is disposed +/// they are left pointing at a dead store and a channel nobody reads: later enqueues throw +/// ObjectDisposedException and request audits are silently lost. +/// When any host from stops, this points both back at the shared host. +/// +internal sealed class PrimaryHostStaticsRestorer : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task StopAsync(CancellationToken cancellationToken) + { + var primaryStorage = FshWebApplicationFactory.PrimaryJobStorage; + if (primaryStorage is not null && !ReferenceEquals(JobStorage.Current, primaryStorage)) + { + JobStorage.Current = primaryStorage; + } + + if (FshWebApplicationFactory.PrimaryAuditingConfigurator is { } configurator) + { + await configurator.StartAsync(cancellationToken); + } + } +} diff --git a/src/Tests/Integration.Tests/Integration.Tests.csproj b/src/Tests/Integration.Tests/Integration.Tests.csproj index 5fe88bf1aa..5934973e76 100644 --- a/src/Tests/Integration.Tests/Integration.Tests.csproj +++ b/src/Tests/Integration.Tests/Integration.Tests.csproj @@ -2,6 +2,7 @@ net10.0 + Exe false false enable @@ -28,7 +29,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Tests/Integration.Tests/Tests/Auditing/AuditQueryTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Auditing/AuditQueryTenantIsolationTests.cs index 6c4cdeb2b9..cd08f2a327 100644 --- a/src/Tests/Integration.Tests/Tests/Auditing/AuditQueryTenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Auditing/AuditQueryTenantIsolationTests.cs @@ -65,12 +65,16 @@ public async Task ByIdCorrelationTrace_Should_NotLeakAcrossTenants() { // The other tenant authenticated (Security audit) and now hits an // audited endpoint; poll until a row scoped to its own tenant appears. + // EntityChange rows are excluded: provisioning writes a tenant's seed data as EntityChange + // rows without a correlation id, and there can be more than one page of them (how many + // depends on what earlier tests created), which would push the request row off page 1. return await AuditTestHelper.PollForAuditAsync( otherClient, a => a.TenantId == tenantId && !string.IsNullOrEmpty(a.CorrelationId) && !string.IsNullOrEmpty(a.TraceId), - attempts: 40); + attempts: 40, + extraQuery: "ExcludeEventType=EntityChange"); } private async Task CreateTenantAdminClientWithRetryAsync( diff --git a/src/Tests/Integration.Tests/Tests/Auditing/AuditTestHelper.cs b/src/Tests/Integration.Tests/Tests/Auditing/AuditTestHelper.cs index df5e42720f..515ebad6b6 100644 --- a/src/Tests/Integration.Tests/Tests/Auditing/AuditTestHelper.cs +++ b/src/Tests/Integration.Tests/Tests/Auditing/AuditTestHelper.cs @@ -40,14 +40,15 @@ internal static class AuditTestHelper public static async Task PollForAuditAsync( HttpClient client, Func predicate, - int attempts = DefaultPollAttempts) + int attempts = DefaultPollAttempts, + string? extraQuery = null) { ArgumentNullException.ThrowIfNull(client); ArgumentNullException.ThrowIfNull(predicate); for (int i = 0; i < attempts; i++) { - var page = await GetAuditsPageAsync(client, pageSize: 100).ConfigureAwait(false); + var page = await GetAuditsPageAsync(client, pageSize: 100, extraQuery: extraQuery).ConfigureAwait(false); var match = page.Items.FirstOrDefault(predicate); if (match is not null) { diff --git a/src/Tests/Integration.Tests/Tests/Caching/HybridCacheRedisTests.cs b/src/Tests/Integration.Tests/Tests/Caching/HybridCacheRedisTests.cs index 45f9c96b8e..0a6ebeafd3 100644 --- a/src/Tests/Integration.Tests/Tests/Caching/HybridCacheRedisTests.cs +++ b/src/Tests/Integration.Tests/Tests/Caching/HybridCacheRedisTests.cs @@ -20,9 +20,9 @@ public sealed class HybridCacheRedisTests : IAsyncLifetime private readonly RedisContainer _redis = new RedisBuilder("valkey/valkey:9.1.3-alpine") .Build(); - public Task InitializeAsync() => _redis.StartAsync(); + public async ValueTask InitializeAsync() => await _redis.StartAsync(); - public Task DisposeAsync() => _redis.DisposeAsync().AsTask(); + public ValueTask DisposeAsync() => _redis.DisposeAsync(); private (HybridCache cache, IDistributedCache distributedCache, ServiceProvider provider) CreateCache() { diff --git a/src/Tests/Integration.Tests/Tests/Health/TenantMigrationsHealthCheckTests.cs b/src/Tests/Integration.Tests/Tests/Health/TenantMigrationsHealthCheckTests.cs index 9b28f5b7c4..ed8e2b61e6 100644 --- a/src/Tests/Integration.Tests/Tests/Health/TenantMigrationsHealthCheckTests.cs +++ b/src/Tests/Integration.Tests/Tests/Health/TenantMigrationsHealthCheckTests.cs @@ -27,9 +27,9 @@ public sealed class TenantMigrationsHealthCheckTests : IAsyncLifetime .WithCleanUp(true) .Build(); - public Task InitializeAsync() => _postgres.StartAsync(); + public async ValueTask InitializeAsync() => await _postgres.StartAsync(); - public Task DisposeAsync() => _postgres.DisposeAsync().AsTask(); + public ValueTask DisposeAsync() => _postgres.DisposeAsync(); [Fact] public async Task CheckHealthAsync_Should_TransitionFromUnhealthyToHealthy_AsMigrationsApply() diff --git a/src/Tests/Integration.Tests/Tests/Impersonation/ImpersonationTests.cs b/src/Tests/Integration.Tests/Tests/Impersonation/ImpersonationTests.cs index 275e3c9f3b..80068212de 100644 --- a/src/Tests/Integration.Tests/Tests/Impersonation/ImpersonationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Impersonation/ImpersonationTests.cs @@ -50,7 +50,7 @@ public ImpersonationTests(FshWebApplicationFactory factory) _auth = new AuthHelper(factory); } - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { var uniqueId = Guid.NewGuid().ToString("N")[..8]; _tenantId = $"imptest-{uniqueId}"; @@ -69,7 +69,7 @@ public async Task InitializeAsync() _rootAdminUserId = ReadSubject(rootToken.AccessToken); } - public Task DisposeAsync() => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; // ─── StartImpersonation ───────────────────────────────────────────── diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/RetryTenantProvisioningTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/RetryTenantProvisioningTests.cs index 9edb8c4285..377a73500b 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/RetryTenantProvisioningTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/RetryTenantProvisioningTests.cs @@ -14,7 +14,7 @@ namespace Integration.Tests.Tests.Multitenancy; /// correlation id. Also covers authentication and the not-found path. /// [Collection(FshCollectionDefinition.Name)] -public sealed class RetryTenantProvisioningTests +public sealed class RetryTenantProvisioningTests : IAsyncLifetime { private static readonly JsonSerializerOptions Json = new() { @@ -28,6 +28,10 @@ public sealed class RetryTenantProvisioningTests "Host=127.0.0.1;Port=59999;Database=fsh_unreachable;Username=nope;Password=nope;Timeout=2;Command Timeout=2"; private readonly FshWebApplicationFactory _factory; + + // Removed after each test; the unreachable one would fail later all-tenant jobs (see FshWebApplicationFactory.RemoveTenantsAsync). + + private readonly List _createdTenantIds = []; private readonly AuthHelper _auth; public RetryTenantProvisioningTests(FshWebApplicationFactory factory) @@ -36,6 +40,12 @@ public RetryTenantProvisioningTests(FshWebApplicationFactory factory) _auth = new AuthHelper(factory); } + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + + public async ValueTask DisposeAsync() => await _factory.RemoveTenantsAsync(_createdTenantIds); + + #region Happy Path (retry of a failed tenant) [Fact] @@ -45,6 +55,7 @@ public async Task RetryProvisioning_Should_StartNewAttempt_When_TenantPreviously using var rootClient = await _auth.CreateRootAdminClientAsync(); var unique = Guid.NewGuid().ToString("N")[..8]; var tenantId = $"retry-{unique}"; + _createdTenantIds.Add(tenantId); var createResponse = await rootClient.PostAsJsonAsync(TestConstants.TenantsBasePath, new { @@ -83,6 +94,7 @@ public async Task RetryProvisioning_Should_Succeed_When_RetriedAfterCompletion() using var rootClient = await _auth.CreateRootAdminClientAsync(); var unique = Guid.NewGuid().ToString("N")[..8]; var tenantId = $"retry-ok-{unique}"; + _createdTenantIds.Add(tenantId); var createResponse = await rootClient.PostAsJsonAsync(TestConstants.TenantsBasePath, new { diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantHeaderOverrideTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantHeaderOverrideTests.cs index e0afd8069d..e9be64e0e7 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantHeaderOverrideTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantHeaderOverrideTests.cs @@ -44,7 +44,7 @@ public TenantHeaderOverrideTests(FshWebApplicationFactory factory) _auth = new AuthHelper(factory); } - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { var unique = Guid.NewGuid().ToString("N")[..8]; _tenantA = $"thot-a-{unique}"; @@ -64,7 +64,7 @@ public async Task InitializeAsync() _ = await GetTokenWithRetryAsync(_tenantBAdminEmail, TestConstants.DefaultPassword, _tenantB); } - public Task DisposeAsync() => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; [Fact] public async Task RootOperator_Should_TargetOtherTenant_When_HeaderProvided() diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantProvisioningFailureTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantProvisioningFailureTests.cs index b7e413f41a..46856de39d 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantProvisioningFailureTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantProvisioningFailureTests.cs @@ -23,7 +23,7 @@ namespace Integration.Tests.Tests.Multitenancy; /// modified; the failure flows through the exact production pipeline. /// [Collection(FshCollectionDefinition.Name)] -public sealed class TenantProvisioningFailureTests +public sealed class TenantProvisioningFailureTests : IAsyncLifetime { private static readonly JsonSerializerOptions JsonOptions = new() { @@ -39,12 +39,21 @@ public sealed class TenantProvisioningFailureTests private readonly FshWebApplicationFactory _factory; private readonly AuthHelper _auth; + // Tenants created here point at an unreachable database. They are removed after each test so that + // later tests which walk every tenant (e.g. the legacy public-key migration job) don't count them + // as failures. Test order is not guaranteed, so cleanup can't rely on running last. + private readonly List _createdTenantIds = []; + public TenantProvisioningFailureTests(FshWebApplicationFactory factory) { _factory = factory; _auth = new AuthHelper(factory); } + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public async ValueTask DisposeAsync() => await _factory.RemoveTenantsAsync(_createdTenantIds); + #region Failure Path [Fact] @@ -54,6 +63,7 @@ public async Task Provisioning_Should_TransitionToFailed_When_MigrationsStepThro using var rootClient = await _auth.CreateRootAdminClientAsync(); var uniqueId = Guid.NewGuid().ToString("N")[..8]; var tenantId = $"fail-{uniqueId}"; + _createdTenantIds.Add(tenantId); var createResponse = await rootClient.PostAsJsonAsync(TestConstants.TenantsBasePath, new { @@ -100,6 +110,7 @@ public async Task EnsureCanActivate_Should_Throw_When_ProvisioningStatusIsFailed using var rootClient = await _auth.CreateRootAdminClientAsync(); var uniqueId = Guid.NewGuid().ToString("N")[..8]; var tenantId = $"failguard-{uniqueId}"; + _createdTenantIds.Add(tenantId); var createResponse = await rootClient.PostAsJsonAsync(TestConstants.TenantsBasePath, new { @@ -135,6 +146,7 @@ public async Task ReactivateTenant_Should_BeRejected_When_ProvisioningFailed() using var rootClient = await _auth.CreateRootAdminClientAsync(); var uniqueId = Guid.NewGuid().ToString("N")[..8]; var tenantId = $"reactivate-{uniqueId}"; + _createdTenantIds.Add(tenantId); var createResponse = await rootClient.PostAsJsonAsync(TestConstants.TenantsBasePath, new { diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantThemeTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantThemeTests.cs index 792dc0a01d..063541d405 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantThemeTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantThemeTests.cs @@ -40,7 +40,7 @@ public TenantThemeTests(FshWebApplicationFactory factory) _auth = new AuthHelper(factory); } - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { var unique = Guid.NewGuid().ToString("N")[..8]; _tenantA = $"theme-a-{unique}"; @@ -60,7 +60,7 @@ public async Task InitializeAsync() _ = await GetTokenWithRetryAsync(_tenantBAdminEmail, TestConstants.DefaultPassword, _tenantB); } - public Task DisposeAsync() => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; #region Happy Path diff --git a/src/Tests/Multitenancy.Tests/Multitenancy.Tests.csproj b/src/Tests/Multitenancy.Tests/Multitenancy.Tests.csproj index 69d3751735..a9d404aaec 100644 --- a/src/Tests/Multitenancy.Tests/Multitenancy.Tests.csproj +++ b/src/Tests/Multitenancy.Tests/Multitenancy.Tests.csproj @@ -1,6 +1,7 @@  net10.0 + Exe false false enable @@ -14,7 +15,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Tests/Notifications.Tests/Notifications.Tests.csproj b/src/Tests/Notifications.Tests/Notifications.Tests.csproj index 36dc5e1c69..baaa2b023c 100644 --- a/src/Tests/Notifications.Tests/Notifications.Tests.csproj +++ b/src/Tests/Notifications.Tests/Notifications.Tests.csproj @@ -1,6 +1,7 @@ + Exe Notifications.Tests Notifications.Tests false @@ -13,7 +14,7 @@ - + diff --git a/src/Tests/README.md b/src/Tests/README.md index 33b5c6614f..2926c559b3 100644 --- a/src/Tests/README.md +++ b/src/Tests/README.md @@ -5,7 +5,7 @@ This folder contains solution-wide architecture tests for the FullStackHero .NET ## Project - `Architecture.Tests` targets `net10.0` and lives under `src/Tests/Architecture.Tests`. -- Test dependencies are limited to `xunit`, `Shouldly`, and `AutoFixture`, with versions defined in `src/Directory.Packages.props`. +- Test dependencies are limited to xUnit v3 (`xunit.v3.mtp-off`), `Shouldly`, and `AutoFixture`, with versions defined in `src/Directory.Packages.props`. ## What Is Covered diff --git a/src/Tests/Tickets.Tests/Tickets.Tests.csproj b/src/Tests/Tickets.Tests/Tickets.Tests.csproj index 0cc930f5df..6c9106e12d 100644 --- a/src/Tests/Tickets.Tests/Tickets.Tests.csproj +++ b/src/Tests/Tickets.Tests/Tickets.Tests.csproj @@ -1,6 +1,7 @@ + Exe Tickets.Tests Tickets.Tests false @@ -13,7 +14,7 @@ - + diff --git a/src/Tests/Webhooks.Tests/Webhooks.Tests.csproj b/src/Tests/Webhooks.Tests/Webhooks.Tests.csproj index 95a4742959..4f30885c1d 100644 --- a/src/Tests/Webhooks.Tests/Webhooks.Tests.csproj +++ b/src/Tests/Webhooks.Tests/Webhooks.Tests.csproj @@ -1,6 +1,7 @@ + Exe Webhooks.Tests Webhooks.Tests false @@ -13,7 +14,7 @@ - +