From 6f4edce4e3098596cf8727ab6856a2d8adeb76e1 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Tue, 8 Aug 2023 17:40:48 -0400 Subject: [PATCH 01/25] Scaffold C18 solution --- C18/REPR.sln | 28 +++++++++++++++++++ C18/Web.Test/GlobalUsings.cs | 1 + C18/Web.Test/UnitTest1.cs | 10 +++++++ C18/Web.Test/Web.Test.csproj | 25 +++++++++++++++++ C18/Web/Program.cs | 6 ++++ C18/Web/Properties/launchSettings.json | 38 ++++++++++++++++++++++++++ C18/Web/Web.csproj | 9 ++++++ C18/Web/appsettings.Development.json | 8 ++++++ C18/Web/appsettings.json | 9 ++++++ 9 files changed, 134 insertions(+) create mode 100644 C18/REPR.sln create mode 100644 C18/Web.Test/GlobalUsings.cs create mode 100644 C18/Web.Test/UnitTest1.cs create mode 100644 C18/Web.Test/Web.Test.csproj create mode 100644 C18/Web/Program.cs create mode 100644 C18/Web/Properties/launchSettings.json create mode 100644 C18/Web/Web.csproj create mode 100644 C18/Web/appsettings.Development.json create mode 100644 C18/Web/appsettings.json diff --git a/C18/REPR.sln b/C18/REPR.sln new file mode 100644 index 0000000..8fcf673 --- /dev/null +++ b/C18/REPR.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web.Test", "Web.Test\Web.Test.csproj", "{50D34992-16B0-4421-948F-9C7D6A0EEEB3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web", "Web\Web.csproj", "{0653C456-D190-43D1-98BC-74000960AE85}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {50D34992-16B0-4421-948F-9C7D6A0EEEB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50D34992-16B0-4421-948F-9C7D6A0EEEB3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50D34992-16B0-4421-948F-9C7D6A0EEEB3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50D34992-16B0-4421-948F-9C7D6A0EEEB3}.Release|Any CPU.Build.0 = Release|Any CPU + {0653C456-D190-43D1-98BC-74000960AE85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0653C456-D190-43D1-98BC-74000960AE85}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0653C456-D190-43D1-98BC-74000960AE85}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0653C456-D190-43D1-98BC-74000960AE85}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/C18/Web.Test/GlobalUsings.cs b/C18/Web.Test/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/C18/Web.Test/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/C18/Web.Test/UnitTest1.cs b/C18/Web.Test/UnitTest1.cs new file mode 100644 index 0000000..a8142c3 --- /dev/null +++ b/C18/Web.Test/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace Web.Test; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} \ No newline at end of file diff --git a/C18/Web.Test/Web.Test.csproj b/C18/Web.Test/Web.Test.csproj new file mode 100644 index 0000000..9e0c306 --- /dev/null +++ b/C18/Web.Test/Web.Test.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/C18/Web/Program.cs b/C18/Web/Program.cs new file mode 100644 index 0000000..1760df1 --- /dev/null +++ b/C18/Web/Program.cs @@ -0,0 +1,6 @@ +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +app.MapGet("/", () => "Hello World!"); + +app.Run(); diff --git a/C18/Web/Properties/launchSettings.json b/C18/Web/Properties/launchSettings.json new file mode 100644 index 0000000..8a054bd --- /dev/null +++ b/C18/Web/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:62741", + "sslPort": 44316 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5202", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7252;http://localhost:5202", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/C18/Web/Web.csproj b/C18/Web/Web.csproj new file mode 100644 index 0000000..1b28a01 --- /dev/null +++ b/C18/Web/Web.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/C18/Web/appsettings.Development.json b/C18/Web/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/C18/Web/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/C18/Web/appsettings.json b/C18/Web/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/C18/Web/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From ed7dcf8db0aed4caccc77f4a3990fa20ade867bf Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Sat, 12 Aug 2023 14:02:34 -0400 Subject: [PATCH 02/25] Move the REPR project to a subfolder --- C18/{ => REPR}/REPR.sln | 0 C18/{ => REPR}/Web.Test/GlobalUsings.cs | 0 C18/{ => REPR}/Web.Test/UnitTest1.cs | 0 C18/{ => REPR}/Web.Test/Web.Test.csproj | 0 C18/{ => REPR}/Web/Program.cs | 4 ++++ .../Web/Properties/launchSettings.json | 0 C18/REPR/Web/Web.csproj | 18 ++++++++++++++++++ .../Web/appsettings.Development.json | 0 C18/{ => REPR}/Web/appsettings.json | 0 C18/Web/Web.csproj | 9 --------- 10 files changed, 22 insertions(+), 9 deletions(-) rename C18/{ => REPR}/REPR.sln (100%) rename C18/{ => REPR}/Web.Test/GlobalUsings.cs (100%) rename C18/{ => REPR}/Web.Test/UnitTest1.cs (100%) rename C18/{ => REPR}/Web.Test/Web.Test.csproj (100%) rename C18/{ => REPR}/Web/Program.cs (68%) rename C18/{ => REPR}/Web/Properties/launchSettings.json (100%) create mode 100644 C18/REPR/Web/Web.csproj rename C18/{ => REPR}/Web/appsettings.Development.json (100%) rename C18/{ => REPR}/Web/appsettings.json (100%) delete mode 100644 C18/Web/Web.csproj diff --git a/C18/REPR.sln b/C18/REPR/REPR.sln similarity index 100% rename from C18/REPR.sln rename to C18/REPR/REPR.sln diff --git a/C18/Web.Test/GlobalUsings.cs b/C18/REPR/Web.Test/GlobalUsings.cs similarity index 100% rename from C18/Web.Test/GlobalUsings.cs rename to C18/REPR/Web.Test/GlobalUsings.cs diff --git a/C18/Web.Test/UnitTest1.cs b/C18/REPR/Web.Test/UnitTest1.cs similarity index 100% rename from C18/Web.Test/UnitTest1.cs rename to C18/REPR/Web.Test/UnitTest1.cs diff --git a/C18/Web.Test/Web.Test.csproj b/C18/REPR/Web.Test/Web.Test.csproj similarity index 100% rename from C18/Web.Test/Web.Test.csproj rename to C18/REPR/Web.Test/Web.Test.csproj diff --git a/C18/Web/Program.cs b/C18/REPR/Web/Program.cs similarity index 68% rename from C18/Web/Program.cs rename to C18/REPR/Web/Program.cs index 1760df1..3a37c81 100644 --- a/C18/Web/Program.cs +++ b/C18/REPR/Web/Program.cs @@ -1,5 +1,9 @@ var builder = WebApplication.CreateBuilder(args); +builder.AddExceptionMapper(); + + var app = builder.Build(); +app.UseExceptionMapper(); app.MapGet("/", () => "Hello World!"); diff --git a/C18/Web/Properties/launchSettings.json b/C18/REPR/Web/Properties/launchSettings.json similarity index 100% rename from C18/Web/Properties/launchSettings.json rename to C18/REPR/Web/Properties/launchSettings.json diff --git a/C18/REPR/Web/Web.csproj b/C18/REPR/Web/Web.csproj new file mode 100644 index 0000000..69befc8 --- /dev/null +++ b/C18/REPR/Web/Web.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/C18/Web/appsettings.Development.json b/C18/REPR/Web/appsettings.Development.json similarity index 100% rename from C18/Web/appsettings.Development.json rename to C18/REPR/Web/appsettings.Development.json diff --git a/C18/Web/appsettings.json b/C18/REPR/Web/appsettings.json similarity index 100% rename from C18/Web/appsettings.json rename to C18/REPR/Web/appsettings.json diff --git a/C18/Web/Web.csproj b/C18/Web/Web.csproj deleted file mode 100644 index 1b28a01..0000000 --- a/C18/Web/Web.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net8.0 - enable - enable - - - From d01675ff500ee8e304f75de0274e46d52c6a8622 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Sat, 12 Aug 2023 14:02:56 -0400 Subject: [PATCH 03/25] Add the SimpleEndpoint project --- C18/SimpleEndpoint/Program.cs | 17 +++++++++++ .../Properties/launchSettings.json | 14 +++++++++ C18/SimpleEndpoint/RandomNumber.cs | 22 ++++++++++++++ C18/SimpleEndpoint/ShuffleText.cs | 16 ++++++++++ C18/SimpleEndpoint/SimpleEndpoint.csproj | 9 ++++++ C18/SimpleEndpoint/SimpleEndpoint.http | 13 +++++++++ C18/SimpleEndpoint/SimpleEndpoint.sln | 22 ++++++++++++++ C18/SimpleEndpoint/UpperCase.cs | 29 +++++++++++++++++++ .../appsettings.Development.json | 8 +++++ C18/SimpleEndpoint/appsettings.json | 9 ++++++ 10 files changed, 159 insertions(+) create mode 100644 C18/SimpleEndpoint/Program.cs create mode 100644 C18/SimpleEndpoint/Properties/launchSettings.json create mode 100644 C18/SimpleEndpoint/RandomNumber.cs create mode 100644 C18/SimpleEndpoint/ShuffleText.cs create mode 100644 C18/SimpleEndpoint/SimpleEndpoint.csproj create mode 100644 C18/SimpleEndpoint/SimpleEndpoint.http create mode 100644 C18/SimpleEndpoint/SimpleEndpoint.sln create mode 100644 C18/SimpleEndpoint/UpperCase.cs create mode 100644 C18/SimpleEndpoint/appsettings.Development.json create mode 100644 C18/SimpleEndpoint/appsettings.json diff --git a/C18/SimpleEndpoint/Program.cs b/C18/SimpleEndpoint/Program.cs new file mode 100644 index 0000000..0fb388e --- /dev/null +++ b/C18/SimpleEndpoint/Program.cs @@ -0,0 +1,17 @@ +using SimpleEndpoint; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddUpperCase(); + +var app = builder.Build(); + +app.MapGet("/shuffle-text/{text}", ([AsParameters] ShuffleText.Request query, ShuffleText.Endpoint endpoint) + => endpoint.Handle(query)); + +app.MapGet("/random-number/{Amount}/{Min}/{Max}", RandomNumber.Endpoint); + +app.MapUpperCase(); + +app.Run(); diff --git a/C18/SimpleEndpoint/Properties/launchSettings.json b/C18/SimpleEndpoint/Properties/launchSettings.json new file mode 100644 index 0000000..42d260b --- /dev/null +++ b/C18/SimpleEndpoint/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7289;http://localhost:5091", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/C18/SimpleEndpoint/RandomNumber.cs b/C18/SimpleEndpoint/RandomNumber.cs new file mode 100644 index 0000000..f26b17b --- /dev/null +++ b/C18/SimpleEndpoint/RandomNumber.cs @@ -0,0 +1,22 @@ +namespace SimpleEndpoint; + +public class RandomNumber +{ + public record class Request(int Amount, int Min, int Max); + public record class Response(IEnumerable Numbers); + public class Handler + { + public Response Handle(Request request) + { + var result = new int[request.Amount]; + for (var i = 0; i < request.Amount; i++) + { + result[i] = Random.Shared.Next(request.Min, request.Max); + } + return new Response(result); + } + } + + public static Response Endpoint([AsParameters] Request query, Handler handler) + => handler.Handle(query); +} diff --git a/C18/SimpleEndpoint/ShuffleText.cs b/C18/SimpleEndpoint/ShuffleText.cs new file mode 100644 index 0000000..e9de3ea --- /dev/null +++ b/C18/SimpleEndpoint/ShuffleText.cs @@ -0,0 +1,16 @@ +namespace SimpleEndpoint; + +public class ShuffleText +{ + public record class Request(string Text); + public record class Response(string Text); + public class Endpoint + { + public Response Handle(Request request) + { + var chars = request.Text.ToArray(); + Random.Shared.Shuffle(chars); + return new Response(new string(chars)); + } + } +} diff --git a/C18/SimpleEndpoint/SimpleEndpoint.csproj b/C18/SimpleEndpoint/SimpleEndpoint.csproj new file mode 100644 index 0000000..1b28a01 --- /dev/null +++ b/C18/SimpleEndpoint/SimpleEndpoint.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/C18/SimpleEndpoint/SimpleEndpoint.http b/C18/SimpleEndpoint/SimpleEndpoint.http new file mode 100644 index 0000000..7331861 --- /dev/null +++ b/C18/SimpleEndpoint/SimpleEndpoint.http @@ -0,0 +1,13 @@ +@SimpleEndpoint_HostAddress = https://localhost:7289 + +GET {{SimpleEndpoint_HostAddress}}/shuffle-text/I%20love%20ASP.NET%20Core + +### + +GET {{SimpleEndpoint_HostAddress}}/random-number/5/0/100 + +### + +GET {{SimpleEndpoint_HostAddress}}/upper-case/I%20love%20ASP.NET%20Core + +### diff --git a/C18/SimpleEndpoint/SimpleEndpoint.sln b/C18/SimpleEndpoint/SimpleEndpoint.sln new file mode 100644 index 0000000..180f424 --- /dev/null +++ b/C18/SimpleEndpoint/SimpleEndpoint.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleEndpoint", "SimpleEndpoint.csproj", "{9358C6E7-9990-482E-968A-D93A7FEF6715}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9358C6E7-9990-482E-968A-D93A7FEF6715}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9358C6E7-9990-482E-968A-D93A7FEF6715}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9358C6E7-9990-482E-968A-D93A7FEF6715}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9358C6E7-9990-482E-968A-D93A7FEF6715}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/C18/SimpleEndpoint/UpperCase.cs b/C18/SimpleEndpoint/UpperCase.cs new file mode 100644 index 0000000..fbdf0fe --- /dev/null +++ b/C18/SimpleEndpoint/UpperCase.cs @@ -0,0 +1,29 @@ +namespace SimpleEndpoint; + +public static class UpperCase +{ + public record class Request(string Text); + public record class Response(string Text); + public class Handler + { + public Response Handle(Request request) + { + return new Response(request.Text.ToUpper()); + } + } + + public static IServiceCollection AddUpperCase(this IServiceCollection services) + { + return services.AddSingleton(); + } + + public static IEndpointRouteBuilder MapUpperCase(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet( + "/upper-case/{Text}", + ([AsParameters] Request query, Handler handler) + => handler.Handle(query) + ); + return endpoints; + } +} diff --git a/C18/SimpleEndpoint/appsettings.Development.json b/C18/SimpleEndpoint/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/C18/SimpleEndpoint/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/C18/SimpleEndpoint/appsettings.json b/C18/SimpleEndpoint/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/C18/SimpleEndpoint/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From 8d06afa00c8c5bc8a85584ff9ea9ef8dd3b06a89 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Sat, 12 Aug 2023 14:03:13 -0400 Subject: [PATCH 04/25] Create the REPR diagram --- Diagrams/REPR.drawio | 73 ++++++++++++++++++++++++++++++++++++++++++++ Diagrams/REPR.svg | 1 + 2 files changed, 74 insertions(+) create mode 100644 Diagrams/REPR.drawio create mode 100644 Diagrams/REPR.svg diff --git a/Diagrams/REPR.drawio b/Diagrams/REPR.drawio new file mode 100644 index 0000000..d7bebaf --- /dev/null +++ b/Diagrams/REPR.drawio @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Diagrams/REPR.svg b/Diagrams/REPR.svg new file mode 100644 index 0000000..2d7fea6 --- /dev/null +++ b/Diagrams/REPR.svg @@ -0,0 +1 @@ +
Request 
Request 
Input
Input
 Endpoint
 Endpoint
Logic
Logic
 Response
 Response
Output
Output
600 px MAX width
600 px MAX width
Text is not SVG - cannot display
\ No newline at end of file From 414c9abdebd30a9c7e23cf365c9ccfc6e33e7462 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Sat, 12 Aug 2023 15:23:43 -0400 Subject: [PATCH 05/25] Scaffold features and implement the Products feature --- .../Web/Features/Baskets/Baskets.AddItem.cs | 10 +++ .../Features/Baskets/Baskets.CreateOrFetch.cs | 10 +++ .../Features/Baskets/Baskets.RemoveItem.cs | 10 +++ .../Baskets/Baskets.UpdateQuantity.cs | 10 +++ C18/REPR/Web/Features/Baskets/Baskets.cs | 51 ++++++++++++ C18/REPR/Web/Features/Features.cs | 33 ++++++++ .../Features/Products/Products.FetchAll.cs | 57 ++++++++++++++ .../Features/Products/Products.FetchOne.cs | 62 +++++++++++++++ C18/REPR/Web/Features/Products/Products.cs | 78 +++++++++++++++++++ C18/REPR/Web/Program.cs | 8 +- C18/REPR/Web/Properties/launchSettings.json | 26 +------ C18/REPR/Web/Web.http | 10 +++ 12 files changed, 338 insertions(+), 27 deletions(-) create mode 100644 C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs create mode 100644 C18/REPR/Web/Features/Baskets/Baskets.CreateOrFetch.cs create mode 100644 C18/REPR/Web/Features/Baskets/Baskets.RemoveItem.cs create mode 100644 C18/REPR/Web/Features/Baskets/Baskets.UpdateQuantity.cs create mode 100644 C18/REPR/Web/Features/Baskets/Baskets.cs create mode 100644 C18/REPR/Web/Features/Features.cs create mode 100644 C18/REPR/Web/Features/Products/Products.FetchAll.cs create mode 100644 C18/REPR/Web/Features/Products/Products.FetchOne.cs create mode 100644 C18/REPR/Web/Features/Products/Products.cs create mode 100644 C18/REPR/Web/Web.http diff --git a/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs b/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs new file mode 100644 index 0000000..3288287 --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs @@ -0,0 +1,10 @@ +namespace Web.Features; + +public partial class Baskets +{ + public class AddItem + { + + } + +} diff --git a/C18/REPR/Web/Features/Baskets/Baskets.CreateOrFetch.cs b/C18/REPR/Web/Features/Baskets/Baskets.CreateOrFetch.cs new file mode 100644 index 0000000..8d8c4e7 --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/Baskets.CreateOrFetch.cs @@ -0,0 +1,10 @@ +namespace Web.Features; + +public partial class Baskets +{ + public class CreateOrFetch + { + + } + +} diff --git a/C18/REPR/Web/Features/Baskets/Baskets.RemoveItem.cs b/C18/REPR/Web/Features/Baskets/Baskets.RemoveItem.cs new file mode 100644 index 0000000..02ccccf --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/Baskets.RemoveItem.cs @@ -0,0 +1,10 @@ +namespace Web.Features; + +public partial class Baskets +{ + public class RemoveItem + { + + } + +} diff --git a/C18/REPR/Web/Features/Baskets/Baskets.UpdateQuantity.cs b/C18/REPR/Web/Features/Baskets/Baskets.UpdateQuantity.cs new file mode 100644 index 0000000..58312de --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/Baskets.UpdateQuantity.cs @@ -0,0 +1,10 @@ +namespace Web.Features; + +public partial class Baskets +{ + public class UpdateQuantity + { + + } + +} diff --git a/C18/REPR/Web/Features/Baskets/Baskets.cs b/C18/REPR/Web/Features/Baskets/Baskets.cs new file mode 100644 index 0000000..fb148dc --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/Baskets.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Web.Features; + +public static partial class Baskets +{ + public record class ShoppingCart(int CustomerId) + { + public List Items { get; } = new(); + } + public record class Item(int ProductId, int Quantity); + + public class BasketContext : DbContext + { + public BasketContext(DbContextOptions options) + : base(options) { } + + public DbSet ShoppingCarts => Set(); + } + + public static IServiceCollection AddBasketsFeature(this IServiceCollection services) + { + return services + // + // TODO: Register endpoints services + // + .AddDbContext(options => options + .UseInMemoryDatabase("BasketContextMemoryDB") + .ConfigureWarnings(builder => builder.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + ) + ; + } + + public static IEndpointRouteBuilder MapBasketsFeature(this IEndpointRouteBuilder endpoints) + { + var group = endpoints + .MapGroup(nameof(Baskets).ToLower()) + .WithTags(nameof(Baskets)) + ; + //group + // // TODO: Register endpoints maps + //; + return endpoints; + } + + public static Task SeedBasketAsync(this IServiceScope scope) + { + return Task.CompletedTask; + } +} diff --git a/C18/REPR/Web/Features/Features.cs b/C18/REPR/Web/Features/Features.cs new file mode 100644 index 0000000..375bbb3 --- /dev/null +++ b/C18/REPR/Web/Features/Features.cs @@ -0,0 +1,33 @@ +namespace Web.Features; + +public static class Features +{ + public static IServiceCollection AddFeatures(this IServiceCollection services) + { + return services + .AddProductsFeature() + .AddBasketsFeature() + ; + } + + public static IEndpointRouteBuilder MapFeatures(this IEndpointRouteBuilder endpoints) + { + var group = endpoints + .MapGroup("/") + .AddFluentValidationFilter(); + ; + group + .MapProductsFeature() + .MapBasketsFeature() + ; + return endpoints; + } + + public static async Task SeedFeaturesAsync(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + + await scope.SeedProductsAsync(); + await scope.SeedBasketAsync(); + } +} diff --git a/C18/REPR/Web/Features/Products/Products.FetchAll.cs b/C18/REPR/Web/Features/Products/Products.FetchAll.cs new file mode 100644 index 0000000..6c49ae7 --- /dev/null +++ b/C18/REPR/Web/Features/Products/Products.FetchAll.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Riok.Mapperly.Abstractions; + +namespace Web.Features; + +public partial class Products +{ + public partial class FetchAll + { + public record class Query(); + public record class Response(IEnumerable Products); + public record class ResponseProduct(int Id, string Name); + + [Mapper] + public partial class Mapper + { + public partial IEnumerable Project(IQueryable products); + } + + public class Handler + { + private readonly ProductContext _db; + private readonly Mapper _mapper; + + public Handler(ProductContext db, Mapper mapper) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + + public async Task HandleAsync(Query query, CancellationToken cancellationToken) + { + await _db.Products.LoadAsync(cancellationToken); + var products = _mapper.Project(_db.Products.OrderBy(x => x.Name)); + return new Response(products); + } + } + } + + public static IServiceCollection AddFetchAll(this IServiceCollection services) + { + return services + .AddScoped() + .AddSingleton() + ; + } + + public static IEndpointRouteBuilder MapFetchAll(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet( + "/", + (FetchAll.Handler handler, CancellationToken cancellationToken) + => handler.HandleAsync(new FetchAll.Query(), cancellationToken) + ); + return endpoints; + } +} diff --git a/C18/REPR/Web/Features/Products/Products.FetchOne.cs b/C18/REPR/Web/Features/Products/Products.FetchOne.cs new file mode 100644 index 0000000..a2811e9 --- /dev/null +++ b/C18/REPR/Web/Features/Products/Products.FetchOne.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Riok.Mapperly.Abstractions; + +namespace Web.Features; + +public partial class Products +{ + public partial class FetchOne + { + public record class Query(int ProductId); + public record class Response(int Id, string Name); + + [Mapper] + public partial class Mapper + { + public partial Response Map(Product product); + } + + public class Handler + { + private readonly ProductContext _db; + private readonly Mapper _mapper; + + public Handler(ProductContext db, Mapper mapper) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + + public async Task HandleAsync(Query query, CancellationToken cancellationToken) + { + var product = await _db.Products.FirstOrDefaultAsync( + x => x.Id == query.ProductId, + cancellationToken: cancellationToken + ); + if (product is null) + { + throw new ProductNotFoundException(query.ProductId); + } + return _mapper.Map(product); + } + } + } + + public static IServiceCollection AddFetchOne(this IServiceCollection services) + { + return services + .AddScoped() + .AddSingleton() + ; + } + + public static IEndpointRouteBuilder MapFetchOne(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet( + "/{ProductId}", + ([AsParameters] FetchOne.Query query, FetchOne.Handler handler, CancellationToken cancellationToken) + => handler.HandleAsync(query, cancellationToken) + ); + return endpoints; + } +} diff --git a/C18/REPR/Web/Features/Products/Products.cs b/C18/REPR/Web/Features/Products/Products.cs new file mode 100644 index 0000000..3593d0c --- /dev/null +++ b/C18/REPR/Web/Features/Products/Products.cs @@ -0,0 +1,78 @@ +using ForEvolve.ExceptionMapper; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Web.Features; + +public static partial class Products +{ + public record class Product(string Name, int? Id = null); + + public class ProductContext : DbContext + { + public ProductContext(DbContextOptions options) + : base(options) { } + + public DbSet Products => Set(); + } + + public class ProductNotFoundException : NotFoundException + { + public ProductNotFoundException(int productId) + : base($"The product '{productId}' was not found.") + { + + } + } + + internal static class ProductSeeder + { + public static Task SeedAsync(ProductContext db) + { + db.Products.Add(new Product( + Name: "Banana", + Id: 1 + )); + db.Products.Add(new Product( + Name: "Apple", + Id: 2 + )); + db.Products.Add(new Product( + Name: "Habanero Pepper", + Id: 3 + )); + return db.SaveChangesAsync(); + } + } + + public static IServiceCollection AddProductsFeature(this IServiceCollection services) + { + return services + .AddFetchAll() + .AddFetchOne() + .AddDbContext(options => options + .UseInMemoryDatabase("ProductContextMemoryDB") + .ConfigureWarnings(builder => builder.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + ) + ; + } + + public static IEndpointRouteBuilder MapProductsFeature(this IEndpointRouteBuilder endpoints) + { + var group = endpoints + .MapGroup(nameof(Products).ToLower()) + .WithTags(nameof(Products)) + ; + group + .MapFetchAll() + .MapFetchOne() + ; + return endpoints; + } + + public static async Task SeedProductsAsync(this IServiceScope scope) + { + var db = scope.ServiceProvider.GetRequiredService(); + await ProductSeeder.SeedAsync(db); + } +} diff --git a/C18/REPR/Web/Program.cs b/C18/REPR/Web/Program.cs index 3a37c81..856d96b 100644 --- a/C18/REPR/Web/Program.cs +++ b/C18/REPR/Web/Program.cs @@ -1,10 +1,14 @@ +using Web.Features; + var builder = WebApplication.CreateBuilder(args); builder.AddExceptionMapper(); - +builder.AddFluentValidationEndpointFilter(); +builder.Services.AddFeatures(); var app = builder.Build(); app.UseExceptionMapper(); +app.MapFeatures(); -app.MapGet("/", () => "Hello World!"); +await app.SeedFeaturesAsync(); app.Run(); diff --git a/C18/REPR/Web/Properties/launchSettings.json b/C18/REPR/Web/Properties/launchSettings.json index 8a054bd..5f2c386 100644 --- a/C18/REPR/Web/Properties/launchSettings.json +++ b/C18/REPR/Web/Properties/launchSettings.json @@ -1,38 +1,14 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:62741", - "sslPort": 44316 - } - }, "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5202", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:7252;http://localhost:5202", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } diff --git a/C18/REPR/Web/Web.http b/C18/REPR/Web/Web.http new file mode 100644 index 0000000..c5b9c51 --- /dev/null +++ b/C18/REPR/Web/Web.http @@ -0,0 +1,10 @@ +@Web_HostAddress = https://localhost:7252 +@ProductId = 3 + +GET {{Web_HostAddress}}/products + +### + +GET {{Web_HostAddress}}/products/{{ProductId}} + +### From 93874285e715bc5b37a95004aa282188a937189f Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Sat, 12 Aug 2023 21:20:29 -0400 Subject: [PATCH 06/25] Create the basket feature --- .../Web/Features/Baskets/Baskets.AddItem.cs | 72 ++++++++++++++++- .../Features/Baskets/Baskets.CreateOrFetch.cs | 10 --- .../Features/Baskets/Baskets.FetchItems.cs | 81 +++++++++++++++++++ .../Features/Baskets/Baskets.RemoveItem.cs | 68 +++++++++++++++- .../Baskets/Baskets.UpdateQuantity.cs | 74 ++++++++++++++++- C18/REPR/Web/Features/Baskets/Baskets.cs | 52 +++++++++--- C18/REPR/Web/Program.cs | 20 ++++- C18/REPR/Web/Web.http | 32 ++++++++ 8 files changed, 379 insertions(+), 30 deletions(-) delete mode 100644 C18/REPR/Web/Features/Baskets/Baskets.CreateOrFetch.cs create mode 100644 C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs diff --git a/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs b/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs index 3288287..f86da58 100644 --- a/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs +++ b/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs @@ -1,10 +1,78 @@ -namespace Web.Features; +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Riok.Mapperly.Abstractions; + +namespace Web.Features; public partial class Baskets { - public class AddItem + public partial class AddItem { + public record class Command(int CustomerId, int ProductId, int Quantity); + public record class Response(int ProductId, int Quantity); + + [Mapper] + public partial class Mapper + { + public partial BasketItem Map(Command item); + public partial Response Map(BasketItem item); + } + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.CustomerId).GreaterThan(0); + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.Quantity).GreaterThan(0); + } + } + + public class Handler + { + private readonly BasketContext _db; + private readonly Mapper _mapper; + + public Handler(BasketContext db, Mapper mapper) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + public async Task HandleAsync(Command command, CancellationToken cancellationToken) + { + var itemExists = await _db.Items.AnyAsync( + x => x.CustomerId == command.CustomerId && x.ProductId == command.ProductId, + cancellationToken: cancellationToken + ); + if (itemExists) + { + throw new DuplicateBasketItemException(command.ProductId); + } + var item = _mapper.Map(command); + _db.Add(item); + await _db.SaveChangesAsync(cancellationToken); + var result = _mapper.Map(item); + return result; + } + } } + public static IServiceCollection AddAddItem(this IServiceCollection services) + { + return services + .AddScoped() + .AddSingleton() + ; + } + + public static IEndpointRouteBuilder MapAddItem(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPost( + "/", + (AddItem.Command command, AddItem.Handler handler, CancellationToken cancellationToken) + => handler.HandleAsync(command, cancellationToken) + ); + return endpoints; + } } diff --git a/C18/REPR/Web/Features/Baskets/Baskets.CreateOrFetch.cs b/C18/REPR/Web/Features/Baskets/Baskets.CreateOrFetch.cs deleted file mode 100644 index 8d8c4e7..0000000 --- a/C18/REPR/Web/Features/Baskets/Baskets.CreateOrFetch.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Web.Features; - -public partial class Baskets -{ - public class CreateOrFetch - { - - } - -} diff --git a/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs b/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs new file mode 100644 index 0000000..e4478b2 --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs @@ -0,0 +1,81 @@ +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Riok.Mapperly.Abstractions; +using System.Collections; + +namespace Web.Features; + +public partial class Baskets +{ + public partial class FetchItems + { + public record class Query(int CustomerId); + public record class Response(IEnumerable Items) : IEnumerable + { + public IEnumerator GetEnumerator() + { + return Items.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)Items).GetEnumerator(); + } + } + + public record class Item(int ProductId, int Quantity); + + [Mapper] + public partial class Mapper + { + public partial Response Map(IQueryable items); + } + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.CustomerId).GreaterThan(0); + } + } + + public class Handler + { + private readonly BasketContext _db; + private readonly Mapper _mapper; + + public Handler(BasketContext db, Mapper mapper) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + + public async Task HandleAsync(Query query, CancellationToken cancellationToken) + { + var items = _db.Items.Where(x => x.CustomerId == query.CustomerId); + await items.LoadAsync(cancellationToken); + var result = _mapper.Map(items); + return result; + } + } + } + + public static IServiceCollection AddFetchItems(this IServiceCollection services) + { + return services + .AddScoped() + .AddSingleton() + ; + } + + public static IEndpointRouteBuilder MapFetchItems(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet( + "/{CustomerId}", + ([AsParameters] FetchItems.Query query, FetchItems.Handler handler, CancellationToken cancellationToken) + => handler.HandleAsync(query, cancellationToken) + ); + return endpoints; + } + +} diff --git a/C18/REPR/Web/Features/Baskets/Baskets.RemoveItem.cs b/C18/REPR/Web/Features/Baskets/Baskets.RemoveItem.cs index 02ccccf..1d9061a 100644 --- a/C18/REPR/Web/Features/Baskets/Baskets.RemoveItem.cs +++ b/C18/REPR/Web/Features/Baskets/Baskets.RemoveItem.cs @@ -1,10 +1,74 @@ -namespace Web.Features; +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Riok.Mapperly.Abstractions; +namespace Web.Features; public partial class Baskets { - public class RemoveItem + public partial class RemoveItem { + public record class Command(int CustomerId, int ProductId); + public record class Response(int ProductId, int Quantity); + [Mapper] + public partial class Mapper + { + public partial Response Map(BasketItem item); + } + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.CustomerId).GreaterThan(0); + RuleFor(x => x.ProductId).GreaterThan(0); + } + } + + public class Handler + { + private readonly BasketContext _db; + private readonly Mapper _mapper; + + public Handler(BasketContext db, Mapper mapper) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + + public async Task HandleAsync(Command command, CancellationToken cancellationToken) + { + var item = await _db.Items.FirstOrDefaultAsync( + x => x.CustomerId == command.CustomerId && x.ProductId == command.ProductId, + cancellationToken: cancellationToken + ); + if (item is null) + { + throw new BasketItemNotFoundException(command.ProductId); + } + _db.Items.Remove(item); + await _db.SaveChangesAsync(cancellationToken); + var result = _mapper.Map(item); + return result; + } + } } + public static IServiceCollection AddRemoveItem(this IServiceCollection services) + { + return services + .AddScoped() + .AddSingleton() + ; + } + + public static IEndpointRouteBuilder MapRemoveItem(this IEndpointRouteBuilder endpoints) + { + endpoints.MapDelete( + "/{customerId}/{productId}", + ([AsParameters] RemoveItem.Command command, RemoveItem.Handler handler, CancellationToken cancellationToken) + => handler.HandleAsync(command, cancellationToken) + ); + return endpoints; + } } diff --git a/C18/REPR/Web/Features/Baskets/Baskets.UpdateQuantity.cs b/C18/REPR/Web/Features/Baskets/Baskets.UpdateQuantity.cs index 58312de..b16727b 100644 --- a/C18/REPR/Web/Features/Baskets/Baskets.UpdateQuantity.cs +++ b/C18/REPR/Web/Features/Baskets/Baskets.UpdateQuantity.cs @@ -1,10 +1,80 @@ -namespace Web.Features; +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Riok.Mapperly.Abstractions; +namespace Web.Features; public partial class Baskets { - public class UpdateQuantity + public partial class UpdateQuantity { + public record class Command(int CustomerId, int ProductId, int Quantity); + public record class Response(int ProductId, int Quantity); + [Mapper] + public partial class Mapper + { + public partial BasketItem Map(Command item); + public partial Response Map(BasketItem item); + } + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.CustomerId).GreaterThan(0); + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.Quantity).GreaterThan(0); + } + } + + public class Handler + { + private readonly BasketContext _db; + private readonly Mapper _mapper; + + public Handler(BasketContext db, Mapper mapper) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + + public async Task HandleAsync(Command command, CancellationToken cancellationToken) + { + var item = await _db.Items.AsNoTracking().FirstOrDefaultAsync( + x => x.CustomerId == command.CustomerId && x.ProductId == command.ProductId, + cancellationToken: cancellationToken + ); + if (item is null) + { + throw new BasketItemNotFoundException(command.ProductId); + } + var itemToUpdate = item with { Quantity = command.Quantity }; + if (item.Quantity != command.Quantity) + { + _db.Items.Update(itemToUpdate); + await _db.SaveChangesAsync(cancellationToken); + } + var result = _mapper.Map(itemToUpdate); + return result; + } + } } + public static IServiceCollection AddUpdateQuantity(this IServiceCollection services) + { + return services + .AddScoped() + .AddSingleton() + ; + } + + public static IEndpointRouteBuilder MapUpdateQuantity(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPut( + "/", + (UpdateQuantity.Command command, UpdateQuantity.Handler handler, CancellationToken cancellationToken) + => handler.HandleAsync(command, cancellationToken) + ); + return endpoints; + } } diff --git a/C18/REPR/Web/Features/Baskets/Baskets.cs b/C18/REPR/Web/Features/Baskets/Baskets.cs index fb148dc..743ec11 100644 --- a/C18/REPR/Web/Features/Baskets/Baskets.cs +++ b/C18/REPR/Web/Features/Baskets/Baskets.cs @@ -1,30 +1,53 @@ -using Microsoft.EntityFrameworkCore; +using ForEvolve.ExceptionMapper; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; namespace Web.Features; public static partial class Baskets { - public record class ShoppingCart(int CustomerId) - { - public List Items { get; } = new(); - } - public record class Item(int ProductId, int Quantity); + public record class BasketItem(int CustomerId, int ProductId, int Quantity); public class BasketContext : DbContext { public BasketContext(DbContextOptions options) : base(options) { } - public DbSet ShoppingCarts => Set(); + public DbSet Items => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder + .Entity() + .HasKey(x => new { x.CustomerId, x.ProductId }) + ; + } + } + + public class DuplicateBasketItemException : ConflictException + { + public DuplicateBasketItemException(int productId) + : base($"The product '{productId}' is already in your shopping cart.") + { + } + } + + public class BasketItemNotFoundException : NotFoundException + { + public BasketItemNotFoundException(int productId) + : base($"The product '{productId}' is not in your shopping cart.") + { + } } public static IServiceCollection AddBasketsFeature(this IServiceCollection services) { return services - // - // TODO: Register endpoints services - // + .AddAddItem() + .AddFetchItems() + .AddRemoveItem() + .AddUpdateQuantity() .AddDbContext(options => options .UseInMemoryDatabase("BasketContextMemoryDB") .ConfigureWarnings(builder => builder.Ignore(InMemoryEventId.TransactionIgnoredWarning)) @@ -38,9 +61,12 @@ public static IEndpointRouteBuilder MapBasketsFeature(this IEndpointRouteBuilder .MapGroup(nameof(Baskets).ToLower()) .WithTags(nameof(Baskets)) ; - //group - // // TODO: Register endpoints maps - //; + group + .MapFetchItems() + .MapAddItem() + .MapUpdateQuantity() + .MapRemoveItem() + ; return endpoints; } diff --git a/C18/REPR/Web/Program.cs b/C18/REPR/Web/Program.cs index 856d96b..e21fb5c 100644 --- a/C18/REPR/Web/Program.cs +++ b/C18/REPR/Web/Program.cs @@ -1,8 +1,20 @@ +using FluentValidation; +using FluentValidation.AspNetCore; +using Microsoft.EntityFrameworkCore; +using System.Reflection; using Web.Features; var builder = WebApplication.CreateBuilder(args); -builder.AddExceptionMapper(); +builder.AddExceptionMapper(builder => +{ + builder.Map().ToStatusCode(StatusCodes.Status409Conflict); + builder.Map().ToStatusCode(StatusCodes.Status409Conflict); +}); builder.AddFluentValidationEndpointFilter(); +builder.Services + .AddFluentValidationAutoValidation() + .AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()) +; builder.Services.AddFeatures(); var app = builder.Build(); @@ -12,3 +24,9 @@ await app.SeedFeaturesAsync(); app.Run(); + +// Workaround that makes the autogenerated program public so tests can +// access it without granting internal visibility. +#pragma warning disable CA1050 // Declare types in namespaces +public partial class Program { } +#pragma warning restore CA1050 // Declare types in namespaces \ No newline at end of file diff --git a/C18/REPR/Web/Web.http b/C18/REPR/Web/Web.http index c5b9c51..c8a1d7e 100644 --- a/C18/REPR/Web/Web.http +++ b/C18/REPR/Web/Web.http @@ -1,5 +1,6 @@ @Web_HostAddress = https://localhost:7252 @ProductId = 3 +@CustomerId = 1 GET {{Web_HostAddress}}/products @@ -8,3 +9,34 @@ GET {{Web_HostAddress}}/products GET {{Web_HostAddress}}/products/{{ProductId}} ### + + +GET {{Web_HostAddress}}/baskets/{{CustomerId}} + +### + +POST {{Web_HostAddress}}/baskets +Content-Type: application/json + +{ + "customerId": {{CustomerId}}, + "productId": {{ProductId}}, + "quantity": 10 +} + +### + +PUT {{Web_HostAddress}}/baskets +Content-Type: application/json + +{ + "customerId": {{CustomerId}}, + "productId": {{ProductId}}, + "quantity": 15 +} + +### + +DELETE {{Web_HostAddress}}/baskets/{{CustomerId}}/{{ProductId}} + +### From 2c53db35bace091a2630892a8bfef2a4a448e8b8 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Sat, 12 Aug 2023 23:30:46 -0400 Subject: [PATCH 07/25] Write tests --- C18/REPR/.gitignore | 1 + C18/REPR/README.md | 22 ++++ C18/REPR/REPR.sln | 2 +- C18/REPR/Web.Test/UnitTest1.cs | 10 -- .../Baskets/BasketsTest.AddItemTest.cs | 74 +++++++++++++ .../Baskets/BasketsTest.FetchItemsTest.cs | 50 +++++++++ .../Baskets/BasketsTest.RemoveItemTest.cs | 68 ++++++++++++ .../Baskets/BasketsTest.UpdateQuantityTest.cs | 103 ++++++++++++++++++ .../Web.Tests/Features/Baskets/BasketsTest.cs | 19 ++++ .../Products/ProductsTest.FetchAllTest.cs | 34 ++++++ .../Products/ProductsTest.FetchOneTest.cs | 48 ++++++++ .../Features/Products/ProductsTest.cs | 20 ++++ .../{Web.Test => Web.Tests}/GlobalUsings.cs | 0 .../Web.Tests.csproj} | 6 + C18/REPR/Web.Tests/WebApplication.cs | 55 ++++++++++ C18/REPR/Web/Web.http | 2 +- Diagrams/REPR.drawio | 2 +- 17 files changed, 503 insertions(+), 13 deletions(-) create mode 100644 C18/REPR/.gitignore create mode 100644 C18/REPR/README.md delete mode 100644 C18/REPR/Web.Test/UnitTest1.cs create mode 100644 C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs create mode 100644 C18/REPR/Web.Tests/Features/Baskets/BasketsTest.FetchItemsTest.cs create mode 100644 C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs create mode 100644 C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs create mode 100644 C18/REPR/Web.Tests/Features/Baskets/BasketsTest.cs create mode 100644 C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchAllTest.cs create mode 100644 C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchOneTest.cs create mode 100644 C18/REPR/Web.Tests/Features/Products/ProductsTest.cs rename C18/REPR/{Web.Test => Web.Tests}/GlobalUsings.cs (100%) rename C18/REPR/{Web.Test/Web.Test.csproj => Web.Tests/Web.Tests.csproj} (80%) create mode 100644 C18/REPR/Web.Tests/WebApplication.cs diff --git a/C18/REPR/.gitignore b/C18/REPR/.gitignore new file mode 100644 index 0000000..d5f2961 --- /dev/null +++ b/C18/REPR/.gitignore @@ -0,0 +1 @@ +coveragereport \ No newline at end of file diff --git a/C18/REPR/README.md b/C18/REPR/README.md new file mode 100644 index 0000000..495b0ab --- /dev/null +++ b/C18/REPR/README.md @@ -0,0 +1,22 @@ +# Test coverage + +The initial test coverage report indicates 96.6% Line Coverage and 63.1% Branch coverage. +Most of the not tested branches are related to the constructor injection guards that we have no unit tests for. + +## How to collect code coverage + +```bash +# 1. Generage the coverage.cobertura.xml file +dotnet test --collect:"XPlat Code Coverage" + +# 2. Generate the repport based on the previous file (change the GUID by the GUID generated by the collector) +reportgenerator -reports:"Web.Tests\TestResults\b74d6e70-a4f3-49ff-bf3c-00e4abba742c\coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:Html +``` + +## Prerequisites + +Run once to install the `reportgenerator` global tool: + +```bash +dotnet tool install -g dotnet-reportgenerator-globaltool +``` diff --git a/C18/REPR/REPR.sln b/C18/REPR/REPR.sln index 8fcf673..c7a7593 100644 --- a/C18/REPR/REPR.sln +++ b/C18/REPR/REPR.sln @@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web.Test", "Web.Test\Web.Test.csproj", "{50D34992-16B0-4421-948F-9C7D6A0EEEB3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web.Tests", "Web.Tests\Web.Tests.csproj", "{50D34992-16B0-4421-948F-9C7D6A0EEEB3}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web", "Web\Web.csproj", "{0653C456-D190-43D1-98BC-74000960AE85}" EndProject diff --git a/C18/REPR/Web.Test/UnitTest1.cs b/C18/REPR/Web.Test/UnitTest1.cs deleted file mode 100644 index a8142c3..0000000 --- a/C18/REPR/Web.Test/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Web.Test; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} \ No newline at end of file diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs new file mode 100644 index 0000000..dbe90ef --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http.Json; +using static Web.Features.Baskets; + +namespace Web.Features; +public partial class BasketsTest +{ + public class AddItemTest + { + [Fact] + public async Task Should_add_the_new_item_to_the_cart() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/baskets", + new AddItem.Command(4, 1, 22) + ); + + // Assert the response + Assert.NotNull(response); + Assert.True(response.IsSuccessStatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(1, result.ProductId); + Assert.Equal(22, result.Quantity); + + // Assert the database state + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 4 && x.ProductId == 1); + Assert.NotNull(dbItem); + Assert.Equal(22, dbItem.Quantity); + } + + [Fact] + public async Task Should_return_a_ProblemDetails_with_a_Conflict_status_code() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/baskets", + new AddItem.Command(1, 1, 20) + ); + + // Assert the response + Assert.NotNull(response); + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(problem); + Assert.Equal("The product \u00271\u0027 is already in your shopping cart.", problem.Title); + + // Assert the database state + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 1 && x.ProductId == 1); + Assert.NotNull(dbItem); + Assert.Equal(10, dbItem.Quantity); + } + + } +} diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.FetchItemsTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.FetchItemsTest.cs new file mode 100644 index 0000000..7f74616 --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.FetchItemsTest.cs @@ -0,0 +1,50 @@ +using System.Net.Http.Json; +using static Web.Features.Baskets; + +namespace Web.Features; +public partial class BasketsTest +{ + public class FetchItemsTest + { + [Fact] + public async Task Should_return_the_specified_customer_items() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.GetFromJsonAsync>("/baskets/2"); + + // Assert + Assert.NotNull(response); + Assert.Collection(response, + i => { + Assert.Equal(1, i.ProductId); + Assert.Equal(5, i.Quantity); + }, + i => { + Assert.Equal(3, i.ProductId); + Assert.Equal(15, i.Quantity); + } + ); + } + + [Fact] + public async Task Should_return_an_empty_list_when_the_customer_have_no_item_in_its_cart() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.GetFromJsonAsync>("/baskets/5"); + + // Assert + Assert.NotNull(response); + Assert.Empty(response); + } + } +} diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs new file mode 100644 index 0000000..4eeae42 --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Net; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Xunit; +using static Web.Features.Baskets; + +namespace Web.Features; +public partial class BasketsTest +{ + public class RemoveItemTest + { + [Fact] + public async Task Should_remove_the_specified_item_from_the_customer_cart() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.DeleteAsync("/baskets/2/1"); + + // Assert the response + Assert.NotNull(response); + Assert.True(response.IsSuccessStatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(5, result.Quantity); + + // Assert the database state + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 2 && x.ProductId == 1); + Assert.Null(dbItem); + } + + + [Fact] + public async Task Should_return_a_ProblemDetails_with_a_NotFound_status_code() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.DeleteAsync("/baskets/99/99"); + + // Assert the response + Assert.NotNull(response); + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(problem); + Assert.Equal("The product \u002799\u0027 is not in your shopping cart.", problem.Title); + + // Assert the database state + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 99); + Assert.Null(dbItem); + } + + } +} diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs new file mode 100644 index 0000000..082e230 --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs @@ -0,0 +1,103 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Net; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Xunit; +using static Web.Features.Baskets; + +namespace Web.Features; +public partial class BasketsTest +{ + public class UpdateQuantityTest + { + [Fact] + public async Task Should_update_the_item_quantity() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.PutAsJsonAsync( + "/baskets", + new UpdateQuantity.Command(2, 1, 25) + ); + + // Assert the response + Assert.NotNull(response); + Assert.True(response.IsSuccessStatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(25, result.Quantity); + + // Assert the database state + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 2 && x.ProductId == 1); + Assert.NotNull(dbItem); + Assert.Equal(25, dbItem.Quantity); + } + + + [Fact] + public async Task Should_return_a_ProblemDetails_with_a_NotFound_status_code() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.PutAsJsonAsync( + "/baskets", + new UpdateQuantity.Command(99, 99, 25) + ); + + // Assert the response + Assert.NotNull(response); + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(problem); + Assert.Equal("The product \u002799\u0027 is not in your shopping cart.", problem.Title); + + // Assert the database state + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 99); + Assert.Null(dbItem); + } + + [Fact] + public async Task Should_not_touch_the_database_when_the_quantity_is_the_same() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + var mapper = seedScope.ServiceProvider.GetRequiredService(); + db.SavedChanges += Db_SavedChanges; + var saved = false; + var sut = new UpdateQuantity.Handler(db, mapper); + + // Act + var response = await sut.HandleAsync( + new UpdateQuantity.Command(2, 1, 5), + CancellationToken.None + ); + + // Assert + Assert.NotNull(response); + Assert.False(saved); + + void Db_SavedChanges(object? sender, Microsoft.EntityFrameworkCore.SavedChangesEventArgs e) + { + saved = true; + } + } + } +} diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.cs new file mode 100644 index 0000000..8cb6ddd --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.cs @@ -0,0 +1,19 @@ + +using static Web.Features.Baskets; + +namespace Web.Features; +public partial class BasketsTest +{ + private static async Task SeederDelegateAsync(BasketContext db) + { + db.Items.RemoveRange(db.Items.ToArray()); + await db.Items.AddAsync(new BasketItem(1, 1, 10)); + await db.Items.AddAsync(new BasketItem(1, 2, 20)); + await db.Items.AddAsync(new BasketItem(1, 3, 30)); + await db.Items.AddAsync(new BasketItem(2, 1, 5)); + await db.Items.AddAsync(new BasketItem(2, 3, 15)); + await db.Items.AddAsync(new BasketItem(3, 2, 18)); + await db.Items.AddAsync(new BasketItem(3, 4, 36)); + await db.SaveChangesAsync(); + } +} diff --git a/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchAllTest.cs b/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchAllTest.cs new file mode 100644 index 0000000..a4b4f4b --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchAllTest.cs @@ -0,0 +1,34 @@ +using System.Net.Http.Json; +using static Web.Features.Products; + +namespace Web.Features; +public partial class ProductsTest +{ + public class FetchAllTest + { + [Fact] + public async Task Should_return_the_products() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.GetFromJsonAsync("/products"); + + // Assert + Assert.NotNull(response); + Assert.Collection(response.Products, + p => { + Assert.Equal(3, p.Id); + Assert.Equal("Habanero Pepper", p.Name); + }, + p => { + Assert.Equal(2, p.Id); + Assert.Equal("Scotch Bottle", p.Name); + } + ); + } + } +} \ No newline at end of file diff --git a/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchOneTest.cs b/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchOneTest.cs new file mode 100644 index 0000000..0613d31 --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchOneTest.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using System.Net; +using System.Net.Http.Json; +using static Web.Features.Products; + +namespace Web.Features; +public partial class ProductsTest +{ + public class FetchOneTest + { + [Fact] + public async Task Should_return_the_product() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.GetFromJsonAsync("/products/2"); + + // Assert + Assert.NotNull(response); + Assert.Equal(2, response.Id); + Assert.Equal("Scotch Bottle", response.Name); + } + + [Fact] + public async Task Should_return_a_ProblemDetails_with_a_NotFound_status_code() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.GetAsync("/products/10"); + + // Assert + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(problem); + Assert.Equal("The product \u002710\u0027 was not found.", problem.Title); + } + + } +} diff --git a/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs b/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs new file mode 100644 index 0000000..1eb75c9 --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs @@ -0,0 +1,20 @@ +using static Web.Features.Products; + +namespace Web.Features; + +public partial class ProductsTest +{ + private static async Task SeederDelegateAsync(ProductContext db) + { + db.Products.RemoveRange(db.Products.ToArray()); + await db.Products.AddAsync(new Product( + Name: "Scotch Bottle", + Id: 2 + )); + await db.Products.AddAsync(new Product( + Name: "Habanero Pepper", + Id: 3 + )); + await db.SaveChangesAsync(); + } +} diff --git a/C18/REPR/Web.Test/GlobalUsings.cs b/C18/REPR/Web.Tests/GlobalUsings.cs similarity index 100% rename from C18/REPR/Web.Test/GlobalUsings.cs rename to C18/REPR/Web.Tests/GlobalUsings.cs diff --git a/C18/REPR/Web.Test/Web.Test.csproj b/C18/REPR/Web.Tests/Web.Tests.csproj similarity index 80% rename from C18/REPR/Web.Test/Web.Test.csproj rename to C18/REPR/Web.Tests/Web.Tests.csproj index 9e0c306..8765050 100644 --- a/C18/REPR/Web.Test/Web.Test.csproj +++ b/C18/REPR/Web.Tests/Web.Tests.csproj @@ -7,9 +7,11 @@ false true + Web + @@ -22,4 +24,8 @@ + + + + diff --git a/C18/REPR/Web.Tests/WebApplication.cs b/C18/REPR/Web.Tests/WebApplication.cs new file mode 100644 index 0000000..e1b4d72 --- /dev/null +++ b/C18/REPR/Web.Tests/WebApplication.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Runtime.CompilerServices; + +namespace Web; + +public class C18WebApplication : WebApplicationFactory +{ + private readonly Action? _afterConfigureServices; + private readonly string _databaseName; + public C18WebApplication([CallerMemberName] string? databaseName = null, Action? afterConfigureServices = null) + { + _databaseName = databaseName ?? nameof(C18WebApplication); + // Add some randomness to the database name to ensure uniqueness + // for test methods that have the same name. + _databaseName += Guid.NewGuid().ToString(); + _afterConfigureServices = afterConfigureServices; + } + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.ConfigureServices(services => + { + // Override the default DbContext options to make + // a different InMemory database per test case so there is no + // seed conflicts. + services + .AddScoped(ConfigureContext) + .AddScoped(ConfigureContext) + ; + _afterConfigureServices?.Invoke(services); + }); + return base.CreateHost(builder); + } + + public DbContextOptions ConfigureContext(IServiceProvider sp) + where TDbContext : DbContext + { + return new DbContextOptionsBuilder() + .UseInMemoryDatabase(_databaseName + typeof(TDbContext).Name) + .UseApplicationServiceProvider(sp) + .Options; + } + + public Task SeedAsync(Func seeder) + where TDbContext : DbContext + { + using var seedScope = Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + return seeder(db); + } + +} \ No newline at end of file diff --git a/C18/REPR/Web/Web.http b/C18/REPR/Web/Web.http index c8a1d7e..262c74f 100644 --- a/C18/REPR/Web/Web.http +++ b/C18/REPR/Web/Web.http @@ -1,5 +1,5 @@ @Web_HostAddress = https://localhost:7252 -@ProductId = 3 +@ProductId = 4 @CustomerId = 1 GET {{Web_HostAddress}}/products diff --git a/Diagrams/REPR.drawio b/Diagrams/REPR.drawio index d7bebaf..7e31546 100644 --- a/Diagrams/REPR.drawio +++ b/Diagrams/REPR.drawio @@ -1,6 +1,6 @@ - + From fca2d3ac914b6e32d4653cce1772c8170a462b68 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Sat, 12 Aug 2023 23:35:12 -0400 Subject: [PATCH 08/25] Cleanup --- C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs | 1 - .../Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs | 3 --- .../Features/Baskets/BasketsTest.UpdateQuantityTest.cs | 3 --- 3 files changed, 7 deletions(-) diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs index dbe90ef..d5d4ef9 100644 --- a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using System.Net; using System.Net.Http.Json; diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs index 4eeae42..099967c 100644 --- a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs @@ -1,10 +1,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; -using System; using System.Net; using System.Net.Http.Json; -using System.Threading.Tasks; -using Xunit; using static Web.Features.Baskets; namespace Web.Features; diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs index 082e230..d87ff86 100644 --- a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs @@ -1,10 +1,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; -using System; using System.Net; using System.Net.Http.Json; -using System.Threading.Tasks; -using Xunit; using static Web.Features.Baskets; namespace Web.Features; From d9e779a9c2ee5a1c1e8cc77c0a025dd4cafd963a Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Sat, 12 Aug 2023 23:38:09 -0400 Subject: [PATCH 09/25] Move FluentValidation registration to Features The features leverage FluentValidation. The Features class leverages the FluentValidationEndpointFilter. --- C18/REPR/Web/Features/Features.cs | 16 +++++++++++++--- C18/REPR/Web/Program.cs | 10 +--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/C18/REPR/Web/Features/Features.cs b/C18/REPR/Web/Features/Features.cs index 375bbb3..0b09392 100644 --- a/C18/REPR/Web/Features/Features.cs +++ b/C18/REPR/Web/Features/Features.cs @@ -1,10 +1,20 @@ -namespace Web.Features; +using FluentValidation; +using FluentValidation.AspNetCore; +using System.Reflection; + +namespace Web.Features; public static class Features { - public static IServiceCollection AddFeatures(this IServiceCollection services) + public static IServiceCollection AddFeatures(this WebApplicationBuilder builder) { - return services + // Register fluent validation + builder.AddFluentValidationEndpointFilter(); + return builder.Services + .AddFluentValidationAutoValidation() + .AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()) + + // Add features .AddProductsFeature() .AddBasketsFeature() ; diff --git a/C18/REPR/Web/Program.cs b/C18/REPR/Web/Program.cs index e21fb5c..d500aff 100644 --- a/C18/REPR/Web/Program.cs +++ b/C18/REPR/Web/Program.cs @@ -1,7 +1,4 @@ -using FluentValidation; -using FluentValidation.AspNetCore; using Microsoft.EntityFrameworkCore; -using System.Reflection; using Web.Features; var builder = WebApplication.CreateBuilder(args); @@ -10,12 +7,7 @@ builder.Map().ToStatusCode(StatusCodes.Status409Conflict); builder.Map().ToStatusCode(StatusCodes.Status409Conflict); }); -builder.AddFluentValidationEndpointFilter(); -builder.Services - .AddFluentValidationAutoValidation() - .AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()) -; -builder.Services.AddFeatures(); +builder.AddFeatures(); var app = builder.Build(); app.UseExceptionMapper(); From 69597ade276bff7005cea53167499080e7b00da5 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Sat, 12 Aug 2023 23:58:06 -0400 Subject: [PATCH 10/25] Update version to the NuGet one --- C18/REPR/Web/Web.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/C18/REPR/Web/Web.csproj b/C18/REPR/Web/Web.csproj index 69befc8..8740266 100644 --- a/C18/REPR/Web/Web.csproj +++ b/C18/REPR/Web/Web.csproj @@ -9,7 +9,7 @@ - + From c933f56d83482f82b758c3ea420de7a25806dc2c Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Mon, 14 Aug 2023 10:26:29 -0400 Subject: [PATCH 11/25] Split the GET in two Ensure the existing request receives 200 OK. Create a new failing request that gets 404 NOT FOUND --- C18/REPR/Web/Web.http | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/C18/REPR/Web/Web.http b/C18/REPR/Web/Web.http index 262c74f..73f9b18 100644 --- a/C18/REPR/Web/Web.http +++ b/C18/REPR/Web/Web.http @@ -1,5 +1,6 @@ @Web_HostAddress = https://localhost:7252 -@ProductId = 4 +@ProductId = 3 +@NonExistingProductId = 4 @CustomerId = 1 GET {{Web_HostAddress}}/products @@ -11,6 +12,12 @@ GET {{Web_HostAddress}}/products/{{ProductId}} ### +GET {{Web_HostAddress}}/products/{{NonExistingProductId}} + +### + + + GET {{Web_HostAddress}}/baskets/{{CustomerId}} ### From 1a0578fcca4fccd4a18a8241e72ea2f014ef477d Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Mon, 14 Aug 2023 10:27:03 -0400 Subject: [PATCH 12/25] Add the UnitPrice property to products --- .../Web.Tests/Features/Products/ProductsTest.FetchAllTest.cs | 2 ++ .../Web.Tests/Features/Products/ProductsTest.FetchOneTest.cs | 1 + C18/REPR/Web.Tests/Features/Products/ProductsTest.cs | 2 ++ C18/REPR/Web/Features/Products/Products.FetchAll.cs | 2 +- C18/REPR/Web/Features/Products/Products.FetchOne.cs | 2 +- C18/REPR/Web/Features/Products/Products.cs | 5 ++++- 6 files changed, 11 insertions(+), 3 deletions(-) diff --git a/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchAllTest.cs b/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchAllTest.cs index a4b4f4b..a711704 100644 --- a/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchAllTest.cs +++ b/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchAllTest.cs @@ -22,10 +22,12 @@ public async Task Should_return_the_products() Assert.Collection(response.Products, p => { Assert.Equal(3, p.Id); + Assert.Equal(1, p.UnitPrice); Assert.Equal("Habanero Pepper", p.Name); }, p => { Assert.Equal(2, p.Id); + Assert.Equal(99, p.UnitPrice); Assert.Equal("Scotch Bottle", p.Name); } ); diff --git a/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchOneTest.cs b/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchOneTest.cs index 0613d31..0030925 100644 --- a/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchOneTest.cs +++ b/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchOneTest.cs @@ -22,6 +22,7 @@ public async Task Should_return_the_product() // Assert Assert.NotNull(response); Assert.Equal(2, response.Id); + Assert.Equal(99, response.UnitPrice); Assert.Equal("Scotch Bottle", response.Name); } diff --git a/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs b/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs index 1eb75c9..15b7fd6 100644 --- a/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs +++ b/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs @@ -9,10 +9,12 @@ private static async Task SeederDelegateAsync(ProductContext db) db.Products.RemoveRange(db.Products.ToArray()); await db.Products.AddAsync(new Product( Name: "Scotch Bottle", + UnitPrice: 99, Id: 2 )); await db.Products.AddAsync(new Product( Name: "Habanero Pepper", + UnitPrice: 1, Id: 3 )); await db.SaveChangesAsync(); diff --git a/C18/REPR/Web/Features/Products/Products.FetchAll.cs b/C18/REPR/Web/Features/Products/Products.FetchAll.cs index 6c49ae7..452879e 100644 --- a/C18/REPR/Web/Features/Products/Products.FetchAll.cs +++ b/C18/REPR/Web/Features/Products/Products.FetchAll.cs @@ -9,7 +9,7 @@ public partial class FetchAll { public record class Query(); public record class Response(IEnumerable Products); - public record class ResponseProduct(int Id, string Name); + public record class ResponseProduct(int Id, string Name, decimal UnitPrice); [Mapper] public partial class Mapper diff --git a/C18/REPR/Web/Features/Products/Products.FetchOne.cs b/C18/REPR/Web/Features/Products/Products.FetchOne.cs index a2811e9..a1a500e 100644 --- a/C18/REPR/Web/Features/Products/Products.FetchOne.cs +++ b/C18/REPR/Web/Features/Products/Products.FetchOne.cs @@ -8,7 +8,7 @@ public partial class Products public partial class FetchOne { public record class Query(int ProductId); - public record class Response(int Id, string Name); + public record class Response(int Id, string Name, decimal UnitPrice); [Mapper] public partial class Mapper diff --git a/C18/REPR/Web/Features/Products/Products.cs b/C18/REPR/Web/Features/Products/Products.cs index 3593d0c..1abef30 100644 --- a/C18/REPR/Web/Features/Products/Products.cs +++ b/C18/REPR/Web/Features/Products/Products.cs @@ -6,7 +6,7 @@ namespace Web.Features; public static partial class Products { - public record class Product(string Name, int? Id = null); + public record class Product(string Name, decimal UnitPrice, int? Id = null); public class ProductContext : DbContext { @@ -31,14 +31,17 @@ public static Task SeedAsync(ProductContext db) { db.Products.Add(new Product( Name: "Banana", + UnitPrice: 0.30m, Id: 1 )); db.Products.Add(new Product( Name: "Apple", + UnitPrice: 0.79m, Id: 2 )); db.Products.Add(new Product( Name: "Habanero Pepper", + UnitPrice: 0.99m, Id: 3 )); return db.SaveChangesAsync(); From ddeea98aa8adb5757b769a168e6ee8fbe289349f Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Mon, 14 Aug 2023 14:52:43 -0400 Subject: [PATCH 13/25] Extract exceptions and remove ProductSeeder The ProductSeeder class is a vestige of the previous projects. Format Program.cs so the code fits better in the book. --- .../Baskets/BasketItemNotFoundException.cs | 11 ++++ C18/REPR/Web/Features/Baskets/Baskets.cs | 19 +------ .../Baskets/DuplicateBasketItemException.cs | 11 ++++ .../Products/ProductNotFoundException.cs | 12 +++++ C18/REPR/Web/Features/Products/Products.cs | 52 ++++++------------- C18/REPR/Web/Program.cs | 10 +++- 6 files changed, 60 insertions(+), 55 deletions(-) create mode 100644 C18/REPR/Web/Features/Baskets/BasketItemNotFoundException.cs create mode 100644 C18/REPR/Web/Features/Baskets/DuplicateBasketItemException.cs create mode 100644 C18/REPR/Web/Features/Products/ProductNotFoundException.cs diff --git a/C18/REPR/Web/Features/Baskets/BasketItemNotFoundException.cs b/C18/REPR/Web/Features/Baskets/BasketItemNotFoundException.cs new file mode 100644 index 0000000..d7a3368 --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/BasketItemNotFoundException.cs @@ -0,0 +1,11 @@ +using ForEvolve.ExceptionMapper; + +namespace Web.Features; + +public class BasketItemNotFoundException : NotFoundException +{ + public BasketItemNotFoundException(int productId) + : base($"The product '{productId}' is not in your shopping cart.") + { + } +} diff --git a/C18/REPR/Web/Features/Baskets/Baskets.cs b/C18/REPR/Web/Features/Baskets/Baskets.cs index 743ec11..cdb15e2 100644 --- a/C18/REPR/Web/Features/Baskets/Baskets.cs +++ b/C18/REPR/Web/Features/Baskets/Baskets.cs @@ -1,5 +1,4 @@ -using ForEvolve.ExceptionMapper; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; namespace Web.Features; @@ -25,22 +24,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - public class DuplicateBasketItemException : ConflictException - { - public DuplicateBasketItemException(int productId) - : base($"The product '{productId}' is already in your shopping cart.") - { - } - } - - public class BasketItemNotFoundException : NotFoundException - { - public BasketItemNotFoundException(int productId) - : base($"The product '{productId}' is not in your shopping cart.") - { - } - } - public static IServiceCollection AddBasketsFeature(this IServiceCollection services) { return services diff --git a/C18/REPR/Web/Features/Baskets/DuplicateBasketItemException.cs b/C18/REPR/Web/Features/Baskets/DuplicateBasketItemException.cs new file mode 100644 index 0000000..74aa74e --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/DuplicateBasketItemException.cs @@ -0,0 +1,11 @@ +using ForEvolve.ExceptionMapper; + +namespace Web.Features; + +public class DuplicateBasketItemException : ConflictException +{ + public DuplicateBasketItemException(int productId) + : base($"The product '{productId}' is already in your shopping cart.") + { + } +} diff --git a/C18/REPR/Web/Features/Products/ProductNotFoundException.cs b/C18/REPR/Web/Features/Products/ProductNotFoundException.cs new file mode 100644 index 0000000..e9b7a2f --- /dev/null +++ b/C18/REPR/Web/Features/Products/ProductNotFoundException.cs @@ -0,0 +1,12 @@ +using ForEvolve.ExceptionMapper; + +namespace Web.Features; + +public class ProductNotFoundException : NotFoundException +{ + public ProductNotFoundException(int productId) + : base($"The product '{productId}' was not found.") + { + + } +} diff --git a/C18/REPR/Web/Features/Products/Products.cs b/C18/REPR/Web/Features/Products/Products.cs index 1abef30..5e88a1f 100644 --- a/C18/REPR/Web/Features/Products/Products.cs +++ b/C18/REPR/Web/Features/Products/Products.cs @@ -1,5 +1,4 @@ -using ForEvolve.ExceptionMapper; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; namespace Web.Features; @@ -16,38 +15,6 @@ public ProductContext(DbContextOptions options) public DbSet Products => Set(); } - public class ProductNotFoundException : NotFoundException - { - public ProductNotFoundException(int productId) - : base($"The product '{productId}' was not found.") - { - - } - } - - internal static class ProductSeeder - { - public static Task SeedAsync(ProductContext db) - { - db.Products.Add(new Product( - Name: "Banana", - UnitPrice: 0.30m, - Id: 1 - )); - db.Products.Add(new Product( - Name: "Apple", - UnitPrice: 0.79m, - Id: 2 - )); - db.Products.Add(new Product( - Name: "Habanero Pepper", - UnitPrice: 0.99m, - Id: 3 - )); - return db.SaveChangesAsync(); - } - } - public static IServiceCollection AddProductsFeature(this IServiceCollection services) { return services @@ -76,6 +43,21 @@ public static IEndpointRouteBuilder MapProductsFeature(this IEndpointRouteBuilde public static async Task SeedProductsAsync(this IServiceScope scope) { var db = scope.ServiceProvider.GetRequiredService(); - await ProductSeeder.SeedAsync(db); + db.Products.Add(new Product( + Name: "Banana", + UnitPrice: 0.30m, + Id: 1 + )); + db.Products.Add(new Product( + Name: "Apple", + UnitPrice: 0.79m, + Id: 2 + )); + db.Products.Add(new Product( + Name: "Habanero Pepper", + UnitPrice: 0.99m, + Id: 3 + )); + await db.SaveChangesAsync(); } } diff --git a/C18/REPR/Web/Program.cs b/C18/REPR/Web/Program.cs index d500aff..39dce5c 100644 --- a/C18/REPR/Web/Program.cs +++ b/C18/REPR/Web/Program.cs @@ -4,8 +4,14 @@ var builder = WebApplication.CreateBuilder(args); builder.AddExceptionMapper(builder => { - builder.Map().ToStatusCode(StatusCodes.Status409Conflict); - builder.Map().ToStatusCode(StatusCodes.Status409Conflict); + builder + .Map() + .ToStatusCode(StatusCodes.Status409Conflict) + ; + builder + .Map() + .ToStatusCode(StatusCodes.Status409Conflict) + ; }); builder.AddFeatures(); From a7309087fea74520237fa0bbd65c11de0019a9a1 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Mon, 14 Aug 2023 14:53:49 -0400 Subject: [PATCH 14/25] Add folders structure diagram --- Diagrams/REPR.drawio | 81 +++++++++++++++++++++++++++++++++++++++++--- Diagrams/REPR.svg | 2 +- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/Diagrams/REPR.drawio b/Diagrams/REPR.drawio index 7e31546..6a17bdc 100644 --- a/Diagrams/REPR.drawio +++ b/Diagrams/REPR.drawio @@ -1,6 +1,6 @@ - + @@ -57,16 +57,87 @@ - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Diagrams/REPR.svg b/Diagrams/REPR.svg index 2d7fea6..2d76eda 100644 --- a/Diagrams/REPR.svg +++ b/Diagrams/REPR.svg @@ -1 +1 @@ -
Request 
Request 
Input
Input
 Endpoint
 Endpoint
Logic
Logic
 Response
 Response
Output
Output
600 px MAX width
600 px MAX width
Text is not SVG - cannot display
\ No newline at end of file +
Request 
Request 
Input
Input
 Endpoint
 Endpoint
Logic
Logic
 Response
 Response
Output
Output
600 px MAX width
600 px MAX width
Root
Root
Areas
Areas
Features
Features
Features
Features
Products
Products
Baskets
Baskets
AddItem
AddItem
RemoveItem
RemoveItem
FetchItems
FetchItems
UpdateQuantity
UpdateQuantity
FetchAll
FetchAll
FetchOne
FetchOne
Text is not SVG - cannot display
\ No newline at end of file From 8f5b9a9d1acb554560fadd4c11674bb34f2df2e2 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Mon, 14 Aug 2023 15:06:22 -0400 Subject: [PATCH 15/25] Fix typo in Baskets --- C18/REPR/Web/Features/Baskets/Baskets.cs | 2 +- C18/REPR/Web/Features/Features.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/C18/REPR/Web/Features/Baskets/Baskets.cs b/C18/REPR/Web/Features/Baskets/Baskets.cs index cdb15e2..01bba26 100644 --- a/C18/REPR/Web/Features/Baskets/Baskets.cs +++ b/C18/REPR/Web/Features/Baskets/Baskets.cs @@ -53,7 +53,7 @@ public static IEndpointRouteBuilder MapBasketsFeature(this IEndpointRouteBuilder return endpoints; } - public static Task SeedBasketAsync(this IServiceScope scope) + public static Task SeedBasketsAsync(this IServiceScope scope) { return Task.CompletedTask; } diff --git a/C18/REPR/Web/Features/Features.cs b/C18/REPR/Web/Features/Features.cs index 0b09392..1a3ec19 100644 --- a/C18/REPR/Web/Features/Features.cs +++ b/C18/REPR/Web/Features/Features.cs @@ -38,6 +38,6 @@ public static async Task SeedFeaturesAsync(this WebApplication app) using var scope = app.Services.CreateScope(); await scope.SeedProductsAsync(); - await scope.SeedBasketAsync(); + await scope.SeedBasketsAsync(); } } From 8922dec588386ee8a0ca352cc39320c64da54d5d Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Mon, 14 Aug 2023 21:32:47 -0400 Subject: [PATCH 16/25] Add call hierarchy diagrams --- Diagrams/REPR.drawio | 270 ++++++++++++++++++++++++++++++++++++++++++- Diagrams/REPR.svg | 2 +- 2 files changed, 268 insertions(+), 4 deletions(-) diff --git a/Diagrams/REPR.drawio b/Diagrams/REPR.drawio index 6a17bdc..a721679 100644 --- a/Diagrams/REPR.drawio +++ b/Diagrams/REPR.drawio @@ -1,6 +1,6 @@ - + @@ -63,8 +63,8 @@ - - + + @@ -138,6 +138,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Diagrams/REPR.svg b/Diagrams/REPR.svg index 2d76eda..360e5cd 100644 --- a/Diagrams/REPR.svg +++ b/Diagrams/REPR.svg @@ -1 +1 @@ -
Request 
Request 
Input
Input
 Endpoint
 Endpoint
Logic
Logic
 Response
 Response
Output
Output
600 px MAX width
600 px MAX width
Root
Root
Areas
Areas
Features
Features
Features
Features
Products
Products
Baskets
Baskets
AddItem
AddItem
RemoveItem
RemoveItem
FetchItems
FetchItems
UpdateQuantity
UpdateQuantity
FetchAll
FetchAll
FetchOne
FetchOne
Text is not SVG - cannot display
\ No newline at end of file +
Request 
Request 
Input
Input
 Endpoint
 Endpoint
Logic
Logic
 Response
 Response
Output
Output
600 px MAX width
600 px MAX width
Root
Root
Areas
Areas
Features
Features
Features
Features
Products
Products
Baskets
Baskets
AddItem
AddItem
RemoveItem
RemoveItem
FetchItems
FetchItems
UpdateQuantity
UpdateQuantity
FetchAll
FetchAll
FetchOne
FetchOne
Program
Program
AddFeatures
AddFeatures
MapFeatures
MapFeatures
SeedFeaturesAsync
SeedFeaturesAsync
AddProductsFeature
AddProductsFeature
AddBasketsFeature
AddBasketsFeature
AddFetchAll
AddFetchAll
AddFetchOne
AddFetchOne
AddDbContext
AddDbContext
AddScoped<FetchAll.Handler>
AddScoped<FetchAll.Handler>
AddSingleton<FetchAll.Mapper>
AddSingleton<FetchAll.Mapper>
600 px MAX width
600 px MAX width
Program
Program
AddFeatures
AddFeatures
MapFeatures
MapFeatures
SeedFeaturesAsync
SeedFeaturesAsync
AddProductsFeature
AddProductsFeature
AddBasketsFeature
AddBasketsFeature
AddFetchAll
AddFetchAll
AddFetchOne
AddFetchOne
AddDbContext
AddDbContext
Program.cs
Program.cs
Features.cs
AddFeatures
Features.cs...
Features.cs
MapFeatures
Features.cs...
Features.cs
SeedFeaturesAsync
Features.cs...
Products.cs
AddProductsFeature
Products.cs...
Baskets.cs
AddBasketsFeature
Baskets.cs...
Products.FetchAll.cs
AddFetchAll
Products.FetchAll.cs...
Products.FetchOne.cs
AddFetchOne
Products.FetchOne.cs...
Products.cs
ProductContext
Products.cs...
Baskets.AddItem.cs
AddAddItem
Baskets.AddItem.cs...
Baskets.FetchItems.cs
AddFetchItems
Baskets.FetchItems.cs...
Baskets.RemoveItem.cs
AddRemoveItem
Baskets.RemoveItem.cs...
Baskets.cs
BasketContext
Baskets.cs...
Baskets.UpdateQuantity.cs
AddUpdateQuantity
Baskets.UpdateQuantity.cs...
Products.cs
MapProductsFeature
Products.cs...
Products.cs
SeedProductsAsync
Products.cs...
Program.cs
Program.cs
Program.cs
Program.cs
Baskets.cs
MapBasketsFeature
Baskets.cs...
Products.FetchAll.cs
MapFetchAll
Products.FetchAll.cs...
Products.FetchOne.cs
MapFetchOne
Products.FetchOne.cs...
Baskets.FetchItems.cs
MapFetchItems
Baskets.FetchItems.cs...
Baskets.AddItem.cs
MapAddItem
Baskets.AddItem.cs...
Baskets.UpdateQuantity.cs
MapUpdateQuantity
Baskets.UpdateQuantity.cs...
Baskets.RemoveItem.cs
MapRemoveItem
Baskets.RemoveItem.cs...
Baskets.cs
SeedBasketsAsync
Baskets.cs...
Text is not SVG - cannot display
\ No newline at end of file From c4192b32924029b44e23ff4bdc9e58ff01e844a9 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Mon, 14 Aug 2023 21:41:05 -0400 Subject: [PATCH 17/25] Use expression bodies to shorten the code --- C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs b/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs index e4478b2..a3de6c4 100644 --- a/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs +++ b/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs @@ -13,14 +13,9 @@ public record class Query(int CustomerId); public record class Response(IEnumerable Items) : IEnumerable { public IEnumerator GetEnumerator() - { - return Items.GetEnumerator(); - } - + => Items.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() - { - return ((IEnumerable)Items).GetEnumerator(); - } + => ((IEnumerable)Items).GetEnumerator(); } public record class Item(int ProductId, int Quantity); From 23b1404a412012d48321b3958122be7ee6d01ca3 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Mon, 14 Aug 2023 23:33:36 -0400 Subject: [PATCH 18/25] Adding an item to the cart now returns 201 The Location header points to the product endpoint. Add a test to validate this and in case the URL changes. --- .../Baskets/BasketsTest.AddItemTest.cs | 30 +++++++++++++++++++ .../Web/Features/Baskets/Baskets.AddItem.cs | 18 ++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs index d5d4ef9..731e784 100644 --- a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs @@ -39,6 +39,36 @@ public async Task Should_add_the_new_item_to_the_cart() Assert.Equal(22, dbItem.Quantity); } + [Fact] + public async Task Should_return_a_valid_product_url() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(async db => + { + db.Products.RemoveRange(db.Products); + db.Products.Add(new("A test product", 15.22m, 1)); + await db.SaveChangesAsync(); + }); + var client = application.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/baskets", + new AddItem.Command(4, 1, 22) + ); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.NotNull(response.Headers.Location); + + var productResponse = await client.GetAsync(response.Headers.Location); + Assert.NotNull(productResponse); + Assert.True(productResponse.IsSuccessStatusCode); + } + + [Fact] public async Task Should_return_a_ProblemDetails_with_a_Conflict_status_code() { diff --git a/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs b/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs index f86da58..7f0a415 100644 --- a/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs +++ b/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs @@ -8,8 +8,15 @@ public partial class Baskets { public partial class AddItem { - public record class Command(int CustomerId, int ProductId, int Quantity); - public record class Response(int ProductId, int Quantity); + public record class Command( + int CustomerId, + int ProductId, + int Quantity + ); + public record class Response( + int ProductId, + int Quantity + ); [Mapper] public partial class Mapper @@ -70,8 +77,11 @@ public static IEndpointRouteBuilder MapAddItem(this IEndpointRouteBuilder endpoi { endpoints.MapPost( "/", - (AddItem.Command command, AddItem.Handler handler, CancellationToken cancellationToken) - => handler.HandleAsync(command, cancellationToken) + async (AddItem.Command command, AddItem.Handler handler, CancellationToken cancellationToken) => + { + var result = await handler.HandleAsync(command, cancellationToken); + return TypedResults.Created($"/products/{result.ProductId}", result); + } ); return endpoints; } From 4baba94ea4b195e8d0820c948b0f04f909bacff4 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Tue, 15 Aug 2023 00:33:41 -0400 Subject: [PATCH 19/25] Cleanup and fine-tuning --- .../Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs | 5 +---- C18/REPR/Web.Tests/WebApplication.cs | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs index 731e784..fb1a2f7 100644 --- a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs @@ -10,11 +10,10 @@ public partial class BasketsTest public class AddItemTest { [Fact] - public async Task Should_add_the_new_item_to_the_cart() + public async Task Should_add_the_new_item_to_the_basket() { // Arrange await using var application = new C18WebApplication(); - await application.SeedAsync(SeederDelegateAsync); var client = application.CreateClient(); // Act @@ -68,7 +67,6 @@ public async Task Should_return_a_valid_product_url() Assert.True(productResponse.IsSuccessStatusCode); } - [Fact] public async Task Should_return_a_ProblemDetails_with_a_Conflict_status_code() { @@ -98,6 +96,5 @@ public async Task Should_return_a_ProblemDetails_with_a_Conflict_status_code() Assert.NotNull(dbItem); Assert.Equal(10, dbItem.Quantity); } - } } diff --git a/C18/REPR/Web.Tests/WebApplication.cs b/C18/REPR/Web.Tests/WebApplication.cs index e1b4d72..9cb5421 100644 --- a/C18/REPR/Web.Tests/WebApplication.cs +++ b/C18/REPR/Web.Tests/WebApplication.cs @@ -51,5 +51,4 @@ public Task SeedAsync(Func seeder) var db = seedScope.ServiceProvider.GetRequiredService(); return seeder(db); } - } \ No newline at end of file From 685e411faf4c2c9b0685392ff8d91552aeb34dde Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Wed, 16 Aug 2023 22:10:47 -0400 Subject: [PATCH 20/25] Add sequence diagram --- Diagrams/REPR.drawio | 92 +++++++++++++++++++++++++++++++++++++++++++- Diagrams/REPR.svg | 2 +- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/Diagrams/REPR.drawio b/Diagrams/REPR.drawio index a721679..cde548c 100644 --- a/Diagrams/REPR.drawio +++ b/Diagrams/REPR.drawio @@ -1,9 +1,27 @@ - + + + + + + + + + + + + + + + + + + + @@ -402,6 +420,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Diagrams/REPR.svg b/Diagrams/REPR.svg index 360e5cd..b1be5f3 100644 --- a/Diagrams/REPR.svg +++ b/Diagrams/REPR.svg @@ -1 +1 @@ -
Request 
Request 
Input
Input
 Endpoint
 Endpoint
Logic
Logic
 Response
 Response
Output
Output
600 px MAX width
600 px MAX width
Root
Root
Areas
Areas
Features
Features
Features
Features
Products
Products
Baskets
Baskets
AddItem
AddItem
RemoveItem
RemoveItem
FetchItems
FetchItems
UpdateQuantity
UpdateQuantity
FetchAll
FetchAll
FetchOne
FetchOne
Program
Program
AddFeatures
AddFeatures
MapFeatures
MapFeatures
SeedFeaturesAsync
SeedFeaturesAsync
AddProductsFeature
AddProductsFeature
AddBasketsFeature
AddBasketsFeature
AddFetchAll
AddFetchAll
AddFetchOne
AddFetchOne
AddDbContext
AddDbContext
AddScoped<FetchAll.Handler>
AddScoped<FetchAll.Handler>
AddSingleton<FetchAll.Mapper>
AddSingleton<FetchAll.Mapper>
600 px MAX width
600 px MAX width
Program
Program
AddFeatures
AddFeatures
MapFeatures
MapFeatures
SeedFeaturesAsync
SeedFeaturesAsync
AddProductsFeature
AddProductsFeature
AddBasketsFeature
AddBasketsFeature
AddFetchAll
AddFetchAll
AddFetchOne
AddFetchOne
AddDbContext
AddDbContext
Program.cs
Program.cs
Features.cs
AddFeatures
Features.cs...
Features.cs
MapFeatures
Features.cs...
Features.cs
SeedFeaturesAsync
Features.cs...
Products.cs
AddProductsFeature
Products.cs...
Baskets.cs
AddBasketsFeature
Baskets.cs...
Products.FetchAll.cs
AddFetchAll
Products.FetchAll.cs...
Products.FetchOne.cs
AddFetchOne
Products.FetchOne.cs...
Products.cs
ProductContext
Products.cs...
Baskets.AddItem.cs
AddAddItem
Baskets.AddItem.cs...
Baskets.FetchItems.cs
AddFetchItems
Baskets.FetchItems.cs...
Baskets.RemoveItem.cs
AddRemoveItem
Baskets.RemoveItem.cs...
Baskets.cs
BasketContext
Baskets.cs...
Baskets.UpdateQuantity.cs
AddUpdateQuantity
Baskets.UpdateQuantity.cs...
Products.cs
MapProductsFeature
Products.cs...
Products.cs
SeedProductsAsync
Products.cs...
Program.cs
Program.cs
Program.cs
Program.cs
Baskets.cs
MapBasketsFeature
Baskets.cs...
Products.FetchAll.cs
MapFetchAll
Products.FetchAll.cs...
Products.FetchOne.cs
MapFetchOne
Products.FetchOne.cs...
Baskets.FetchItems.cs
MapFetchItems
Baskets.FetchItems.cs...
Baskets.AddItem.cs
MapAddItem
Baskets.AddItem.cs...
Baskets.UpdateQuantity.cs
MapUpdateQuantity
Baskets.UpdateQuantity.cs...
Baskets.RemoveItem.cs
MapRemoveItem
Baskets.RemoveItem.cs...
Baskets.cs
SeedBasketsAsync
Baskets.cs...
Text is not SVG - cannot display
\ No newline at end of file +
ExceptionMapper
Middleware
ExceptionMapper...
handle the error
handle the error
Request 
Request 
Input
Input
 Endpoint
 Endpoint
Logic
Logic
 Response
 Response
Output
Output
600 px MAX width
600 px MAX width
Root
Root
Areas
Areas
Features
Features
Features
Features
Products
Products
Baskets
Baskets
AddItem
AddItem
RemoveItem
RemoveItem
FetchItems
FetchItems
UpdateQuantity
UpdateQuantity
FetchAll
FetchAll
FetchOne
FetchOne
Program
Program
AddFeatures
AddFeatures
MapFeatures
MapFeatures
SeedFeaturesAsync
SeedFeaturesAsync
AddProductsFeature
AddProductsFeature
AddBasketsFeature
AddBasketsFeature
AddFetchAll
AddFetchAll
AddFetchOne
AddFetchOne
AddDbContext
AddDbContext
AddScoped<FetchAll.Handler>
AddScoped<FetchAll.Handler>
AddSingleton<FetchAll.Mapper>
AddSingleton<FetchAll.Mapper>
600 px MAX width
600 px MAX width
Program
Program
AddFeatures
AddFeatures
MapFeatures
MapFeatures
SeedFeaturesAsync
SeedFeaturesAsync
AddProductsFeature
AddProductsFeature
AddBasketsFeature
AddBasketsFeature
AddFetchAll
AddFetchAll
AddFetchOne
AddFetchOne
AddDbContext
AddDbContext
Program.cs
Program.cs
Features.cs
AddFeatures
Features.cs...
Features.cs
MapFeatures
Features.cs...
Features.cs
SeedFeaturesAsync
Features.cs...
Products.cs
AddProductsFeature
Products.cs...
Baskets.cs
AddBasketsFeature
Baskets.cs...
Products.FetchAll.cs
AddFetchAll
Products.FetchAll.cs...
Products.FetchOne.cs
AddFetchOne
Products.FetchOne.cs...
Products.cs
ProductContext
Products.cs...
Baskets.AddItem.cs
AddAddItem
Baskets.AddItem.cs...
Baskets.FetchItems.cs
AddFetchItems
Baskets.FetchItems.cs...
Baskets.RemoveItem.cs
AddRemoveItem
Baskets.RemoveItem.cs...
Baskets.cs
BasketContext
Baskets.cs...
Baskets.UpdateQuantity.cs
AddUpdateQuantity
Baskets.UpdateQuantity.cs...
Products.cs
MapProductsFeature
Products.cs...
Products.cs
SeedProductsAsync
Products.cs...
Program.cs
Program.cs
Program.cs
Program.cs
Baskets.cs
MapBasketsFeature
Baskets.cs...
Products.FetchAll.cs
MapFetchAll
Products.FetchAll.cs...
Products.FetchOne.cs
MapFetchOne
Products.FetchOne.cs...
Baskets.FetchItems.cs
MapFetchItems
Baskets.FetchItems.cs...
Baskets.AddItem.cs
MapAddItem
Baskets.AddItem.cs...
Baskets.UpdateQuantity.cs
MapUpdateQuantity
Baskets.UpdateQuantity.cs...
Baskets.RemoveItem.cs
MapRemoveItem
Baskets.RemoveItem.cs...
Baskets.cs
SeedBasketsAsync
Baskets.cs...
ASP.NET Core Pipeline
ASP.NET Core Pip...
 AddItem Endpoint
 AddItem Endpoint
DuplicateBasketItemException
DuplicateBasketItemException
Baskets.AddItem.Handler.HandleAsync(command)
Baskets.AddItem...
throw DuplicateBasketItemException
throw DuplicateBasketItemException
InvokeAsync
InvokeAsync
return IResult
return IResult
Response 409
Response 409
POST /baskets
POST /baskets
Text is not SVG - cannot display
\ No newline at end of file From 559516295853f8e207ba615f325e926043c684f1 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Wed, 16 Aug 2023 22:11:39 -0400 Subject: [PATCH 21/25] Add MyExceptionMiddleware --- C18/REPR/Web/MyExceptionMiddleware.cs | 19 +++++++++++++++ C18/REPR/Web/Program.cs | 33 ++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 C18/REPR/Web/MyExceptionMiddleware.cs diff --git a/C18/REPR/Web/MyExceptionMiddleware.cs b/C18/REPR/Web/MyExceptionMiddleware.cs new file mode 100644 index 0000000..0df1291 --- /dev/null +++ b/C18/REPR/Web/MyExceptionMiddleware.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Diagnostics; + +namespace Web; + +public class MyExceptionMiddleware : IMiddleware +{ + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + var exceptionHandlerPathFeature = context.Features + .Get() ?? throw new NotSupportedException(); + + var exception = exceptionHandlerPathFeature.Error; + await context.Response.WriteAsJsonAsync(new + { + Error = exception.Message + }); + await next(context); + } +} diff --git a/C18/REPR/Web/Program.cs b/C18/REPR/Web/Program.cs index 39dce5c..305fa45 100644 --- a/C18/REPR/Web/Program.cs +++ b/C18/REPR/Web/Program.cs @@ -1,3 +1,9 @@ +#undef MY_EXCEPTION_MIDDLEWARE +#if MY_EXCEPTION_MIDDLEWARE +using Microsoft.AspNetCore.Diagnostics; +using Web; +#endif + using Microsoft.EntityFrameworkCore; using Web.Features; @@ -15,8 +21,34 @@ }); builder.AddFeatures(); +#if MY_EXCEPTION_MIDDLEWARE +builder.Services.AddSingleton(); +#endif + var app = builder.Build(); app.UseExceptionMapper(); + +#if MY_EXCEPTION_MIDDLEWARE +app.UseExceptionHandler(errorApp => +{ + errorApp.Use(async (context, next) => + { + var exceptionHandlerPathFeature = context.Features + .Get() ?? throw new NotSupportedException(); + var logger = context.RequestServices + .GetRequiredService() + .CreateLogger("ExceptionHandler"); + var exception = exceptionHandlerPathFeature.Error; + logger.LogWarning( + "An exception occurred: {message}", + exception.Message + ); + await next(context); + }); + errorApp.UseMiddleware(); +}); +#endif + app.MapFeatures(); await app.SeedFeaturesAsync(); @@ -27,4 +59,3 @@ // access it without granting internal visibility. #pragma warning disable CA1050 // Declare types in namespaces public partial class Program { } -#pragma warning restore CA1050 // Declare types in namespaces \ No newline at end of file From 0c53fee45222337d2fc48578a06731b11fa5c891 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Thu, 17 Aug 2023 21:34:07 -0400 Subject: [PATCH 22/25] Move MyExceptionMiddleware to Program.cs This allows excluding the class from the code coverage. --- C18/REPR/Web/MyExceptionMiddleware.cs | 19 ------------------- C18/REPR/Web/Program.cs | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 19 deletions(-) delete mode 100644 C18/REPR/Web/MyExceptionMiddleware.cs diff --git a/C18/REPR/Web/MyExceptionMiddleware.cs b/C18/REPR/Web/MyExceptionMiddleware.cs deleted file mode 100644 index 0df1291..0000000 --- a/C18/REPR/Web/MyExceptionMiddleware.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.AspNetCore.Diagnostics; - -namespace Web; - -public class MyExceptionMiddleware : IMiddleware -{ - public async Task InvokeAsync(HttpContext context, RequestDelegate next) - { - var exceptionHandlerPathFeature = context.Features - .Get() ?? throw new NotSupportedException(); - - var exception = exceptionHandlerPathFeature.Error; - await context.Response.WriteAsJsonAsync(new - { - Error = exception.Message - }); - await next(context); - } -} diff --git a/C18/REPR/Web/Program.cs b/C18/REPR/Web/Program.cs index 305fa45..2f5c51d 100644 --- a/C18/REPR/Web/Program.cs +++ b/C18/REPR/Web/Program.cs @@ -59,3 +59,21 @@ // access it without granting internal visibility. #pragma warning disable CA1050 // Declare types in namespaces public partial class Program { } + +#if MY_EXCEPTION_MIDDLEWARE +public class MyExceptionMiddleware : IMiddleware +{ + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + var exceptionHandlerPathFeature = context.Features + .Get() ?? throw new NotSupportedException(); + + var exception = exceptionHandlerPathFeature.Error; + await context.Response.WriteAsJsonAsync(new + { + Error = exception.Message + }); + await next(context); + } +} +#endif \ No newline at end of file From 99d7c7f7e6c704603324760f037bc0ef5c88b7b2 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Thu, 17 Aug 2023 21:35:33 -0400 Subject: [PATCH 23/25] Remove SeederDelegateAsync method Move the test data into the tests for better readability. Improve the tests. --- .../Baskets/BasketsTest.AddItemTest.cs | 23 ++++++++-- .../Baskets/BasketsTest.FetchItemsTest.cs | 20 +++++++-- .../Baskets/BasketsTest.RemoveItemTest.cs | 15 +++++-- .../Baskets/BasketsTest.UpdateQuantityTest.cs | 45 ++++++++++++++----- .../Web.Tests/Features/Baskets/BasketsTest.cs | 19 -------- 5 files changed, 81 insertions(+), 41 deletions(-) delete mode 100644 C18/REPR/Web.Tests/Features/Baskets/BasketsTest.cs diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs index fb1a2f7..cae11b7 100644 --- a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs @@ -72,26 +72,41 @@ public async Task Should_return_a_ProblemDetails_with_a_Conflict_status_code() { // Arrange await using var application = new C18WebApplication(); - await application.SeedAsync(SeederDelegateAsync); + await application.SeedAsync(async db => + { + db.Items.RemoveRange(db.Items); + db.Items.Add(new( + CustomerId: 1, + ProductId: 1, + Quantity: 10 + )); + await db.SaveChangesAsync(); + }); var client = application.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/baskets", - new AddItem.Command(1, 1, 20) + new AddItem.Command( + CustomerId: 1, + ProductId: 1, + Quantity: 20 + ) ); // Assert the response Assert.NotNull(response); Assert.False(response.IsSuccessStatusCode); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); - var problem = await response.Content.ReadFromJsonAsync(); + var problem = await response.Content + .ReadFromJsonAsync(); Assert.NotNull(problem); Assert.Equal("The product \u00271\u0027 is already in your shopping cart.", problem.Title); // Assert the database state using var seedScope = application.Services.CreateScope(); - var db = seedScope.ServiceProvider.GetRequiredService(); + var db = seedScope.ServiceProvider + .GetRequiredService(); var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 1 && x.ProductId == 1); Assert.NotNull(dbItem); Assert.Equal(10, dbItem.Quantity); diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.FetchItemsTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.FetchItemsTest.cs index 7f74616..6a072ac 100644 --- a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.FetchItemsTest.cs +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.FetchItemsTest.cs @@ -11,11 +11,18 @@ public async Task Should_return_the_specified_customer_items() { // Arrange await using var application = new C18WebApplication(); - await application.SeedAsync(SeederDelegateAsync); + await application.SeedAsync(async (db) => + { + db.Items.RemoveRange(db.Items.ToArray()); + db.Items.Add(new BasketItem(2, 1, 5)); + db.Items.Add(new BasketItem(2, 3, 15)); + await db.SaveChangesAsync(); + }); var client = application.CreateClient(); // Act - var response = await client.GetFromJsonAsync>("/baskets/2"); + var response = await client + .GetFromJsonAsync>("/baskets/2"); // Assert Assert.NotNull(response); @@ -36,11 +43,16 @@ public async Task Should_return_an_empty_list_when_the_customer_have_no_item_in_ { // Arrange await using var application = new C18WebApplication(); - await application.SeedAsync(SeederDelegateAsync); + await application.SeedAsync(async (db) => + { + db.Items.RemoveRange(db.Items.ToArray()); + await db.SaveChangesAsync(); + }); var client = application.CreateClient(); // Act - var response = await client.GetFromJsonAsync>("/baskets/5"); + var response = await client + .GetFromJsonAsync>("/baskets/5"); // Assert Assert.NotNull(response); diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs index 099967c..96a921c 100644 --- a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs @@ -14,7 +14,15 @@ public async Task Should_remove_the_specified_item_from_the_customer_cart() { // Arrange await using var application = new C18WebApplication(); - await application.SeedAsync(SeederDelegateAsync); + await application.SeedAsync(async db => + { + db.Items.RemoveRange(db.Items.ToArray()); + db.Items.Add(new BasketItem(1, 3, 30)); + db.Items.Add(new BasketItem(2, 1, 5)); + db.Items.Add(new BasketItem(2, 3, 15)); + db.Items.Add(new BasketItem(3, 2, 18)); + await db.SaveChangesAsync(); + }); var client = application.CreateClient(); // Act @@ -32,15 +40,15 @@ public async Task Should_remove_the_specified_item_from_the_customer_cart() var db = seedScope.ServiceProvider.GetRequiredService(); var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 2 && x.ProductId == 1); Assert.Null(dbItem); + var remainingItems = db.Items.Count(); + Assert.Equal(3, remainingItems); } - [Fact] public async Task Should_return_a_ProblemDetails_with_a_NotFound_status_code() { // Arrange await using var application = new C18WebApplication(); - await application.SeedAsync(SeederDelegateAsync); var client = application.CreateClient(); // Act @@ -60,6 +68,5 @@ public async Task Should_return_a_ProblemDetails_with_a_NotFound_status_code() var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 99); Assert.Null(dbItem); } - } } diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs index d87ff86..9ce3699 100644 --- a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using System.Net; using System.Net.Http.Json; @@ -14,7 +15,14 @@ public async Task Should_update_the_item_quantity() { // Arrange await using var application = new C18WebApplication(); - await application.SeedAsync(SeederDelegateAsync); + await application.SeedAsync(async db => + { + db.Items.RemoveRange(db.Items.ToArray()); + db.Items.Add(new BasketItem(1, 3, 30)); + db.Items.Add(new BasketItem(2, 1, 5)); + db.Items.Add(new BasketItem(2, 3, 15)); + await db.SaveChangesAsync(); + }); var client = application.CreateClient(); // Act @@ -33,18 +41,26 @@ public async Task Should_update_the_item_quantity() // Assert the database state using var seedScope = application.Services.CreateScope(); var db = seedScope.ServiceProvider.GetRequiredService(); - var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 2 && x.ProductId == 1); - Assert.NotNull(dbItem); - Assert.Equal(25, dbItem.Quantity); - } + AssertProductQuantity(1, 3, 30); + AssertProductQuantity(2, 1, 25); + AssertProductQuantity(2, 3, 15); + void AssertProductQuantity(int customerId, int productId, int expectedQuantity) + { + var dbItem = db.Items.FirstOrDefault( + x => x.CustomerId == customerId && + x.ProductId == productId + ); + Assert.NotNull(dbItem); + Assert.Equal(expectedQuantity, dbItem.Quantity); + } + } [Fact] public async Task Should_return_a_ProblemDetails_with_a_NotFound_status_code() { // Arrange await using var application = new C18WebApplication(); - await application.SeedAsync(SeederDelegateAsync); var client = application.CreateClient(); // Act @@ -73,12 +89,21 @@ public async Task Should_not_touch_the_database_when_the_quantity_is_the_same() { // Arrange await using var application = new C18WebApplication(); - await application.SeedAsync(SeederDelegateAsync); + await application.SeedAsync(async db => + { + db.Items.RemoveRange(db.Items.ToArray()); + db.Items.Add(new BasketItem(2, 1, 5)); + await db.SaveChangesAsync(); + }); + using var seedScope = application.Services.CreateScope(); - var db = seedScope.ServiceProvider.GetRequiredService(); - var mapper = seedScope.ServiceProvider.GetRequiredService(); + var db = seedScope.ServiceProvider + .GetRequiredService(); + var mapper = seedScope.ServiceProvider + .GetRequiredService(); db.SavedChanges += Db_SavedChanges; var saved = false; + var sut = new UpdateQuantity.Handler(db, mapper); // Act @@ -91,7 +116,7 @@ public async Task Should_not_touch_the_database_when_the_quantity_is_the_same() Assert.NotNull(response); Assert.False(saved); - void Db_SavedChanges(object? sender, Microsoft.EntityFrameworkCore.SavedChangesEventArgs e) + void Db_SavedChanges(object? sender, SavedChangesEventArgs e) { saved = true; } diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.cs deleted file mode 100644 index 8cb6ddd..0000000 --- a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.cs +++ /dev/null @@ -1,19 +0,0 @@ - -using static Web.Features.Baskets; - -namespace Web.Features; -public partial class BasketsTest -{ - private static async Task SeederDelegateAsync(BasketContext db) - { - db.Items.RemoveRange(db.Items.ToArray()); - await db.Items.AddAsync(new BasketItem(1, 1, 10)); - await db.Items.AddAsync(new BasketItem(1, 2, 20)); - await db.Items.AddAsync(new BasketItem(1, 3, 30)); - await db.Items.AddAsync(new BasketItem(2, 1, 5)); - await db.Items.AddAsync(new BasketItem(2, 3, 15)); - await db.Items.AddAsync(new BasketItem(3, 2, 18)); - await db.Items.AddAsync(new BasketItem(3, 4, 36)); - await db.SaveChangesAsync(); - } -} From 99a11c09f6e82b3f90ea64f454fb55b8182c2011 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Thu, 17 Aug 2023 21:35:59 -0400 Subject: [PATCH 24/25] Use the synchronous Add method --- C18/REPR/Web.Tests/Features/Products/ProductsTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs b/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs index 15b7fd6..3079b14 100644 --- a/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs +++ b/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs @@ -7,12 +7,12 @@ public partial class ProductsTest private static async Task SeederDelegateAsync(ProductContext db) { db.Products.RemoveRange(db.Products.ToArray()); - await db.Products.AddAsync(new Product( + db.Products.Add(new Product( Name: "Scotch Bottle", UnitPrice: 99, Id: 2 )); - await db.Products.AddAsync(new Product( + db.Products.Add(new Product( Name: "Habanero Pepper", UnitPrice: 1, Id: 3 From f6ad3df18f5e56eb42296d24030919edb579e5d4 Mon Sep 17 00:00:00 2001 From: Carl-Hugo Marcotte Date: Thu, 17 Aug 2023 21:36:12 -0400 Subject: [PATCH 25/25] Cleanup --- C18/REPR/README.md | 2 +- C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/C18/REPR/README.md b/C18/REPR/README.md index 495b0ab..cee099e 100644 --- a/C18/REPR/README.md +++ b/C18/REPR/README.md @@ -1,6 +1,6 @@ # Test coverage -The initial test coverage report indicates 96.6% Line Coverage and 63.1% Branch coverage. +The initial test coverage report indicates 97.2% Line Coverage and 63.1% Branch coverage for the Web assembly. Most of the not tested branches are related to the constructor injection guards that we have no unit tests for. ## How to collect code coverage diff --git a/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs b/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs index a3de6c4..4c11799 100644 --- a/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs +++ b/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs @@ -72,5 +72,4 @@ public static IEndpointRouteBuilder MapFetchItems(this IEndpointRouteBuilder end ); return endpoints; } - }