Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 = Match.Regex(expected2.Status.ToString(), string.Join("|", Enum.GetNames<OrderStatus>())),
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()
{
Expand Down
120 changes: 120 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,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,
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
15 changes: 13 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,15 @@ private async Task EnsureEventExistsAsync(IDictionary<string, object> parameters
await this.orders.InsertAsync(new OrderDto(id.GetInt32(), OrderStatus.Fulfilling, DateTimeOffset.Now));
}

private async Task EnsureEventsExistAsync(IDictionary<string, object> 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));
}
}

/// <summary>
/// Handle the request
/// </summary>
Expand Down Expand Up @@ -79,7 +90,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
24 changes: 24 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,29 @@ public async Task<IActionResult> GetByIdAsync(int id)
}
}

[HttpGet("many/{ids}", Name = "getMany")]
[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
28 changes: 28 additions & 0 deletions src/PactNet.Abstractions/Matchers/ArrayContainsMatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;

namespace PactNet.Matchers
{
public class ArrayContainsMatcher : IMatcher
{
/// <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)
Copy link
Contributor

Choose a reason for hiding this comment

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

question: This passes in an array, but we store the Value field as a single dynamic - is that correct? I would've thought it would have been a collection also.

suggestion: Use ICollection instead of an array for a more ergonomic API

{
Value = variants;
}
}
}
5 changes: 5 additions & 0 deletions src/PactNet.Abstractions/Matchers/Match.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,5 +168,10 @@ public static IMatcher Include(string example)
{
return new IncludeMatcher(example);
}

public static IMatcher ArrayContains(dynamic[] variations)
{
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""]}");
}
}
}
Loading