diff --git a/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs b/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs index 63a34b09..d2740434 100644 --- a/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs +++ b/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs @@ -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.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.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.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*")] diff --git a/DevProxy.Integration.Tests/MockStdioResponsePluginTests.cs b/DevProxy.Integration.Tests/MockStdioResponsePluginTests.cs new file mode 100644 index 00000000..0dc20607 --- /dev/null +++ b/DevProxy.Integration.Tests/MockStdioResponsePluginTests.cs @@ -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.Instance, new HashSet(), + 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.Instance, new HashSet(), + 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 = [] } + }; +} diff --git a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs index 1fd6d32a..b0f1755f 100644 --- a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs @@ -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 _appliedMocks = []; + private readonly ConcurrentDictionary _requestCounts = []; + + private readonly record struct MockRequestKey(string Method, string Url, string? BodyFragment); private MockResponsesLoader? _loader; private Argument>? _httpResponseFilesArgument; @@ -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(); + 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) diff --git a/DevProxy.Plugins/Mocking/MockStdioResponsePlugin.cs b/DevProxy.Plugins/Mocking/MockStdioResponsePlugin.cs index d5dc0926..7dc4b4a4 100644 --- a/DevProxy.Plugins/Mocking/MockStdioResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/MockStdioResponsePlugin.cs @@ -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 _appliedMocks = []; + private readonly ConcurrentDictionary _requestCounts = []; // tracks whether we've applied startup mocks (mocks without stdin patterns) private bool _startupMocksApplied; @@ -217,11 +217,11 @@ 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; } } @@ -229,7 +229,7 @@ private void ApplyStartupMocks(StdioRequestArgs e) Logger.LogInformation("Applying startup mock response"); SendMockResponse(e, mock); - _ = _appliedMocks.AddOrUpdate(mockKey, 1, (_, v) => ++v); + _ = _requestCounts.AddOrUpdate(mockKey, 1, (_, v) => ++v); } } @@ -242,85 +242,78 @@ 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(); + 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); + } - 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) { - return mockResponse.Request?.BodyRegex - ?? mockResponse.Request?.BodyFragment - ?? "default"; + return !string.IsNullOrEmpty(mockResponse.Request?.BodyRegex) + ? $"regex:{mockResponse.Request.BodyRegex}" + : $"fragment:{mockResponse.Request?.BodyFragment}"; } private void ProcessMockResponse(StdioRequestArgs e, MockStdioResponse matchingResponse)