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
129 changes: 129 additions & 0 deletions DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,135 @@ public async Task MockResponse_ShortCircuitsWithConfiguredMock()
Assert.Equal("mocked-by-test", body);
}

[Theory]
[InlineData("/get")]
[InlineData("/ge*")]
public async Task MockResponse_Nth_CountsInterceptedRequestsWithoutFallbackMock(string mockPath)
{
await using var origin = await FakeOrigin.StartAsync();
var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);

var config = PluginConfig.FromJson($$"""
{
"mocks": [
{
"request": { "url": "http://{{origin.Host}}{{mockPath}}", "method": "GET", "nth": 2 },
"response": { "statusCode": 201, "body": "mocked-by-test" }
}
]
}
""");
var plugin = new MockResponsePlugin(
SharedHttpClient,
NullLogger<MockResponsePlugin>.Instance,
urls,
ProxyConfig,
config);

await using var proxy = await KestrelProxyHarness.StartAsync(
origin.Host, [plugin]);
using var client = proxy.CreateHttpClient();
var uri = new Uri($"http://{origin.Host}/get");

using var first = await client.GetAsync(uri);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);

using var second = await client.GetAsync(uri);
Assert.Equal(HttpStatusCode.Created, second.StatusCode);
Assert.Contains("mocked-by-test", await second.Content.ReadAsStringAsync(), StringComparison.Ordinal);

using var third = await client.GetAsync(uri);
Assert.Equal(HttpStatusCode.OK, third.StatusCode);
}

[Theory]
[InlineData(null)]
[InlineData("ignored-on-get")]
public async Task MockResponse_Nth_WithFallbackMock_AppliesOnNthRequest(string? bodyFragment)
{
await using var origin = await FakeOrigin.StartAsync();
var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);

var config = PluginConfig.FromJson($$"""
{
"mocks": [
{
"request": { "url": "http://{{origin.Host}}/get", "method": "GET", "bodyFragment": {{System.Text.Json.JsonSerializer.Serialize(bodyFragment)}}, "nth": 2 },
"response": { "statusCode": 201, "body": "nth-mock" }
},
{
"request": { "url": "http://{{origin.Host}}/get", "method": "GET" },
"response": { "statusCode": 202, "body": "fallback-mock" }
}
]
}
""");
var plugin = new MockResponsePlugin(
SharedHttpClient,
NullLogger<MockResponsePlugin>.Instance,
urls,
ProxyConfig,
config);

await using var proxy = await KestrelProxyHarness.StartAsync(
origin.Host, [plugin]);
using var client = proxy.CreateHttpClient();
var uri = new Uri($"http://{origin.Host}/get");

using var first = await client.GetAsync(uri);
Assert.Equal(HttpStatusCode.Accepted, first.StatusCode);

using var second = await client.GetAsync(uri);
Assert.Equal(HttpStatusCode.Created, second.StatusCode);

using var third = await client.GetAsync(uri);
Assert.Equal(HttpStatusCode.Accepted, third.StatusCode);
}

[Theory]
[InlineData(null, null)]
[InlineData("alpha", "beta")]
public async Task MockResponse_Nth_DistinctMatchersHaveIndependentCounts(
string? firstBody, string? secondBody)
{
await using var origin = await FakeOrigin.StartAsync();
var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
var firstMethod = firstBody is null ? "GET" : "POST";
var config = PluginConfig.FromJson($$"""
{
"mocks": [
{
"request": { "url": "http://{{origin.Host}}/echo", "method": "{{firstMethod}}", "bodyFragment": {{System.Text.Json.JsonSerializer.Serialize(firstBody)}}, "nth": 1 },
"response": { "statusCode": 201 }
},
{
"request": { "url": "http://{{origin.Host}}/echo", "method": "POST", "bodyFragment": {{System.Text.Json.JsonSerializer.Serialize(secondBody)}}, "nth": 2 },
"response": { "statusCode": 202 }
}
]
}
""");
using var plugin = new MockResponsePlugin(
SharedHttpClient, NullLogger<MockResponsePlugin>.Instance, urls, ProxyConfig, config);
await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]);
using var client = proxy.CreateHttpClient();
var uri = new Uri($"http://{origin.Host}/echo");

using var first = await client.PostAsync(uri, new StringContent(secondBody ?? ""));
Assert.Equal(HttpStatusCode.OK, first.StatusCode);

using var request = new HttpRequestMessage(new HttpMethod(firstMethod), uri);
if (firstBody is not null)
{
request.Content = new StringContent(firstBody);
}
using var second = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Created, second.StatusCode);

using var third = await client.PostAsync(uri, new StringContent(secondBody ?? ""));
Assert.Equal(HttpStatusCode.Accepted, third.StatusCode);
}

[Theory]
[InlineData("/Get")]
[InlineData("/Ge*")]
Expand Down
94 changes: 94 additions & 0 deletions DevProxy.Integration.Tests/MockStdioResponsePluginTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Text;
using DevProxy.Abstractions.Proxy;
using DevProxy.Plugins.Mocking;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

namespace DevProxy.Integration.Tests;

public sealed class MockStdioResponsePluginTests
{
[Fact]
public async Task Nth_RegexAndFragmentWithIdenticalText_HaveIndependentCounts()
{
var config = PluginConfig.FromJson("""
{
"mocks": [
{
"request": { "bodyRegex": "^ping$", "nth": 1 },
"response": { "stdout": "regex" }
},
{
"request": { "bodyFragment": "^ping$", "nth": 2 },
"response": { "stdout": "fragment" }
}
]
}
""");
using var httpClient = new HttpClient();
using var plugin = new MockStdioResponsePlugin(
httpClient, NullLogger<MockStdioResponsePlugin>.Instance, new HashSet<UrlToWatch>(),
new TestProxyConfiguration(), config);

var first = CreateRequest("^ping$");
await plugin.BeforeStdinAsync(first, CancellationToken.None);
Assert.False(first.ResponseState.HasBeenSet);
Assert.Null(first.StdoutResponse);

var second = CreateRequest("ping");
await plugin.BeforeStdinAsync(second, CancellationToken.None);
Assert.True(second.ResponseState.HasBeenSet);
Assert.Equal("regex", second.StdoutResponse);

var third = CreateRequest("^ping$");
await plugin.BeforeStdinAsync(third, CancellationToken.None);
Assert.True(third.ResponseState.HasBeenSet);
Assert.Equal("fragment", third.StdoutResponse);
}

[Theory]
[InlineData(null, "ping")]
[InlineData("", "ping")]
[InlineData("ping", "ignored")]
public async Task Nth_EffectiveMatcher_SharesFallbackCount(string? bodyRegex, string bodyFragment)
{
var fallbackRegex = string.IsNullOrEmpty(bodyRegex) ? null : bodyRegex;
var config = PluginConfig.FromJson($$"""
{
"mocks": [
{
"request": { "bodyRegex": {{System.Text.Json.JsonSerializer.Serialize(bodyRegex)}}, "bodyFragment": {{System.Text.Json.JsonSerializer.Serialize(bodyFragment)}}, "nth": 2 },
"response": { "stdout": "nth" }
},
{
"request": { "bodyRegex": {{System.Text.Json.JsonSerializer.Serialize(fallbackRegex)}}, "bodyFragment": "ping" },
"response": { "stdout": "fallback" }
}
]
}
""");
using var httpClient = new HttpClient();
using var plugin = new MockStdioResponsePlugin(
httpClient, NullLogger<MockStdioResponsePlugin>.Instance, new HashSet<UrlToWatch>(),
new TestProxyConfiguration(), config);

foreach (var expected in new[] { "fallback", "nth", "fallback" })
{
var request = CreateRequest("ping");
await plugin.BeforeStdinAsync(request, CancellationToken.None);
Assert.True(request.ResponseState.HasBeenSet);
Assert.Equal(expected, request.StdoutResponse);
}
}

private static StdioRequestArgs CreateRequest(string body) => new()
{
Body = Encoding.UTF8.GetBytes(body),
ResponseState = new(),
Session = new() { Command = "test", Args = [] }
};
}
85 changes: 43 additions & 42 deletions DevProxy.Plugins/Mocking/MockResponsePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,11 @@ public class MockResponsePlugin(
private const string _mocksFileOptionName = "--mocks-file";

private readonly IProxyConfiguration _proxyConfiguration = proxyConfiguration;
// tracks the number of times a mock has been applied
// tracks the number of intercepted requests matching each effective matcher
// used in combination with mocks that have an Nth property
private readonly ConcurrentDictionary<string, int> _appliedMocks = [];
private readonly ConcurrentDictionary<MockRequestKey, int> _requestCounts = [];

private readonly record struct MockRequestKey(string Method, string Url, string? BodyFragment);

private MockResponsesLoader? _loader;
private Argument<IEnumerable<string>>? _httpResponseFilesArgument;
Expand Down Expand Up @@ -292,58 +294,57 @@ Configuration.Mocks is null ||
return null;
}

var mockResponse = Configuration.Mocks.FirstOrDefault(mockResponse =>
{
if (mockResponse.Request is null)
{
return false;
}

if (mockResponse.Request.Method != request.Method)
{
return false;
}
var matchingMocks = Configuration.Mocks
.Where(mockResponse => MatchesRequest(mockResponse, request))
.ToList();

if (string.Equals(mockResponse.Request.Url, request.Url, StringComparison.OrdinalIgnoreCase) &&
HasMatchingBody(mockResponse, request) &&
IsNthRequest(mockResponse))
{
return true;
}
// count the intercepted request once for each effective matcher,
// regardless of whether a mock is eventually applied, so that
// mocks with an Nth property fire on the nth matching request
var requestCounts = new Dictionary<MockRequestKey, int>();
foreach (var key in matchingMocks.Select(GetMockKey).Distinct())
{
requestCounts[key] = _requestCounts.AddOrUpdate(key, 1, (_, value) => value + 1);
}

// check if the URL contains a wildcard
// if it doesn't, it's not a match for the current request for sure
if (!mockResponse.Request.Url.Contains('*', StringComparison.OrdinalIgnoreCase))
{
return false;
}
return matchingMocks.FirstOrDefault(mockResponse =>
mockResponse.Request!.Nth is null ||
mockResponse.Request.Nth == requestCounts[GetMockKey(mockResponse)]);
}

// turn mock URL with wildcard into a regex and match against the request URL
return Regex.IsMatch(request.Url, ProxyUtils.PatternToRegex(mockResponse.Request.Url), RegexOptions.IgnoreCase) &&
HasMatchingBody(mockResponse, request) &&
IsNthRequest(mockResponse);
});
private static MockRequestKey GetMockKey(MockResponse mockResponse)
{
var request = mockResponse.Request!;
return new(request.Method, request.Url, request.Method == "GET" ? null : request.BodyFragment);
}

if (mockResponse is not null && mockResponse.Request is not null)
private static bool MatchesRequest(MockResponse mockResponse, IHttpRequest request)
{
if (mockResponse.Request is null)
{
_ = _appliedMocks.AddOrUpdate(mockResponse.Request.Url, 1, (_, value) => ++value);
return false;
}

return mockResponse;
}
if (mockResponse.Request.Method != request.Method)
{
return false;
}

private bool IsNthRequest(MockResponse mockResponse)
{
if (mockResponse.Request?.Nth is null)
if (string.Equals(mockResponse.Request.Url, request.Url, StringComparison.OrdinalIgnoreCase))
{
// mock doesn't define an Nth property so it always qualifies
return true;
return HasMatchingBody(mockResponse, request);
}

_ = _appliedMocks.TryGetValue(mockResponse.Request.Url, out var nth);
nth++;
// check if the URL contains a wildcard
// if it doesn't, it's not a match for the current request for sure
if (!mockResponse.Request.Url.Contains('*', StringComparison.OrdinalIgnoreCase))
{
return false;
}

return mockResponse.Request.Nth == nth;
// turn mock URL with wildcard into a regex and match against the request URL
return Regex.IsMatch(request.Url, ProxyUtils.PatternToRegex(mockResponse.Request.Url), RegexOptions.IgnoreCase) &&
HasMatchingBody(mockResponse, request);
}

private void ProcessMockResponseInternal(ProxyRequestArgs e, MockResponse matchingResponse)
Expand Down
Loading
Loading