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
2 changes: 2 additions & 0 deletions Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ namespace Mockly
public Mockly.RequestMockBuilder ForHttp() { }
public Mockly.RequestMockBuilder ForHttps() { }
public Mockly.RequestMockResponseBuilder RespondsWith(System.Func<Mockly.RequestInfo, System.Net.Http.HttpResponseMessage> responder) { }
public Mockly.RequestMockResponseBuilder RespondsWith(System.Func<Mockly.RequestInfo, System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage>> responder) { }
public Mockly.RequestMockResponseBuilder RespondsWith(System.Func<Mockly.RequestInfo, System.Threading.CancellationToken, System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage>> 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) { }
Expand Down
2 changes: 2 additions & 0 deletions Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ namespace Mockly
public Mockly.RequestMockBuilder ForHttp() { }
public Mockly.RequestMockBuilder ForHttps() { }
public Mockly.RequestMockResponseBuilder RespondsWith(System.Func<Mockly.RequestInfo, System.Net.Http.HttpResponseMessage> responder) { }
public Mockly.RequestMockResponseBuilder RespondsWith(System.Func<Mockly.RequestInfo, System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage>> responder) { }
public Mockly.RequestMockResponseBuilder RespondsWith(System.Func<Mockly.RequestInfo, System.Threading.CancellationToken, System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage>> 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) { }
Expand Down
295 changes: 293 additions & 2 deletions Mockly.Specs/HttpMockSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1677,7 +1677,7 @@

mock.ForGet()
.WithPath("/api/custom")
.RespondsWith(_ => throw new InvalidOperationException());
.RespondsWith((Func<RequestInfo, HttpResponseMessage>)(_ => throw new InvalidOperationException()));

// Build step removed;
var client = mock.GetClient();
Expand Down Expand Up @@ -1828,6 +1828,297 @@
}
}

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<Task> act = () => client.GetAsync("https://localhost/api/cancel", cts.Token);

// Assert
await act.Should().ThrowAsync<OperationCanceledException>();
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<HttpResponseMessage>(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<RequestInfo, Task<HttpResponseMessage>>)null!);

// Assert
act.Should().Throw<ArgumentNullException>();
}

[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<RequestInfo, CancellationToken, Task<HttpResponseMessage>>)null!);

// Assert
act.Should().Throw<ArgumentNullException>();
}
}

public class WhenLimitingInvocations
{
[Fact]
Expand Down
10 changes: 5 additions & 5 deletions Mockly/HttpMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,13 +255,13 @@ internal void AddMock(RequestMock mock)
mocks.Add(mock);
}

private async Task<HttpResponseMessage> HandleRequest(HttpRequestMessage httpRequest)
private async Task<HttpResponseMessage> 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)
Expand Down Expand Up @@ -381,14 +381,14 @@ private async Task<RequestInfo> BuildRequestInfo(HttpRequestMessage httpRequest)
return request;
}

private async Task<CapturedRequest?> TryFindMatchingMock(RequestInfo request)
private async Task<CapturedRequest?> 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;
Expand Down Expand Up @@ -422,7 +422,7 @@ private class MockHttpMessageHandler(HttpMock mock) : HttpMessageHandler
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
return await mock.HandleRequest(request);
return await mock.HandleRequest(request, cancellationToken);
}
}

Expand Down
Loading
Loading