diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs new file mode 100644 index 000000000..5a9cb1bf3 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using Core.Testing; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Warehouse.Products.GettingProductDetails; +using Warehouse.Products.GettingProducts; +using Warehouse.Products.RegisteringProduct; +using Xunit; + +namespace Warehouse.Api.Tests.Products.GettingProductDetails +{ + public class GetProductDetailsFixture: ApiFixture + { + protected override string ApiUrl => "/api/products"; + + protected override Func SetupWebHostBuilder => + whb => WarehouseTestWebHostBuilder.Configure(whb, nameof(GetProductDetailsFixture)); + + public ProductDetails ExistingProduct = default!; + + public Guid ProductId = default!; + + public override async Task InitializeAsync() + { + var registerProduct = new RegisterProductRequest("IN11111", "ValidName", "ValidDescription"); + var registerResponse = await Post(registerProduct); + + registerResponse.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.Created); + + ProductId = await registerResponse.GetResultFromJson(); + + var (sku, name, description) = registerProduct; + ExistingProduct = new ProductDetails(ProductId, sku!, name!, description); + } + } + + public class GetProductDetailsTests: IClassFixture + { + private readonly GetProductDetailsFixture fixture; + + public GetProductDetailsTests(GetProductDetailsFixture fixture) + { + this.fixture = fixture; + } + + [Fact] + public async Task ValidRequest_With_NoParams_ShouldReturn_200() + { + // Given + + // When + var response = await fixture.Get(fixture.ProductId.ToString()); + + // Then + response.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.OK); + + var product = await response.GetResultFromJson(); + product.Should().NotBeNull(); + product.Should().BeEquivalentTo(fixture.ExistingProduct); + } + + [Theory] + [InlineData(12)] + [InlineData("not-a-guid")] + public async Task InvalidGuidId_ShouldReturn_400(object invalidId) + { + // Given + + // When + var response = await fixture.Get($"{invalidId}"); + + // Then + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task NotExistingId_ShouldReturn_404() + { + // Given + var notExistingId = Guid.NewGuid(); + + // When + var response = await fixture.Get($"{notExistingId}"); + + // Then + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } +} diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs new file mode 100644 index 000000000..a6c154b97 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Core.Testing; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Warehouse.Products.GettingProducts; +using Warehouse.Products.RegisteringProduct; +using Xunit; + +namespace Warehouse.Api.Tests.Products.GettingProducts +{ + public class GetProductsFixture: ApiFixture + { + protected override string ApiUrl => "/api/products"; + + protected override Func SetupWebHostBuilder => + whb => WarehouseTestWebHostBuilder.Configure(whb, nameof(GetProductsFixture)); + + public IList RegisteredProducts = new List(); + + public override async Task InitializeAsync() + { + var productsToRegister = new[] + { + new RegisterProductRequest("ZX1234", "ValidName", "ValidDescription"), + new RegisterProductRequest("AD5678", "OtherValidName", "OtherValidDescription"), + new RegisterProductRequest("BH90210", "AnotherValid", "AnotherValidDescription") + }; + + foreach (var registerProduct in productsToRegister) + { + var registerResponse = await Post(registerProduct); + registerResponse.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.Created); + + var createdId = await registerResponse.GetResultFromJson(); + + var (sku, name, _) = registerProduct; + RegisteredProducts.Add(new ProductListItem(createdId, sku!, name!)); + } + } + } + + public class GetProductsTests: IClassFixture + { + private readonly GetProductsFixture fixture; + + public GetProductsTests(GetProductsFixture fixture) + { + this.fixture = fixture; + } + + [Fact] + public async Task ValidRequest_With_NoParams_ShouldReturn_200() + { + // Given + + // When + var response = await fixture.Get(); + + // Then + response.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.OK); + + var products = await response.GetResultFromJson>(); + products.Should().NotBeEmpty(); + products.Should().BeEquivalentTo(fixture.RegisteredProducts); + } + + [Fact] + public async Task ValidRequest_With_Filter_ShouldReturn_SubsetOfRecords() + { + // Given + var filteredRecord = fixture.RegisteredProducts.First(); + var filter = fixture.RegisteredProducts.First().Sku.Substring(1); + + // When + var response = await fixture.Get($"?filter={filter}"); + + // Then + response.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.OK); + + var products = await response.GetResultFromJson>(); + products.Should().NotBeEmpty(); + products.Should().BeEquivalentTo(new List{filteredRecord}); + } + + + + [Fact] + public async Task ValidRequest_With_Paging_ShouldReturn_PageOfRecords() + { + // Given + const int page = 2; + const int pageSize = 1; + var filteredRecords = fixture.RegisteredProducts + .Skip(page - 1) + .Take(pageSize) + .ToList(); + + // When + var response = await fixture.Get($"?page={page}&pageSize={pageSize}"); + + // Then + response.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.OK); + + var products = await response.GetResultFromJson>(); + products.Should().NotBeEmpty(); + products.Should().BeEquivalentTo(filteredRecords); + } + + [Fact] + public async Task NegativePage_ShouldReturn_400() + { + // Given + var pageSize = -20; + + // When + var response = await fixture.Get($"?page={pageSize}"); + + // Then + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Theory] + [InlineData(0)] + [InlineData(-20)] + public async Task NegativeOrZeroPageSize_ShouldReturn_400(int pageSize) + { + // Given + + // When + var response = await fixture.Get($"?page={pageSize}"); + + // Then + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + } +} diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs index f38a4ca0a..beb125560 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs @@ -9,14 +9,14 @@ namespace Warehouse.Api.Tests.Products.RegisteringProduct { - public class RegisteringProduct + public class RegisteringProductTests { public class RegisterProductFixture: ApiFixture { protected override string ApiUrl => "/api/products"; protected override Func SetupWebHostBuilder => - WarehouseTestWebHostBuilder.Configure; + whb => WarehouseTestWebHostBuilder.Configure(whb, nameof(RegisterProductFixture)); } public class RegisterProductTests: IClassFixture @@ -78,7 +78,7 @@ public async Task RequestForExistingSKUShouldFail_ShouldReturn_409() private static string ValidSKU => $"CC{DateTime.Now.Ticks}"; private const string ValidDescription = "VALID_DESCRIPTION"; - public static TheoryData ValidRequests = new () + public static TheoryData ValidRequests = new() { new RegisterProductRequest(ValidSKU, ValidName, ValidDescription), new RegisterProductRequest(ValidSKU, ValidName, null) diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj b/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj index 7d5a53c82..fde345ac8 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj +++ b/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj @@ -32,11 +32,6 @@ - - - - - true diff --git a/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs b/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs index 4a85136e4..4f3302874 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs +++ b/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs @@ -1,15 +1,18 @@ -using Core.WebApi.Middlewares.ExceptionHandling; +using Castle.Core.Configuration; +using Core.WebApi.Middlewares.ExceptionHandling; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Warehouse.Storage; +using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; namespace Warehouse.Api.Tests { public static class WarehouseTestWebHostBuilder { - public static IWebHostBuilder Configure(IWebHostBuilder webHostBuilder) + public static IWebHostBuilder Configure(IWebHostBuilder webHostBuilder, string schemaName) { webHostBuilder .ConfigureServices(services => @@ -17,7 +20,16 @@ public static IWebHostBuilder Configure(IWebHostBuilder webHostBuilder) services.AddRouting() .AddAuthorization() .AddCors() - .AddWarehouseServices(); + .AddWarehouseServices() + .AddTransient>(s => + { + var connectionString = s.GetRequiredService().GetConnectionString("WarehouseDB"); + var options = new DbContextOptionsBuilder(); + options.UseNpgsql( + $"{connectionString}; searchpath = {schemaName.ToLower()}", + x => x.MigrationsHistoryTable("__EFMigrationsHistory", schemaName.ToLower())); + return options.Options; + }); }) .Configure(app => { diff --git a/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs index 80490b7ce..52125983a 100644 --- a/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs +++ b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs @@ -1,36 +1,79 @@ using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; namespace Warehouse.Core.Extensions { public static class HttpExtensions { - public static T FromRoute(this HttpContext context, string name) + public static string FromRoute(this HttpContext context, string name) { - var value = context.Request.RouteValues[name]; + var routeValue = context.Request.RouteValues[name]; + + if (routeValue == null) + throw new ArgumentNullException(name); - if (value is not T typedValue) + if (routeValue is not string stringValue) throw new ArgumentOutOfRangeException(name); - return typedValue; + return stringValue; } - public static T FromQuery(this HttpContext context, string name) + public static T FromRoute(this HttpContext context, string name) + where T: struct { - var value = context.Request.Query[name]; + var routeValue = context.Request.RouteValues[name]; - if (value is not T typedValue) - throw new ArgumentOutOfRangeException(name); + return ConvertTo(routeValue, name) ?? throw new ArgumentNullException(name); + } - return typedValue; + public static string? FromQuery(this HttpContext context, string name) + { + var stringValues = context.Request.Query[name]; + + return !StringValues.IsNullOrEmpty(stringValues) + ? stringValues.ToString() + : null; + } + + + public static T? FromQuery(this HttpContext context, string name) + where T: struct + { + var stringValues = context.Request.Query[name]; + + return !StringValues.IsNullOrEmpty(stringValues) + ? ConvertTo(stringValues.ToString(), name) + : null; } public static async Task FromBody(this HttpContext context) { return await context.Request.ReadFromJsonAsync() ?? - throw new ArgumentNullException("request"); + throw new ArgumentNullException("request"); + } + + public static T? ConvertTo(object? value, string name) + where T: struct + { + if (value == null) + return null; + + T? result; + try + { + result = (T?) TypeDescriptor.GetConverter(typeof(T)).ConvertFrom(value); + } + catch + { + throw new ArgumentOutOfRangeException(name); + } + + return result; } public static Task OK(this HttpContext context, T result) @@ -39,7 +82,11 @@ public static Task OK(this HttpContext context, T result) public static Task Created(this HttpContext context, T result) => context.ReturnJSON(result, HttpStatusCode.Created); - public static async Task ReturnJSON(this HttpContext context, T result, HttpStatusCode statusCode = HttpStatusCode.OK) + public static void NotFound(this HttpContext context) + => context.Response.StatusCode = (int)HttpStatusCode.NotFound; + + public static async Task ReturnJSON(this HttpContext context, T result, + HttpStatusCode statusCode = HttpStatusCode.OK) { context.Response.StatusCode = (int)statusCode; diff --git a/Sample/Warehouse/Warehouse/Products/Configuration.cs b/Sample/Warehouse/Warehouse/Products/Configuration.cs index 9bfe8371e..abbe5a1c7 100644 --- a/Sample/Warehouse/Warehouse/Products/Configuration.cs +++ b/Sample/Warehouse/Warehouse/Products/Configuration.cs @@ -21,12 +21,12 @@ public static IServiceCollection AddProductServices(this IServiceCollection serv var dbContext = s.GetRequiredService(); return new HandleRegisterProduct(dbContext.AddAndSave, dbContext.ProductWithSKUExists); }) - .AddQueryHandler, HandleGetProducts>(s => + .AddQueryHandler, HandleGetProducts>(s => { var dbContext = s.GetRequiredService(); return new HandleGetProducts(dbContext.Set().AsNoTracking()); }) - .AddQueryHandler(s => + .AddQueryHandler(s => { var dbContext = s.GetRequiredService(); return new HandleGetProductDetails(dbContext.Set().AsNoTracking()); diff --git a/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs index 6e98cb33e..3862fbe53 100644 --- a/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs +++ b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs @@ -8,7 +8,7 @@ namespace Warehouse.Products.GettingProductDetails { - internal class HandleGetProductDetails: IQueryHandler + internal class HandleGetProductDetails: IQueryHandler { private readonly IQueryable products; @@ -17,11 +17,21 @@ public HandleGetProductDetails(IQueryable products) this.products = products; } - public async ValueTask Handle(GetProductDetails query, CancellationToken ct) + public async ValueTask Handle(GetProductDetails query, CancellationToken ct) { // await is needed because of https://github.com/dotnet/efcore/issues/21793#issuecomment-667096367 - return await products + var product = await products .SingleOrDefaultAsync(p => p.Id == query.ProductId, ct); + + if (product == null) + return null; + + return new ProductDetails( + product.Id, + product.Sku.Value, + product.Name, + product.Description + ); } } @@ -37,4 +47,11 @@ private GetProductDetails(Guid productId) public static GetProductDetails Create(Guid productId) => new(productId.AssertNotEmpty(nameof(productId))); } + + public record ProductDetails( + Guid Id, + string Sku, + string Name, + string? Description + ); } diff --git a/Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs index 7f9b6b686..5dfd31646 100644 --- a/Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs +++ b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs @@ -19,7 +19,13 @@ internal static IEndpointRouteBuilder UseGetProductDetailsEndpoint(this IEndpoin var query = GetProductDetails.Create(productId); var result = await context - .SendQuery(query); + .SendQuery(query); + + if (result == null) + { + context.NotFound(); + return; + } await context.OK(result); }); diff --git a/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs b/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs index acf571395..1802b27e1 100644 --- a/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs +++ b/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs @@ -8,9 +8,8 @@ namespace Warehouse.Products.GettingProducts { - internal class HandleGetProducts : IQueryHandler> + internal class HandleGetProducts : IQueryHandler> { - private const int PageSize = 10; private readonly IQueryable products; public HandleGetProducts(IQueryable products) @@ -18,9 +17,9 @@ public HandleGetProducts(IQueryable products) this.products = products; } - public async ValueTask> Handle(GetProducts query, CancellationToken ct) + public async ValueTask> Handle(GetProducts query, CancellationToken ct) { - var (filter, page) = query; + var (filter, page, pageSize) = query; var filteredProducts = string.IsNullOrEmpty(filter) ? products @@ -33,38 +32,56 @@ public async ValueTask> Handle(GetProducts query, Cancell // await is needed because of https://github.com/dotnet/efcore/issues/21793#issuecomment-667096367 return await filteredProducts - .Skip(PageSize * page - 1) - .Take(PageSize) + .Skip(pageSize * (page - 1)) + .Take(pageSize) + .Select(p => new ProductListItem(p.Id, p.Sku.Value, p.Name)) .ToListAsync(ct); } } public record GetProducts { + private const int DefaultPage = 1; + private const int DefaultPageSize = 10; + public string? Filter { get; } public int Page { get; } - private GetProducts(string? filter, int page) + public int PageSize { get; } + + private GetProducts(string? filter, int page, int pageSize) { Filter = filter; Page = page; + PageSize = pageSize; } - public static GetProducts Create(string? filter, int? page) + public static GetProducts Create(string? filter, int? page, int? pageSize) { - page ??= 1; + page ??= DefaultPage; + pageSize ??= DefaultPageSize; if (page <= 0) throw new ArgumentOutOfRangeException(nameof(page)); - return new (filter, page.Value); + if (pageSize <= 0) + throw new ArgumentOutOfRangeException(nameof(pageSize)); + + return new (filter, page.Value, pageSize.Value); } - public void Deconstruct(out string? filter, out int page) + public void Deconstruct(out string? filter, out int page, out int pageSize) { filter = Filter; page = Page; + pageSize = PageSize; } - }; + } + + public record ProductListItem( + Guid Id, + string Sku, + string Name + ); } diff --git a/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs b/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs index 2ca0a111c..d88eeb2e3 100644 --- a/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs +++ b/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs @@ -16,12 +16,14 @@ internal static IEndpointRouteBuilder UseGetProductsEndpoint(this IEndpointRoute // var dbContext = WarehouseDBContextFactory.Create(); // var handler = new HandleGetProducts(dbContext.Set().AsQueryable()); - var filter = context.FromQuery("filter"); - var page = context.FromQuery("page"); - var query = GetProducts.Create(filter, page); + var filter = context.FromQuery("filter"); + var page = context.FromQuery("page"); + var pageSize = context.FromQuery("pageSize"); - var result = context - .SendQuery>(query); + var query = GetProducts.Create(filter, page, pageSize); + + var result = await context + .SendQuery>(query); await context.OK(result); }); diff --git a/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs b/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs index 9bb422eab..9d1e1e4e0 100644 --- a/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs +++ b/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs @@ -1,4 +1,5 @@ using System; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace Warehouse.Products.Primitives @@ -7,6 +8,7 @@ public record SKU { public string Value { get; init; } + [JsonConstructor] private SKU(string value) { Value = value; diff --git a/Sample/Warehouse/Warehouse/Products/Product.cs b/Sample/Warehouse/Warehouse/Products/Product.cs index cfef2a39e..8aadee1a3 100644 --- a/Sample/Warehouse/Warehouse/Products/Product.cs +++ b/Sample/Warehouse/Warehouse/Products/Product.cs @@ -1,4 +1,5 @@ using System; +using System.Text.Json.Serialization; using Warehouse.Products.Primitives; namespace Warehouse.Products