// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
///
/// Unit tests for .
///
public sealed class DefaultHttpRequestHandlerTests
{
private static readonly string[] s_setCookieValues = ["a=1", "b=2"];
private const string TestUrl = "https://api.example.test/resource";
#region Constructor Tests
[Fact]
public async Task ConstructorWithNoParametersCreatesInstanceAsync()
{
// Act
await using DefaultHttpRequestHandler handler = new();
// Assert
handler.Should().NotBeNull();
}
[Fact]
public async Task ConstructorWithNullProviderCreatesInstanceAsync()
{
// Act
await using DefaultHttpRequestHandler handler = new(httpClientProvider: null);
// Assert
handler.Should().NotBeNull();
}
[Fact]
public void ConstructorWithNullHttpClientThrows()
{
// Act
Action act = () => _ = new DefaultHttpRequestHandler((HttpClient)null!);
// Assert
act.Should().Throw();
}
[Fact]
public async Task ConstructorWithHttpClientUsesSuppliedClientForAllRequestsAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("ok", Encoding.UTF8, "text/plain"),
}));
using HttpClient suppliedClient = new(messageHandler);
await using DefaultHttpRequestHandler handler = new(suppliedClient);
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
// Act
HttpRequestResult result = await handler.SendAsync(request);
// Assert - the supplied HttpClient's underlying handler saw the request
messageHandler.LastRequest.Should().NotBeNull();
messageHandler.LastRequest!.RequestUri!.ToString().Should().Be(TestUrl);
result.Body.Should().Be("ok");
}
[Fact]
public async Task DisposeAsyncDoesNotDisposeCallerSuppliedHttpClientAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
using HttpClient suppliedClient = new(messageHandler);
// Act
DefaultHttpRequestHandler handler = new(suppliedClient);
await handler.DisposeAsync();
// Assert - supplied client remains usable (not disposed)
Func act = async () => await suppliedClient.GetAsync(new Uri(TestUrl));
await act.Should().NotThrowAsync();
}
#endregion
#region Argument Validation Tests
[Fact]
public async Task SendAsyncWithNullRequestThrowsAsync()
{
// Arrange
await using DefaultHttpRequestHandler handler = new();
// Act
Func act = async () => await handler.SendAsync(null!);
// Assert
await act.Should().ThrowAsync();
}
[Fact]
public async Task SendAsyncWithEmptyUrlThrowsAsync()
{
// Arrange
await using DefaultHttpRequestHandler handler = new();
HttpRequestInfo request = new() { Method = "GET", Url = "" };
// Act
Func act = async () => await handler.SendAsync(request);
// Assert
await act.Should().ThrowAsync();
}
[Fact]
public async Task SendAsyncWithEmptyMethodThrowsAsync()
{
// Arrange
await using DefaultHttpRequestHandler handler = new();
HttpRequestInfo request = new() { Method = "", Url = TestUrl };
// Act
Func act = async () => await handler.SendAsync(request);
// Assert
await act.Should().ThrowAsync();
}
#endregion
#region Send Behavior Tests
[Fact]
public async Task SendAsyncUsesProvidedHttpClientAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("hello", Encoding.UTF8, "text/plain"),
}));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler)));
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
// Act
HttpRequestResult result = await handler.SendAsync(request);
// Assert
messageHandler.LastRequest.Should().NotBeNull();
messageHandler.LastRequest!.Method.Should().Be(HttpMethod.Get);
messageHandler.LastRequest.RequestUri!.ToString().Should().Be(TestUrl);
result.StatusCode.Should().Be(200);
result.IsSuccessStatusCode.Should().BeTrue();
result.Body.Should().Be("hello");
}
[Fact]
public async Task SendAsyncMapsAllKnownMethodsAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler)));
foreach (string method in new[] { "GET", "POST", "PUT", "PATCH", "DELETE", "CUSTOM" })
{
HttpRequestInfo request = new() { Method = method, Url = TestUrl };
// Act
await handler.SendAsync(request);
// Assert
messageHandler.LastRequest!.Method.Method.Should().Be(method);
}
}
[Fact]
public async Task SendAsyncNormalizesWhitespaceAroundCustomMethodAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler)));
HttpRequestInfo request = new() { Method = " custom ", Url = TestUrl };
// Act
await handler.SendAsync(request);
// Assert - fallback path should apply the same Trim/ToUpperInvariant normalization.
messageHandler.LastRequest!.Method.Method.Should().Be("CUSTOM");
}
[Fact]
public async Task SendAsyncAppliesBodyAndContentTypeAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler)));
HttpRequestInfo request = new()
{
Method = "POST",
Url = TestUrl,
Body = "{\"hello\":\"world\"}",
BodyContentType = "application/json",
};
// Act
await handler.SendAsync(request);
// Assert
messageHandler.LastRequestBody.Should().Be("{\"hello\":\"world\"}");
messageHandler.LastRequestContentType.Should().Be("application/json");
}
[Fact]
public async Task SendAsyncAppliesRequestHeadersAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler)));
HttpRequestInfo request = new()
{
Method = "GET",
Url = TestUrl,
Headers = new Dictionary
{
["Authorization"] = "Bearer secret",
["Accept"] = "application/json",
},
};
// Act
await handler.SendAsync(request);
// Assert
messageHandler.LastRequest!.Headers.Authorization!.ToString().Should().Be("Bearer secret");
messageHandler.LastRequest.Headers.Accept.Should().Contain(mediaType => mediaType.MediaType == "application/json");
}
[Fact]
public async Task SendAsyncRoutesContentHeadersToBodyAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler)));
HttpRequestInfo request = new()
{
Method = "POST",
Url = TestUrl,
Body = "raw",
BodyContentType = "text/plain",
Headers = new Dictionary
{
["Content-Language"] = "en-US",
},
};
// Act
await handler.SendAsync(request);
// Assert
messageHandler.LastRequest!.Content!.Headers.ContentLanguage.Should().Contain("en-US");
}
[Fact]
public async Task SendAsyncCapturesResponseHeadersAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
{
#pragma warning disable CA2025
HttpResponseMessage response = new(HttpStatusCode.OK)
{
Content = new StringContent("ok", Encoding.UTF8, "text/plain"),
};
response.Headers.Add("X-Request-Id", "request-1");
response.Headers.Add("Set-Cookie", s_setCookieValues);
return Task.FromResult(response);
#pragma warning restore CA2025
});
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler)));
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
// Act
HttpRequestResult result = await handler.SendAsync(request);
// Assert
result.Headers.Should().NotBeNull();
result.Headers!.Should().ContainKey("X-Request-Id");
result.Headers!["Set-Cookie"].Should().BeEquivalentTo(s_setCookieValues);
// Content headers also flattened in.
result.Headers!.Should().ContainKey("Content-Type");
}
[Fact]
public async Task SendAsyncReturnsFailureStatusWithoutThrowingAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad request", Encoding.UTF8, "text/plain"),
}));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler)));
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
// Act
HttpRequestResult result = await handler.SendAsync(request);
// Assert
result.IsSuccessStatusCode.Should().BeFalse();
result.StatusCode.Should().Be(400);
result.Body.Should().Be("bad request");
}
[Fact]
public async Task SendAsyncTimeoutCancelsRequestAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new(async (req, ct) =>
{
await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false);
return new HttpResponseMessage(HttpStatusCode.OK);
});
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler)));
HttpRequestInfo request = new()
{
Method = "GET",
Url = TestUrl,
Timeout = TimeSpan.FromMilliseconds(50),
};
// Act
Func act = async () => await handler.SendAsync(request);
// Assert
await act.Should().ThrowAsync();
}
[Fact]
public async Task SendAsyncFallsBackToOwnedClientWhenProviderReturnsNullAsync()
{
// Arrange
int providerCallCount = 0;
await using DefaultHttpRequestHandler handler = new((_, _) =>
{
providerCallCount++;
return Task.FromResult(null);
});
HttpRequestInfo request = new() { Method = "GET", Url = "http://127.0.0.1:1/" };
// Act - owned client will attempt real network and fail, but provider path should have been consulted first.
Func act = async () => await handler.SendAsync(request);
// Assert
await act.Should().ThrowAsync();
providerCallCount.Should().Be(1);
}
#endregion
#region DisposeAsync
[Fact]
public async Task DisposeAsyncCompletesAsync()
{
// Arrange
DefaultHttpRequestHandler handler = new();
// Act
Func act = async () => await handler.DisposeAsync();
// Assert
await act.Should().NotThrowAsync();
}
[Fact]
public async Task DisposeAsyncCalledMultipleTimesSucceedsAsync()
{
// Arrange
DefaultHttpRequestHandler handler = new();
// Act
await handler.DisposeAsync();
Func second = async () => await handler.DisposeAsync();
// Assert
await second.Should().NotThrowAsync();
}
#endregion
#region Query Parameters and Connection Tests
[Fact]
public async Task QueryParametersAreAppendedToUrlAsync()
{
// Arrange
TestHttpMessageHandler fake = new(static (req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(fake)));
HttpRequestInfo info = new()
{
Method = "GET",
Url = TestUrl,
QueryParameters = new Dictionary
{
["filter"] = "active items",
["ids"] = "1,2,3",
},
};
// Act
await handler.SendAsync(info);
// Assert
fake.LastRequest.Should().NotBeNull();
string? query = fake.LastRequest!.RequestUri!.Query;
query.Should().Contain("filter=active%20items");
query.Should().Contain("ids=1%2C2%2C3");
}
[Fact]
public async Task QueryParametersPreserveExistingQueryStringAsync()
{
// Arrange
TestHttpMessageHandler fake = new(static (req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(fake)));
HttpRequestInfo info = new()
{
Method = "GET",
Url = TestUrl + "?existing=yes",
QueryParameters = new Dictionary
{
["added"] = "true",
},
};
// Act
await handler.SendAsync(info);
// Assert
fake.LastRequest!.RequestUri!.Query.Should().Be("?existing=yes&added=true");
}
#endregion
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Func> _responseFactory;
public TestHttpMessageHandler(Func> responseFactory)
{
this._responseFactory = responseFactory;
}
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastRequestBody { get; private set; }
public string? LastRequestContentType { get; private set; }
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.LastRequest = request;
if (request.Content is not null)
{
#if NET
this.LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
this.LastRequestContentType = request.Content.Headers.ContentType?.MediaType;
}
return await this._responseFactory(request, cancellationToken).ConfigureAwait(false);
}
}
}