From f919929cb7ec45a8acb2c88759d39688e20e1d70 Mon Sep 17 00:00:00 2001 From: m4tchl0ck Date: Wed, 25 Mar 2026 13:04:44 +0100 Subject: [PATCH] Add injectable IScenarioStateStore for distributed scenario state (#1430) * Move ScenarioState to Abstractions and add IScenarioStateStore interface ScenarioState is moved to the Abstractions project so it can be referenced by the new IScenarioStateStore interface. The interface defines the contract for storing and retrieving scenario states, enabling distributed implementations. * Add InMemoryScenarioStateStore default implementation Wraps ConcurrentDictionary with OrdinalIgnoreCase comparer, preserving exact current behavior. The Update method encapsulates read-modify-write so distributed implementations can make it atomic. * Wire IScenarioStateStore into middleware options, settings, and consumers Replace direct ConcurrentDictionary usage with IScenarioStateStore across all consumer files. The store is injectable via WireMockServerSettings.ScenarioStateStore, defaulting to the InMemoryScenarioStateStore for backward compatibility. * Add FileBasedScenarioStateStore for persistent scenario state In-memory ConcurrentDictionary backed by JSON file persistence in __admin/scenarios/. Reads from cache, mutations write through to disk. Constructor loads existing state from disk on startup. * Make ScenarioStateStore non-nullable with default InMemoryScenarioStateStore Move InMemoryScenarioStateStore from WireMock.Net.Minimal to WireMock.Net.Shared so it lives alongside WireMockServerSettings. This allows WireMockServerSettings.ScenarioStateStore to be non-nullable with a default value, following the same pattern as DefaultJsonSerializer. The null-coalescing fallback in WireMockMiddlewareOptionsHelper is no longer needed. --- .../Handlers/IScenarioStateStore.cs | 26 + .../ScenarioState.cs | 2 +- .../Handlers/FileBasedScenarioStateStore.cs | 131 ++ .../Owin/IWireMockMiddlewareOptions.cs | 2 +- .../Owin/MappingMatcher.cs | 7 +- .../Owin/WireMockMiddleware.cs | 33 +- .../Owin/WireMockMiddlewareOptions.cs | 2 +- .../Owin/WireMockMiddlewareOptionsHelper.cs | 1 + .../Server/WireMockServer.Admin.cs | 4 +- .../Server/WireMockServer.cs | 1518 ++++++++--------- .../Handlers/InMemoryScenarioStateStore.cs | 60 + .../Settings/WireMockServerSettings.cs | 4 + .../FileBasedScenarioStateStoreTests.cs | 273 +++ .../InMemoryScenarioStateStoreTests.cs | 157 ++ .../Owin/MappingMatcherTests.cs | 3 +- .../Owin/WireMockMiddlewareTests.cs | 2 +- .../StatefulBehaviorTests.cs | 30 +- 17 files changed, 1454 insertions(+), 801 deletions(-) create mode 100644 src/WireMock.Net.Abstractions/Handlers/IScenarioStateStore.cs rename src/{WireMock.Net.Minimal => WireMock.Net.Abstractions}/ScenarioState.cs (99%) create mode 100644 src/WireMock.Net.Minimal/Handlers/FileBasedScenarioStateStore.cs create mode 100644 src/WireMock.Net.Shared/Handlers/InMemoryScenarioStateStore.cs create mode 100644 test/WireMock.Net.Tests/Handlers/FileBasedScenarioStateStoreTests.cs create mode 100644 test/WireMock.Net.Tests/Handlers/InMemoryScenarioStateStoreTests.cs diff --git a/src/WireMock.Net.Abstractions/Handlers/IScenarioStateStore.cs b/src/WireMock.Net.Abstractions/Handlers/IScenarioStateStore.cs new file mode 100644 index 00000000..e39e9da3 --- /dev/null +++ b/src/WireMock.Net.Abstractions/Handlers/IScenarioStateStore.cs @@ -0,0 +1,26 @@ +// Copyright © WireMock.Net + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace WireMock.Handlers; + +public interface IScenarioStateStore +{ + bool TryGet(string name, [NotNullWhen(true)] out ScenarioState? state); + + IReadOnlyList GetAll(); + + bool ContainsKey(string name); + + bool TryAdd(string name, ScenarioState scenarioState); + + ScenarioState AddOrUpdate(string name, Func addFactory, Func updateFactory); + + ScenarioState? Update(string name, Action updateAction); + + bool TryRemove(string name); + + void Clear(); +} diff --git a/src/WireMock.Net.Minimal/ScenarioState.cs b/src/WireMock.Net.Abstractions/ScenarioState.cs similarity index 99% rename from src/WireMock.Net.Minimal/ScenarioState.cs rename to src/WireMock.Net.Abstractions/ScenarioState.cs index fa8da709..86387faa 100644 --- a/src/WireMock.Net.Minimal/ScenarioState.cs +++ b/src/WireMock.Net.Abstractions/ScenarioState.cs @@ -31,4 +31,4 @@ public class ScenarioState /// Gets or sets the state counter. /// public int Counter { get; set; } -} \ No newline at end of file +} diff --git a/src/WireMock.Net.Minimal/Handlers/FileBasedScenarioStateStore.cs b/src/WireMock.Net.Minimal/Handlers/FileBasedScenarioStateStore.cs new file mode 100644 index 00000000..53c57b41 --- /dev/null +++ b/src/WireMock.Net.Minimal/Handlers/FileBasedScenarioStateStore.cs @@ -0,0 +1,131 @@ +// Copyright © WireMock.Net + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using Newtonsoft.Json; + +namespace WireMock.Handlers; + +public class FileBasedScenarioStateStore : IScenarioStateStore +{ + private readonly ConcurrentDictionary _scenarios = new(StringComparer.OrdinalIgnoreCase); + private readonly string _scenariosFolder; + private readonly object _lock = new(); + + public FileBasedScenarioStateStore(string rootFolder) + { + _scenariosFolder = Path.Combine(rootFolder, "__admin", "scenarios"); + Directory.CreateDirectory(_scenariosFolder); + LoadScenariosFromDisk(); + } + + public bool TryGet(string name, [NotNullWhen(true)] out ScenarioState? state) + { + return _scenarios.TryGetValue(name, out state); + } + + public IReadOnlyList GetAll() + { + return _scenarios.Values.ToArray(); + } + + public bool ContainsKey(string name) + { + return _scenarios.ContainsKey(name); + } + + public bool TryAdd(string name, ScenarioState scenarioState) + { + if (_scenarios.TryAdd(name, scenarioState)) + { + WriteScenarioToFile(name, scenarioState); + return true; + } + + return false; + } + + public ScenarioState AddOrUpdate(string name, Func addFactory, Func updateFactory) + { + lock (_lock) + { + var result = _scenarios.AddOrUpdate(name, addFactory, updateFactory); + WriteScenarioToFile(name, result); + return result; + } + } + + public ScenarioState? Update(string name, Action updateAction) + { + lock (_lock) + { + if (_scenarios.TryGetValue(name, out var state)) + { + updateAction(state); + WriteScenarioToFile(name, state); + return state; + } + + return null; + } + } + + public bool TryRemove(string name) + { + if (_scenarios.TryRemove(name, out _)) + { + DeleteScenarioFile(name); + return true; + } + + return false; + } + + public void Clear() + { + _scenarios.Clear(); + + foreach (var file in Directory.GetFiles(_scenariosFolder, "*.json")) + { + File.Delete(file); + } + } + + private string GetScenarioFilePath(string name) + { + var sanitized = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); + return Path.Combine(_scenariosFolder, sanitized + ".json"); + } + + private void WriteScenarioToFile(string name, ScenarioState state) + { + var json = JsonConvert.SerializeObject(state, Formatting.Indented); + File.WriteAllText(GetScenarioFilePath(name), json); + } + + private void DeleteScenarioFile(string name) + { + var path = GetScenarioFilePath(name); + if (File.Exists(path)) + { + File.Delete(path); + } + } + + private void LoadScenariosFromDisk() + { + foreach (var file in Directory.GetFiles(_scenariosFolder, "*.json")) + { + var json = File.ReadAllText(file); + var state = JsonConvert.DeserializeObject(json); + if (state != null) + { + _scenarios.TryAdd(state.Name, state); + } + } + } +} diff --git a/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareOptions.cs b/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareOptions.cs index 2d5b3900..59c3a237 100644 --- a/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareOptions.cs +++ b/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareOptions.cs @@ -27,7 +27,7 @@ internal interface IWireMockMiddlewareOptions ConcurrentDictionary Mappings { get; } - ConcurrentDictionary Scenarios { get; } + IScenarioStateStore ScenarioStateStore { get; set; } ConcurrentObservableCollection LogEntries { get; } diff --git a/src/WireMock.Net.Minimal/Owin/MappingMatcher.cs b/src/WireMock.Net.Minimal/Owin/MappingMatcher.cs index ac4d3d98..0750afce 100644 --- a/src/WireMock.Net.Minimal/Owin/MappingMatcher.cs +++ b/src/WireMock.Net.Minimal/Owin/MappingMatcher.cs @@ -89,14 +89,13 @@ internal class MappingMatcher(IWireMockMiddlewareOptions options, IRandomizerDou private string? GetNextState(IMapping mapping) { - // If the mapping does not have a scenario or _options.Scenarios does not contain this scenario from the mapping, + // If the mapping does not have a scenario or the store does not contain this scenario, // just return null to indicate that there is no next state. - if (mapping.Scenario == null || !_options.Scenarios.ContainsKey(mapping.Scenario)) + if (mapping.Scenario == null) { return null; } - // Else just return the next state - return _options.Scenarios[mapping.Scenario].NextState; + return _options.ScenarioStateStore.TryGet(mapping.Scenario, out var state) ? state.NextState : null; } } \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs b/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs index 71851167..243e0972 100644 --- a/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs +++ b/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs @@ -81,9 +81,9 @@ internal class WireMockMiddleware( } // Set scenario start - if (!options.Scenarios.ContainsKey(mapping.Scenario) && mapping.IsStartState) + if (!options.ScenarioStateStore.ContainsKey(mapping.Scenario) && mapping.IsStartState) { - options.Scenarios.TryAdd(mapping.Scenario, new ScenarioState + options.ScenarioStateStore.TryAdd(mapping.Scenario, new ScenarioState { Name = mapping.Scenario }); @@ -233,20 +233,21 @@ internal class WireMockMiddleware( private void UpdateScenarioState(IMapping mapping) { - var scenario = options.Scenarios[mapping.Scenario!]; - - // Increase the number of times this state has been executed - scenario.Counter++; - - // Only if the number of times this state is executed equals the required StateTimes, proceed to next state and reset the counter to 0 - if (scenario.Counter == (mapping.TimesInSameState ?? 1)) + options.ScenarioStateStore.Update(mapping.Scenario!, scenario => { - scenario.NextState = mapping.NextState; - scenario.Counter = 0; - } + // Increase the number of times this state has been executed + scenario.Counter++; - // Else just update Started and Finished - scenario.Started = true; - scenario.Finished = mapping.NextState == null; + // Only if the number of times this state is executed equals the required StateTimes, proceed to next state and reset the counter to 0 + if (scenario.Counter == (mapping.TimesInSameState ?? 1)) + { + scenario.NextState = mapping.NextState; + scenario.Counter = 0; + } + + // Else just update Started and Finished + scenario.Started = true; + scenario.Finished = mapping.NextState == null; + }); } -} \ No newline at end of file +} diff --git a/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptions.cs b/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptions.cs index 653096f9..d94bcd8a 100644 --- a/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptions.cs +++ b/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptions.cs @@ -27,7 +27,7 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions public ConcurrentDictionary Mappings { get; } = new ConcurrentDictionary(); - public ConcurrentDictionary Scenarios { get; } = new(StringComparer.OrdinalIgnoreCase); + public IScenarioStateStore ScenarioStateStore { get; set; } = new InMemoryScenarioStateStore(); public ConcurrentObservableCollection LogEntries { get; } = new(); diff --git a/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptionsHelper.cs b/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptionsHelper.cs index f2207048..2426693f 100644 --- a/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptionsHelper.cs +++ b/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptionsHelper.cs @@ -27,6 +27,7 @@ internal static class WireMockMiddlewareOptionsHelper options.FileSystemHandler = settings.FileSystemHandler; options.HandleRequestsSynchronously = settings.HandleRequestsSynchronously; options.Logger = settings.Logger; + options.ScenarioStateStore = settings.ScenarioStateStore; options.MaxRequestLogCount = settings.MaxRequestLogCount; options.PostWireMockMiddlewareInit = settings.PostWireMockMiddlewareInit; options.PreWireMockMiddlewareInit = settings.PreWireMockMiddlewareInit; diff --git a/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs b/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs index 12507600..83f8aa60 100644 --- a/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs +++ b/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs @@ -672,7 +672,7 @@ public partial class WireMockServer #region Scenarios private IResponseMessage ScenariosGet(HttpContext _, IRequestMessage requestMessage) { - var scenariosStates = Scenarios.Values.Select(s => new ScenarioStateModel + var scenariosStates = Scenarios.Select(s => new ScenarioStateModel { Name = s.Name, NextState = s.NextState, @@ -705,7 +705,7 @@ public partial class WireMockServer private IResponseMessage ScenariosSetState(HttpContext _, IRequestMessage requestMessage) { var name = Enumerable.Reverse(requestMessage.Path.Split('/')).Skip(1).First(); - if (!_options.Scenarios.ContainsKey(name)) + if (!_options.ScenarioStateStore.ContainsKey(name)) { ResponseMessageBuilder.Create(HttpStatusCode.NotFound, $"No scenario found by name '{name}'."); } diff --git a/src/WireMock.Net.Minimal/Server/WireMockServer.cs b/src/WireMock.Net.Minimal/Server/WireMockServer.cs index 63324046..40129a4d 100644 --- a/src/WireMock.Net.Minimal/Server/WireMockServer.cs +++ b/src/WireMock.Net.Minimal/Server/WireMockServer.cs @@ -1,760 +1,760 @@ -// Copyright © WireMock.Net and mock4net by Alexandre Victoor - -// This source file is based on mock4net by Alexandre Victoor which is licensed under the Apache 2.0 License. -// For more details see 'mock4net/LICENSE.txt' and 'mock4net/readme.md' in this project root. -using System.Collections.Concurrent; -using System.Net; -using AnyOfTypes; -using JetBrains.Annotations; -using JsonConverter.Newtonsoft.Json; -using Newtonsoft.Json; -using Stef.Validation; -using WireMock.Admin.Mappings; -using WireMock.Authentication; -using WireMock.Constants; -using WireMock.Exceptions; -using WireMock.Handlers; -using WireMock.Http; -using WireMock.Logging; -using WireMock.Models; -using WireMock.Owin; -using WireMock.RequestBuilders; -using WireMock.ResponseProviders; -using WireMock.Serialization; -using WireMock.Settings; -using WireMock.Types; -using WireMock.Util; - -namespace WireMock.Server; - -/// -/// The fluent mock server. -/// -public partial class WireMockServer : IWireMockServer -{ - private const int ServerStartDelayInMs = 100; - - private readonly WireMockServerSettings _settings; - private readonly AspNetCoreSelfHost? _httpServer; - private readonly IWireMockMiddlewareOptions _options = new WireMockMiddlewareOptions(); - private readonly MappingConverter _mappingConverter; - private readonly MatcherMapper _matcherMapper; - private readonly MappingToFileSaver _mappingToFileSaver; - private readonly MappingBuilder _mappingBuilder; - private readonly IGuidUtils _guidUtils = new GuidUtils(); - private readonly IDateTimeUtils _dateTimeUtils = new DateTimeUtils(); - private readonly MappingSerializer _mappingSerializer; - - /// - [PublicAPI] - public bool IsStarted => _httpServer is { IsStarted: true }; - - /// - [PublicAPI] - public bool IsStartedWithAdminInterface => IsStarted && _settings.StartAdminInterface.GetValueOrDefault(); - - /// - [PublicAPI] - public List Ports { get; } - - /// - [PublicAPI] - public int Port => Ports?.FirstOrDefault() ?? 0; - - /// - [PublicAPI] - public string[] Urls { get; } - - /// - [PublicAPI] - public string? Url => Urls?.FirstOrDefault(); - - /// - [PublicAPI] - public string? Consumer { get; private set; } - - /// - [PublicAPI] - public string? Provider { get; private set; } - - /// - /// Gets the mappings. - /// - [PublicAPI] - public IReadOnlyList Mappings => _options.Mappings.Values.ToArray(); - - /// - [PublicAPI] - public IReadOnlyList MappingModels => ToMappingModels(); - - /// - /// Gets the scenarios. - /// - [PublicAPI] - public ConcurrentDictionary Scenarios => new(_options.Scenarios); - - #region IDisposable Members - /// - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// - public void Dispose() - { - _options.LogEntries.CollectionChanged -= LogEntries_CollectionChanged; - - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases unmanaged and - optionally - managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - DisposeEnhancedFileSystemWatcher(); - _httpServer?.StopAsync(); - } - #endregion - - #region HttpClient -#if NET5_0_OR_GREATER - private readonly Lazy _lazyHttpClientFactory; - - /// - /// Create a which can be used to generate a HttpClient to call this instance. - /// - /// An ordered list of System.Net.Http.DelegatingHandler instances to be invoked - /// as an System.Net.Http.HttpRequestMessage travels from the System.Net.Http.HttpClient - /// to the network and an System.Net.Http.HttpResponseMessage travels from the network - /// back to System.Net.Http.HttpClient. The handlers are invoked in a top-down fashion. - /// That is, the first entry is invoked first for an outbound request message but - /// last for an inbound response message. - /// - /// - [PublicAPI] - public IHttpClientFactory CreateHttpClientFactory(params DelegatingHandler[] handlers) - { - if (!IsStarted) - { - throw new InvalidOperationException("Unable to create IHttpClientFactory because the service is not started."); - } - - return handlers.Length > 0 ? new WireMockHttpClientFactory(this, handlers) : _lazyHttpClientFactory.Value; - } -#endif - - /// - /// Create a which can be used to call this instance. - /// - /// An ordered list of System.Net.Http.DelegatingHandler instances to be invoked - /// as an System.Net.Http.HttpRequestMessage travels from the System.Net.Http.HttpClient - /// to the network and an System.Net.Http.HttpResponseMessage travels from the network - /// back to System.Net.Http.HttpClient. The handlers are invoked in a top-down fashion. - /// That is, the first entry is invoked first for an outbound request message but - /// last for an inbound response message. - /// - /// - [PublicAPI] - public HttpClient CreateClient(params DelegatingHandler[] handlers) - { - if (!IsStarted) - { - throw new InvalidOperationException("Unable to create HttpClient because the service is not started."); - } - - var client = HttpClientFactory2.Create(handlers); - client.BaseAddress = new Uri(Url!); - return client; - } - - /// - /// Create a which can be used to call this instance. - /// - /// The inner handler represents the destination of the HTTP message channel. - /// An ordered list of System.Net.Http.DelegatingHandler instances to be invoked - /// as an System.Net.Http.HttpRequestMessage travels from the System.Net.Http.HttpClient - /// to the network and an System.Net.Http.HttpResponseMessage travels from the network - /// back to System.Net.Http.HttpClient. The handlers are invoked in a top-down fashion. - /// That is, the first entry is invoked first for an outbound request message but - /// last for an inbound response message. - /// - /// - [PublicAPI] - public HttpClient CreateClient(HttpMessageHandler innerHandler, params DelegatingHandler[] handlers) - { - if (!IsStarted) - { - throw new InvalidOperationException("Unable to create HttpClient because the service is not started."); - } - - var client = HttpClientFactory2.Create(innerHandler, handlers); - client.BaseAddress = new Uri(Url!); - return client; - } - - /// - /// Create s (one for each URL) which can be used to call this instance. - /// The inner handler represents the destination of the HTTP message channel. - /// - /// An ordered list of System.Net.Http.DelegatingHandler instances to be invoked - /// as an System.Net.Http.HttpRequestMessage travels from the System.Net.Http.HttpClient - /// to the network and an System.Net.Http.HttpResponseMessage travels from the network - /// back to System.Net.Http.HttpClient. The handlers are invoked in a top-down fashion. - /// That is, the first entry is invoked first for an outbound request message but - /// last for an inbound response message. - /// - /// - [PublicAPI] - public HttpClient[] CreateClients(HttpMessageHandler innerHandler, params DelegatingHandler[] handlers) - { - if (!IsStarted) - { - throw new InvalidOperationException("Unable to create HttpClients because the service is not started."); - } - - return Urls.Select(url => - { - var client = HttpClientFactory2.Create(innerHandler, handlers); - client.BaseAddress = new Uri(url); - return client; - }).ToArray(); - } - #endregion - - #region Start/Stop - /// - /// Starts this WireMockServer with the specified settings. - /// - /// The WireMockServerSettings. - /// The . - [PublicAPI] - public static WireMockServer Start(WireMockServerSettings settings) - { - Guard.NotNull(settings); - - return new WireMockServer(settings); - } - - /// - /// Starts this WireMockServer with the specified settings. - /// - /// The action to configure the WireMockServerSettings. - /// The . - [PublicAPI] - public static WireMockServer Start(Action action) - { - Guard.NotNull(action); - - var settings = new WireMockServerSettings(); - - action(settings); - - return new WireMockServer(settings); - } - - /// - /// Start this WireMockServer. - /// - /// The port. - /// The SSL support. - /// Use HTTP 2 (needed for Grpc). - /// The . - [PublicAPI] - public static WireMockServer Start(int? port = 0, bool useSSL = false, bool useHttp2 = false) - { - return new WireMockServer(new WireMockServerSettings - { - Port = port, - UseSSL = useSSL, - UseHttp2 = useHttp2 - }); - } - - /// - /// Start this WireMockServer. - /// - /// The urls to listen on. - /// The . - [PublicAPI] - public static WireMockServer Start(params string[] urls) - { - Guard.NotNullOrEmpty(urls); - - return new WireMockServer(new WireMockServerSettings - { - Urls = urls - }); - } - - /// - /// Start this WireMockServer with the admin interface. - /// - /// The port. - /// The SSL support. - /// Use HTTP 2 (needed for Grpc). - /// The . - [PublicAPI] - public static WireMockServer StartWithAdminInterface(int? port = 0, bool useSSL = false, bool useHttp2 = false) - { - return new WireMockServer(new WireMockServerSettings - { - Port = port, - UseSSL = useSSL, - UseHttp2 = useHttp2, - StartAdminInterface = true - }); - } - - /// - /// Start this WireMockServer with the admin interface. - /// - /// The urls. - /// The . - [PublicAPI] - public static WireMockServer StartWithAdminInterface(params string[] urls) - { - Guard.NotNullOrEmpty(urls); - - return new WireMockServer(new WireMockServerSettings - { - Urls = urls, - StartAdminInterface = true - }); - } - - /// - /// Start this WireMockServer with the admin interface and read static mappings. - /// - /// The urls. - /// The . - [PublicAPI] - public static WireMockServer StartWithAdminInterfaceAndReadStaticMappings(params string[] urls) - { - Guard.NotNullOrEmpty(urls); - - return new WireMockServer(new WireMockServerSettings - { - Urls = urls, - StartAdminInterface = true, - ReadStaticMappings = true - }); - } - - /// - /// Initializes a new instance of the class. - /// - /// The settings. - /// - /// Service start failed with error: {_httpServer.RunningException.Message} - /// or - /// Service start failed with error: {startTask.Exception.Message} - /// - /// Service start timed out after {TimeSpan.FromMilliseconds(settings.StartTimeout)} - protected WireMockServer(WireMockServerSettings settings) - { - _settings = Guard.NotNull(settings); - - _mappingSerializer = new MappingSerializer(settings.DefaultJsonSerializer ?? new NewtonsoftJsonConverter()); - - // Set default values if not provided - _settings.Logger = settings.Logger ?? new WireMockNullLogger(); - _settings.FileSystemHandler = settings.FileSystemHandler ?? new LocalFileSystemHandler(); - - _settings.Logger.Info("By Stef Heyenrath (https://github.com/wiremock/WireMock.Net)"); - _settings.Logger.Debug("Server settings {0}", JsonConvert.SerializeObject(settings, Formatting.Indented)); - - HostUrlOptions urlOptions; - if (settings.Urls != null) - { - urlOptions = new HostUrlOptions - { - Urls = settings.Urls - }; - } - else - { - if (settings.HostingScheme is not null) - { - urlOptions = new HostUrlOptions - { - HostingScheme = settings.HostingScheme.Value, - UseHttp2 = settings.UseHttp2, - Port = settings.Port - }; - } - else - { - urlOptions = new HostUrlOptions - { - HostingScheme = settings.UseSSL == true ? HostingScheme.Https : HostingScheme.Http, - UseHttp2 = settings.UseHttp2, - Port = settings.Port - }; - } - } - - WireMockMiddlewareOptionsHelper.InitFromSettings(settings, _options, o => - { - o.LogEntries.CollectionChanged += LogEntries_CollectionChanged; - }); - - _matcherMapper = new MatcherMapper(_settings); - _mappingConverter = new MappingConverter(_matcherMapper); - _mappingToFileSaver = new MappingToFileSaver(_settings, _mappingConverter); - _mappingBuilder = new MappingBuilder( - settings, - _options, - _mappingConverter, - _mappingToFileSaver, - _guidUtils, - _dateTimeUtils - ); - - _options.AdditionalServiceRegistration = _settings.AdditionalServiceRegistration; - _options.CorsPolicyOptions = _settings.CorsPolicyOptions; - _options.ClientCertificateMode = (Microsoft.AspNetCore.Server.Kestrel.Https.ClientCertificateMode)_settings.ClientCertificateMode; - _options.AcceptAnyClientCertificate = _settings.AcceptAnyClientCertificate; - - _httpServer = new AspNetCoreSelfHost(_options, urlOptions); - var startTask = _httpServer.StartAsync(); - - using (var ctsStartTimeout = new CancellationTokenSource(settings.StartTimeout)) - { - while (!_httpServer.IsStarted) - { - // Throw exception if service start fails - if (_httpServer.RunningException != null) - { - throw new WireMockException($"Service start failed with error: {_httpServer.RunningException.Message}", _httpServer.RunningException); - } - - if (ctsStartTimeout.IsCancellationRequested) - { - // In case of an aggregate exception, throw the exception. - if (startTask.Exception != null) - { - throw new WireMockException($"Service start failed with error: {startTask.Exception.Message}", startTask.Exception); - } - - // Else throw TimeoutException - throw new TimeoutException($"Service start timed out after {TimeSpan.FromMilliseconds(settings.StartTimeout)}"); - } - - ctsStartTimeout.Token.WaitHandle.WaitOne(ServerStartDelayInMs); - } - - Urls = _httpServer.Urls.ToArray(); - Ports = _httpServer.Ports; - } - - InitSettings(settings); - -#if NET5_0_OR_GREATER - _lazyHttpClientFactory = new Lazy(() => new WireMockHttpClientFactory(this)); -#endif - } - - /// - [PublicAPI] - public void Stop() - { - var result = _httpServer?.StopAsync(); - result?.Wait(); // wait for stop to actually happen - } - #endregion - - /// - [PublicAPI] - public void AddCatchAllMapping() - { - Given(Request.Create().WithPath("/*").UsingAnyMethod()) - .WithGuid(Guid.Parse("90008000-0000-4444-a17e-669cd84f1f05")) - .AtPriority(1000) - .RespondWith(new DynamicResponseProvider((_, _) => ResponseMessageBuilder.Create(HttpStatusCode.NotFound, WireMockConstants.NoMatchingFound))); - } - - /// - [PublicAPI] - public void Reset() - { - ResetLogEntries(); - - ResetScenarios(); - - ResetMappings(); - } - - /// - [PublicAPI] - public void ResetMappings() - { - foreach (var nonAdmin in _options.Mappings.ToArray().Where(m => !m.Value.IsAdminInterface)) - { - _options.Mappings.TryRemove(nonAdmin.Key, out _); - } - } - - /// - [PublicAPI] - public bool DeleteMapping(Guid guid) - { - // Check a mapping exists with the same GUID, if so, remove it. - if (_options.Mappings.ContainsKey(guid)) - { - return _options.Mappings.TryRemove(guid, out _); - } - - return false; - } - - private bool DeleteMapping(string path) - { - // Check a mapping exists with the same path, if so, remove it. - var mapping = _options.Mappings.ToArray().FirstOrDefault(entry => string.Equals(entry.Value.Path, path, StringComparison.OrdinalIgnoreCase)); - return DeleteMapping(mapping.Key); - } - - /// - [PublicAPI] - public void AddGlobalProcessingDelay(TimeSpan delay) - { - _options.RequestProcessingDelay = delay; - } - - /// - [PublicAPI] - public void AllowPartialMapping(bool allow = true) - { - _settings.Logger.Info("AllowPartialMapping is set to {0}", allow); - _options.AllowPartialMapping = allow; - } - - /// - [PublicAPI] - public void SetAzureADAuthentication(string tenant, string audience) - { - Guard.NotNull(tenant); - Guard.NotNull(audience); - - _options.AuthenticationMatcher = new AzureADAuthenticationMatcher( - new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(), - new Microsoft.IdentityModel.Protocols.ConfigurationManager($"https://login.microsoftonline.com/{tenant}/.well-known/openid-configuration", new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever()), - tenant, - audience); - } - - /// - [PublicAPI] - public void SetBasicAuthentication(string username, string password) - { - Guard.NotNull(username); - Guard.NotNull(password); - - _options.AuthenticationMatcher = new BasicAuthenticationMatcher(username, password); - } - - /// - [PublicAPI] - public void RemoveAuthentication() - { - _options.AuthenticationMatcher = null; - } - - /// - [PublicAPI] - public void SetMaxRequestLogCount(int? maxRequestLogCount) - { - _options.MaxRequestLogCount = maxRequestLogCount; - } - - /// - [PublicAPI] - public void SetRequestLogExpirationDuration(int? requestLogExpirationDuration) - { - _options.RequestLogExpirationDuration = requestLogExpirationDuration; - } - - /// - [PublicAPI] - public void ResetScenarios() - { - _options.Scenarios.Clear(); - } - - /// - [PublicAPI] - public bool ResetScenario(string name) - { - return _options.Scenarios.ContainsKey(name) && _options.Scenarios.TryRemove(name, out _); - } - - /// - [PublicAPI] - public bool SetScenarioState(string name, string? state) - { - if (state == null) - { - return ResetScenario(name); - } - - _options.Scenarios.AddOrUpdate( - name, - _ => new ScenarioState - { - Name = name, - NextState = state - }, - (_, current) => - { - current.NextState = state; - return current; - } - ); - - return true; - } - - /// - [PublicAPI] - public IWireMockServer WithMapping(params MappingModel[] mappings) - { - foreach (var mapping in mappings) - { - ConvertMappingAndRegisterAsRespondProvider(mapping, mapping.Guid ?? Guid.NewGuid()); - } - - return this; - } - - /// - [PublicAPI] - public IWireMockServer WithMapping(string mappings) - { - var mappingModels = _mappingSerializer.DeserializeJsonToArray(mappings); - foreach (var mappingModel in mappingModels) - { - ConvertMappingAndRegisterAsRespondProvider(mappingModel, mappingModel.Guid ?? Guid.NewGuid()); - } - - return this; - } - - /// - /// Add a Grpc ProtoDefinition at server-level. - /// - /// Unique identifier for the ProtoDefinition. - /// The ProtoDefinition as text. - /// - [PublicAPI] - public WireMockServer AddProtoDefinition(string id, params string[] protoDefinition) - { - Guard.NotNullOrWhiteSpace(id); - Guard.NotNullOrEmpty(protoDefinition); - - _settings.ProtoDefinitions ??= new Dictionary(); - - if (_settings.ProtoDefinitions.TryGetValue(id, out var existingProtoDefinitions)) - { - _settings.ProtoDefinitions[id] = existingProtoDefinitions.Union(protoDefinition).ToArray(); - } - else - { - _settings.ProtoDefinitions[id] = protoDefinition; - } - - return this; - } - - /// - /// Add a GraphQL Schema at server-level. - /// - /// Unique identifier for the GraphQL Schema. - /// The GraphQL Schema as string or StringPattern. - /// A dictionary defining the custom scalars used in this schema. [optional] - /// - [PublicAPI] - public WireMockServer AddGraphQLSchema(string id, AnyOf graphQLSchema, Dictionary? customScalars = null) - { - Guard.NotNullOrWhiteSpace(id); - Guard.NotNullOrWhiteSpace(graphQLSchema); - - _settings.GraphQLSchemas ??= new Dictionary(); - - _settings.GraphQLSchemas[id] = new GraphQLSchemaDetails - { - SchemaAsString = graphQLSchema, - CustomScalars = customScalars - }; - - return this; - } - - /// - [PublicAPI] - public string? MappingToCSharpCode(Guid guid, MappingConverterType converterType) - { - return _mappingBuilder.ToCSharpCode(guid, converterType); - } - - /// - [PublicAPI] - public string MappingsToCSharpCode(MappingConverterType converterType) - { - return _mappingBuilder.ToCSharpCode(converterType); - } - - private void InitSettings(WireMockServerSettings settings) - { - if (settings.AllowBodyForAllHttpMethods == true) - { - _settings.Logger.Info("AllowBodyForAllHttpMethods is set to True"); - } - - if (settings.AllowOnlyDefinedHttpStatusCodeInResponse == true) - { - _settings.Logger.Info("AllowOnlyDefinedHttpStatusCodeInResponse is set to True"); - } - - if (settings.AllowPartialMapping == true) - { - AllowPartialMapping(); - } - - if (settings.StartAdminInterface == true) - { - if (!string.IsNullOrEmpty(settings.AdminUsername) && !string.IsNullOrEmpty(settings.AdminPassword)) - { - SetBasicAuthentication(settings.AdminUsername!, settings.AdminPassword!); - } - - if (!string.IsNullOrEmpty(settings.AdminAzureADTenant) && !string.IsNullOrEmpty(settings.AdminAzureADAudience)) - { - SetAzureADAuthentication(settings.AdminAzureADTenant!, settings.AdminAzureADAudience!); - } - - InitAdmin(); - } - - if (settings.ReadStaticMappings == true) - { - ReadStaticMappings(); - } - - if (settings.WatchStaticMappings == true) - { - WatchStaticMappings(); - } - - InitProxyAndRecord(settings); - - if (settings.RequestLogExpirationDuration != null) - { - SetRequestLogExpirationDuration(settings.RequestLogExpirationDuration); - } - - if (settings.MaxRequestLogCount != null) - { - SetMaxRequestLogCount(settings.MaxRequestLogCount); - } - } +// Copyright © WireMock.Net and mock4net by Alexandre Victoor + +// This source file is based on mock4net by Alexandre Victoor which is licensed under the Apache 2.0 License. +// For more details see 'mock4net/LICENSE.txt' and 'mock4net/readme.md' in this project root. +using System.Net; +using AnyOfTypes; +using JetBrains.Annotations; +using JsonConverter.Newtonsoft.Json; +using Newtonsoft.Json; +using Stef.Validation; +using WireMock.Admin.Mappings; +using WireMock.Authentication; +using WireMock.Constants; +using WireMock.Exceptions; +using WireMock.Handlers; +using WireMock.Http; +using WireMock.Logging; +using WireMock.Models; +using WireMock.Owin; +using WireMock.RequestBuilders; +using WireMock.ResponseProviders; +using WireMock.Serialization; +using WireMock.Settings; +using WireMock.Types; +using WireMock.Util; + +namespace WireMock.Server; + +/// +/// The fluent mock server. +/// +public partial class WireMockServer : IWireMockServer +{ + private const int ServerStartDelayInMs = 100; + + private readonly WireMockServerSettings _settings; + private readonly AspNetCoreSelfHost? _httpServer; + private readonly IWireMockMiddlewareOptions _options = new WireMockMiddlewareOptions(); + private readonly MappingConverter _mappingConverter; + private readonly MatcherMapper _matcherMapper; + private readonly MappingToFileSaver _mappingToFileSaver; + private readonly MappingBuilder _mappingBuilder; + private readonly IGuidUtils _guidUtils = new GuidUtils(); + private readonly IDateTimeUtils _dateTimeUtils = new DateTimeUtils(); + private readonly MappingSerializer _mappingSerializer; + + /// + [PublicAPI] + public bool IsStarted => _httpServer is { IsStarted: true }; + + /// + [PublicAPI] + public bool IsStartedWithAdminInterface => IsStarted && _settings.StartAdminInterface.GetValueOrDefault(); + + /// + [PublicAPI] + public List Ports { get; } + + /// + [PublicAPI] + public int Port => Ports?.FirstOrDefault() ?? 0; + + /// + [PublicAPI] + public string[] Urls { get; } + + /// + [PublicAPI] + public string? Url => Urls?.FirstOrDefault(); + + /// + [PublicAPI] + public string? Consumer { get; private set; } + + /// + [PublicAPI] + public string? Provider { get; private set; } + + /// + /// Gets the mappings. + /// + [PublicAPI] + public IReadOnlyList Mappings => _options.Mappings.Values.ToArray(); + + /// + [PublicAPI] + public IReadOnlyList MappingModels => ToMappingModels(); + + /// + /// Gets the scenarios. + /// + [PublicAPI] + public IReadOnlyList Scenarios => + _options.ScenarioStateStore.GetAll(); + + #region IDisposable Members + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + _options.LogEntries.CollectionChanged -= LogEntries_CollectionChanged; + + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + DisposeEnhancedFileSystemWatcher(); + _httpServer?.StopAsync(); + } + #endregion + + #region HttpClient +#if NET5_0_OR_GREATER + private readonly Lazy _lazyHttpClientFactory; + + /// + /// Create a which can be used to generate a HttpClient to call this instance. + /// + /// An ordered list of System.Net.Http.DelegatingHandler instances to be invoked + /// as an System.Net.Http.HttpRequestMessage travels from the System.Net.Http.HttpClient + /// to the network and an System.Net.Http.HttpResponseMessage travels from the network + /// back to System.Net.Http.HttpClient. The handlers are invoked in a top-down fashion. + /// That is, the first entry is invoked first for an outbound request message but + /// last for an inbound response message. + /// + /// + [PublicAPI] + public IHttpClientFactory CreateHttpClientFactory(params DelegatingHandler[] handlers) + { + if (!IsStarted) + { + throw new InvalidOperationException("Unable to create IHttpClientFactory because the service is not started."); + } + + return handlers.Length > 0 ? new WireMockHttpClientFactory(this, handlers) : _lazyHttpClientFactory.Value; + } +#endif + + /// + /// Create a which can be used to call this instance. + /// + /// An ordered list of System.Net.Http.DelegatingHandler instances to be invoked + /// as an System.Net.Http.HttpRequestMessage travels from the System.Net.Http.HttpClient + /// to the network and an System.Net.Http.HttpResponseMessage travels from the network + /// back to System.Net.Http.HttpClient. The handlers are invoked in a top-down fashion. + /// That is, the first entry is invoked first for an outbound request message but + /// last for an inbound response message. + /// + /// + [PublicAPI] + public HttpClient CreateClient(params DelegatingHandler[] handlers) + { + if (!IsStarted) + { + throw new InvalidOperationException("Unable to create HttpClient because the service is not started."); + } + + var client = HttpClientFactory2.Create(handlers); + client.BaseAddress = new Uri(Url!); + return client; + } + + /// + /// Create a which can be used to call this instance. + /// + /// The inner handler represents the destination of the HTTP message channel. + /// An ordered list of System.Net.Http.DelegatingHandler instances to be invoked + /// as an System.Net.Http.HttpRequestMessage travels from the System.Net.Http.HttpClient + /// to the network and an System.Net.Http.HttpResponseMessage travels from the network + /// back to System.Net.Http.HttpClient. The handlers are invoked in a top-down fashion. + /// That is, the first entry is invoked first for an outbound request message but + /// last for an inbound response message. + /// + /// + [PublicAPI] + public HttpClient CreateClient(HttpMessageHandler innerHandler, params DelegatingHandler[] handlers) + { + if (!IsStarted) + { + throw new InvalidOperationException("Unable to create HttpClient because the service is not started."); + } + + var client = HttpClientFactory2.Create(innerHandler, handlers); + client.BaseAddress = new Uri(Url!); + return client; + } + + /// + /// Create s (one for each URL) which can be used to call this instance. + /// The inner handler represents the destination of the HTTP message channel. + /// + /// An ordered list of System.Net.Http.DelegatingHandler instances to be invoked + /// as an System.Net.Http.HttpRequestMessage travels from the System.Net.Http.HttpClient + /// to the network and an System.Net.Http.HttpResponseMessage travels from the network + /// back to System.Net.Http.HttpClient. The handlers are invoked in a top-down fashion. + /// That is, the first entry is invoked first for an outbound request message but + /// last for an inbound response message. + /// + /// + [PublicAPI] + public HttpClient[] CreateClients(HttpMessageHandler innerHandler, params DelegatingHandler[] handlers) + { + if (!IsStarted) + { + throw new InvalidOperationException("Unable to create HttpClients because the service is not started."); + } + + return Urls.Select(url => + { + var client = HttpClientFactory2.Create(innerHandler, handlers); + client.BaseAddress = new Uri(url); + return client; + }).ToArray(); + } + #endregion + + #region Start/Stop + /// + /// Starts this WireMockServer with the specified settings. + /// + /// The WireMockServerSettings. + /// The . + [PublicAPI] + public static WireMockServer Start(WireMockServerSettings settings) + { + Guard.NotNull(settings); + + return new WireMockServer(settings); + } + + /// + /// Starts this WireMockServer with the specified settings. + /// + /// The action to configure the WireMockServerSettings. + /// The . + [PublicAPI] + public static WireMockServer Start(Action action) + { + Guard.NotNull(action); + + var settings = new WireMockServerSettings(); + + action(settings); + + return new WireMockServer(settings); + } + + /// + /// Start this WireMockServer. + /// + /// The port. + /// The SSL support. + /// Use HTTP 2 (needed for Grpc). + /// The . + [PublicAPI] + public static WireMockServer Start(int? port = 0, bool useSSL = false, bool useHttp2 = false) + { + return new WireMockServer(new WireMockServerSettings + { + Port = port, + UseSSL = useSSL, + UseHttp2 = useHttp2 + }); + } + + /// + /// Start this WireMockServer. + /// + /// The urls to listen on. + /// The . + [PublicAPI] + public static WireMockServer Start(params string[] urls) + { + Guard.NotNullOrEmpty(urls); + + return new WireMockServer(new WireMockServerSettings + { + Urls = urls + }); + } + + /// + /// Start this WireMockServer with the admin interface. + /// + /// The port. + /// The SSL support. + /// Use HTTP 2 (needed for Grpc). + /// The . + [PublicAPI] + public static WireMockServer StartWithAdminInterface(int? port = 0, bool useSSL = false, bool useHttp2 = false) + { + return new WireMockServer(new WireMockServerSettings + { + Port = port, + UseSSL = useSSL, + UseHttp2 = useHttp2, + StartAdminInterface = true + }); + } + + /// + /// Start this WireMockServer with the admin interface. + /// + /// The urls. + /// The . + [PublicAPI] + public static WireMockServer StartWithAdminInterface(params string[] urls) + { + Guard.NotNullOrEmpty(urls); + + return new WireMockServer(new WireMockServerSettings + { + Urls = urls, + StartAdminInterface = true + }); + } + + /// + /// Start this WireMockServer with the admin interface and read static mappings. + /// + /// The urls. + /// The . + [PublicAPI] + public static WireMockServer StartWithAdminInterfaceAndReadStaticMappings(params string[] urls) + { + Guard.NotNullOrEmpty(urls); + + return new WireMockServer(new WireMockServerSettings + { + Urls = urls, + StartAdminInterface = true, + ReadStaticMappings = true + }); + } + + /// + /// Initializes a new instance of the class. + /// + /// The settings. + /// + /// Service start failed with error: {_httpServer.RunningException.Message} + /// or + /// Service start failed with error: {startTask.Exception.Message} + /// + /// Service start timed out after {TimeSpan.FromMilliseconds(settings.StartTimeout)} + protected WireMockServer(WireMockServerSettings settings) + { + _settings = Guard.NotNull(settings); + + _mappingSerializer = new MappingSerializer(settings.DefaultJsonSerializer ?? new NewtonsoftJsonConverter()); + + // Set default values if not provided + _settings.Logger = settings.Logger ?? new WireMockNullLogger(); + _settings.FileSystemHandler = settings.FileSystemHandler ?? new LocalFileSystemHandler(); + + _settings.Logger.Info("By Stef Heyenrath (https://github.com/wiremock/WireMock.Net)"); + _settings.Logger.Debug("Server settings {0}", JsonConvert.SerializeObject(settings, Formatting.Indented)); + + HostUrlOptions urlOptions; + if (settings.Urls != null) + { + urlOptions = new HostUrlOptions + { + Urls = settings.Urls + }; + } + else + { + if (settings.HostingScheme is not null) + { + urlOptions = new HostUrlOptions + { + HostingScheme = settings.HostingScheme.Value, + UseHttp2 = settings.UseHttp2, + Port = settings.Port + }; + } + else + { + urlOptions = new HostUrlOptions + { + HostingScheme = settings.UseSSL == true ? HostingScheme.Https : HostingScheme.Http, + UseHttp2 = settings.UseHttp2, + Port = settings.Port + }; + } + } + + WireMockMiddlewareOptionsHelper.InitFromSettings(settings, _options, o => + { + o.LogEntries.CollectionChanged += LogEntries_CollectionChanged; + }); + + _matcherMapper = new MatcherMapper(_settings); + _mappingConverter = new MappingConverter(_matcherMapper); + _mappingToFileSaver = new MappingToFileSaver(_settings, _mappingConverter); + _mappingBuilder = new MappingBuilder( + settings, + _options, + _mappingConverter, + _mappingToFileSaver, + _guidUtils, + _dateTimeUtils + ); + + _options.AdditionalServiceRegistration = _settings.AdditionalServiceRegistration; + _options.CorsPolicyOptions = _settings.CorsPolicyOptions; + _options.ClientCertificateMode = (Microsoft.AspNetCore.Server.Kestrel.Https.ClientCertificateMode)_settings.ClientCertificateMode; + _options.AcceptAnyClientCertificate = _settings.AcceptAnyClientCertificate; + + _httpServer = new AspNetCoreSelfHost(_options, urlOptions); + var startTask = _httpServer.StartAsync(); + + using (var ctsStartTimeout = new CancellationTokenSource(settings.StartTimeout)) + { + while (!_httpServer.IsStarted) + { + // Throw exception if service start fails + if (_httpServer.RunningException != null) + { + throw new WireMockException($"Service start failed with error: {_httpServer.RunningException.Message}", _httpServer.RunningException); + } + + if (ctsStartTimeout.IsCancellationRequested) + { + // In case of an aggregate exception, throw the exception. + if (startTask.Exception != null) + { + throw new WireMockException($"Service start failed with error: {startTask.Exception.Message}", startTask.Exception); + } + + // Else throw TimeoutException + throw new TimeoutException($"Service start timed out after {TimeSpan.FromMilliseconds(settings.StartTimeout)}"); + } + + ctsStartTimeout.Token.WaitHandle.WaitOne(ServerStartDelayInMs); + } + + Urls = _httpServer.Urls.ToArray(); + Ports = _httpServer.Ports; + } + + InitSettings(settings); + +#if NET5_0_OR_GREATER + _lazyHttpClientFactory = new Lazy(() => new WireMockHttpClientFactory(this)); +#endif + } + + /// + [PublicAPI] + public void Stop() + { + var result = _httpServer?.StopAsync(); + result?.Wait(); // wait for stop to actually happen + } + #endregion + + /// + [PublicAPI] + public void AddCatchAllMapping() + { + Given(Request.Create().WithPath("/*").UsingAnyMethod()) + .WithGuid(Guid.Parse("90008000-0000-4444-a17e-669cd84f1f05")) + .AtPriority(1000) + .RespondWith(new DynamicResponseProvider((_, _) => ResponseMessageBuilder.Create(HttpStatusCode.NotFound, WireMockConstants.NoMatchingFound))); + } + + /// + [PublicAPI] + public void Reset() + { + ResetLogEntries(); + + ResetScenarios(); + + ResetMappings(); + } + + /// + [PublicAPI] + public void ResetMappings() + { + foreach (var nonAdmin in _options.Mappings.ToArray().Where(m => !m.Value.IsAdminInterface)) + { + _options.Mappings.TryRemove(nonAdmin.Key, out _); + } + } + + /// + [PublicAPI] + public bool DeleteMapping(Guid guid) + { + // Check a mapping exists with the same GUID, if so, remove it. + if (_options.Mappings.ContainsKey(guid)) + { + return _options.Mappings.TryRemove(guid, out _); + } + + return false; + } + + private bool DeleteMapping(string path) + { + // Check a mapping exists with the same path, if so, remove it. + var mapping = _options.Mappings.ToArray().FirstOrDefault(entry => string.Equals(entry.Value.Path, path, StringComparison.OrdinalIgnoreCase)); + return DeleteMapping(mapping.Key); + } + + /// + [PublicAPI] + public void AddGlobalProcessingDelay(TimeSpan delay) + { + _options.RequestProcessingDelay = delay; + } + + /// + [PublicAPI] + public void AllowPartialMapping(bool allow = true) + { + _settings.Logger.Info("AllowPartialMapping is set to {0}", allow); + _options.AllowPartialMapping = allow; + } + + /// + [PublicAPI] + public void SetAzureADAuthentication(string tenant, string audience) + { + Guard.NotNull(tenant); + Guard.NotNull(audience); + + _options.AuthenticationMatcher = new AzureADAuthenticationMatcher( + new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(), + new Microsoft.IdentityModel.Protocols.ConfigurationManager($"https://login.microsoftonline.com/{tenant}/.well-known/openid-configuration", new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever()), + tenant, + audience); + } + + /// + [PublicAPI] + public void SetBasicAuthentication(string username, string password) + { + Guard.NotNull(username); + Guard.NotNull(password); + + _options.AuthenticationMatcher = new BasicAuthenticationMatcher(username, password); + } + + /// + [PublicAPI] + public void RemoveAuthentication() + { + _options.AuthenticationMatcher = null; + } + + /// + [PublicAPI] + public void SetMaxRequestLogCount(int? maxRequestLogCount) + { + _options.MaxRequestLogCount = maxRequestLogCount; + } + + /// + [PublicAPI] + public void SetRequestLogExpirationDuration(int? requestLogExpirationDuration) + { + _options.RequestLogExpirationDuration = requestLogExpirationDuration; + } + + /// + [PublicAPI] + public void ResetScenarios() + { + _options.ScenarioStateStore.Clear(); + } + + /// + [PublicAPI] + public bool ResetScenario(string name) + { + return _options.ScenarioStateStore.TryRemove(name); + } + + /// + [PublicAPI] + public bool SetScenarioState(string name, string? state) + { + if (state == null) + { + return ResetScenario(name); + } + + _options.ScenarioStateStore.AddOrUpdate( + name, + _ => new ScenarioState + { + Name = name, + NextState = state + }, + (_, current) => + { + current.NextState = state; + return current; + } + ); + + return true; + } + + /// + [PublicAPI] + public IWireMockServer WithMapping(params MappingModel[] mappings) + { + foreach (var mapping in mappings) + { + ConvertMappingAndRegisterAsRespondProvider(mapping, mapping.Guid ?? Guid.NewGuid()); + } + + return this; + } + + /// + [PublicAPI] + public IWireMockServer WithMapping(string mappings) + { + var mappingModels = _mappingSerializer.DeserializeJsonToArray(mappings); + foreach (var mappingModel in mappingModels) + { + ConvertMappingAndRegisterAsRespondProvider(mappingModel, mappingModel.Guid ?? Guid.NewGuid()); + } + + return this; + } + + /// + /// Add a Grpc ProtoDefinition at server-level. + /// + /// Unique identifier for the ProtoDefinition. + /// The ProtoDefinition as text. + /// + [PublicAPI] + public WireMockServer AddProtoDefinition(string id, params string[] protoDefinition) + { + Guard.NotNullOrWhiteSpace(id); + Guard.NotNullOrEmpty(protoDefinition); + + _settings.ProtoDefinitions ??= new Dictionary(); + + if (_settings.ProtoDefinitions.TryGetValue(id, out var existingProtoDefinitions)) + { + _settings.ProtoDefinitions[id] = existingProtoDefinitions.Union(protoDefinition).ToArray(); + } + else + { + _settings.ProtoDefinitions[id] = protoDefinition; + } + + return this; + } + + /// + /// Add a GraphQL Schema at server-level. + /// + /// Unique identifier for the GraphQL Schema. + /// The GraphQL Schema as string or StringPattern. + /// A dictionary defining the custom scalars used in this schema. [optional] + /// + [PublicAPI] + public WireMockServer AddGraphQLSchema(string id, AnyOf graphQLSchema, Dictionary? customScalars = null) + { + Guard.NotNullOrWhiteSpace(id); + Guard.NotNullOrWhiteSpace(graphQLSchema); + + _settings.GraphQLSchemas ??= new Dictionary(); + + _settings.GraphQLSchemas[id] = new GraphQLSchemaDetails + { + SchemaAsString = graphQLSchema, + CustomScalars = customScalars + }; + + return this; + } + + /// + [PublicAPI] + public string? MappingToCSharpCode(Guid guid, MappingConverterType converterType) + { + return _mappingBuilder.ToCSharpCode(guid, converterType); + } + + /// + [PublicAPI] + public string MappingsToCSharpCode(MappingConverterType converterType) + { + return _mappingBuilder.ToCSharpCode(converterType); + } + + private void InitSettings(WireMockServerSettings settings) + { + if (settings.AllowBodyForAllHttpMethods == true) + { + _settings.Logger.Info("AllowBodyForAllHttpMethods is set to True"); + } + + if (settings.AllowOnlyDefinedHttpStatusCodeInResponse == true) + { + _settings.Logger.Info("AllowOnlyDefinedHttpStatusCodeInResponse is set to True"); + } + + if (settings.AllowPartialMapping == true) + { + AllowPartialMapping(); + } + + if (settings.StartAdminInterface == true) + { + if (!string.IsNullOrEmpty(settings.AdminUsername) && !string.IsNullOrEmpty(settings.AdminPassword)) + { + SetBasicAuthentication(settings.AdminUsername!, settings.AdminPassword!); + } + + if (!string.IsNullOrEmpty(settings.AdminAzureADTenant) && !string.IsNullOrEmpty(settings.AdminAzureADAudience)) + { + SetAzureADAuthentication(settings.AdminAzureADTenant!, settings.AdminAzureADAudience!); + } + + InitAdmin(); + } + + if (settings.ReadStaticMappings == true) + { + ReadStaticMappings(); + } + + if (settings.WatchStaticMappings == true) + { + WatchStaticMappings(); + } + + InitProxyAndRecord(settings); + + if (settings.RequestLogExpirationDuration != null) + { + SetRequestLogExpirationDuration(settings.RequestLogExpirationDuration); + } + + if (settings.MaxRequestLogCount != null) + { + SetMaxRequestLogCount(settings.MaxRequestLogCount); + } + } } \ No newline at end of file diff --git a/src/WireMock.Net.Shared/Handlers/InMemoryScenarioStateStore.cs b/src/WireMock.Net.Shared/Handlers/InMemoryScenarioStateStore.cs new file mode 100644 index 00000000..0af16776 --- /dev/null +++ b/src/WireMock.Net.Shared/Handlers/InMemoryScenarioStateStore.cs @@ -0,0 +1,60 @@ +// Copyright © WireMock.Net + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace WireMock.Handlers; + +public class InMemoryScenarioStateStore : IScenarioStateStore +{ + private readonly ConcurrentDictionary _scenarios = new(StringComparer.OrdinalIgnoreCase); + + public bool TryGet(string name, [NotNullWhen(true)] out ScenarioState? state) + { + return _scenarios.TryGetValue(name, out state); + } + + public IReadOnlyList GetAll() + { + return _scenarios.Values.ToArray(); + } + + public bool ContainsKey(string name) + { + return _scenarios.ContainsKey(name); + } + + public bool TryAdd(string name, ScenarioState scenarioState) + { + return _scenarios.TryAdd(name, scenarioState); + } + + public ScenarioState AddOrUpdate(string name, Func addFactory, Func updateFactory) + { + return _scenarios.AddOrUpdate(name, addFactory, updateFactory); + } + + public ScenarioState? Update(string name, Action updateAction) + { + if (_scenarios.TryGetValue(name, out var state)) + { + updateAction(state); + return state; + } + + return null; + } + + public bool TryRemove(string name) + { + return _scenarios.TryRemove(name, out _); + } + + public void Clear() + { + _scenarios.Clear(); + } +} diff --git a/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs b/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs index be4f62b1..258cd3a8 100644 --- a/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs +++ b/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs @@ -175,6 +175,10 @@ public class WireMockServerSettings [JsonIgnore] public IFileSystemHandler FileSystemHandler { get; set; } = null!; + [PublicAPI] + [JsonIgnore] + public IScenarioStateStore ScenarioStateStore { get; set; } = new InMemoryScenarioStateStore(); + /// /// Action which can be used to add additional Handlebars registrations. [Optional] /// diff --git a/test/WireMock.Net.Tests/Handlers/FileBasedScenarioStateStoreTests.cs b/test/WireMock.Net.Tests/Handlers/FileBasedScenarioStateStoreTests.cs new file mode 100644 index 00000000..b41c6745 --- /dev/null +++ b/test/WireMock.Net.Tests/Handlers/FileBasedScenarioStateStoreTests.cs @@ -0,0 +1,273 @@ +// Copyright © WireMock.Net + +using System; +using System.IO; +using Newtonsoft.Json; +using WireMock.Handlers; +using Xunit; + +namespace WireMock.Net.Tests.Handlers; + +public class FileBasedScenarioStateStoreTests : IDisposable +{ + private readonly string _tempFolder; + private readonly string _scenariosFolder; + + public FileBasedScenarioStateStoreTests() + { + _tempFolder = Path.Combine(Path.GetTempPath(), "WireMock_Tests_" + Guid.NewGuid().ToString("N")); + _scenariosFolder = Path.Combine(_tempFolder, "__admin", "scenarios"); + } + + public void Dispose() + { + if (Directory.Exists(_tempFolder)) + { + Directory.Delete(_tempFolder, true); + } + } + + private FileBasedScenarioStateStore CreateSut() => new(_tempFolder); + + // --- Mirror tests from InMemoryScenarioStateStoreTests --- + + [Fact] + public void TryAdd_ShouldAddNewScenario() + { + var sut = CreateSut(); + var state = new ScenarioState { Name = "scenario1" }; + + sut.TryAdd("scenario1", state).Should().BeTrue(); + + sut.ContainsKey("scenario1").Should().BeTrue(); + } + + [Fact] + public void TryAdd_ShouldReturnFalse_WhenScenarioAlreadyExists() + { + var sut = CreateSut(); + var state = new ScenarioState { Name = "scenario1" }; + sut.TryAdd("scenario1", state); + + sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" }).Should().BeFalse(); + } + + [Fact] + public void TryGet_ShouldReturnTrue_WhenExists() + { + var sut = CreateSut(); + var state = new ScenarioState { Name = "scenario1", NextState = "state2" }; + sut.TryAdd("scenario1", state); + + sut.TryGet("scenario1", out var result).Should().BeTrue(); + + result.Should().NotBeNull(); + result!.NextState.Should().Be("state2"); + } + + [Fact] + public void TryGet_ShouldReturnFalse_WhenNotExists() + { + var sut = CreateSut(); + sut.TryGet("nonexistent", out var result).Should().BeFalse(); + result.Should().BeNull(); + } + + [Fact] + public void GetAll_ShouldReturnAllScenarios() + { + var sut = CreateSut(); + sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" }); + sut.TryAdd("scenario2", new ScenarioState { Name = "scenario2" }); + + var result = sut.GetAll(); + + result.Should().HaveCount(2); + } + + [Fact] + public void GetAll_ShouldReturnEmpty_WhenNoScenarios() + { + var sut = CreateSut(); + sut.GetAll().Should().BeEmpty(); + } + + [Fact] + public void Update_ShouldModifyExistingScenario() + { + var sut = CreateSut(); + sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1", Counter = 0 }); + + var result = sut.Update("scenario1", s => { s.Counter = 5; s.NextState = "state2"; }); + + result.Should().NotBeNull(); + result!.Counter.Should().Be(5); + result.NextState.Should().Be("state2"); + } + + [Fact] + public void Update_ShouldReturnNull_WhenNotExists() + { + var sut = CreateSut(); + sut.Update("nonexistent", s => { s.Counter = 5; }).Should().BeNull(); + } + + [Fact] + public void AddOrUpdate_ShouldAddNewScenario() + { + var sut = CreateSut(); + var result = sut.AddOrUpdate( + "scenario1", + _ => new ScenarioState { Name = "scenario1", NextState = "added" }, + (_, current) => { current.NextState = "updated"; return current; } + ); + + result.NextState.Should().Be("added"); + } + + [Fact] + public void AddOrUpdate_ShouldUpdateExistingScenario() + { + var sut = CreateSut(); + sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1", NextState = "initial" }); + + var result = sut.AddOrUpdate( + "scenario1", + _ => new ScenarioState { Name = "scenario1", NextState = "added" }, + (_, current) => { current.NextState = "updated"; return current; } + ); + + result.NextState.Should().Be("updated"); + } + + [Fact] + public void TryRemove_ShouldRemoveExistingScenario() + { + var sut = CreateSut(); + sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" }); + + sut.TryRemove("scenario1").Should().BeTrue(); + sut.ContainsKey("scenario1").Should().BeFalse(); + } + + [Fact] + public void TryRemove_ShouldReturnFalse_WhenNotExists() + { + var sut = CreateSut(); + sut.TryRemove("nonexistent").Should().BeFalse(); + } + + [Fact] + public void Clear_ShouldRemoveAllScenarios() + { + var sut = CreateSut(); + sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" }); + sut.TryAdd("scenario2", new ScenarioState { Name = "scenario2" }); + + sut.Clear(); + + sut.GetAll().Should().BeEmpty(); + } + + [Fact] + public void ContainsKey_ShouldBeCaseInsensitive() + { + var sut = CreateSut(); + sut.TryAdd("Scenario1", new ScenarioState { Name = "Scenario1" }); + + sut.ContainsKey("scenario1").Should().BeTrue(); + sut.ContainsKey("SCENARIO1").Should().BeTrue(); + } + + [Fact] + public void TryGet_ShouldBeCaseInsensitive() + { + var sut = CreateSut(); + sut.TryAdd("Scenario1", new ScenarioState { Name = "Scenario1", NextState = "state2" }); + + sut.TryGet("scenario1", out var result1).Should().BeTrue(); + result1!.NextState.Should().Be("state2"); + + sut.TryGet("SCENARIO1", out var result2).Should().BeTrue(); + result2!.NextState.Should().Be("state2"); + } + + // --- File-persistence-specific tests --- + + [Fact] + public void TryAdd_ShouldCreateJsonFileOnDisk() + { + var sut = CreateSut(); + sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1", NextState = "state2" }); + + var filePath = Path.Combine(_scenariosFolder, "scenario1.json"); + File.Exists(filePath).Should().BeTrue(); + + var json = File.ReadAllText(filePath); + var deserialized = JsonConvert.DeserializeObject(json); + deserialized!.Name.Should().Be("scenario1"); + deserialized.NextState.Should().Be("state2"); + } + + [Fact] + public void TryRemove_ShouldDeleteJsonFileFromDisk() + { + var sut = CreateSut(); + sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" }); + + var filePath = Path.Combine(_scenariosFolder, "scenario1.json"); + File.Exists(filePath).Should().BeTrue(); + + sut.TryRemove("scenario1"); + + File.Exists(filePath).Should().BeFalse(); + } + + [Fact] + public void Clear_ShouldDeleteAllJsonFilesFromDisk() + { + var sut = CreateSut(); + sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" }); + sut.TryAdd("scenario2", new ScenarioState { Name = "scenario2" }); + + Directory.GetFiles(_scenariosFolder, "*.json").Should().HaveCount(2); + + sut.Clear(); + + Directory.GetFiles(_scenariosFolder, "*.json").Should().BeEmpty(); + } + + [Fact] + public void Constructor_ShouldLoadExistingScenariosFromDisk() + { + // Pre-write JSON files before constructing the store + Directory.CreateDirectory(_scenariosFolder); + var state1 = new ScenarioState { Name = "scenario1", NextState = "loaded1" }; + var state2 = new ScenarioState { Name = "scenario2", NextState = "loaded2", Counter = 3 }; + File.WriteAllText(Path.Combine(_scenariosFolder, "scenario1.json"), JsonConvert.SerializeObject(state1)); + File.WriteAllText(Path.Combine(_scenariosFolder, "scenario2.json"), JsonConvert.SerializeObject(state2)); + + var sut = CreateSut(); + + sut.GetAll().Should().HaveCount(2); + sut.TryGet("scenario1", out var loaded1).Should().BeTrue(); + loaded1!.NextState.Should().Be("loaded1"); + sut.TryGet("scenario2", out var loaded2).Should().BeTrue(); + loaded2!.Counter.Should().Be(3); + } + + [Fact] + public void Update_ShouldPersistChangesToDisk() + { + var sut = CreateSut(); + sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1", Counter = 0 }); + + sut.Update("scenario1", s => { s.Counter = 10; s.NextState = "persisted"; }); + + var filePath = Path.Combine(_scenariosFolder, "scenario1.json"); + var json = File.ReadAllText(filePath); + var deserialized = JsonConvert.DeserializeObject(json); + deserialized!.Counter.Should().Be(10); + deserialized.NextState.Should().Be("persisted"); + } +} diff --git a/test/WireMock.Net.Tests/Handlers/InMemoryScenarioStateStoreTests.cs b/test/WireMock.Net.Tests/Handlers/InMemoryScenarioStateStoreTests.cs new file mode 100644 index 00000000..13a9ae62 --- /dev/null +++ b/test/WireMock.Net.Tests/Handlers/InMemoryScenarioStateStoreTests.cs @@ -0,0 +1,157 @@ +// Copyright © WireMock.Net + +using WireMock.Handlers; +using Xunit; + +namespace WireMock.Net.Tests.Handlers; + +public class InMemoryScenarioStateStoreTests +{ + private readonly InMemoryScenarioStateStore _sut = new(); + + [Fact] + public void TryAdd_ShouldAddNewScenario() + { + var state = new ScenarioState { Name = "scenario1" }; + + _sut.TryAdd("scenario1", state).Should().BeTrue(); + + _sut.ContainsKey("scenario1").Should().BeTrue(); + } + + [Fact] + public void TryAdd_ShouldReturnFalse_WhenScenarioAlreadyExists() + { + var state = new ScenarioState { Name = "scenario1" }; + _sut.TryAdd("scenario1", state); + + _sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" }).Should().BeFalse(); + } + + [Fact] + public void TryGet_ShouldReturnTrue_WhenExists() + { + var state = new ScenarioState { Name = "scenario1", NextState = "state2" }; + _sut.TryAdd("scenario1", state); + + _sut.TryGet("scenario1", out var result).Should().BeTrue(); + + result.Should().NotBeNull(); + result!.NextState.Should().Be("state2"); + } + + [Fact] + public void TryGet_ShouldReturnFalse_WhenNotExists() + { + _sut.TryGet("nonexistent", out var result).Should().BeFalse(); + result.Should().BeNull(); + } + + [Fact] + public void GetAll_ShouldReturnAllScenarios() + { + _sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" }); + _sut.TryAdd("scenario2", new ScenarioState { Name = "scenario2" }); + + var result = _sut.GetAll(); + + result.Should().HaveCount(2); + } + + [Fact] + public void GetAll_ShouldReturnEmpty_WhenNoScenarios() + { + _sut.GetAll().Should().BeEmpty(); + } + + [Fact] + public void Update_ShouldModifyExistingScenario() + { + _sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1", Counter = 0 }); + + var result = _sut.Update("scenario1", s => { s.Counter = 5; s.NextState = "state2"; }); + + result.Should().NotBeNull(); + result!.Counter.Should().Be(5); + result.NextState.Should().Be("state2"); + } + + [Fact] + public void Update_ShouldReturnNull_WhenNotExists() + { + _sut.Update("nonexistent", s => { s.Counter = 5; }).Should().BeNull(); + } + + [Fact] + public void AddOrUpdate_ShouldAddNewScenario() + { + var result = _sut.AddOrUpdate( + "scenario1", + _ => new ScenarioState { Name = "scenario1", NextState = "added" }, + (_, current) => { current.NextState = "updated"; return current; } + ); + + result.NextState.Should().Be("added"); + } + + [Fact] + public void AddOrUpdate_ShouldUpdateExistingScenario() + { + _sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1", NextState = "initial" }); + + var result = _sut.AddOrUpdate( + "scenario1", + _ => new ScenarioState { Name = "scenario1", NextState = "added" }, + (_, current) => { current.NextState = "updated"; return current; } + ); + + result.NextState.Should().Be("updated"); + } + + [Fact] + public void TryRemove_ShouldRemoveExistingScenario() + { + _sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" }); + + _sut.TryRemove("scenario1").Should().BeTrue(); + _sut.ContainsKey("scenario1").Should().BeFalse(); + } + + [Fact] + public void TryRemove_ShouldReturnFalse_WhenNotExists() + { + _sut.TryRemove("nonexistent").Should().BeFalse(); + } + + [Fact] + public void Clear_ShouldRemoveAllScenarios() + { + _sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" }); + _sut.TryAdd("scenario2", new ScenarioState { Name = "scenario2" }); + + _sut.Clear(); + + _sut.GetAll().Should().BeEmpty(); + } + + [Fact] + public void ContainsKey_ShouldBeCaseInsensitive() + { + _sut.TryAdd("Scenario1", new ScenarioState { Name = "Scenario1" }); + + _sut.ContainsKey("scenario1").Should().BeTrue(); + _sut.ContainsKey("SCENARIO1").Should().BeTrue(); + } + + [Fact] + public void TryGet_ShouldBeCaseInsensitive() + { + _sut.TryAdd("Scenario1", new ScenarioState { Name = "Scenario1", NextState = "state2" }); + + _sut.TryGet("scenario1", out var result1).Should().BeTrue(); + result1!.NextState.Should().Be("state2"); + + _sut.TryGet("SCENARIO1", out var result2).Should().BeTrue(); + result2!.NextState.Should().Be("state2"); + } +} diff --git a/test/WireMock.Net.Tests/Owin/MappingMatcherTests.cs b/test/WireMock.Net.Tests/Owin/MappingMatcherTests.cs index 1e7ab6a0..945d10d2 100644 --- a/test/WireMock.Net.Tests/Owin/MappingMatcherTests.cs +++ b/test/WireMock.Net.Tests/Owin/MappingMatcherTests.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using Moq; +using WireMock.Handlers; using WireMock.Logging; using WireMock.Matchers.Request; using WireMock.Models; @@ -23,7 +24,7 @@ public class MappingMatcherTests _optionsMock.SetupAllProperties(); _optionsMock.Setup(o => o.Mappings).Returns(new ConcurrentDictionary()); _optionsMock.Setup(o => o.LogEntries).Returns([]); - _optionsMock.Setup(o => o.Scenarios).Returns(new ConcurrentDictionary()); + _optionsMock.Setup(o => o.ScenarioStateStore).Returns(new InMemoryScenarioStateStore()); var loggerMock = new Mock(); loggerMock.SetupAllProperties(); diff --git a/test/WireMock.Net.Tests/Owin/WireMockMiddlewareTests.cs b/test/WireMock.Net.Tests/Owin/WireMockMiddlewareTests.cs index 8d8716dc..8cd1b2bc 100644 --- a/test/WireMock.Net.Tests/Owin/WireMockMiddlewareTests.cs +++ b/test/WireMock.Net.Tests/Owin/WireMockMiddlewareTests.cs @@ -55,7 +55,7 @@ public class WireMockMiddlewareTests _optionsMock.SetupAllProperties(); _optionsMock.Setup(o => o.Mappings).Returns(_mappings); _optionsMock.Setup(o => o.LogEntries).Returns(new ConcurrentObservableCollection()); - _optionsMock.Setup(o => o.Scenarios).Returns(new ConcurrentDictionary()); + _optionsMock.Setup(o => o.ScenarioStateStore).Returns(new InMemoryScenarioStateStore()); _optionsMock.Setup(o => o.Logger.Warn(It.IsAny(), It.IsAny())); _optionsMock.Setup(o => o.Logger.Error(It.IsAny(), It.IsAny())); _optionsMock.Setup(o => o.Logger.DebugRequestResponse(It.IsAny(), It.IsAny())); diff --git a/test/WireMock.Net.Tests/StatefulBehaviorTests.cs b/test/WireMock.Net.Tests/StatefulBehaviorTests.cs index 536bdf05..3bb85dba 100644 --- a/test/WireMock.Net.Tests/StatefulBehaviorTests.cs +++ b/test/WireMock.Net.Tests/StatefulBehaviorTests.cs @@ -325,26 +325,26 @@ public class StatefulBehaviorTests var getResponse1 = await client.GetStringAsync("/todo/items", cancelationToken); getResponse1.Should().Be("Buy milk"); - server.Scenarios["To do list"].Name.Should().Be("To do list"); - server.Scenarios["To do list"].NextState.Should().Be("TodoList State Started"); - server.Scenarios["To do list"].Started.Should().BeTrue(); - server.Scenarios["To do list"].Finished.Should().BeFalse(); + server.Scenarios.First(s => s.Name == "To do list").Name.Should().Be("To do list"); + server.Scenarios.First(s => s.Name == "To do list").NextState.Should().Be("TodoList State Started"); + server.Scenarios.First(s => s.Name == "To do list").Started.Should().BeTrue(); + server.Scenarios.First(s => s.Name == "To do list").Finished.Should().BeFalse(); var postResponse = await client.PostAsync("/todo/items", new StringContent("Cancel newspaper subscription"), cancelationToken); postResponse.StatusCode.Should().Be(HttpStatusCode.Created); - server.Scenarios["To do list"].Name.Should().Be("To do list"); - server.Scenarios["To do list"].NextState.Should().Be("Cancel newspaper item added"); - server.Scenarios["To do list"].Started.Should().BeTrue(); - server.Scenarios["To do list"].Finished.Should().BeFalse(); + server.Scenarios.First(s => s.Name == "To do list").Name.Should().Be("To do list"); + server.Scenarios.First(s => s.Name == "To do list").NextState.Should().Be("Cancel newspaper item added"); + server.Scenarios.First(s => s.Name == "To do list").Started.Should().BeTrue(); + server.Scenarios.First(s => s.Name == "To do list").Finished.Should().BeFalse(); string getResponse2 = await client.GetStringAsync("/todo/items", cancelationToken); getResponse2.Should().Be("Buy milk;Cancel newspaper subscription"); - server.Scenarios["To do list"].Name.Should().Be("To do list"); - server.Scenarios["To do list"].NextState.Should().BeNull(); - server.Scenarios["To do list"].Started.Should().BeTrue(); - server.Scenarios["To do list"].Finished.Should().BeTrue(); + server.Scenarios.First(s => s.Name == "To do list").Name.Should().Be("To do list"); + server.Scenarios.First(s => s.Name == "To do list").NextState.Should().BeNull(); + server.Scenarios.First(s => s.Name == "To do list").Started.Should().BeTrue(); + server.Scenarios.First(s => s.Name == "To do list").Finished.Should().BeTrue(); server.Stop(); } @@ -372,14 +372,14 @@ public class StatefulBehaviorTests // Act and Assert server.SetScenarioState(scenario, "Buy milk"); - server.Scenarios[scenario].Should().BeEquivalentTo(new { Name = scenario, NextState = "Buy milk" }); + server.Scenarios.First(s => s.Name == scenario).Should().BeEquivalentTo(new { Name = scenario, NextState = "Buy milk" }); var getResponse1 = await client.GetStringAsync("/todo/items", cancelationToken); getResponse1.Should().Be("Buy milk"); server.SetScenarioState(scenario, "Cancel newspaper"); - server.Scenarios[scenario].Name.Should().Be(scenario); - server.Scenarios[scenario].Should().BeEquivalentTo(new { Name = scenario, NextState = "Cancel newspaper" }); + server.Scenarios.First(s => s.Name == scenario).Name.Should().Be(scenario); + server.Scenarios.First(s => s.Name == scenario).Should().BeEquivalentTo(new { Name = scenario, NextState = "Cancel newspaper" }); var getResponse2 = await client.GetStringAsync("/todo/items", cancelationToken); getResponse2.Should().Be("Buy milk;Cancel newspaper subscription");