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