Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
685c57c
Migrate integration tests to TUnit.AspNetCore WebApplicationTest
BenjaminMichaelis May 13, 2026
10bfa05
fix: use Factory.Inner.CreateClient() in FunctionalTests for redirect…
BenjaminMichaelis May 13, 2026
756a93d
refactor(tests): restore trace correlation and add async InServiceSco…
BenjaminMichaelis May 13, 2026
148b516
fix: scope disposal guard to SQLite connections only, not base disposal
BenjaminMichaelis May 13, 2026
a264306
Merge branch 'main' into agents/tunit-aspnetcore-integration-tests
BenjaminMichaelis May 14, 2026
1a845d8
test: fix integration factory disposal and traced redirects
BenjaminMichaelis May 15, 2026
2f9c831
test: fix SqliteConnection disposal analyzer warning
BenjaminMichaelis May 15, 2026
c47b9be
test: address PR feedback on startup order and redirects
BenjaminMichaelis May 16, 2026
93269b3
test: address remaining PR review comments
BenjaminMichaelis May 16, 2026
395b9d8
refactor(tests): address PR feedback - rename helper, fix doc, scoped…
BenjaminMichaelis May 16, 2026
dff58d7
fix(tests): make WebApplicationFactory service overrides idempotent
BenjaminMichaelis May 17, 2026
c4445d2
fix(tests): serialize SQLite EnsureCreated and disable auto redirect …
BenjaminMichaelis May 17, 2026
153572e
fix(tests): harden factory override checks and keep redirect handling…
BenjaminMichaelis May 17, 2026
761b9d5
refactor(tests): address latest PR feedback on tracing and helper con…
BenjaminMichaelis May 17, 2026
4cc987d
fix(tests): serialize schema creation per test factory
BenjaminMichaelis May 17, 2026
794da81
fix(tests): restore non-redirecting clients and harden keep-alive init
BenjaminMichaelis May 17, 2026
5026519
fix(tests): dispose response before redirect-limit exception
BenjaminMichaelis May 17, 2026
25cb216
chore(tests): clarify hosted-service insertion and harden connection …
BenjaminMichaelis May 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.AspNetCore" Version="1.5.0" />
<PackageVersion Include="TUnit" Version="1.40.5" />
<PackageVersion Include="TUnit.AspNetCore" Version="1.40.5" />
<PackageVersion Include="EssentialCSharp.Shared.Models" Version="$(ToolingPackagesVersion)" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="IntelliTect.Multitool" Version="2.1.0" />
Expand Down
12 changes: 3 additions & 9 deletions EssentialCSharp.Web.Tests/ContentRateLimitingTests.cs
Original file line number Diff line number Diff line change
@@ -1,24 +1,18 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;

namespace EssentialCSharp.Web.Tests;

/// <summary>
/// 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.
/// </summary>
[ClassDataSource<WebApplicationFactory>(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++)
Expand Down
1 change: 1 addition & 0 deletions EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<PackageReference Include="Moq" />
<PackageReference Include="Moq.AutoMock" />
<PackageReference Include="TUnit" />
<PackageReference Include="TUnit.AspNetCore" />
<PackageReference Include="Newtonsoft.Json" />
</ItemGroup>

Expand Down
18 changes: 8 additions & 10 deletions EssentialCSharp.Web.Tests/FunctionalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

namespace EssentialCSharp.Web.Tests;

[NotInParallel("FunctionalTests")]
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
public class FunctionalTests(WebApplicationFactory factory)
public class FunctionalTests : IntegrationTestBase
{
[Test]
[Arguments("/")]
Expand All @@ -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);
}
Expand All @@ -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);

Expand All @@ -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);
}
}
}
84 changes: 84 additions & 0 deletions EssentialCSharp.Web.Tests/IntegrationTestBase.cs
Original file line number Diff line number Diff line change
@@ -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<WebApplicationFactory, Program>
{
/// <summary>
/// Creates an <see cref="HttpClient"/> with <c>AllowAutoRedirect = false</c> so callers can
/// assert exact redirect status codes and <c>Location</c> headers without the client
/// silently following them.
/// </summary>
protected HttpClient CreateClientWithoutAutoRedirect() =>
Factory.Inner.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });

/// <summary>
/// Executes an initial GET and follows redirect responses with subsequent GET requests.
/// This helper is intentionally GET-only.
/// </summary>
protected static async Task<HttpResponseMessage> GetFollowingGetRedirectsAsync(
Comment thread
BenjaminMichaelis marked this conversation as resolved.
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}.");
Comment thread
BenjaminMichaelis marked this conversation as resolved.
Comment on lines +46 to +48
}

return response;
}
Comment on lines +22 to +52

private static bool IsRedirectStatusCode(HttpStatusCode statusCode) =>
statusCode == HttpStatusCode.Moved ||
statusCode == HttpStatusCode.Found ||
statusCode == HttpStatusCode.RedirectMethod ||
statusCode == HttpStatusCode.TemporaryRedirect ||
statusCode == HttpStatusCode.PermanentRedirect;
Comment thread
BenjaminMichaelis marked this conversation as resolved.

protected T InServiceScope<T>(Func<IServiceProvider, T> action)
{
using IServiceScope scope = Factory.Services.GetRequiredService<IServiceScopeFactory>().CreateScope();
return action(scope.ServiceProvider);
}

protected void InServiceScope(Action<IServiceProvider> action)
{
using IServiceScope scope = Factory.Services.GetRequiredService<IServiceScopeFactory>().CreateScope();
action(scope.ServiceProvider);
}

protected async Task<T> InServiceScopeAsync<T>(Func<IServiceProvider, Task<T>> action)
{
using IServiceScope scope = Factory.Services.GetRequiredService<IServiceScopeFactory>().CreateScope();
return await action(scope.ServiceProvider);
}

protected async Task InServiceScopeAsync(Func<IServiceProvider, Task> action)
{
using IServiceScope scope = Factory.Services.GetRequiredService<IServiceScopeFactory>().CreateScope();
await action(scope.ServiceProvider);
}
Comment thread
BenjaminMichaelis marked this conversation as resolved.
}
13 changes: 6 additions & 7 deletions EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@

namespace EssentialCSharp.Web.Tests;

[ClassDataSource<WebApplicationFactory>(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");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
11 changes: 4 additions & 7 deletions EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<WebApplicationFactory>(Shared = SharedType.PerClass)]
public class McpApiTokenServiceTests(WebApplicationFactory factory)
public class McpApiTokenServiceTests : IntegrationTestBase
{
private readonly List<IServiceScope> _scopes = [];

Expand All @@ -21,16 +18,16 @@ public void DisposeScopes()

Comment thread
BenjaminMichaelis marked this conversation as resolved.
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<McpApiTokenService>();
return (userId, tokenService);
}

private async Task<McpApiTokenService> FillToLimitAsync(string userId)
{
var scope = factory.Services.CreateScope();
var scope = Factory.Services.CreateScope();
_scopes.Add(scope);
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
for (int i = 0; i < McpApiTokenService.MaxTokensPerUser; i++)
Expand Down
Loading
Loading