From ce68765992b2ea27da3ca02186b36245ab186e4b Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Thu, 10 Aug 2023 14:29:53 +0200 Subject: [PATCH 01/11] Initial set of refactorings to make setup easier and more self explanatory --- src/Ogooreck/API/ApiSpecification.cs | 254 ++++++++++++++------------- 1 file changed, 133 insertions(+), 121 deletions(-) diff --git a/src/Ogooreck/API/ApiSpecification.cs b/src/Ogooreck/API/ApiSpecification.cs index 82054f8..6cd16c6 100644 --- a/src/Ogooreck/API/ApiSpecification.cs +++ b/src/Ogooreck/API/ApiSpecification.cs @@ -11,22 +11,28 @@ namespace Ogooreck.API; +public delegate HttpRequestMessage RequestTransform(HttpRequestMessage request); + public static class ApiSpecification { /////////////////// //// GIVEN //// /////////////////// - public static Func URI(string uri) => + + /////////////////// + //// WHEN //// + /////////////////// + public static RequestTransform URI(string uri) => URI(new Uri(uri, UriKind.RelativeOrAbsolute)); - public static Func URI(Uri uri) => + public static RequestTransform URI(Uri uri) => request => { request.RequestUri = uri; return request; }; - public static Func BODY(T body) => + public static RequestTransform BODY(T body) => request => { request.Content = JsonContent.Create(body); @@ -34,7 +40,7 @@ public static Func BODY(T body) => return request; }; - public static Func HEADERS(params Action[] headers) => + public static RequestTransform HEADERS(params Action[] headers) => request => { foreach (var header in headers) @@ -45,6 +51,29 @@ public static Func HEADERS(params Action 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)); @@ -58,7 +87,8 @@ public static Task And(this Task respo public static Task And(this Task response, Func and) => response.ContinueWith(t => and(t.Result)); - public static Task And(this Task response, Func> and) => + public static Task And(this Task response, + Func> and) => response.ContinueWith(t => and(t.Result)); public static Task And(this Task response, Func and) => @@ -67,62 +97,44 @@ public static Task And(this Task response, Func 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 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!; - }; + // public static Func, Task> GET_UNTIL( + // Func> check) => + // SEND_UNTIL(HttpMethod.Get, check); + // + // 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 //// @@ -133,8 +145,10 @@ public static Func, Task 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); @@ -152,7 +166,7 @@ public static Func CREATED_WITH_DEFAULT_HEADERS( { await CREATED(response); await RESPONSE_LOCATION_HEADER(locationHeaderPrefix)(response); - if(eTag != null) + if (eTag != null) await RESPONSE_ETAG_HEADER(eTag, isETagWeak)(response); }; @@ -178,10 +192,11 @@ public static Func> RESPONSE_ETAG_IS(object 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.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.IsWeak.Should().Be(isWeak, "Etag response header should be {0}", isWeak ? "Weak" : "Strong"); headers.ETag.Tag.Should().Be($"\"{eTag}\""); }); @@ -198,6 +213,7 @@ public static Func RESPONSE_LOCATION_HEADER(stri location.Should().StartWith(locationHeaderPrefix ?? response.RequestMessage!.RequestUri!.AbsolutePath); }; + public static Func RESPONSE_HEADERS(params Action[] headers) => response => { @@ -205,6 +221,7 @@ public static Func RESPONSE_HEADERS(params Actio { header(response.Headers); } + return ValueTask.CompletedTask; }; @@ -230,7 +247,7 @@ public static Func> RESPONSE_BODY_MATCHES: IDisposable where TProgram : class { private readonly WebApplicationFactory applicationFactory; - private readonly HttpClient client; + private readonly Func createClient; public ApiSpecification(): this(new WebApplicationFactory()) { @@ -239,30 +256,15 @@ 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(); - - foreach (var request in apiRequests) - { - responses.Add(await client.Send(request)); - } - - return responses.ToArray(); - } - public GivenApiSpecificationBuilder Given( - params Func[] builders) => - new(client, builders); + params RequestTransform[][] builders) => + new(createClient, builders); public async Task Scenario( Task first, @@ -298,32 +300,32 @@ public async Task Scenario( public class GivenApiSpecificationBuilder { - private readonly Func[] given; - private readonly HttpClient client; + private readonly RequestTransform[][] given; + private readonly Func createClient; - internal GivenApiSpecificationBuilder(HttpClient client, Func[] given) + internal GivenApiSpecificationBuilder(Func createClient, RequestTransform[][] given) { - this.client = client; + this.createClient = createClient; this.given = given; } - public WhenApiSpecificationBuilder When(Func, Task> when) => - new(client, given, when); + public WhenApiSpecificationBuilder When(params RequestTransform[] when) => + new(createClient, given, when); } public class WhenApiSpecificationBuilder { - private readonly Func[] given; - private readonly Func, Task> when; - private readonly HttpClient client; + private readonly RequestTransform[][] given; + private readonly RequestTransform[] when; + private readonly Func createClient; internal WhenApiSpecificationBuilder( - HttpClient client, - Func[] given, - Func, Task> when + Func createClient, + RequestTransform[][] given, + RequestTransform[] when ) { - this.client = client; + this.createClient = createClient; this.given = given; this.when = when; } @@ -331,12 +333,21 @@ Func, Task> when public Task Then(Func then) => Then(new[] { then }); - public async Task Then(params Func[] thens) + public Task Then(params Func[] thens) => + Then(thens, default); + + public async Task Then(IEnumerable> thens, CancellationToken ct) { - var request = new ApiRequest(when, given); + using var client = createClient(); - var response = await client.Send(request); + // Given + foreach (var givenBuilder in given) + await client.Send(givenBuilder, ct: ct); + // When + var response = await client.Send(when, ct: ct); + + // Then foreach (var then in thens) { await then(response); @@ -348,7 +359,6 @@ public async Task Then(params Func> then; - public AndSpecificationBuilder(Lazy> then) - { + public AndSpecificationBuilder(Lazy> then) => this.then = then; - } public Task Response => then.Value; } public class ApiRequest { - private readonly Func, Task> send; - private readonly Func[] builders; + private readonly RequestTransform[] builders; - public ApiRequest( - Func, Task> send, - params Func[] builders - ) - { - this.send = send; + public ApiRequest(params RequestTransform[] builders) => 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)); - return send(httpClient, BuildRequest); - } + public static HttpRequestMessage For(params RequestTransform[] builders) => + builders.Aggregate(new HttpRequestMessage(), (request, build) => build(request)); } public static class ApiRequestExtensions { - public static Task Send(this HttpClient httpClient, ApiRequest apiRequest) => - apiRequest.Send(httpClient); + public static Task Send( + this HttpClient httpClient, + ApiRequest apiRequest, + CancellationToken ct = default + ) => + httpClient.SendAsync(apiRequest.Build(), ct); + + + public static Task Send( + this HttpClient httpClient, + RequestTransform[] builders, + CancellationToken ct = default + ) => + httpClient.SendAsync(ApiRequest.For(builders), ct); } public static class HttpResponseMessageExtensions @@ -405,7 +416,7 @@ public static bool TryGetCreatedId(this HttpResponseMessage response, out T? if (string.IsNullOrEmpty(locationHeader)) return false; - locationHeader = locationHeader.StartsWith("/") ? locationHeader: $"/{locationHeader}"; + locationHeader = locationHeader.StartsWith("/") ? locationHeader : $"/{locationHeader}"; var start = locationHeader.LastIndexOf("/", locationHeader.Length - 1); @@ -458,7 +469,8 @@ public static T GetETagValue(this HttpResponseMessage response) => public static string GetETagValue(this HttpResponseMessage response) => response.GetETagValue(); - public static async Task GetResultFromJson(this HttpResponseMessage response, JsonSerializerSettings? settings = null) + public static async Task GetResultFromJson(this HttpResponseMessage response, + JsonSerializerSettings? settings = null) { var result = await response.Content.ReadAsStringAsync(); From 51bbc5a3343d8427ca3816317b450d7b65d7dcd8 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Fri, 11 Aug 2023 12:11:20 +0200 Subject: [PATCH 02/11] Added Retry Policy, enhanced the scenarios support --- mdsource/README.source.md | 16 +- src/Ogooreck.Sample.Api.Tests/ApiTests.cs | 8 +- src/Ogooreck/API/ApiSpecification.cs | 311 ++++++++++++++-------- 3 files changed, 212 insertions(+), 123 deletions(-) diff --git a/mdsource/README.source.md b/mdsource/README.source.md index 4b93c2f..fada97c 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 diff --git a/src/Ogooreck.Sample.Api.Tests/ApiTests.cs b/src/Ogooreck.Sample.Api.Tests/ApiTests.cs index d7da25d..118f9d0 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 @@ -25,10 +25,12 @@ public Task GetProducts() => [Fact] public Task RegisterProduct() => 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 6cd16c6..573d8ae 100644 --- a/src/Ogooreck/API/ApiSpecification.cs +++ b/src/Ogooreck/API/ApiSpecification.cs @@ -22,6 +22,9 @@ public static class ApiSpecification /////////////////// //// WHEN //// /////////////////// + + public static RequestTransform[] SEND(params RequestTransform[] when) => when; + public static RequestTransform URI(string uri) => URI(new Uri(uri, UriKind.RelativeOrAbsolute)); @@ -80,61 +83,79 @@ public static Action IF_MATCH(string ifMatch, bool isWeak = public static Action IF_MATCH(object ifMatch, bool isWeak = true) => IF_MATCH(ifMatch.ToString()!, isWeak); - public static Task And(this Task response, + public static Task And(this Task result, Func and) => - response.ContinueWith(t => and(t.Result)); + result.ContinueWith(t => and(t.Result.Response)); - public static Task And(this Task response, Func and) => - response.ContinueWith(t => and(t.Result)); + public static Task And(this Task result, Func and) => + result.ContinueWith(t => and(t.Result.Response)); - public static Task And(this Task response, + public static Task And(this Task result, 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); - - - // public static Func, Task> GET_UNTIL( - // Func> check) => - // SEND_UNTIL(HttpMethod.Get, check); - // - // 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!; - // }; + 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.CreateClient)); + + public static Task AndWhen(this Task result, params RequestTransform[] when) => + result.And().When(when); + + public static Task AndWhen( + this Task result, + Func when + ) => + result.ContinueWith(r => + new GivenApiSpecificationBuilder(r.Result.CreateClient).When(when(r.Result.Response)) + ); + + public static Task When( + this Task result, + params RequestTransform[] when + ) => + result.ContinueWith(_ => result.Result.When(when)); + + public static Task Until( + this Task when, + Func> check, + int maxNumberOfRetries = 5, + int retryIntervalInMs = 1000 + ) => + when.ContinueWith(t => t.Result.Until(check, maxNumberOfRetries, retryIntervalInMs)); + + public static Task Then( + this Task when, + Func then + ) => + when.ContinueWith(t => t.Result.Then(then)).Unwrap(); + + public static Task Then( + this Task when, + params Func[] 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 //// @@ -182,6 +203,12 @@ public static Func RESPONSE_BODY(Action as result.Should().BeEquivalentTo(result); }; + public static Func> RESPONSE_BODY() => + response => response.GetResultFromJson(); + + public static Func> CREATED_ID() => + response => Task.FromResult(response.GetCreatedId()); + public static Func> RESPONSE_ETAG_IS(object eTag, bool isWeak = true) => async response => { @@ -242,6 +269,8 @@ public static Func> RESPONSE_BODY_MATCHES CreateClient); } public class ApiSpecification: IDisposable where TProgram : class @@ -266,18 +295,18 @@ public GivenApiSpecificationBuilder Given( params RequestTransform[][] builders) => new(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( @@ -298,79 +327,128 @@ public async Task Scenario( //// BUILDER //// ///////////////////// - public class GivenApiSpecificationBuilder - { - private readonly RequestTransform[][] given; - private readonly Func createClient; - internal GivenApiSpecificationBuilder(Func createClient, RequestTransform[][] given) - { - this.createClient = createClient; - this.given = given; - } + public void Dispose() => + applicationFactory.Dispose(); +} + +public class GivenApiSpecificationBuilder +{ + private readonly RequestTransform[][] given; + private readonly Func createClient; - public WhenApiSpecificationBuilder When(params RequestTransform[] when) => - new(createClient, given, when); + internal GivenApiSpecificationBuilder(Func createClient, RequestTransform[][] given) + { + this.createClient = createClient; + this.given = given; } - public class WhenApiSpecificationBuilder + internal GivenApiSpecificationBuilder(Func createClient): this(createClient, + Array.Empty()) { - private readonly RequestTransform[][] given; - private readonly RequestTransform[] when; - private readonly Func createClient; - - internal WhenApiSpecificationBuilder( - Func createClient, - RequestTransform[][] given, - RequestTransform[] when - ) - { - this.createClient = createClient; - this.given = given; - this.when = when; - } + } - public Task Then(Func then) => - Then(new[] { then }); + public WhenApiSpecificationBuilder When(params RequestTransform[] when) => + new(createClient, given, when); +} - public Task Then(params Func[] thens) => - Then(thens, default); +public record RetryPolicy( + Func> Check, + int MaxNumberOfRetries = 5, + int RetryIntervalInMs = 1000 +) +{ + public async Task Perform(Func> send, CancellationToken ct) + { + var retryCount = MaxNumberOfRetries; + var finished = false; - public async Task Then(IEnumerable> thens, CancellationToken ct) + HttpResponseMessage? response = null; + do { - using var client = createClient(); - - // Given - foreach (var givenBuilder in given) - await client.Send(givenBuilder, ct: ct); - - // When - var response = await client.Send(when, ct: ct); + try + { + response = await send(ct); - // Then - foreach (var then in thens) + finished = await Check(response); + } + catch { - await then(response); + if (retryCount == 0) + throw; } - return response; - } - } + await Task.Delay(RetryIntervalInMs, ct); + retryCount--; + } while (!finished); - public void Dispose() - { - applicationFactory.Dispose(); + response.Should().NotBeNull(); + + return response!; } + + public static readonly RetryPolicy NoRetry = new RetryPolicy( + _ => ValueTask.FromResult(true), + 0, + 0 + ); } -public class AndSpecificationBuilder +public class WhenApiSpecificationBuilder { - private readonly Lazy> then; + private readonly RequestTransform[][] given; + private readonly RequestTransform[] when; + private readonly Func createClient; + private RetryPolicy retryPolicy; - public AndSpecificationBuilder(Lazy> then) => - this.then = then; + internal WhenApiSpecificationBuilder( + Func createClient, + RequestTransform[][] given, + RequestTransform[] when + ) + { + this.createClient = createClient; + this.given = given; + this.when = when; + retryPolicy = RetryPolicy.NoRetry; + } + + public WhenApiSpecificationBuilder Until( + Func> check, + int maxNumberOfRetries = 5, + int retryIntervalInMs = 1000 + ) + { + retryPolicy = new RetryPolicy(check, maxNumberOfRetries, retryIntervalInMs); + return this; + } + + public Task Then(Func then) => + Then(new[] { then }); + + public Task Then(params Func[] thens) => + Then(thens, default); + + public async Task Then(IEnumerable> thens, + CancellationToken ct) + { + using var client = createClient(); - public Task Response => then.Value; + // Given + foreach (var givenBuilder in given) + await client.Send(givenBuilder, ct: ct); + + // When + var response = await retryPolicy.Perform(t => client.Send(when, ct: t), ct); + + // Then + foreach (var then in thens) + { + await then(response); + } + + return new ApiSpecification.Result(response, createClient); + } } public class ApiRequest @@ -469,8 +547,17 @@ public static T GetETagValue(this HttpResponseMessage response) => public static string GetETagValue(this HttpResponseMessage response) => response.GetETagValue(); - public static async Task GetResultFromJson(this HttpResponseMessage response, - JsonSerializerSettings? settings = null) + 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(); From 9ddba634d48f0658cdfe5c857521b0ab6b476a0b Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Fri, 11 Aug 2023 15:44:29 +0200 Subject: [PATCH 03/11] Added test context to make setup easier and more advanced scenarios. Moved some tooling code to dedicated files --- src/Ogooreck.Sample.Api.Tests/ApiTests.cs | 3 +- src/Ogooreck/API/ApiSpecification.cs | 336 +++++++----------- .../API/HttpResponseMessageExtensions.cs | 96 +++++ src/Ogooreck/API/RetryPolicy.cs | 46 +++ 4 files changed, 272 insertions(+), 209 deletions(-) create mode 100644 src/Ogooreck/API/HttpResponseMessageExtensions.cs create mode 100644 src/Ogooreck/API/RetryPolicy.cs diff --git a/src/Ogooreck.Sample.Api.Tests/ApiTests.cs b/src/Ogooreck.Sample.Api.Tests/ApiTests.cs index 118f9d0..2362b27 100644 --- a/src/Ogooreck.Sample.Api.Tests/ApiTests.cs +++ b/src/Ogooreck.Sample.Api.Tests/ApiTests.cs @@ -24,8 +24,7 @@ public Task GetProducts() => [Fact] public Task RegisterProduct() => - API.Given( - ) + API.Given() .When( POST, URI("/api/products"), diff --git a/src/Ogooreck/API/ApiSpecification.cs b/src/Ogooreck/API/ApiSpecification.cs index 573d8ae..a7a0835 100644 --- a/src/Ogooreck/API/ApiSpecification.cs +++ b/src/Ogooreck/API/ApiSpecification.cs @@ -1,17 +1,31 @@ -using System.ComponentModel; -using System.Net; +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; #pragma warning disable CS1591 namespace Ogooreck.API; -public delegate HttpRequestMessage RequestTransform(HttpRequestMessage request); +public record MadeApiCall(HttpRequestMessage Request, HttpResponseMessage Response); + +public class TestContext +{ + public List Calls { get; } = new(); + + public void Record(HttpRequestMessage request, HttpResponseMessage response) => + Calls.Add(new MadeApiCall(request, response)); + + public T GetCreatedId() where T : notnull => + Calls.First().Response.GetCreatedId(); +} + +public record RequestDefinition(RequestTransform[] Transformations); + +public delegate HttpRequestMessage RequestTransform(HttpRequestMessage request, TestContext context); + +public delegate ValueTask ResponseAssert(HttpResponseMessage response, TestContext context); public static class ApiSpecification { @@ -25,18 +39,24 @@ public static class ApiSpecification public static RequestTransform[] SEND(params RequestTransform[] when) => when; + 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) => - request => + URI(_ => uri); + + public static RequestTransform URI(Func getUri) => + (request, ctx) => { - request.RequestUri = uri; + request.RequestUri = getUri(ctx); return request; }; public static RequestTransform BODY(T body) => - request => + (request, _) => { request.Content = JsonContent.Create(body); @@ -44,7 +64,7 @@ public static RequestTransform BODY(T body) => }; public static RequestTransform HEADERS(params Action[] headers) => - request => + (request, _) => { foreach (var header in headers) { @@ -56,7 +76,7 @@ public static RequestTransform HEADERS(params Action[] heade public static RequestTransform GET => - request => + (request, _) => { request.Method = HttpMethod.Get; return request; @@ -71,7 +91,7 @@ public static RequestTransform HEADERS(params Action[] heade public static RequestTransform OPTIONS => SEND(HttpMethod.Options); public static RequestTransform SEND(HttpMethod method) => - request => + (request, _) => { request.Method = method; return request; @@ -101,7 +121,7 @@ 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.CreateClient)); + result.ContinueWith(_ => new GivenApiSpecificationBuilder(result.Result.TestContext, result.Result.CreateClient)); public static Task AndWhen(this Task result, params RequestTransform[] when) => result.And().When(when); @@ -111,7 +131,7 @@ public static Task AndWhen( Func when ) => result.ContinueWith(r => - new GivenApiSpecificationBuilder(r.Result.CreateClient).When(when(r.Result.Response)) + new GivenApiSpecificationBuilder(r.Result.TestContext, r.Result.CreateClient).When(when(r.Result.Response)) ); public static Task When( @@ -122,7 +142,7 @@ params RequestTransform[] when public static Task Until( this Task when, - Func> check, + Func> check, int maxNumberOfRetries = 5, int retryIntervalInMs = 1000 ) => @@ -130,19 +150,19 @@ public static Task Until( public static Task Then( this Task when, - Func then + ResponseAssert then ) => when.ContinueWith(t => t.Result.Then(then)).Unwrap(); public static Task Then( this Task when, - params Func[] thens + params ResponseAssert[] thens ) => when.ContinueWith(t => t.Result.Then(thens)).Unwrap(); public static Task Then( this Task when, - IEnumerable> thens, + IEnumerable thens, CancellationToken ct ) => when.ContinueWith(t => t.Result.Then(thens, ct), ct).Unwrap(); @@ -160,45 +180,51 @@ Func> map /////////////////// //// 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 = + 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 Func METHOD_NOT_ALLOWED = + public static ResponseAssert METHOD_NOT_ALLOWED = HTTP_STATUS(HttpStatusCode.MethodNotAllowed); - public static Func HTTP_STATUS(HttpStatusCode status) => - response => + public static ResponseAssert HTTP_STATUS(HttpStatusCode status) => + (response, ctx) => { response.StatusCode.Should().Be(status); return ValueTask.CompletedTask; }; - - public static Func CREATED_WITH_DEFAULT_HEADERS( + public static ResponseAssert CREATED_WITH_DEFAULT_HEADERS( string? locationHeaderPrefix = null, object? eTag = null, bool isETagWeak = true) => - async response => + async (response, ctx) => { - await CREATED(response); - await RESPONSE_LOCATION_HEADER(locationHeaderPrefix)(response); + await CREATED(response, ctx); + await RESPONSE_LOCATION_HEADER(locationHeaderPrefix)(response, ctx); if (eTag != null) - await RESPONSE_ETAG_HEADER(eTag, isETagWeak)(response); + await RESPONSE_ETAG_HEADER(eTag, isETagWeak)(response, ctx); }; - public static Func RESPONSE_BODY(T body) => + + public static ResponseAssert RESPONSE_BODY(T body) => RESPONSE_BODY(result => result.Should().BeEquivalentTo(body)); - public static Func RESPONSE_BODY(Action assert) => - async response => + 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); + assert(result, ctx); result.Should().BeEquivalentTo(result); }; @@ -209,14 +235,15 @@ public static Func> RESPONSE_BODY() => public static Func> CREATED_ID() => response => Task.FromResult(response.GetCreatedId()); - public static Func> RESPONSE_ETAG_IS(object eTag, bool isWeak = true) => - async response => + public static Func> RESPONSE_ETAG_IS(object eTag, + bool isWeak = true) => + async (response, ctx) => { - await RESPONSE_ETAG_HEADER(eTag, isWeak)(response); + await RESPONSE_ETAG_HEADER(eTag, isWeak)(response, ctx); return true; }; - public static Func RESPONSE_ETAG_HEADER(object eTag, bool isWeak = true) => + 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 @@ -227,10 +254,10 @@ public static Func RESPONSE_ETAG_HEADER(object e headers.ETag.Tag.Should().Be($"\"{eTag}\""); }); - public static Func RESPONSE_LOCATION_HEADER(string? locationHeaderPrefix = null) => - async response => + public static ResponseAssert RESPONSE_LOCATION_HEADER(string? locationHeaderPrefix = null) => + async (response, ctx) => { - await HTTP_STATUS(HttpStatusCode.Created)(response); + await HTTP_STATUS(HttpStatusCode.Created)(response, ctx); var locationHeader = response.Headers.Location; @@ -241,8 +268,8 @@ public static Func RESPONSE_LOCATION_HEADER(stri location.Should().StartWith(locationHeaderPrefix ?? response.RequestMessage!.RequestUri!.AbsolutePath); }; - public static Func RESPONSE_HEADERS(params Action[] headers) => - response => + public static ResponseAssert RESPONSE_HEADERS(params Action[] headers) => + (response, ctx) => { foreach (var header in headers) { @@ -270,7 +297,7 @@ public static Func> RESPONSE_BODY_MATCHES CreateClient); + public record Result(HttpResponseMessage Response, TestContext TestContext, Func CreateClient); } public class ApiSpecification: IDisposable where TProgram : class @@ -293,7 +320,7 @@ public static ApiSpecification Setup(WebApplicationFactory a public GivenApiSpecificationBuilder Given( params RequestTransform[][] builders) => - new(createClient, builders); + new(new TestContext(), createClient, builders); public async Task Scenario( Task first, @@ -336,62 +363,30 @@ public class GivenApiSpecificationBuilder { private readonly RequestTransform[][] given; private readonly Func createClient; + private readonly TestContext testContext; - internal GivenApiSpecificationBuilder(Func createClient, RequestTransform[][] given) + internal GivenApiSpecificationBuilder + ( + TestContext testContext, + Func createClient, + RequestTransform[][] given + ) { + this.testContext = testContext; this.createClient = createClient; this.given = given; } - internal GivenApiSpecificationBuilder(Func createClient): this(createClient, - Array.Empty()) + internal GivenApiSpecificationBuilder + ( + TestContext testContext, + Func createClient + ): this(testContext, createClient, Array.Empty()) { } public WhenApiSpecificationBuilder When(params RequestTransform[] when) => - new(createClient, given, when); -} - -public record RetryPolicy( - Func> Check, - int MaxNumberOfRetries = 5, - int RetryIntervalInMs = 1000 -) -{ - public async Task Perform(Func> send, CancellationToken ct) - { - var retryCount = MaxNumberOfRetries; - var finished = false; - - HttpResponseMessage? response = null; - do - { - try - { - response = await send(ct); - - finished = await Check(response); - } - 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( - _ => ValueTask.FromResult(true), - 0, - 0 - ); + new(createClient, testContext, given, when); } public class WhenApiSpecificationBuilder @@ -399,22 +394,25 @@ public class WhenApiSpecificationBuilder private readonly RequestTransform[][] given; private readonly RequestTransform[] when; private readonly Func createClient; + private readonly TestContext testContext; private RetryPolicy retryPolicy; internal WhenApiSpecificationBuilder( Func createClient, + TestContext testContext, RequestTransform[][] given, RequestTransform[] when ) { this.createClient = createClient; + this.testContext = testContext; this.given = given; this.when = when; retryPolicy = RetryPolicy.NoRetry; } public WhenApiSpecificationBuilder Until( - Func> check, + Func> check, int maxNumberOfRetries = 5, int retryIntervalInMs = 1000 ) @@ -423,151 +421,75 @@ public WhenApiSpecificationBuilder Until( return this; } - public Task Then(Func then) => + public Task Then(ResponseAssert then) => Then(new[] { then }); - public Task Then(params Func[] thens) => + public Task Then(params ResponseAssert[] thens) => Then(thens, default); - public async Task Then(IEnumerable> thens, + public async Task Then(IEnumerable thens, CancellationToken ct) { using var client = createClient(); // Given foreach (var givenBuilder in given) - await client.Send(givenBuilder, ct: ct); + await Send(client, givenBuilder, testContext, ct); // When - var response = await retryPolicy.Perform(t => client.Send(when, ct: t), ct); + var response = await retryPolicy + .Perform(t => + Send(client, when, testContext, ct), testContext, ct + ); // Then foreach (var then in thens) { - await then(response); + await then(response, testContext); } - return new ApiSpecification.Result(response, createClient); + return new ApiSpecification.Result(response, testContext, createClient); + } + + private static async Task Send( + HttpClient client, + RequestTransform[] givenBuilder, + TestContext testContext, + CancellationToken ct + ) + { + var request = TestApiRequest.For(testContext, givenBuilder); + var response = await client.SendAsync(request, ct); + testContext.Record(request, response); + + return response; } } -public class ApiRequest +public class TestApiRequest { private readonly RequestTransform[] builders; + private readonly TestContext testContext; - public ApiRequest(params RequestTransform[] builders) => + public TestApiRequest(TestContext testContext, params RequestTransform[] builders) + { + this.testContext = testContext; this.builders = builders; + } public HttpRequestMessage Build() => - builders.Aggregate(new HttpRequestMessage(), (request, build) => build(request)); + builders.Aggregate(new HttpRequestMessage(), (request, build) => build(request, testContext)); - public static HttpRequestMessage For(params RequestTransform[] builders) => - builders.Aggregate(new HttpRequestMessage(), (request, build) => build(request)); + public static HttpRequestMessage For(TestContext testContext, params RequestTransform[] builders) => + builders.Aggregate(new HttpRequestMessage(), (request, build) => build(request, testContext)); } public static class ApiRequestExtensions { public static Task Send( this HttpClient httpClient, - ApiRequest apiRequest, - CancellationToken ct = default - ) => - httpClient.SendAsync(apiRequest.Build(), ct); - - - public static Task Send( - this HttpClient httpClient, - RequestTransform[] builders, + TestApiRequest testApiRequest, CancellationToken ct = default ) => - httpClient.SendAsync(ApiRequest.For(builders), ct); -} - -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 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; - } + httpClient.SendAsync(testApiRequest.Build(), ct); } diff --git a/src/Ogooreck/API/HttpResponseMessageExtensions.cs b/src/Ogooreck/API/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000..1ce94d4 --- /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); + + 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..c481cf8 --- /dev/null +++ b/src/Ogooreck/API/RetryPolicy.cs @@ -0,0 +1,46 @@ +using FluentAssertions; + +namespace Ogooreck.API; + +#pragma warning disable CS1591 +public record RetryPolicy( + Func> 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 + ); +} From ee57cb4b40ff97e64dcce7678f349d5c51cbccfd Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 15 Aug 2023 10:20:21 +0200 Subject: [PATCH 04/11] Broken down the API Specification into separate files for easier code understanding --- README.md | 16 +- src/Ogooreck/API/ApiSpecification.cs | 398 ------------------ .../API/ApiSpecificationExtensions.cs | 279 ++++++++++++ .../API/HttpResponseMessageExtensions.cs | 2 +- src/Ogooreck/API/TestBuilders.cs | 123 ++++++ 5 files changed, 411 insertions(+), 407 deletions(-) create mode 100644 src/Ogooreck/API/ApiSpecificationExtensions.cs create mode 100644 src/Ogooreck/API/TestBuilders.cs diff --git a/README.md b/README.md index 4b93c2f..fada97c 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 diff --git a/src/Ogooreck/API/ApiSpecification.cs b/src/Ogooreck/API/ApiSpecification.cs index a7a0835..b6b8c78 100644 --- a/src/Ogooreck/API/ApiSpecification.cs +++ b/src/Ogooreck/API/ApiSpecification.cs @@ -8,298 +8,12 @@ namespace Ogooreck.API; -public record MadeApiCall(HttpRequestMessage Request, HttpResponseMessage Response); - -public class TestContext -{ - public List Calls { get; } = new(); - - public void Record(HttpRequestMessage request, HttpResponseMessage response) => - Calls.Add(new MadeApiCall(request, response)); - - public T GetCreatedId() where T : notnull => - Calls.First().Response.GetCreatedId(); -} - public record RequestDefinition(RequestTransform[] Transformations); public delegate HttpRequestMessage RequestTransform(HttpRequestMessage request, TestContext context); public delegate ValueTask ResponseAssert(HttpResponseMessage response, TestContext context); -public static class ApiSpecification -{ - /////////////////// - //// GIVEN //// - /////////////////// - - /////////////////// - //// WHEN //// - /////////////////// - - public static RequestTransform[] SEND(params RequestTransform[] when) => when; - - 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, params RequestTransform[] when) => - result.And().When(when); - - 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, - params RequestTransform[] when - ) => - result.ContinueWith(_ => result.Result.When(when)); - - public static Task Until( - this Task when, - Func> 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 Func> RESPONSE_ETAG_IS(object eTag, - bool isWeak = true) => - async (response, ctx) => - { - await RESPONSE_ETAG_HEADER(eTag, isWeak)(response, ctx); - return true; - }; - - 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; - }; - - 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 record Result(HttpResponseMessage Response, TestContext TestContext, Func CreateClient); -} - public class ApiSpecification: IDisposable where TProgram : class { private readonly WebApplicationFactory applicationFactory; @@ -350,122 +64,10 @@ public async Task Scenario( return response; } - ///////////////////// - //// BUILDER //// - ///////////////////// - - public void Dispose() => applicationFactory.Dispose(); } -public class GivenApiSpecificationBuilder -{ - private readonly RequestTransform[][] given; - private readonly Func createClient; - private readonly TestContext testContext; - - internal GivenApiSpecificationBuilder - ( - TestContext testContext, - Func createClient, - RequestTransform[][] 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) => - new(createClient, testContext, given, when); -} - -public class WhenApiSpecificationBuilder -{ - private readonly RequestTransform[][] given; - private readonly RequestTransform[] when; - private readonly Func createClient; - private readonly TestContext testContext; - private RetryPolicy retryPolicy; - - internal WhenApiSpecificationBuilder( - Func createClient, - TestContext testContext, - RequestTransform[][] given, - RequestTransform[] when - ) - { - this.createClient = createClient; - this.testContext = testContext; - this.given = given; - this.when = when; - retryPolicy = RetryPolicy.NoRetry; - } - - public WhenApiSpecificationBuilder Until( - Func> 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, givenBuilder, testContext, ct); - - // When - var response = await retryPolicy - .Perform(t => - Send(client, when, testContext, ct), testContext, ct - ); - - // Then - foreach (var then in thens) - { - await then(response, testContext); - } - - return new ApiSpecification.Result(response, testContext, createClient); - } - - private static async Task Send( - HttpClient client, - RequestTransform[] givenBuilder, - TestContext testContext, - CancellationToken ct - ) - { - var request = TestApiRequest.For(testContext, givenBuilder); - var response = await client.SendAsync(request, ct); - testContext.Record(request, response); - - return response; - } -} - public class TestApiRequest { private readonly RequestTransform[] builders; diff --git a/src/Ogooreck/API/ApiSpecificationExtensions.cs b/src/Ogooreck/API/ApiSpecificationExtensions.cs new file mode 100644 index 0000000..52f4c6c --- /dev/null +++ b/src/Ogooreck/API/ApiSpecificationExtensions.cs @@ -0,0 +1,279 @@ +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 //// + /////////////////// + + /////////////////// + //// WHEN //// + /////////////////// + + public static RequestTransform[] SEND(params RequestTransform[] when) => when; + + 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, params RequestTransform[] when) => + result.And().When(when); + + 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, + params RequestTransform[] when + ) => + result.ContinueWith(_ => result.Result.When(when)); + + public static Task Until( + this Task when, + Func> 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 Func> RESPONSE_ETAG_IS(object eTag, + bool isWeak = true) => + async (response, ctx) => + { + await RESPONSE_ETAG_HEADER(eTag, isWeak)(response, ctx); + return true; + }; + + 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; + }; + + 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 record Result(HttpResponseMessage Response, TestContext TestContext, Func CreateClient); +} diff --git a/src/Ogooreck/API/HttpResponseMessageExtensions.cs b/src/Ogooreck/API/HttpResponseMessageExtensions.cs index 1ce94d4..241249c 100644 --- a/src/Ogooreck/API/HttpResponseMessageExtensions.cs +++ b/src/Ogooreck/API/HttpResponseMessageExtensions.cs @@ -19,7 +19,7 @@ public static bool TryGetCreatedId(this HttpResponseMessage response, out T? locationHeader = locationHeader.StartsWith("/") ? locationHeader : $"/{locationHeader}"; - var start = locationHeader.LastIndexOf("/", locationHeader.Length - 1); + var start = locationHeader.LastIndexOf("/", locationHeader.Length - 1, StringComparison.Ordinal); var createdId = locationHeader.Substring(start + 1, locationHeader.Length - 1 - start); diff --git a/src/Ogooreck/API/TestBuilders.cs b/src/Ogooreck/API/TestBuilders.cs new file mode 100644 index 0000000..a375082 --- /dev/null +++ b/src/Ogooreck/API/TestBuilders.cs @@ -0,0 +1,123 @@ +namespace Ogooreck.API; + +#pragma warning disable CS1591 + +public class GivenApiSpecificationBuilder +{ + private readonly RequestTransform[][] given; + private readonly Func createClient; + private readonly TestContext testContext; + + internal GivenApiSpecificationBuilder + ( + TestContext testContext, + Func createClient, + RequestTransform[][] 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) => + new(createClient, testContext, given, when); +} + +public class WhenApiSpecificationBuilder +{ + private readonly RequestTransform[][] given; + private readonly RequestTransform[] when; + private readonly Func createClient; + private readonly TestContext testContext; + private RetryPolicy retryPolicy; + + internal WhenApiSpecificationBuilder( + Func createClient, + TestContext testContext, + RequestTransform[][] given, + RequestTransform[] when + ) + { + this.createClient = createClient; + this.testContext = testContext; + this.given = given; + this.when = when; + retryPolicy = RetryPolicy.NoRetry; + } + + public WhenApiSpecificationBuilder Until( + Func> 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, givenBuilder, testContext, ct); + + // When + var response = await retryPolicy + .Perform(t => + Send(client, when, testContext, ct), testContext, ct + ); + + // Then + foreach (var then in thens) + { + await then(response, testContext); + } + + return new ApiSpecification.Result(response, testContext, createClient); + } + + private static async Task Send( + HttpClient client, + RequestTransform[] givenBuilder, + TestContext testContext, + CancellationToken ct + ) + { + var request = TestApiRequest.For(testContext, givenBuilder); + var response = await client.SendAsync(request, ct); + testContext.Record(request, response); + + return response; + } +} + +public record MadeApiCall(HttpRequestMessage Request, HttpResponseMessage Response); + +public class TestContext +{ + public List Calls { get; } = new(); + + public void Record(HttpRequestMessage request, HttpResponseMessage response) => + Calls.Add(new MadeApiCall(request, response)); + + public T GetCreatedId() where T : notnull => + Calls.First().Response.GetCreatedId(); +} From 26925faa4b6cb477ef6da2ce2b902ebe801440cb Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 15 Aug 2023 10:49:22 +0200 Subject: [PATCH 05/11] Added information about the test phase to the made api call --- src/Ogooreck/API/TestBuilders.cs | 47 ++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/Ogooreck/API/TestBuilders.cs b/src/Ogooreck/API/TestBuilders.cs index a375082..3598c81 100644 --- a/src/Ogooreck/API/TestBuilders.cs +++ b/src/Ogooreck/API/TestBuilders.cs @@ -1,3 +1,5 @@ +using System.Net; + namespace Ogooreck.API; #pragma warning disable CS1591 @@ -77,13 +79,10 @@ public WhenApiSpecificationBuilder Until( // Given foreach (var givenBuilder in given) - await Send(client, givenBuilder, testContext, ct); + await Send(client, RetryPolicy.NoRetry, TestPhase.Given, givenBuilder, testContext, ct); // When - var response = await retryPolicy - .Perform(t => - Send(client, when, testContext, ct), testContext, ct - ); + var response = await Send(client, retryPolicy, TestPhase.When, when, testContext, ct); // Then foreach (var then in thens) @@ -94,30 +93,42 @@ public WhenApiSpecificationBuilder Until( return new ApiSpecification.Result(response, testContext, createClient); } - private static async Task Send( + private static Task Send( HttpClient client, - RequestTransform[] givenBuilder, + RetryPolicy retryPolicy, + TestPhase testPhase, + RequestTransform[] requestBuilder, TestContext testContext, CancellationToken ct - ) - { - var request = TestApiRequest.For(testContext, givenBuilder); - var response = await client.SendAsync(request, ct); - testContext.Record(request, response); + ) => + retryPolicy + .Perform(async t => + { + var request = TestApiRequest.For(testContext, requestBuilder); + var response = await client.SendAsync(request, t); - return response; - } + testContext.Record(testPhase, request, response); + + return response; + }, testContext, ct); +} + +public enum TestPhase +{ + Given, + When, + Then } -public record MadeApiCall(HttpRequestMessage Request, HttpResponseMessage Response); +public record MadeApiCall(TestPhase TestPhase, HttpRequestMessage Request, HttpResponseMessage Response, string Description = ""); public class TestContext { public List Calls { get; } = new(); - public void Record(HttpRequestMessage request, HttpResponseMessage response) => - Calls.Add(new MadeApiCall(request, response)); + public void Record(TestPhase testPhase, HttpRequestMessage request, HttpResponseMessage response) => + Calls.Add(new MadeApiCall(testPhase, request, response)); public T GetCreatedId() where T : notnull => - Calls.First().Response.GetCreatedId(); + Calls.First(c => c.Response.StatusCode == HttpStatusCode.Created).Response.GetCreatedId(); } From 5fc5833a1a149503bcbf4211794837ca7b9946b0 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 15 Aug 2023 11:08:57 +0200 Subject: [PATCH 06/11] Added RequestDefinition, to allow providing more context into it --- src/Ogooreck/API/ApiSpecification.cs | 4 ++-- src/Ogooreck/API/TestBuilders.cs | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Ogooreck/API/ApiSpecification.cs b/src/Ogooreck/API/ApiSpecification.cs index b6b8c78..7641209 100644 --- a/src/Ogooreck/API/ApiSpecification.cs +++ b/src/Ogooreck/API/ApiSpecification.cs @@ -8,7 +8,7 @@ namespace Ogooreck.API; -public record RequestDefinition(RequestTransform[] Transformations); +public record RequestDefinition(RequestTransform[] Transformations, string Description = ""); public delegate HttpRequestMessage RequestTransform(HttpRequestMessage request, TestContext context); @@ -33,7 +33,7 @@ public static ApiSpecification Setup(WebApplicationFactory a new(applicationFactory); public GivenApiSpecificationBuilder Given( - params RequestTransform[][] builders) => + params RequestDefinition[] builders) => new(new TestContext(), createClient, builders); public async Task Scenario( diff --git a/src/Ogooreck/API/TestBuilders.cs b/src/Ogooreck/API/TestBuilders.cs index 3598c81..18e0aa6 100644 --- a/src/Ogooreck/API/TestBuilders.cs +++ b/src/Ogooreck/API/TestBuilders.cs @@ -6,7 +6,7 @@ namespace Ogooreck.API; public class GivenApiSpecificationBuilder { - private readonly RequestTransform[][] given; + private readonly RequestDefinition[] given; private readonly Func createClient; private readonly TestContext testContext; @@ -14,7 +14,7 @@ internal GivenApiSpecificationBuilder ( TestContext testContext, Func createClient, - RequestTransform[][] given + RequestDefinition[] given ) { this.testContext = testContext; @@ -26,18 +26,18 @@ internal GivenApiSpecificationBuilder ( TestContext testContext, Func createClient - ): this(testContext, createClient, Array.Empty()) + ): this(testContext, createClient, Array.Empty()) { } public WhenApiSpecificationBuilder When(params RequestTransform[] when) => - new(createClient, testContext, given, when); + new(createClient, testContext, given, new RequestDefinition(when)); } public class WhenApiSpecificationBuilder { - private readonly RequestTransform[][] given; - private readonly RequestTransform[] when; + private readonly RequestDefinition[] given; + private readonly RequestDefinition when; private readonly Func createClient; private readonly TestContext testContext; private RetryPolicy retryPolicy; @@ -45,8 +45,8 @@ public class WhenApiSpecificationBuilder internal WhenApiSpecificationBuilder( Func createClient, TestContext testContext, - RequestTransform[][] given, - RequestTransform[] when + RequestDefinition[] given, + RequestDefinition when ) { this.createClient = createClient; @@ -97,14 +97,14 @@ private static Task Send( HttpClient client, RetryPolicy retryPolicy, TestPhase testPhase, - RequestTransform[] requestBuilder, + RequestDefinition requestBuilder, TestContext testContext, CancellationToken ct ) => retryPolicy .Perform(async t => { - var request = TestApiRequest.For(testContext, requestBuilder); + var request = TestApiRequest.For(testContext, requestBuilder.Transformations); var response = await client.SendAsync(request, t); testContext.Record(testPhase, request, response); From 6d00973ad5405f476549731727559eae1f3af301 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 15 Aug 2023 12:26:44 +0200 Subject: [PATCH 07/11] Added description to the test step definition --- src/Ogooreck/API/ApiSpecification.cs | 27 +++++---- .../API/ApiSpecificationExtensions.cs | 5 +- src/Ogooreck/API/TestBuilders.cs | 55 +++++++++++++------ 3 files changed, 59 insertions(+), 28 deletions(-) diff --git a/src/Ogooreck/API/ApiSpecification.cs b/src/Ogooreck/API/ApiSpecification.cs index 7641209..f6439ac 100644 --- a/src/Ogooreck/API/ApiSpecification.cs +++ b/src/Ogooreck/API/ApiSpecification.cs @@ -1,15 +1,9 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using FluentAssertions; -using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Mvc.Testing; #pragma warning disable CS1591 namespace Ogooreck.API; -public record RequestDefinition(RequestTransform[] Transformations, string Description = ""); - public delegate HttpRequestMessage RequestTransform(HttpRequestMessage request, TestContext context); public delegate ValueTask ResponseAssert(HttpResponseMessage response, TestContext context); @@ -33,7 +27,19 @@ public static ApiSpecification Setup(WebApplicationFactory a new(applicationFactory); public GivenApiSpecificationBuilder Given( - params RequestDefinition[] builders) => + params RequestDefinition[] builders + ) => + Given(builders.Select(b => new ApiTestStep(TestPhase.Given, b)).ToArray()); + + + public GivenApiSpecificationBuilder Given( + string description, + params RequestDefinition[] builders + ) => + Given(builders.Select(b => new ApiTestStep(TestPhase.Given, b, description)).ToArray()); + + public GivenApiSpecificationBuilder Given( + ApiTestStep[] builders) => new(new TestContext(), createClient, builders); public async Task Scenario( @@ -82,8 +88,9 @@ public TestApiRequest(TestContext testContext, params RequestTransform[] builder public HttpRequestMessage Build() => builders.Aggregate(new HttpRequestMessage(), (request, build) => build(request, testContext)); - public static HttpRequestMessage For(TestContext testContext, params RequestTransform[] builders) => - builders.Aggregate(new HttpRequestMessage(), (request, build) => build(request, testContext)); + public static HttpRequestMessage For(TestContext testContext, RequestDefinition requestDefinition) => + requestDefinition.Transformations + .Aggregate(new HttpRequestMessage(), (request, build) => build(request, testContext)); } public static class ApiRequestExtensions diff --git a/src/Ogooreck/API/ApiSpecificationExtensions.cs b/src/Ogooreck/API/ApiSpecificationExtensions.cs index 52f4c6c..984703a 100644 --- a/src/Ogooreck/API/ApiSpecificationExtensions.cs +++ b/src/Ogooreck/API/ApiSpecificationExtensions.cs @@ -16,7 +16,10 @@ public static class ApiSpecification //// WHEN //// /////////////////// - public static RequestTransform[] SEND(params RequestTransform[] when) => 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)); diff --git a/src/Ogooreck/API/TestBuilders.cs b/src/Ogooreck/API/TestBuilders.cs index 18e0aa6..264a417 100644 --- a/src/Ogooreck/API/TestBuilders.cs +++ b/src/Ogooreck/API/TestBuilders.cs @@ -6,7 +6,7 @@ namespace Ogooreck.API; public class GivenApiSpecificationBuilder { - private readonly RequestDefinition[] given; + private readonly ApiTestStep[] given; private readonly Func createClient; private readonly TestContext testContext; @@ -14,7 +14,7 @@ internal GivenApiSpecificationBuilder ( TestContext testContext, Func createClient, - RequestDefinition[] given + ApiTestStep[] given ) { this.testContext = testContext; @@ -26,18 +26,31 @@ internal GivenApiSpecificationBuilder ( TestContext testContext, Func createClient - ): this(testContext, createClient, Array.Empty()) + ): this(testContext, createClient, Array.Empty()) { } public WhenApiSpecificationBuilder When(params RequestTransform[] when) => - new(createClient, testContext, given, new RequestDefinition(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 RequestDefinition[] given; - private readonly RequestDefinition when; + private readonly ApiTestStep[] given; + private readonly ApiTestStep when; private readonly Func createClient; private readonly TestContext testContext; private RetryPolicy retryPolicy; @@ -45,8 +58,8 @@ public class WhenApiSpecificationBuilder internal WhenApiSpecificationBuilder( Func createClient, TestContext testContext, - RequestDefinition[] given, - RequestDefinition when + ApiTestStep[] given, + ApiTestStep when ) { this.createClient = createClient; @@ -79,10 +92,10 @@ public WhenApiSpecificationBuilder Until( // Given foreach (var givenBuilder in given) - await Send(client, RetryPolicy.NoRetry, TestPhase.Given, givenBuilder, testContext, ct); + await Send(client, RetryPolicy.NoRetry, givenBuilder, testContext, ct); // When - var response = await Send(client, retryPolicy, TestPhase.When, when, testContext, ct); + var response = await Send(client, retryPolicy, when, testContext, ct); // Then foreach (var then in thens) @@ -96,18 +109,17 @@ public WhenApiSpecificationBuilder Until( private static Task Send( HttpClient client, RetryPolicy retryPolicy, - TestPhase testPhase, - RequestDefinition requestBuilder, + ApiTestStep testStep, TestContext testContext, CancellationToken ct ) => retryPolicy .Perform(async t => { - var request = TestApiRequest.For(testContext, requestBuilder.Transformations); + var request = TestApiRequest.For(testContext, testStep.RequestDefinition); var response = await client.SendAsync(request, t); - testContext.Record(testPhase, request, response); + testContext.Record(testStep, request, response); return response; }, testContext, ct); @@ -120,14 +132,23 @@ public enum TestPhase Then } -public record MadeApiCall(TestPhase TestPhase, HttpRequestMessage Request, HttpResponseMessage Response, string Description = ""); +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(TestPhase testPhase, HttpRequestMessage request, HttpResponseMessage response) => - Calls.Add(new MadeApiCall(testPhase, request, response)); + 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(); From 8ee0c28444c15b4ab9d48eaf8920e91eaeed56e0 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 15 Aug 2023 12:28:27 +0200 Subject: [PATCH 08/11] Bumped version to 0.8.0-rc.1 --- src/Ogooreck/Ogooreck.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ogooreck/Ogooreck.csproj b/src/Ogooreck/Ogooreck.csproj index ab3dd9d..cd64262 100644 --- a/src/Ogooreck/Ogooreck.csproj +++ b/src/Ogooreck/Ogooreck.csproj @@ -1,7 +1,7 @@ - 0.7.0 + 0.8.0-rc.1 net6.0;net7.0 true true From 9f960a08fc6c183485b866d185e1b7254487ba0d Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 15 Aug 2023 13:36:37 +0200 Subject: [PATCH 09/11] Added helpers for easier setup and fixed built-in checks for retry policy --- .../API/ApiSpecificationExtensions.cs | 67 +++++++++++++++---- src/Ogooreck/API/RetryPolicy.cs | 5 +- src/Ogooreck/API/TestBuilders.cs | 2 +- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/src/Ogooreck/API/ApiSpecificationExtensions.cs b/src/Ogooreck/API/ApiSpecificationExtensions.cs index 984703a..4c551b8 100644 --- a/src/Ogooreck/API/ApiSpecificationExtensions.cs +++ b/src/Ogooreck/API/ApiSpecificationExtensions.cs @@ -12,6 +12,16 @@ 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 //// /////////////////// @@ -103,11 +113,29 @@ 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)); + 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 @@ -116,6 +144,13 @@ Func when 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 @@ -124,7 +159,7 @@ params RequestTransform[] when public static Task Until( this Task when, - Func> check, + RetryCheck check, int maxNumberOfRetries = 5, int retryIntervalInMs = 1000 ) => @@ -216,14 +251,6 @@ public static Func> RESPONSE_BODY() => public static Func> CREATED_ID() => response => Task.FromResult(response.GetCreatedId()); - public static Func> RESPONSE_ETAG_IS(object eTag, - bool isWeak = true) => - async (response, ctx) => - { - await RESPONSE_ETAG_HEADER(eTag, isWeak)(response, ctx); - return true; - }; - public static ResponseAssert RESPONSE_ETAG_HEADER(object eTag, bool isWeak = true) => RESPONSE_HEADERS(headers => { @@ -260,15 +287,27 @@ public static ResponseAssert RESPONSE_HEADERS(params Action return ValueTask.CompletedTask; }; - public static Func> RESPONSE_SUCCEEDED() => - response => + ///////////////// + // 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 Func> RESPONSE_BODY_MATCHES(Func assert) => - async response => + public static RetryCheck RESPONSE_BODY_MATCHES(Func assert) => + async (response, ctx) => { response.EnsureSuccessStatusCode(); diff --git a/src/Ogooreck/API/RetryPolicy.cs b/src/Ogooreck/API/RetryPolicy.cs index c481cf8..65b7bca 100644 --- a/src/Ogooreck/API/RetryPolicy.cs +++ b/src/Ogooreck/API/RetryPolicy.cs @@ -4,7 +4,7 @@ namespace Ogooreck.API; #pragma warning disable CS1591 public record RetryPolicy( - Func> Check, + RetryCheck Check, int MaxNumberOfRetries = 5, int RetryIntervalInMs = 1000 ) @@ -44,3 +44,6 @@ public async Task Perform(Func RetryCheck(HttpResponseMessage responseMessage, TestContext testContext); + diff --git a/src/Ogooreck/API/TestBuilders.cs b/src/Ogooreck/API/TestBuilders.cs index 264a417..1795032 100644 --- a/src/Ogooreck/API/TestBuilders.cs +++ b/src/Ogooreck/API/TestBuilders.cs @@ -70,7 +70,7 @@ ApiTestStep when } public WhenApiSpecificationBuilder Until( - Func> check, + RetryCheck check, int maxNumberOfRetries = 5, int retryIntervalInMs = 1000 ) From 2f09f70158f13d1e8e85b64f0fac9119a5591f2b Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 15 Aug 2023 13:37:08 +0200 Subject: [PATCH 10/11] Bumped to 0.8.0-rc.2 --- src/Ogooreck/Ogooreck.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ogooreck/Ogooreck.csproj b/src/Ogooreck/Ogooreck.csproj index cd64262..2dcbc59 100644 --- a/src/Ogooreck/Ogooreck.csproj +++ b/src/Ogooreck/Ogooreck.csproj @@ -1,7 +1,7 @@ - 0.8.0-rc.1 + 0.8.0-rc.2 net6.0;net7.0 true true From c23fd8b42a2cafb21dd78fedc54c64b59b36f322 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 15 Aug 2023 14:55:49 +0200 Subject: [PATCH 11/11] Updated docs and bumped to 0.8.0 --- README.md | 198 +++++++++++++++++++++-------------- mdsource/README.source.md | 198 +++++++++++++++++++++-------------- src/Ogooreck/Ogooreck.csproj | 2 +- 3 files changed, 245 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md index fada97c..f43b091 100644 --- a/README.md +++ b/README.md @@ -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 fada97c..f43b091 100644 --- a/mdsource/README.source.md +++ b/mdsource/README.source.md @@ -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/Ogooreck.csproj b/src/Ogooreck/Ogooreck.csproj index 2dcbc59..fff796c 100644 --- a/src/Ogooreck/Ogooreck.csproj +++ b/src/Ogooreck/Ogooreck.csproj @@ -1,7 +1,7 @@ - 0.8.0-rc.2 + 0.8.0 net6.0;net7.0 true true