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..7685eeca 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
- });
+ HttpClient client = Factory.CreateClient();
// 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..d3fca5d8 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,7 +13,7 @@ public class FunctionalTests(WebApplicationFactory factory)
[Arguments("/alive")]
public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl)
{
- HttpClient client = factory.CreateClient();
+ HttpClient client = CreateRedirectFollowingClient();
using HttpResponseMessage response = await client.GetAsync(relativeUrl);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
@@ -31,7 +29,7 @@ public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl
[Arguments("/about?someOtherParam=value")]
public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl)
{
- HttpClient client = factory.CreateClient();
+ HttpClient client = CreateRedirectFollowingClient();
using HttpResponseMessage response = await client.GetAsync(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();
+ HttpClient client = CreateRedirectFollowingClient();
using HttpResponseMessage response = await client.GetAsync("/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..a7bf21b0
--- /dev/null
+++ b/EssentialCSharp.Web.Tests/IntegrationTestBase.cs
@@ -0,0 +1,42 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using TUnit.AspNetCore;
+
+namespace EssentialCSharp.Web.Tests;
+
+public abstract class IntegrationTestBase : WebApplicationTest
+{
+ ///
+ /// Creates an HTTP client with redirect following enabled.
+ /// NOTE: This bypasses TUnit trace correlation because
+ /// does not expose a CreateClient(WebApplicationFactoryClientOptions) overload.
+ /// Use for all
+ /// other tests where AllowAutoRedirect=false is acceptable.
+ ///
+ protected HttpClient CreateRedirectFollowingClient() =>
+ Factory.Inner.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = true });
+
+ public T InServiceScope(Func action)
+ {
+ using IServiceScope scope = Factory.Services.GetRequiredService().CreateScope();
+ return action(scope.ServiceProvider);
+ }
+
+ public void InServiceScope(Action action)
+ {
+ using IServiceScope scope = Factory.Services.GetRequiredService().CreateScope();
+ action(scope.ServiceProvider);
+ }
+
+ public async Task InServiceScopeAsync(Func> action)
+ {
+ using IServiceScope scope = Factory.Services.GetRequiredService().CreateScope();
+ return await action(scope.ServiceProvider);
+ }
+
+ public 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..db610ea4 100644
--- a/EssentialCSharp.Web.Tests/McpTestHelper.cs
+++ b/EssentialCSharp.Web.Tests/McpTestHelper.cs
@@ -6,18 +6,16 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
-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.CreateClient();
public static HttpRequestMessage CreateInitializeRequest(string path = "/mcp")
{
@@ -50,7 +48,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 +71,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 +88,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..db0feeb3 100644
--- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs
+++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs
@@ -1,9 +1,9 @@
+using System.Collections.Concurrent;
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;
@@ -12,19 +12,12 @@
namespace EssentialCSharp.Web.Tests;
-public sealed class WebApplicationFactory : WebApplicationFactory, IAsyncInitializer
+public sealed class WebApplicationFactory : TestWebApplicationFactory
{
- public Task InitializeAsync()
- {
- // 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;
- }
-
private static string SqlConnectionString => $"DataSource=file:{Guid.NewGuid()}?mode=memory&cache=shared";
- private SqliteConnection? _Connection;
+
+ // Each per-test factory's ConfigureWebHost creates a new connection; track all for disposal.
+ private readonly ConcurrentBag _connections = [];
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
@@ -58,12 +51,15 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
services.Remove(migrationServiceDescriptor);
}
- _Connection = new SqliteConnection(SqlConnectionString);
- _Connection.Open();
+ // Capture in a local variable so each per-test factory's closure binds
+ // to its own connection, not to a shared field that gets overwritten.
+ SqliteConnection connection = new(SqlConnectionString);
+ connection.Open();
+ _connections.Add(connection);
services.AddDbContext(options =>
{
- options.UseSqlite(_Connection);
+ options.UseSqlite(connection);
});
using ServiceProvider serviceProvider = services.BuildServiceProvider();
@@ -80,48 +76,25 @@ 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)
- {
- var factory = Services.GetRequiredService();
- using var scope = factory.CreateScope();
- return action(scope.ServiceProvider);
- }
-
- ///
- /// 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)
- {
- var factory = Services.GetRequiredService();
- using var scope = factory.CreateScope();
- action(scope.ServiceProvider);
- }
+ private int _connectionsDisposed;
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync().ConfigureAwait(false);
- if (_Connection != null)
+ if (Interlocked.Exchange(ref _connectionsDisposed, 1) == 0)
{
- await _Connection.DisposeAsync().ConfigureAwait(false);
- _Connection = null;
+ foreach (SqliteConnection connection in _connections)
+ await connection.DisposeAsync().ConfigureAwait(false);
}
- GC.SuppressFinalize(this);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
- if (disposing)
+ if (disposing && Interlocked.Exchange(ref _connectionsDisposed, 1) == 0)
{
- _Connection?.Dispose();
- _Connection = null;
+ foreach (SqliteConnection connection in _connections)
+ connection.Dispose();
}
}
}