Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Source/Mockolate/Internals/Polyfills/StringExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#if NETSTANDARD2_0
using System;
using System.Diagnostics.CodeAnalysis;

namespace Mockolate.Internals.Polyfills;
Expand All @@ -9,6 +10,27 @@ namespace Mockolate.Internals.Polyfills;
[ExcludeFromCodeCoverage]
internal static class StringExtensionMethods
{

/// <summary>
/// Returns a value indicating whether a specified character occurs within this string, using the specified comparison
/// rules.
/// </summary>
/// <returns>
/// <see langword="true" /> if the <paramref name="value" /> parameter occurs within this string; otherwise,
/// <see langword="false" />.
/// </returns>
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),
};

/// <summary>
/// Determines whether the end of this string instance matches the specified character.
/// </summary>
Expand Down
35 changes: 29 additions & 6 deletions Source/Mockolate/Web/ItExtensions.HttpContent.WithString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -12,7 +15,7 @@ public static partial class ItExtensions
extension(IHttpContentParameter parameter)
{
/// <summary>
/// Expects the content to have a string body equal to the <paramref name="expected" /> value.
/// Expects the content to have a string body that contains the <paramref name="expected" /> value.
/// </summary>
public IStringContentBodyParameter WithString(string expected)
{
Expand Down Expand Up @@ -54,28 +57,39 @@ public interface IStringContentBodyParameter : IHttpContentParameter
/// Ignores case when matching the body.
/// </summary>
IStringContentBodyParameter IgnoringCase();

/// <summary>
/// Requires the body to completely match the given string.
/// </summary>
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,
Expand All @@ -101,6 +115,9 @@ public void AsRegex(
_bodyMatchType = BodyMatchType.Regex;
}

public void Exactly()
=> _exactly = true;

public void IgnoringCase()
=> _ignoringCase = true;

Expand Down Expand Up @@ -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);
Expand Down
31 changes: 22 additions & 9 deletions Source/Mockolate/Web/ItExtensions.HttpContent.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -61,10 +62,11 @@ public interface IHttpContentParameter : IParameter<HttpContent?>, IHttpHeaderPa
private sealed class HttpContentParameter
: IHttpContentHeaderParameter, IHttpRequestMessagePropertyParameter<HttpContent?>, IParameter
{
private BinaryMatcher? _binaryContentMatcher;
private List<Action<HttpContent?>>? _callbacks;
private IContentMatcher? _contentMatcher;
private HttpHeadersMatcher? _headers;
private string? _mediaType;
private PredicateStringMatcher? _stringContentMatcher;

public IHttpContentParameter IncludingRequestHeaders()
{
Expand All @@ -74,13 +76,14 @@ public IHttpContentParameter IncludingRequestHeaders()

public IHttpContentParameter WithString(Func<string, bool> predicate)
{
_contentMatcher = new PredicateStringMatcher(predicate);
_stringContentMatcher ??= new PredicateStringMatcher();
_stringContentMatcher.AddPredicate(predicate);
return this;
}

public IHttpContentParameter WithBytes(Func<byte[], bool> predicate)
{
_contentMatcher = new BinaryMatcher(predicate);
_binaryContentMatcher ??= new BinaryMatcher(predicate);
return this;
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -153,8 +162,10 @@ private interface IContentMatcher
bool Matches(HttpContent content, HttpRequestMessage? message);
}

private sealed class PredicateStringMatcher(Func<string, bool> predicate) : IContentMatcher
private sealed class PredicateStringMatcher : IContentMatcher
{
private readonly List<Func<string, bool>> _predicates = [];

public bool Matches(HttpContent content, HttpRequestMessage? message)
{
static Encoding GetEncodingFromCharset(string? charset)
Expand Down Expand Up @@ -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);
}
Expand All @@ -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<string, bool> predicate)
=> _predicates.Add(predicate);
}

private sealed class BinaryMatcher(Func<byte[], bool> predicate) : IContentMatcher
Expand Down
1 change: 1 addition & 0 deletions Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1943,6 +1943,7 @@ namespace Mockolate.Web
}
public interface IStringContentBodyParameter : Mockolate.Parameters.IParameter<System.Net.Http.HttpContent?>, Mockolate.Web.ItExtensions.IHttpContentParameter, Mockolate.Web.ItExtensions.IHttpHeaderParameter<Mockolate.Web.ItExtensions.IHttpContentHeaderParameter>
{
Mockolate.Web.ItExtensions.IStringContentBodyParameter Exactly();
Mockolate.Web.ItExtensions.IStringContentBodyParameter IgnoringCase();
}
public interface IUriParameter : Mockolate.Parameters.IParameter<System.Uri?>
Expand Down
1 change: 1 addition & 0 deletions Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1942,6 +1942,7 @@ namespace Mockolate.Web
}
public interface IStringContentBodyParameter : Mockolate.Parameters.IParameter<System.Net.Http.HttpContent?>, Mockolate.Web.ItExtensions.IHttpContentParameter, Mockolate.Web.ItExtensions.IHttpHeaderParameter<Mockolate.Web.ItExtensions.IHttpContentHeaderParameter>
{
Mockolate.Web.ItExtensions.IStringContentBodyParameter Exactly();
Mockolate.Web.ItExtensions.IStringContentBodyParameter IgnoringCase();
}
public interface IUriParameter : Mockolate.Parameters.IParameter<System.Uri?>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1883,6 +1883,7 @@ namespace Mockolate.Web
}
public interface IStringContentBodyParameter : Mockolate.Parameters.IParameter<System.Net.Http.HttpContent?>, Mockolate.Web.ItExtensions.IHttpContentParameter, Mockolate.Web.ItExtensions.IHttpHeaderParameter<Mockolate.Web.ItExtensions.IHttpContentHeaderParameter>
{
Mockolate.Web.ItExtensions.IStringContentBodyParameter Exactly();
Mockolate.Web.ItExtensions.IStringContentBodyParameter IgnoringCase();
}
public interface IUriParameter : Mockolate.Parameters.IParameter<System.Uri?>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
httpClient.SetupMock.Method
.PostAsync(It.IsAny<Uri>(), 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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
httpClient.SetupMock.Method
.PostAsync(It.IsAny<Uri>(), 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>();
httpClient.SetupMock.Method
.PostAsync(It.IsAny<Uri>(), 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)
Expand Down Expand Up @@ -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>();
httpClient.SetupMock.Method
.PostAsync(It.IsAny<Uri>(), 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);
}
}
}
}
Loading