diff --git a/Directory.Packages.props b/Directory.Packages.props index 1588ffe3..ed8932f6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,7 @@ + diff --git a/EssentialCSharp.Web.Tests/ContentRateLimitingTests.cs b/EssentialCSharp.Web.Tests/ContentRateLimitingTests.cs index cdbb0de4..78e0e701 100644 --- a/EssentialCSharp.Web.Tests/ContentRateLimitingTests.cs +++ b/EssentialCSharp.Web.Tests/ContentRateLimitingTests.cs @@ -1,24 +1,18 @@ using System.Net; -using Microsoft.AspNetCore.Mvc.Testing; namespace EssentialCSharp.Web.Tests; /// /// HTTP integration tests for the "content" rate limit policy. -/// Uses its own factory (PerClass) to get a fresh in-memory rate limiter for each run. +/// Each test gets its own factory (fresh IHost) so the rate limiter starts from a clean state. /// Anonymous users are limited to 10 requests per minute on chapter content pages. /// -[ClassDataSource(Shared = SharedType.PerClass)] -public class ContentRateLimitingTests(WebApplicationFactory factory) +public class ContentRateLimitingTests : IntegrationTestBase { [Test] public async Task ContentEndpoint_ExceedingPerMinuteLimit_Returns429() { - // AllowAutoRedirect = false prevents redirect-following from consuming extra permits. - HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false - }); + using HttpClient client = CreateClientWithoutAutoRedirect(); // Anonymous limit is 10/min. First 10 requests should not be rate-limited. for (int i = 0; i < 10; i++) diff --git a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj index 3d3fb91e..23a0c5ff 100644 --- a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj +++ b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/EssentialCSharp.Web.Tests/FunctionalTests.cs b/EssentialCSharp.Web.Tests/FunctionalTests.cs index 31aa723e..14f129e4 100644 --- a/EssentialCSharp.Web.Tests/FunctionalTests.cs +++ b/EssentialCSharp.Web.Tests/FunctionalTests.cs @@ -2,9 +2,7 @@ namespace EssentialCSharp.Web.Tests; -[NotInParallel("FunctionalTests")] -[ClassDataSource(Shared = SharedType.PerClass)] -public class FunctionalTests(WebApplicationFactory factory) +public class FunctionalTests : IntegrationTestBase { [Test] [Arguments("/")] @@ -15,8 +13,8 @@ public class FunctionalTests(WebApplicationFactory factory) [Arguments("/alive")] public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl) { - HttpClient client = factory.CreateClient(); - using HttpResponseMessage response = await client.GetAsync(relativeUrl); + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, relativeUrl); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); } @@ -31,8 +29,8 @@ public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl [Arguments("/about?someOtherParam=value")] public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl) { - HttpClient client = factory.CreateClient(); - using HttpResponseMessage response = await client.GetAsync(relativeUrl); + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, relativeUrl); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); @@ -47,9 +45,9 @@ public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl) [Test] public async Task WhenTheApplicationStarts_NonExistingPage_GivesCorrectStatusCode() { - HttpClient client = factory.CreateClient(); - using HttpResponseMessage response = await client.GetAsync("/non-existing-page1234"); + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, "/non-existing-page1234"); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } -} \ No newline at end of file +} diff --git a/EssentialCSharp.Web.Tests/IntegrationTestBase.cs b/EssentialCSharp.Web.Tests/IntegrationTestBase.cs new file mode 100644 index 00000000..a526b486 --- /dev/null +++ b/EssentialCSharp.Web.Tests/IntegrationTestBase.cs @@ -0,0 +1,84 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using TUnit.AspNetCore; + +namespace EssentialCSharp.Web.Tests; + +public abstract class IntegrationTestBase : WebApplicationTest +{ + /// + /// Creates an with AllowAutoRedirect = false so callers can + /// assert exact redirect status codes and Location headers without the client + /// silently following them. + /// + protected HttpClient CreateClientWithoutAutoRedirect() => + Factory.Inner.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + /// + /// Executes an initial GET and follows redirect responses with subsequent GET requests. + /// This helper is intentionally GET-only. + /// + protected static async Task GetFollowingGetRedirectsAsync( + HttpClient client, + string relativeUrl, + int maxRedirects = 10) + { + HttpResponseMessage response = await client.GetAsync(relativeUrl); + + for (int redirectCount = 0; + redirectCount < maxRedirects && IsRedirectStatusCode(response.StatusCode); + redirectCount++) + { + Uri? location = response.Headers.Location; + if (location is null) + { + return response; + } + + response.Dispose(); + + response = await client.GetAsync(location); + } + + if (IsRedirectStatusCode(response.StatusCode)) + { + response.Dispose(); + throw new InvalidOperationException( + $"Exceeded redirect limit ({maxRedirects}) for '{relativeUrl}'. Last status: {(int)response.StatusCode} {response.StatusCode}."); + } + + return response; + } + + private static bool IsRedirectStatusCode(HttpStatusCode statusCode) => + statusCode == HttpStatusCode.Moved || + statusCode == HttpStatusCode.Found || + statusCode == HttpStatusCode.RedirectMethod || + statusCode == HttpStatusCode.TemporaryRedirect || + statusCode == HttpStatusCode.PermanentRedirect; + + protected T InServiceScope(Func action) + { + using IServiceScope scope = Factory.Services.GetRequiredService().CreateScope(); + return action(scope.ServiceProvider); + } + + protected void InServiceScope(Action action) + { + using IServiceScope scope = Factory.Services.GetRequiredService().CreateScope(); + action(scope.ServiceProvider); + } + + protected async Task InServiceScopeAsync(Func> action) + { + using IServiceScope scope = Factory.Services.GetRequiredService().CreateScope(); + return await action(scope.ServiceProvider); + } + + protected async Task InServiceScopeAsync(Func action) + { + using IServiceScope scope = Factory.Services.GetRequiredService().CreateScope(); + await action(scope.ServiceProvider); + } +} diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs index 05a29baa..7232a7c3 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs @@ -4,14 +4,13 @@ namespace EssentialCSharp.Web.Tests; -[ClassDataSource(Shared = SharedType.PerClass)] -public class ListingSourceCodeControllerTests(WebApplicationFactory factory) +public class ListingSourceCodeControllerTests : IntegrationTestBase { [Test] public async Task GetListing_WithValidChapterAndListing_Returns200WithContent() { // Arrange - HttpClient client = factory.CreateClient(); + HttpClient client = Factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/1"); @@ -35,7 +34,7 @@ public async Task GetListing_WithValidChapterAndListing_Returns200WithContent() public async Task GetListing_WithInvalidChapter_Returns404() { // Arrange - HttpClient client = factory.CreateClient(); + HttpClient client = Factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999/listing/1"); @@ -48,7 +47,7 @@ public async Task GetListing_WithInvalidChapter_Returns404() public async Task GetListing_WithInvalidListing_Returns404() { // Arrange - HttpClient client = factory.CreateClient(); + HttpClient client = Factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/999"); @@ -61,7 +60,7 @@ public async Task GetListing_WithInvalidListing_Returns404() public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings() { // Arrange - HttpClient client = factory.CreateClient(); + HttpClient client = Factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1"); @@ -92,7 +91,7 @@ public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings( public async Task GetListingsByChapter_WithInvalidChapter_ReturnsEmptyList() { // Arrange - HttpClient client = factory.CreateClient(); + HttpClient client = Factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999"); diff --git a/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs b/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs index 8e78d718..0eb417bd 100644 --- a/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs +++ b/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs @@ -1,13 +1,10 @@ using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; namespace EssentialCSharp.Web.Tests; -[NotInParallel("McpTests")] -[ClassDataSource(Shared = SharedType.PerClass)] -public class McpApiTokenServiceTests(WebApplicationFactory factory) +public class McpApiTokenServiceTests : IntegrationTestBase { private readonly List _scopes = []; @@ -21,8 +18,8 @@ public void DisposeScopes() private async Task<(string UserId, McpApiTokenService TokenService)> ArrangeAsync(string prefix) { - string userId = await McpTestHelper.CreateUserAsync(factory, prefix); - var scope = factory.Services.CreateScope(); + string userId = await McpTestHelper.CreateUserAsync(Factory, prefix); + var scope = Factory.Services.CreateScope(); _scopes.Add(scope); var tokenService = scope.ServiceProvider.GetRequiredService(); return (userId, tokenService); @@ -30,7 +27,7 @@ public void DisposeScopes() private async Task FillToLimitAsync(string userId) { - var scope = factory.Services.CreateScope(); + var scope = Factory.Services.CreateScope(); _scopes.Add(scope); var tokenService = scope.ServiceProvider.GetRequiredService(); for (int i = 0; i < McpApiTokenService.MaxTokensPerUser; i++) diff --git a/EssentialCSharp.Web.Tests/McpRateLimitingTests.cs b/EssentialCSharp.Web.Tests/McpRateLimitingTests.cs index a89be156..202f0a56 100644 --- a/EssentialCSharp.Web.Tests/McpRateLimitingTests.cs +++ b/EssentialCSharp.Web.Tests/McpRateLimitingTests.cs @@ -2,27 +2,25 @@ using System.Text.Json; using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Services; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; namespace EssentialCSharp.Web.Tests; /// -/// Each class gets its own factory so the global limiter starts from a fresh state. +/// Each test method gets its own per-test factory (fresh IHost + rate limiter state) +/// via TUnit.AspNetCore's WebApplicationTest, so [NotInParallel] is no longer needed. /// -[NotInParallel("McpTests")] -[ClassDataSource(Shared = SharedType.PerClass)] -public class McpDistinctUserRateLimitingTests(WebApplicationFactory factory) +public class McpRateLimitingTests : IntegrationTestBase { [Test] public async Task DistinctValidMcpUsers_DoNotShareRateLimitBucket() { - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); for (int i = 0; i < 31; i++) { (_, string rawToken) = await McpTestHelper.CreateUserAndTokenAsync( - factory, + Factory, $"mcp-rate-limit-isolation-{i}", userPrefix: $"mcp-isolation-{i}"); @@ -35,20 +33,15 @@ await Assert.That(response.StatusCode) .Because($"distinct MCP user request {i + 1} should use its own rate-limit bucket"); } } -} -[NotInParallel("McpTests")] -[ClassDataSource(Shared = SharedType.PerClass)] -public class McpPerUserRateLimitingTests(WebApplicationFactory factory) -{ [Test] public async Task SingleValidMcpUser_ExceedingTokenBucket_Returns429AndDoesNotCountRejectedRequests() { (_, string rawToken) = await McpTestHelper.CreateUserAndTokenAsync( - factory, + Factory, "mcp-rate-limit-single-user", userPrefix: "mcp-single-user"); - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); List statuses = []; string? rateLimitedPayload = null; string? rateLimitedContentType = null; @@ -70,7 +63,7 @@ public async Task SingleValidMcpUser_ExceedingTokenBucket_Returns429AndDoesNotCo } } - (long UsageCount, bool HasLastUsedAt) tokenUsage = factory.InServiceScope(services => + (long UsageCount, bool HasLastUsedAt) tokenUsage = InServiceScope(services => { var db = services.GetRequiredService(); byte[] tokenHash = McpApiTokenService.HashToken(rawToken); @@ -104,16 +97,11 @@ await Assert.That(statuses.Skip(McpRateLimiterPolicy.AuthenticatedTokenLimit) await Assert.That(error.GetProperty("code").GetInt32()).IsEqualTo(-32000); await Assert.That(error.GetProperty("message").GetString()).Contains("Rate limit exceeded"); } -} -[NotInParallel("McpTests")] -[ClassDataSource(Shared = SharedType.PerClass)] -public class McpAnonymousRateLimitingTests(WebApplicationFactory factory) -{ [Test] public async Task InvalidMcpBearerRequests_FallBackToAnonymousIpBucket() { - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); for (int i = 0; i < McpRateLimiterPolicy.AnonymousPermitLimit; i++) { @@ -132,21 +120,16 @@ await Assert.That(response.StatusCode) using HttpResponseMessage rateLimitedResponse = await client.SendAsync(rateLimitedRequest); await Assert.That(rateLimitedResponse.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests); } -} -[NotInParallel("McpTests")] -[ClassDataSource(Shared = SharedType.PerClass)] -public class McpCookieIsolationRateLimitingTests(WebApplicationFactory factory) -{ [Test] public async Task InvalidMcpBearerRequests_WithDifferentSiteCookies_StillShareAnonymousIpBucket() { - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); for (int i = 0; i < McpRateLimiterPolicy.AnonymousPermitLimit; i++) { - string cookieUserId = await McpTestHelper.CreateUserAsync(factory, $"mcp-cookie-user-{i}"); - (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(factory, cookieUserId); + string cookieUserId = await McpTestHelper.CreateUserAsync(Factory, $"mcp-cookie-user-{i}"); + (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, cookieUserId); using var request = McpTestHelper.CreateInitializeRequest("/mcp"); McpTestHelper.AddBearerToken(request, "mcp_invalid_token_that_does_not_exist"); @@ -158,8 +141,8 @@ await Assert.That(response.StatusCode) .Because($"invalid MCP bearer request {i + 1} should ignore the site cookie principal and stay in the anonymous/IP bucket"); } - string finalCookieUserId = await McpTestHelper.CreateUserAsync(factory, "mcp-cookie-user-final"); - (string finalCookieName, string finalCookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(factory, finalCookieUserId); + string finalCookieUserId = await McpTestHelper.CreateUserAsync(Factory, "mcp-cookie-user-final"); + (string finalCookieName, string finalCookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, finalCookieUserId); using var rateLimitedRequest = McpTestHelper.CreateInitializeRequest("/mcp"); McpTestHelper.AddBearerToken(rateLimitedRequest, "mcp_invalid_token_that_does_not_exist"); @@ -168,20 +151,15 @@ await Assert.That(response.StatusCode) using HttpResponseMessage rateLimitedResponse = await client.SendAsync(rateLimitedRequest); await Assert.That(rateLimitedResponse.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests); } -} -[NotInParallel("McpTests")] -[ClassDataSource(Shared = SharedType.PerClass)] -public class McpGlobalBypassRateLimitingTests(WebApplicationFactory factory) -{ [Test] public async Task ValidMcpPostRequests_DoNotConsumeGlobalLimiterBudgetForGetShim() { (_, string rawToken) = await McpTestHelper.CreateUserAndTokenAsync( - factory, + Factory, "mcp-global-bypass", userPrefix: "mcp-bypass"); - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); for (int i = 0; i < 10; i++) { @@ -211,16 +189,11 @@ await Assert.That(getResponse.StatusCode) using HttpResponseMessage rateLimitedGetResponse = await client.SendAsync(rateLimitedGetRequest); await Assert.That(rateLimitedGetResponse.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests); } -} -[NotInParallel("McpTests")] -[ClassDataSource(Shared = SharedType.PerClass)] -public class McpWellKnownIsolationRateLimitingTests(WebApplicationFactory factory) -{ [Test] public async Task WellKnownRequests_DoNotConsumeContentLimiterBudget() { - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); for (int i = 0; i < 10; i++) { @@ -243,3 +216,4 @@ await Assert.That(contentResponse.StatusCode) await Assert.That(rateLimitedResponse.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests); } } + diff --git a/EssentialCSharp.Web.Tests/McpTestHelper.cs b/EssentialCSharp.Web.Tests/McpTestHelper.cs index 12ed4255..e564da4f 100644 --- a/EssentialCSharp.Web.Tests/McpTestHelper.cs +++ b/EssentialCSharp.Web.Tests/McpTestHelper.cs @@ -9,15 +9,14 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using TUnit.AspNetCore; namespace EssentialCSharp.Web.Tests; internal static class McpTestHelper { - public static HttpClient CreateClient(WebApplicationFactory factory) => factory.CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false - }); + public static HttpClient CreateClient(TracedWebApplicationFactory factory) => + factory.Inner.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); public static HttpRequestMessage CreateInitializeRequest(string path = "/mcp") { @@ -50,7 +49,7 @@ public static void AddBearerToken(HttpRequestMessage request, string rawToken) = public static void AddCookie(HttpRequestMessage request, string cookieName, string cookieValue) => request.Headers.Add("Cookie", $"{cookieName}={cookieValue}"); - public static async Task CreateUserAsync(WebApplicationFactory factory, string userPrefix) + public static async Task CreateUserAsync(TracedWebApplicationFactory factory, string userPrefix) { string userId = Guid.NewGuid().ToString(); string suffix = Guid.NewGuid().ToString("N")[..8]; @@ -73,7 +72,7 @@ public static async Task CreateUserAsync(WebApplicationFactory factory, } public static async Task<(string UserId, string RawToken)> CreateUserAndTokenAsync( - WebApplicationFactory factory, + TracedWebApplicationFactory factory, string tokenName, string userPrefix = "mcp-test", DateTime? expiresAt = null) @@ -90,7 +89,7 @@ public static async Task CreateUserAsync(WebApplicationFactory factory, } public static async Task<(string CookieName, string CookieValue)> CreateIdentityApplicationCookieAsync( - WebApplicationFactory factory, + TracedWebApplicationFactory factory, string userId) { using var scope = factory.Services.CreateScope(); diff --git a/EssentialCSharp.Web.Tests/McpTests.cs b/EssentialCSharp.Web.Tests/McpTests.cs index 109b1f10..a4f4d5ed 100644 --- a/EssentialCSharp.Web.Tests/McpTests.cs +++ b/EssentialCSharp.Web.Tests/McpTests.cs @@ -1,19 +1,16 @@ using System.Net; using System.Text; using EssentialCSharp.Web.Services; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; namespace EssentialCSharp.Web.Tests; -[NotInParallel("McpTests")] -[ClassDataSource(Shared = SharedType.PerClass)] -public class McpTests(WebApplicationFactory factory) +public class McpTests : IntegrationTestBase { [Test] public async Task McpTokenEndpoint_WithoutAuth_Returns401() { - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); using HttpResponseMessage response = await client.PostAsync("/api/McpToken", null); @@ -23,7 +20,7 @@ public async Task McpTokenEndpoint_WithoutAuth_Returns401() [Test] public async Task McpEndpoint_WithoutToken_Returns401() { - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); using var request = McpTestHelper.CreateInitializeRequest("/mcp"); using HttpResponseMessage response = await client.SendAsync(request); @@ -34,11 +31,11 @@ public async Task McpEndpoint_WithoutToken_Returns401() [Test] public async Task McpEndpoint_WithSiteCookieButWithoutBearer_Returns401() { - string cookieUserId = await McpTestHelper.CreateUserAsync(factory, "mcp-cookie-only"); + string cookieUserId = await McpTestHelper.CreateUserAsync(Factory, "mcp-cookie-only"); (string cookieName, string cookieValue) = - await McpTestHelper.CreateIdentityApplicationCookieAsync(factory, cookieUserId); + await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, cookieUserId); - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); using var request = McpTestHelper.CreateInitializeRequest("/mcp"); McpTestHelper.AddCookie(request, cookieName, cookieValue); @@ -51,11 +48,11 @@ public async Task McpEndpoint_WithSiteCookieButWithoutBearer_Returns401() public async Task McpEndpoint_WithValidToken_Returns200AndListsTools() { (_, string rawToken) = await McpTestHelper.CreateUserAndTokenAsync( - factory, + Factory, "integration-test", userPrefix: "mcp-testuser"); - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); // Step 1: Initialize the MCP session using var initRequest = McpTestHelper.CreateInitializeRequest("/mcp"); @@ -108,7 +105,7 @@ public async Task McpEndpoint_WithValidToken_Returns200AndListsTools() [Test] public async Task McpEndpoint_WithInvalidToken_Returns401() { - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); using var request = McpTestHelper.CreateInitializeRequest("/mcp"); McpTestHelper.AddBearerToken(request, "mcp_invalid_token_that_does_not_exist"); using HttpResponseMessage response = await client.SendAsync(request); @@ -118,16 +115,16 @@ public async Task McpEndpoint_WithInvalidToken_Returns401() [Test] public async Task McpEndpoint_WithRevokedToken_Returns401() { - string testUserId = await McpTestHelper.CreateUserAsync(factory, "revoked-user"); + string testUserId = await McpTestHelper.CreateUserAsync(Factory, "revoked-user"); string rawToken; - using (var scope = factory.Services.CreateScope()) + using (var scope = Factory.Services.CreateScope()) { var tokenService = scope.ServiceProvider.GetRequiredService(); (rawToken, var entity) = await tokenService.CreateTokenAsync(testUserId, "revoke-test"); await tokenService.RevokeTokenAsync(entity.Id, testUserId); } - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); using var request = McpTestHelper.CreateInitializeRequest("/mcp"); McpTestHelper.AddBearerToken(request, rawToken); using HttpResponseMessage response = await client.SendAsync(request); @@ -138,12 +135,12 @@ public async Task McpEndpoint_WithRevokedToken_Returns401() public async Task McpEndpoint_WithExpiredToken_Returns401() { (_, string rawToken) = await McpTestHelper.CreateUserAndTokenAsync( - factory, + Factory, "expired-test", userPrefix: "expired-user", expiresAt: DateTime.UtcNow.AddSeconds(-1)); - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); using var request = McpTestHelper.CreateInitializeRequest("/mcp"); McpTestHelper.AddBearerToken(request, rawToken); using HttpResponseMessage response = await client.SendAsync(request); @@ -153,7 +150,7 @@ public async Task McpEndpoint_WithExpiredToken_Returns401() [Test] public async Task WellKnownOAuthProtectedResource_AllMethodsReturn404WithoutRedirectAndNoStore() { - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); foreach (HttpMethod method in new[] { HttpMethod.Get, HttpMethod.Post, HttpMethod.Options }) { @@ -171,7 +168,7 @@ await Assert.That(response.StatusCode) [Test] public async Task McpEndpoint_PreflightFromLoopbackOrigin_ReturnsCorsHeaders() { - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); using var request = new HttpRequestMessage(HttpMethod.Options, "/mcp"); request.Headers.Add("Origin", "http://localhost:6274"); request.Headers.Add("Access-Control-Request-Method", "POST"); @@ -191,7 +188,7 @@ public async Task McpEndpoint_PreflightFromLoopbackOrigin_ReturnsCorsHeaders() [Test] public async Task McpEndpoint_GetFromLoopbackOrigin_Returns405WithoutRedirect() { - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); using var request = new HttpRequestMessage(HttpMethod.Get, "/mcp"); request.Headers.Add("Origin", "http://localhost:6274"); request.Headers.Accept.ParseAdd("text/event-stream"); diff --git a/EssentialCSharp.Web.Tests/McpToolContractTests.cs b/EssentialCSharp.Web.Tests/McpToolContractTests.cs index ec806220..d9e1582f 100644 --- a/EssentialCSharp.Web.Tests/McpToolContractTests.cs +++ b/EssentialCSharp.Web.Tests/McpToolContractTests.cs @@ -2,15 +2,12 @@ using System.Text; using System.Text.Json; using EssentialCSharp.Web.Services; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace EssentialCSharp.Web.Tests; -[NotInParallel("McpTests")] -[ClassDataSource(Shared = SharedType.PerClass)] -public class McpToolContractTests(WebApplicationFactory factory) +public class McpToolContractTests : IntegrationTestBase { [Test] public async Task McpToolsList_StructuredAndHybridTools_AdvertiseOutputSchema() @@ -201,10 +198,10 @@ public async Task McpCall_GetChapterSections_WithInvalidChapter_ReturnsMcpError( private async Task<(HttpClient Client, string RawToken, string? SessionId)> CreateAuthenticatedSessionAsync() { (_, string rawToken) = await McpTestHelper.CreateUserAndTokenAsync( - factory, + Factory, "mcp-contract-test", userPrefix: "mcp-contract"); - HttpClient client = McpTestHelper.CreateClient(factory); + HttpClient client = McpTestHelper.CreateClient(Factory); using var initRequest = McpTestHelper.CreateInitializeRequest("/mcp"); McpTestHelper.AddBearerToken(initRequest, rawToken); @@ -223,7 +220,7 @@ public async Task McpCall_GetChapterSections_WithInvalidChapter_ReturnsMcpError( private string GetConfiguredBaseUrl() { - string baseUrl = factory.Services.GetRequiredService>().Value.BaseUrl; + string baseUrl = Factory.Services.GetRequiredService>().Value.BaseUrl; return baseUrl.TrimEnd('/') + "/"; } diff --git a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs index ed5866fc..a238451d 100644 --- a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs +++ b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs @@ -3,21 +3,13 @@ namespace EssentialCSharp.Web.Tests; -[ClassDataSource(Shared = SharedType.PerClass)] -public class RouteConfigurationServiceTests +public class RouteConfigurationServiceTests : IntegrationTestBase { - private readonly WebApplicationFactory _Factory; - - public RouteConfigurationServiceTests(WebApplicationFactory factory) - { - _Factory = factory; - } - [Test] public async Task GetStaticRoutes_ShouldReturnExpectedRoutes() { // Act - var routes = _Factory.InServiceScope(serviceProvider => + var routes = InServiceScope(serviceProvider => { var routeConfigurationService = serviceProvider.GetRequiredService(); return routeConfigurationService.GetStaticRoutes().ToList(); @@ -33,4 +25,4 @@ public async Task GetStaticRoutes_ShouldReturnExpectedRoutes() await Assert.That(routes).Contains("announcements"); await Assert.That(routes).Contains("termsofservice"); } -} \ No newline at end of file +} diff --git a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs index 1adc0dff..b47b49bf 100644 --- a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs +++ b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs @@ -3,20 +3,10 @@ using EssentialCSharp.Web.Helpers; using EssentialCSharp.Web.Services; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace EssentialCSharp.Web.Tests; -[NotInParallel("SitemapTests")] -[ClassDataSource(Shared = SharedType.PerClass)] -public class SitemapXmlHelpersTests +public class SitemapXmlHelpersTests : IntegrationTestBase { - private readonly WebApplicationFactory _Factory; - - public SitemapXmlHelpersTests(WebApplicationFactory factory) - { - _Factory = factory; - } - [Test] public async Task EnsureSitemapHealthy_WithValidSiteMappings_DoesNotThrow() { @@ -73,7 +63,7 @@ public async Task GenerateSitemapXml_DoesNotIncludeIdentityRoutes() var baseUrl = "https://test.example.com/"; // Act & Assert - var routeConfigurationService = _Factory.Services.GetRequiredService(); + var routeConfigurationService = Factory.Services.GetRequiredService(); SitemapXmlHelpers.GenerateSitemapXml( siteMappings, routeConfigurationService, @@ -99,7 +89,7 @@ public async Task GenerateSitemapXml_IncludesBaseUrl() var baseUrl = "https://test.example.com/"; // Act & Assert - var routeConfigurationService = _Factory.Services.GetRequiredService(); + var routeConfigurationService = Factory.Services.GetRequiredService(); SitemapXmlHelpers.GenerateSitemapXml( siteMappings, routeConfigurationService, @@ -131,7 +121,7 @@ public async Task GenerateSitemapXml_IncludesSiteMappingsMarkedForXml() }; // Act & Assert - var routeConfigurationService = _Factory.Services.GetRequiredService(); + var routeConfigurationService = Factory.Services.GetRequiredService(); SitemapXmlHelpers.GenerateSitemapXml( siteMappings, routeConfigurationService, @@ -153,7 +143,7 @@ public async Task GenerateSitemapXml_DoesNotIncludeIndexRoutes() var baseUrl = "https://test.example.com/"; // Act & Assert - var routeConfigurationService = _Factory.Services.GetRequiredService(); + var routeConfigurationService = Factory.Services.GetRequiredService(); SitemapXmlHelpers.GenerateSitemapXml( siteMappings, routeConfigurationService, @@ -174,7 +164,7 @@ public async Task GenerateSitemapXml_DoesNotIncludeErrorRoutes() var baseUrl = "https://test.example.com/"; // Act & Assert - var routeConfigurationService = _Factory.Services.GetRequiredService(); + var routeConfigurationService = Factory.Services.GetRequiredService(); SitemapXmlHelpers.GenerateSitemapXml( siteMappings, routeConfigurationService, @@ -195,7 +185,7 @@ public async Task GenerateSitemapXml_DoesNotIncludeSitemapRoute() var baseUrl = "https://test.example.com/"; // Act - var routeConfigurationService = _Factory.Services.GetRequiredService(); + var routeConfigurationService = Factory.Services.GetRequiredService(); SitemapXmlHelpers.GenerateSitemapXml( siteMappings, routeConfigurationService, @@ -221,7 +211,7 @@ public async Task GenerateSitemapXml_UsesLastModifiedDateFromSiteMapping() }; // Act - var routeConfigurationService = _Factory.Services.GetRequiredService(); + var routeConfigurationService = Factory.Services.GetRequiredService(); SitemapXmlHelpers.GenerateSitemapXml( siteMappings, routeConfigurationService, @@ -244,7 +234,7 @@ public async Task GenerateSitemapXml_DoesNotSetLastModifiedDateWhenSiteMappingDa }; // Act - var routeConfigurationService = _Factory.Services.GetRequiredService(); + var routeConfigurationService = Factory.Services.GetRequiredService(); SitemapXmlHelpers.GenerateSitemapXml( siteMappings, routeConfigurationService, diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 287150ee..cf7a978e 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -1,77 +1,98 @@ using System.Data.Common; using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Services; -using TUnit.Core.Interfaces; +using TUnit.AspNetCore; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; namespace EssentialCSharp.Web.Tests; -public sealed class WebApplicationFactory : WebApplicationFactory, IAsyncInitializer +public sealed class WebApplicationFactory : TestWebApplicationFactory { - public Task InitializeAsync() + // One GUID per factory instance (field, not computed property) ensures each factory + // gets its own isolated in-memory database and keeps a stable connection string. + private readonly string _sqlConnectionString = + $"DataSource=file:{Guid.NewGuid():N}?mode=memory&cache=shared"; + + private readonly SemaphoreSlim _schemaInitializationGate = new(1, 1); + + // Kept open for the factory lifetime so the shared-cache in-memory database is not dropped + // when per-scope connections are disposed between requests. + private readonly SqliteConnection _keepAliveConnection; + + public WebApplicationFactory() { - // Force eager server initialization before tests run. - // This is thread-safe and prevents race conditions from parallel tests - // calling CreateClient() concurrently during lazy init. - _ = Server; - return Task.CompletedTask; + _keepAliveConnection = new SqliteConnection(_sqlConnectionString); + _keepAliveConnection.Open(); } - private static string SqlConnectionString => $"DataSource=file:{Guid.NewGuid()}?mode=memory&cache=shared"; - private SqliteConnection? _Connection; - protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { - ServiceDescriptor? dbContextDescriptor = services.SingleOrDefault( - d => d.ServiceType == - typeof(IDbContextOptionsConfiguration)); + RemoveSingleOrNone( + services, + descriptor => descriptor.ServiceType == + typeof(IDbContextOptionsConfiguration), + "IDbContextOptionsConfiguration"); - if (dbContextDescriptor != null) - { - services.Remove(dbContextDescriptor); - } - - ServiceDescriptor? dbConnectionDescriptor = - services.SingleOrDefault( - d => d.ServiceType == - typeof(DbConnection)); - - if (dbConnectionDescriptor != null) - { - services.Remove(dbConnectionDescriptor); - } + RemoveSingleOrNone( + services, + descriptor => descriptor.ServiceType == typeof(DbConnection), + nameof(DbConnection)); // Remove DatabaseMigrationService: it calls MigrateAsync which conflicts // with EnsureCreated() used below for the in-memory SQLite test database. - ServiceDescriptor? migrationServiceDescriptor = services.SingleOrDefault( - d => d.ImplementationType == typeof(DatabaseMigrationService)); - if (migrationServiceDescriptor != null) + RemoveSingleOrNone( + services, + descriptor => descriptor.ServiceType == typeof(IHostedService) && + descriptor.ImplementationType == typeof(DatabaseMigrationService), + nameof(DatabaseMigrationService)); + + // Keep per-scope connections so each request/test scope uses a fresh DbConnection, + // which avoids locking issues from sharing one SqliteConnection instance. + services.AddScoped(_ => { - services.Remove(migrationServiceDescriptor); - } - - _Connection = new SqliteConnection(SqlConnectionString); - _Connection.Open(); + SqliteConnection conn = new(_sqlConnectionString); + try + { + conn.Open(); + } + catch + { + conn.Dispose(); + throw; + } + return conn; + }); - services.AddDbContext(options => + // The scoped DI container owns this DbConnection instance and disposes it at + // scope end; EF Core treats it as externally owned when passed via UseSqlite. + services.AddDbContext((serviceProvider, options) => { - options.UseSqlite(_Connection); + DbConnection dbConnection = serviceProvider.GetRequiredService(); + options.UseSqlite(dbConnection); }); - using ServiceProvider serviceProvider = services.BuildServiceProvider(); - using IServiceScope scope = serviceProvider.CreateScope(); - IServiceProvider scopedServices = scope.ServiceProvider; - EssentialCSharpWebContext db = scopedServices.GetRequiredService(); - - db.Database.EnsureCreated(); + // Ensure schema exists before hosted services by prepending this registration + // at index 0 of the service collection. + RemoveSingleOrNone( + services, + descriptor => descriptor.ServiceType == typeof(IHostedService) && + descriptor.ImplementationType == typeof(EnsureCreatedHostedService), + nameof(EnsureCreatedHostedService)); + + ServiceDescriptor ensureCreatedDescriptor = + ServiceDescriptor.Singleton( + serviceProvider => new EnsureCreatedHostedService( + serviceProvider, + _schemaInitializationGate)); + services.Insert(0, ensureCreatedDescriptor); // Replace IListingSourceCodeService with one backed by TestData services.RemoveAll(); @@ -80,48 +101,62 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); } - /// - /// Executes an action within a service scope, handling scope creation and cleanup automatically. - /// - /// The return type of the action - /// The action to execute with the scoped service provider - /// The result of the action - public T InServiceScope(Func action) + protected override void Dispose(bool disposing) { - var factory = Services.GetRequiredService(); - using var scope = factory.CreateScope(); - return action(scope.ServiceProvider); + if (disposing) + { + _keepAliveConnection.Dispose(); + _schemaInitializationGate.Dispose(); + } + base.Dispose(disposing); } - /// - /// Executes an action within a service scope, handling scope creation and cleanup automatically. - /// - /// The action to execute with the scoped service provider - public void InServiceScope(Action action) + private static void RemoveSingleOrNone( + IServiceCollection services, + Func predicate, + string descriptorName) { - var factory = Services.GetRequiredService(); - using var scope = factory.CreateScope(); - action(scope.ServiceProvider); - } + List matchIndexes = []; + for (int i = 0; i < services.Count; i++) + { + if (predicate(services[i])) + { + matchIndexes.Add(i); + } + } - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync().ConfigureAwait(false); - if (_Connection != null) + if (matchIndexes.Count > 1) + { + throw new InvalidOperationException( + $"Expected at most one '{descriptorName}' registration but found {matchIndexes.Count}."); + } + + if (matchIndexes.Count == 1) { - await _Connection.DisposeAsync().ConfigureAwait(false); - _Connection = null; + services.RemoveAt(matchIndexes[0]); } - GC.SuppressFinalize(this); } - protected override void Dispose(bool disposing) + private sealed class EnsureCreatedHostedService( + IServiceProvider serviceProvider, + SemaphoreSlim schemaInitializationGate) : IHostedService { - base.Dispose(disposing); - if (disposing) + public async Task StartAsync(CancellationToken cancellationToken) { - _Connection?.Dispose(); - _Connection = null; + await schemaInitializationGate.WaitAsync(cancellationToken); + try + { + using IServiceScope scope = serviceProvider.CreateScope(); + EssentialCSharpWebContext dbContext = + scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.EnsureCreatedAsync(cancellationToken); + } + finally + { + schemaInitializationGate.Release(); + } } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } }