Add near-miss diagnostics and request count/unmatched endpoints to the admin API (#1521)

* Add near-miss diagnostics and request count/unmatched admin endpoints (#171)

Add IWireMockServer.FindUnmatchedLogEntries, FindNearMisses and
FindNearMissesForUnmatchedRequests, which rank the mappings that
partially match a request by score.

Expose them through the admin API (POST requests/count,
GET requests/unmatched, GET requests/unmatched/near-misses and
GET requests/{guid}/near-misses), the RestClient and the OpenAPI document.

* Make scenario delay tests independent of server warm-up time

The two *_StateTransitions_BeforeDelayCompletes tests waited a fixed
100 ms for the scenario state to change, which fails on a cold server.
Poll server.Scenarios until the expected state is reached instead.

---------

Co-authored-by: Mattias <13809209+Quattordici@users.noreply.github.com>
This commit is contained in:
Mattias SundströmandMattias authored and GitHub committed 2026-10-23 17:48:54 +02:00
1 parent c94824bd64
commit dc14136492
18 files changed
+1104 -62

No files matched your search

@@ -0,0 +1,230 @@
// Copyright © WireMock.Net
using System.Net;
using System.Net.Http;
using RestEase;
using WireMock.Admin.Mappings;
using WireMock.Client;
using WireMock.Logging;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;
namespace WireMock.Net.Tests.AdminApi;
public partial class WireMockAdminApiTests
{
private static readonly Guid OrdersPostMappingGuid = new("22222222-0000-0000-0000-000000000001");
private static readonly Guid OrdersGetMappingGuid = new("22222222-0000-0000-0000-000000000002");
[Fact]
public async Task IWireMockAdminApi_CountRequestsAsync()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
await httpClient.GetAsync("/orders", cancellationToken);
await httpClient.PostAsync("/orders", new StringContent("{}"), cancellationToken);
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var getCount = await api.CountRequestsAsync(new RequestModel { Methods = ["GET"] }, cancellationToken);
var deleteCount = await api.CountRequestsAsync(new RequestModel { Methods = ["DELETE"] }, cancellationToken);
// Assert
getCount.Count.Should().Be(2);
deleteCount.Count.Should().Be(0);
}
[Fact]
public async Task IWireMockAdminApi_GetUnmatchedRequestsAsync()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
await httpClient.GetAsync("/unknown", cancellationToken);
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var requests = await api.GetUnmatchedRequestsAsync(cancellationToken);
// Assert
requests.Should().ContainSingle();
requests[0].Request!.Path.Should().Be("/unknown");
requests[0].MappingGuid.Should().BeNull();
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForUnmatchedRequestsAsync()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.PutAsync("/orders", new StringContent("{}"), cancellationToken);
var logEntryGuid = server.LogEntries.Single().Guid;
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var nearMisses = await api.GetNearMissesForUnmatchedRequestsAsync(cancellationToken: cancellationToken);
// Assert
nearMisses.Should().HaveCount(2);
nearMisses.Select(nm => nm.MappingGuid).Should().BeEquivalentTo([OrdersPostMappingGuid, OrdersGetMappingGuid]);
nearMisses.Should().AllSatisfy(nm =>
{
nm.LogEntryGuid.Should().Be(logEntryGuid);
nm.Request!.Method.Should().Be("PUT");
nm.Request.Path.Should().Be("/orders");
nm.RequestMatchResult!.IsPerfectMatch.Should().BeFalse();
nm.RequestMatchResult.AverageTotalScore.Should().BeApproximately(0.5, 0.001);
});
nearMisses.Select(nm => nm.MappingTitle).Should().Contain("Create order");
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForUnmatchedRequestsAsync_WithMaxResults()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.PutAsync("/orders", new StringContent("{}"), cancellationToken);
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var nearMisses = await api.GetNearMissesForUnmatchedRequestsAsync(1, cancellationToken);
// Assert
nearMisses.Should().ContainSingle();
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForUnmatchedRequestsAsync_WithInvalidMaxResults_ReturnsBadRequest()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
// Act
var response = await httpClient.GetAsync("/__admin/requests/unmatched/near-misses?maxResults=0", cancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForRequestAsync_ShouldExcludeMatchedMapping()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
var logEntry = server.LogEntries.Single();
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var nearMisses = await api.GetNearMissesForRequestAsync(logEntry.Guid, cancellationToken: cancellationToken);
// Assert
logEntry.MappingGuid.Should().Be(OrdersGetMappingGuid);
nearMisses.Should().ContainSingle();
nearMisses[0].MappingGuid.Should().Be(OrdersPostMappingGuid);
nearMisses[0].LogEntryGuid.Should().Be(logEntry.Guid);
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForRequestAsync_UnknownRequest_ReturnsNotFound()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
Func<Task> act = () => api.GetNearMissesForRequestAsync(Guid.NewGuid(), cancellationToken: cancellationToken);
// Assert
(await act.Should().ThrowAsync<ApiException>()).Which.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForRequestAsync_WithMultiplePerfectMatches_ShouldNotReturnPerfectMatches()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
server
.Given(Request.Create().WithPath("/orders").UsingGet())
.AtPriority(100)
.WithTitle("Get orders (fallback)")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
var logEntry = server.LogEntries.Single();
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var nearMisses = await api.GetNearMissesForRequestAsync(logEntry.Guid, cancellationToken: cancellationToken);
// Assert
nearMisses.Should().ContainSingle();
nearMisses[0].MappingGuid.Should().Be(OrdersPostMappingGuid);
}
[Fact]
public async Task IWireMockAdminApi_CountRequestsAsync_ShouldNotCountAdminRequests()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
await httpClient.GetAsync("/__admin/mappings", cancellationToken);
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var count = await api.CountRequestsAsync(new RequestModel { Methods = ["GET"] }, cancellationToken);
// Assert
count.Count.Should().Be(1);
}
private static WireMockServer StartServerWithOrdersMappings()
{
var server = WireMockServer.Start(new WireMockServerSettings
{
StartAdminInterface = true,
Logger = new WireMockNullLogger()
});
server
.Given(Request.Create().WithPath("/orders").UsingPost())
.WithGuid(OrdersPostMappingGuid)
.WithTitle("Create order")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.Created));
server
.Given(Request.Create().WithPath("/orders").UsingGet())
.WithGuid(OrdersGetMappingGuid)
.WithTitle("Get orders")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
return server;
}
}
+1 -1
View File
@@ -6,5 +6,5 @@ internal static class Constants
{
internal const int NumStaticMappings = 10;
internal const int NumAdminMappings = 39;
internal const int NumAdminMappings = 43;
}
@@ -259,6 +259,96 @@ public class MappingMatcherTests
mappingMock.Verify(m => m.GetRequestMatchResult(It.IsAny<RequestMessage>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public void MappingMatcher_FindNearMisses_ShouldExcludePerfectMatchesAndMismatches_AndOrderByBestMatchFirst()
{
// Assign
var perfect = Guid.Parse("00000000-0000-0000-0000-000000000001");
var mismatch = Guid.Parse("00000000-0000-0000-0000-000000000002");
var low = Guid.Parse("00000000-0000-0000-0000-000000000003");
var high = Guid.Parse("00000000-0000-0000-0000-000000000004");
var mappings = InitMappings
(
(perfect, [1.0], null),
(mismatch, [0.0], null),
(low, [0.4], null),
(high, [0.8], null)
);
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
// Act
var result = _sut.FindNearMisses(request, 10);
// Assert
result.Select(r => r.Mapping.Guid).Should().Equal(high, low);
}
[Fact]
public void MappingMatcher_FindNearMisses_ShouldRespectMaxResults()
{
// Assign
var low = Guid.Parse("00000000-0000-0000-0000-000000000001");
var high = Guid.Parse("00000000-0000-0000-0000-000000000002");
var mappings = InitMappings
(
(low, [0.4], null),
(high, [0.8], null)
);
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
// Act
var result = _sut.FindNearMisses(request, 1);
// Assert
result.Should().ContainSingle().Which.Mapping.Guid.Should().Be(high);
}
[Fact]
public void MappingMatcher_FindNearMisses_ShouldIgnoreProbability()
{
// Assign
var withProbability = Guid.Parse("00000000-0000-0000-0000-000000000001");
var mappings = InitMappings
(
(withProbability, [0.5], 0.1)
);
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
// Act
var result = _sut.FindNearMisses(request, 3);
// Assert
result.Should().ContainSingle().Which.Mapping.Guid.Should().Be(withProbability);
_randomizerDoubleBetween0And1Mock.Verify(r => r.Generate(), Times.Never);
}
[Fact]
public void MappingMatcher_FindNearMisses_WhenMappingThrowsException_ShouldSkipMapping()
{
// Assign
var mappingMock = new Mock<IMapping>();
mappingMock.SetupGet(m => m.IsDisabled).Returns(false);
mappingMock.Setup(m => m.GetRequestMatchResult(It.IsAny<IRequestMessage>(), It.IsAny<string>())).Throws<Exception>();
var mappings = new ConcurrentDictionary<Guid, IMapping>();
mappings.TryAdd(Guid.NewGuid(), mappingMock.Object);
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
// Act
var result = _sut.FindNearMisses(request, 3);
// Assert
result.Should().BeEmpty();
}
private static ConcurrentDictionary<Guid, IMapping> InitMappings(params (Guid guid, double[] scores, double? probability)[] matches)
{
var mappings = new ConcurrentDictionary<Guid, IMapping>();
@@ -442,8 +442,9 @@ public class StatefulBehaviorTests
// Act: fire request 1 but don't await it yet — it will sit in a 500 ms delay
var request1Task = client.GetStringAsync(server.Url + path, cancellationToken);
// Give the server a moment to match & transition state before the delay completes
await Task.Delay(100, cancellationToken);
// Wait until the server has matched request 1 & transitioned the state; request 1 must still be delayed
await WaitForScenarioStateAsync(server, "1260", "State1", cancellationToken);
request1Task.IsCompleted.Should().BeFalse();
// Request 2 is sent while request 1 is still being delayed.
// After the fix the state has already transitioned, so request 2 matches Mapping 2.
@@ -481,7 +482,8 @@ public class StatefulBehaviorTests
// Act
var request1Task = client.GetStringAsync(server.Url + path, cancellationToken);
await Task.Delay(100, cancellationToken);
await WaitForScenarioStateAsync(server, "s", "State1", cancellationToken);
request1Task.IsCompleted.Should().BeFalse();
var response2 = await client.GetStringAsync(server.Url + path, cancellationToken);
var response1 = await request1Task;
@@ -574,5 +576,20 @@ public class StatefulBehaviorTests
server.Stop();
}
/// <summary>
/// Polls the scenario state instead of using a fixed delay, so tests do not depend on how fast the server handles the first request
/// (a cold server can take longer than a fixed delay to match the request and transition the state).
/// </summary>
private static async Task WaitForScenarioStateAsync(WireMockServer server, string scenario, string expectedState, CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
while (server.Scenarios.FirstOrDefault(s => s.Name == scenario)?.NextState != expectedState)
{
await Task.Delay(10, timeout.Token);
}
}
}
@@ -0,0 +1,226 @@
// Copyright © WireMock.Net
using System.Net;
using WireMock.Models;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
namespace WireMock.Net.Tests;
public partial class WireMockServerTests
{
private static readonly Guid NearMissPostGuid = new("11111111-0000-0000-0000-000000000001");
private static readonly Guid NearMissGetGuid = new("11111111-0000-0000-0000-000000000002");
private static readonly Guid NearMissOtherGuid = new("11111111-0000-0000-0000-000000000003");
[Fact]
public async Task WireMockServer_FindUnmatchedLogEntries_ShouldOnlyReturnRequestsWithoutMatchingMapping()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
await client.GetAsync("/orders", cancellationToken);
await client.GetAsync("/order", cancellationToken);
// Act
var unmatched = server.FindUnmatchedLogEntries();
// Assert
unmatched.Should().ContainSingle();
unmatched[0].RequestMessage!.Path.Should().Be("/order");
unmatched[0].ResponseMessage!.StatusCode.Should().Be((int)HttpStatusCode.NotFound);
}
[Fact]
public async Task WireMockServer_FindNearMisses_ShouldReturnClosestMappingsFirst()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
// Path matches the POST and GET mappings, but the method is wrong for both.
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
var requestMessage = server.LogEntries.Single().RequestMessage!;
// Act
var nearMisses = server.FindNearMisses(requestMessage);
// Assert
nearMisses.Should().HaveCount(2);
nearMisses.Select(nm => nm.MappingGuid).Should().BeEquivalentTo([NearMissPostGuid, NearMissGetGuid]);
nearMisses.Should().AllSatisfy(nm =>
{
nm.LogEntryGuid.Should().BeNull();
nm.RequestMessage.Should().BeSameAs(requestMessage);
nm.RequestMatchResult.IsPerfectMatch.Should().BeFalse();
nm.RequestMatchResult.MatchDetails.Should().Contain(md => md.Name == "MethodMatcher" && md.Score < 1.0);
});
}
[Fact]
public async Task WireMockServer_FindNearMisses_ShouldRespectMaxResults()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
// Act
var nearMisses = server.FindNearMisses(server.LogEntries.Single().RequestMessage!, maxResults: 1);
// Assert
nearMisses.Should().ContainSingle();
}
[Fact]
public async Task WireMockServer_FindNearMisses_ShouldIgnoreDisabledMappings()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
server.Mappings.Single(m => m.Guid == NearMissPostGuid).IsDisabled = true;
using var client = server.CreateClient();
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
// Act
var nearMisses = server.FindNearMisses(server.LogEntries.Single().RequestMessage!);
// Assert
nearMisses.Select(nm => nm.MappingGuid).Should().NotContain(NearMissPostGuid);
}
[Fact]
public void WireMockServer_FindNearMisses_WithInvalidMaxResults_ShouldThrow()
{
// Arrange
using var server = WireMockServer.Start();
var requestMessage = new RequestMessage(new UrlDetails("http://localhost/orders"), "GET", "::1");
// Act
Action act = () => server.FindNearMisses(requestMessage, maxResults: 0);
// Assert
act.Should().Throw<ArgumentException>();
}
[Fact]
public async Task WireMockServer_FindNearMissesForUnmatchedRequests_ShouldReturnNearMissesPerUnmatchedRequest()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
await client.GetAsync("/orders", cancellationToken);
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
await client.DeleteAsync("/customers", cancellationToken);
var unmatchedGuids = server.FindUnmatchedLogEntries().Select(le => le.Guid).ToArray();
// Act
var nearMisses = server.FindNearMissesForUnmatchedRequests(maxResultsPerRequest: 1);
// Assert
unmatchedGuids.Should().HaveCount(2);
nearMisses.Should().HaveCount(2);
nearMisses.Select(nm => nm.LogEntryGuid!.Value).Should().BeEquivalentTo(unmatchedGuids);
nearMisses.Single(nm => nm.RequestMessage.Path == "/customers").MappingGuid.Should().Be(NearMissOtherGuid);
}
[Fact]
public async Task WireMockServer_FindNearMisses_ShouldNotReturnPerfectMatches()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
server
.Given(Request.Create().WithPath("/orders").UsingGet())
.AtPriority(100)
.WithTitle("Get orders (fallback)")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
using var client = server.CreateClient();
await client.GetAsync("/orders", cancellationToken);
// Act
var nearMisses = server.FindNearMisses(server.LogEntries.Single().RequestMessage!);
// Assert
nearMisses.Should().ContainSingle();
nearMisses[0].MappingGuid.Should().Be(NearMissPostGuid);
}
[Fact]
public async Task WireMockServer_FindNearMisses_ForLogEntry_ShouldSetLogEntryGuid()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
var logEntry = server.LogEntries.Single();
// Act
var nearMisses = server.FindNearMisses(logEntry, maxResults: 1);
// Assert
nearMisses.Should().ContainSingle();
nearMisses[0].LogEntryGuid.Should().Be(logEntry.Guid);
nearMisses[0].RequestMessage.Should().BeSameAs(logEntry.RequestMessage);
}
[Fact]
public async Task WireMockServer_FindUnmatchedLogEntries_WithAllowPartialMapping_ShouldIncludeRequestsServedByAPartialMatch()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
server.AllowPartialMapping();
using var client = server.CreateClient();
await client.GetAsync("/orders", cancellationToken);
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
// Act
var unmatched = server.FindUnmatchedLogEntries();
var nearMisses = server.FindNearMissesForUnmatchedRequests();
// Assert
unmatched.Should().ContainSingle();
unmatched[0].RequestMessage!.Method.Should().Be("PUT");
unmatched[0].MappingGuid.Should().NotBeNull("the request was served by a partial match");
nearMisses.Select(nm => nm.MappingGuid).Should().Contain(unmatched[0].MappingGuid!.Value);
}
private static WireMockServer StartServerWithNearMissMappings()
{
var server = WireMockServer.Start();
server
.Given(Request.Create().WithPath("/orders").UsingPost())
.WithGuid(NearMissPostGuid)
.WithTitle("Create order")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.Created));
server
.Given(Request.Create().WithPath("/orders").UsingGet())
.WithGuid(NearMissGetGuid)
.WithTitle("Get orders")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
server
.Given(Request.Create().WithPath("/customers").UsingDelete().WithHeader("X-Api-Key", "secret"))
.WithGuid(NearMissOtherGuid)
.WithTitle("Delete customers")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.NoContent));
return server;
}
}