From 1915b96d6fdd2d8ad16b6510069e5f1424ade750 Mon Sep 17 00:00:00 2001 From: "M. J. W-H. Campman" Date: Mon, 5 Aug 2024 20:46:06 -0400 Subject: [PATCH] feat: add array contains matcher --- .gitignore | 2 +- .../Consumer.Tests/OrdersClientTests.cs | 52 ++++ .../pacts/Fulfilment API-Orders API.json | 120 +++++++++ samples/OrdersApi/Consumer/OrdersClient.cs | 16 +- .../Provider.Tests/ProviderStateMiddleware.cs | 15 +- .../Provider/Orders/OrdersController.cs | 24 ++ .../Matchers/ArrayContainsMatcher.cs | 24 ++ src/PactNet.Abstractions/Matchers/Match.cs | 5 + .../Matchers/MatcherConverter.cs | 3 + .../Matchers/ArrayContainsMatcherTests.cs | 29 ++ .../Matchers/MatchTests.cs | 19 +- .../Matchers/ArrayContainsMatcherTests.cs | 252 ++++++++++++++++++ 12 files changed, 556 insertions(+), 5 deletions(-) create mode 100644 src/PactNet.Abstractions/Matchers/ArrayContainsMatcher.cs create mode 100644 tests/PactNet.Abstractions.Tests/Matchers/ArrayContainsMatcherTests.cs create mode 100644 tests/PactNet.Tests/Matchers/ArrayContainsMatcherTests.cs diff --git a/.gitignore b/.gitignore index 9cc9bc67..c21499ba 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,4 @@ dist/ samples/ReadMe/Provider.Tests/Properties/launchSettings.json samples/ReadMe/Provider/Properties/launchSettings.json -.idea/ +.idea/ diff --git a/samples/OrdersApi/Consumer.Tests/OrdersClientTests.cs b/samples/OrdersApi/Consumer.Tests/OrdersClientTests.cs index a9127c1b..70323032 100644 --- a/samples/OrdersApi/Consumer.Tests/OrdersClientTests.cs +++ b/samples/OrdersApi/Consumer.Tests/OrdersClientTests.cs @@ -84,6 +84,58 @@ await this.pact.VerifyAsync(async ctx => }); } + [Fact] + public async Task GetOrdersAsync_WhenCalled_ReturnsMultipleOrders() + { + var expected1 = new OrderDto(1, OrderStatus.Pending, new DateTimeOffset(2023, 6, 28, 12, 13, 14, TimeSpan.FromHours(1))); + var expected2 = new OrderDto(2, OrderStatus.Pending, new DateTimeOffset(2023, 6, 29, 12, 13, 14, TimeSpan.FromHours(1))); + + this.pact + .UponReceiving("a request for multiple orders by id") + .Given("orders with ids {ids} exist", new Dictionary { ["ids"] = "1,2" }) + .WithRequest(HttpMethod.Get, "/api/orders/many/1,2") + .WithHeader("Accept", "application/json") + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithJsonBody(Match.ArrayContains(new dynamic[] + { + new + { + Id = Match.Integer(expected1.Id), + Status = Match.Regex(expected1.Status.ToString(), string.Join("|", Enum.GetNames())), + Date = Match.Type(expected1.Date.ToString("O")) + }, + new + { + Id = Match.Integer(expected2.Id), + Status = Match.Regex(expected2.Status.ToString(), string.Join("|", Enum.GetNames())), + Date = Match.Type(expected2.Date.ToString("O")) + }, + })); + + await this.pact.VerifyAsync(async ctx => + { + this.mockFactory + .Setup(f => f.CreateClient("Orders")) + .Returns(() => new HttpClient + { + BaseAddress = ctx.MockServerUri, + DefaultRequestHeaders = + { + Accept = { MediaTypeWithQualityHeaderValue.Parse("application/json") } + } + }); + + var client = new OrdersClient(this.mockFactory.Object); + + OrderDto[] orders = await client.GetOrdersAsync(new[] { 1, 2 }); + + orders.Should().HaveCount(2); + orders[0].Should().Be(expected1); + orders[1].Should().Be(expected2); + }); + } + [Fact] public async Task GetOrderAsync_UnknownOrder_ReturnsNotFound() { diff --git a/samples/OrdersApi/Consumer.Tests/pacts/Fulfilment API-Orders API.json b/samples/OrdersApi/Consumer.Tests/pacts/Fulfilment API-Orders API.json index 24dd275e..6921e54d 100644 --- a/samples/OrdersApi/Consumer.Tests/pacts/Fulfilment API-Orders API.json +++ b/samples/OrdersApi/Consumer.Tests/pacts/Fulfilment API-Orders API.json @@ -88,6 +88,126 @@ }, "type": "Synchronous/HTTP" }, + { + "description": "a request for multiple orders by id", + "pending": false, + "providerStates": [ + { + "name": "orders with ids {ids} exist", + "params": { + "ids": "1,2" + } + } + ], + "request": { + "headers": { + "Accept": [ + "application/json" + ] + }, + "method": "GET", + "path": "/api/orders/many/1,2" + }, + "response": { + "body": { + "content": [ + { + "date": "2023-06-28T12:13:14.0000000+01:00", + "id": 1, + "status": "Pending" + }, + { + "date": "2023-06-29T12:13:14.0000000+01:00", + "id": 2, + "status": "Pending" + } + ], + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "matchingRules": { + "body": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "arrayContains", + "variants": [ + { + "index": 0, + "rules": { + "$.date": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.id": { + "combine": "AND", + "matchers": [ + { + "match": "integer" + } + ] + }, + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "Pending|Fulfilling|Shipped" + } + ] + } + } + }, + { + "index": 1, + "rules": { + "$.date": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.id": { + "combine": "AND", + "matchers": [ + { + "match": "integer" + } + ] + }, + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "Pending|Fulfilling|Shipped" + } + ] + } + } + } + ] + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, { "description": "a request to update the status of an order", "pending": false, diff --git a/samples/OrdersApi/Consumer/OrdersClient.cs b/samples/OrdersApi/Consumer/OrdersClient.cs index 85ec5ba6..6ce6aeea 100644 --- a/samples/OrdersApi/Consumer/OrdersClient.cs +++ b/samples/OrdersApi/Consumer/OrdersClient.cs @@ -1,4 +1,5 @@ -using System.Net.Http; +using System.Collections.Generic; +using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; @@ -40,6 +41,19 @@ public async Task GetOrderAsync(int orderId) return order; } + /// + /// Get a orders by ID + /// + /// Order IDs + /// Order + public async Task GetOrdersAsync(IEnumerable orderIds) + { + using HttpClient client = this.factory.CreateClient("Orders"); + + OrderDto[] orders = await client.GetFromJsonAsync($"/api/orders/many/{string.Join(',', orderIds)}", Options); + return orders; + } + /// /// Update the status of an order /// diff --git a/samples/OrdersApi/Provider.Tests/ProviderStateMiddleware.cs b/samples/OrdersApi/Provider.Tests/ProviderStateMiddleware.cs index ee1760bc..62da82a2 100644 --- a/samples/OrdersApi/Provider.Tests/ProviderStateMiddleware.cs +++ b/samples/OrdersApi/Provider.Tests/ProviderStateMiddleware.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 System.Text.Json; @@ -36,7 +37,8 @@ public ProviderStateMiddleware(RequestDelegate next, IOrderRepository orders) this.providerStates = new Dictionary, Task>> { - ["an order with ID {id} exists"] = this.EnsureEventExistsAsync + ["an order with ID {id} exists"] = this.EnsureEventExistsAsync, + ["orders with ids {ids} exist"] = this.EnsureEventsExistAsync }; } @@ -52,6 +54,15 @@ private async Task EnsureEventExistsAsync(IDictionary parameters await this.orders.InsertAsync(new OrderDto(id.GetInt32(), OrderStatus.Fulfilling, DateTimeOffset.Now)); } + private async Task EnsureEventsExistAsync(IDictionary parameters) + { + var ids = (JsonElement)parameters["ids"]; + foreach (var id in ids.GetString()!.Split(',').Select(int.Parse)) + { + await this.orders.InsertAsync(new OrderDto(id, OrderStatus.Fulfilling, DateTimeOffset.Now)); + } + } + /// /// Handle the request /// @@ -79,7 +90,7 @@ public async Task InvokeAsync(HttpContext context) try { ProviderState providerState = JsonSerializer.Deserialize(jsonRequestBody, Options); - + if (!string.IsNullOrEmpty(providerState?.State)) { await this.providerStates[providerState.State].Invoke(providerState.Params); diff --git a/samples/OrdersApi/Provider/Orders/OrdersController.cs b/samples/OrdersApi/Provider/Orders/OrdersController.cs index 3bfb5a7b..c97460fb 100644 --- a/samples/OrdersApi/Provider/Orders/OrdersController.cs +++ b/samples/OrdersApi/Provider/Orders/OrdersController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -46,6 +47,29 @@ public async Task GetByIdAsync(int id) } } + [HttpGet("many/{ids}", Name = "getMany")] + [ProducesResponseType(typeof(OrderDto[]), StatusCodes.Status200OK)] + public async Task GetManyAsync(string ids) + { + try + { + var idsAsInts = ids.Split(',').Select(int.Parse); + + List result = new List(); + foreach (int id in idsAsInts) + { + var order = await this.orders.GetAsync(id); + result.Add(order); + } + + return this.Ok(result.ToArray()); + } + catch (KeyNotFoundException) + { + return this.NotFound(); + } + } + /// /// Create a new pending order /// diff --git a/src/PactNet.Abstractions/Matchers/ArrayContainsMatcher.cs b/src/PactNet.Abstractions/Matchers/ArrayContainsMatcher.cs new file mode 100644 index 00000000..a9ffe98e --- /dev/null +++ b/src/PactNet.Abstractions/Matchers/ArrayContainsMatcher.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace PactNet.Matchers +{ + public class ArrayContainsMatcher : IMatcher + { + /// + /// Type of the matcher + /// + [JsonPropertyName("pact:matcher:type")] + public string Type => "array-contains"; + + /// + /// The items expected to be in the array. + /// + [JsonPropertyName("variants")] + public dynamic Value { get; } + + public ArrayContainsMatcher(dynamic[] variants) + { + Value = variants; + } + } +} diff --git a/src/PactNet.Abstractions/Matchers/Match.cs b/src/PactNet.Abstractions/Matchers/Match.cs index 7531fb29..5efbcd7a 100644 --- a/src/PactNet.Abstractions/Matchers/Match.cs +++ b/src/PactNet.Abstractions/Matchers/Match.cs @@ -168,5 +168,10 @@ public static IMatcher Include(string example) { return new IncludeMatcher(example); } + + public static IMatcher ArrayContains(dynamic[] variations) + { + return new ArrayContainsMatcher(variations); + } } } diff --git a/src/PactNet.Abstractions/Matchers/MatcherConverter.cs b/src/PactNet.Abstractions/Matchers/MatcherConverter.cs index e6493de3..42fd7881 100644 --- a/src/PactNet.Abstractions/Matchers/MatcherConverter.cs +++ b/src/PactNet.Abstractions/Matchers/MatcherConverter.cs @@ -52,6 +52,9 @@ public override void Write(Utf8JsonWriter writer, IMatcher value, JsonSerializer case TypeMatcher matcher: JsonSerializer.Serialize(writer, matcher, options); break; + case ArrayContainsMatcher matcher: + JsonSerializer.Serialize(writer, matcher, options); + break; default: throw new ArgumentOutOfRangeException($"Unsupported matcher: {value.GetType()}"); } diff --git a/tests/PactNet.Abstractions.Tests/Matchers/ArrayContainsMatcherTests.cs b/tests/PactNet.Abstractions.Tests/Matchers/ArrayContainsMatcherTests.cs new file mode 100644 index 00000000..927e8f41 --- /dev/null +++ b/tests/PactNet.Abstractions.Tests/Matchers/ArrayContainsMatcherTests.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using FluentAssertions; +using PactNet.Matchers; +using Xunit; + +namespace PactNet.Abstractions.Tests.Matchers +{ + public class ArrayContainsMatcherTests + { + [Fact] + public void Ctor_String_SerializesCorrectly() + { + // Arrange + var example = new[] + { + "Thing1", + "Thing2", + }; + + var matcher = new ArrayContainsMatcher(example); + + // Act + var actual = JsonSerializer.Serialize(matcher); + + // Assert + actual.Should().Be(@"{""pact:matcher:type"":""array-contains"",""variants"":[""Thing1"",""Thing2""]}"); + } + } +} diff --git a/tests/PactNet.Abstractions.Tests/Matchers/MatchTests.cs b/tests/PactNet.Abstractions.Tests/Matchers/MatchTests.cs index d0c96cf8..80f8543c 100644 --- a/tests/PactNet.Abstractions.Tests/Matchers/MatchTests.cs +++ b/tests/PactNet.Abstractions.Tests/Matchers/MatchTests.cs @@ -166,6 +166,23 @@ public void Include_WhenCalled_ReturnsMatcher() matcher.Should().BeEquivalentTo(new IncludeMatcher(example)); } + [Fact] + public void ArrayContainsMatcher_WhenCalled_ReturnsMatcher() + { + // Arrange + var example = new[] + { + "test1", + "test2", + }; + + // Act + var matcher = Match.ArrayContains(example); + + // Assert + matcher.Should().BeEquivalentTo(new ArrayContainsMatcher(example)); + } + [Fact] public void ComposingTwoTypeMatchers_WhenCalled_ReturnsAllMatchers() { @@ -200,7 +217,7 @@ public void ComposingAMinTypeMatcherAndATypeMatcher_WhenCalled_ReturnsAllMatcher }; IMatcher matcher = Match.MinType(expected, 2); - + object[] value = matcher.Value as object[]; value.First().Should().BeEquivalentTo(expected); } diff --git a/tests/PactNet.Tests/Matchers/ArrayContainsMatcherTests.cs b/tests/PactNet.Tests/Matchers/ArrayContainsMatcherTests.cs new file mode 100644 index 00000000..abec204d --- /dev/null +++ b/tests/PactNet.Tests/Matchers/ArrayContainsMatcherTests.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using PactNet.Matchers; +using Xunit; + +namespace PactNet.Tests.Matchers; + +public class ArrayContainsMatcherTests +{ + private readonly IPactBuilderV4 pactBuilder; + + public ArrayContainsMatcherTests() + { + pactBuilder = Pact.V4("Some Consumer", "Some Producer") + .WithHttpInteractions(); + } + + [Fact] + public async Task ConsumerTests_Simple_StringList() + { + // Arrange + this.pactBuilder + .UponReceiving("A request for strings") + .WithRequest(HttpMethod.Get, "/api/list/string") + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithJsonBody(Match.ArrayContains([ + "Thing 1", + "Thing 2" + ])); + + await this.pactBuilder.VerifyAsync(async ctx => + { + using var service = Service(ctx.MockServerUri); + + // Act + var strings = await service.GetStringList(); + + // Assert + strings.Should().Contain("Thing 1").And.Contain("Thing 2"); + }); + } + + [Fact] + public async Task ConsumerTests_Simple_StringList_RegexMatch() + { + // Arrange + this.pactBuilder + .UponReceiving("A request for strings") + .WithRequest(HttpMethod.Get, "/api/list/string") + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithJsonBody(Match.ArrayContains([ + Match.Regex("Thing 1", "Thing [0-9]+"), + Match.Regex("Thing X", "Thing [A-Z]+"), + ])); + + await this.pactBuilder.VerifyAsync(async ctx => + { + using var service = Service(ctx.MockServerUri); + + // Act + var strings = await service.GetStringList(); + + // Assert + strings.Should().Contain("Thing 1").And.Contain("Thing X"); + }); + } + + [Fact] + public async Task ConsumerTests_Simple_ObjectList() + { + // Arrange + this.pactBuilder + .UponReceiving("A request for strings") + .WithRequest(HttpMethod.Get, "/api/list/insert-or-update") + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithJsonBody(Match.ArrayContains([ + new InsertOrUpdate + { + Type = ActionEnum.Insert, + Content = new ContentDto + { + Name = "Name 1", + } + }, + new InsertOrUpdate + { + Type = ActionEnum.Update, + Content = new ContentDto + { + Name = "Name 2", + } + }, + ])); + + await this.pactBuilder.VerifyAsync(async ctx => + { + using var service = Service(ctx.MockServerUri); + + // Act + var strings = await service.GetInsertOrUpdateList(); + + // Assert + strings.Should() + .Contain(new InsertOrUpdate + { + Type = ActionEnum.Insert, + Content = new ContentDto + { + Name = "Name 1", + } + }) + .And + .Contain(new InsertOrUpdate + { + Type = ActionEnum.Update, + Content = new ContentDto + { + Name = "Name 2", + } + }); + }); + } + + [Fact] + public async Task ConsumerTests_Simple_StringListList() + { + // Arrange + this.pactBuilder + .UponReceiving("A request for strings") + .WithRequest(HttpMethod.Get, "/api/list/list/string") + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithJsonBody(Match.ArrayContains([ + new[] { "A", "B" }, + new[] { "C", "D" }, + ])); + + await this.pactBuilder.VerifyAsync(async ctx => + { + using var service = Service(ctx.MockServerUri); + + // Act + var strings = await service.GetStringListList(); + + // Assert + strings.Should() + .Contain(xs => xs.Count == 2 && xs[0] == "A" && xs[1] == "B") + .And + .Contain(xs => xs.Count == 2 && xs[0] == "C" && xs[1] == "D"); + }); + } + + [Fact] + public async Task ConsumerTests_Nested_StringListList() + { + // Arrange + this.pactBuilder + .UponReceiving("A request for strings") + .WithRequest(HttpMethod.Get, "/api/list/list/string") + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithJsonBody(Match.ArrayContains([ + Match.ArrayContains(["A", "B"]), + Match.ArrayContains(["C", "D"]), + ])); + + await this.pactBuilder.VerifyAsync(async ctx => + { + using var service = Service(ctx.MockServerUri); + + // Act + var strings = await service.GetStringListList(); + + // Assert + strings.Should() + .Contain(xs => xs.Count == 2 && xs[0] == "A" && xs[1] == "B") + .And + .Contain(xs => xs.Count == 2 && xs[0] == "C" && xs[1] == "D"); + }); + } + + private static ArrayIncludeTestService Service(Uri uri) + => new(uri); + + private enum ActionEnum + { + Insert, + Update, + } + + private sealed record ContentDto + { + public string Name { get; set; } + } + + private sealed record InsertOrUpdate where T : IEquatable + { + public ActionEnum Type { get; set; } + public T Content { get; set; } + } + + private sealed class ArrayIncludeTestService : IDisposable + { + private readonly Uri baseUri; + private readonly HttpClient client; + + public ArrayIncludeTestService(Uri baseUri) + { + this.baseUri = baseUri; + this.client = new HttpClient(); + } + + private string BaseUri => this.baseUri.ToString().TrimEnd('/'); + + public Task> GetStringList() + { + return Get>("/api/list/string"); + } + + public Task>> GetStringListList() + { + return Get>>("/api/list/list/string"); + } + + public Task>> GetInsertOrUpdateList() + { + return Get>>("/api/list/insert-or-update"); + } + + private async Task Get(string url) + { + using var response = await this.client.GetAsync($"{BaseUri}{url}"); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(); + return await JsonSerializer.DeserializeAsync(stream); + } + + public void Dispose() + { + this.client.Dispose(); + } + } +}