diff --git a/README.md b/README.md index 4b93c2f..f43b091 100644 --- a/README.md +++ b/README.md @@ -599,11 +599,12 @@ Ogooreck provides a set of helpers to construct the request (e.g. `URI`, `BODY`) ```csharp public Task POST_CreatesNewMeeting() => - API.Given( + API.Given() + .When( + POST URI("/api/meetings/), BODY(new CreateMeeting(Guid.NewGuid(), "Event Sourcing Workshop")) ) - .When(POST) .Then(CREATED); ``` @@ -613,11 +614,12 @@ You can also specify headers, e.g. `IF_MATCH` to perform an optimistic concurren ```csharp public Task PUT_ConfirmsShoppingCart() => - API.Given( + API.Given() + .When( + PUT, URI($"/api/ShoppingCarts/{API.ShoppingCartId}/confirmation"), HEADERS(IF_MATCH(1)) ) - .When(PUT) .Then(OK); ``` @@ -627,10 +629,8 @@ You can also do response body assertions, to, e.g. out of the box check if the r ```csharp public Task GET_ReturnsShoppingCartDetails() => - API.Given( - URI($"/api/ShoppingCarts/{API.ShoppingCartId}") - ) - .When(GET) + API.Given() + .When(GET, URI($"/api/ShoppingCarts/{API.ShoppingCartId}")) .Then( OK, RESPONSE_BODY(new ShoppingCartDetails @@ -649,10 +649,9 @@ You can use various conditions, e.g. `RESPONSE_SUCCEEDED` waits until a response ```csharp public Task GET_ReturnsShoppingCartDetails() => - API.Given( - URI($"/api/ShoppingCarts/{API.ShoppingCartId}") - ) - .When(GET_UNTIL(RESPONSE_SUCCEEDED)) + API.Given() + .When(GET, URI($"/api/ShoppingCarts/{API.ShoppingCartId}")) + .Until(RESPONSE_SUCCEEDED) .Then( OK, RESPONSE_BODY(new ShoppingCartDetails @@ -669,10 +668,9 @@ You can also use `RESPONSE_ETAG_IS` helper to check if ETag matches your expecte ```csharp public Task GET_ReturnsShoppingCartDetails() => - API.Given( - URI($"/api/ShoppingCarts/{API.ShoppingCartId}") - ) - .When(GET_UNTIL(RESPONSE_ETAG_IS(2))) + API.Given() + .When(GET, URI($"/api/ShoppingCarts/{API.ShoppingCartId}")) + .Until(RESPONSE_ETAG_IS(2)) .Then( OK, RESPONSE_BODY(new ShoppingCartDetails @@ -691,14 +689,15 @@ You can also do custom checks on the body, providing expression. ```csharp public Task GET_ReturnsShoppingCartDetails() => - API.Given( + API.Given() + .When( + GET, URI($"{MeetingsSearchApi.MeetingsUrl}?filter={MeetingName}") ) - .When( - GET_UNTIL( - RESPONSE_BODY_MATCHES>( - meetings => meetings.Any(m => m.Id == MeetingId)) - )) + .UNTIL( + RESPONSE_BODY_MATCHES>( + meetings => meetings.Any(m => m.Id == MeetingId)) + ) .Then( RESPONSE_BODY>(meetings => meetings.Should().Contain(meeting => @@ -714,15 +713,47 @@ Of course, the delete keyword is also supported. ```csharp public Task DELETE_ShouldRemoveProductFromShoppingCart() => - API.Given( - URI( - $"/api/ShoppingCarts/{API.ShoppingCartId}/products/{API.ProductItem.ProductId}?quantity={RemovedCount}&unitPrice={API.UnitPrice}"), + API.Given() + .When( + DELETE, + URI($"/api/ShoppingCarts/{API.ShoppingCartId}/products/{API.ProductItem.ProductId}?quantity={RemovedCount}&unitPrice={API.UnitPrice}"), HEADERS(IF_MATCH(1)) ) - .When(DELETE) .Then(NO_CONTENT); ``` +### Using data from results of the previous tests + +For instance created id to shape proper URI. + +```csharp +public class CancelShoppingCartTests: IClassFixture> +{ + private readonly ApiSpecification API; + public CancelShoppingCartTests(ApiSpecification api) => API = api; + + public readonly Guid ClientId = Guid.NewGuid(); + + [Fact] + [Trait("Category", "Acceptance")] + public Task Delete_Should_Return_OK_And_Cancel_Shopping_Cart() => + API + .Given( + "Opened ShoppingCart", + POST, + URI("/api/ShoppingCarts"), + BODY(new OpenShoppingCartRequest(clientId: Guid.NewGuid())) + ) + .When( + "Cancel Shopping Cart", + DELETE, + URI(ctx => $"/api/ShoppingCarts/{ctx.GetCreatedId()}"), + HEADERS(IF_MATCH(0)) + ) + .Then(OK); +} +``` + ### Scenarios and advanced composition Ogooreck supports various ways of composing the API, e.g. @@ -736,19 +767,21 @@ public async Task POST_WithExistingSKU_ReturnsConflictStatus() => var request = new RegisterProductRequest("AA2039485", ValidName, ValidDescription); // first one should succeed - await API.Given( + await API.Given() + .When( + POST, URI("/api/products/"), BODY(request) ) - .When(POST) .Then(CREATED); // second one will fail with conflict - await API.Given( + await API.Given() + .When( + POST, URI("/api/products/"), BODY(request) ) - .When(POST) .Then(CONFLICT); } ``` @@ -756,23 +789,27 @@ public async Task POST_WithExistingSKU_ReturnsConflictStatus() => **Joining with `And`** ```csharp -public Task SendPackage_ShouldReturn_CreatedStatus_With_PackageId() => - API.Given( - URI("/api/Shipments/"), - BODY(new SendPackage(OrderId, ProductItems)) - ) - .When(POST) - .Then(CREATED) - .And(response => fixture.ShouldPublishInternalEventOfType( - @event => - @event.PackageId == response.GetCreatedId() - && @event.OrderId == OrderId - && @event.SentAt > TimeBeforeSending - && @event.ProductItems.Count == ProductItems.Count - && @event.ProductItems.All( - pi => ProductItems.Exists( - expi => expi.ProductId == pi.ProductId && expi.Quantity == pi.Quantity)) - )); +public async Task POST_WithExistingSKU_ReturnsConflictStatus() => +{ + // Given + var request = new RegisterProductRequest("AA2039485", ValidName, ValidDescription); + + // first one should succeed + await API.Given() + .When( + POST, + URI("/api/products/"), + BODY(request) + ) + .Then(CREATED) + .And() + .When( + POST, + URI("/api/products/"), + BODY(request) + ) + .Then(CONFLICT); +} ``` **Chained Api Scenario** @@ -784,11 +821,12 @@ public async Task Post_ShouldReturn_CreatedStatus_With_CartId() await API.Scenario( // Create Reservations - API.Given( + API.Given() + .When( + POST, URI("/api/Reservations/"), BODY(new CreateTentativeReservationRequest { SeatId = SeatId }) ) - .When(POST) .Then(CREATED, response => { @@ -797,10 +835,11 @@ public async Task Post_ShouldReturn_CreatedStatus_With_CartId() }), // Get reservation details - _ => API.Given( + _ => API.Given() + .When( + GET URI($"/api/Reservations/{createdReservationId}") ) - .When(GET) .Then( OK, RESPONSE_BODY(reservation => @@ -813,10 +852,8 @@ public async Task Post_ShouldReturn_CreatedStatus_With_CartId() })), // Get reservations list - _ => API.Given( - URI("/api/Reservations/") - ) - .When(GET) + _ => API.Given() + .When(GET, URI("/api/Reservations/")) .Then( OK, RESPONSE_BODY>(reservations => @@ -836,10 +873,8 @@ public async Task Post_ShouldReturn_CreatedStatus_With_CartId() })), // Get reservation history - _ => API.Given( - URI($"/api/Reservations/{createdReservationId}/history") - ) - .When(GET) + _ => API.Given() + .When(GET, URI($"/api/Reservations/{createdReservationId}/history")) .Then( OK, RESPONSE_BODY>(reservations => @@ -875,11 +910,12 @@ public class CreateMeetingTests: IClassFixture> [Fact] public Task CreateCommand_ShouldPublish_MeetingCreateEvent() => - API.Given( + API.Given() + .When( + POST, URI("/api/meetings/), BODY(new CreateMeeting(Guid.NewGuid(), "Event Sourcing Workshop")) ) - .When(POST) .Then(CREATED); } ``` @@ -890,42 +926,52 @@ public class CreateMeetingTests: IClassFixture> Sometimes you need to set up test data asynchronously (e.g. open a shopping cart before cancelling it). You might not want to pollute your tests code with test case setup or do more extended preparation. For that XUnit provides `IAsyncLifetime` interface. You can create a fixture derived from the `APISpecification` to benefit from built-in helpers and use it later in your tests. ```csharp -public class CancelShoppingCartFixture: ApiSpecification, IAsyncLifetime +public class GetProductDetailsFixture: ApiSpecification, IAsyncLifetime { - public Guid ShoppingCartId { get; private set; } + public ProductDetails ExistingProduct = default!; + + public GetProductDetailsFixture(): base(new WarehouseTestWebApplicationFactory()) { } public async Task InitializeAsync() { - var openResponse = await Send( - new ApiRequest(POST, URI("/api/ShoppingCarts"), BODY(new OpenShoppingCartRequest(Guid.NewGuid()))) - ); - - await CREATED(openResponse); + var registerProduct = new RegisterProductRequest("IN11111", "ValidName", "ValidDescription"); + var productId = await Given() + .When(POST, URI("/api/products"), BODY(registerProduct)) + .Then(CREATED) + .GetCreatedId(); - ShoppingCartId = openResponse.GetCreatedId(); + var (sku, name, description) = registerProduct; + ExistingProduct = new ProductDetails(productId, sku!, name!, description); } - public Task DisposeAsync() - { - Dispose(); - return Task.CompletedTask; - } + public Task DisposeAsync() => Task.CompletedTask; } -public class CancelShoppingCartTests: IClassFixture +public class GetProductDetailsTests: IClassFixture { - private readonly CancelShoppingCartFixture API; + private readonly GetProductDetailsFixture API; - public CancelShoppingCartTests(CancelShoppingCartFixture api) => API = api; + public GetProductDetailsTests(GetProductDetailsFixture api) => API = api; [Fact] - public async Task Delete_Should_Return_OK_And_Cancel_Shopping_Cart() => - API.Given( - URI($"/api/ShoppingCarts/{API.ShoppingCartId}"), - HEADERS(IF_MATCH(1)) - ) - .When(DELETE) - .Then(OK); + public Task ValidRequest_With_NoParams_ShouldReturn_200() => + API.Given() + .When(GET, URI($"/api/products/{API.ExistingProduct.Id}")) + .Then(OK, RESPONSE_BODY(API.ExistingProduct)); + + [Theory] + [InlineData(12)] + [InlineData("not-a-guid")] + public Task InvalidGuidId_ShouldReturn_404(object invalidId) => + API.Given() + .When(GET, URI($"/api/products/{invalidId}")) + .Then(NOT_FOUND); + + [Fact] + public Task NotExistingId_ShouldReturn_404() => + API.Given() + .When(GET, URI($"/api/products/{Guid.NewGuid()}")) + .Then(NOT_FOUND); } ``` diff --git a/mdsource/README.source.md b/mdsource/README.source.md index 4b93c2f..f43b091 100644 --- a/mdsource/README.source.md +++ b/mdsource/README.source.md @@ -599,11 +599,12 @@ Ogooreck provides a set of helpers to construct the request (e.g. `URI`, `BODY`) ```csharp public Task POST_CreatesNewMeeting() => - API.Given( + API.Given() + .When( + POST URI("/api/meetings/), BODY(new CreateMeeting(Guid.NewGuid(), "Event Sourcing Workshop")) ) - .When(POST) .Then(CREATED); ``` @@ -613,11 +614,12 @@ You can also specify headers, e.g. `IF_MATCH` to perform an optimistic concurren ```csharp public Task PUT_ConfirmsShoppingCart() => - API.Given( + API.Given() + .When( + PUT, URI($"/api/ShoppingCarts/{API.ShoppingCartId}/confirmation"), HEADERS(IF_MATCH(1)) ) - .When(PUT) .Then(OK); ``` @@ -627,10 +629,8 @@ You can also do response body assertions, to, e.g. out of the box check if the r ```csharp public Task GET_ReturnsShoppingCartDetails() => - API.Given( - URI($"/api/ShoppingCarts/{API.ShoppingCartId}") - ) - .When(GET) + API.Given() + .When(GET, URI($"/api/ShoppingCarts/{API.ShoppingCartId}")) .Then( OK, RESPONSE_BODY(new ShoppingCartDetails @@ -649,10 +649,9 @@ You can use various conditions, e.g. `RESPONSE_SUCCEEDED` waits until a response ```csharp public Task GET_ReturnsShoppingCartDetails() => - API.Given( - URI($"/api/ShoppingCarts/{API.ShoppingCartId}") - ) - .When(GET_UNTIL(RESPONSE_SUCCEEDED)) + API.Given() + .When(GET, URI($"/api/ShoppingCarts/{API.ShoppingCartId}")) + .Until(RESPONSE_SUCCEEDED) .Then( OK, RESPONSE_BODY(new ShoppingCartDetails @@ -669,10 +668,9 @@ You can also use `RESPONSE_ETAG_IS` helper to check if ETag matches your expecte ```csharp public Task GET_ReturnsShoppingCartDetails() => - API.Given( - URI($"/api/ShoppingCarts/{API.ShoppingCartId}") - ) - .When(GET_UNTIL(RESPONSE_ETAG_IS(2))) + API.Given() + .When(GET, URI($"/api/ShoppingCarts/{API.ShoppingCartId}")) + .Until(RESPONSE_ETAG_IS(2)) .Then( OK, RESPONSE_BODY(new ShoppingCartDetails @@ -691,14 +689,15 @@ You can also do custom checks on the body, providing expression. ```csharp public Task GET_ReturnsShoppingCartDetails() => - API.Given( + API.Given() + .When( + GET, URI($"{MeetingsSearchApi.MeetingsUrl}?filter={MeetingName}") ) - .When( - GET_UNTIL( - RESPONSE_BODY_MATCHES>( - meetings => meetings.Any(m => m.Id == MeetingId)) - )) + .UNTIL( + RESPONSE_BODY_MATCHES>( + meetings => meetings.Any(m => m.Id == MeetingId)) + ) .Then( RESPONSE_BODY>(meetings => meetings.Should().Contain(meeting => @@ -714,15 +713,47 @@ Of course, the delete keyword is also supported. ```csharp public Task DELETE_ShouldRemoveProductFromShoppingCart() => - API.Given( - URI( - $"/api/ShoppingCarts/{API.ShoppingCartId}/products/{API.ProductItem.ProductId}?quantity={RemovedCount}&unitPrice={API.UnitPrice}"), + API.Given() + .When( + DELETE, + URI($"/api/ShoppingCarts/{API.ShoppingCartId}/products/{API.ProductItem.ProductId}?quantity={RemovedCount}&unitPrice={API.UnitPrice}"), HEADERS(IF_MATCH(1)) ) - .When(DELETE) .Then(NO_CONTENT); ``` +### Using data from results of the previous tests + +For instance created id to shape proper URI. + +```csharp +public class CancelShoppingCartTests: IClassFixture> +{ + private readonly ApiSpecification API; + public CancelShoppingCartTests(ApiSpecification api) => API = api; + + public readonly Guid ClientId = Guid.NewGuid(); + + [Fact] + [Trait("Category", "Acceptance")] + public Task Delete_Should_Return_OK_And_Cancel_Shopping_Cart() => + API + .Given( + "Opened ShoppingCart", + POST, + URI("/api/ShoppingCarts"), + BODY(new OpenShoppingCartRequest(clientId: Guid.NewGuid())) + ) + .When( + "Cancel Shopping Cart", + DELETE, + URI(ctx => $"/api/ShoppingCarts/{ctx.GetCreatedId()}"), + HEADERS(IF_MATCH(0)) + ) + .Then(OK); +} +``` + ### Scenarios and advanced composition Ogooreck supports various ways of composing the API, e.g. @@ -736,19 +767,21 @@ public async Task POST_WithExistingSKU_ReturnsConflictStatus() => var request = new RegisterProductRequest("AA2039485", ValidName, ValidDescription); // first one should succeed - await API.Given( + await API.Given() + .When( + POST, URI("/api/products/"), BODY(request) ) - .When(POST) .Then(CREATED); // second one will fail with conflict - await API.Given( + await API.Given() + .When( + POST, URI("/api/products/"), BODY(request) ) - .When(POST) .Then(CONFLICT); } ``` @@ -756,23 +789,27 @@ public async Task POST_WithExistingSKU_ReturnsConflictStatus() => **Joining with `And`** ```csharp -public Task SendPackage_ShouldReturn_CreatedStatus_With_PackageId() => - API.Given( - URI("/api/Shipments/"), - BODY(new SendPackage(OrderId, ProductItems)) - ) - .When(POST) - .Then(CREATED) - .And(response => fixture.ShouldPublishInternalEventOfType( - @event => - @event.PackageId == response.GetCreatedId() - && @event.OrderId == OrderId - && @event.SentAt > TimeBeforeSending - && @event.ProductItems.Count == ProductItems.Count - && @event.ProductItems.All( - pi => ProductItems.Exists( - expi => expi.ProductId == pi.ProductId && expi.Quantity == pi.Quantity)) - )); +public async Task POST_WithExistingSKU_ReturnsConflictStatus() => +{ + // Given + var request = new RegisterProductRequest("AA2039485", ValidName, ValidDescription); + + // first one should succeed + await API.Given() + .When( + POST, + URI("/api/products/"), + BODY(request) + ) + .Then(CREATED) + .And() + .When( + POST, + URI("/api/products/"), + BODY(request) + ) + .Then(CONFLICT); +} ``` **Chained Api Scenario** @@ -784,11 +821,12 @@ public async Task Post_ShouldReturn_CreatedStatus_With_CartId() await API.Scenario( // Create Reservations - API.Given( + API.Given() + .When( + POST, URI("/api/Reservations/"), BODY(new CreateTentativeReservationRequest { SeatId = SeatId }) ) - .When(POST) .Then(CREATED, response => { @@ -797,10 +835,11 @@ public async Task Post_ShouldReturn_CreatedStatus_With_CartId() }), // Get reservation details - _ => API.Given( + _ => API.Given() + .When( + GET URI($"/api/Reservations/{createdReservationId}") ) - .When(GET) .Then( OK, RESPONSE_BODY(reservation => @@ -813,10 +852,8 @@ public async Task Post_ShouldReturn_CreatedStatus_With_CartId() })), // Get reservations list - _ => API.Given( - URI("/api/Reservations/") - ) - .When(GET) + _ => API.Given() + .When(GET, URI("/api/Reservations/")) .Then( OK, RESPONSE_BODY>(reservations => @@ -836,10 +873,8 @@ public async Task Post_ShouldReturn_CreatedStatus_With_CartId() })), // Get reservation history - _ => API.Given( - URI($"/api/Reservations/{createdReservationId}/history") - ) - .When(GET) + _ => API.Given() + .When(GET, URI($"/api/Reservations/{createdReservationId}/history")) .Then( OK, RESPONSE_BODY>(reservations => @@ -875,11 +910,12 @@ public class CreateMeetingTests: IClassFixture> [Fact] public Task CreateCommand_ShouldPublish_MeetingCreateEvent() => - API.Given( + API.Given() + .When( + POST, URI("/api/meetings/), BODY(new CreateMeeting(Guid.NewGuid(), "Event Sourcing Workshop")) ) - .When(POST) .Then(CREATED); } ``` @@ -890,42 +926,52 @@ public class CreateMeetingTests: IClassFixture> Sometimes you need to set up test data asynchronously (e.g. open a shopping cart before cancelling it). You might not want to pollute your tests code with test case setup or do more extended preparation. For that XUnit provides `IAsyncLifetime` interface. You can create a fixture derived from the `APISpecification` to benefit from built-in helpers and use it later in your tests. ```csharp -public class CancelShoppingCartFixture: ApiSpecification, IAsyncLifetime +public class GetProductDetailsFixture: ApiSpecification, IAsyncLifetime { - public Guid ShoppingCartId { get; private set; } + public ProductDetails ExistingProduct = default!; + + public GetProductDetailsFixture(): base(new WarehouseTestWebApplicationFactory()) { } public async Task InitializeAsync() { - var openResponse = await Send( - new ApiRequest(POST, URI("/api/ShoppingCarts"), BODY(new OpenShoppingCartRequest(Guid.NewGuid()))) - ); - - await CREATED(openResponse); + var registerProduct = new RegisterProductRequest("IN11111", "ValidName", "ValidDescription"); + var productId = await Given() + .When(POST, URI("/api/products"), BODY(registerProduct)) + .Then(CREATED) + .GetCreatedId(); - ShoppingCartId = openResponse.GetCreatedId(); + var (sku, name, description) = registerProduct; + ExistingProduct = new ProductDetails(productId, sku!, name!, description); } - public Task DisposeAsync() - { - Dispose(); - return Task.CompletedTask; - } + public Task DisposeAsync() => Task.CompletedTask; } -public class CancelShoppingCartTests: IClassFixture +public class GetProductDetailsTests: IClassFixture { - private readonly CancelShoppingCartFixture API; + private readonly GetProductDetailsFixture API; - public CancelShoppingCartTests(CancelShoppingCartFixture api) => API = api; + public GetProductDetailsTests(GetProductDetailsFixture api) => API = api; [Fact] - public async Task Delete_Should_Return_OK_And_Cancel_Shopping_Cart() => - API.Given( - URI($"/api/ShoppingCarts/{API.ShoppingCartId}"), - HEADERS(IF_MATCH(1)) - ) - .When(DELETE) - .Then(OK); + public Task ValidRequest_With_NoParams_ShouldReturn_200() => + API.Given() + .When(GET, URI($"/api/products/{API.ExistingProduct.Id}")) + .Then(OK, RESPONSE_BODY(API.ExistingProduct)); + + [Theory] + [InlineData(12)] + [InlineData("not-a-guid")] + public Task InvalidGuidId_ShouldReturn_404(object invalidId) => + API.Given() + .When(GET, URI($"/api/products/{invalidId}")) + .Then(NOT_FOUND); + + [Fact] + public Task NotExistingId_ShouldReturn_404() => + API.Given() + .When(GET, URI($"/api/products/{Guid.NewGuid()}")) + .Then(NOT_FOUND); } ``` diff --git a/src/Ogooreck.Sample.Api.Tests/ApiTests.cs b/src/Ogooreck.Sample.Api.Tests/ApiTests.cs index d7da25d..2362b27 100644 --- a/src/Ogooreck.Sample.Api.Tests/ApiTests.cs +++ b/src/Ogooreck.Sample.Api.Tests/ApiTests.cs @@ -13,8 +13,8 @@ public class Tests: IClassFixture> [Fact] public Task GetProducts() => - API.Given(URI("/api/products")) - .When(GET) + API.Given() + .When(GET, URI("/api/products")) .Then(OK); #endregion ApiGetSample @@ -24,11 +24,12 @@ public Task GetProducts() => [Fact] public Task RegisterProduct() => - API.Given( + API.Given() + .When( + POST, URI("/api/products"), BODY(new RegisterProductRequest("abc-123", "Ogooreck")) ) - .When(POST) .Then(CREATED, RESPONSE_LOCATION_HEADER()); #endregion ApiPostSample diff --git a/src/Ogooreck/API/ApiSpecification.cs b/src/Ogooreck/API/ApiSpecification.cs index 82054f8..f6439ac 100644 --- a/src/Ogooreck/API/ApiSpecification.cs +++ b/src/Ogooreck/API/ApiSpecification.cs @@ -1,236 +1,17 @@ -using System.ComponentModel; -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using FluentAssertions; -using Microsoft.AspNetCore.Mvc.Testing; -using Newtonsoft.Json; -using Ogooreck.Newtonsoft; +using Microsoft.AspNetCore.Mvc.Testing; #pragma warning disable CS1591 namespace Ogooreck.API; -public static class ApiSpecification -{ - /////////////////// - //// GIVEN //// - /////////////////// - public static Func URI(string uri) => - URI(new Uri(uri, UriKind.RelativeOrAbsolute)); - - public static Func URI(Uri uri) => - request => - { - request.RequestUri = uri; - return request; - }; - - public static Func BODY(T body) => - request => - { - request.Content = JsonContent.Create(body); - - return request; - }; - - public static Func HEADERS(params Action[] headers) => - request => - { - foreach (var header in headers) - { - header(request.Headers); - } - - return request; - }; - - public static Action IF_MATCH(string ifMatch, bool isWeak = true) => - headers => headers.IfMatch.Add(new EntityTagHeaderValue($"\"{ifMatch}\"", isWeak)); - - public static Action IF_MATCH(object ifMatch, bool isWeak = true) => - IF_MATCH(ifMatch.ToString()!, isWeak); - - public static Task And(this Task response, - Func and) => - response.ContinueWith(t => and(t.Result)); - - public static Task And(this Task response, Func and) => - response.ContinueWith(t => and(t.Result)); - - public static Task And(this Task response, Func> and) => - response.ContinueWith(t => and(t.Result)); - - public static Task And(this Task response, Func and) => - response.ContinueWith(_ => and()); - - public static Task And(this Task response, Task and) => - response.ContinueWith(_ => and); - - /////////////////// - //// WHEN //// - /////////////////// - public static Func, Task> GET = SEND(HttpMethod.Get); - - public static Func, Task> GET_UNTIL( - Func> check) => - SEND_UNTIL(HttpMethod.Get, check); +public delegate HttpRequestMessage RequestTransform(HttpRequestMessage request, TestContext context); - public static Func, Task> POST = SEND(HttpMethod.Post); - - public static Func, Task> PUT = SEND(HttpMethod.Put); - - public static Func, Task> DELETE = SEND(HttpMethod.Delete); - - public static Func, Task> SEND(HttpMethod httpMethod) => - (api, buildRequest) => - { - var request = buildRequest(); - request.Method = httpMethod; - return api.SendAsync(request); - }; - - public static Func, Task> SEND_UNTIL(HttpMethod httpMethod, - Func> check, int maxNumberOfRetries = 5, int retryIntervalInMs = 1000) => - async (api, buildRequest) => - { - var retryCount = maxNumberOfRetries; - var finished = false; - - HttpResponseMessage? response = null; - do - { - try - { - var request = buildRequest(); - request.Method = httpMethod; - - response = await api.SendAsync(request); - - finished = await check(response); - } - catch - { - if (retryCount == 0) - throw; - } - - await Task.Delay(retryIntervalInMs); - retryCount--; - } while (!finished); - - response.Should().NotBeNull(); - - return response!; - }; - - /////////////////// - //// THEN //// - /////////////////// - public static Func OK = HTTP_STATUS(HttpStatusCode.OK); - public static Func CREATED = HTTP_STATUS(HttpStatusCode.Created); - public static Func NO_CONTENT = HTTP_STATUS(HttpStatusCode.NoContent); - public static Func BAD_REQUEST = HTTP_STATUS(HttpStatusCode.BadRequest); - public static Func NOT_FOUND = HTTP_STATUS(HttpStatusCode.NotFound); - public static Func CONFLICT = HTTP_STATUS(HttpStatusCode.Conflict); - public static Func PRECONDITION_FAILED = - HTTP_STATUS(HttpStatusCode.PreconditionFailed); - public static Func METHOD_NOT_ALLOWED = - HTTP_STATUS(HttpStatusCode.MethodNotAllowed); - - public static Func HTTP_STATUS(HttpStatusCode status) => - response => - { - response.StatusCode.Should().Be(status); - return ValueTask.CompletedTask; - }; - - - public static Func CREATED_WITH_DEFAULT_HEADERS( - string? locationHeaderPrefix = null, object? eTag = null, bool isETagWeak = true) => - async response => - { - await CREATED(response); - await RESPONSE_LOCATION_HEADER(locationHeaderPrefix)(response); - if(eTag != null) - await RESPONSE_ETAG_HEADER(eTag, isETagWeak)(response); - }; - - public static Func RESPONSE_BODY(T body) => - RESPONSE_BODY(result => result.Should().BeEquivalentTo(body)); - - public static Func RESPONSE_BODY(Action assert) => - async response => - { - var result = await response.GetResultFromJson(); - assert(result); - - result.Should().BeEquivalentTo(result); - }; - - public static Func> RESPONSE_ETAG_IS(object eTag, bool isWeak = true) => - async response => - { - await RESPONSE_ETAG_HEADER(eTag, isWeak)(response); - return true; - }; - - public static Func RESPONSE_ETAG_HEADER(object eTag, bool isWeak = true) => - RESPONSE_HEADERS(headers => - { - headers.ETag.Should().NotBeNull("ETag response header should be defined").And.NotBe("", "ETag response header should not be empty"); - headers.ETag!.Tag.Should().NotBeEmpty("ETag response header should not be empty"); - - headers.ETag.IsWeak.Should().Be(isWeak, "Etag response header should be {0}", isWeak? "Weak": "Strong"); - headers.ETag.Tag.Should().Be($"\"{eTag}\""); - }); - - public static Func RESPONSE_LOCATION_HEADER(string? locationHeaderPrefix = null) => - async response => - { - await HTTP_STATUS(HttpStatusCode.Created)(response); - - var locationHeader = response.Headers.Location; - - locationHeader.Should().NotBeNull(); - - var location = locationHeader!.ToString(); - - location.Should().StartWith(locationHeaderPrefix ?? response.RequestMessage!.RequestUri!.AbsolutePath); - }; - public static Func RESPONSE_HEADERS(params Action[] headers) => - response => - { - foreach (var header in headers) - { - header(response.Headers); - } - return ValueTask.CompletedTask; - }; - - public static Func> RESPONSE_SUCCEEDED() => - response => - { - response.EnsureSuccessStatusCode(); - return new ValueTask(true); - }; - - public static Func> RESPONSE_BODY_MATCHES(Func assert) => - async response => - { - response.EnsureSuccessStatusCode(); - - var result = await response.GetResultFromJson(); - result.Should().NotBeNull(); - - return assert(result); - }; -} +public delegate ValueTask ResponseAssert(HttpResponseMessage response, TestContext context); public class ApiSpecification: IDisposable where TProgram : class { private readonly WebApplicationFactory applicationFactory; - private readonly HttpClient client; + private readonly Func createClient; public ApiSpecification(): this(new WebApplicationFactory()) { @@ -239,43 +20,40 @@ public ApiSpecification(): this(new WebApplicationFactory()) protected ApiSpecification(WebApplicationFactory applicationFactory) { this.applicationFactory = applicationFactory; - client = applicationFactory.CreateClient(); + createClient = applicationFactory.CreateClient; } public static ApiSpecification Setup(WebApplicationFactory applicationFactory) => new(applicationFactory); - public async Task Send(ApiRequest apiRequest) => - (await Send(new[] { apiRequest })).Single(); - - public async Task Send(params ApiRequest[] apiRequests) - { - var responses = new List(); + public GivenApiSpecificationBuilder Given( + params RequestDefinition[] builders + ) => + Given(builders.Select(b => new ApiTestStep(TestPhase.Given, b)).ToArray()); - foreach (var request in apiRequests) - { - responses.Add(await client.Send(request)); - } - return responses.ToArray(); - } + public GivenApiSpecificationBuilder Given( + string description, + params RequestDefinition[] builders + ) => + Given(builders.Select(b => new ApiTestStep(TestPhase.Given, b, description)).ToArray()); public GivenApiSpecificationBuilder Given( - params Func[] builders) => - new(client, builders); + ApiTestStep[] builders) => + new(new TestContext(), createClient, builders); - public async Task Scenario( - Task first, - params Func>[] following) + public async Task Scenario( + Task first, + params Func>[] following) { - var response = await first; + var result = await first; foreach (var next in following) { - response = await next(response); + result = await next(result.Response); } - return response; + return result; } public async Task Scenario( @@ -292,183 +70,35 @@ public async Task Scenario( return response; } - ///////////////////// - //// BUILDER //// - ///////////////////// - - public class GivenApiSpecificationBuilder - { - private readonly Func[] given; - private readonly HttpClient client; - - internal GivenApiSpecificationBuilder(HttpClient client, Func[] given) - { - this.client = client; - this.given = given; - } - - public WhenApiSpecificationBuilder When(Func, Task> when) => - new(client, given, when); - } - - public class WhenApiSpecificationBuilder - { - private readonly Func[] given; - private readonly Func, Task> when; - private readonly HttpClient client; - - internal WhenApiSpecificationBuilder( - HttpClient client, - Func[] given, - Func, Task> when - ) - { - this.client = client; - this.given = given; - this.when = when; - } - - public Task Then(Func then) => - Then(new[] { then }); - - public async Task Then(params Func[] thens) - { - var request = new ApiRequest(when, given); - - var response = await client.Send(request); - - foreach (var then in thens) - { - await then(response); - } - - return response; - } - } - - public void Dispose() - { - client.Dispose(); + public void Dispose() => applicationFactory.Dispose(); - } } -public class AndSpecificationBuilder +public class TestApiRequest { - private readonly Lazy> then; + private readonly RequestTransform[] builders; + private readonly TestContext testContext; - public AndSpecificationBuilder(Lazy> then) + public TestApiRequest(TestContext testContext, params RequestTransform[] builders) { - this.then = then; - } - - public Task Response => then.Value; -} - -public class ApiRequest -{ - private readonly Func, Task> send; - private readonly Func[] builders; - - public ApiRequest( - Func, Task> send, - params Func[] builders - ) - { - this.send = send; + this.testContext = testContext; this.builders = builders; } - public Task Send(HttpClient httpClient) - { - HttpRequestMessage BuildRequest() => - builders.Aggregate(new HttpRequestMessage(), (request, build) => build(request)); + public HttpRequestMessage Build() => + builders.Aggregate(new HttpRequestMessage(), (request, build) => build(request, testContext)); - return send(httpClient, BuildRequest); - } + public static HttpRequestMessage For(TestContext testContext, RequestDefinition requestDefinition) => + requestDefinition.Transformations + .Aggregate(new HttpRequestMessage(), (request, build) => build(request, testContext)); } public static class ApiRequestExtensions { - public static Task Send(this HttpClient httpClient, ApiRequest apiRequest) => - apiRequest.Send(httpClient); -} - -public static class HttpResponseMessageExtensions -{ - public static bool TryGetCreatedId(this HttpResponseMessage response, out T? value) - { - value = default; - - var locationHeader = response.Headers.Location?.OriginalString.TrimEnd('/'); - - if (string.IsNullOrEmpty(locationHeader)) - return false; - - locationHeader = locationHeader.StartsWith("/") ? locationHeader: $"/{locationHeader}"; - - var start = locationHeader.LastIndexOf("/", locationHeader.Length - 1); - - var createdId = locationHeader.Substring(start + 1, locationHeader.Length - 1 - start); - - var result = TypeDescriptor.GetConverter(typeof(T)).ConvertFromInvariantString(createdId); - - if (result == null) - return false; - - value = (T?)result; - - return true; - } - - public static T GetCreatedId(this HttpResponseMessage response) => - response.TryGetCreatedId(out var createdId) - ? createdId! - : throw new ArgumentOutOfRangeException(nameof(response.Headers.Location)); - - public static string GetCreatedId(this HttpResponseMessage response) => - response.GetCreatedId(); - - public static bool TryGetETagValue(this HttpResponseMessage response, out T? value) - { - value = default; - - var eTagHeader = response.Headers.ETag?.Tag; - - if (string.IsNullOrEmpty(eTagHeader)) - return false; - - eTagHeader = eTagHeader.Substring(1, eTagHeader.Length - 2); - - var result = TypeDescriptor.GetConverter(typeof(T)).ConvertFromInvariantString(eTagHeader); - - if (result == null) - return false; - - value = (T?)result; - - return true; - } - - public static T GetETagValue(this HttpResponseMessage response) => - response.TryGetCreatedId(out var createdId) - ? createdId! - : throw new ArgumentOutOfRangeException(nameof(response.Headers.ETag)); - - public static string GetETagValue(this HttpResponseMessage response) => - response.GetETagValue(); - - public static async Task GetResultFromJson(this HttpResponseMessage response, JsonSerializerSettings? settings = null) - { - var result = await response.Content.ReadAsStringAsync(); - - result.Should().NotBeNull(); - result.Should().NotBeEmpty(); - - var deserialised = result.FromJson(settings); - - deserialised.Should().NotBeNull(); - - return deserialised; - } + public static Task Send( + this HttpClient httpClient, + TestApiRequest testApiRequest, + CancellationToken ct = default + ) => + httpClient.SendAsync(testApiRequest.Build(), ct); } diff --git a/src/Ogooreck/API/ApiSpecificationExtensions.cs b/src/Ogooreck/API/ApiSpecificationExtensions.cs new file mode 100644 index 0000000..4c551b8 --- /dev/null +++ b/src/Ogooreck/API/ApiSpecificationExtensions.cs @@ -0,0 +1,321 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using FluentAssertions; + +namespace Ogooreck.API; + +#pragma warning disable CS1591 +public static class ApiSpecification +{ + /////////////////// + //// GIVEN //// + /////////////////// + + public static GivenApiSpecificationBuilder Given(this ApiSpecification api, + params RequestTransform[] when) where TProgram : class => + api.Given(new RequestDefinition(when)); + + + public static GivenApiSpecificationBuilder Given(this ApiSpecification api, + string description, + params RequestTransform[] when) where TProgram : class => + api.Given(description, new RequestDefinition(when, description)); + + /////////////////// + //// WHEN //// + /////////////////// + + public static RequestDefinition SEND(params RequestTransform[] when) => new(when); + + + public static RequestDefinition SEND(string description, params RequestTransform[] when) => new(when, description); + + public static RequestTransform URI(Func getUrl) => + URI(ctx => new Uri(getUrl(ctx), UriKind.RelativeOrAbsolute)); + + public static RequestTransform URI(string uri) => + URI(new Uri(uri, UriKind.RelativeOrAbsolute)); + + public static RequestTransform URI(Uri uri) => + URI(_ => uri); + + public static RequestTransform URI(Func getUri) => + (request, ctx) => + { + request.RequestUri = getUri(ctx); + return request; + }; + + public static RequestTransform BODY(T body) => + (request, _) => + { + request.Content = JsonContent.Create(body); + + return request; + }; + + public static RequestTransform HEADERS(params Action[] headers) => + (request, _) => + { + foreach (var header in headers) + { + header(request.Headers); + } + + return request; + }; + + + public static RequestTransform GET => + (request, _) => + { + request.Method = HttpMethod.Get; + return request; + }; + + public static RequestTransform POST => SEND(HttpMethod.Post); + + public static RequestTransform PUT => SEND(HttpMethod.Put); + + public static RequestTransform DELETE => SEND(HttpMethod.Delete); + + public static RequestTransform OPTIONS => SEND(HttpMethod.Options); + + public static RequestTransform SEND(HttpMethod method) => + (request, _) => + { + request.Method = method; + return request; + }; + + public static Action IF_MATCH(string ifMatch, bool isWeak = true) => + headers => headers.IfMatch.Add(new EntityTagHeaderValue($"\"{ifMatch}\"", isWeak)); + + public static Action IF_MATCH(object ifMatch, bool isWeak = true) => + IF_MATCH(ifMatch.ToString()!, isWeak); + + public static Task And(this Task result, + Func and) => + result.ContinueWith(t => and(t.Result.Response)); + + public static Task And(this Task result, Func and) => + result.ContinueWith(t => and(t.Result.Response)); + + public static Task And(this Task result, + Func> and) => + result.ContinueWith(t => and(t.Result.Response)); + + public static Task And(this Task result, Func and) => + result.ContinueWith(_ => and()); + + public static Task And(this Task result, Task and) => + result.ContinueWith(_ => and); + + public static Task And(this Task result) => + result.ContinueWith( + _ => new GivenApiSpecificationBuilder(result.Result.TestContext, result.Result.CreateClient)); + + public static Task AndWhen( + this Task result, + string description, + params RequestTransform[] when + ) => + result.And().When(description, when); + + public static Task AndWhen(this Task result, params RequestTransform[] when) => + result.And().When(when); + + public static Task AndWhen( + this Task result, + string description, + Func when + ) => + result.ContinueWith(r => + new GivenApiSpecificationBuilder(r.Result.TestContext, r.Result.CreateClient) + .When(description, when(r.Result.Response)) + ); + + public static Task AndWhen( + this Task result, + Func when + ) => + result.ContinueWith(r => + new GivenApiSpecificationBuilder(r.Result.TestContext, r.Result.CreateClient).When(when(r.Result.Response)) + ); + + public static Task When( + this Task result, + string description, + params RequestTransform[] when + ) => + result.ContinueWith(_ => result.Result.When(description, when)); + + public static Task When( + this Task result, + params RequestTransform[] when + ) => + result.ContinueWith(_ => result.Result.When(when)); + + public static Task Until( + this Task when, + RetryCheck check, + int maxNumberOfRetries = 5, + int retryIntervalInMs = 1000 + ) => + when.ContinueWith(t => t.Result.Until(check, maxNumberOfRetries, retryIntervalInMs)); + + public static Task Then( + this Task when, + ResponseAssert then + ) => + when.ContinueWith(t => t.Result.Then(then)).Unwrap(); + + public static Task Then( + this Task when, + params ResponseAssert[] thens + ) => + when.ContinueWith(t => t.Result.Then(thens)).Unwrap(); + + public static Task Then( + this Task when, + IEnumerable thens, + CancellationToken ct + ) => + when.ContinueWith(t => t.Result.Then(thens, ct), ct).Unwrap(); + + + public static Task GetResponseBody(this Task result) => result.Map(RESPONSE_BODY()); + public static Task GetCreatedId(this Task result) => result.Map(CREATED_ID()); + + public static Task Map( + this Task result, + Func> map + ) => + result.ContinueWith(t => map(t.Result.Response)).Unwrap(); + + /////////////////// + //// THEN //// + /////////////////// + public static ResponseAssert OK = HTTP_STATUS(HttpStatusCode.OK); + public static ResponseAssert CREATED = HTTP_STATUS(HttpStatusCode.Created); + public static ResponseAssert NO_CONTENT = HTTP_STATUS(HttpStatusCode.NoContent); + public static ResponseAssert BAD_REQUEST = HTTP_STATUS(HttpStatusCode.BadRequest); + public static ResponseAssert NOT_FOUND = HTTP_STATUS(HttpStatusCode.NotFound); + public static ResponseAssert CONFLICT = HTTP_STATUS(HttpStatusCode.Conflict); + + public static ResponseAssert PRECONDITION_FAILED = + HTTP_STATUS(HttpStatusCode.PreconditionFailed); + + public static ResponseAssert METHOD_NOT_ALLOWED = + HTTP_STATUS(HttpStatusCode.MethodNotAllowed); + + public static ResponseAssert HTTP_STATUS(HttpStatusCode status) => + (response, ctx) => + { + response.StatusCode.Should().Be(status); + return ValueTask.CompletedTask; + }; + + public static ResponseAssert CREATED_WITH_DEFAULT_HEADERS( + string? locationHeaderPrefix = null, object? eTag = null, bool isETagWeak = true) => + async (response, ctx) => + { + await CREATED(response, ctx); + await RESPONSE_LOCATION_HEADER(locationHeaderPrefix)(response, ctx); + if (eTag != null) + await RESPONSE_ETAG_HEADER(eTag, isETagWeak)(response, ctx); + }; + + public static ResponseAssert RESPONSE_BODY(T body) => + RESPONSE_BODY(result => result.Should().BeEquivalentTo(body)); + + public static ResponseAssert RESPONSE_BODY(Func getBody) => + RESPONSE_BODY((result, ctx) => result.Should().BeEquivalentTo(getBody(ctx))); + + public static ResponseAssert RESPONSE_BODY(Action assert) => + RESPONSE_BODY((body, _) => assert(body)); + + public static ResponseAssert RESPONSE_BODY(Action assert) => + async (response, ctx) => + { + var result = await response.GetResultFromJson(); + assert(result, ctx); + + result.Should().BeEquivalentTo(result); + }; + + public static Func> RESPONSE_BODY() => + response => response.GetResultFromJson(); + + public static Func> CREATED_ID() => + response => Task.FromResult(response.GetCreatedId()); + + public static ResponseAssert RESPONSE_ETAG_HEADER(object eTag, bool isWeak = true) => + RESPONSE_HEADERS(headers => + { + headers.ETag.Should().NotBeNull("ETag response header should be defined").And + .NotBe("", "ETag response header should not be empty"); + headers.ETag!.Tag.Should().NotBeEmpty("ETag response header should not be empty"); + + headers.ETag.IsWeak.Should().Be(isWeak, "Etag response header should be {0}", isWeak ? "Weak" : "Strong"); + headers.ETag.Tag.Should().Be($"\"{eTag}\""); + }); + + public static ResponseAssert RESPONSE_LOCATION_HEADER(string? locationHeaderPrefix = null) => + async (response, ctx) => + { + await HTTP_STATUS(HttpStatusCode.Created)(response, ctx); + + var locationHeader = response.Headers.Location; + + locationHeader.Should().NotBeNull(); + + var location = locationHeader!.ToString(); + + location.Should().StartWith(locationHeaderPrefix ?? response.RequestMessage!.RequestUri!.AbsolutePath); + }; + + public static ResponseAssert RESPONSE_HEADERS(params Action[] headers) => + (response, ctx) => + { + foreach (var header in headers) + { + header(response.Headers); + } + + return ValueTask.CompletedTask; + }; + + ///////////////// + // UNTIL + //////////////// + + public static RetryCheck RESPONSE_ETAG_IS(object eTag, + bool isWeak = true) => + async (response, ctx) => + { + await RESPONSE_ETAG_HEADER(eTag, isWeak)(response, ctx); + return true; + }; + + public static RetryCheck RESPONSE_SUCCEEDED() => + (response, ctx) => + { + response.EnsureSuccessStatusCode(); + return new ValueTask(true); + }; + + public static RetryCheck RESPONSE_BODY_MATCHES(Func assert) => + async (response, ctx) => + { + response.EnsureSuccessStatusCode(); + + var result = await response.GetResultFromJson(); + result.Should().NotBeNull(); + + return assert(result); + }; + + public record Result(HttpResponseMessage Response, TestContext TestContext, Func CreateClient); +} diff --git a/src/Ogooreck/API/HttpResponseMessageExtensions.cs b/src/Ogooreck/API/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000..241249c --- /dev/null +++ b/src/Ogooreck/API/HttpResponseMessageExtensions.cs @@ -0,0 +1,96 @@ +using System.ComponentModel; +using FluentAssertions; +using Newtonsoft.Json; +using Ogooreck.Newtonsoft; + +namespace Ogooreck.API; + +#pragma warning disable CS1591 +public static class HttpResponseMessageExtensions +{ + public static bool TryGetCreatedId(this HttpResponseMessage response, out T? value) + { + value = default; + + var locationHeader = response.Headers.Location?.OriginalString.TrimEnd('/'); + + if (string.IsNullOrEmpty(locationHeader)) + return false; + + locationHeader = locationHeader.StartsWith("/") ? locationHeader : $"/{locationHeader}"; + + var start = locationHeader.LastIndexOf("/", locationHeader.Length - 1, StringComparison.Ordinal); + + var createdId = locationHeader.Substring(start + 1, locationHeader.Length - 1 - start); + + var result = TypeDescriptor.GetConverter(typeof(T)).ConvertFromInvariantString(createdId); + + if (result == null) + return false; + + value = (T?)result; + + return true; + } + + public static T GetCreatedId(this HttpResponseMessage response) => + response.TryGetCreatedId(out var createdId) + ? createdId! + : throw new ArgumentOutOfRangeException(nameof(response.Headers.Location)); + + public static string GetCreatedId(this HttpResponseMessage response) => + response.GetCreatedId(); + + public static bool TryGetETagValue(this HttpResponseMessage response, out T? value) + { + value = default; + + var eTagHeader = response.Headers.ETag?.Tag; + + if (string.IsNullOrEmpty(eTagHeader)) + return false; + + eTagHeader = eTagHeader.Substring(1, eTagHeader.Length - 2); + + var result = TypeDescriptor.GetConverter(typeof(T)).ConvertFromInvariantString(eTagHeader); + + if (result == null) + return false; + + value = (T?)result; + + return true; + } + + public static T GetETagValue(this HttpResponseMessage response) => + response.TryGetCreatedId(out var createdId) + ? createdId! + : throw new ArgumentOutOfRangeException(nameof(response.Headers.ETag)); + + public static string GetETagValue(this HttpResponseMessage response) => + response.GetETagValue(); + + public static Task GetResultFromJson( + this ApiSpecification.Result result, + JsonSerializerSettings? settings = null + ) => + result.Response.GetResultFromJson(); + + + public static async Task GetResultFromJson( + this HttpResponseMessage response, + JsonSerializerSettings? settings = null + ) + { + var result = await response.Content.ReadAsStringAsync(); + + result.Should().NotBeNull(); + result.Should().NotBeEmpty(); + + var deserialised = result.FromJson(settings); + + deserialised.Should().NotBeNull(); + + return deserialised; + } +} diff --git a/src/Ogooreck/API/RetryPolicy.cs b/src/Ogooreck/API/RetryPolicy.cs new file mode 100644 index 0000000..65b7bca --- /dev/null +++ b/src/Ogooreck/API/RetryPolicy.cs @@ -0,0 +1,49 @@ +using FluentAssertions; + +namespace Ogooreck.API; + +#pragma warning disable CS1591 +public record RetryPolicy( + RetryCheck Check, + int MaxNumberOfRetries = 5, + int RetryIntervalInMs = 1000 +) +{ + public async Task Perform(Func> send, TestContext testContext, CancellationToken ct) + { + var retryCount = MaxNumberOfRetries; + var finished = false; + + HttpResponseMessage? response = null; + do + { + try + { + response = await send(ct); + + finished = await Check(response, testContext); + } + catch + { + if (retryCount == 0) + throw; + } + + await Task.Delay(RetryIntervalInMs, ct); + retryCount--; + } while (!finished); + + response.Should().NotBeNull(); + + return response!; + } + + public static readonly RetryPolicy NoRetry = new RetryPolicy( + (r, t) => ValueTask.FromResult(true), + 0, + 0 + ); +} + +public delegate ValueTask RetryCheck(HttpResponseMessage responseMessage, TestContext testContext); + diff --git a/src/Ogooreck/API/TestBuilders.cs b/src/Ogooreck/API/TestBuilders.cs new file mode 100644 index 0000000..1795032 --- /dev/null +++ b/src/Ogooreck/API/TestBuilders.cs @@ -0,0 +1,155 @@ +using System.Net; + +namespace Ogooreck.API; + +#pragma warning disable CS1591 + +public class GivenApiSpecificationBuilder +{ + private readonly ApiTestStep[] given; + private readonly Func createClient; + private readonly TestContext testContext; + + internal GivenApiSpecificationBuilder + ( + TestContext testContext, + Func createClient, + ApiTestStep[] given + ) + { + this.testContext = testContext; + this.createClient = createClient; + this.given = given; + } + + internal GivenApiSpecificationBuilder + ( + TestContext testContext, + Func createClient + ): this(testContext, createClient, Array.Empty()) + { + } + + public WhenApiSpecificationBuilder When(params RequestTransform[] when) => + When("", when); + + public WhenApiSpecificationBuilder When(string description, params RequestTransform[] when) => + When("", new RequestDefinition(when, description)); + + public WhenApiSpecificationBuilder When(RequestDefinition when) => When(when.Description, when); + + public WhenApiSpecificationBuilder When(string description, RequestDefinition when) => + new( + createClient, + testContext, + given, + new ApiTestStep(TestPhase.When, when, description) + ); +} + +public class WhenApiSpecificationBuilder +{ + private readonly ApiTestStep[] given; + private readonly ApiTestStep when; + private readonly Func createClient; + private readonly TestContext testContext; + private RetryPolicy retryPolicy; + + internal WhenApiSpecificationBuilder( + Func createClient, + TestContext testContext, + ApiTestStep[] given, + ApiTestStep when + ) + { + this.createClient = createClient; + this.testContext = testContext; + this.given = given; + this.when = when; + retryPolicy = RetryPolicy.NoRetry; + } + + public WhenApiSpecificationBuilder Until( + RetryCheck check, + int maxNumberOfRetries = 5, + int retryIntervalInMs = 1000 + ) + { + retryPolicy = new RetryPolicy(check, maxNumberOfRetries, retryIntervalInMs); + return this; + } + + public Task Then(ResponseAssert then) => + Then(new[] { then }); + + public Task Then(params ResponseAssert[] thens) => + Then(thens, default); + + public async Task Then(IEnumerable thens, + CancellationToken ct) + { + using var client = createClient(); + + // Given + foreach (var givenBuilder in given) + await Send(client, RetryPolicy.NoRetry, givenBuilder, testContext, ct); + + // When + var response = await Send(client, retryPolicy, when, testContext, ct); + + // Then + foreach (var then in thens) + { + await then(response, testContext); + } + + return new ApiSpecification.Result(response, testContext, createClient); + } + + private static Task Send( + HttpClient client, + RetryPolicy retryPolicy, + ApiTestStep testStep, + TestContext testContext, + CancellationToken ct + ) => + retryPolicy + .Perform(async t => + { + var request = TestApiRequest.For(testContext, testStep.RequestDefinition); + var response = await client.SendAsync(request, t); + + testContext.Record(testStep, request, response); + + return response; + }, testContext, ct); +} + +public enum TestPhase +{ + Given, + When, + Then +} + +public record RequestDefinition(RequestTransform[] Transformations, string Description = ""); + +public record ApiTestStep(TestPhase Phase, RequestDefinition RequestDefinition, string Description = ""); + +public record MadeApiCall(TestPhase TestPhase, HttpRequestMessage Request, HttpResponseMessage Response, + string Description = ""); + +public class TestContext +{ + public List Calls { get; } = new(); + + public void Record(ApiTestStep testStep, HttpRequestMessage request, HttpResponseMessage response) => + Calls.Add(new MadeApiCall(testStep.Phase, request, response, testStep.RequestDefinition.Description)); + + + public string GetCreatedId() => + Calls.First(c => c.Response.StatusCode == HttpStatusCode.Created).Response.GetCreatedId(); + + public T GetCreatedId() where T : notnull => + Calls.First(c => c.Response.StatusCode == HttpStatusCode.Created).Response.GetCreatedId(); +} diff --git a/src/Ogooreck/Ogooreck.csproj b/src/Ogooreck/Ogooreck.csproj index ab3dd9d..fff796c 100644 --- a/src/Ogooreck/Ogooreck.csproj +++ b/src/Ogooreck/Ogooreck.csproj @@ -1,7 +1,7 @@ - 0.7.0 + 0.8.0 net6.0;net7.0 true true