From 4ea2e29baef1c9ba4d1e456754aedffe24ac6fde Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:29:43 +0000 Subject: [PATCH] =?UTF-8?q?Add=20cross-service=20integration=20tests=20for?= =?UTF-8?q?=20Order=20=E2=86=92=20Product=20=E2=86=92=20Notification=20flo?= =?UTF-8?q?w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement Product service CRUD (entity, repository, controller with POST/GET) - Implement Order service with product validation via HTTP and event publishing to Notification service - Fix csproj Shared reference paths across all 5 services (..\..\Shared → ..\..\..\Shared) - Add docker-compose.integration.yml with health checks for test orchestration - Add xUnit integration test project (16 tests covering full flow): * Product creation and retrieval * Order placement with product validation * Notification event propagation verification * Product existence validation before order acceptance * Error scenarios: non-existent product, invalid customer, invalid quantity * Full E2E multi-order flow with distinct notifications - Install curl in service Dockerfiles for health check support --- src/Microservices.sln | 264 ++++++++- .../Customer/Customer.API/Customer.API.csproj | 4 +- .../Identity/Identity.API/Identity.API.csproj | 4 +- .../Notification/Notification.API/Dockerfile | 1 + .../Notification.API/Notification.API.csproj | 4 +- .../Order.API/Controllers/OrderController.cs | 147 ++++- src/Services/Order/Order.API/Dockerfile | 1 + src/Services/Order/Order.API/Order.API.csproj | 4 +- src/Services/Order/Order.API/Program.cs | 22 + .../Order/Order.Domain/Entities/.gitkeep | 0 .../Order.Domain/Entities/OrderEntity.cs | 23 + .../Order/Order.Domain/Interfaces/.gitkeep | 0 .../Interfaces/IOrderRepository.cs | 10 + .../Data/OrderDbContext.cs | 14 +- .../Repositories/OrderRepository.cs | 35 ++ .../Controllers/ProductController.cs | 78 ++- src/Services/Product/Product.API/Dockerfile | 1 + .../Product/Product.API/Product.API.csproj | 4 +- src/Services/Product/Product.API/Program.cs | 10 + .../Product/Product.Domain/Entities/.gitkeep | 0 .../Product.Domain/Entities/Product.cs | 11 + .../Product.Domain/Interfaces/.gitkeep | 0 .../Interfaces/IProductRepository.cs | 10 + .../Data/ProductDbContext.cs | 12 +- .../Repositories/ProductRepository.cs | 35 ++ src/Tests/Integration/DockerComposeFixture.cs | 123 +++++ .../Integration/Integration.Tests.csproj | 17 + .../OrderProductNotificationFlowTests.cs | 518 ++++++++++++++++++ .../Integration/TestPriorityAttribute.cs | 40 ++ src/docker-compose.integration.yml | 89 +++ 30 files changed, 1439 insertions(+), 42 deletions(-) delete mode 100644 src/Services/Order/Order.Domain/Entities/.gitkeep create mode 100644 src/Services/Order/Order.Domain/Entities/OrderEntity.cs delete mode 100644 src/Services/Order/Order.Domain/Interfaces/.gitkeep create mode 100644 src/Services/Order/Order.Domain/Interfaces/IOrderRepository.cs create mode 100644 src/Services/Order/Order.Infrastructure/Repositories/OrderRepository.cs delete mode 100644 src/Services/Product/Product.Domain/Entities/.gitkeep create mode 100644 src/Services/Product/Product.Domain/Entities/Product.cs delete mode 100644 src/Services/Product/Product.Domain/Interfaces/.gitkeep create mode 100644 src/Services/Product/Product.Domain/Interfaces/IProductRepository.cs create mode 100644 src/Services/Product/Product.Infrastructure/Repositories/ProductRepository.cs create mode 100644 src/Tests/Integration/DockerComposeFixture.cs create mode 100644 src/Tests/Integration/Integration.Tests.csproj create mode 100644 src/Tests/Integration/OrderProductNotificationFlowTests.cs create mode 100644 src/Tests/Integration/TestPriorityAttribute.cs create mode 100644 src/docker-compose.integration.yml diff --git a/src/Microservices.sln b/src/Microservices.sln index bf2eaa1..0eea31d 100644 --- a/src/Microservices.sln +++ b/src/Microservices.sln @@ -1,12 +1,10 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{A1B2C3D4-0001-0000-0000-000000000001}" EndProject - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{A1B2C3D4-0002-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Identity.API", "Services\Identity\Identity.API\Identity.API.csproj", "{B1000001-0000-0000-0000-000000000001}" @@ -15,7 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Identity.Domain", "Services EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Identity.Infrastructure", "Services\Identity\Identity.Infrastructure\Identity.Infrastructure.csproj", "{B1000003-0000-0000-0000-000000000001}" EndProject - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Customer", "Customer", "{A1B2C3D4-0003-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Customer.API", "Services\Customer\Customer.API\Customer.API.csproj", "{B2000001-0000-0000-0000-000000000001}" @@ -24,7 +21,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Customer.Domain", "Services EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Customer.Infrastructure", "Services\Customer\Customer.Infrastructure\Customer.Infrastructure.csproj", "{B2000003-0000-0000-0000-000000000001}" EndProject - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Order", "Order", "{A1B2C3D4-0004-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Order.API", "Services\Order\Order.API\Order.API.csproj", "{B3000001-0000-0000-0000-000000000001}" @@ -33,7 +29,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Order.Domain", "Services\Or EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Order.Infrastructure", "Services\Order\Order.Infrastructure\Order.Infrastructure.csproj", "{B3000003-0000-0000-0000-000000000001}" EndProject - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Product", "Product", "{A1B2C3D4-0005-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Product.API", "Services\Product\Product.API\Product.API.csproj", "{B4000001-0000-0000-0000-000000000001}" @@ -42,7 +37,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Product.Domain", "Services\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Product.Infrastructure", "Services\Product\Product.Infrastructure\Product.Infrastructure.csproj", "{B4000003-0000-0000-0000-000000000001}" EndProject - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Notification", "Notification", "{A1B2C3D4-0006-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notification.API", "Services\Notification\Notification.API\Notification.API.csproj", "{B5000001-0000-0000-0000-000000000001}" @@ -51,42 +45,284 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notification.Domain", "Serv EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notification.Infrastructure", "Services\Notification\Notification.Infrastructure\Notification.Infrastructure.csproj", "{B5000003-0000-0000-0000-000000000001}" EndProject - Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiGateway", "ApiGateway\ApiGateway.csproj", "{C0000001-0000-0000-0000-000000000001}" EndProject - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{A1B2C3D4-0007-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Contracts", "Shared\Shared.Contracts\Shared.Contracts.csproj", "{D0000001-0000-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Infrastructure", "Shared\Shared.Infrastructure\Shared.Infrastructure.csproj", "{D0000002-0000-0000-0000-000000000001}" EndProject - +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Integration", "Integration", "{28A6993C-471E-82FE-7D9E-AD3B1EC22BD9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Integration.Tests", "Tests\Integration\Integration.Tests.csproj", "{2235899F-6390-4820-A7D6-B1B67819B88B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B1000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B1000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B1000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B1000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B1000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B1000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B1000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B1000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1000002-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1000002-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B1000002-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1000002-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B1000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B1000002-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B1000002-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B1000002-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B1000002-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B1000003-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1000003-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1000003-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1000003-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B1000003-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1000003-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B1000003-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1000003-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B1000003-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B1000003-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B1000003-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B1000003-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B2000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2000002-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2000002-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B2000002-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2000002-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B2000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B2000002-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B2000002-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B2000002-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B2000002-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B2000003-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2000003-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2000003-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2000003-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B2000003-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2000003-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B2000003-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2000003-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B2000003-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B2000003-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B2000003-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B2000003-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B3000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B3000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B3000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B3000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B3000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B3000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B3000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B3000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B3000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B3000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3000002-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B3000002-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B3000002-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B3000002-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B3000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B3000002-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B3000002-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B3000002-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B3000002-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B3000003-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3000003-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3000003-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B3000003-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B3000003-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B3000003-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B3000003-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3000003-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B3000003-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B3000003-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B3000003-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B3000003-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4000002-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4000002-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4000002-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4000002-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4000002-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4000002-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4000002-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4000002-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4000003-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4000003-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4000003-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4000003-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4000003-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4000003-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4000003-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4000003-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4000003-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4000003-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4000003-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4000003-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B5000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B5000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B5000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B5000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B5000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B5000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B5000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B5000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B5000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5000002-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5000002-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B5000002-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B5000002-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B5000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B5000002-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B5000002-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B5000002-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B5000002-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B5000003-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5000003-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5000003-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5000003-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B5000003-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B5000003-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B5000003-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5000003-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B5000003-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B5000003-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B5000003-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B5000003-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {C0000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {C0000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {C0000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {C0000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {C0000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {C0000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {C0000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {C0000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {C0000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {D0000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {D0000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {D0000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {D0000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {D0000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {D0000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {D0000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {D0000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0000002-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0000002-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {D0000002-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0000002-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {D0000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {D0000002-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {D0000002-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {D0000002-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {D0000002-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {2235899F-6390-4820-A7D6-B1B67819B88B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2235899F-6390-4820-A7D6-B1B67819B88B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2235899F-6390-4820-A7D6-B1B67819B88B}.Debug|x64.ActiveCfg = Debug|Any CPU + {2235899F-6390-4820-A7D6-B1B67819B88B}.Debug|x64.Build.0 = Debug|Any CPU + {2235899F-6390-4820-A7D6-B1B67819B88B}.Debug|x86.ActiveCfg = Debug|Any CPU + {2235899F-6390-4820-A7D6-B1B67819B88B}.Debug|x86.Build.0 = Debug|Any CPU + {2235899F-6390-4820-A7D6-B1B67819B88B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2235899F-6390-4820-A7D6-B1B67819B88B}.Release|Any CPU.Build.0 = Release|Any CPU + {2235899F-6390-4820-A7D6-B1B67819B88B}.Release|x64.ActiveCfg = Release|Any CPU + {2235899F-6390-4820-A7D6-B1B67819B88B}.Release|x64.Build.0 = Release|Any CPU + {2235899F-6390-4820-A7D6-B1B67819B88B}.Release|x86.ActiveCfg = Release|Any CPU + {2235899F-6390-4820-A7D6-B1B67819B88B}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {A1B2C3D4-0002-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} - {A1B2C3D4-0003-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} - {A1B2C3D4-0004-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} - {A1B2C3D4-0005-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} - {A1B2C3D4-0006-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} {B1000001-0000-0000-0000-000000000001} = {A1B2C3D4-0002-0000-0000-000000000001} {B1000002-0000-0000-0000-000000000001} = {A1B2C3D4-0002-0000-0000-000000000001} {B1000003-0000-0000-0000-000000000001} = {A1B2C3D4-0002-0000-0000-000000000001} + {A1B2C3D4-0003-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} {B2000001-0000-0000-0000-000000000001} = {A1B2C3D4-0003-0000-0000-000000000001} {B2000002-0000-0000-0000-000000000001} = {A1B2C3D4-0003-0000-0000-000000000001} {B2000003-0000-0000-0000-000000000001} = {A1B2C3D4-0003-0000-0000-000000000001} + {A1B2C3D4-0004-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} {B3000001-0000-0000-0000-000000000001} = {A1B2C3D4-0004-0000-0000-000000000001} {B3000002-0000-0000-0000-000000000001} = {A1B2C3D4-0004-0000-0000-000000000001} {B3000003-0000-0000-0000-000000000001} = {A1B2C3D4-0004-0000-0000-000000000001} + {A1B2C3D4-0005-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} {B4000001-0000-0000-0000-000000000001} = {A1B2C3D4-0005-0000-0000-000000000001} {B4000002-0000-0000-0000-000000000001} = {A1B2C3D4-0005-0000-0000-000000000001} {B4000003-0000-0000-0000-000000000001} = {A1B2C3D4-0005-0000-0000-000000000001} + {A1B2C3D4-0006-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} {B5000001-0000-0000-0000-000000000001} = {A1B2C3D4-0006-0000-0000-000000000001} {B5000002-0000-0000-0000-000000000001} = {A1B2C3D4-0006-0000-0000-000000000001} {B5000003-0000-0000-0000-000000000001} = {A1B2C3D4-0006-0000-0000-000000000001} + {28A6993C-471E-82FE-7D9E-AD3B1EC22BD9} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {2235899F-6390-4820-A7D6-B1B67819B88B} = {28A6993C-471E-82FE-7D9E-AD3B1EC22BD9} EndGlobalSection EndGlobal diff --git a/src/Services/Customer/Customer.API/Customer.API.csproj b/src/Services/Customer/Customer.API/Customer.API.csproj index 8c284d4..4a0b5b7 100644 --- a/src/Services/Customer/Customer.API/Customer.API.csproj +++ b/src/Services/Customer/Customer.API/Customer.API.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/src/Services/Identity/Identity.API/Identity.API.csproj b/src/Services/Identity/Identity.API/Identity.API.csproj index 9b3e932..c4e24d9 100644 --- a/src/Services/Identity/Identity.API/Identity.API.csproj +++ b/src/Services/Identity/Identity.API/Identity.API.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/src/Services/Notification/Notification.API/Dockerfile b/src/Services/Notification/Notification.API/Dockerfile index 252f690..b4c2752 100644 --- a/src/Services/Notification/Notification.API/Dockerfile +++ b/src/Services/Notification/Notification.API/Dockerfile @@ -18,6 +18,7 @@ FROM build AS publish RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false FROM base AS final +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=publish /app/publish . ENV ASPNETCORE_URLS=http://+:5005 diff --git a/src/Services/Notification/Notification.API/Notification.API.csproj b/src/Services/Notification/Notification.API/Notification.API.csproj index 25b5fb0..1518ca2 100644 --- a/src/Services/Notification/Notification.API/Notification.API.csproj +++ b/src/Services/Notification/Notification.API/Notification.API.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/src/Services/Order/Order.API/Controllers/OrderController.cs b/src/Services/Order/Order.API/Controllers/OrderController.cs index 1621bae..1e3ebc9 100644 --- a/src/Services/Order/Order.API/Controllers/OrderController.cs +++ b/src/Services/Order/Order.API/Controllers/OrderController.cs @@ -1,4 +1,7 @@ using Microsoft.AspNetCore.Mvc; +using Order.Domain.Entities; +using Order.Domain.Interfaces; +using Shared.Contracts.Events; namespace Order.API.Controllers; @@ -6,24 +9,152 @@ namespace Order.API.Controllers; [Route("api/[controller]")] public class OrderController : ControllerBase { + private readonly IOrderRepository _repository; + private readonly HttpClient _productClient; + private readonly HttpClient _notificationClient; private readonly ILogger _logger; - public OrderController(ILogger logger) + public OrderController( + IOrderRepository repository, + IHttpClientFactory httpClientFactory, + ILogger logger) { + _repository = repository; + _productClient = httpClientFactory.CreateClient("ProductService"); + _notificationClient = httpClientFactory.CreateClient("NotificationService"); _logger = logger; } [HttpGet] - public IActionResult GetAll() + public async Task GetAll() { - // TODO: Implement — migrate logic from monolith's OrderController - return Ok(new { service = "Order", status = "scaffold" }); + var orders = await _repository.GetAllAsync(); + return Ok(orders.Select(o => new + { + o.Id, + o.CustomerId, + o.ProductId, + o.ProductName, + o.Quantity, + o.UnitPrice, + o.TotalAmount, + o.Status, + o.CreatedAt + })); } - [HttpGet("{id}")] - public IActionResult GetById(int id) + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) { - // TODO: Implement — migrate logic from monolith - return Ok(new { service = "Order", id }); + var order = await _repository.GetByIdAsync(id); + if (order is null) + return NotFound(); + + return Ok(new + { + order.Id, + order.CustomerId, + order.ProductId, + order.ProductName, + order.Quantity, + order.UnitPrice, + order.TotalAmount, + order.Status, + order.CreatedAt + }); + } + + [HttpPost] + public async Task Create([FromBody] CreateOrderRequest request) + { + if (request.CustomerId == Guid.Empty) + return BadRequest(new { error = "A valid customer ID is required." }); + + if (request.ProductId == Guid.Empty) + return BadRequest(new { error = "A valid product ID is required." }); + + if (request.Quantity <= 0) + return BadRequest(new { error = "Quantity must be greater than zero." }); + + // Validate product exists by calling Product service + ProductDto? product; + try + { + var response = await _productClient.GetAsync($"/api/product/{request.ProductId}"); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Product {ProductId} not found", request.ProductId); + return BadRequest(new { error = $"Product {request.ProductId} does not exist." }); + } + product = await response.Content.ReadFromJsonAsync(); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to reach Product service"); + return StatusCode(503, new { error = "Product service is unavailable." }); + } + + if (product is null) + return BadRequest(new { error = $"Product {request.ProductId} does not exist." }); + + var totalAmount = product.Price * request.Quantity; + + var order = new OrderEntity + { + Id = Guid.NewGuid(), + CustomerId = request.CustomerId, + ProductId = request.ProductId, + ProductName = product.Name, + Quantity = request.Quantity, + UnitPrice = product.Price, + TotalAmount = totalAmount, + Status = OrderStatus.Confirmed, + CreatedAt = DateTime.UtcNow + }; + + await _repository.AddAsync(order); + _logger.LogInformation("Order {OrderId} created for customer {CustomerId}", order.Id, order.CustomerId); + + // Publish OrderPlacedEvent to Notification service + try + { + var orderEvent = new OrderPlacedEvent( + order.Id, + order.CustomerId, + order.TotalAmount, + order.CreatedAt); + + await _notificationClient.PostAsJsonAsync("/api/notification/events/order-placed", orderEvent); + _logger.LogInformation("OrderPlacedEvent published for order {OrderId}", order.Id); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Failed to publish OrderPlacedEvent for order {OrderId}", order.Id); + } + + return CreatedAtAction(nameof(GetById), new { id = order.Id }, new + { + order.Id, + order.CustomerId, + order.ProductId, + order.ProductName, + order.Quantity, + order.UnitPrice, + order.TotalAmount, + order.Status, + order.CreatedAt + }); } } + +public record CreateOrderRequest( + Guid CustomerId, + Guid ProductId, + int Quantity); + +public record ProductDto( + Guid Id, + string Name, + string? Description, + decimal Price, + int StockQuantity); diff --git a/src/Services/Order/Order.API/Dockerfile b/src/Services/Order/Order.API/Dockerfile index eb9131b..6d76933 100644 --- a/src/Services/Order/Order.API/Dockerfile +++ b/src/Services/Order/Order.API/Dockerfile @@ -18,6 +18,7 @@ FROM build AS publish RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false FROM base AS final +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=publish /app/publish . ENV ASPNETCORE_URLS=http://+:5003 diff --git a/src/Services/Order/Order.API/Order.API.csproj b/src/Services/Order/Order.API/Order.API.csproj index 54aad4b..12339d9 100644 --- a/src/Services/Order/Order.API/Order.API.csproj +++ b/src/Services/Order/Order.API/Order.API.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/src/Services/Order/Order.API/Program.cs b/src/Services/Order/Order.API/Program.cs index 4512675..46f7d25 100644 --- a/src/Services/Order/Order.API/Program.cs +++ b/src/Services/Order/Order.API/Program.cs @@ -1,4 +1,6 @@ +using Order.Domain.Interfaces; using Order.Infrastructure.Data; +using Order.Infrastructure.Repositories; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -11,8 +13,28 @@ builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped(); + +builder.Services.AddHttpClient("ProductService", client => +{ + var baseUrl = builder.Configuration["ServiceUrls:ProductService"] ?? "http://product-service:5004"; + client.BaseAddress = new Uri(baseUrl); +}); + +builder.Services.AddHttpClient("NotificationService", client => +{ + var baseUrl = builder.Configuration["ServiceUrls:NotificationService"] ?? "http://notification-service:5005"; + client.BaseAddress = new Uri(baseUrl); +}); + var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); +} + if (app.Environment.IsDevelopment()) { app.UseSwagger(); diff --git a/src/Services/Order/Order.Domain/Entities/.gitkeep b/src/Services/Order/Order.Domain/Entities/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Services/Order/Order.Domain/Entities/OrderEntity.cs b/src/Services/Order/Order.Domain/Entities/OrderEntity.cs new file mode 100644 index 0000000..6b187db --- /dev/null +++ b/src/Services/Order/Order.Domain/Entities/OrderEntity.cs @@ -0,0 +1,23 @@ +namespace Order.Domain.Entities; + +public class OrderEntity +{ + public Guid Id { get; set; } + public Guid CustomerId { get; set; } + public Guid ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public int Quantity { get; set; } + public decimal UnitPrice { get; set; } + public decimal TotalAmount { get; set; } + public OrderStatus Status { get; set; } + public DateTime CreatedAt { get; set; } +} + +public enum OrderStatus +{ + Pending, + Confirmed, + Shipped, + Delivered, + Cancelled +} diff --git a/src/Services/Order/Order.Domain/Interfaces/.gitkeep b/src/Services/Order/Order.Domain/Interfaces/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Services/Order/Order.Domain/Interfaces/IOrderRepository.cs b/src/Services/Order/Order.Domain/Interfaces/IOrderRepository.cs new file mode 100644 index 0000000..6dd0761 --- /dev/null +++ b/src/Services/Order/Order.Domain/Interfaces/IOrderRepository.cs @@ -0,0 +1,10 @@ +using Order.Domain.Entities; + +namespace Order.Domain.Interfaces; + +public interface IOrderRepository +{ + Task GetByIdAsync(Guid id); + Task> GetAllAsync(); + Task AddAsync(OrderEntity order); +} diff --git a/src/Services/Order/Order.Infrastructure/Data/OrderDbContext.cs b/src/Services/Order/Order.Infrastructure/Data/OrderDbContext.cs index 4f80d89..c61e654 100644 --- a/src/Services/Order/Order.Infrastructure/Data/OrderDbContext.cs +++ b/src/Services/Order/Order.Infrastructure/Data/OrderDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Order.Domain.Entities; namespace Order.Infrastructure.Data; @@ -8,9 +9,20 @@ public OrderDbContext(DbContextOptions options) : base(options) { } + public DbSet Orders => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - // TODO: Configure entity mappings migrated from monolith + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.ProductName).HasMaxLength(256); + entity.Property(e => e.UnitPrice).HasColumnType("decimal(18,2)"); + entity.Property(e => e.TotalAmount).HasColumnType("decimal(18,2)"); + entity.HasIndex(e => e.CustomerId); + entity.HasIndex(e => e.ProductId); + }); } } diff --git a/src/Services/Order/Order.Infrastructure/Repositories/OrderRepository.cs b/src/Services/Order/Order.Infrastructure/Repositories/OrderRepository.cs new file mode 100644 index 0000000..7ab6430 --- /dev/null +++ b/src/Services/Order/Order.Infrastructure/Repositories/OrderRepository.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Order.Domain.Entities; +using Order.Domain.Interfaces; +using Order.Infrastructure.Data; + +namespace Order.Infrastructure.Repositories; + +public class OrderRepository : IOrderRepository +{ + private readonly OrderDbContext _context; + + public OrderRepository(OrderDbContext context) + { + _context = context; + } + + public async Task GetByIdAsync(Guid id) + { + return await _context.Orders.FindAsync(id); + } + + public async Task> GetAllAsync() + { + return await _context.Orders + .OrderByDescending(o => o.CreatedAt) + .ToListAsync(); + } + + public async Task AddAsync(OrderEntity order) + { + _context.Orders.Add(order); + await _context.SaveChangesAsync(); + return order; + } +} diff --git a/src/Services/Product/Product.API/Controllers/ProductController.cs b/src/Services/Product/Product.API/Controllers/ProductController.cs index 710c58f..d446863 100644 --- a/src/Services/Product/Product.API/Controllers/ProductController.cs +++ b/src/Services/Product/Product.API/Controllers/ProductController.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Mvc; +using Product.Domain.Entities; +using Product.Domain.Interfaces; namespace Product.API.Controllers; @@ -6,24 +8,84 @@ namespace Product.API.Controllers; [Route("api/[controller]")] public class ProductController : ControllerBase { + private readonly IProductRepository _repository; private readonly ILogger _logger; - public ProductController(ILogger logger) + public ProductController(IProductRepository repository, ILogger logger) { + _repository = repository; _logger = logger; } [HttpGet] - public IActionResult GetAll() + public async Task GetAll() { - // TODO: Implement — migrate logic from monolith's ProductController - return Ok(new { service = "Product", status = "scaffold" }); + var products = await _repository.GetAllAsync(); + return Ok(products.Select(p => new + { + p.Id, + p.Name, + p.Description, + p.Price, + p.StockQuantity, + p.CreatedAt + })); } - [HttpGet("{id}")] - public IActionResult GetById(int id) + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) { - // TODO: Implement — migrate logic from monolith - return Ok(new { service = "Product", id }); + var product = await _repository.GetByIdAsync(id); + if (product is null) + return NotFound(); + + return Ok(new + { + product.Id, + product.Name, + product.Description, + product.Price, + product.StockQuantity, + product.CreatedAt + }); + } + + [HttpPost] + public async Task Create([FromBody] CreateProductRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest(new { error = "Product name is required." }); + + if (request.Price < 0) + return BadRequest(new { error = "Price must be non-negative." }); + + var product = new ProductEntity + { + Id = Guid.NewGuid(), + Name = request.Name, + Description = request.Description, + Price = request.Price, + StockQuantity = request.StockQuantity, + CreatedAt = DateTime.UtcNow + }; + + await _repository.AddAsync(product); + _logger.LogInformation("Created product {ProductId}: {ProductName}", product.Id, product.Name); + + return CreatedAtAction(nameof(GetById), new { id = product.Id }, new + { + product.Id, + product.Name, + product.Description, + product.Price, + product.StockQuantity, + product.CreatedAt + }); } } + +public record CreateProductRequest( + string Name, + string? Description, + decimal Price, + int StockQuantity); diff --git a/src/Services/Product/Product.API/Dockerfile b/src/Services/Product/Product.API/Dockerfile index 68798df..dbe96b6 100644 --- a/src/Services/Product/Product.API/Dockerfile +++ b/src/Services/Product/Product.API/Dockerfile @@ -18,6 +18,7 @@ FROM build AS publish RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false FROM base AS final +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=publish /app/publish . ENV ASPNETCORE_URLS=http://+:5004 diff --git a/src/Services/Product/Product.API/Product.API.csproj b/src/Services/Product/Product.API/Product.API.csproj index 68be876..5ff75f3 100644 --- a/src/Services/Product/Product.API/Product.API.csproj +++ b/src/Services/Product/Product.API/Product.API.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/src/Services/Product/Product.API/Program.cs b/src/Services/Product/Product.API/Program.cs index 73146ef..f9b5191 100644 --- a/src/Services/Product/Product.API/Program.cs +++ b/src/Services/Product/Product.API/Program.cs @@ -1,4 +1,6 @@ +using Product.Domain.Interfaces; using Product.Infrastructure.Data; +using Product.Infrastructure.Repositories; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -11,8 +13,16 @@ builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped(); + var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); +} + if (app.Environment.IsDevelopment()) { app.UseSwagger(); diff --git a/src/Services/Product/Product.Domain/Entities/.gitkeep b/src/Services/Product/Product.Domain/Entities/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Services/Product/Product.Domain/Entities/Product.cs b/src/Services/Product/Product.Domain/Entities/Product.cs new file mode 100644 index 0000000..052aad8 --- /dev/null +++ b/src/Services/Product/Product.Domain/Entities/Product.cs @@ -0,0 +1,11 @@ +namespace Product.Domain.Entities; + +public class ProductEntity +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public decimal Price { get; set; } + public int StockQuantity { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/src/Services/Product/Product.Domain/Interfaces/.gitkeep b/src/Services/Product/Product.Domain/Interfaces/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Services/Product/Product.Domain/Interfaces/IProductRepository.cs b/src/Services/Product/Product.Domain/Interfaces/IProductRepository.cs new file mode 100644 index 0000000..cb47df1 --- /dev/null +++ b/src/Services/Product/Product.Domain/Interfaces/IProductRepository.cs @@ -0,0 +1,10 @@ +using Product.Domain.Entities; + +namespace Product.Domain.Interfaces; + +public interface IProductRepository +{ + Task GetByIdAsync(Guid id); + Task> GetAllAsync(); + Task AddAsync(ProductEntity product); +} diff --git a/src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs b/src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs index 37cfb81..4a6967b 100644 --- a/src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs +++ b/src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Product.Domain.Entities; namespace Product.Infrastructure.Data; @@ -8,9 +9,18 @@ public ProductDbContext(DbContextOptions options) : base(optio { } + public DbSet Products => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - // TODO: Configure entity mappings migrated from monolith + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).HasMaxLength(256).IsRequired(); + entity.Property(e => e.Description).HasMaxLength(1024); + entity.Property(e => e.Price).HasColumnType("decimal(18,2)"); + }); } } diff --git a/src/Services/Product/Product.Infrastructure/Repositories/ProductRepository.cs b/src/Services/Product/Product.Infrastructure/Repositories/ProductRepository.cs new file mode 100644 index 0000000..e0259d1 --- /dev/null +++ b/src/Services/Product/Product.Infrastructure/Repositories/ProductRepository.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Product.Domain.Entities; +using Product.Domain.Interfaces; +using Product.Infrastructure.Data; + +namespace Product.Infrastructure.Repositories; + +public class ProductRepository : IProductRepository +{ + private readonly ProductDbContext _context; + + public ProductRepository(ProductDbContext context) + { + _context = context; + } + + public async Task GetByIdAsync(Guid id) + { + return await _context.Products.FindAsync(id); + } + + public async Task> GetAllAsync() + { + return await _context.Products + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(); + } + + public async Task AddAsync(ProductEntity product) + { + _context.Products.Add(product); + await _context.SaveChangesAsync(); + return product; + } +} diff --git a/src/Tests/Integration/DockerComposeFixture.cs b/src/Tests/Integration/DockerComposeFixture.cs new file mode 100644 index 0000000..dcabdb4 --- /dev/null +++ b/src/Tests/Integration/DockerComposeFixture.cs @@ -0,0 +1,123 @@ +using System.Diagnostics; +using System.Net; +using Xunit; + +namespace Integration.Tests; + +/// +/// xUnit collection fixture that starts the Docker Compose stack before tests +/// and tears it down afterward. Health checks are polled to ensure all services +/// are ready before any test runs. +/// +public class DockerComposeFixture : IAsyncLifetime +{ + private readonly string _composeFile; + + public HttpClient ProductClient { get; } + public HttpClient OrderClient { get; } + public HttpClient NotificationClient { get; } + + public DockerComposeFixture() + { + var srcDir = FindSrcDirectory(); + _composeFile = Path.Combine(srcDir, "docker-compose.integration.yml"); + + ProductClient = new HttpClient { BaseAddress = new Uri("http://localhost:5004") }; + OrderClient = new HttpClient { BaseAddress = new Uri("http://localhost:5003") }; + NotificationClient = new HttpClient { BaseAddress = new Uri("http://localhost:5005") }; + } + + public async Task InitializeAsync() + { + // Build and start the stack + await RunDockerCompose("up --build -d"); + + // Wait for all services to be healthy + await WaitForHealthy(ProductClient, "Product", "/healthz"); + await WaitForHealthy(OrderClient, "Order", "/healthz"); + await WaitForHealthy(NotificationClient, "Notification", "/healthz"); + } + + public async Task DisposeAsync() + { + ProductClient.Dispose(); + OrderClient.Dispose(); + NotificationClient.Dispose(); + + await RunDockerCompose("down -v --remove-orphans"); + } + + private async Task RunDockerCompose(string args) + { + var psi = new ProcessStartInfo + { + FileName = "docker", + Arguments = $"compose -f {_composeFile} {args}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var process = Process.Start(psi)!; + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + var stderr = await process.StandardError.ReadToEndAsync(); + var stdout = await process.StandardOutput.ReadToEndAsync(); + throw new InvalidOperationException( + $"docker compose {args} failed (exit {process.ExitCode}):\n{stderr}\n{stdout}"); + } + } + + private static async Task WaitForHealthy(HttpClient client, string serviceName, string healthPath) + { + var timeout = TimeSpan.FromMinutes(3); + var interval = TimeSpan.FromSeconds(3); + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + try + { + var response = await client.GetAsync(healthPath); + if (response.StatusCode == HttpStatusCode.OK) + { + Console.WriteLine($"[IntegrationTests] {serviceName} service is healthy"); + return; + } + } + catch + { + // Service not yet reachable + } + + await Task.Delay(interval); + } + + throw new TimeoutException($"{serviceName} service did not become healthy within {timeout.TotalSeconds}s"); + } + + private static string FindSrcDirectory() + { + var dir = AppContext.BaseDirectory; + while (dir is not null) + { + if (File.Exists(Path.Combine(dir, "docker-compose.integration.yml"))) + return dir; + if (File.Exists(Path.Combine(dir, "Microservices.sln"))) + return dir; + dir = Directory.GetParent(dir)?.FullName; + } + + // Fallback: relative to repo root + var repoRoot = Environment.GetEnvironmentVariable("REPO_ROOT") + ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + return repoRoot; + } +} + +[CollectionDefinition("DockerCompose")] +public class DockerComposeCollection : ICollectionFixture +{ +} diff --git a/src/Tests/Integration/Integration.Tests.csproj b/src/Tests/Integration/Integration.Tests.csproj new file mode 100644 index 0000000..c07017a --- /dev/null +++ b/src/Tests/Integration/Integration.Tests.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + false + true + + + + + + + + + + diff --git a/src/Tests/Integration/OrderProductNotificationFlowTests.cs b/src/Tests/Integration/OrderProductNotificationFlowTests.cs new file mode 100644 index 0000000..f6ac695 --- /dev/null +++ b/src/Tests/Integration/OrderProductNotificationFlowTests.cs @@ -0,0 +1,518 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Xunit; + +namespace Integration.Tests; + +/// +/// Cross-service integration tests for the Order → Product → Notification flow. +/// Requires all three services running via Docker Compose. +/// +[Collection("DockerCompose")] +[TestCaseOrderer("Integration.Tests.PriorityOrderer", "Integration.Tests")] +public class OrderProductNotificationFlowTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly HttpClient _productClient; + private readonly HttpClient _orderClient; + private readonly HttpClient _notificationClient; + + public OrderProductNotificationFlowTests(DockerComposeFixture fixture) + { + _productClient = fixture.ProductClient; + _orderClient = fixture.OrderClient; + _notificationClient = fixture.NotificationClient; + } + + // ────────────────────────────────────────────────────────────── + // Test 1: Create a product via the Product service + // ────────────────────────────────────────────────────────────── + + [Fact, TestPriority(1)] + public async Task CreateProduct_ReturnsCreatedWithProductData() + { + var request = new + { + Name = "Integration Test Widget", + Description = "A widget created during integration testing", + Price = 2999m, // $29.99 in cents + StockQuantity = 100 + }; + + var response = await _productClient.PostAsJsonAsync("/api/product", request); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var product = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(product); + Assert.NotEqual(Guid.Empty, product.Id); + Assert.Equal("Integration Test Widget", product.Name); + Assert.Equal(2999m, product.Price); + Assert.Equal(100, product.StockQuantity); + } + + [Fact, TestPriority(2)] + public async Task GetProduct_ReturnsCreatedProduct() + { + // Create a product first + var createResponse = await _productClient.PostAsJsonAsync("/api/product", new + { + Name = "Retrievable Widget", + Description = "For GET test", + Price = 1500m, + StockQuantity = 50 + }); + var created = await createResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(created); + + // Retrieve it + var getResponse = await _productClient.GetAsync($"/api/product/{created.Id}"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + var product = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(product); + Assert.Equal(created.Id, product.Id); + Assert.Equal("Retrievable Widget", product.Name); + } + + // ────────────────────────────────────────────────────────────── + // Test 2: Place an order referencing the product + // ────────────────────────────────────────────────────────────── + + [Fact, TestPriority(3)] + public async Task PlaceOrder_WithValidProduct_ReturnsCreatedOrder() + { + // Step 1: Create a product + var productResponse = await _productClient.PostAsJsonAsync("/api/product", new + { + Name = "Orderable Widget", + Description = "Product for order flow test", + Price = 5000m, + StockQuantity = 200 + }); + var product = await productResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(product); + + var customerId = Guid.NewGuid(); + + // Step 2: Place an order + var orderResponse = await _orderClient.PostAsJsonAsync("/api/order", new + { + CustomerId = customerId, + ProductId = product.Id, + Quantity = 3 + }); + + Assert.Equal(HttpStatusCode.Created, orderResponse.StatusCode); + + var order = await orderResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(order); + Assert.NotEqual(Guid.Empty, order.Id); + Assert.Equal(customerId, order.CustomerId); + Assert.Equal(product.Id, order.ProductId); + Assert.Equal("Orderable Widget", order.ProductName); + Assert.Equal(3, order.Quantity); + Assert.Equal(5000m, order.UnitPrice); + Assert.Equal(15000m, order.TotalAmount); // 5000 * 3 + } + + [Fact, TestPriority(4)] + public async Task GetOrder_ReturnsPlacedOrder() + { + // Create product + order + var productResponse = await _productClient.PostAsJsonAsync("/api/product", new + { + Name = "GetOrder Widget", + Price = 1000m, + StockQuantity = 10 + }); + var product = await productResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(product); + + var orderResponse = await _orderClient.PostAsJsonAsync("/api/order", new + { + CustomerId = Guid.NewGuid(), + ProductId = product.Id, + Quantity = 1 + }); + var created = await orderResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(created); + + // Retrieve it + var getResponse = await _orderClient.GetAsync($"/api/order/{created.Id}"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + var order = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(order); + Assert.Equal(created.Id, order.Id); + } + + // ────────────────────────────────────────────────────────────── + // Test 3: Verify the Notification service received the event + // ────────────────────────────────────────────────────────────── + + [Fact, TestPriority(5)] + public async Task PlaceOrder_TriggersNotification_WithCorrectData() + { + // Step 1: Create a product + var productResponse = await _productClient.PostAsJsonAsync("/api/product", new + { + Name = "Notification Widget", + Description = "Product for notification flow test", + Price = 7500m, + StockQuantity = 50 + }); + var product = await productResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(product); + + var customerId = Guid.NewGuid(); + + // Step 2: Place an order (this should trigger notification) + var orderResponse = await _orderClient.PostAsJsonAsync("/api/order", new + { + CustomerId = customerId, + ProductId = product.Id, + Quantity = 2 + }); + var order = await orderResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(order); + + // Step 3: Poll notifications until the order-placed notification appears + var notification = await PollForNotification(order.Id, timeout: TimeSpan.FromSeconds(15)); + + Assert.NotNull(notification); + Assert.Equal(order.Id, notification.OrderId); + Assert.Equal(customerId, notification.CustomerId); + Assert.Equal(15000m, notification.OrderTotal); // 7500 * 2 + Assert.Equal(1, notification.Status); // NotificationStatus.Rendered + Assert.NotNull(notification.RenderedSubject); + Assert.Contains("Order Confirmed", notification.RenderedSubject); + } + + [Fact, TestPriority(6)] + public async Task Notification_HasRenderedEmailPreview() + { + // Create product + order + var productResponse = await _productClient.PostAsJsonAsync("/api/product", new + { + Name = "Preview Widget", + Price = 4200m, + StockQuantity = 10 + }); + var product = await productResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(product); + + var orderResponse = await _orderClient.PostAsJsonAsync("/api/order", new + { + CustomerId = Guid.NewGuid(), + ProductId = product.Id, + Quantity = 1 + }); + var order = await orderResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(order); + + // Find the notification + var notification = await PollForNotification(order.Id, timeout: TimeSpan.FromSeconds(15)); + Assert.NotNull(notification); + + // Fetch the rendered preview + var previewResponse = await _notificationClient.GetAsync($"/api/notification/{notification.Id}/preview"); + Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); + + var html = await previewResponse.Content.ReadAsStringAsync(); + Assert.Contains("Order Confirmed", html); + Assert.Contains("", html); + } + + // ────────────────────────────────────────────────────────────── + // Test 4: Order service validates product existence + // ────────────────────────────────────────────────────────────── + + [Fact, TestPriority(7)] + public async Task PlaceOrder_WithNonExistentProduct_ReturnsBadRequest() + { + var fakeProductId = Guid.NewGuid(); + + var response = await _orderClient.PostAsJsonAsync("/api/order", new + { + CustomerId = Guid.NewGuid(), + ProductId = fakeProductId, + Quantity = 1 + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("does not exist", body); + } + + [Fact, TestPriority(8)] + public async Task PlaceOrder_ProductValidation_UsesCorrectProductData() + { + // Create a product with a specific price + var productResponse = await _productClient.PostAsJsonAsync("/api/product", new + { + Name = "Validation Widget", + Price = 3333m, + StockQuantity = 5 + }); + var product = await productResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(product); + + // Place an order — total should be computed from the actual product price + var orderResponse = await _orderClient.PostAsJsonAsync("/api/order", new + { + CustomerId = Guid.NewGuid(), + ProductId = product.Id, + Quantity = 4 + }); + var order = await orderResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(order); + + Assert.Equal(3333m, order.UnitPrice); + Assert.Equal(13332m, order.TotalAmount); // 3333 * 4 + Assert.Equal("Validation Widget", order.ProductName); + } + + // ────────────────────────────────────────────────────────────── + // Test 5: Error scenarios + // ────────────────────────────────────────────────────────────── + + [Fact, TestPriority(9)] + public async Task PlaceOrder_WithEmptyCustomerId_ReturnsBadRequest() + { + // Create a valid product first + var productResponse = await _productClient.PostAsJsonAsync("/api/product", new + { + Name = "Error Test Widget", + Price = 100m, + StockQuantity = 10 + }); + var product = await productResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(product); + + var response = await _orderClient.PostAsJsonAsync("/api/order", new + { + CustomerId = Guid.Empty, + ProductId = product.Id, + Quantity = 1 + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("customer", body, StringComparison.OrdinalIgnoreCase); + } + + [Fact, TestPriority(10)] + public async Task PlaceOrder_WithZeroQuantity_ReturnsBadRequest() + { + var productResponse = await _productClient.PostAsJsonAsync("/api/product", new + { + Name = "Quantity Test Widget", + Price = 100m, + StockQuantity = 10 + }); + var product = await productResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(product); + + var response = await _orderClient.PostAsJsonAsync("/api/order", new + { + CustomerId = Guid.NewGuid(), + ProductId = product.Id, + Quantity = 0 + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("Quantity", body, StringComparison.OrdinalIgnoreCase); + } + + [Fact, TestPriority(11)] + public async Task PlaceOrder_WithNegativeQuantity_ReturnsBadRequest() + { + var productResponse = await _productClient.PostAsJsonAsync("/api/product", new + { + Name = "Negative Qty Widget", + Price = 100m, + StockQuantity = 10 + }); + var product = await productResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(product); + + var response = await _orderClient.PostAsJsonAsync("/api/order", new + { + CustomerId = Guid.NewGuid(), + ProductId = product.Id, + Quantity = -5 + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact, TestPriority(12)] + public async Task PlaceOrder_WithEmptyProductId_ReturnsBadRequest() + { + var response = await _orderClient.PostAsJsonAsync("/api/order", new + { + CustomerId = Guid.NewGuid(), + ProductId = Guid.Empty, + Quantity = 1 + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("product", body, StringComparison.OrdinalIgnoreCase); + } + + [Fact, TestPriority(13)] + public async Task GetNonExistentOrder_ReturnsNotFound() + { + var response = await _orderClient.GetAsync($"/api/order/{Guid.NewGuid()}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact, TestPriority(14)] + public async Task GetNonExistentProduct_ReturnsNotFound() + { + var response = await _productClient.GetAsync($"/api/product/{Guid.NewGuid()}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact, TestPriority(15)] + public async Task GetNonExistentNotification_ReturnsNotFound() + { + var response = await _notificationClient.GetAsync($"/api/notification/{Guid.NewGuid()}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact, TestPriority(16)] + public async Task FullEndToEndFlow_MultipleOrders_CreateDistinctNotifications() + { + // Create two products + var product1Response = await _productClient.PostAsJsonAsync("/api/product", new + { + Name = "E2E Widget Alpha", + Price = 1000m, + StockQuantity = 50 + }); + var product1 = await product1Response.Content.ReadFromJsonAsync(JsonOptions); + + var product2Response = await _productClient.PostAsJsonAsync("/api/product", new + { + Name = "E2E Widget Beta", + Price = 2000m, + StockQuantity = 30 + }); + var product2 = await product2Response.Content.ReadFromJsonAsync(JsonOptions); + + Assert.NotNull(product1); + Assert.NotNull(product2); + + var customer1 = Guid.NewGuid(); + var customer2 = Guid.NewGuid(); + + // Place two orders for different products/customers + var order1Response = await _orderClient.PostAsJsonAsync("/api/order", new + { + CustomerId = customer1, + ProductId = product1.Id, + Quantity = 2 + }); + var order1 = await order1Response.Content.ReadFromJsonAsync(JsonOptions); + + var order2Response = await _orderClient.PostAsJsonAsync("/api/order", new + { + CustomerId = customer2, + ProductId = product2.Id, + Quantity = 5 + }); + var order2 = await order2Response.Content.ReadFromJsonAsync(JsonOptions); + + Assert.NotNull(order1); + Assert.NotNull(order2); + + // Verify each order generated its own notification + var notif1 = await PollForNotification(order1.Id, timeout: TimeSpan.FromSeconds(15)); + var notif2 = await PollForNotification(order2.Id, timeout: TimeSpan.FromSeconds(15)); + + Assert.NotNull(notif1); + Assert.NotNull(notif2); + + Assert.NotEqual(notif1.Id, notif2.Id); + Assert.Equal(order1.Id, notif1.OrderId); + Assert.Equal(order2.Id, notif2.OrderId); + Assert.Equal(2000m, notif1.OrderTotal); // 1000 * 2 + Assert.Equal(10000m, notif2.OrderTotal); // 2000 * 5 + } + + // ────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────── + + private async Task PollForNotification(Guid orderId, TimeSpan timeout) + { + var deadline = DateTime.UtcNow.Add(timeout); + + while (DateTime.UtcNow < deadline) + { + var response = await _notificationClient.GetAsync("/api/notification"); + if (response.IsSuccessStatusCode) + { + var notifications = await response.Content + .ReadFromJsonAsync(JsonOptions); + + var match = notifications?.FirstOrDefault(n => n.OrderId == orderId); + if (match is not null) + return match; + } + + await Task.Delay(TimeSpan.FromSeconds(1)); + } + + return null; + } +} + +// ────────────────────────────────────────────────────────────── +// Response DTOs for deserialization +// ────────────────────────────────────────────────────────────── + +public record ProductResponse( + Guid Id, + string Name, + string? Description, + decimal Price, + int StockQuantity, + DateTime CreatedAt); + +public record OrderResponse( + Guid Id, + Guid CustomerId, + Guid ProductId, + string ProductName, + int Quantity, + decimal UnitPrice, + decimal TotalAmount, + int Status, + DateTime CreatedAt); + +public record NotificationResponse( + Guid Id, + Guid OrderId, + Guid CustomerId, + decimal OrderTotal, + int Type, + int Status, + string? RenderedSubject, + DateTime CreatedAt, + DateTime? SentAt); diff --git a/src/Tests/Integration/TestPriorityAttribute.cs b/src/Tests/Integration/TestPriorityAttribute.cs new file mode 100644 index 0000000..966ea93 --- /dev/null +++ b/src/Tests/Integration/TestPriorityAttribute.cs @@ -0,0 +1,40 @@ +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Integration.Tests; + +[AttributeUsage(AttributeTargets.Method)] +public class TestPriorityAttribute : Attribute +{ + public int Priority { get; } + public TestPriorityAttribute(int priority) => Priority = priority; +} + +public class PriorityOrderer : ITestCaseOrderer +{ + public IEnumerable OrderTestCases(IEnumerable testCases) + where TTestCase : ITestCase + { + var sorted = new SortedDictionary>(); + + foreach (var testCase in testCases) + { + var priority = testCase.TestMethod.Method + .GetCustomAttributes(typeof(TestPriorityAttribute).AssemblyQualifiedName!) + .FirstOrDefault() + ?.GetNamedArgument(nameof(TestPriorityAttribute.Priority)) ?? int.MaxValue; + + if (!sorted.TryGetValue(priority, out var list)) + { + list = new List(); + sorted[priority] = list; + } + + list.Add(testCase); + } + + foreach (var list in sorted.Values) + foreach (var testCase in list) + yield return testCase; + } +} diff --git a/src/docker-compose.integration.yml b/src/docker-compose.integration.yml new file mode 100644 index 0000000..aed83b4 --- /dev/null +++ b/src/docker-compose.integration.yml @@ -0,0 +1,89 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 3s + timeout: 3s + retries: 10 + + rabbitmq: + image: rabbitmq:3-management-alpine + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 5s + timeout: 5s + retries: 10 + + product-service: + build: + context: . + dockerfile: Services/Product/Product.API/Dockerfile + ports: + - "5004:5004" + depends_on: + postgres: + condition: service_healthy + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__DefaultConnection=Host=postgres;Database=productdb;Username=postgres;Password=postgres + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5004/healthz || exit 1"] + interval: 5s + timeout: 5s + retries: 15 + start_period: 15s + + order-service: + build: + context: . + dockerfile: Services/Order/Order.API/Dockerfile + ports: + - "5003:5003" + depends_on: + postgres: + condition: service_healthy + product-service: + condition: service_healthy + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__DefaultConnection=Host=postgres;Database=orderdb;Username=postgres;Password=postgres + - ServiceUrls__ProductService=http://product-service:5004 + - ServiceUrls__NotificationService=http://notification-service:5005 + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5003/healthz || exit 1"] + interval: 5s + timeout: 5s + retries: 15 + start_period: 15s + + notification-service: + build: + context: . + dockerfile: Services/Notification/Notification.API/Dockerfile + ports: + - "5005:5005" + depends_on: + postgres: + condition: service_healthy + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__DefaultConnection=Host=postgres;Database=notificationdb;Username=postgres;Password=postgres + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5005/healthz || exit 1"] + interval: 5s + timeout: 5s + retries: 15 + start_period: 15s