diff --git a/src/WireMock.Net.Abstractions/Admin/Requests/NearMissModel.cs b/src/WireMock.Net.Abstractions/Admin/Requests/NearMissModel.cs
new file mode 100644
index 000000000..6048615d1
--- /dev/null
+++ b/src/WireMock.Net.Abstractions/Admin/Requests/NearMissModel.cs
@@ -0,0 +1,34 @@
+// Copyright © WireMock.Net
+
+namespace WireMock.Admin.Requests;
+
+///
+/// NearMiss Model: a mapping which did not (fully) match a request.
+///
+public class NearMissModel
+{
+ ///
+ /// The unique identifier of the log entry for the request. [Optional]
+ ///
+ public Guid? LogEntryGuid { get; set; }
+
+ ///
+ /// The request.
+ ///
+ public LogRequestModel? Request { get; set; }
+
+ ///
+ /// The unique identifier of the mapping.
+ ///
+ public Guid MappingGuid { get; set; }
+
+ ///
+ /// The title of the mapping.
+ ///
+ public string? MappingTitle { get; set; }
+
+ ///
+ /// The request match result, containing the score per matcher.
+ ///
+ public LogRequestMatchModel? RequestMatchResult { get; set; }
+}
diff --git a/src/WireMock.Net.Abstractions/Admin/Requests/RequestCountModel.cs b/src/WireMock.Net.Abstractions/Admin/Requests/RequestCountModel.cs
new file mode 100644
index 000000000..7df58324d
--- /dev/null
+++ b/src/WireMock.Net.Abstractions/Admin/Requests/RequestCountModel.cs
@@ -0,0 +1,14 @@
+// Copyright © WireMock.Net
+
+namespace WireMock.Admin.Requests;
+
+///
+/// RequestCount Model
+///
+public class RequestCountModel
+{
+ ///
+ /// The number of requests which match the criteria.
+ ///
+ public int Count { get; set; }
+}
diff --git a/src/WireMock.Net.Abstractions/Matchers/Request/NearMiss.cs b/src/WireMock.Net.Abstractions/Matchers/Request/NearMiss.cs
new file mode 100644
index 000000000..e3e45e5b5
--- /dev/null
+++ b/src/WireMock.Net.Abstractions/Matchers/Request/NearMiss.cs
@@ -0,0 +1,34 @@
+// Copyright © WireMock.Net
+
+namespace WireMock.Matchers.Request;
+
+///
+/// A mapping which did not (fully) match a request, together with the match result explaining why.
+///
+public class NearMiss
+{
+ ///
+ /// The request which was evaluated against the mapping.
+ ///
+ public required IRequestMessage RequestMessage { get; init; }
+
+ ///
+ /// The unique identifier of the log entry for the request. [Optional]
+ ///
+ public Guid? LogEntryGuid { get; init; }
+
+ ///
+ /// The unique identifier of the mapping.
+ ///
+ public required Guid MappingGuid { get; init; }
+
+ ///
+ /// The title of the mapping. [Optional]
+ ///
+ public string? MappingTitle { get; init; }
+
+ ///
+ /// The match result, containing the score per matcher.
+ ///
+ public required IRequestMatchResult RequestMatchResult { get; init; }
+}
diff --git a/src/WireMock.Net.Abstractions/Server/IWireMockServer.cs b/src/WireMock.Net.Abstractions/Server/IWireMockServer.cs
index c21795331..afb4ec5cc 100644
--- a/src/WireMock.Net.Abstractions/Server/IWireMockServer.cs
+++ b/src/WireMock.Net.Abstractions/Server/IWireMockServer.cs
@@ -107,6 +107,39 @@ public interface IWireMockServer : IDisposable
/// The .
IReadOnlyList FindLogEntries(params IRequestMatcher[] matchers);
+ ///
+ /// Get the log-entries for the requests which did not fully match any mapping.
+ /// This includes the requests which were served by a partial match when AllowPartialMapping is enabled.
+ ///
+ /// The .
+ IReadOnlyList FindUnmatchedLogEntries();
+
+ ///
+ /// Find the mappings which partially match the request (the near misses), ordered by best match first.
+ /// Perfect matches and mappings which do not match at all are excluded.
+ /// Use this to find out why a request did not match the expected mapping.
+ ///
+ /// The request to evaluate.
+ /// The maximum number of near misses to return. Default is 3.
+ /// The .
+ IReadOnlyList FindNearMisses(IRequestMessage requestMessage, int maxResults = 3);
+
+ ///
+ /// Find the mappings which partially match the request of a log-entry (the near misses), ordered by best match first.
+ /// Perfect matches and mappings which do not match at all are excluded.
+ ///
+ /// The log-entry for which to evaluate the request.
+ /// The maximum number of near misses to return. Default is 3.
+ /// The .
+ IReadOnlyList FindNearMisses(ILogEntry logEntry, int maxResults = 3);
+
+ ///
+ /// Find the near misses for all requests which did not fully match any mapping.
+ ///
+ /// The maximum number of near misses to return per request. Default is 3.
+ /// The .
+ IReadOnlyList FindNearMissesForUnmatchedRequests(int maxResultsPerRequest = 3);
+
///
/// Reads a static mapping file and adds or updates a single mapping.
///
diff --git a/src/WireMock.Net.Minimal/Owin/AdminPaths.cs b/src/WireMock.Net.Minimal/Owin/AdminPaths.cs
index 191c5521e..4621761fb 100644
--- a/src/WireMock.Net.Minimal/Owin/AdminPaths.cs
+++ b/src/WireMock.Net.Minimal/Owin/AdminPaths.cs
@@ -43,6 +43,8 @@ internal sealed class AdminPaths(string? adminPath) : IAdminPaths
public RegexMatcher RequestsGuidPathMatcher => new($@"^{PrefixRegexEscaped}\/requests\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})$");
+ public RegexMatcher RequestsGuidNearMissesPathMatcher => new($@"^{PrefixRegexEscaped}\/requests\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})\/near-misses$");
+
public RegexMatcher ScenariosNameMatcher => new($@"^{PrefixRegexEscaped}\/scenarios\/.+$");
public RegexMatcher ScenariosNameWithStateMatcher => new($@"^{PrefixRegexEscaped}\/scenarios\/.+\/state$");
diff --git a/src/WireMock.Net.Minimal/Owin/IAdminPaths.cs b/src/WireMock.Net.Minimal/Owin/IAdminPaths.cs
index c0f8b8ce3..04c07dd15 100644
--- a/src/WireMock.Net.Minimal/Owin/IAdminPaths.cs
+++ b/src/WireMock.Net.Minimal/Owin/IAdminPaths.cs
@@ -24,6 +24,8 @@ internal interface IAdminPaths
RegexMatcher RequestsGuidPathMatcher { get; }
+ RegexMatcher RequestsGuidNearMissesPathMatcher { get; }
+
RegexMatcher ScenariosNameMatcher { get; }
RegexMatcher ScenariosNameWithStateMatcher { get; }
diff --git a/src/WireMock.Net.Minimal/Owin/IMappingMatcher.cs b/src/WireMock.Net.Minimal/Owin/IMappingMatcher.cs
index 8047d64dd..1fa10d41c 100644
--- a/src/WireMock.Net.Minimal/Owin/IMappingMatcher.cs
+++ b/src/WireMock.Net.Minimal/Owin/IMappingMatcher.cs
@@ -5,4 +5,6 @@ namespace WireMock.Owin;
internal interface IMappingMatcher
{
(MappingMatcherResult? Match, MappingMatcherResult? Partial) FindBestMatch(RequestMessage request);
+
+ IReadOnlyList FindNearMisses(IRequestMessage request, int maxResults);
}
\ No newline at end of file
diff --git a/src/WireMock.Net.Minimal/Owin/MappingMatcher.cs b/src/WireMock.Net.Minimal/Owin/MappingMatcher.cs
index 97d1fc58b..261bbd8a5 100644
--- a/src/WireMock.Net.Minimal/Owin/MappingMatcher.cs
+++ b/src/WireMock.Net.Minimal/Owin/MappingMatcher.cs
@@ -83,6 +83,39 @@ internal class MappingMatcher(IWireMockMiddlewareOptions options, IRandomizerDou
return (match, partialMatch);
}
+ public IReadOnlyList FindNearMisses(IRequestMessage request, int maxResults)
+ {
+ Guard.NotNull(request);
+
+ var nearMisses = new List();
+
+ // Probability is not taken into account here: a mapping which was skipped based on probability can still be a near miss.
+ foreach (var mapping in _options.Mappings.Values.Where(m => !m.IsAdminInterface && !m.IsDisabled && m.TimeSettings.IsValid()))
+ {
+ try
+ {
+ var requestMatchResult = mapping.GetRequestMatchResult(request, GetNextState(mapping));
+ if (requestMatchResult.MatchDetails.All(md => md.Exception == null))
+ {
+ nearMisses.Add(new MappingMatcherResult(mapping, requestMatchResult));
+ }
+ }
+ catch (Exception ex)
+ {
+ LogException(mapping, ex);
+ }
+ }
+
+ // A near miss is a mapping which matches the request partially: perfect matches and complete mismatches are excluded.
+ return nearMisses
+ .Where(nm => !nm.RequestMatchResult.IsPerfectMatch && nm.RequestMatchResult.AverageTotalScore > 0.0)
+ .OrderBy(nm => nm.RequestMatchResult)
+ .ThenBy(nm => nm.Mapping.Priority)
+ .ThenByDescending(nm => nm.Mapping.UpdatedAt)
+ .Take(maxResults)
+ .ToArray();
+ }
+
private void LogException(IMapping mapping, Exception ex)
{
_options.Logger.Error($"Getting a Request MatchResult for Mapping '{mapping.Guid}' failed. This mapping will not be evaluated. Exception: {ex}");
diff --git a/src/WireMock.Net.Minimal/Serialization/LogEntryMapper.cs b/src/WireMock.Net.Minimal/Serialization/LogEntryMapper.cs
index c244c3dfc..bad3b999d 100644
--- a/src/WireMock.Net.Minimal/Serialization/LogEntryMapper.cs
+++ b/src/WireMock.Net.Minimal/Serialization/LogEntryMapper.cs
@@ -14,60 +14,7 @@ internal class LogEntryMapper(IWireMockMiddlewareOptions options)
{
public LogEntryModel Map(ILogEntry logEntry)
{
- LogRequestModel? logRequestModel = null;
- if (logEntry.RequestMessage != null)
- {
- logRequestModel = new LogRequestModel
- {
- DateTime = logEntry.RequestMessage.DateTime,
- ClientIP = logEntry.RequestMessage.ClientIP,
- Path = logEntry.RequestMessage.Path,
- AbsolutePath = logEntry.RequestMessage.AbsolutePath,
- Url = logEntry.RequestMessage.Url,
- AbsoluteUrl = logEntry.RequestMessage.AbsoluteUrl,
- ProxyUrl = logEntry.RequestMessage.ProxyUrl,
- Query = logEntry.RequestMessage.Query,
- Method = logEntry.RequestMessage.Method,
- HttpVersion = logEntry.RequestMessage.HttpVersion,
- Headers = logEntry.RequestMessage.Headers,
- Cookies = logEntry.RequestMessage.Cookies
- };
-
- if (logEntry.RequestMessage.BodyData != null)
- {
- logRequestModel.DetectedBodyType = logEntry.RequestMessage.BodyData.DetectedBodyType?.ToString();
- logRequestModel.DetectedBodyTypeFromContentType = logEntry.RequestMessage.BodyData.DetectedBodyTypeFromContentType?.ToString();
-
- switch (logEntry.RequestMessage.BodyData.DetectedBodyType)
- {
- case BodyType.String:
- case BodyType.FormUrlEncoded:
- case BodyType.WebSocketText:
- case BodyType.WebSocketClose:
- logRequestModel.Body = logEntry.RequestMessage.BodyData.BodyAsString;
- break;
-
- case BodyType.Json:
- logRequestModel.Body = logEntry.RequestMessage.BodyData.BodyAsString; // In case of Json, do also save the Body as string (backwards compatible)
- logRequestModel.BodyAsJson = logEntry.RequestMessage.BodyData.BodyAsJson;
- break;
-
- case BodyType.Bytes:
- case BodyType.WebSocketBinary:
- logRequestModel.BodyAsBytes = logEntry.RequestMessage.BodyData.BodyAsBytes;
- break;
- }
-
- logRequestModel.BodyEncoding = logEntry.RequestMessage.BodyData.Encoding != null
- ? new EncodingModel
- {
- EncodingName = logEntry.RequestMessage.BodyData.Encoding.EncodingName,
- CodePage = logEntry.RequestMessage.BodyData.Encoding.CodePage,
- WebName = logEntry.RequestMessage.BodyData.Encoding.WebName
- }
- : null;
- }
- }
+ var logRequestModel = logEntry.RequestMessage != null ? MapRequest(logEntry.RequestMessage) : null;
LogResponseModel? logResponseModel = null;
if (logEntry.ResponseMessage != null)
@@ -123,6 +70,74 @@ public LogEntryModel Map(ILogEntry logEntry)
};
}
+ public NearMissModel Map(NearMiss nearMiss)
+ {
+ return new NearMissModel
+ {
+ LogEntryGuid = nearMiss.LogEntryGuid,
+ Request = MapRequest(nearMiss.RequestMessage),
+ MappingGuid = nearMiss.MappingGuid,
+ MappingTitle = nearMiss.MappingTitle,
+ RequestMatchResult = Map(nearMiss.RequestMatchResult)
+ };
+ }
+
+ private static LogRequestModel MapRequest(IRequestMessage requestMessage)
+ {
+ var logRequestModel = new LogRequestModel
+ {
+ DateTime = requestMessage.DateTime,
+ ClientIP = requestMessage.ClientIP,
+ Path = requestMessage.Path,
+ AbsolutePath = requestMessage.AbsolutePath,
+ Url = requestMessage.Url,
+ AbsoluteUrl = requestMessage.AbsoluteUrl,
+ ProxyUrl = requestMessage.ProxyUrl,
+ Query = requestMessage.Query,
+ Method = requestMessage.Method,
+ HttpVersion = requestMessage.HttpVersion,
+ Headers = requestMessage.Headers,
+ Cookies = requestMessage.Cookies
+ };
+
+ if (requestMessage.BodyData != null)
+ {
+ logRequestModel.DetectedBodyType = requestMessage.BodyData.DetectedBodyType?.ToString();
+ logRequestModel.DetectedBodyTypeFromContentType = requestMessage.BodyData.DetectedBodyTypeFromContentType?.ToString();
+
+ switch (requestMessage.BodyData.DetectedBodyType)
+ {
+ case BodyType.String:
+ case BodyType.FormUrlEncoded:
+ case BodyType.WebSocketText:
+ case BodyType.WebSocketClose:
+ logRequestModel.Body = requestMessage.BodyData.BodyAsString;
+ break;
+
+ case BodyType.Json:
+ logRequestModel.Body = requestMessage.BodyData.BodyAsString; // In case of Json, do also save the Body as string (backwards compatible)
+ logRequestModel.BodyAsJson = requestMessage.BodyData.BodyAsJson;
+ break;
+
+ case BodyType.Bytes:
+ case BodyType.WebSocketBinary:
+ logRequestModel.BodyAsBytes = requestMessage.BodyData.BodyAsBytes;
+ break;
+ }
+
+ logRequestModel.BodyEncoding = requestMessage.BodyData.Encoding != null
+ ? new EncodingModel
+ {
+ EncodingName = requestMessage.BodyData.Encoding.EncodingName,
+ CodePage = requestMessage.BodyData.Encoding.CodePage,
+ WebName = requestMessage.BodyData.Encoding.WebName
+ }
+ : null;
+ }
+
+ return logRequestModel;
+ }
+
private void MapBody(ILogEntry logEntry, LogResponseModel logResponseModel)
{
switch (logEntry.ResponseMessage?.BodyData?.DetectedBodyType)
diff --git a/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs b/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs
index 6190dc643..c00e847d6 100644
--- a/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs
+++ b/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs
@@ -8,6 +8,7 @@
using Newtonsoft.Json.Linq;
using Stef.Validation;
using WireMock.Admin.Mappings;
+using WireMock.Admin.Requests;
using WireMock.Admin.Scenarios;
using WireMock.Admin.Settings;
using WireMock.Constants;
@@ -93,6 +94,18 @@ private void InitAdmin()
Given(Request.Create().WithPath(_adminPaths.RequestsGuidPathMatcher).UsingGet()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestGet));
Given(Request.Create().WithPath(_adminPaths.RequestsGuidPathMatcher).UsingDelete()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestDelete));
+ // __admin/requests/{guid}/near-misses
+ Given(Request.Create().WithPath(_adminPaths.RequestsGuidNearMissesPathMatcher).UsingGet()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestNearMissesGet));
+
+ // __admin/requests/count
+ Given(Request.Create().WithPath(_adminPaths.Requests + "/count").UsingPost()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestsCount));
+
+ // __admin/requests/unmatched
+ Given(Request.Create().WithPath(_adminPaths.Requests + "/unmatched").UsingGet()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestsUnmatchedGet));
+
+ // __admin/requests/unmatched/near-misses
+ Given(Request.Create().WithPath(_adminPaths.Requests + "/unmatched/near-misses").UsingGet()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestsUnmatchedNearMissesGet));
+
// __admin/requests/find
Given(Request.Create().WithPath(_adminPaths.Requests + "/find").UsingPost()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestsFind));
Given(Request.Create().WithPath(_adminPaths.Requests + "/find").UsingGet().WithParam("mappingGuid", new NotNullOrEmptyMatcher())).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestsFindByMappingGuid));
@@ -641,6 +654,22 @@ private IResponseMessage RequestsDelete(HttpContext _, IRequestMessage requestMe
#region Requests/find
private IResponseMessage RequestsFind(HttpContext _, IRequestMessage requestMessage)
+ {
+ var logEntryMapper = new LogEntryMapper(_options);
+ var result = FindLogEntriesByRequestModel(requestMessage)
+ .OrderBy(x => x.Value.AverageTotalScore)
+ .Select(x => x.Key)
+ .Select(logEntryMapper.Map);
+
+ return ToJson(result);
+ }
+
+ private IResponseMessage RequestsCount(HttpContext _, IRequestMessage requestMessage)
+ {
+ return ToJson(new RequestCountModel { Count = FindLogEntriesByRequestModel(requestMessage).Count });
+ }
+
+ private Dictionary FindLogEntriesByRequestModel(IRequestMessage requestMessage)
{
var requestModel = DeserializeObject(requestMessage);
@@ -656,10 +685,7 @@ private IResponseMessage RequestsFind(HttpContext _, IRequestMessage requestMess
}
}
- var logEntryMapper = new LogEntryMapper(_options);
- var result = dict.OrderBy(x => x.Value.AverageTotalScore).Select(x => x.Key).Select(logEntryMapper.Map);
-
- return ToJson(result);
+ return dict;
}
private IResponseMessage RequestsFindByMappingGuid(HttpContext _, IRequestMessage requestMessage)
@@ -679,6 +705,65 @@ private IResponseMessage RequestsFindByMappingGuid(HttpContext _, IRequestMessag
}
#endregion Requests/find
+ #region Requests/unmatched
+ private IResponseMessage RequestsUnmatchedGet(HttpContext _, IRequestMessage requestMessage)
+ {
+ var logEntryMapper = new LogEntryMapper(_options);
+ var result = FindUnmatchedLogEntries().Select(logEntryMapper.Map);
+
+ return ToJson(result);
+ }
+
+ private IResponseMessage RequestsUnmatchedNearMissesGet(HttpContext _, IRequestMessage requestMessage)
+ {
+ if (!TryGetMaxResultsFromQuery(requestMessage, out var maxResults))
+ {
+ return _responseMessageBuilder.Create(HttpStatusCode.BadRequest, "Query parameter 'maxResults' must be a positive integer");
+ }
+
+ var logEntryMapper = new LogEntryMapper(_options);
+ var result = FindNearMissesForUnmatchedRequests(maxResults).Select(logEntryMapper.Map);
+
+ return ToJson(result);
+ }
+ #endregion Requests/unmatched
+
+ #region Requests/{guid}/near-misses
+ private IResponseMessage RequestNearMissesGet(HttpContext _, IRequestMessage requestMessage)
+ {
+ if (!TryGetMaxResultsFromQuery(requestMessage, out var maxResults))
+ {
+ return _responseMessageBuilder.Create(HttpStatusCode.BadRequest, "Query parameter 'maxResults' must be a positive integer");
+ }
+
+ if (TryParseGuidFromSecondToLastSegment(requestMessage, out var guid))
+ {
+ var entry = LogEntries.SingleOrDefault(r => !_adminPaths!.Includes(r.RequestMessage?.Path) && r.Guid == guid);
+ if (entry is not null)
+ {
+ var logEntryMapper = new LogEntryMapper(_options);
+ var result = FindNearMisses(entry, maxResults).Select(logEntryMapper.Map);
+
+ return ToJson(result);
+ }
+ }
+
+ _settings.Logger.Warn("HttpStatusCode set to 404 : Request not found");
+ return _responseMessageBuilder.Create(HttpStatusCode.NotFound, "Request not found");
+ }
+
+ private static bool TryGetMaxResultsFromQuery(IRequestMessage requestMessage, out int maxResults)
+ {
+ maxResults = DefaultMaxNearMisses;
+ if (requestMessage.Query == null || !requestMessage.Query.TryGetValue("maxResults", out var value))
+ {
+ return true;
+ }
+
+ return int.TryParse(value.ToString(), out maxResults) && maxResults > 0;
+ }
+ #endregion Requests/{guid}/near-misses
+
#region Scenarios
private IResponseMessage ScenariosGet(HttpContext _, IRequestMessage requestMessage)
{
diff --git a/src/WireMock.Net.Minimal/Server/WireMockServer.LogEntries.cs b/src/WireMock.Net.Minimal/Server/WireMockServer.LogEntries.cs
index 54f208864..6d1bc39ca 100644
--- a/src/WireMock.Net.Minimal/Server/WireMockServer.LogEntries.cs
+++ b/src/WireMock.Net.Minimal/Server/WireMockServer.LogEntries.cs
@@ -7,6 +7,8 @@
using WireMock.Logging;
using WireMock.Matchers;
using WireMock.Matchers.Request;
+using WireMock.Owin;
+using WireMock.Services;
namespace WireMock.Server;
@@ -57,6 +59,68 @@ public IReadOnlyList FindLogEntries(params IRequestMatcher[] matchers
.ToArray();
}
+ ///
+ [PublicAPI]
+ public IReadOnlyList FindUnmatchedLogEntries()
+ {
+ return LogEntries
+ .Where(le => le.RequestMessage != null && !_options.AdminPaths.Includes(le.RequestMessage.Path))
+ .Where(le => le.RequestMatchResult is not { IsPerfectMatch: true })
+ .ToArray();
+ }
+
+ ///
+ [PublicAPI]
+ public IReadOnlyList FindNearMisses(IRequestMessage requestMessage, int maxResults = DefaultMaxNearMisses)
+ {
+ Guard.NotNull(requestMessage);
+ Guard.Condition(maxResults, m => m > 0);
+
+ return FindNearMisses(requestMessage, null, maxResults);
+ }
+
+ ///
+ [PublicAPI]
+ public IReadOnlyList FindNearMisses(ILogEntry logEntry, int maxResults = DefaultMaxNearMisses)
+ {
+ Guard.NotNull(logEntry);
+ Guard.Condition(maxResults, m => m > 0);
+
+ return logEntry.RequestMessage is null ? [] : FindNearMisses(logEntry.RequestMessage, logEntry.Guid, maxResults);
+ }
+
+ ///
+ [PublicAPI]
+ public IReadOnlyList FindNearMissesForUnmatchedRequests(int maxResultsPerRequest = DefaultMaxNearMisses)
+ {
+ Guard.Condition(maxResultsPerRequest, m => m > 0);
+
+ return FindUnmatchedLogEntries()
+ .SelectMany(le => FindNearMisses(le, maxResultsPerRequest))
+ .ToArray();
+ }
+
+ private const int DefaultMaxNearMisses = 3;
+
+ private IMappingMatcher? _nearMissMappingMatcher;
+
+ private IReadOnlyList FindNearMisses(IRequestMessage requestMessage, Guid? logEntryGuid, int maxResults)
+ {
+ _nearMissMappingMatcher ??= new MappingMatcher(_options, new RandomizerDoubleBetween0And1());
+
+ return _nearMissMappingMatcher
+ .FindNearMisses(requestMessage, maxResults)
+ .Select(nm => new NearMiss
+ {
+ RequestMessage = requestMessage,
+ LogEntryGuid = logEntryGuid,
+ MappingGuid = nm.Mapping.Guid,
+ MappingTitle = nm.Mapping.Title,
+ RequestMatchResult = nm.RequestMatchResult
+ })
+ .ToArray();
+ }
+
///
[PublicAPI]
public void ResetLogEntries()
diff --git a/src/WireMock.Net.Minimal/Server/wiremock.net-openapi.yml b/src/WireMock.Net.Minimal/Server/wiremock.net-openapi.yml
index b7c8c0e88..a56f372b7 100644
--- a/src/WireMock.Net.Minimal/Server/wiremock.net-openapi.yml
+++ b/src/WireMock.Net.Minimal/Server/wiremock.net-openapi.yml
@@ -421,6 +421,105 @@ paths:
schema:
$ref: '#/components/schemas/StatusModel'
+ /__admin/requests/{guid}/near-misses:
+ get:
+ summary: Get the near misses (mappings which partially match) for a logged request
+ parameters:
+ - name: guid
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: maxResults
+ in: query
+ required: false
+ description: The maximum number of near misses per request (default 3).
+ schema:
+ type: integer
+ format: int32
+ minimum: 1
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/NearMissModel'
+ '400':
+ description: Bad Request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/StatusModel'
+ '404':
+ description: Not Found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/StatusModel'
+
+ /__admin/requests/count:
+ post:
+ summary: Count logged requests matching a request filter
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RequestModel'
+ required: true
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RequestCountModel'
+ x-codegen-request-body-name: requestFilter
+
+ /__admin/requests/unmatched:
+ get:
+ summary: Get logged requests which did not fully match any mapping
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/LogEntryModel'
+
+ /__admin/requests/unmatched/near-misses:
+ get:
+ summary: Get the near misses (mappings which partially match) for each logged request which did not fully match any mapping
+ parameters:
+ - name: maxResults
+ in: query
+ required: false
+ description: The maximum number of near misses per request (default 3).
+ schema:
+ type: integer
+ format: int32
+ minimum: 1
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/NearMissModel'
+ '400':
+ description: Bad Request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/StatusModel'
+
/__admin/requests/find:
post:
summary: Find logged requests matching a request filter
@@ -895,6 +994,35 @@ components:
additionalProperties: true
description: Log Request Match Model
+ NearMissModel:
+ type: object
+ properties:
+ LogEntryGuid:
+ type: string
+ description: The unique identifier of the log entry for the request.
+ format: uuid
+ Request:
+ $ref: '#/components/schemas/LogRequestModel'
+ MappingGuid:
+ type: string
+ description: The unique identifier of the mapping.
+ format: uuid
+ MappingTitle:
+ type: string
+ description: The title of the mapping.
+ RequestMatchResult:
+ $ref: '#/components/schemas/LogRequestMatchModel'
+ description: Near Miss Model, a mapping which did not (fully) match a request
+
+ RequestCountModel:
+ type: object
+ properties:
+ Count:
+ type: integer
+ description: The number of requests which match the criteria.
+ format: int32
+ description: Request Count Model
+
LogRequestModel:
type: object
properties:
diff --git a/src/WireMock.Net.RestClient/IWireMockAdminApi.cs b/src/WireMock.Net.RestClient/IWireMockAdminApi.cs
index 82fd1cb86..7359f9424 100644
--- a/src/WireMock.Net.RestClient/IWireMockAdminApi.cs
+++ b/src/WireMock.Net.RestClient/IWireMockAdminApi.cs
@@ -250,6 +250,39 @@ public interface IWireMockAdminApi
[Get("requests/find")]
Task> FindRequestsByMappingGuidAsync([Query] Guid mappingGuid, CancellationToken cancellationToken = default);
+ ///
+ /// Count the requests which match the criteria ().
+ ///
+ /// The RequestModel
+ /// The optional cancellationToken.
+ [Post("requests/count")]
+ [Header("Content-Type", "application/json")]
+ Task CountRequestsAsync([Body] RequestModel model, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get the requests which did not fully match any mapping.
+ ///
+ /// The optional cancellationToken.
+ [Get("requests/unmatched")]
+ Task> GetUnmatchedRequestsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Get the near misses (the mappings which partially match) for all requests which did not fully match any mapping.
+ ///
+ /// The maximum number of near misses per request. Default is 3.
+ /// The optional cancellationToken.
+ [Get("requests/unmatched/near-misses")]
+ Task> GetNearMissesForUnmatchedRequestsAsync([Query] int? maxResults = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get the near misses (the mappings which partially match) for a request.
+ ///
+ /// The Guid of the request
+ /// The maximum number of near misses. Default is 3.
+ /// The optional cancellationToken.
+ [Get("requests/{guid}/near-misses")]
+ Task> GetNearMissesForRequestAsync([Path] Guid guid, [Query] int? maxResults = null, CancellationToken cancellationToken = default);
+
///
/// Get all scenarios
///
diff --git a/test/WireMock.Net.Tests/AdminApi/WireMockAdminApiTests.NearMisses.cs b/test/WireMock.Net.Tests/AdminApi/WireMockAdminApiTests.NearMisses.cs
new file mode 100644
index 000000000..956ba9ba1
--- /dev/null
+++ b/test/WireMock.Net.Tests/AdminApi/WireMockAdminApiTests.NearMisses.cs
@@ -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(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(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(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(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(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(server.Url!);
+
+ // Act
+ Func act = () => api.GetNearMissesForRequestAsync(Guid.NewGuid(), cancellationToken: cancellationToken);
+
+ // Assert
+ (await act.Should().ThrowAsync()).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(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(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;
+ }
+}
diff --git a/test/WireMock.Net.Tests/Constants.cs b/test/WireMock.Net.Tests/Constants.cs
index aadd6fb9e..082cfd337 100644
--- a/test/WireMock.Net.Tests/Constants.cs
+++ b/test/WireMock.Net.Tests/Constants.cs
@@ -6,5 +6,5 @@ internal static class Constants
{
internal const int NumStaticMappings = 10;
- internal const int NumAdminMappings = 39;
+ internal const int NumAdminMappings = 43;
}
\ No newline at end of file
diff --git a/test/WireMock.Net.Tests/Owin/MappingMatcherTests.cs b/test/WireMock.Net.Tests/Owin/MappingMatcherTests.cs
index 17da566c3..7e993df89 100644
--- a/test/WireMock.Net.Tests/Owin/MappingMatcherTests.cs
+++ b/test/WireMock.Net.Tests/Owin/MappingMatcherTests.cs
@@ -259,6 +259,96 @@ public void MappingMatcher_FindBestMatch_WhenMappingIsDisabled_ShouldReturnNull(
mappingMock.Verify(m => m.GetRequestMatchResult(It.IsAny(), It.IsAny()), 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();
+ mappingMock.SetupGet(m => m.IsDisabled).Returns(false);
+ mappingMock.Setup(m => m.GetRequestMatchResult(It.IsAny(), It.IsAny())).Throws();
+
+ var mappings = new ConcurrentDictionary();
+ 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 InitMappings(params (Guid guid, double[] scores, double? probability)[] matches)
{
var mappings = new ConcurrentDictionary();
diff --git a/test/WireMock.Net.Tests/StatefulBehaviorTests.cs b/test/WireMock.Net.Tests/StatefulBehaviorTests.cs
index 42ff1b2a6..bd01f5d4a 100644
--- a/test/WireMock.Net.Tests/StatefulBehaviorTests.cs
+++ b/test/WireMock.Net.Tests/StatefulBehaviorTests.cs
@@ -442,8 +442,9 @@ public async Task Scenarios_FirstRequestWithDelay_StateTransitions_BeforeDelayCo
// 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 async Task Scenarios_WithGlobalRequestProcessingDelay_StateTransitions_Be
// 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 async Task Scenarios_Should_process_request_if_equals_state_and_multiple_
server.Stop();
}
+
+ ///
+ /// 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).
+ ///
+ 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);
+ }
+ }
}
diff --git a/test/WireMock.Net.Tests/WireMockServerTests.NearMisses.cs b/test/WireMock.Net.Tests/WireMockServerTests.NearMisses.cs
new file mode 100644
index 000000000..9bc298b69
--- /dev/null
+++ b/test/WireMock.Net.Tests/WireMockServerTests.NearMisses.cs
@@ -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();
+ }
+
+ [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;
+ }
+}