diff --git a/Directory.Packages.props b/Directory.Packages.props index 317b53e..cba59ad 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ - + diff --git a/README.md b/README.md index 0564417..d45b655 100644 --- a/README.md +++ b/README.md @@ -80,3 +80,27 @@ mock.MyMethod(1); // Fails, because the setup for MyMethod(2) was never used. await That(mock.VerifyMock).AllSetupsAreUsed(); ``` + +### Web Extensions + +#### JSON Content + +With `It.IsJsonContent()`, you can precisely verify JSON content in HTTP requests during your tests. This feature is +especially useful for testing HTTP clients and web APIs. + +```csharp +// Verifies that a request was sent with a JSON body equivalent to { "foo": 1, "bar": "baz" } +httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(new { foo = 1, bar = \"baz\" })) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + +// You can also provide a string representation of the JSON and it ignores formatting differences or property order +httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBody("{\"bar\": \"baz\", \"foo\": 1}")) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + +// In addition, you can specify the expected media type +httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent("application/json")) + .ReturnsAsync(...); +``` diff --git a/Source/aweXpect.Mockolate/Web/ItExtensions.HttpContent.IsJsonContent.cs b/Source/aweXpect.Mockolate/Web/ItExtensions.HttpContent.IsJsonContent.cs new file mode 100644 index 0000000..8152725 --- /dev/null +++ b/Source/aweXpect.Mockolate/Web/ItExtensions.HttpContent.IsJsonContent.cs @@ -0,0 +1,284 @@ +#if NET8_0_OR_GREATER +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using Mockolate.Parameters; + +// ReSharper disable once CheckNamespace +namespace Mockolate.Web; + +#pragma warning disable S2325 // Methods and properties that don't access instance data should be static +/// +/// Extensions for parameter matchers for HTTP-related types. +/// +public static class AweXpectItExtensions +{ + /// + extension(It _) + { + /// + /// Expects the parameter to be a JSON content. + /// + public static IJsonContentParameter IsJsonContent() + => new JsonContentParameter(); + + /// + /// Expects the parameter to be a JSON content + /// with the given . + /// + public static IJsonContentParameter IsJsonContent(string mediaType) + => new JsonContentParameter().WithMediaType(mediaType); + } + + /// + /// Further expectations on the JSON . + /// + public interface IJsonContentParameter : ItExtensions.IHttpContentParameter + { + /// + /// Expects the to have a body equal to the given . + /// + IJsonContentBodyParameter WithBody(string json, JsonDocumentOptions? options = null); + + /// + /// Expects the to have a JSON body which matches the value. + /// + IJsonContentBodyParameter WithBodyMatching(object? expected, JsonDocumentOptions? options = null); + + /// + /// Expects the to have a JSON body which matches the value. + /// + IJsonContentBodyParameter WithBodyMatching(IEnumerable expected, JsonDocumentOptions? options = null); + } + + /// + /// Further expectations on the matching of a JSON body of the . + /// + public interface IJsonContentBodyParameter : IJsonContentParameter + { + /// + /// Ignores additional properties in JSON objects when comparing. + /// + IJsonContentBodyParameter IgnoringAdditionalProperties(bool ignoreAdditionalProperties = true); + } + + private sealed class JsonContentParameter : HttpContentParameter, IJsonContentBodyParameter + { + private string? _body; + private bool _ignoringAdditionalProperties = true; + private JsonDocumentOptions? _jsonDocumentOptions; + + /// + protected override IJsonContentParameter GetThis => this; + + /// + public IJsonContentBodyParameter WithBody(string json, + JsonDocumentOptions? options = null) + { + _body = json; + _jsonDocumentOptions = options; + return this; + } + + /// + public IJsonContentBodyParameter WithBodyMatching(object? expected, + JsonDocumentOptions? options = null) + => WithBody(JsonSerializer.Serialize(expected, JsonSerializerOptions.Default), options); + + public IJsonContentBodyParameter WithBodyMatching(IEnumerable expected, + JsonDocumentOptions? options = null) + => WithBody(JsonSerializer.Serialize(expected, JsonSerializerOptions.Default), options); + + /// + public IJsonContentBodyParameter IgnoringAdditionalProperties(bool ignoreAdditionalProperties = true) + { + _ignoringAdditionalProperties = ignoreAdditionalProperties; + return this; + } + + protected override bool Matches(HttpContent value) + { + if (!base.Matches(value)) + { + return false; + } + + if (_body is not null) + { + try + { + JsonDocumentOptions options = _jsonDocumentOptions ?? GetDefaultOptions(); + using JsonDocument actualDocument = JsonDocument.Parse(value.ReadAsStream(), options); + using JsonDocument expectedDocument = JsonDocument.Parse(_body, options); + + if (!Compare(actualDocument.RootElement, expectedDocument.RootElement, + _ignoringAdditionalProperties)) + { + return false; + } + } + catch (JsonException) + { + return false; + } + } + + return true; + } + + private static JsonDocumentOptions GetDefaultOptions() => new() + { + AllowTrailingCommas = true, + }; + + private static bool Compare( + JsonElement actualElement, + JsonElement expectedElement, + bool ignoreAdditionalProperties) + { + if (actualElement.ValueKind != expectedElement.ValueKind) + { + return false; + } + + return actualElement.ValueKind switch + { + JsonValueKind.Array => CompareJsonArray(actualElement, expectedElement, ignoreAdditionalProperties), + JsonValueKind.Number => CompareJsonNumber(actualElement, expectedElement), + JsonValueKind.String => CompareJsonString(actualElement, expectedElement), + JsonValueKind.Object => CompareJsonObject(actualElement, expectedElement, ignoreAdditionalProperties), + _ => true, + }; + } + + private static bool CompareJsonObject(JsonElement actualElement, JsonElement expectedElement, + bool ignoreAdditionalProperties) + { + foreach (JsonProperty item in expectedElement.EnumerateObject()) + { + if (!actualElement.TryGetProperty(item.Name, out JsonElement property)) + { + return false; + } + + if (!Compare(property, item.Value, ignoreAdditionalProperties)) + { + return false; + } + } + + if (!ignoreAdditionalProperties) + { + foreach (JsonProperty property in actualElement.EnumerateObject()) + { + if (!expectedElement.TryGetProperty(property.Name, out _)) + { + return false; + } + } + } + + return true; + } + + private static bool CompareJsonArray(JsonElement actualElement, JsonElement expectedElement, + bool ignoreAdditionalProperties) + { + for (int index = 0; index < expectedElement.GetArrayLength(); index++) + { + JsonElement expectedArrayElement = expectedElement[index]; + if (actualElement.GetArrayLength() <= index) + { + return false; + } + + JsonElement actualArrayElement = actualElement[index]; + if (!Compare(actualArrayElement, expectedArrayElement, ignoreAdditionalProperties)) + { + return false; + } + } + + return ignoreAdditionalProperties || actualElement.GetArrayLength() <= expectedElement.GetArrayLength(); + } + + private static bool CompareJsonString(JsonElement actualElement, JsonElement expectedElement) + { + string? value1 = actualElement.GetString(); + string? value2 = expectedElement.GetString(); + return value1 == value2; + } + + private static bool CompareJsonNumber(JsonElement actualElement, JsonElement expectedElement) + { + if (actualElement.TryGetInt32(out int v1) && expectedElement.TryGetInt32(out int v2)) + { + return v1 == v2; + } + + if (actualElement.TryGetDouble(out double n1) && expectedElement.TryGetDouble(out double n2)) + { + return n1.Equals(n2); + } + + return false; + } + } + + private abstract class HttpContentParameter + : ItExtensions.IHttpContentParameter, IParameter + { + private List>? _callbacks; + private string? _mediaType; + + /// + /// Returns this typed as for fluent API. + /// + protected abstract TParameter GetThis { get; } + + /// + public TParameter WithMediaType(string? mediaType) + { + _mediaType = mediaType; + return GetThis; + } + + /// + public IParameter Do(Action callback) + { + _callbacks ??= []; + _callbacks.Add(callback); + return this; + } + + /// + public bool Matches(object? value) + => value is HttpContent typedValue && Matches(typedValue); + + /// + public void InvokeCallbacks(object? value) + { + if (value is HttpContent httpContent) + { + _callbacks?.ForEach(a => a.Invoke(httpContent)); + } + } + + /// + /// Checks whether the given matches the expectations. + /// + protected virtual bool Matches(HttpContent value) + { + if (_mediaType is not null && + value.Headers.ContentType?.MediaType?.Equals(_mediaType, StringComparison.OrdinalIgnoreCase) != true) + { + return false; + } + + return true; + } + } +} +#pragma warning restore S2325 // Methods and properties that don't access instance data should be static +#endif diff --git a/Tests/aweXpect.Mockolate.Api.Tests/Expected/aweXpect.Mockolate_net10.0.txt b/Tests/aweXpect.Mockolate.Api.Tests/Expected/aweXpect.Mockolate_net10.0.txt index 9b3c72a..d277a45 100644 --- a/Tests/aweXpect.Mockolate.Api.Tests/Expected/aweXpect.Mockolate_net10.0.txt +++ b/Tests/aweXpect.Mockolate.Api.Tests/Expected/aweXpect.Mockolate_net10.0.txt @@ -1,5 +1,26 @@ [assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/aweXpect/aweXpect.Mockolate.git")] [assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")] +namespace Mockolate.Web +{ + public static class AweXpectItExtensions + { + public interface IJsonContentBodyParameter : Mockolate.Parameters.IParameter, Mockolate.Web.AweXpectItExtensions.IJsonContentParameter, Mockolate.Web.ItExtensions.IHttpContentParameter + { + Mockolate.Web.AweXpectItExtensions.IJsonContentBodyParameter IgnoringAdditionalProperties(bool ignoreAdditionalProperties = true); + } + public interface IJsonContentParameter : Mockolate.Parameters.IParameter, Mockolate.Web.ItExtensions.IHttpContentParameter + { + Mockolate.Web.AweXpectItExtensions.IJsonContentBodyParameter WithBody(string json, System.Text.Json.JsonDocumentOptions? options = default); + Mockolate.Web.AweXpectItExtensions.IJsonContentBodyParameter WithBodyMatching(object? expected, System.Text.Json.JsonDocumentOptions? options = default); + Mockolate.Web.AweXpectItExtensions.IJsonContentBodyParameter WithBodyMatching(System.Collections.Generic.IEnumerable expected, System.Text.Json.JsonDocumentOptions? options = default); + } + extension(Mockolate.It _) + { + Mockolate.Web.AweXpectItExtensions.IJsonContentParameter IsJsonContent(); + Mockolate.Web.AweXpectItExtensions.IJsonContentParameter IsJsonContent(string mediaType); + } + } +} namespace aweXpect { public static class ThatMockVerify diff --git a/Tests/aweXpect.Mockolate.Api.Tests/Expected/aweXpect.Mockolate_net8.0.txt b/Tests/aweXpect.Mockolate.Api.Tests/Expected/aweXpect.Mockolate_net8.0.txt index fe91a4d..dc13a11 100644 --- a/Tests/aweXpect.Mockolate.Api.Tests/Expected/aweXpect.Mockolate_net8.0.txt +++ b/Tests/aweXpect.Mockolate.Api.Tests/Expected/aweXpect.Mockolate_net8.0.txt @@ -1,5 +1,26 @@ [assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/aweXpect/aweXpect.Mockolate.git")] [assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")] +namespace Mockolate.Web +{ + public static class AweXpectItExtensions + { + public interface IJsonContentBodyParameter : Mockolate.Parameters.IParameter, Mockolate.Web.AweXpectItExtensions.IJsonContentParameter, Mockolate.Web.ItExtensions.IHttpContentParameter + { + Mockolate.Web.AweXpectItExtensions.IJsonContentBodyParameter IgnoringAdditionalProperties(bool ignoreAdditionalProperties = true); + } + public interface IJsonContentParameter : Mockolate.Parameters.IParameter, Mockolate.Web.ItExtensions.IHttpContentParameter + { + Mockolate.Web.AweXpectItExtensions.IJsonContentBodyParameter WithBody(string json, System.Text.Json.JsonDocumentOptions? options = default); + Mockolate.Web.AweXpectItExtensions.IJsonContentBodyParameter WithBodyMatching(object? expected, System.Text.Json.JsonDocumentOptions? options = default); + Mockolate.Web.AweXpectItExtensions.IJsonContentBodyParameter WithBodyMatching(System.Collections.Generic.IEnumerable expected, System.Text.Json.JsonDocumentOptions? options = default); + } + extension(Mockolate.It _) + { + Mockolate.Web.AweXpectItExtensions.IJsonContentParameter IsJsonContent(); + Mockolate.Web.AweXpectItExtensions.IJsonContentParameter IsJsonContent(string mediaType); + } + } +} namespace aweXpect { public static class ThatMockVerify diff --git a/Tests/aweXpect.Mockolate.Tests/Web/ItExtensionsTests.HttpContentTests.IsJsonContentTests.cs b/Tests/aweXpect.Mockolate.Tests/Web/ItExtensionsTests.HttpContentTests.IsJsonContentTests.cs new file mode 100644 index 0000000..09549bd --- /dev/null +++ b/Tests/aweXpect.Mockolate.Tests/Web/ItExtensionsTests.HttpContentTests.IsJsonContentTests.cs @@ -0,0 +1,548 @@ +#if NET8_0_OR_GREATER +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using Mockolate; +using Mockolate.Web; + +namespace aweXpect.Mockolate.Tests.Web; + +public sealed partial class ItExtensionsTests +{ + public sealed class HttpContentTests + { + public sealed class IsJsonContentTests + { + [Theory] + [InlineData("true", true, true)] + [InlineData("true", false, false)] + [InlineData("false", true, false)] + [InlineData("false", false, true)] + public async Task BooleanValue_ShouldSucceedWhenMatching(string body, bool expected, bool expectSuccess) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(expected)) + .ReturnsAsync(new HttpResponseMessage(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("42.1", 42.1, true)] + [InlineData("1.2", 2.1, false)] + public async Task DoubleValue_ShouldSucceedWhenMatching(string body, double expected, bool expectSuccess) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(expected)) + .ReturnsAsync(new HttpResponseMessage(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("42", 42, true)] + [InlineData("1", 2, false)] + public async Task IntegerValue_ShouldSucceedWhenMatching(string body, int expected, bool expectSuccess) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(expected)) + .ReturnsAsync(new HttpResponseMessage(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("null", true)] + [InlineData("{}", false)] + public async Task NullValue_ShouldSucceedWhenMatching(string body, bool expectSuccess) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(null)) + .ReturnsAsync(new HttpResponseMessage(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); + } + + [Fact] + public async Task ShouldSupportNestedObjects() + { + string body = "[{\"foo\": 2}, {\"foo\": 3}, {\"foo\": 4}]"; + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching([ + new + { + foo = 2, + }, + new + { + foo = 3, + }, + new + { + foo = 4, + }, + ])) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(HttpStatusCode.OK); + } + + [Theory] + [InlineData("application/json", true)] + [InlineData("text/plain", false)] + [InlineData("application/txt", false)] + public async Task ShouldVerifyMediaType(string mediaType, bool expectSuccess) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent("application/json")) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent("", Encoding.UTF8, mediaType), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(expectSuccess ? HttpStatusCode.OK : HttpStatusCode.NotImplemented); + } + + [Theory] + [InlineData("\"foo\"", "foo", true)] + [InlineData("\"foo\"", "bar", false)] + public async Task StringValue_ShouldSucceedWhenMatching(string body, string expected, bool expectSuccess) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(expected)) + .ReturnsAsync(new HttpResponseMessage(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("{\n \"foo\": 1,\n \"bar\": 2,\n}", "{\"bar\":2,\"foo\": 1}", true)] + [InlineData("\"foo\"", "\"foo\"", true)] + [InlineData("foo", "bar", false)] + public async Task WithBody_ShouldCompareAsJson(string body, + string expected, bool expectSuccess) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBody(expected)) + .ReturnsAsync(new HttpResponseMessage(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); + } + + [Fact] + public async Task WithBodyMatching_ShouldCompareAsJson() + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(new + { + foo = 1, + bar = 2, + })) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(""" + { + "bar": 2, + "foo": 1 + } + """), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(HttpStatusCode.OK); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WithBodyMatching_WithAdditionalProperties_ShouldMatchWhenIgnoringAdditionalProperties( + bool ignoreAdditionalProperties) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(new + { + foo = 1, + bar = 2, + }).IgnoringAdditionalProperties(ignoreAdditionalProperties)) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(""" + { + "bar": 2, + "foo": 1, + "baz": null, + } + """), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(ignoreAdditionalProperties ? HttpStatusCode.OK : HttpStatusCode.NotImplemented); + } + + [Theory] + [InlineData("[1, 2,]", "[1, 2,]", true)] + [InlineData("[1, 2,]", "[1, 2,]", false)] + [InlineData("[1, 2]", "[1, 2,]", false)] + [InlineData("[1, 2,]", "[1, 2]", false)] + public async Task WithOptions_ShouldApplyOptions(string body, string expected, bool allowTrailingCommas) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBody(expected, new JsonDocumentOptions + { + AllowTrailingCommas = allowTrailingCommas, + })) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(allowTrailingCommas ? HttpStatusCode.OK : HttpStatusCode.NotImplemented); + } + + public sealed class ArrayTests + { + [Theory] + [MemberData(nameof(MatchingArrayValues))] + public async Task MatchingValues_ShouldSucceed(string[] expected, string body) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(expected)) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(HttpStatusCode.OK); + } + + [Theory] + [MemberData(nameof(NotMatchingArrayValues))] + public async Task NotMatchingValues_ShouldFail(string[] expected, string body) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(expected)) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsNotEqualTo(HttpStatusCode.OK); + } + + [Fact] + public async Task WhenElementsAreInDifferentOrder_ShouldFail() + { + string body = "[1, 2]"; + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching([2, 1,])) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsNotEqualTo(HttpStatusCode.OK); + } + + [Fact] + public async Task WhenExpectedContainsAdditionalElements_ShouldFail() + { + string body = "[1, 2]"; + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching([1, 2, 3,])) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsNotEqualTo(HttpStatusCode.OK); + } + + [Fact] + public async Task WhenSubjectContainsAdditionalElements_ShouldSucceed() + { + string body = "[1, 2, 3]"; + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching([1, 2,])) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(HttpStatusCode.OK); + } + + [Fact] + public async Task WhenSubjectContainsAdditionalElements_WhenNotIgnoringAdditionalProperties_ShouldFail() + { + string body = "[1, 2, 3]"; + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), + It.IsJsonContent().WithBodyMatching([1, 2,]).IgnoringAdditionalProperties(false)) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsNotEqualTo(HttpStatusCode.OK); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WithOptions_ShouldApplyOptions(bool allowTrailingCommas) + { + string body = "[1, 2,]"; + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching([1, 2,], new JsonDocumentOptions + { + AllowTrailingCommas = allowTrailingCommas, + })) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(allowTrailingCommas ? HttpStatusCode.OK : HttpStatusCode.NotImplemented); + } + + public static TheoryData MatchingArrayValues + => new() + { + { + [], "[]" + }, + { + [], "[\"foo\"]" + }, + { + [ + "foo", "bar", + ], + "[\"foo\", \"bar\"]" + }, + }; + + public static TheoryData NotMatchingArrayValues + => new() + { + { + [ + "foo", + ], + "[]" + }, + { + [ + "bar", "foo", + ], + "[\"foo\", \"bar\"]" + }, + }; + } + + public sealed class ObjectTests + { + [Theory] + [InlineData("{}", false)] + [InlineData("{\"foo\": 2}", true)] + public async Task ShouldFailIfPropertyIsMissing(string body, bool expectSuccess) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(new + { + foo = 2, + })) + .ReturnsAsync(new HttpResponseMessage(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("{}")] + [InlineData("{\"foo\": 1}")] + public async Task WhenExpectedIsEmpty_ShouldSucceed(string body) + { + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(new object())) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(HttpStatusCode.OK); + } + + [Fact] + public async Task WhenPropertyHasDifferentValue_ShouldFail() + { + string body = "{\"bar\": 2}"; + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(new + { + bar = 3, + })) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsNotEqualTo(HttpStatusCode.OK); + } + + [Fact] + public async Task WhenSubjectHasAdditionalProperties_ShouldSucceed() + { + string body = "{\"foo\": null, \"bar\": 2}"; + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(new + { + bar = 2, + })) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(HttpStatusCode.OK); + } + + [Fact] + public async Task WhenSubjectHasAdditionalProperties_WhenNotIgnoringAdditionalProperties_ShouldFail() + { + string body = "{\"foo\": null, \"bar\": 2}"; + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(new + { + bar = 2, + }).IgnoringAdditionalProperties(false)) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsNotEqualTo(HttpStatusCode.OK); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WithOptions_ShouldApplyOptions(bool allowTrailingCommas) + { + string body = "{\"foo\": 1,}"; + HttpClient httpClient = Mock.Create(); + httpClient.SetupMock.Method + .PostAsync(It.IsAny(), It.IsJsonContent().WithBodyMatching(new + { + foo = 1, + }, + new JsonDocumentOptions + { + AllowTrailingCommas = allowTrailingCommas, + })) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + HttpResponseMessage result = await httpClient.PostAsync("https://www.aweXpect.com", + new StringContent(body), + CancellationToken.None); + + await That(result.StatusCode) + .IsEqualTo(allowTrailingCommas ? HttpStatusCode.OK : HttpStatusCode.NotImplemented); + } + } + } + } +} +#endif diff --git a/Tests/aweXpect.Mockolate.Tests/Web/ItExtensionsTests.cs b/Tests/aweXpect.Mockolate.Tests/Web/ItExtensionsTests.cs new file mode 100644 index 0000000..7a95d96 --- /dev/null +++ b/Tests/aweXpect.Mockolate.Tests/Web/ItExtensionsTests.cs @@ -0,0 +1,5 @@ +#if NET8_0_OR_GREATER +namespace aweXpect.Mockolate.Tests.Web; + +public sealed partial class ItExtensionsTests; +#endif