diff --git a/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt b/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt index 191daf8..992772c 100644 --- a/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt +++ b/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt @@ -113,6 +113,8 @@ namespace Mockly public Mockly.RequestMockBuilder ForHttp() { } public Mockly.RequestMockBuilder ForHttps() { } public Mockly.RequestMockResponseBuilder RespondsWith(System.Func responder) { } + public Mockly.RequestMockResponseBuilder RespondsWith(System.Func> responder) { } + public Mockly.RequestMockResponseBuilder RespondsWith(System.Func> responder) { } public Mockly.RequestMockResponseBuilder RespondsWith(System.Net.Http.HttpContent content) { } public Mockly.RequestMockResponseBuilder RespondsWith(System.Net.HttpStatusCode statusCode, System.Net.Http.HttpContent content) { } public Mockly.RequestMockResponseBuilder RespondsWithBytes(byte[] content, string contentType) { } diff --git a/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt b/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt index eaf48e5..724a5b2 100644 --- a/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt +++ b/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt @@ -116,6 +116,8 @@ namespace Mockly public Mockly.RequestMockBuilder ForHttp() { } public Mockly.RequestMockBuilder ForHttps() { } public Mockly.RequestMockResponseBuilder RespondsWith(System.Func responder) { } + public Mockly.RequestMockResponseBuilder RespondsWith(System.Func> responder) { } + public Mockly.RequestMockResponseBuilder RespondsWith(System.Func> responder) { } public Mockly.RequestMockResponseBuilder RespondsWith(System.Net.Http.HttpContent content) { } public Mockly.RequestMockResponseBuilder RespondsWith(System.Net.HttpStatusCode statusCode, System.Net.Http.HttpContent content) { } public Mockly.RequestMockResponseBuilder RespondsWithBytes(byte[] content, string contentType) { } diff --git a/Mockly.Specs/HttpMockSpecs.cs b/Mockly.Specs/HttpMockSpecs.cs index 05e4366..20700cc 100644 --- a/Mockly.Specs/HttpMockSpecs.cs +++ b/Mockly.Specs/HttpMockSpecs.cs @@ -7,10 +7,10 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Threading; #if NET8_0_OR_GREATER using System.Collections.Concurrent; using System.Net.Http.Json; -using System.Threading; #endif using System.Threading.Tasks; using FluentAssertions; @@ -1677,7 +1677,7 @@ public async Task A_custom_response_can_throw_an_exception() mock.ForGet() .WithPath("/api/custom") - .RespondsWith(_ => throw new InvalidOperationException()); + .RespondsWith((Func)(_ => throw new InvalidOperationException())); // Build step removed; var client = mock.GetClient(); @@ -1828,6 +1828,297 @@ await response.Should().BeEquivalentTo(new } } + public class WhenUsingAsyncResponders + { + [Fact] + public async Task An_async_responder_is_awaited() + { + // Arrange + var mock = new HttpMock(); + + mock.ForGet() + .WithPath("/api/async") + .RespondsWith(async _ => + { + await Task.Yield(); + return new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent("async body") + }; + }); + + var client = mock.GetClient(); + + // Act + var response = await client.GetAsync("https://localhost/api/async"); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + content.Should().Be("async body"); + } + + [Fact] + public async Task An_async_responder_with_a_cancellation_token_is_awaited() + { + // Arrange + var mock = new HttpMock(); + + mock.ForGet() + .WithPath("/api/async-ct") + .RespondsWith(async (_, _) => + { + await Task.Yield(); + return new HttpResponseMessage(HttpStatusCode.Created); + }); + + var client = mock.GetClient(); + + // Act + var response = await client.GetAsync("https://localhost/api/async-ct"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task The_cancellation_token_is_passed_to_the_async_responder() + { + // Arrange + var mock = new HttpMock(); + CancellationToken observedToken = default; + + mock.ForGet() + .WithPath("/api/observe") + .RespondsWith((_, ct) => + { + observedToken = ct; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + var client = mock.GetClient(); + + // Act + await client.GetAsync("https://localhost/api/observe"); + + // Assert + observedToken.CanBeCanceled.Should().BeTrue(); + } + + [Fact] + public async Task A_cancelled_token_is_observed_by_the_async_responder() + { + // Arrange + var mock = new HttpMock(); + using var cts = new CancellationTokenSource(); + var wasCancelled = false; + + mock.ForGet() + .WithPath("/api/cancel") + .RespondsWith(async (_, ct) => + { +#if NET8_0_OR_GREATER + await cts.CancelAsync(); +#else + cts.Cancel(); +#endif + try + { + await Task.Delay(Timeout.Infinite, ct); + } + catch (OperationCanceledException) + { + wasCancelled = true; + throw; + } + + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var client = mock.GetClient(); + + // Act + Func act = () => client.GetAsync("https://localhost/api/cancel", cts.Token); + + // Assert + await act.Should().ThrowAsync(); + wasCancelled.Should().BeTrue(); + } + + [Fact] + public async Task An_async_responder_works_with_invocation_limits() + { + // Arrange + var mock = new HttpMock(); + var invocations = 0; + + mock.ForGet() + .WithPath("/api/limited") + .RespondsWith(_ => + { + Interlocked.Increment(ref invocations); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }) + .Once(); + + var client = mock.GetClient(); + + // Act + await client.GetAsync("https://localhost/api/limited"); + + // Assert + invocations.Should().Be(1); + mock.AllMocksInvoked.Should().BeTrue(); + } + + [Fact] + public async Task An_async_responder_collects_requests() + { + // Arrange + var mock = new HttpMock(); + var collected = new RequestCollection(); + + mock.ForPost() + .WithPath("/api/collect") + .CollectingRequestsIn(collected) + .RespondsWith(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Accepted))); + + var client = mock.GetClient(); + + // Act + await client.PostAsync("https://localhost/api/collect", new StringContent("payload")); + + // Assert + collected.Should().ContainSingle(); + } + + [Fact] + public async Task An_exception_from_an_async_responder_results_in_an_internal_server_error() + { + // Arrange + var mock = new HttpMock(); + + mock.ForGet() + .WithPath("/api/async-throw") + .RespondsWith(_ => Task.FromException(new InvalidOperationException())); + + var client = mock.GetClient(); + + // Act + var response = await client.GetAsync("https://localhost/api/async-throw"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + response.ReasonPhrase.Should().Contain("InvalidOperationException"); + } + + [Fact] + public async Task An_existing_synchronous_responder_still_works() + { + // Arrange + var mock = new HttpMock(); + + mock.ForGet() + .WithPath("/api/sync") + .RespondsWith(_ => new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent("sync body") + }); + + var client = mock.GetClient(); + + // Act + var response = await client.GetAsync("https://localhost/api/sync"); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + content.Should().Be("sync body"); + } + + [Fact] + public async Task An_async_responder_receives_request_info() + { + // Arrange + var mock = new HttpMock(); + RequestInfo capturedInfo = null; + + mock.ForPost() + .WithPath("/api/info") + .RespondsWith(async request => + { + capturedInfo = request; + await Task.Yield(); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var client = mock.GetClient(); + + // Act + await client.PostAsync("https://localhost/api/info", new StringContent("hello", Encoding.UTF8, "text/plain")); + + // Assert + capturedInfo.Should().NotBeNull(); + capturedInfo.Method.Should().Be(HttpMethod.Post); + capturedInfo.Uri.AbsolutePath.Should().Be("/api/info"); + capturedInfo.Body.Should().Be("hello"); + } + + [Fact] + public async Task An_async_responder_works_with_multiple_invocations() + { + // Arrange + var mock = new HttpMock(); + var counter = 0; + + mock.ForGet() + .WithPath("/api/multi") + .RespondsWith(_ => + { + Interlocked.Increment(ref counter); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + var client = mock.GetClient(); + + // Act + await client.GetAsync("https://localhost/api/multi"); + await client.GetAsync("https://localhost/api/multi"); + await client.GetAsync("https://localhost/api/multi"); + + // Assert + counter.Should().Be(3); + } + + [Fact] + public void Null_async_responder_throws_argument_null_exception() + { + // Arrange + var mock = new HttpMock(); + var builder = mock.ForGet().WithPath("/api/null"); + + // Act + Action act = () => builder.RespondsWith((Func>)null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Null_async_responder_with_cancellation_token_throws_argument_null_exception() + { + // Arrange + var mock = new HttpMock(); + var builder = mock.ForGet().WithPath("/api/null-ct"); + + // Act + Action act = () => builder.RespondsWith((Func>)null!); + + // Assert + act.Should().Throw(); + } + } + public class WhenLimitingInvocations { [Fact] diff --git a/Mockly/HttpMock.cs b/Mockly/HttpMock.cs index 88ffe57..a2d535d 100644 --- a/Mockly/HttpMock.cs +++ b/Mockly/HttpMock.cs @@ -255,13 +255,13 @@ internal void AddMock(RequestMock mock) mocks.Add(mock); } - private async Task HandleRequest(HttpRequestMessage httpRequest) + private async Task HandleRequest(HttpRequestMessage httpRequest, CancellationToken cancellationToken) { RequestInfo request = await BuildRequestInfo(httpRequest); // Try to find a matching mock bool foundMatch = true; - CapturedRequest? capturedRequest = await TryFindMatchingMock(request); + CapturedRequest? capturedRequest = await TryFindMatchingMock(request, cancellationToken); if (capturedRequest == null) { capturedRequest = new CapturedRequest(request) @@ -381,14 +381,14 @@ private async Task BuildRequestInfo(HttpRequestMessage httpRequest) return request; } - private async Task TryFindMatchingMock(RequestInfo request) + private async Task TryFindMatchingMock(RequestInfo request, CancellationToken cancellationToken) { RequestMock? matchingMock = await mocks.FirstOrDefaultAsync(m => m.IsExhausted ? Task.FromResult(false) : m.Matches(request)); if (matchingMock != null) { - return matchingMock.TrackRequest(request); + return await matchingMock.TrackRequestAsync(request, cancellationToken); } return null; @@ -422,7 +422,7 @@ private class MockHttpMessageHandler(HttpMock mock) : HttpMessageHandler protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - return await mock.HandleRequest(request); + return await mock.HandleRequest(request, cancellationToken); } } diff --git a/Mockly/RequestMock.cs b/Mockly/RequestMock.cs index fb530b0..468a96c 100644 --- a/Mockly/RequestMock.cs +++ b/Mockly/RequestMock.cs @@ -34,6 +34,13 @@ public class RequestMock public Func Responder { get; set; } = _ => new HttpResponseMessage(); + /// + /// Gets the asynchronous responder used to produce a response for a matching request. + /// When set, this takes precedence over and receives the + /// flowing from the HTTP pipeline. + /// + internal Func>? AsyncResponder { get; init; } + public RequestCollection? RequestCollection { get; init; } = []; /// @@ -254,6 +261,11 @@ private static bool MatchesPattern(string value, string pattern) /// /// Handles the request and returns a response. /// + /// + /// This synchronous overload only invokes the synchronous and is preserved for + /// backwards compatibility. The HTTP pipeline uses so that asynchronous + /// responders and cancellation are honored. + /// public CapturedRequest TrackRequest(RequestInfo request) { Interlocked.Increment(ref invocationCount); @@ -284,6 +296,58 @@ public CapturedRequest TrackRequest(RequestInfo request) return capturedRequest; } + /// + /// Handles the request asynchronously and returns a response, awaiting the configured responder and + /// flowing the supplied into it. + /// + /// + /// Both synchronous and asynchronous responders converge on this single asynchronous execution path. + /// + internal async Task TrackRequestAsync(RequestInfo request, CancellationToken cancellationToken) + { + Interlocked.Increment(ref invocationCount); + + CapturedRequest capturedRequest = new(request) + { + Mock = this, + WasExpected = true, + Timestamp = DateTime.UtcNow + }; + + try + { + capturedRequest.Response = await InvokeResponderAsync(request, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 + catch (Exception e) +#pragma warning restore CA1031 + { + capturedRequest.Response = new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + ReasonPhrase = $"{e.GetType().Name}:{e.Message}" + }; + } + + RequestCollection?.Add(capturedRequest); + + return capturedRequest; + } + + /// + /// Invokes the asynchronous responder when configured; otherwise adapts the synchronous + /// onto the asynchronous path. + /// + private Task InvokeResponderAsync(RequestInfo request, CancellationToken cancellationToken) + { + return AsyncResponder is not null + ? AsyncResponder(request, cancellationToken) + : Task.FromResult(Responder(request)); + } + /// /// Builds a detailed textual representation of this mock, including its route /// and any configured custom matchers. diff --git a/Mockly/RequestMockBuilder.cs b/Mockly/RequestMockBuilder.cs index f32054e..29aa3e8 100644 --- a/Mockly/RequestMockBuilder.cs +++ b/Mockly/RequestMockBuilder.cs @@ -6,6 +6,8 @@ using System.Text; using System.Text.Json; using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; using Mockly.Common; #if NET472_OR_GREATER @@ -1072,6 +1074,51 @@ public RequestMockResponseBuilder RespondsWith(Func + /// Responds using a custom asynchronous responder function that is awaited when producing the response. + /// + /// An asynchronous function that produces the response for a matching request. + public RequestMockResponseBuilder RespondsWith(Func> responder) + { + if (responder is null) + { + throw new ArgumentNullException(nameof(responder)); + } + + return RespondsWith((request, _) => responder(request)); + } + + /// + /// Responds using a custom asynchronous responder function that is awaited when producing the response and + /// receives the flowing from the HTTP pipeline. + /// + /// + /// An asynchronous function that produces the response for a matching request, observing the supplied + /// . + /// + public RequestMockResponseBuilder RespondsWith(Func> responder) + { + if (responder is null) + { + throw new ArgumentNullException(nameof(responder)); + } + + var mock = new RequestMock + { + Method = Method, + PathPattern = pathPattern, + QueryPattern = queryPattern, + Scheme = scheme, + HostPattern = hostPattern, + CustomMatchers = customMatchers, + RequestCollection = requestCollection, + AsyncResponder = responder + }; + + mockBuilder.AddMock(mock); + return new RequestMockResponseBuilder(mock); + } + private static string InferContentTypeFromExtension(string path) { string extension = Path.GetExtension(path).ToUpperInvariant();