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