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");