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;
}
}