Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 53 additions & 0 deletions src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.Mvc.Testing.Handlers;
using Microsoft.Extensions.Http;

namespace TUnit.AspNetCore.Http;
Expand Down Expand Up @@ -40,4 +42,55 @@ internal static DelegatingHandler[] PrependPropagationHandlers(DelegatingHandler
Array.Copy(handlers, 0, all, 2, handlers.Length);
return all;
}

/// <summary>
/// Creates a client the way <c>WebApplicationFactory.CreateClient(WebApplicationFactoryClientOptions)</c>
/// does, with the TUnit propagation handlers added. <paramref name="createDefaultClient"/> must be the
/// factory's base <c>CreateDefaultClient(DelegatingHandler[])</c>, which also runs the factory's
/// <c>ConfigureClient</c> override.
/// </summary>
internal static HttpClient CreateClient(
Func<DelegatingHandler[], HttpClient> createDefaultClient,
WebApplicationFactoryClientOptions options)
{
var client = createDefaultClient(CreateClientOptionsHandlers(options));
// Read BaseAddress after the client is created: starting a Kestrel-backed factory
// replaces ClientOptions.BaseAddress with the server's real address.
client.BaseAddress = options.BaseAddress;
return client;
}

/// <summary>
/// Returns the handlers that <see cref="WebApplicationFactoryClientOptions"/> requests, followed by the
/// TUnit propagation handlers: a <see cref="RedirectHandler"/> when
/// <see cref="WebApplicationFactoryClientOptions.AllowAutoRedirect"/> is set, then a
/// <see cref="CookieContainerHandler"/> when <see cref="WebApplicationFactoryClientOptions.HandleCookies"/>
/// is set. Mirrors the internal <c>WebApplicationFactoryClientOptions.CreateHandlers</c> in
/// Microsoft.AspNetCore.Mvc.Testing 8.0 to 10.0; update this if a later version adds a handler.
/// </summary>
/// <remarks>
/// The propagation handlers sit inside <see cref="RedirectHandler"/> so that every redirect hop
/// gets freshly injected headers, not only the first request.
/// </remarks>
internal static DelegatingHandler[] CreateClientOptionsHandlers(WebApplicationFactoryClientOptions options)
{
var count = 2 + (options.AllowAutoRedirect ? 1 : 0) + (options.HandleCookies ? 1 : 0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
var all = new DelegatingHandler[count];
var index = 0;

if (options.AllowAutoRedirect)
{
all[index++] = new RedirectHandler(options.MaxAutomaticRedirections);
}

if (options.HandleCookies)
{
all[index++] = new CookieContainerHandler();
}

all[index++] = new ActivityPropagationHandler();
all[index] = new TUnitTestIdHandler();

return all;
}
}
22 changes: 14 additions & 8 deletions src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,14 +233,20 @@ private static ServiceDescriptor WrapHostedServiceDescriptor(ServiceDescriptor d
}

/// <summary>
/// Creates an <see cref="HttpClient"/> with automatic Activity tracing and test context propagation.
/// Equivalent to calling <see cref="CreateDefaultClient(DelegatingHandler[])"/> with no additional handlers.
/// Creates an <see cref="HttpClient"/> configured by <see cref="WebApplicationFactory{TEntryPoint}.ClientOptions"/>,
/// with automatic Activity tracing and test context propagation.
/// </summary>
public new HttpClient CreateClient()
{
var client = CreateDefaultClient();
ConfigureClient(client);
return client;
}
public new HttpClient CreateClient() => CreateClient(ClientOptions);

/// <summary>
/// Creates an <see cref="HttpClient"/> configured by <paramref name="options"/>, with automatic
/// Activity tracing and test context propagation. Honors
/// <see cref="WebApplicationFactoryClientOptions.AllowAutoRedirect"/>,
/// <see cref="WebApplicationFactoryClientOptions.MaxAutomaticRedirections"/>,
/// <see cref="WebApplicationFactoryClientOptions.HandleCookies"/> and
/// <see cref="WebApplicationFactoryClientOptions.BaseAddress"/>.
/// </summary>
public new HttpClient CreateClient(WebApplicationFactoryClientOptions options) =>
TUnitHttpClientFilter.CreateClient(base.CreateDefaultClient, options);
Comment on lines +249 to +250

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cover the new overload in public-API snapshots

This overload and the matching TracedWebApplicationFactory.CreateClient(options) method expand the shipped public surface, but tests/TUnit.PublicAPI neither references nor snapshots TUnit.AspNetCore.Core, so these additions bypass API compatibility checks. Add the ASP.NET Core assembly to that suite and commit the per-TFM baselines for the new APIs.

AGENTS.md reference: AGENTS.md:L15-L16

Useful? React with 👍 / 👎.


}
15 changes: 12 additions & 3 deletions src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,19 @@ public TracedWebApplicationFactory(WebApplicationFactory<TEntryPoint> inner)
public IServiceProvider Services => _inner.Services;

/// <summary>
/// Creates an <see cref="HttpClient"/> with activity tracing and test context propagation.
/// Creates an <see cref="HttpClient"/> configured by the inner factory's
/// <see cref="WebApplicationFactory{TEntryPoint}.ClientOptions"/>, with activity tracing and
/// test context propagation.
/// </summary>
public HttpClient CreateClient() =>
_inner.CreateDefaultClient(TUnitHttpClientFilter.PrependPropagationHandlers([]));
public HttpClient CreateClient() => CreateClient(_inner.ClientOptions);

/// <summary>
/// Creates an <see cref="HttpClient"/> configured by <paramref name="options"/>, with activity
/// tracing and test context propagation. Honors cookie handling, auto-redirect and base address
/// settings the same way as <see cref="WebApplicationFactory{TEntryPoint}.CreateClient(WebApplicationFactoryClientOptions)"/>.
/// </summary>
public HttpClient CreateClient(WebApplicationFactoryClientOptions options) =>
TUnitHttpClientFilter.CreateClient(_inner.CreateDefaultClient, options);

/// <summary>
/// Creates an <see cref="HttpClient"/> with the specified delegating handlers, plus
Expand Down
19 changes: 19 additions & 0 deletions tests/TUnit.AspNetCore.Tests.WebApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@

app.MapGet("/ping", () => "pong");

// Cookie round-trip endpoints (thomhurst/TUnit#6921): the client must store the cookie
// set by /cookie/set and send it back on /cookie/get when ClientOptions.HandleCookies is on.
app.MapGet("/cookie/set/{value}", (string value, HttpContext context) =>
{
context.Response.Cookies.Append("tunit-cookie", value);
return Results.Ok();
});

app.MapGet("/cookie/get", (HttpContext context) =>
Results.Text(context.Request.Cookies.TryGetValue("tunit-cookie", out var value) ? value : "<none>"));

app.MapGet("/redirect", () => Results.Redirect("/ping"));
app.MapGet("/redirect-to-echo-headers", () => Results.Redirect("/echo-headers"));

// Echoes the request headers the test client sent, so tests can assert that TUnit's
// propagation headers are still emitted by option-configured clients.
app.MapGet("/echo-headers", (HttpContext context) =>
Results.Text(string.Join("\n", context.Request.Headers.Select(h => $"{h.Key}: {h.Value}"))));

// Outbound call through IHttpClientFactory. The downstream pipeline's primary
// handler echoes request headers back in the response body so tests can assert
// which headers the SUT-side HttpClient actually emitted.
Expand Down
198 changes: 198 additions & 0 deletions tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using TUnit.AspNetCore;
using TUnit.Core;

namespace TUnit.AspNetCore.Tests;

/// <summary>
/// Regression coverage for thomhurst/TUnit#6921: clients created by
/// <see cref="TestWebApplicationFactory{TEntryPoint}"/> must honor
/// <see cref="WebApplicationFactoryClientOptions"/> (cookies, redirects, base address)
/// while still carrying TUnit's propagation headers.
/// </summary>
public class ClientOptionsTests
{
[ClassDataSource(Shared = [SharedType.PerTestSession])]
public TestWebAppFactory Factory { get; set; } = null!;

[Test]
public async Task CreateClient_HandlesCookies_ByDefault()
{
using var client = Factory.CreateClient();

await AssertCookieRoundTrip(client, expected: "abc");
}

[Test]
public async Task CreateClient_FollowsRedirects_ByDefault()
{
using var client = Factory.CreateClient();

var response = await client.GetAsync("/redirect");

await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
await Assert.That(await response.Content.ReadAsStringAsync()).IsEqualTo("pong");
}

[Test]
public async Task CreateClient_WithOptions_CanDisableCookiesAndRedirects()
{
using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = false,
AllowAutoRedirect = false,
});

await AssertCookieRoundTrip(client, expected: "<none>");

var response = await client.GetAsync("/redirect");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Redirect);
}

[Test]
public async Task CreateClient_WithOptions_UsesBaseAddress()
{
var baseAddress = new Uri("http://tunit.test/");

using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = baseAddress });

await Assert.That(client.BaseAddress).IsEqualTo(baseAddress);
}

[Test]
public async Task CreateClient_WithOptions_KeepsPropagationHeaders()
{
using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions());

var echoed = await client.GetStringAsync("/echo-headers");

await Assert.That(echoed).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id);
}

[Test]
public async Task CreateClient_Redirect_KeepsPropagationHeadersOnRedirectedRequest()
{
using var client = Factory.CreateClient();

await AssertRedirectedRequestHasTestIdHeader(client);
}

internal static async Task AssertRedirectedRequestHasTestIdHeader(HttpClient client)
{
var response = await client.GetAsync("/redirect-to-echo-headers");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);

var lines = (await response.Content.ReadAsStringAsync()).Split('\n');

// Exact line match proves a single value: /echo-headers joins repeated values with
// commas, so a header duplicated across redirect hops would not match this line.
await Assert.That(lines).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id);
}

internal static async Task AssertCookieRoundTrip(HttpClient client, string expected)
{
var set = await client.GetAsync("/cookie/set/abc");
set.EnsureSuccessStatusCode();

var value = await client.GetStringAsync("/cookie/get");

await Assert.That(value).IsEqualTo(expected);
}
}

public class WebApplicationTestClientOptionsTests : WebApplicationTest<TestWebAppFactory, Program>
{
[Test]
public async Task CreateClient_HandlesCookies_ByDefault()
{
using var client = Factory.CreateClient();

await ClientOptionsTests.AssertCookieRoundTrip(client, expected: "abc");
}

[Test]
public async Task CreateClient_FollowsRedirects_ByDefault()
{
using var client = Factory.CreateClient();

var response = await client.GetAsync("/redirect");

await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
await Assert.That(await response.Content.ReadAsStringAsync()).IsEqualTo("pong");
}

[Test]
public async Task CreateClient_Redirect_KeepsPropagationHeadersOnRedirectedRequest()
{
using var client = Factory.CreateClient();

await ClientOptionsTests.AssertRedirectedRequestHasTestIdHeader(client);
}

[Test]
public async Task CreateClient_WithOptions_CanDisableCookiesAndRedirects()
{
using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = false,
AllowAutoRedirect = false,
});

await ClientOptionsTests.AssertCookieRoundTrip(client, expected: "<none>");
Comment thread
greptile-apps[bot] marked this conversation as resolved.

var response = await client.GetAsync("/redirect");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Redirect);
}

[Test]
public async Task CreateClient_WithOptions_UsesBaseAddress()
{
var baseAddress = new Uri("http://tunit.test/");

using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = baseAddress });

await Assert.That(client.BaseAddress).IsEqualTo(baseAddress);
}

[Test]
public async Task CreateClient_WithOptions_KeepsPropagationHeaders()
{
using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions());

var echoed = await client.GetStringAsync("/echo-headers");

await Assert.That(echoed).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id);
}
}

/// <summary>
/// A <c>ConfigureClient</c> override must still apply to clients created through the
/// option-aware <c>CreateClient</c> overloads.
/// </summary>
public class ConfigureClientOverrideTests
{
[ClassDataSource(Shared = [SharedType.PerTestSession])]
public ConfigureClientWebAppFactory Factory { get; set; } = null!;

[Test]
public async Task CreateClient_RunsConfigureClientOverride()
{
using var client = Factory.CreateClient();

var echoed = await client.GetStringAsync("/echo-headers");

await Assert.That(echoed).Contains(ConfigureClientWebAppFactory.HeaderName + ": yes");
}
}

public class ConfigureClientWebAppFactory : TestWebAppFactory
{
public const string HeaderName = "X-Configured-By-Override";

protected override void ConfigureClient(HttpClient client)
{
base.ConfigureClient(client);
client.DefaultRequestHeaders.Add(HeaderName, "yes");
}
}
Loading