diff --git a/Source/Mockolate/Internals/Polyfills/StringExtensions.cs b/Source/Mockolate/Internals/Polyfills/StringExtensions.cs index 95253878..5b8601be 100644 --- a/Source/Mockolate/Internals/Polyfills/StringExtensions.cs +++ b/Source/Mockolate/Internals/Polyfills/StringExtensions.cs @@ -1,4 +1,5 @@ #if NETSTANDARD2_0 +using System; using System.Diagnostics.CodeAnalysis; namespace Mockolate.Internals.Polyfills; @@ -9,6 +10,27 @@ namespace Mockolate.Internals.Polyfills; [ExcludeFromCodeCoverage] internal static class StringExtensionMethods { + + /// + /// Returns a value indicating whether a specified character occurs within this string, using the specified comparison + /// rules. + /// + /// + /// if the parameter occurs within this string; otherwise, + /// . + /// + internal static bool Contains( + this string @this, + string value, + StringComparison comparisonType) + => comparisonType switch + { + StringComparison.OrdinalIgnoreCase => @this.ToLowerInvariant().Contains(value.ToLowerInvariant()), + StringComparison.InvariantCultureIgnoreCase => @this.ToLowerInvariant().Contains(value.ToLowerInvariant()), + StringComparison.CurrentCultureIgnoreCase => @this.ToLower().Contains(value.ToLower()), + _ => @this.Contains(value), + }; + /// /// Determines whether the end of this string instance matches the specified character. /// diff --git a/Source/Mockolate/Web/ItExtensions.HttpContent.WithString.cs b/Source/Mockolate/Web/ItExtensions.HttpContent.WithString.cs index 13a8e782..ad34d4a6 100644 --- a/Source/Mockolate/Web/ItExtensions.HttpContent.WithString.cs +++ b/Source/Mockolate/Web/ItExtensions.HttpContent.WithString.cs @@ -2,6 +2,9 @@ using System.Net.Http; using System.Text.RegularExpressions; using Mockolate.Internals; +#if NETSTANDARD2_0 +using Mockolate.Internals.Polyfills; +#endif namespace Mockolate.Web; @@ -12,7 +15,7 @@ public static partial class ItExtensions extension(IHttpContentParameter parameter) { /// - /// Expects the content to have a string body equal to the value. + /// Expects the content to have a string body that contains the value. /// public IStringContentBodyParameter WithString(string expected) { @@ -54,28 +57,39 @@ public interface IStringContentBodyParameter : IHttpContentParameter /// Ignores case when matching the body. /// IStringContentBodyParameter IgnoringCase(); + + /// + /// Requires the body to completely match the given string. + /// + IStringContentBodyParameter Exactly(); } private sealed class StringMatcher(string value, bool isExact) { private BodyMatchType _bodyMatchType = isExact ? BodyMatchType.Exact : BodyMatchType.Wildcard; + private bool _exactly; private bool _ignoringCase; private RegexOptions _regexOptions; private TimeSpan? _timeout; public bool Matches(string stringContent) { - switch (_bodyMatchType) + switch (_bodyMatchType, _exactly) { - case BodyMatchType.Exact when + case (BodyMatchType.Exact, false) when + !stringContent.Contains(value, _ignoringCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal): + return false; + case (BodyMatchType.Exact, true) when !stringContent.Equals(value, _ignoringCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal): return false; - case BodyMatchType.Wildcard when - !Wildcard.Pattern(value, _ignoringCase, false).Matches(stringContent): + case (BodyMatchType.Wildcard, _) when + !Wildcard.Pattern(value, _ignoringCase, _exactly).Matches(stringContent): return false; - case BodyMatchType.Regex: + case (BodyMatchType.Regex, _): { Regex regex = new(value, _ignoringCase ? _regexOptions | RegexOptions.IgnoreCase : _regexOptions, @@ -101,6 +115,9 @@ public void AsRegex( _bodyMatchType = BodyMatchType.Regex; } + public void Exactly() + => _exactly = true; + public void IgnoringCase() => _ignoringCase = true; @@ -128,6 +145,12 @@ public IStringContentBodyParameter IgnoringCase() return this; } + public IStringContentBodyParameter Exactly() + { + _data.Exactly(); + return this; + } + public IStringContentBodyParameter AsRegex(RegexOptions options = RegexOptions.None, TimeSpan? timeout = null) { _data.AsRegex(options, timeout); diff --git a/Source/Mockolate/Web/ItExtensions.HttpContent.cs b/Source/Mockolate/Web/ItExtensions.HttpContent.cs index 526c003b..325ce1b5 100644 --- a/Source/Mockolate/Web/ItExtensions.HttpContent.cs +++ b/Source/Mockolate/Web/ItExtensions.HttpContent.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net.Http; using System.Text; using Mockolate.Parameters; @@ -61,10 +62,11 @@ public interface IHttpContentParameter : IParameter, IHttpHeaderPa private sealed class HttpContentParameter : IHttpContentHeaderParameter, IHttpRequestMessagePropertyParameter, IParameter { + private BinaryMatcher? _binaryContentMatcher; private List>? _callbacks; - private IContentMatcher? _contentMatcher; private HttpHeadersMatcher? _headers; private string? _mediaType; + private PredicateStringMatcher? _stringContentMatcher; public IHttpContentParameter IncludingRequestHeaders() { @@ -74,13 +76,14 @@ public IHttpContentParameter IncludingRequestHeaders() public IHttpContentParameter WithString(Func predicate) { - _contentMatcher = new PredicateStringMatcher(predicate); + _stringContentMatcher ??= new PredicateStringMatcher(); + _stringContentMatcher.AddPredicate(predicate); return this; } public IHttpContentParameter WithBytes(Func predicate) { - _contentMatcher = new BinaryMatcher(predicate); + _binaryContentMatcher ??= new BinaryMatcher(predicate); return this; } @@ -126,8 +129,14 @@ public bool Matches(HttpContent? value, HttpRequestMessage? requestMessage) return false; } - if (_contentMatcher is not null && - !_contentMatcher.Matches(value, requestMessage)) + if (_stringContentMatcher is not null && + !_stringContentMatcher.Matches(value, requestMessage)) + { + return false; + } + + if (_binaryContentMatcher is not null && + !_binaryContentMatcher.Matches(value, requestMessage)) { return false; } @@ -153,8 +162,10 @@ private interface IContentMatcher bool Matches(HttpContent content, HttpRequestMessage? message); } - private sealed class PredicateStringMatcher(Func predicate) : IContentMatcher + private sealed class PredicateStringMatcher : IContentMatcher { + private readonly List> _predicates = []; + public bool Matches(HttpContent content, HttpRequestMessage? message) { static Encoding GetEncodingFromCharset(string? charset) @@ -184,8 +195,7 @@ static Encoding GetEncodingFromCharset(string? charset) stream.Position = position; #else string stringContent; - if (message?.Properties.TryGetValue("Mockolate:HttpContent", out object value) == true - && value is byte[] bytes) + if (message?.Properties.TryGetValue("Mockolate:HttpContent", out object value) == true && value is byte[] bytes) { stringContent = encoding.GetString(bytes); } @@ -198,8 +208,11 @@ static Encoding GetEncodingFromCharset(string? charset) stream.Position = position; } #endif - return predicate.Invoke(stringContent); + return _predicates.All(predicate => predicate.Invoke(stringContent)); } + + public void AddPredicate(Func predicate) + => _predicates.Add(predicate); } private sealed class BinaryMatcher(Func predicate) : IContentMatcher diff --git a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt index 7eacc9c5..354d5d8f 100644 --- a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt +++ b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt @@ -1943,6 +1943,7 @@ namespace Mockolate.Web } public interface IStringContentBodyParameter : Mockolate.Parameters.IParameter, Mockolate.Web.ItExtensions.IHttpContentParameter, Mockolate.Web.ItExtensions.IHttpHeaderParameter { + Mockolate.Web.ItExtensions.IStringContentBodyParameter Exactly(); Mockolate.Web.ItExtensions.IStringContentBodyParameter IgnoringCase(); } public interface IUriParameter : Mockolate.Parameters.IParameter diff --git a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt index a12e74e4..15265de0 100644 --- a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt +++ b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt @@ -1942,6 +1942,7 @@ namespace Mockolate.Web } public interface IStringContentBodyParameter : Mockolate.Parameters.IParameter, Mockolate.Web.ItExtensions.IHttpContentParameter, Mockolate.Web.ItExtensions.IHttpHeaderParameter { + Mockolate.Web.ItExtensions.IStringContentBodyParameter Exactly(); Mockolate.Web.ItExtensions.IStringContentBodyParameter IgnoringCase(); } public interface IUriParameter : Mockolate.Parameters.IParameter diff --git a/Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt b/Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt index c39e9cfe..124a62a3 100644 --- a/Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt +++ b/Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt @@ -1883,6 +1883,7 @@ namespace Mockolate.Web } public interface IStringContentBodyParameter : Mockolate.Parameters.IParameter, Mockolate.Web.ItExtensions.IHttpContentParameter, Mockolate.Web.ItExtensions.IHttpHeaderParameter { + Mockolate.Web.ItExtensions.IStringContentBodyParameter Exactly(); Mockolate.Web.ItExtensions.IStringContentBodyParameter IgnoringCase(); } public interface IUriParameter : Mockolate.Parameters.IParameter diff --git a/Tests/Mockolate.Tests/Web/ItExtensionsTests.IsHttpContentTests.WithStringMatchingTests.cs b/Tests/Mockolate.Tests/Web/ItExtensionsTests.IsHttpContentTests.WithStringMatchingTests.cs index 88ce8700..7740efa8 100644 --- a/Tests/Mockolate.Tests/Web/ItExtensionsTests.IsHttpContentTests.WithStringMatchingTests.cs +++ b/Tests/Mockolate.Tests/Web/ItExtensionsTests.IsHttpContentTests.WithStringMatchingTests.cs @@ -135,6 +135,39 @@ public async Task ShouldCheckForMatchingWildcard(string body, string pattern, bo await That(result.StatusCode) .IsEqualTo(expectSuccess ? HttpStatusCode.OK : HttpStatusCode.NotImplemented); } + + [Theory] + [InlineData(true, "f?o", "foo")] + [InlineData(true, "f?o", "bar")] + [InlineData(true, "bar", "f?o")] + [InlineData(true, "bar", "bar")] + [InlineData(true, "f?o", "?oo", "b?r", "?ar")] + [InlineData(false, "f?o", "b?r", "baz")] + [InlineData(false, "f?o", "baz", "b?r")] + [InlineData(false, "?az", "f?o", "b?r")] + [InlineData(false, "?az")] + public async Task WithMultipleExpectations_ShouldVerifyAll(bool expectSuccess, + params string[] expectedValues) + { + ItExtensions.IHttpContentParameter isHttpContent = It.IsHttpContent(); + foreach (string expectedValue in expectedValues) + { + isHttpContent = isHttpContent.WithStringMatching(expectedValue); + } + + string body = "foo;bar"; + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), isHttpContent) + .ReturnsAsync(HttpStatusCode.OK); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(expectSuccess ? HttpStatusCode.OK : HttpStatusCode.NotImplemented); + } } } } diff --git a/Tests/Mockolate.Tests/Web/ItExtensionsTests.IsHttpContentTests.WithStringTests.cs b/Tests/Mockolate.Tests/Web/ItExtensionsTests.IsHttpContentTests.WithStringTests.cs index e0f421cf..77ffc968 100644 --- a/Tests/Mockolate.Tests/Web/ItExtensionsTests.IsHttpContentTests.WithStringTests.cs +++ b/Tests/Mockolate.Tests/Web/ItExtensionsTests.IsHttpContentTests.WithStringTests.cs @@ -16,9 +16,50 @@ public sealed partial class IsHttpContentTests { public sealed class WithStringTests { + [Theory] + [InlineData("foo", "FOO", true)] + public async Task Exactly_IgnoringCase_ShouldCheckCaseInsensitiveForFullContentBody( + string body, string expected, bool expectSuccess) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsHttpContent().WithString(expected).Exactly().IgnoringCase()) + .ReturnsAsync(HttpStatusCode.OK); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(expectSuccess ? HttpStatusCode.OK : HttpStatusCode.NotImplemented); + } + + [Theory] + [InlineData("foo", "foo", true)] + [InlineData("foo", "FOO", false)] + [InlineData("foo", "baz", false)] + [InlineData(" bar", "bar", false)] + [InlineData("baz ", "baz", false)] + public async Task Exactly_ShouldCheckForFullContentBody(string body, string expected, bool expectSuccess) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsHttpContent().WithString(expected).Exactly()) + .ReturnsAsync(HttpStatusCode.OK); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(expectSuccess ? HttpStatusCode.OK : HttpStatusCode.NotImplemented); + } + [Theory] [InlineData("foo", "foo", true)] [InlineData("foo", "FOO", true)] + [InlineData(" foo", "FOO", true)] + [InlineData("FOO ", "foo", true)] [InlineData("foo", "bar", false)] public async Task IgnoringCase_ShouldCheckForCaseInsensitiveEquality(string body, string expected, bool expectSuccess) @@ -233,6 +274,39 @@ public async Task WithInvalidCharsetHeader_ShouldFallbackToUtf8(string charsetHe await That(result.StatusCode) .IsEqualTo(HttpStatusCode.OK); } + + [Theory] + [InlineData(true, "foo", "foo")] + [InlineData(true, "foo", "bar")] + [InlineData(true, "bar", "foo")] + [InlineData(true, "bar", "bar")] + [InlineData(true, "foo", "foo", "bar", "bar")] + [InlineData(false, "foo", "bar", "baz")] + [InlineData(false, "foo", "baz", "bar")] + [InlineData(false, "baz", "foo", "bar")] + [InlineData(false, "baz")] + public async Task WithMultipleExpectations_ShouldVerifyAll(bool expectSuccess, + params string[] expectedValues) + { + ItExtensions.IHttpContentParameter isHttpContent = It.IsHttpContent(); + foreach (string expectedValue in expectedValues) + { + isHttpContent = isHttpContent.WithString(expectedValue); + } + + string body = "foo;bar"; + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), isHttpContent) + .ReturnsAsync(HttpStatusCode.OK); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(expectSuccess ? HttpStatusCode.OK : HttpStatusCode.NotImplemented); + } } } }