mirror of
https://github.com/wiremock/WireMock.Net.git
synced 2026-11-13 12:23:42 +01:00
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:
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>
|
||||
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>
|
||||
/// 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 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$");
|
||||
|
||||
@@ -24,6 +24,8 @@ internal interface IAdminPaths
|
||||
|
||||
RegexMatcher RequestsGuidPathMatcher { get; }
|
||||
|
||||
RegexMatcher RequestsGuidNearMissesPathMatcher { get; }
|
||||
|
||||
RegexMatcher ScenariosNameMatcher { get; }
|
||||
|
||||
RegexMatcher ScenariosNameWithStateMatcher { get; }
|
||||
|
||||
@@ -5,4 +5,6 @@ namespace WireMock.Owin;
|
||||
internal interface IMappingMatcher
|
||||
{
|
||||
(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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
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 @@ 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)
|
||||
{
|
||||
switch (logEntry.ResponseMessage?.BodyData?.DetectedBodyType)
|
||||
|
||||
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http;
|
||||
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 @@ public partial class WireMockServer
|
||||
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 @@ public partial class WireMockServer
|
||||
|
||||
#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<ILogEntry, RequestMatchResult> FindLogEntriesByRequestModel(IRequestMessage requestMessage)
|
||||
{
|
||||
var requestModel = DeserializeObject<RequestModel>(requestMessage);
|
||||
|
||||
@@ -656,10 +685,7 @@ public partial class WireMockServer
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ public partial class WireMockServer
|
||||
}
|
||||
#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)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,8 @@ using Stef.Validation;
|
||||
using WireMock.Logging;
|
||||
using WireMock.Matchers;
|
||||
using WireMock.Matchers.Request;
|
||||
using WireMock.Owin;
|
||||
using WireMock.Services;
|
||||
|
||||
namespace WireMock.Server;
|
||||
|
||||
@@ -57,6 +59,68 @@ public partial class WireMockServer
|
||||
.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" />
|
||||
[PublicAPI]
|
||||
public void ResetLogEntries()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -250,6 +250,39 @@ public interface IWireMockAdminApi
|
||||
[Get("requests/find")]
|
||||
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>
|
||||
/// Get all scenarios
|
||||
/// </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;
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,5 @@ internal static class Constants
|
||||
{
|
||||
internal const int NumStaticMappings = 10;
|
||||
|
||||
internal const int NumAdminMappings = 39;
|
||||
internal const int NumAdminMappings = 43;
|
||||
}
|
||||
@@ -259,6 +259,96 @@ public class MappingMatcherTests
|
||||
mappingMock.Verify(m => m.GetRequestMatchResult(It.IsAny<RequestMessage>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MappingMatcher_FindNearMisses_ShouldExcludePerfectMatchesAndMismatches_AndOrderByBestMatchFirst()
|
||||
{
|
||||
// Assign
|
||||
var perfect = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
var mismatch = Guid.Parse("00000000-0000-0000-0000-000000000002");
|
||||
var low = Guid.Parse("00000000-0000-0000-0000-000000000003");
|
||||
var high = Guid.Parse("00000000-0000-0000-0000-000000000004");
|
||||
var mappings = InitMappings
|
||||
(
|
||||
(perfect, [1.0], null),
|
||||
(mismatch, [0.0], null),
|
||||
(low, [0.4], null),
|
||||
(high, [0.8], null)
|
||||
);
|
||||
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
|
||||
|
||||
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
|
||||
|
||||
// Act
|
||||
var result = _sut.FindNearMisses(request, 10);
|
||||
|
||||
// Assert
|
||||
result.Select(r => r.Mapping.Guid).Should().Equal(high, low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MappingMatcher_FindNearMisses_ShouldRespectMaxResults()
|
||||
{
|
||||
// Assign
|
||||
var low = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
var high = Guid.Parse("00000000-0000-0000-0000-000000000002");
|
||||
var mappings = InitMappings
|
||||
(
|
||||
(low, [0.4], null),
|
||||
(high, [0.8], null)
|
||||
);
|
||||
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
|
||||
|
||||
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
|
||||
|
||||
// Act
|
||||
var result = _sut.FindNearMisses(request, 1);
|
||||
|
||||
// Assert
|
||||
result.Should().ContainSingle().Which.Mapping.Guid.Should().Be(high);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MappingMatcher_FindNearMisses_ShouldIgnoreProbability()
|
||||
{
|
||||
// Assign
|
||||
var withProbability = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
var mappings = InitMappings
|
||||
(
|
||||
(withProbability, [0.5], 0.1)
|
||||
);
|
||||
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
|
||||
|
||||
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
|
||||
|
||||
// Act
|
||||
var result = _sut.FindNearMisses(request, 3);
|
||||
|
||||
// Assert
|
||||
result.Should().ContainSingle().Which.Mapping.Guid.Should().Be(withProbability);
|
||||
_randomizerDoubleBetween0And1Mock.Verify(r => r.Generate(), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MappingMatcher_FindNearMisses_WhenMappingThrowsException_ShouldSkipMapping()
|
||||
{
|
||||
// Assign
|
||||
var mappingMock = new Mock<IMapping>();
|
||||
mappingMock.SetupGet(m => m.IsDisabled).Returns(false);
|
||||
mappingMock.Setup(m => m.GetRequestMatchResult(It.IsAny<IRequestMessage>(), It.IsAny<string>())).Throws<Exception>();
|
||||
|
||||
var mappings = new ConcurrentDictionary<Guid, IMapping>();
|
||||
mappings.TryAdd(Guid.NewGuid(), mappingMock.Object);
|
||||
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
|
||||
|
||||
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
|
||||
|
||||
// Act
|
||||
var result = _sut.FindNearMisses(request, 3);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private static ConcurrentDictionary<Guid, IMapping> InitMappings(params (Guid guid, double[] scores, double? probability)[] matches)
|
||||
{
|
||||
var mappings = new ConcurrentDictionary<Guid, IMapping>();
|
||||
|
||||
@@ -442,8 +442,9 @@ public class StatefulBehaviorTests
|
||||
// Act: fire request 1 but don't await it yet — it will sit in a 500 ms delay
|
||||
var request1Task = client.GetStringAsync(server.Url + path, cancellationToken);
|
||||
|
||||
// Give the server a moment to match & transition state before the delay completes
|
||||
await Task.Delay(100, cancellationToken);
|
||||
// Wait until the server has matched request 1 & transitioned the state; request 1 must still be delayed
|
||||
await WaitForScenarioStateAsync(server, "1260", "State1", cancellationToken);
|
||||
request1Task.IsCompleted.Should().BeFalse();
|
||||
|
||||
// Request 2 is sent while request 1 is still being delayed.
|
||||
// After the fix the state has already transitioned, so request 2 matches Mapping 2.
|
||||
@@ -481,7 +482,8 @@ public class StatefulBehaviorTests
|
||||
|
||||
// Act
|
||||
var request1Task = client.GetStringAsync(server.Url + path, cancellationToken);
|
||||
await Task.Delay(100, cancellationToken);
|
||||
await WaitForScenarioStateAsync(server, "s", "State1", cancellationToken);
|
||||
request1Task.IsCompleted.Should().BeFalse();
|
||||
var response2 = await client.GetStringAsync(server.Url + path, cancellationToken);
|
||||
var response1 = await request1Task;
|
||||
|
||||
@@ -574,5 +576,20 @@ public class StatefulBehaviorTests
|
||||
|
||||
server.Stop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls the scenario state instead of using a fixed delay, so tests do not depend on how fast the server handles the first request
|
||||
/// (a cold server can take longer than a fixed delay to match the request and transition the state).
|
||||
/// </summary>
|
||||
private static async Task WaitForScenarioStateAsync(WireMockServer server, string scenario, string expectedState, CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
|
||||
while (server.Scenarios.FirstOrDefault(s => s.Name == scenario)?.NextState != expectedState)
|
||||
{
|
||||
await Task.Delay(10, timeout.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Copyright © WireMock.Net
|
||||
|
||||
using System.Net;
|
||||
using WireMock.Models;
|
||||
using WireMock.RequestBuilders;
|
||||
using WireMock.ResponseBuilders;
|
||||
using WireMock.Server;
|
||||
|
||||
namespace WireMock.Net.Tests;
|
||||
|
||||
public partial class WireMockServerTests
|
||||
{
|
||||
private static readonly Guid NearMissPostGuid = new("11111111-0000-0000-0000-000000000001");
|
||||
private static readonly Guid NearMissGetGuid = new("11111111-0000-0000-0000-000000000002");
|
||||
private static readonly Guid NearMissOtherGuid = new("11111111-0000-0000-0000-000000000003");
|
||||
|
||||
[Fact]
|
||||
public async Task WireMockServer_FindUnmatchedLogEntries_ShouldOnlyReturnRequestsWithoutMatchingMapping()
|
||||
{
|
||||
// Arrange
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
using var server = StartServerWithNearMissMappings();
|
||||
using var client = server.CreateClient();
|
||||
|
||||
await client.GetAsync("/orders", cancellationToken);
|
||||
await client.GetAsync("/order", cancellationToken);
|
||||
|
||||
// Act
|
||||
var unmatched = server.FindUnmatchedLogEntries();
|
||||
|
||||
// Assert
|
||||
unmatched.Should().ContainSingle();
|
||||
unmatched[0].RequestMessage!.Path.Should().Be("/order");
|
||||
unmatched[0].ResponseMessage!.StatusCode.Should().Be((int)HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WireMockServer_FindNearMisses_ShouldReturnClosestMappingsFirst()
|
||||
{
|
||||
// Arrange
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
using var server = StartServerWithNearMissMappings();
|
||||
using var client = server.CreateClient();
|
||||
|
||||
// Path matches the POST and GET mappings, but the method is wrong for both.
|
||||
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
|
||||
var requestMessage = server.LogEntries.Single().RequestMessage!;
|
||||
|
||||
// Act
|
||||
var nearMisses = server.FindNearMisses(requestMessage);
|
||||
|
||||
// Assert
|
||||
nearMisses.Should().HaveCount(2);
|
||||
nearMisses.Select(nm => nm.MappingGuid).Should().BeEquivalentTo([NearMissPostGuid, NearMissGetGuid]);
|
||||
nearMisses.Should().AllSatisfy(nm =>
|
||||
{
|
||||
nm.LogEntryGuid.Should().BeNull();
|
||||
nm.RequestMessage.Should().BeSameAs(requestMessage);
|
||||
nm.RequestMatchResult.IsPerfectMatch.Should().BeFalse();
|
||||
nm.RequestMatchResult.MatchDetails.Should().Contain(md => md.Name == "MethodMatcher" && md.Score < 1.0);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WireMockServer_FindNearMisses_ShouldRespectMaxResults()
|
||||
{
|
||||
// Arrange
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
using var server = StartServerWithNearMissMappings();
|
||||
using var client = server.CreateClient();
|
||||
|
||||
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
|
||||
|
||||
// Act
|
||||
var nearMisses = server.FindNearMisses(server.LogEntries.Single().RequestMessage!, maxResults: 1);
|
||||
|
||||
// Assert
|
||||
nearMisses.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WireMockServer_FindNearMisses_ShouldIgnoreDisabledMappings()
|
||||
{
|
||||
// Arrange
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
using var server = StartServerWithNearMissMappings();
|
||||
server.Mappings.Single(m => m.Guid == NearMissPostGuid).IsDisabled = true;
|
||||
using var client = server.CreateClient();
|
||||
|
||||
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
|
||||
|
||||
// Act
|
||||
var nearMisses = server.FindNearMisses(server.LogEntries.Single().RequestMessage!);
|
||||
|
||||
// Assert
|
||||
nearMisses.Select(nm => nm.MappingGuid).Should().NotContain(NearMissPostGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireMockServer_FindNearMisses_WithInvalidMaxResults_ShouldThrow()
|
||||
{
|
||||
// Arrange
|
||||
using var server = WireMockServer.Start();
|
||||
var requestMessage = new RequestMessage(new UrlDetails("http://localhost/orders"), "GET", "::1");
|
||||
|
||||
// Act
|
||||
Action act = () => server.FindNearMisses(requestMessage, maxResults: 0);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WireMockServer_FindNearMissesForUnmatchedRequests_ShouldReturnNearMissesPerUnmatchedRequest()
|
||||
{
|
||||
// Arrange
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
using var server = StartServerWithNearMissMappings();
|
||||
using var client = server.CreateClient();
|
||||
|
||||
await client.GetAsync("/orders", cancellationToken);
|
||||
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
|
||||
await client.DeleteAsync("/customers", cancellationToken);
|
||||
var unmatchedGuids = server.FindUnmatchedLogEntries().Select(le => le.Guid).ToArray();
|
||||
|
||||
// Act
|
||||
var nearMisses = server.FindNearMissesForUnmatchedRequests(maxResultsPerRequest: 1);
|
||||
|
||||
// Assert
|
||||
unmatchedGuids.Should().HaveCount(2);
|
||||
nearMisses.Should().HaveCount(2);
|
||||
nearMisses.Select(nm => nm.LogEntryGuid!.Value).Should().BeEquivalentTo(unmatchedGuids);
|
||||
nearMisses.Single(nm => nm.RequestMessage.Path == "/customers").MappingGuid.Should().Be(NearMissOtherGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WireMockServer_FindNearMisses_ShouldNotReturnPerfectMatches()
|
||||
{
|
||||
// Arrange
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
using var server = StartServerWithNearMissMappings();
|
||||
server
|
||||
.Given(Request.Create().WithPath("/orders").UsingGet())
|
||||
.AtPriority(100)
|
||||
.WithTitle("Get orders (fallback)")
|
||||
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
|
||||
using var client = server.CreateClient();
|
||||
|
||||
await client.GetAsync("/orders", cancellationToken);
|
||||
|
||||
// Act
|
||||
var nearMisses = server.FindNearMisses(server.LogEntries.Single().RequestMessage!);
|
||||
|
||||
// Assert
|
||||
nearMisses.Should().ContainSingle();
|
||||
nearMisses[0].MappingGuid.Should().Be(NearMissPostGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WireMockServer_FindNearMisses_ForLogEntry_ShouldSetLogEntryGuid()
|
||||
{
|
||||
// Arrange
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
using var server = StartServerWithNearMissMappings();
|
||||
using var client = server.CreateClient();
|
||||
|
||||
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
|
||||
var logEntry = server.LogEntries.Single();
|
||||
|
||||
// Act
|
||||
var nearMisses = server.FindNearMisses(logEntry, maxResults: 1);
|
||||
|
||||
// Assert
|
||||
nearMisses.Should().ContainSingle();
|
||||
nearMisses[0].LogEntryGuid.Should().Be(logEntry.Guid);
|
||||
nearMisses[0].RequestMessage.Should().BeSameAs(logEntry.RequestMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WireMockServer_FindUnmatchedLogEntries_WithAllowPartialMapping_ShouldIncludeRequestsServedByAPartialMatch()
|
||||
{
|
||||
// Arrange
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
using var server = StartServerWithNearMissMappings();
|
||||
server.AllowPartialMapping();
|
||||
using var client = server.CreateClient();
|
||||
|
||||
await client.GetAsync("/orders", cancellationToken);
|
||||
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
|
||||
|
||||
// Act
|
||||
var unmatched = server.FindUnmatchedLogEntries();
|
||||
var nearMisses = server.FindNearMissesForUnmatchedRequests();
|
||||
|
||||
// Assert
|
||||
unmatched.Should().ContainSingle();
|
||||
unmatched[0].RequestMessage!.Method.Should().Be("PUT");
|
||||
unmatched[0].MappingGuid.Should().NotBeNull("the request was served by a partial match");
|
||||
nearMisses.Select(nm => nm.MappingGuid).Should().Contain(unmatched[0].MappingGuid!.Value);
|
||||
}
|
||||
|
||||
private static WireMockServer StartServerWithNearMissMappings()
|
||||
{
|
||||
var server = WireMockServer.Start();
|
||||
|
||||
server
|
||||
.Given(Request.Create().WithPath("/orders").UsingPost())
|
||||
.WithGuid(NearMissPostGuid)
|
||||
.WithTitle("Create order")
|
||||
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.Created));
|
||||
|
||||
server
|
||||
.Given(Request.Create().WithPath("/orders").UsingGet())
|
||||
.WithGuid(NearMissGetGuid)
|
||||
.WithTitle("Get orders")
|
||||
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
|
||||
|
||||
server
|
||||
.Given(Request.Create().WithPath("/customers").UsingDelete().WithHeader("X-Api-Key", "secret"))
|
||||
.WithGuid(NearMissOtherGuid)
|
||||
.WithTitle("Delete customers")
|
||||
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.NoContent));
|
||||
|
||||
return server;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user