From df1a13e38d74f389d3346eb088c3a20c22bcf2a5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:34:08 +0000 Subject: [PATCH 1/5] feat(Order.Domain): add domain entities (BaseEntity, Order, OrderDetail) - Add BaseEntity with Id, audit fields (CreatedBy, UpdatedBy, CreatedDate, UpdatedDate) - Add Order entity with Discount, Comments, CashierId (string FK), CustomerId (int FK) - Add OrderDetail entity with UnitPrice, Quantity, Discount, ProductId (int FK), OrderId (int FK) - Remove .gitkeep from Entities/ and Interfaces/ directories --- src/Services/Order/Order.Domain/Entities/.gitkeep | 0 .../Order/Order.Domain/Entities/BaseEntity.cs | 10 ++++++++++ src/Services/Order/Order.Domain/Entities/Order.cs | 10 ++++++++++ .../Order/Order.Domain/Entities/OrderDetail.cs | 11 +++++++++++ src/Services/Order/Order.Domain/Interfaces/.gitkeep | 0 5 files changed, 31 insertions(+) delete mode 100644 src/Services/Order/Order.Domain/Entities/.gitkeep create mode 100644 src/Services/Order/Order.Domain/Entities/BaseEntity.cs create mode 100644 src/Services/Order/Order.Domain/Entities/Order.cs create mode 100644 src/Services/Order/Order.Domain/Entities/OrderDetail.cs delete mode 100644 src/Services/Order/Order.Domain/Interfaces/.gitkeep 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/BaseEntity.cs b/src/Services/Order/Order.Domain/Entities/BaseEntity.cs new file mode 100644 index 0000000..745a8cb --- /dev/null +++ b/src/Services/Order/Order.Domain/Entities/BaseEntity.cs @@ -0,0 +1,10 @@ +namespace Order.Domain.Entities; + +public abstract class BaseEntity +{ + public int Id { get; set; } + public string? CreatedBy { get; set; } + public string? UpdatedBy { get; set; } + public DateTime CreatedDate { get; set; } + public DateTime UpdatedDate { get; set; } +} diff --git a/src/Services/Order/Order.Domain/Entities/Order.cs b/src/Services/Order/Order.Domain/Entities/Order.cs new file mode 100644 index 0000000..b97f866 --- /dev/null +++ b/src/Services/Order/Order.Domain/Entities/Order.cs @@ -0,0 +1,10 @@ +namespace Order.Domain.Entities; + +public class Order : BaseEntity +{ + public decimal Discount { get; set; } + public string? Comments { get; set; } + public string? CashierId { get; set; } + public int CustomerId { get; set; } + public ICollection OrderDetails { get; set; } = []; +} diff --git a/src/Services/Order/Order.Domain/Entities/OrderDetail.cs b/src/Services/Order/Order.Domain/Entities/OrderDetail.cs new file mode 100644 index 0000000..20b5478 --- /dev/null +++ b/src/Services/Order/Order.Domain/Entities/OrderDetail.cs @@ -0,0 +1,11 @@ +namespace Order.Domain.Entities; + +public class OrderDetail : BaseEntity +{ + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } + public decimal Discount { get; set; } + public int ProductId { get; set; } + public int OrderId { get; set; } + public Order Order { get; set; } = null!; +} 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 From 81e1487ea4da36ca6de642e6376e7517f6c475e5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:36:04 +0000 Subject: [PATCH 2/5] T2: configure OrderDbContext EF Core mappings and EnsureCreated on startup --- src/Services/Order/Order.API/Program.cs | 7 +++++ .../Data/OrderDbContext.cs | 30 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Services/Order/Order.API/Program.cs b/src/Services/Order/Order.API/Program.cs index 4512675..1097a10 100644 --- a/src/Services/Order/Order.API/Program.cs +++ b/src/Services/Order/Order.API/Program.cs @@ -13,6 +13,13 @@ var app = builder.Build(); +// Auto-create database schema (initial migration via EnsureCreated) +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.Infrastructure/Data/OrderDbContext.cs b/src/Services/Order/Order.Infrastructure/Data/OrderDbContext.cs index 4f80d89..f2a4fdf 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,36 @@ public OrderDbContext(DbContextOptions options) : base(options) { } + public DbSet Orders { get; set; } = null!; + public DbSet OrderDetails { get; set; } = null!; + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - // TODO: Configure entity mappings migrated from monolith + + // Order entity config — ported verbatim from monolith + modelBuilder.Entity(entity => + { + entity.ToTable("AppOrders"); + entity.Property(o => o.Comments).HasMaxLength(500); + entity.Property(o => o.Discount).HasColumnType("decimal(18,2)"); + entity.Property(o => o.CreatedBy).HasMaxLength(40); + entity.Property(o => o.UpdatedBy).HasMaxLength(40); + + entity.HasMany(o => o.OrderDetails) + .WithOne(od => od.Order) + .HasForeignKey(od => od.OrderId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // OrderDetail entity config — ported verbatim from monolith + modelBuilder.Entity(entity => + { + entity.ToTable("AppOrderDetails"); + entity.Property(od => od.UnitPrice).HasColumnType("decimal(18,2)"); + entity.Property(od => od.Discount).HasColumnType("decimal(18,2)"); + entity.Property(od => od.CreatedBy).HasMaxLength(40); + entity.Property(od => od.UpdatedBy).HasMaxLength(40); + }); } } From 9aafd99389aaa6035a21385ccbd1df07509d21fd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:38:54 +0000 Subject: [PATCH 3/5] T3: add JWT Bearer auth, DTOs, and full CRUD OrderController - Add Microsoft.AspNetCore.Authentication.JwtBearer package - Configure JWT validation (issuer, audience, signing key) in Program.cs - Add authentication/authorization middleware pipeline - Create OrderDto, CreateOrderDto, UpdateOrderDto, OrderDetailDto, CreateOrderDetailDto - Replace scaffold controller with full CRUD (GetAll, GetById, Create, Update, Delete) - All controller endpoints protected with class-level [Authorize] - healthz endpoint remains public via MapHealthChecks --- .../Order.API/Controllers/OrderController.cs | 131 ++++++++++++++++-- src/Services/Order/Order.API/DTOs/OrderDto.cs | 45 ++++++ src/Services/Order/Order.API/Order.API.csproj | 1 + src/Services/Order/Order.API/Program.cs | 33 ++++- src/Services/Order/Order.API/appsettings.json | 5 + 5 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 src/Services/Order/Order.API/DTOs/OrderDto.cs diff --git a/src/Services/Order/Order.API/Controllers/OrderController.cs b/src/Services/Order/Order.API/Controllers/OrderController.cs index 1621bae..2b239c9 100644 --- a/src/Services/Order/Order.API/Controllers/OrderController.cs +++ b/src/Services/Order/Order.API/Controllers/OrderController.cs @@ -1,29 +1,142 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Order.API.DTOs; +using Order.Domain.Entities; +using Order.Infrastructure.Data; namespace Order.API.Controllers; [ApiController] [Route("api/[controller]")] +[Authorize] public class OrderController : ControllerBase { - private readonly ILogger _logger; + private readonly OrderDbContext _context; - public OrderController(ILogger logger) + public OrderController(OrderDbContext context) { - _logger = logger; + _context = context; } [HttpGet] - public IActionResult GetAll() + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetAll() { - // TODO: Implement — migrate logic from monolith's OrderController - return Ok(new { service = "Order", status = "scaffold" }); + var orders = await _context.Orders + .Include(o => o.OrderDetails) + .ToListAsync(); + + var result = orders.Select(MapToDto).ToList(); + return Ok(result); } [HttpGet("{id}")] - public IActionResult GetById(int id) + [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetById(int id) + { + var order = await _context.Orders + .Include(o => o.OrderDetails) + .FirstOrDefaultAsync(o => o.Id == id); + + if (order is null) + return NotFound(); + + return Ok(MapToDto(order)); + } + + [HttpPost] + [ProducesResponseType(typeof(OrderDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Create([FromBody] CreateOrderDto dto) + { + var order = new Order.Domain.Entities.Order + { + Discount = dto.Discount, + Comments = dto.Comments, + CashierId = dto.CashierId, + CustomerId = dto.CustomerId, + CreatedDate = DateTime.UtcNow, + UpdatedDate = DateTime.UtcNow + }; + + foreach (var detailDto in dto.OrderDetails) + { + order.OrderDetails.Add(new OrderDetail + { + UnitPrice = detailDto.UnitPrice, + Quantity = detailDto.Quantity, + Discount = detailDto.Discount, + ProductId = detailDto.ProductId, + CreatedDate = DateTime.UtcNow, + UpdatedDate = DateTime.UtcNow + }); + } + + _context.Orders.Add(order); + await _context.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetById), new { id = order.Id }, MapToDto(order)); + } + + [HttpPut("{id}")] + [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Update(int id, [FromBody] UpdateOrderDto dto) { - // TODO: Implement — migrate logic from monolith - return Ok(new { service = "Order", id }); + var order = await _context.Orders + .Include(o => o.OrderDetails) + .FirstOrDefaultAsync(o => o.Id == id); + + if (order is null) + return NotFound(); + + order.Discount = dto.Discount; + order.Comments = dto.Comments; + order.CashierId = dto.CashierId; + order.CustomerId = dto.CustomerId; + order.UpdatedDate = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + return Ok(MapToDto(order)); + } + + [HttpDelete("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Delete(int id) + { + var order = await _context.Orders.FindAsync(id); + + if (order is null) + return NotFound(); + + _context.Orders.Remove(order); + await _context.SaveChangesAsync(); + + return NoContent(); } + + private static OrderDto MapToDto(Order.Domain.Entities.Order order) => new() + { + Id = order.Id, + Discount = order.Discount, + Comments = order.Comments, + CashierId = order.CashierId, + CustomerId = order.CustomerId, + OrderDetails = order.OrderDetails.Select(od => new OrderDetailDto + { + Id = od.Id, + UnitPrice = od.UnitPrice, + Quantity = od.Quantity, + Discount = od.Discount, + ProductId = od.ProductId + }).ToList() + }; } diff --git a/src/Services/Order/Order.API/DTOs/OrderDto.cs b/src/Services/Order/Order.API/DTOs/OrderDto.cs new file mode 100644 index 0000000..ebbfe73 --- /dev/null +++ b/src/Services/Order/Order.API/DTOs/OrderDto.cs @@ -0,0 +1,45 @@ +namespace Order.API.DTOs; + +public class OrderDto +{ + public int Id { get; set; } + public decimal Discount { get; set; } + public string? Comments { get; set; } + public string? CashierId { get; set; } + public int CustomerId { get; set; } + public List OrderDetails { get; set; } = []; +} + +public class CreateOrderDto +{ + public decimal Discount { get; set; } + public string? Comments { get; set; } + public string? CashierId { get; set; } + public int CustomerId { get; set; } + public List OrderDetails { get; set; } = []; +} + +public class UpdateOrderDto +{ + public decimal Discount { get; set; } + public string? Comments { get; set; } + public string? CashierId { get; set; } + public int CustomerId { get; set; } +} + +public class OrderDetailDto +{ + public int Id { get; set; } + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } + public decimal Discount { get; set; } + public int ProductId { get; set; } +} + +public class CreateOrderDetailDto +{ + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } + public decimal Discount { get; set; } + public int ProductId { get; set; } +} diff --git a/src/Services/Order/Order.API/Order.API.csproj b/src/Services/Order/Order.API/Order.API.csproj index 54aad4b..6a44d71 100644 --- a/src/Services/Order/Order.API/Order.API.csproj +++ b/src/Services/Order/Order.API/Order.API.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Services/Order/Order.API/Program.cs b/src/Services/Order/Order.API/Program.cs index 1097a10..86c8489 100644 --- a/src/Services/Order/Order.API/Program.cs +++ b/src/Services/Order/Order.API/Program.cs @@ -1,5 +1,8 @@ using Order.Infrastructure.Data; using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Text; var builder = WebApplication.CreateBuilder(args); @@ -11,9 +14,34 @@ builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +// JWT Authentication +var jwtSettings = builder.Configuration.GetSection("Jwt"); +var key = Encoding.UTF8.GetBytes(jwtSettings["Secret"]!); + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings["Issuer"], + ValidAudience = jwtSettings["Audience"], + IssuerSigningKey = new SymmetricSecurityKey(key) + }; +}); + +builder.Services.AddAuthorization(); + var app = builder.Build(); -// Auto-create database schema (initial migration via EnsureCreated) +// Auto-create database schema using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); @@ -26,6 +54,9 @@ app.UseSwaggerUI(); } +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); app.MapHealthChecks("/healthz"); diff --git a/src/Services/Order/Order.API/appsettings.json b/src/Services/Order/Order.API/appsettings.json index d830094..b6a3013 100644 --- a/src/Services/Order/Order.API/appsettings.json +++ b/src/Services/Order/Order.API/appsettings.json @@ -7,5 +7,10 @@ }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Database=orderdb;Username=postgres;Password=postgres" + }, + "Jwt": { + "Secret": "QuickApp_Microservices_SuperSecret_Key_For_Dev_Only_Min_32_Chars!", + "Issuer": "quickapp-identity", + "Audience": "quickapp-api" } } From ee77c513a898ae7521b1ff0f18480c472b5e2aa7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:41:26 +0000 Subject: [PATCH 4/5] test: add Order service E2E smoke test script --- tests/order-smoke-test.sh | 89 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100755 tests/order-smoke-test.sh diff --git a/tests/order-smoke-test.sh b/tests/order-smoke-test.sh new file mode 100755 index 0000000..6ace147 --- /dev/null +++ b/tests/order-smoke-test.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Order Service E2E Smoke Test +# Usage: ./tests/order-smoke-test.sh [gateway_url] [service_url] +# Defaults: gateway=http://localhost:5000, service=http://localhost:5003 + +set -e + +GATEWAY_URL=${1:-http://localhost:5000} +SERVICE_URL=${2:-http://localhost:5003} +PASSED=0 +FAILED=0 + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +# Generate a valid JWT token for testing +generate_jwt() { + local header=$(echo -n '{"alg":"HS256","typ":"JWT"}' | base64 -w0 | tr '+/' '-_' | tr -d '=') + local now=$(date +%s) + local exp=$((now + 3600)) + local payload=$(echo -n "{\"sub\":\"test-user\",\"iss\":\"quickapp-identity\",\"aud\":\"quickapp-api\",\"exp\":${exp},\"iat\":${now}}" | base64 -w0 | tr '+/' '-_' | tr -d '=') + local secret="QuickApp_Microservices_SuperSecret_Key_For_Dev_Only_Min_32_Chars!" + local signature=$(echo -n "${header}.${payload}" | openssl dgst -sha256 -hmac "${secret}" -binary | base64 -w0 | tr '+/' '-_' | tr -d '=') + echo "${header}.${payload}.${signature}" +} + +assert_status() { + local test_name=$1 + local expected=$2 + local actual=$3 + if [ "$actual" -eq "$expected" ]; then + echo -e "${GREEN}PASS${NC}: $test_name (HTTP $actual)" + PASSED=$((PASSED + 1)) + else + echo -e "${RED}FAIL${NC}: $test_name (expected $expected, got $actual)" + FAILED=$((FAILED + 1)) + fi +} + +echo "=== Order Service Smoke Tests ===" +echo "Gateway: $GATEWAY_URL" +echo "Service: $SERVICE_URL" +echo "" + +TOKEN=$(generate_jwt) + +# Test 1: Health check (direct) +echo "--- Direct Service Tests ---" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SERVICE_URL/healthz") +assert_status "GET /healthz → 200" 200 "$STATUS" + +# Test 2: Unauthenticated GET → 401 (direct) +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SERVICE_URL/api/order") +assert_status "GET /api/order (no token) → 401" 401 "$STATUS" + +# Test 3: Authenticated POST → 201 (direct) +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$SERVICE_URL/api/order" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"discount": 5.00, "comments": "smoke test", "cashierId": "user1", "customerId": 1, "orderDetails": [{"unitPrice": 19.99, "quantity": 2, "discount": 0, "productId": 101}]}') +assert_status "POST /api/order (with token) → 201" 201 "$STATUS" + +# Test 4: Authenticated GET → 200 (direct) +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SERVICE_URL/api/order" \ + -H "Authorization: Bearer $TOKEN") +assert_status "GET /api/order (with token) → 200" 200 "$STATUS" + +# Test 5: Gateway health check +echo "" +echo "--- Gateway Route Tests ---" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$GATEWAY_URL/api/orders/healthz") +assert_status "GET /api/orders/healthz via gateway → 200" 200 "$STATUS" + +# Test 6: Gateway unauthenticated → 401 +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$GATEWAY_URL/api/orders/api/order") +assert_status "GET /api/orders/api/order (no token) via gateway → 401" 401 "$STATUS" + +# Test 7: Gateway authenticated → 200 +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$GATEWAY_URL/api/orders/api/order" \ + -H "Authorization: Bearer $TOKEN") +assert_status "GET /api/orders/api/order (with token) via gateway → 200" 200 "$STATUS" + +echo "" +echo "=== Results: $PASSED passed, $FAILED failed ===" + +if [ $FAILED -gt 0 ]; then + exit 1 +fi From b31336bfaec12c614ed2c0aab045ec0d8dcbf72a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:43:53 +0000 Subject: [PATCH 5/5] fix: add DB startup retry for container orchestration reliability --- src/Services/Order/Order.API/Program.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Services/Order/Order.API/Program.cs b/src/Services/Order/Order.API/Program.cs index 86c8489..e8df6dc 100644 --- a/src/Services/Order/Order.API/Program.cs +++ b/src/Services/Order/Order.API/Program.cs @@ -41,11 +41,22 @@ var app = builder.Build(); -// Auto-create database schema +// Auto-create database schema (with retry for container startup ordering) using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); + for (var i = 0; i < 10; i++) + { + try + { + db.Database.EnsureCreated(); + break; + } + catch (Exception) when (i < 9) + { + Thread.Sleep(2000); + } + } } if (app.Environment.IsDevelopment())