Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,89 @@ public async Task MockResponse_ShortCircuitsWithConfiguredMock()
Assert.Contains("mocked-by-test", body, StringComparison.Ordinal);
}

[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);
}

[Fact]
public async Task MockResponse_Nth_WithFallbackMock_AppliesOnNthRequest()
{
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", "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("/Get")]
[InlineData("/Ge*")]
Expand Down
79 changes: 36 additions & 43 deletions DevProxy.Plugins/Mocking/MockResponsePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ 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 mock URL
// used in combination with mocks that have an Nth property
private readonly ConcurrentDictionary<string, int> _appliedMocks = [];
private readonly ConcurrentDictionary<string, int> _requestCounts = [];

private MockResponsesLoader? _loader;
private Argument<IEnumerable<string>>? _httpResponseFilesArgument;
Expand Down Expand Up @@ -292,58 +292,51 @@ 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;
}

// 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;
}

// 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);
});

if (mockResponse is not null && mockResponse.Request is not null)
// count the intercepted request once for each mock URL it matches,
// 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<string, int>();
foreach (var url in matchingMocks.Select(m => m.Request!.Url).Distinct())
{
_ = _appliedMocks.AddOrUpdate(mockResponse.Request.Url, 1, (_, value) => ++value);
requestCounts[url] = _requestCounts.AddOrUpdate(url, 1, (_, value) => value + 1);
Comment thread
waldekmastykarz marked this conversation as resolved.
Outdated
}

return mockResponse;
return matchingMocks.FirstOrDefault(mockResponse =>
mockResponse.Request!.Nth is null ||
mockResponse.Request.Nth == requestCounts[mockResponse.Request.Url]);
}

private bool IsNthRequest(MockResponse mockResponse)
private static bool MatchesRequest(MockResponse mockResponse, IHttpRequest request)
{
if (mockResponse.Request?.Nth is null)
if (mockResponse.Request is null)
{
// mock doesn't define an Nth property so it always qualifies
return true;
return false;
}

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

if (string.Equals(mockResponse.Request.Url, request.Url, StringComparison.OrdinalIgnoreCase))
{
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
117 changes: 55 additions & 62 deletions DevProxy.Plugins/Mocking/MockStdioResponsePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ public class MockStdioResponsePlugin(

private readonly IProxyConfiguration _proxyConfiguration = proxyConfiguration;

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

// tracks whether we've applied startup mocks (mocks without stdin patterns)
private bool _startupMocksApplied;
Expand Down Expand Up @@ -217,19 +217,19 @@ private void ApplyStartupMocks(StdioRequestArgs e)
var mockKey = "startup_mock";
if (mock.Request?.Nth is not null)
{
_ = _appliedMocks.TryGetValue(mockKey, out var nth);
_ = _requestCounts.TryGetValue(mockKey, out var nth);
nth++;
if (mock.Request.Nth != nth)
{
_ = _appliedMocks.AddOrUpdate(mockKey, 1, (_, v) => ++v);
_ = _requestCounts.AddOrUpdate(mockKey, 1, (_, v) => ++v);
continue;
}
}

Logger.LogInformation("Applying startup mock response");
SendMockResponse(e, mock);

_ = _appliedMocks.AddOrUpdate(mockKey, 1, (_, v) => ++v);
_ = _requestCounts.AddOrUpdate(mockKey, 1, (_, v) => ++v);
}
}

Expand All @@ -242,78 +242,71 @@ Configuration.Mocks is null ||
return null;
}

var mockResponse = Configuration.Mocks.FirstOrDefault(mockResponse =>
var matchingMocks = Configuration.Mocks
.Where(mockResponse => MatchesStdin(mockResponse, stdinBody))
.ToList();

// count the intercepted message once for each mock key it matches,
// regardless of whether a mock is eventually applied, so that
// mocks with an Nth property fire on the nth matching message
var requestCounts = new Dictionary<string, int>();
foreach (var mockKey in matchingMocks.Select(GetMockKey).Distinct())
{
if (mockResponse.Request is null)
{
// Mocks without request patterns are startup mocks, skip them here
return false;
}
requestCounts[mockKey] = _requestCounts.AddOrUpdate(mockKey, 1, (_, value) => value + 1);
Comment thread
waldekmastykarz marked this conversation as resolved.
}

var hasBodyFragment = !string.IsNullOrEmpty(mockResponse.Request.BodyFragment);
var hasBodyRegex = !string.IsNullOrEmpty(mockResponse.Request.BodyRegex);
return matchingMocks.FirstOrDefault(mockResponse =>
mockResponse.Request!.Nth is null ||
mockResponse.Request.Nth == requestCounts[GetMockKey(mockResponse)]);
}

if (!hasBodyFragment && !hasBodyRegex)
{
// No body fragment or regex means startup mock, handled separately
return false;
}
private bool MatchesStdin(MockStdioResponse mockResponse, string stdinBody)
{
if (mockResponse.Request is null)
{
// Mocks without request patterns are startup mocks, skip them here
return false;
}

var hasBodyFragment = !string.IsNullOrEmpty(mockResponse.Request.BodyFragment);
var hasBodyRegex = !string.IsNullOrEmpty(mockResponse.Request.BodyRegex);

if (!hasBodyFragment && !hasBodyRegex)
{
// No body fragment or regex means startup mock, handled separately
return false;
}

// Use bodyRegex if specified, otherwise use bodyFragment (either/or)
if (hasBodyRegex)
// Use bodyRegex if specified, otherwise use bodyFragment (either/or)
if (hasBodyRegex)
{
try
{
try
{
if (!Regex.IsMatch(stdinBody, mockResponse.Request.BodyRegex!, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5)))
{
return false;
}
}
catch (RegexMatchTimeoutException ex)
{
Logger.LogError(ex, "Regex match timed out for body regex pattern '{Pattern}'. Treating as non-matching mock.", mockResponse.Request.BodyRegex);
return false;
}
catch (ArgumentException ex)
if (!Regex.IsMatch(stdinBody, mockResponse.Request.BodyRegex!, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5)))
{
Logger.LogError(ex, "Invalid body regex pattern '{Pattern}'. Treating as non-matching mock.", mockResponse.Request.BodyRegex);
return false;
}
}
else if (hasBodyFragment)
catch (RegexMatchTimeoutException ex)
{
if (!stdinBody.Contains(mockResponse.Request.BodyFragment!, StringComparison.OrdinalIgnoreCase))
{
return false;
}
Logger.LogError(ex, "Regex match timed out for body regex pattern '{Pattern}'. Treating as non-matching mock.", mockResponse.Request.BodyRegex);
return false;
}
catch (ArgumentException ex)
{
Logger.LogError(ex, "Invalid body regex pattern '{Pattern}'. Treating as non-matching mock.", mockResponse.Request.BodyRegex);
return false;
}

// Check Nth condition
return IsNthRequest(mockResponse);
});

if (mockResponse?.Request is not null)
{
var mockKey = GetMockKey(mockResponse);
_ = _appliedMocks.AddOrUpdate(mockKey, 1, (_, value) => ++value);
}

return mockResponse;
}

private bool IsNthRequest(MockStdioResponse mockResponse)
{
if (mockResponse.Request?.Nth is null)
else if (hasBodyFragment)
{
// mock doesn't define an Nth property so it always qualifies
return true;
if (!stdinBody.Contains(mockResponse.Request.BodyFragment!, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}

var mockKey = GetMockKey(mockResponse);
_ = _appliedMocks.TryGetValue(mockKey, out var nth);
nth++;

return mockResponse.Request.Nth == nth;
return true;
}

private static string GetMockKey(MockStdioResponse mockResponse)
Expand Down
Loading