Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add array contains matcher #518

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
52 changes: 52 additions & 0 deletions samples/OrdersApi/Consumer.Tests/OrdersClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> { ["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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: I think for this example to 'come to life' then we'd really need it to match multiple different possible variants

Perhaps if there were different order types or something so that they could have different fields on them, then the matching rules could reflect those variants. Like a fulfilled order has an extra date on it for when it was completed, or something like that.

{
Id = Match.Integer(expected1.Id),
Status = Match.Regex(expected1.Status.ToString(), string.Join("|", Enum.GetNames<OrderStatus>())),
Date = Match.Type(expected1.Date.ToString("O"))
},
new
{
Id = Match.Integer(expected2.Id),
Status = expected2.Status,
Date = Match.Regex("2023-06-29T12:13:14.000000+01:00", @"\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d\.\d\d\d\d\d\d+\d\d:\d\d")
},
}));

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()
{
Expand Down
112 changes: 112 additions & 0 deletions samples/OrdersApi/Consumer.Tests/pacts/Fulfilment API-Orders API.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,118 @@
},
"type": "Synchronous/HTTP"
},
{
"description": "a request for multiple orders by id",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: This interaction appears to be in here twice. I was hoping the updated FFI would fix this issue, but perhaps it doesn't

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is my fault, can see the description here is different to the one above.
Will amend

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

"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.000000+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": "regex",
"regex": "\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d\\.\\d\\d\\d\\d\\d\\d+\\d\\d:\\d\\d"
}
]
},
"$.id": {
"combine": "AND",
"matchers": [
{
"match": "integer"
}
]
}
}
}
]
}
]
}
}
},
"status": 200
},
"type": "Synchronous/HTTP"
},
{
"description": "a request to update the status of an order",
"pending": false,
Expand Down
16 changes: 15 additions & 1 deletion samples/OrdersApi/Consumer/OrdersClient.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -40,6 +41,19 @@ public async Task<OrderDto> GetOrderAsync(int orderId)
return order;
}

/// <summary>
/// Get a orders by ID
/// </summary>
/// <param name="orderIds">Order IDs</param>
/// <returns>Order</returns>
public async Task<OrderDto[]> GetOrdersAsync(IEnumerable<int> orderIds)
{
using HttpClient client = this.factory.CreateClient("Orders");

OrderDto[] orders = await client.GetFromJsonAsync<OrderDto[]>($"/api/orders/many/{string.Join(',', orderIds)}", Options);
return orders;
}

/// <summary>
/// Update the status of an order
/// </summary>
Expand Down
20 changes: 18 additions & 2 deletions samples/OrdersApi/Provider.Tests/ProviderStateMiddleware.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 System.Text.Json;
Expand Down Expand Up @@ -36,7 +37,8 @@ public ProviderStateMiddleware(RequestDelegate next, IOrderRepository orders)

this.providerStates = new Dictionary<string, Func<IDictionary<string, object>, Task>>
{
["an order with ID {id} exists"] = this.EnsureEventExistsAsync
["an order with ID {id} exists"] = this.EnsureEventExistsAsync,
["orders with ids {ids} exist"] = this.EnsureEventsExistAsync
};
}

Expand All @@ -52,6 +54,20 @@ private async Task EnsureEventExistsAsync(IDictionary<string, object> parameters
await this.orders.InsertAsync(new OrderDto(id.GetInt32(), OrderStatus.Fulfilling, DateTimeOffset.Now));
}

/// <summary>
/// Ensure a series of events exist
/// </summary>
/// <param name="parameters">Event parameters</param>
/// <returns>Awaitable</returns>
private async Task EnsureEventsExistAsync(IDictionary<string, object> parameters)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs: Needs doc string like other methods

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

{
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));
}
}

/// <summary>
/// Handle the request
/// </summary>
Expand Down Expand Up @@ -79,7 +95,7 @@ public async Task InvokeAsync(HttpContext context)
try
{
ProviderState providerState = JsonSerializer.Deserialize<ProviderState>(jsonRequestBody, Options);

if (!string.IsNullOrEmpty(providerState?.State))
{
await this.providerStates[providerState.State].Invoke(providerState.Params);
Expand Down
29 changes: 29 additions & 0 deletions samples/OrdersApi/Provider/Orders/OrdersController.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -46,6 +47,34 @@ public async Task<IActionResult> GetByIdAsync(int id)
}
}

/// <summary>
/// Get several orders by their comma-separated IDs
/// </summary>
/// <param name="ids"></param>
/// <returns></returns>
[HttpGet("many/{ids}", Name = "getMany")]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs: Needs doc string like other methods

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

[ProducesResponseType(typeof(OrderDto[]), StatusCodes.Status200OK)]
public async Task<IActionResult> GetManyAsync(string ids)
{
try
{
var idsAsInts = ids.Split(',').Select(int.Parse);

List<OrderDto> result = new List<OrderDto>();
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();
}
}

/// <summary>
/// Create a new pending order
/// </summary>
Expand Down
31 changes: 31 additions & 0 deletions src/PactNet.Abstractions/Matchers/ArrayContainsMatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;

namespace PactNet.Matchers
{
/// <summary>
/// Matcher for array-contains. Checks whether an array contains the specified variations.
/// </summary>
public class ArrayContainsMatcher : IMatcher
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs: Needs doc string like other matchers

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

{
/// <summary>
/// Type of the matcher
/// </summary>
[JsonPropertyName("pact:matcher:type")]
public string Type => "array-contains";

/// <summary>
/// The items expected to be in the array.
/// </summary>
[JsonPropertyName("variants")]
public dynamic Value { get; }

/// <summary>
/// Initialises a new instance of the <see cref="ArrayContainsMatcher"/> class.
/// </summary>
/// <param name="variants"></param>
public ArrayContainsMatcher(dynamic[] variants)
{
Value = variants;
}
}
}
10 changes: 10 additions & 0 deletions src/PactNet.Abstractions/Matchers/Match.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,5 +168,15 @@ public static IMatcher Include(string example)
{
return new IncludeMatcher(example);
}

/// <summary>
/// Matcher which matches an array containing the specified variations.
/// </summary>
/// <param name="variations">Variations which should be contained in the array.</param>
/// <returns>Matcher</returns>
public static IMatcher ArrayContains(dynamic[] variations)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: The public API should be documented

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted, will amend

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one still needs documenting

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

{
return new ArrayContainsMatcher(variations);
}
}
}
3 changes: 3 additions & 0 deletions src/PactNet.Abstractions/Matchers/MatcherConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()}");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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""]}");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: You can use new string literals to format the JSON nicely here

Copy link
Author

@MaxCampman MaxCampman Sep 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, quite the oversight from me

Nevermind: ArrayContainsMatcherTests.cs(27, 17): [CS8370] Feature 'raw string literals' is not available in C# 7.3. Please use language version 11.0 or greater.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes my bad. These have to support .Net Framework also

}
}
}
Loading