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

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

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

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

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

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

---------

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

No files matched your search

@@ -0,0 +1,34 @@
// Copyright © WireMock.Net
namespace WireMock.Admin.Requests;
/// <summary>
/// NearMiss Model: a mapping which did not (fully) match a request.
/// </summary>
public class NearMissModel
{
/// <summary>
/// The unique identifier of the log entry for the request. [Optional]
/// </summary>
public Guid? LogEntryGuid { get; set; }
/// <summary>
/// The request.
/// </summary>
public LogRequestModel? Request { get; set; }
/// <summary>
/// The unique identifier of the mapping.
/// </summary>
public Guid MappingGuid { get; set; }
/// <summary>
/// The title of the mapping.
/// </summary>
public string? MappingTitle { get; set; }
/// <summary>
/// The request match result, containing the score per matcher.
/// </summary>
public LogRequestMatchModel? RequestMatchResult { get; set; }
}
@@ -0,0 +1,14 @@
// Copyright © WireMock.Net
namespace WireMock.Admin.Requests;
/// <summary>
/// RequestCount Model
/// </summary>
public class RequestCountModel
{
/// <summary>
/// The number of requests which match the criteria.
/// </summary>
public int Count { get; set; }
}
@@ -0,0 +1,34 @@
// Copyright © WireMock.Net
namespace WireMock.Matchers.Request;
/// <summary>
/// A mapping which did not (fully) match a request, together with the match result explaining why.
/// </summary>
public class NearMiss
{
/// <summary>
/// The request which was evaluated against the mapping.
/// </summary>
public required IRequestMessage RequestMessage { get; init; }
/// <summary>
/// The unique identifier of the log entry for the request. [Optional]
/// </summary>
public Guid? LogEntryGuid { get; init; }
/// <summary>
/// The unique identifier of the mapping.
/// </summary>
public required Guid MappingGuid { get; init; }
/// <summary>
/// The title of the mapping. [Optional]
/// </summary>
public string? MappingTitle { get; init; }
/// <summary>
/// The match result, containing the score per matcher.
/// </summary>
public required IRequestMatchResult RequestMatchResult { get; init; }
}
@@ -107,6 +107,39 @@ public interface IWireMockServer : IDisposable
/// <returns>The <see cref="IReadOnlyList{ILogEntry}"/>.</returns> /// <returns>The <see cref="IReadOnlyList{ILogEntry}"/>.</returns>
IReadOnlyList<ILogEntry> FindLogEntries(params IRequestMatcher[] matchers); IReadOnlyList<ILogEntry> FindLogEntries(params IRequestMatcher[] matchers);
/// <summary>
/// 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.
/// </summary>
/// <returns>The <see cref="IReadOnlyList{ILogEntry}"/>.</returns>
IReadOnlyList<ILogEntry> FindUnmatchedLogEntries();
/// <summary>
/// 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.
/// </summary>
/// <param name="requestMessage">The request to evaluate.</param>
/// <param name="maxResults">The maximum number of near misses to return. Default is 3.</param>
/// <returns>The <see cref="IReadOnlyList{NearMiss}"/>.</returns>
IReadOnlyList<NearMiss> FindNearMisses(IRequestMessage requestMessage, int maxResults = 3);
/// <summary>
/// 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.
/// </summary>
/// <param name="logEntry">The log-entry for which to evaluate the request.</param>
/// <param name="maxResults">The maximum number of near misses to return. Default is 3.</param>
/// <returns>The <see cref="IReadOnlyList{NearMiss}"/>.</returns>
IReadOnlyList<NearMiss> FindNearMisses(ILogEntry logEntry, int maxResults = 3);
/// <summary>
/// Find the near misses for all requests which did not fully match any mapping.
/// </summary>
/// <param name="maxResultsPerRequest">The maximum number of near misses to return per request. Default is 3.</param>
/// <returns>The <see cref="IReadOnlyList{NearMiss}"/>.</returns>
IReadOnlyList<NearMiss> FindNearMissesForUnmatchedRequests(int maxResultsPerRequest = 3);
/// <summary> /// <summary>
/// Reads a static mapping file and adds or updates a single mapping. /// Reads a static mapping file and adds or updates a single mapping.
/// ///
@@ -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 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 ScenariosNameMatcher => new($@"^{PrefixRegexEscaped}\/scenarios\/.+$");
public RegexMatcher ScenariosNameWithStateMatcher => new($@"^{PrefixRegexEscaped}\/scenarios\/.+\/state$"); public RegexMatcher ScenariosNameWithStateMatcher => new($@"^{PrefixRegexEscaped}\/scenarios\/.+\/state$");
@@ -24,6 +24,8 @@ internal interface IAdminPaths
RegexMatcher RequestsGuidPathMatcher { get; } RegexMatcher RequestsGuidPathMatcher { get; }
RegexMatcher RequestsGuidNearMissesPathMatcher { get; }
RegexMatcher ScenariosNameMatcher { get; } RegexMatcher ScenariosNameMatcher { get; }
RegexMatcher ScenariosNameWithStateMatcher { get; } RegexMatcher ScenariosNameWithStateMatcher { get; }
@@ -5,4 +5,6 @@ namespace WireMock.Owin;
internal interface IMappingMatcher internal interface IMappingMatcher
{ {
(MappingMatcherResult? Match, MappingMatcherResult? Partial) FindBestMatch(RequestMessage request); (MappingMatcherResult? Match, MappingMatcherResult? Partial) FindBestMatch(RequestMessage request);
IReadOnlyList<MappingMatcherResult> FindNearMisses(IRequestMessage request, int maxResults);
} }
@@ -83,6 +83,39 @@ internal class MappingMatcher(IWireMockMiddlewareOptions options, IRandomizerDou
return (match, partialMatch); return (match, partialMatch);
} }
public IReadOnlyList<MappingMatcherResult> FindNearMisses(IRequestMessage request, int maxResults)
{
Guard.NotNull(request);
var nearMisses = new List<MappingMatcherResult>();
// 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) 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}"); _options.Logger.Error($"Getting a Request MatchResult for Mapping '{mapping.Guid}' failed. This mapping will not be evaluated. Exception: {ex}");
@@ -14,60 +14,7 @@ internal class LogEntryMapper(IWireMockMiddlewareOptions options)
{ {
public LogEntryModel Map(ILogEntry logEntry) public LogEntryModel Map(ILogEntry logEntry)
{ {
LogRequestModel? logRequestModel = null; var logRequestModel = logEntry.RequestMessage != null ? MapRequest(logEntry.RequestMessage) : 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;
}
}
LogResponseModel? logResponseModel = null; LogResponseModel? logResponseModel = null;
if (logEntry.ResponseMessage != null) if (logEntry.ResponseMessage != null)
@@ -123,6 +70,74 @@ internal class LogEntryMapper(IWireMockMiddlewareOptions options)
}; };
} }
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) private void MapBody(ILogEntry logEntry, LogResponseModel logResponseModel)
{ {
switch (logEntry.ResponseMessage?.BodyData?.DetectedBodyType) switch (logEntry.ResponseMessage?.BodyData?.DetectedBodyType)
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Stef.Validation; using Stef.Validation;
using WireMock.Admin.Mappings; using WireMock.Admin.Mappings;
using WireMock.Admin.Requests;
using WireMock.Admin.Scenarios; using WireMock.Admin.Scenarios;
using WireMock.Admin.Settings; using WireMock.Admin.Settings;
using WireMock.Constants; using WireMock.Constants;
@@ -93,6 +94,18 @@ public partial class WireMockServer
Given(Request.Create().WithPath(_adminPaths.RequestsGuidPathMatcher).UsingGet()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestGet)); 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)); 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 // __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").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)); Given(Request.Create().WithPath(_adminPaths.Requests + "/find").UsingGet().WithParam("mappingGuid", new NotNullOrEmptyMatcher())).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestsFindByMappingGuid));
@@ -641,6 +654,22 @@ public partial class WireMockServer
#region Requests/find #region Requests/find
private IResponseMessage RequestsFind(HttpContext _, IRequestMessage requestMessage) 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<ILogEntry, RequestMatchResult> FindLogEntriesByRequestModel(IRequestMessage requestMessage)
{ {
var requestModel = DeserializeObject<RequestModel>(requestMessage); var requestModel = DeserializeObject<RequestModel>(requestMessage);
@@ -656,10 +685,7 @@ public partial class WireMockServer
} }
} }
var logEntryMapper = new LogEntryMapper(_options); return dict;
var result = dict.OrderBy(x => x.Value.AverageTotalScore).Select(x => x.Key).Select(logEntryMapper.Map);
return ToJson(result);
} }
private IResponseMessage RequestsFindByMappingGuid(HttpContext _, IRequestMessage requestMessage) private IResponseMessage RequestsFindByMappingGuid(HttpContext _, IRequestMessage requestMessage)
@@ -679,6 +705,65 @@ public partial class WireMockServer
} }
#endregion Requests/find #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 #region Scenarios
private IResponseMessage ScenariosGet(HttpContext _, IRequestMessage requestMessage) private IResponseMessage ScenariosGet(HttpContext _, IRequestMessage requestMessage)
{ {
@@ -7,6 +7,8 @@ using Stef.Validation;
using WireMock.Logging; using WireMock.Logging;
using WireMock.Matchers; using WireMock.Matchers;
using WireMock.Matchers.Request; using WireMock.Matchers.Request;
using WireMock.Owin;
using WireMock.Services;
namespace WireMock.Server; namespace WireMock.Server;
@@ -57,6 +59,68 @@ public partial class WireMockServer
.ToArray(); .ToArray();
} }
/// <inheritdoc />
[PublicAPI]
public IReadOnlyList<ILogEntry> FindUnmatchedLogEntries()
{
return LogEntries
.Where(le => le.RequestMessage != null && !_options.AdminPaths.Includes(le.RequestMessage.Path))
.Where(le => le.RequestMatchResult is not { IsPerfectMatch: true })
.ToArray();
}
/// <inheritdoc />
[PublicAPI]
public IReadOnlyList<NearMiss> FindNearMisses(IRequestMessage requestMessage, int maxResults = DefaultMaxNearMisses)
{
Guard.NotNull(requestMessage);
Guard.Condition(maxResults, m => m > 0);
return FindNearMisses(requestMessage, null, maxResults);
}
/// <inheritdoc />
[PublicAPI]
public IReadOnlyList<NearMiss> 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);
}
/// <inheritdoc />
[PublicAPI]
public IReadOnlyList<NearMiss> 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<NearMiss> 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();
}
/// <inheritdoc cref="IWireMockServer.ResetLogEntries" /> /// <inheritdoc cref="IWireMockServer.ResetLogEntries" />
[PublicAPI] [PublicAPI]
public void ResetLogEntries() public void ResetLogEntries()
@@ -421,6 +421,105 @@ paths:
schema: schema:
$ref: '#/components/schemas/StatusModel' $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: /__admin/requests/find:
post: post:
summary: Find logged requests matching a request filter summary: Find logged requests matching a request filter
@@ -895,6 +994,35 @@ components:
additionalProperties: true additionalProperties: true
description: Log Request Match Model 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: LogRequestModel:
type: object type: object
properties: properties:
@@ -250,6 +250,39 @@ public interface IWireMockAdminApi
[Get("requests/find")] [Get("requests/find")]
Task<IList<LogEntryModel>> FindRequestsByMappingGuidAsync([Query] Guid mappingGuid, CancellationToken cancellationToken = default); Task<IList<LogEntryModel>> FindRequestsByMappingGuidAsync([Query] Guid mappingGuid, CancellationToken cancellationToken = default);
/// <summary>
/// Count the requests which match the criteria (<see cref="RequestModel"/>).
/// </summary>
/// <param name="model">The RequestModel</param>
/// <param name="cancellationToken">The optional cancellationToken.</param>
[Post("requests/count")]
[Header("Content-Type", "application/json")]
Task<RequestCountModel> CountRequestsAsync([Body] RequestModel model, CancellationToken cancellationToken = default);
/// <summary>
/// Get the requests which did not fully match any mapping.
/// </summary>
/// <param name="cancellationToken">The optional cancellationToken.</param>
[Get("requests/unmatched")]
Task<IList<LogEntryModel>> GetUnmatchedRequestsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Get the near misses (the mappings which partially match) for all requests which did not fully match any mapping.
/// </summary>
/// <param name="maxResults">The maximum number of near misses per request. Default is 3.</param>
/// <param name="cancellationToken">The optional cancellationToken.</param>
[Get("requests/unmatched/near-misses")]
Task<IList<NearMissModel>> GetNearMissesForUnmatchedRequestsAsync([Query] int? maxResults = null, CancellationToken cancellationToken = default);
/// <summary>
/// Get the near misses (the mappings which partially match) for a request.
/// </summary>
/// <param name="guid">The Guid of the request</param>
/// <param name="maxResults">The maximum number of near misses. Default is 3.</param>
/// <param name="cancellationToken">The optional cancellationToken.</param>
[Get("requests/{guid}/near-misses")]
Task<IList<NearMissModel>> GetNearMissesForRequestAsync([Path] Guid guid, [Query] int? maxResults = null, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Get all scenarios /// Get all scenarios
/// </summary> /// </summary>
@@ -0,0 +1,230 @@
// Copyright © WireMock.Net
using System.Net;
using System.Net.Http;
using RestEase;
using WireMock.Admin.Mappings;
using WireMock.Client;
using WireMock.Logging;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;
namespace WireMock.Net.Tests.AdminApi;
public partial class WireMockAdminApiTests
{
private static readonly Guid OrdersPostMappingGuid = new("22222222-0000-0000-0000-000000000001");
private static readonly Guid OrdersGetMappingGuid = new("22222222-0000-0000-0000-000000000002");
[Fact]
public async Task IWireMockAdminApi_CountRequestsAsync()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
await httpClient.GetAsync("/orders", cancellationToken);
await httpClient.PostAsync("/orders", new StringContent("{}"), cancellationToken);
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var getCount = await api.CountRequestsAsync(new RequestModel { Methods = ["GET"] }, cancellationToken);
var deleteCount = await api.CountRequestsAsync(new RequestModel { Methods = ["DELETE"] }, cancellationToken);
// Assert
getCount.Count.Should().Be(2);
deleteCount.Count.Should().Be(0);
}
[Fact]
public async Task IWireMockAdminApi_GetUnmatchedRequestsAsync()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
await httpClient.GetAsync("/unknown", cancellationToken);
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var requests = await api.GetUnmatchedRequestsAsync(cancellationToken);
// Assert
requests.Should().ContainSingle();
requests[0].Request!.Path.Should().Be("/unknown");
requests[0].MappingGuid.Should().BeNull();
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForUnmatchedRequestsAsync()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.PutAsync("/orders", new StringContent("{}"), cancellationToken);
var logEntryGuid = server.LogEntries.Single().Guid;
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var nearMisses = await api.GetNearMissesForUnmatchedRequestsAsync(cancellationToken: cancellationToken);
// Assert
nearMisses.Should().HaveCount(2);
nearMisses.Select(nm => nm.MappingGuid).Should().BeEquivalentTo([OrdersPostMappingGuid, OrdersGetMappingGuid]);
nearMisses.Should().AllSatisfy(nm =>
{
nm.LogEntryGuid.Should().Be(logEntryGuid);
nm.Request!.Method.Should().Be("PUT");
nm.Request.Path.Should().Be("/orders");
nm.RequestMatchResult!.IsPerfectMatch.Should().BeFalse();
nm.RequestMatchResult.AverageTotalScore.Should().BeApproximately(0.5, 0.001);
});
nearMisses.Select(nm => nm.MappingTitle).Should().Contain("Create order");
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForUnmatchedRequestsAsync_WithMaxResults()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.PutAsync("/orders", new StringContent("{}"), cancellationToken);
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var nearMisses = await api.GetNearMissesForUnmatchedRequestsAsync(1, cancellationToken);
// Assert
nearMisses.Should().ContainSingle();
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForUnmatchedRequestsAsync_WithInvalidMaxResults_ReturnsBadRequest()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
// Act
var response = await httpClient.GetAsync("/__admin/requests/unmatched/near-misses?maxResults=0", cancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForRequestAsync_ShouldExcludeMatchedMapping()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
var logEntry = server.LogEntries.Single();
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var nearMisses = await api.GetNearMissesForRequestAsync(logEntry.Guid, cancellationToken: cancellationToken);
// Assert
logEntry.MappingGuid.Should().Be(OrdersGetMappingGuid);
nearMisses.Should().ContainSingle();
nearMisses[0].MappingGuid.Should().Be(OrdersPostMappingGuid);
nearMisses[0].LogEntryGuid.Should().Be(logEntry.Guid);
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForRequestAsync_UnknownRequest_ReturnsNotFound()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
Func<Task> act = () => api.GetNearMissesForRequestAsync(Guid.NewGuid(), cancellationToken: cancellationToken);
// Assert
(await act.Should().ThrowAsync<ApiException>()).Which.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForRequestAsync_WithMultiplePerfectMatches_ShouldNotReturnPerfectMatches()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
server
.Given(Request.Create().WithPath("/orders").UsingGet())
.AtPriority(100)
.WithTitle("Get orders (fallback)")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
var logEntry = server.LogEntries.Single();
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var nearMisses = await api.GetNearMissesForRequestAsync(logEntry.Guid, cancellationToken: cancellationToken);
// Assert
nearMisses.Should().ContainSingle();
nearMisses[0].MappingGuid.Should().Be(OrdersPostMappingGuid);
}
[Fact]
public async Task IWireMockAdminApi_CountRequestsAsync_ShouldNotCountAdminRequests()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
await httpClient.GetAsync("/__admin/mappings", cancellationToken);
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var count = await api.CountRequestsAsync(new RequestModel { Methods = ["GET"] }, cancellationToken);
// Assert
count.Count.Should().Be(1);
}
private static WireMockServer StartServerWithOrdersMappings()
{
var server = WireMockServer.Start(new WireMockServerSettings
{
StartAdminInterface = true,
Logger = new WireMockNullLogger()
});
server
.Given(Request.Create().WithPath("/orders").UsingPost())
.WithGuid(OrdersPostMappingGuid)
.WithTitle("Create order")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.Created));
server
.Given(Request.Create().WithPath("/orders").UsingGet())
.WithGuid(OrdersGetMappingGuid)
.WithTitle("Get orders")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
return server;
}
}
+1 -1
View File
@@ -6,5 +6,5 @@ internal static class Constants
{ {
internal const int NumStaticMappings = 10; internal const int NumStaticMappings = 10;
internal const int NumAdminMappings = 39; internal const int NumAdminMappings = 43;
} }
@@ -259,6 +259,96 @@ public class MappingMatcherTests
mappingMock.Verify(m => m.GetRequestMatchResult(It.IsAny<RequestMessage>(), It.IsAny<string>()), Times.Never); mappingMock.Verify(m => m.GetRequestMatchResult(It.IsAny<RequestMessage>(), It.IsAny<string>()), Times.Never);
} }
[Fact]
public void MappingMatcher_FindNearMisses_ShouldExcludePerfectMatchesAndMismatches_AndOrderByBestMatchFirst()
{
// Assign
var perfect = Guid.Parse("00000000-0000-0000-0000-000000000001");
var mismatch = Guid.Parse("00000000-0000-0000-0000-000000000002");
var low = Guid.Parse("00000000-0000-0000-0000-000000000003");
var high = Guid.Parse("00000000-0000-0000-0000-000000000004");
var mappings = InitMappings
(
(perfect, [1.0], null),
(mismatch, [0.0], null),
(low, [0.4], null),
(high, [0.8], null)
);
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
// Act
var result = _sut.FindNearMisses(request, 10);
// Assert
result.Select(r => r.Mapping.Guid).Should().Equal(high, low);
}
[Fact]
public void MappingMatcher_FindNearMisses_ShouldRespectMaxResults()
{
// Assign
var low = Guid.Parse("00000000-0000-0000-0000-000000000001");
var high = Guid.Parse("00000000-0000-0000-0000-000000000002");
var mappings = InitMappings
(
(low, [0.4], null),
(high, [0.8], null)
);
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
// Act
var result = _sut.FindNearMisses(request, 1);
// Assert
result.Should().ContainSingle().Which.Mapping.Guid.Should().Be(high);
}
[Fact]
public void MappingMatcher_FindNearMisses_ShouldIgnoreProbability()
{
// Assign
var withProbability = Guid.Parse("00000000-0000-0000-0000-000000000001");
var mappings = InitMappings
(
(withProbability, [0.5], 0.1)
);
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
// Act
var result = _sut.FindNearMisses(request, 3);
// Assert
result.Should().ContainSingle().Which.Mapping.Guid.Should().Be(withProbability);
_randomizerDoubleBetween0And1Mock.Verify(r => r.Generate(), Times.Never);
}
[Fact]
public void MappingMatcher_FindNearMisses_WhenMappingThrowsException_ShouldSkipMapping()
{
// Assign
var mappingMock = new Mock<IMapping>();
mappingMock.SetupGet(m => m.IsDisabled).Returns(false);
mappingMock.Setup(m => m.GetRequestMatchResult(It.IsAny<IRequestMessage>(), It.IsAny<string>())).Throws<Exception>();
var mappings = new ConcurrentDictionary<Guid, IMapping>();
mappings.TryAdd(Guid.NewGuid(), mappingMock.Object);
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
// Act
var result = _sut.FindNearMisses(request, 3);
// Assert
result.Should().BeEmpty();
}
private static ConcurrentDictionary<Guid, IMapping> InitMappings(params (Guid guid, double[] scores, double? probability)[] matches) private static ConcurrentDictionary<Guid, IMapping> InitMappings(params (Guid guid, double[] scores, double? probability)[] matches)
{ {
var mappings = new ConcurrentDictionary<Guid, IMapping>(); var mappings = new ConcurrentDictionary<Guid, IMapping>();
@@ -442,8 +442,9 @@ public class StatefulBehaviorTests
// Act: fire request 1 but don't await it yet — it will sit in a 500 ms delay // 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); var request1Task = client.GetStringAsync(server.Url + path, cancellationToken);
// Give the server a moment to match & transition state before the delay completes // Wait until the server has matched request 1 & transitioned the state; request 1 must still be delayed
await Task.Delay(100, cancellationToken); await WaitForScenarioStateAsync(server, "1260", "State1", cancellationToken);
request1Task.IsCompleted.Should().BeFalse();
// Request 2 is sent while request 1 is still being delayed. // 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. // After the fix the state has already transitioned, so request 2 matches Mapping 2.
@@ -481,7 +482,8 @@ public class StatefulBehaviorTests
// Act // Act
var request1Task = client.GetStringAsync(server.Url + path, cancellationToken); 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 response2 = await client.GetStringAsync(server.Url + path, cancellationToken);
var response1 = await request1Task; var response1 = await request1Task;
@@ -574,5 +576,20 @@ public class StatefulBehaviorTests
server.Stop(); server.Stop();
} }
/// <summary>
/// Polls the scenario state instead of using a fixed delay, so tests do not depend on how fast the server handles the first request
/// (a cold server can take longer than a fixed delay to match the request and transition the state).
/// </summary>
private static async Task WaitForScenarioStateAsync(WireMockServer server, string scenario, string expectedState, CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
while (server.Scenarios.FirstOrDefault(s => s.Name == scenario)?.NextState != expectedState)
{
await Task.Delay(10, timeout.Token);
}
}
} }
@@ -0,0 +1,226 @@
// Copyright © WireMock.Net
using System.Net;
using WireMock.Models;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
namespace WireMock.Net.Tests;
public partial class WireMockServerTests
{
private static readonly Guid NearMissPostGuid = new("11111111-0000-0000-0000-000000000001");
private static readonly Guid NearMissGetGuid = new("11111111-0000-0000-0000-000000000002");
private static readonly Guid NearMissOtherGuid = new("11111111-0000-0000-0000-000000000003");
[Fact]
public async Task WireMockServer_FindUnmatchedLogEntries_ShouldOnlyReturnRequestsWithoutMatchingMapping()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
await client.GetAsync("/orders", cancellationToken);
await client.GetAsync("/order", cancellationToken);
// Act
var unmatched = server.FindUnmatchedLogEntries();
// Assert
unmatched.Should().ContainSingle();
unmatched[0].RequestMessage!.Path.Should().Be("/order");
unmatched[0].ResponseMessage!.StatusCode.Should().Be((int)HttpStatusCode.NotFound);
}
[Fact]
public async Task WireMockServer_FindNearMisses_ShouldReturnClosestMappingsFirst()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
// Path matches the POST and GET mappings, but the method is wrong for both.
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
var requestMessage = server.LogEntries.Single().RequestMessage!;
// Act
var nearMisses = server.FindNearMisses(requestMessage);
// Assert
nearMisses.Should().HaveCount(2);
nearMisses.Select(nm => nm.MappingGuid).Should().BeEquivalentTo([NearMissPostGuid, NearMissGetGuid]);
nearMisses.Should().AllSatisfy(nm =>
{
nm.LogEntryGuid.Should().BeNull();
nm.RequestMessage.Should().BeSameAs(requestMessage);
nm.RequestMatchResult.IsPerfectMatch.Should().BeFalse();
nm.RequestMatchResult.MatchDetails.Should().Contain(md => md.Name == "MethodMatcher" && md.Score < 1.0);
});
}
[Fact]
public async Task WireMockServer_FindNearMisses_ShouldRespectMaxResults()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
// Act
var nearMisses = server.FindNearMisses(server.LogEntries.Single().RequestMessage!, maxResults: 1);
// Assert
nearMisses.Should().ContainSingle();
}
[Fact]
public async Task WireMockServer_FindNearMisses_ShouldIgnoreDisabledMappings()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
server.Mappings.Single(m => m.Guid == NearMissPostGuid).IsDisabled = true;
using var client = server.CreateClient();
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
// Act
var nearMisses = server.FindNearMisses(server.LogEntries.Single().RequestMessage!);
// Assert
nearMisses.Select(nm => nm.MappingGuid).Should().NotContain(NearMissPostGuid);
}
[Fact]
public void WireMockServer_FindNearMisses_WithInvalidMaxResults_ShouldThrow()
{
// Arrange
using var server = WireMockServer.Start();
var requestMessage = new RequestMessage(new UrlDetails("http://localhost/orders"), "GET", "::1");
// Act
Action act = () => server.FindNearMisses(requestMessage, maxResults: 0);
// Assert
act.Should().Throw<ArgumentException>();
}
[Fact]
public async Task WireMockServer_FindNearMissesForUnmatchedRequests_ShouldReturnNearMissesPerUnmatchedRequest()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
await client.GetAsync("/orders", cancellationToken);
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
await client.DeleteAsync("/customers", cancellationToken);
var unmatchedGuids = server.FindUnmatchedLogEntries().Select(le => le.Guid).ToArray();
// Act
var nearMisses = server.FindNearMissesForUnmatchedRequests(maxResultsPerRequest: 1);
// Assert
unmatchedGuids.Should().HaveCount(2);
nearMisses.Should().HaveCount(2);
nearMisses.Select(nm => nm.LogEntryGuid!.Value).Should().BeEquivalentTo(unmatchedGuids);
nearMisses.Single(nm => nm.RequestMessage.Path == "/customers").MappingGuid.Should().Be(NearMissOtherGuid);
}
[Fact]
public async Task WireMockServer_FindNearMisses_ShouldNotReturnPerfectMatches()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
server
.Given(Request.Create().WithPath("/orders").UsingGet())
.AtPriority(100)
.WithTitle("Get orders (fallback)")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
using var client = server.CreateClient();
await client.GetAsync("/orders", cancellationToken);
// Act
var nearMisses = server.FindNearMisses(server.LogEntries.Single().RequestMessage!);
// Assert
nearMisses.Should().ContainSingle();
nearMisses[0].MappingGuid.Should().Be(NearMissPostGuid);
}
[Fact]
public async Task WireMockServer_FindNearMisses_ForLogEntry_ShouldSetLogEntryGuid()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
var logEntry = server.LogEntries.Single();
// Act
var nearMisses = server.FindNearMisses(logEntry, maxResults: 1);
// Assert
nearMisses.Should().ContainSingle();
nearMisses[0].LogEntryGuid.Should().Be(logEntry.Guid);
nearMisses[0].RequestMessage.Should().BeSameAs(logEntry.RequestMessage);
}
[Fact]
public async Task WireMockServer_FindUnmatchedLogEntries_WithAllowPartialMapping_ShouldIncludeRequestsServedByAPartialMatch()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
server.AllowPartialMapping();
using var client = server.CreateClient();
await client.GetAsync("/orders", cancellationToken);
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
// Act
var unmatched = server.FindUnmatchedLogEntries();
var nearMisses = server.FindNearMissesForUnmatchedRequests();
// Assert
unmatched.Should().ContainSingle();
unmatched[0].RequestMessage!.Method.Should().Be("PUT");
unmatched[0].MappingGuid.Should().NotBeNull("the request was served by a partial match");
nearMisses.Select(nm => nm.MappingGuid).Should().Contain(unmatched[0].MappingGuid!.Value);
}
private static WireMockServer StartServerWithNearMissMappings()
{
var server = WireMockServer.Start();
server
.Given(Request.Create().WithPath("/orders").UsingPost())
.WithGuid(NearMissPostGuid)
.WithTitle("Create order")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.Created));
server
.Given(Request.Create().WithPath("/orders").UsingGet())
.WithGuid(NearMissGetGuid)
.WithTitle("Get orders")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
server
.Given(Request.Create().WithPath("/customers").UsingDelete().WithHeader("X-Api-Key", "secret"))
.WithGuid(NearMissOtherGuid)
.WithTitle("Delete customers")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.NoContent));
return server;
}
}