From 5a33bfb93223076c0ad4552e94e4d86fb0da13af Mon Sep 17 00:00:00 2001 From: Dhruv Patel Date: Sat, 6 Jun 2026 12:50:32 -0400 Subject: [PATCH 1/8] ci: run on push and PR to main and develop --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d12e147..36e1008 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] jobs: build: From 07e707985a4f1704c924953c58e3a2a25498ce24 Mon Sep 17 00:00:00 2001 From: Dhruv Patel Date: Sat, 26 Sep 2026 02:18:33 -0400 Subject: [PATCH 2/8] Added Shipping address and created shipping service --- .DS_Store | Bin 0 -> 6148 bytes .github/workflows/ci.yml | 74 ++++++++--- .../src/main/resources/application.yml | 7 ++ .../common/events/OrderCreatedEvent.java | 25 ++++ .../src/main/resources/config/api-gateway.yml | 1 + .../resources/config/shipping-service.yml | 5 + shopsphere/docker-compose.yml | 20 +++ shopsphere/order-service/pom.xml | 9 ++ .../controller/OrderController.java | 40 ++++-- .../orderservice/dto/AddressDto.java | 102 +++++++++++++++ .../orderservice/dto/CreateOrderRequest.java | 7 ++ .../orderservice/dto/OrderResponse.java | 2 + .../orderservice/entity/Address.java | 68 ++++++++++ .../shopsphere/orderservice/entity/Order.java | 19 +++ .../exception/GlobalExceptionHandler.java | 7 ++ .../orderservice/service/OrderService.java | 39 +++++- .../src/main/resources/application.yml | 15 ++- .../migration/V1__baseline_orders_schema.sql | 32 +++++ .../V2__add_shipping_address_to_orders.sql | 15 +++ .../dto/CreateOrderRequestValidationTest.java | 112 +++++++++++++++++ .../service/OrderServiceTest.java | 119 +++++++++++++++++- .../.config/dotnet-tools.json | 10 ++ shopsphere/shipping-service/.dockerignore | 5 + shopsphere/shipping-service/.gitignore | 6 + shopsphere/shipping-service/Dockerfile | 18 +++ .../Contracts/ShipmentResponse.cs | 36 ++++++ .../Data/ShipmentConfiguration.cs | 60 +++++++++ .../Data/ShippingDbContext.cs | 47 +++++++ .../Data/ShippingDbContextFactory.cs | 24 ++++ .../ShippingService.Api/Domain/Shipment.cs | 71 +++++++++++ .../Domain/ShipmentStatus.cs | 10 ++ .../Domain/ShippingAddress.cs | 89 +++++++++++++ .../Endpoints/ShipmentEndpoints.cs | 66 ++++++++++ .../Health/HealthResponseWriter.cs | 38 ++++++ .../src/ShippingService.Api/Program.cs | 80 ++++++++++++ .../ShippingService.Api.csproj | 34 +++++ .../appsettings.Development.json | 9 ++ .../appsettings.Testing.json | 15 +++ .../src/ShippingService.Api/appsettings.json | 43 +++++++ .../HealthEndpointTests.cs | 50 ++++++++ .../ShipmentEndpointsTests.cs | 71 +++++++++++ .../ShippingService.Tests/ShipmentTests.cs | 97 ++++++++++++++ .../ShippingApiFactory.cs | 38 ++++++ .../ShippingService.Tests.csproj | 28 +++++ 44 files changed, 1625 insertions(+), 38 deletions(-) create mode 100644 .DS_Store create mode 100644 shopsphere/config-service/src/main/resources/config/shipping-service.yml create mode 100644 shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/AddressDto.java create mode 100644 shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/Address.java create mode 100644 shopsphere/order-service/src/main/resources/db/migration/V1__baseline_orders_schema.sql create mode 100644 shopsphere/order-service/src/main/resources/db/migration/V2__add_shipping_address_to_orders.sql create mode 100644 shopsphere/order-service/src/test/java/com/shopsphere/orderservice/dto/CreateOrderRequestValidationTest.java create mode 100644 shopsphere/shipping-service/.config/dotnet-tools.json create mode 100644 shopsphere/shipping-service/.dockerignore create mode 100644 shopsphere/shipping-service/.gitignore create mode 100644 shopsphere/shipping-service/Dockerfile create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Contracts/ShipmentResponse.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Data/ShipmentConfiguration.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Data/ShippingDbContext.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Data/ShippingDbContextFactory.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Domain/Shipment.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Domain/ShipmentStatus.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Domain/ShippingAddress.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Endpoints/ShipmentEndpoints.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Health/HealthResponseWriter.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Program.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/ShippingService.Api.csproj create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/appsettings.Development.json create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/appsettings.Testing.json create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/appsettings.json create mode 100644 shopsphere/shipping-service/tests/ShippingService.Tests/HealthEndpointTests.cs create mode 100644 shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentEndpointsTests.cs create mode 100644 shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentTests.cs create mode 100644 shopsphere/shipping-service/tests/ShippingService.Tests/ShippingApiFactory.cs create mode 100644 shopsphere/shipping-service/tests/ShippingService.Tests/ShippingService.Tests.csproj diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..b0fe3fa9399c9b62ac89ad39ca50b39e4d267b66 GIT binary patch literal 6148 zcmeHKEl&eM5S@hsnqulek+U@gyKiqkc4y!9cDh8QD*Z-@s6a$HG{zv0Zi=v-TSgYFhJzK) zanveR>q)1Z@f3wHAPoFR2K0wdo7AF1I`O{W<*Roj9E@$9B#vuGNe#N(-r($c|Mju# z+Z#XHyKU!eD4#PIE91GTO?|3)tJBhD*2B%=ZFDo-H935nr;~hTGTY0`N1uP#Yl9N1 zQ_s)j^h58{+T0vo)^5i+Ok&;Dr`i2=?=dyysRsrU>H@nMHR+g|ZVttZt%q5DZa#zU z;q_?aiQmiAIo;fT^L6tq-to^wWX>2s&1T818kAlb5C(*S2?KmSglLSB#nhlaI?(AW z05E`B3&y%u07F&)Ba5j)Xdudj0!^s0R}5vsp%1NJWHB{p!b$0sF^^qY*&B+ol}d%$ zom6B{dSO5q@EKS%j|Hy(JG1Zqevmu~1H!;uF`$CD8dtC-TU#@m<60Y{9iXwWUusZ? jptI#z54aSsqiKOHPrzB7X!l4blk%f6BloZ%dX& literal 0 HcmV?d00001 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d12e147..73af897 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,8 @@ on: branches: [main] jobs: - build: - name: Build & Package + java: + name: Java — Build & Test runs-on: ubuntu-latest steps: @@ -21,28 +21,68 @@ jobs: distribution: 'temurin' cache: maven - - name: Build all modules - run: mvn -f shopsphere/pom.xml clean package -DskipTests --no-transfer-progress + - name: Build and test all modules + run: mvn -f shopsphere/pom.xml clean verify --no-transfer-progress + + dotnet: + name: .NET — Build & Test (shipping-service) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore shopsphere/shipping-service/tests/ShippingService.Tests/ShippingService.Tests.csproj + + - name: Build + run: dotnet build shopsphere/shipping-service/tests/ShippingService.Tests/ShippingService.Tests.csproj -c Release --no-restore + + - name: Test + run: dotnet test shopsphere/shipping-service/tests/ShippingService.Tests/ShippingService.Tests.csproj -c Release --no-build --logger "trx;LogFileName=results.trx" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: shipping-service-test-results + path: shopsphere/shipping-service/tests/ShippingService.Tests/TestResults/ docker: name: Docker Build (${{ matrix.service }}) runs-on: ubuntu-latest - needs: build + needs: [java, dotnet] strategy: fail-fast: false matrix: - service: - - service-registry - - config-service - - api-gateway - - user-service - - product-service - - cart-service - - order-service - - inventory-service - - payment-service - - notification-service + include: + - service: service-registry + context: ./shopsphere + - service: config-service + context: ./shopsphere + - service: api-gateway + context: ./shopsphere + - service: user-service + context: ./shopsphere + - service: product-service + context: ./shopsphere + - service: cart-service + context: ./shopsphere + - service: order-service + context: ./shopsphere + - service: inventory-service + context: ./shopsphere + - service: payment-service + context: ./shopsphere + - service: notification-service + context: ./shopsphere + - service: shipping-service + context: ./shopsphere/shipping-service steps: - uses: actions/checkout@v4 @@ -53,7 +93,7 @@ jobs: - name: Build Docker image uses: docker/build-push-action@v5 with: - context: ./shopsphere + context: ${{ matrix.context }} file: ./shopsphere/${{ matrix.service }}/Dockerfile push: false build-args: SERVICE_DIR=${{ matrix.service }} diff --git a/shopsphere/api-gateway/src/main/resources/application.yml b/shopsphere/api-gateway/src/main/resources/application.yml index 71d30fa..25c93cf 100644 --- a/shopsphere/api-gateway/src/main/resources/application.yml +++ b/shopsphere/api-gateway/src/main/resources/application.yml @@ -59,6 +59,12 @@ spring: predicates: - Path=/api/v1/notifications/** + # ---------- Shipping Service (.NET 8 + Steeltoe) ---------- + - id: shipping-service + uri: ${SHIPPING_SERVICE_URL:http://localhost:8088} + predicates: + - Path=/api/v1/shipments/** + app: jwt: secret: ${JWT_SECRET:change-me-to-a-long-random-secret-of-at-least-256-bits-for-hs256} @@ -74,6 +80,7 @@ app: - /api/v1/inventory/health - /api/v1/payments/health - /api/v1/notifications/health + - /api/v1/shipments/health - /actuator/health # GET on these path prefixes is public (browsing catalog without login) public-get-prefixes: diff --git a/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/OrderCreatedEvent.java b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/OrderCreatedEvent.java index e5f0e2e..55e0fd4 100644 --- a/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/OrderCreatedEvent.java +++ b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/OrderCreatedEvent.java @@ -9,6 +9,12 @@ import java.math.BigDecimal; import java.util.List; +/** + * Published by order-service after an order is committed. + * + *

US33 added {@code shippingAddress}. The change is additive, so the type stays at v1: + * consumers built against the old class ignore the unknown field.

+ */ @Data @EqualsAndHashCode(callSuper = true) @NoArgsConstructor @@ -23,6 +29,7 @@ public class OrderCreatedEvent extends BaseEvent { private BigDecimal totalAmount; private Integer itemCount; private List items; + private ShippingAddress shippingAddress; @Data @NoArgsConstructor @@ -35,4 +42,22 @@ public static class Item { private BigDecimal unitPrice; private Integer quantity; } + + /** Snapshot of the shipping address at the time the order was placed. */ + @Data + @NoArgsConstructor + @lombok.AllArgsConstructor + @lombok.Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ShippingAddress { + private String recipientName; + private String phone; + private String line1; + private String line2; + private String city; + private String state; + private String postalCode; + /** ISO 3166-1 alpha-2, upper case (e.g. "CA", "US"). */ + private String country; + } } diff --git a/shopsphere/config-service/src/main/resources/config/api-gateway.yml b/shopsphere/config-service/src/main/resources/config/api-gateway.yml index c389419..af665ca 100644 --- a/shopsphere/config-service/src/main/resources/config/api-gateway.yml +++ b/shopsphere/config-service/src/main/resources/config/api-gateway.yml @@ -10,6 +10,7 @@ app: - /api/v1/inventory/health - /api/v1/payments/health - /api/v1/notifications/health + - /api/v1/shipments/health - /actuator/health public-get-prefixes: - /api/v1/products diff --git a/shopsphere/config-service/src/main/resources/config/shipping-service.yml b/shopsphere/config-service/src/main/resources/config/shipping-service.yml new file mode 100644 index 0000000..c52a1d9 --- /dev/null +++ b/shopsphere/config-service/src/main/resources/config/shipping-service.yml @@ -0,0 +1,5 @@ +# Served to shipping-service (.NET 8 / Steeltoe Config Server client). +# Keys map to IConfiguration (Logging.LogLevel.X -> Logging:LogLevel:X). +Logging: + LogLevel: + ShippingService: Debug diff --git a/shopsphere/docker-compose.yml b/shopsphere/docker-compose.yml index 2463aa9..3008d83 100644 --- a/shopsphere/docker-compose.yml +++ b/shopsphere/docker-compose.yml @@ -249,6 +249,25 @@ services: service-registry: condition: service_healthy + shipping-service: + build: + context: ./shipping-service + dockerfile: Dockerfile + container_name: shopsphere-shipping + ports: + - "8088:8088" + environment: + ASPNETCORE_ENVIRONMENT: Production + ConnectionStrings__ShippingDb: Host=postgres;Port=5432;Database=shopsphere;Username=shopsphere;Password=shopsphere + spring__cloud__config__uri: http://config-service:8888 + eureka__client__serviceUrl: http://service-registry:8761/eureka/ + eureka__instance__hostName: shipping-service + depends_on: + postgres: + condition: service_healthy + service-registry: + condition: service_healthy + api-gateway: build: context: . @@ -269,6 +288,7 @@ services: INVENTORY_SERVICE_URL: http://inventory-service:8085 PAYMENT_SERVICE_URL: http://payment-service:8086 NOTIFICATION_SERVICE_URL: http://notification-service:8087 + SHIPPING_SERVICE_URL: http://shipping-service:8088 depends_on: service-registry: condition: service_healthy diff --git a/shopsphere/order-service/pom.xml b/shopsphere/order-service/pom.xml index 2469ae5..c797a99 100644 --- a/shopsphere/order-service/pom.xml +++ b/shopsphere/order-service/pom.xml @@ -35,6 +35,15 @@ postgresql runtime + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + org.projectlombok lombok diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/controller/OrderController.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/controller/OrderController.java index 7c4414f..5e40324 100644 --- a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/controller/OrderController.java +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/controller/OrderController.java @@ -43,36 +43,49 @@ public class OrderController { Creates a new order containing all items currently in the user's cart. Flow: - 1. Fetches the cart from Cart Service. - 2. Re-fetches each product from Product Service to validate availability and stock. - 3. Snapshots product name and current price into immutable order line items. - 4. Saves the order in PENDING_PAYMENT status. - 5. Clears the user's cart (best-effort). + 1. Validates the request, including the required shipping address. + 2. Fetches the cart from Cart Service. + 3. Checks stock with Inventory Service. + 4. Re-fetches each product from Product Service to validate availability. + 5. Snapshots product name, current price and the shipping address onto the order. + 6. Saves the order in PENDING_PAYMENT status and publishes order.created after commit. + 7. Clears the user's cart (best-effort). """ ) @ApiResponses({ @ApiResponse(responseCode = "201", description = "Order created in PENDING_PAYMENT status"), - @ApiResponse(responseCode = "400", description = "Cart is empty or request invalid", + @ApiResponse(responseCode = "400", description = "Missing/invalid shipping address, empty cart or malformed body", content = @Content(schema = @Schema(implementation = ApiError.class))), @ApiResponse(responseCode = "404", description = "A product in the cart no longer exists", content = @Content(schema = @Schema(implementation = ApiError.class))), @ApiResponse(responseCode = "409", description = "Product inactive or insufficient stock", content = @Content(schema = @Schema(implementation = ApiError.class))), - @ApiResponse(responseCode = "502", description = "Cart or Product Service unavailable", + @ApiResponse(responseCode = "502", description = "Cart, Product or Inventory Service unavailable", content = @Content(schema = @Schema(implementation = ApiError.class))) }) public ResponseEntity createOrder( @Parameter(description = "Customer user id", required = true, example = "1") @RequestHeader("X-User-Id") @Positive Long userId, @io.swagger.v3.oas.annotations.parameters.RequestBody( - required = false, + required = true, content = @Content(examples = @ExampleObject(value = """ - { "notes": "Please leave at the front door" } + { + "shippingAddress": { + "recipientName": "Jane Doe", + "phone": "+1 416 555 0199", + "line1": "123 King St W", + "line2": "Unit 4", + "city": "Toronto", + "state": "ON", + "postalCode": "M5V 3L9", + "country": "CA" + }, + "notes": "Please leave at the front door" + } """)) ) - @Valid @RequestBody(required = false) CreateOrderRequest request) { - CreateOrderRequest body = request == null ? new CreateOrderRequest() : request; - OrderResponse response = orderService.createOrder(userId, body); + @Valid @RequestBody CreateOrderRequest request) { + OrderResponse response = orderService.createOrder(userId, request); return ResponseEntity .created(URI.create("/api/v1/orders/" + response.getId())) .body(response); @@ -107,10 +120,11 @@ public Page getMyOrders( @Operation( summary = "Get details for a specific order", description = """ - Returns the full order with all line items. + Returns the full order with all line items and the shipping address. - The authenticated user must own this order, otherwise 403 Forbidden. - Items include snapshotted product name and unit price from the time of order. + - shippingAddress is null only for orders placed before shipping addresses existed. """ ) @ApiResponses({ diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/AddressDto.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/AddressDto.java new file mode 100644 index 0000000..3d3d4f1 --- /dev/null +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/AddressDto.java @@ -0,0 +1,102 @@ +package com.shopsphere.orderservice.dto; + +import com.shopsphere.orderservice.entity.Address; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Locale; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Postal address") +public class AddressDto { + + @Schema(example = "Jane Doe") + @NotBlank(message = "Recipient name is required") + @Size(max = 100, message = "Recipient name must be at most 100 characters") + private String recipientName; + + @Schema(example = "+1 416 555 0199") + @Size(max = 20, message = "Phone must be at most 20 characters") + @Pattern(regexp = "^\\+?[0-9 ()-]{7,20}$", message = "Phone must contain only digits, spaces, (), - and an optional leading +") + private String phone; + + @Schema(example = "123 King St W") + @NotBlank(message = "Address line 1 is required") + @Size(max = 200, message = "Address line 1 must be at most 200 characters") + private String line1; + + @Schema(example = "Unit 4") + @Size(max = 200, message = "Address line 2 must be at most 200 characters") + private String line2; + + @Schema(example = "Toronto") + @NotBlank(message = "City is required") + @Size(max = 100, message = "City must be at most 100 characters") + private String city; + + @Schema(example = "ON") + @Size(max = 100, message = "State/province must be at most 100 characters") + private String state; + + @Schema(example = "M5V 3L9") + @NotBlank(message = "Postal code is required") + @Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9 -]{1,18}[A-Za-z0-9]$", + message = "Postal code must be 3-20 letters, digits, spaces or hyphens") + private String postalCode; + + @Schema(example = "CA", description = "ISO 3166-1 alpha-2 country code") + @NotBlank(message = "Country is required") + @Pattern(regexp = "^[A-Za-z]{2}$", message = "Country must be a 2-letter ISO 3166-1 alpha-2 code") + private String country; + + /** Maps to the embeddable, trimming input and normalising case. */ + public Address toEntity() { + return Address.builder() + .recipientName(trimToNull(recipientName)) + .phone(trimToNull(phone)) + .line1(trimToNull(line1)) + .line2(trimToNull(line2)) + .city(trimToNull(city)) + .state(trimToNull(state)) + .postalCode(upper(trimToNull(postalCode))) + .country(upper(trimToNull(country))) + .build(); + } + + public static AddressDto from(Address address) { + if (address == null) { + return null; + } + return AddressDto.builder() + .recipientName(address.getRecipientName()) + .phone(address.getPhone()) + .line1(address.getLine1()) + .line2(address.getLine2()) + .city(address.getCity()) + .state(address.getState()) + .postalCode(address.getPostalCode()) + .country(address.getCountry()) + .build(); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static String upper(String value) { + return value == null ? null : value.toUpperCase(Locale.ROOT); + } +} diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/CreateOrderRequest.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/CreateOrderRequest.java index af4d700..be44265 100644 --- a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/CreateOrderRequest.java +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/CreateOrderRequest.java @@ -1,5 +1,7 @@ package com.shopsphere.orderservice.dto; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; @@ -12,6 +14,11 @@ @Builder public class CreateOrderRequest { + /** US33 — required; snapshotted onto the order and into the order.created event. */ + @NotNull(message = "Shipping address is required") + @Valid + private AddressDto shippingAddress; + /** Optional shipping note or customer instruction. Reserved for future use. */ @Size(max = 500, message = "Notes must be at most 500 characters") private String notes; diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/OrderResponse.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/OrderResponse.java index 60db8fb..4a1a983 100644 --- a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/OrderResponse.java +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/OrderResponse.java @@ -20,6 +20,8 @@ public class OrderResponse { private OrderStatus status; private BigDecimal totalAmount; private Integer itemCount; + /** Null only for orders placed before US33. */ + private AddressDto shippingAddress; private List items; private Instant createdAt; private Instant updatedAt; diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/Address.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/Address.java new file mode 100644 index 0000000..6c3663a --- /dev/null +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/Address.java @@ -0,0 +1,68 @@ +package com.shopsphere.orderservice.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Postal address value object, embedded into owning entities. + * + *

The validation constraints are also enforced by Hibernate before insert/update, as a + * second line of defence behind request validation. DB columns stay nullable so orders + * created before US33 (no address) remain valid.

+ */ +@Embeddable +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode +public class Address { + + @NotBlank + @Size(max = 100) + @Column(name = "recipient_name", length = 100) + private String recipientName; + + @Size(max = 20) + @Column(name = "phone", length = 20) + private String phone; + + @NotBlank + @Size(max = 200) + @Column(name = "line1", length = 200) + private String line1; + + @Size(max = 200) + @Column(name = "line2", length = 200) + private String line2; + + @NotBlank + @Size(max = 100) + @Column(name = "city", length = 100) + private String city; + + @Size(max = 100) + @Column(name = "state", length = 100) + private String state; + + @NotBlank + @Size(max = 20) + @Column(name = "postal_code", length = 20) + private String postalCode; + + /** ISO 3166-1 alpha-2, always stored upper case. */ + @NotBlank + @Pattern(regexp = "^[A-Z]{2}$") + @Column(name = "country", length = 2) + private String country; +} diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/Order.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/Order.java index 2655de6..01f67d6 100644 --- a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/Order.java +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/Order.java @@ -1,6 +1,7 @@ package com.shopsphere.orderservice.entity; import jakarta.persistence.*; +import jakarta.validation.Valid; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -44,6 +45,24 @@ public class Order { @Column(name = "item_count", nullable = false) private Integer itemCount; + /** + * US33 — where the order ships. Columns are prefixed with {@code shipping_} so a + * billing address can be embedded later without clashes. Null only for legacy orders. + */ + @Embedded + @Valid + @AttributeOverrides({ + @AttributeOverride(name = "recipientName", column = @Column(name = "shipping_recipient_name", length = 100)), + @AttributeOverride(name = "phone", column = @Column(name = "shipping_phone", length = 20)), + @AttributeOverride(name = "line1", column = @Column(name = "shipping_line1", length = 200)), + @AttributeOverride(name = "line2", column = @Column(name = "shipping_line2", length = 200)), + @AttributeOverride(name = "city", column = @Column(name = "shipping_city", length = 100)), + @AttributeOverride(name = "state", column = @Column(name = "shipping_state", length = 100)), + @AttributeOverride(name = "postalCode", column = @Column(name = "shipping_postal_code", length = 20)), + @AttributeOverride(name = "country", column = @Column(name = "shipping_country", length = 2)) + }) + private Address shippingAddress; + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private List items = new ArrayList<>(); diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/exception/GlobalExceptionHandler.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/exception/GlobalExceptionHandler.java index d325e47..a3b99e2 100644 --- a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/exception/GlobalExceptionHandler.java +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -53,6 +54,12 @@ public ResponseEntity handleValidation(MethodArgumentNotValidException return build(HttpStatus.BAD_REQUEST, "Validation Failed", "Request validation failed", req, fieldErrors); } + /** US33 — the create-order body is now required; missing or unparseable JSON is a client error. */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleUnreadable(HttpMessageNotReadableException ex, HttpServletRequest req) { + return build(HttpStatus.BAD_REQUEST, "Bad Request", "Request body is missing or malformed", req, null); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex, HttpServletRequest req) { return build(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", ex.getMessage(), req, null); diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderService.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderService.java index 0212ede..a0e51b6 100644 --- a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderService.java +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderService.java @@ -4,6 +4,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; @@ -20,10 +21,12 @@ import com.shopsphere.orderservice.client.InventoryClient; import com.shopsphere.orderservice.client.ProductClient; import com.shopsphere.orderservice.client.ProductDto; +import com.shopsphere.orderservice.dto.AddressDto; import com.shopsphere.orderservice.dto.CreateOrderRequest; import com.shopsphere.orderservice.dto.OrderItemResponse; import com.shopsphere.orderservice.dto.OrderResponse; import com.shopsphere.orderservice.dto.OrderSummaryResponse; +import com.shopsphere.orderservice.entity.Address; import com.shopsphere.orderservice.entity.Order; import com.shopsphere.orderservice.entity.OrderItem; import com.shopsphere.orderservice.entity.OrderStatus; @@ -55,6 +58,10 @@ public class OrderService { @Transactional public OrderResponse createOrder(Long userId, CreateOrderRequest request) { + // 0. Guard: the controller validates this; fail fast before any remote calls. + Objects.requireNonNull(request, "request is required"); + Objects.requireNonNull(request.getShippingAddress(), "shippingAddress is required"); + // 1. Fetch cart CartDto cart = cartClient.getCart(userId); log.debug("Fetched cart for user {} with {} items", userId, cart.getItems().size()); @@ -90,10 +97,11 @@ public OrderResponse createOrder(Long userId, CreateOrderRequest request) { productById.put(product.getId(), product); } - // 4. Build the order + // 4. Build the order (US33: snapshot the shipping address) Order order = Order.builder() .userId(userId) .status(OrderStatus.PENDING_PAYMENT) + .shippingAddress(request.getShippingAddress().toEntity()) .build(); BigDecimal computedTotal = BigDecimal.ZERO; @@ -122,8 +130,9 @@ public OrderResponse createOrder(Long userId, CreateOrderRequest request) { order.setItemCount(computedItemCount); Order saved = orderRepository.save(order); - log.info("Created order {} for user {} (total={}, items={})", - saved.getId(), userId, computedTotal, computedItemCount); + log.info("Created order {} for user {} (total={}, items={}, shipTo={}/{})", + saved.getId(), userId, computedTotal, computedItemCount, + saved.getShippingAddress().getCity(), saved.getShippingAddress().getCountry()); // 5. Build the event and ask Spring to publish it AFTER COMMIT OrderCreatedEvent event = buildOrderCreatedEvent(saved); @@ -150,8 +159,8 @@ public void onOrderPersisted(OrderPersisted persisted) { orderEventPublisher.publishOrderCreated(persisted.event()); } - /** Internal Spring event wrapper. */ - private record OrderPersisted(OrderCreatedEvent event) {} + /** Internal Spring event wrapper. Package-private so unit tests can inspect the payload. */ + record OrderPersisted(OrderCreatedEvent event) {} private OrderCreatedEvent buildOrderCreatedEvent(Order order) { List items = order.getItems().stream() @@ -172,11 +181,28 @@ private OrderCreatedEvent buildOrderCreatedEvent(Order order) { .totalAmount(order.getTotalAmount()) .itemCount(order.getItemCount()) .items(items) + .shippingAddress(toEventAddress(order.getShippingAddress())) + .build(); + } + + private static OrderCreatedEvent.ShippingAddress toEventAddress(Address address) { + if (address == null) { + return null; + } + return OrderCreatedEvent.ShippingAddress.builder() + .recipientName(address.getRecipientName()) + .phone(address.getPhone()) + .line1(address.getLine1()) + .line2(address.getLine2()) + .city(address.getCity()) + .state(address.getState()) + .postalCode(address.getPostalCode()) + .country(address.getCountry()) .build(); } /* ---------------------------------------------------------------- */ - /* Read APIs (unchanged from US14) */ + /* Read APIs */ /* ---------------------------------------------------------------- */ @Transactional(readOnly = true) @@ -210,6 +236,7 @@ public OrderResponse toResponse(Order order) { .status(order.getStatus()) .totalAmount(order.getTotalAmount()) .itemCount(order.getItemCount()) + .shippingAddress(AddressDto.from(order.getShippingAddress())) .createdAt(order.getCreatedAt()) .updatedAt(order.getUpdatedAt()) .items(order.getItems().stream() diff --git a/shopsphere/order-service/src/main/resources/application.yml b/shopsphere/order-service/src/main/resources/application.yml index 2986c7f..341be2e 100644 --- a/shopsphere/order-service/src/main/resources/application.yml +++ b/shopsphere/order-service/src/main/resources/application.yml @@ -15,9 +15,21 @@ spring: hikari: connection-init-sql: CREATE SCHEMA IF NOT EXISTS shopsphere_orders + # US33 — Flyway owns the schema from now on. + # baseline-on-migrate lets existing DBs (tables created by Hibernate before Flyway) + # be marked as V1 and only run V2+. Fresh DBs run V1 then V2. + flyway: + enabled: true + schemas: shopsphere_orders + default-schema: shopsphere_orders + locations: classpath:db/migration + baseline-on-migrate: true + baseline-version: 1 + baseline-description: Pre-Flyway Hibernate-managed schema + jpa: hibernate: - ddl-auto: update + ddl-auto: validate # Flyway migrates, Hibernate only verifies the mapping show-sql: true properties: hibernate: @@ -74,6 +86,7 @@ logging: level: com.shopsphere.orderservice: DEBUG org.hibernate.SQL: DEBUG + org.flywaydb: INFO eureka: client: diff --git a/shopsphere/order-service/src/main/resources/db/migration/V1__baseline_orders_schema.sql b/shopsphere/order-service/src/main/resources/db/migration/V1__baseline_orders_schema.sql new file mode 100644 index 0000000..4bdc737 --- /dev/null +++ b/shopsphere/order-service/src/main/resources/db/migration/V1__baseline_orders_schema.sql @@ -0,0 +1,32 @@ +-- V1: Baseline — the order schema as Hibernate generated it before Flyway (pre-US33). +-- Existing databases are baselined at V1 (spring.flyway.baseline-on-migrate) and skip this file. +-- Fresh databases run it. IF NOT EXISTS keeps it safe against partially created schemas. + +CREATE TABLE IF NOT EXISTS orders ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + status VARCHAR(30) NOT NULL + CHECK (status IN ('PENDING_PAYMENT', 'PAID', 'PAYMENT_FAILED', 'SHIPPED', 'DELIVERED', 'CANCELLED')), + total_amount NUMERIC(12, 2) NOT NULL, + item_count INTEGER NOT NULL, + created_at TIMESTAMP(6) WITH TIME ZONE, + updated_at TIMESTAMP(6) WITH TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id); +CREATE INDEX IF NOT EXISTS idx_orders_status ON orders (status); +CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders (created_at); + +CREATE TABLE IF NOT EXISTS order_items ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200) NOT NULL, + unit_price NUMERIC(12, 2) NOT NULL, + quantity INTEGER NOT NULL, + line_total NUMERIC(12, 2) NOT NULL, + CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders (id) +); + +CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items (order_id); +CREATE INDEX IF NOT EXISTS idx_order_items_product ON order_items (product_id); diff --git a/shopsphere/order-service/src/main/resources/db/migration/V2__add_shipping_address_to_orders.sql b/shopsphere/order-service/src/main/resources/db/migration/V2__add_shipping_address_to_orders.sql new file mode 100644 index 0000000..34c847b --- /dev/null +++ b/shopsphere/order-service/src/main/resources/db/migration/V2__add_shipping_address_to_orders.sql @@ -0,0 +1,15 @@ +-- V2 (US33): shipping address snapshot on orders. +-- Columns are nullable on purpose: orders placed before US33 have no address. +-- New orders are guaranteed an address by request validation + JPA pre-persist validation. + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS shipping_recipient_name VARCHAR(100), + ADD COLUMN IF NOT EXISTS shipping_phone VARCHAR(20), + ADD COLUMN IF NOT EXISTS shipping_line1 VARCHAR(200), + ADD COLUMN IF NOT EXISTS shipping_line2 VARCHAR(200), + ADD COLUMN IF NOT EXISTS shipping_city VARCHAR(100), + ADD COLUMN IF NOT EXISTS shipping_state VARCHAR(100), + ADD COLUMN IF NOT EXISTS shipping_postal_code VARCHAR(20), + ADD COLUMN IF NOT EXISTS shipping_country VARCHAR(2); + +COMMENT ON COLUMN orders.shipping_country IS 'ISO 3166-1 alpha-2, upper case'; diff --git a/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/dto/CreateOrderRequestValidationTest.java b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/dto/CreateOrderRequestValidationTest.java new file mode 100644 index 0000000..16ffd9f --- /dev/null +++ b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/dto/CreateOrderRequestValidationTest.java @@ -0,0 +1,112 @@ +package com.shopsphere.orderservice.dto; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class CreateOrderRequestValidationTest { + + private static ValidatorFactory factory; + private static Validator validator; + + @BeforeAll + static void setUp() { + factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @AfterAll + static void tearDown() { + factory.close(); + } + + private static AddressDto.AddressDtoBuilder validAddress() { + return AddressDto.builder() + .recipientName("Jane Doe") + .line1("123 King St W") + .city("Toronto") + .state("ON") + .postalCode("M5V 3L9") + .country("CA"); + } + + private Set violatedPaths(CreateOrderRequest request) { + Set> violations = validator.validate(request); + return violations.stream() + .map(v -> v.getPropertyPath().toString()) + .collect(Collectors.toSet()); + } + + @Test + void validRequest_hasNoViolations() { + CreateOrderRequest request = CreateOrderRequest.builder() + .shippingAddress(validAddress().build()) + .build(); + + assertThat(violatedPaths(request)).isEmpty(); + } + + @Test + void missingShippingAddress_isRejected() { + assertThat(violatedPaths(new CreateOrderRequest())).containsExactly("shippingAddress"); + } + + @Test + void missingRequiredAddressFields_areRejected() { + CreateOrderRequest request = CreateOrderRequest.builder() + .shippingAddress(AddressDto.builder().build()) + .build(); + + assertThat(violatedPaths(request)).containsExactlyInAnyOrder( + "shippingAddress.recipientName", + "shippingAddress.line1", + "shippingAddress.city", + "shippingAddress.postalCode", + "shippingAddress.country"); + } + + @Test + void invalidCountryCode_isRejected() { + CreateOrderRequest request = CreateOrderRequest.builder() + .shippingAddress(validAddress().country("CAN").build()) + .build(); + + assertThat(violatedPaths(request)).containsExactly("shippingAddress.country"); + } + + @Test + void invalidPostalCode_isRejected() { + CreateOrderRequest request = CreateOrderRequest.builder() + .shippingAddress(validAddress().postalCode("M5V#3L9").build()) + .build(); + + assertThat(violatedPaths(request)).containsExactly("shippingAddress.postalCode"); + } + + @Test + void invalidPhone_isRejected() { + CreateOrderRequest request = CreateOrderRequest.builder() + .shippingAddress(validAddress().phone("call me").build()) + .build(); + + assertThat(violatedPaths(request)).containsExactly("shippingAddress.phone"); + } + + @Test + void tooLongLine1_isRejected() { + CreateOrderRequest request = CreateOrderRequest.builder() + .shippingAddress(validAddress().line1("x".repeat(201)).build()) + .build(); + + assertThat(violatedPaths(request)).containsExactly("shippingAddress.line1"); + } +} diff --git a/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/service/OrderServiceTest.java b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/service/OrderServiceTest.java index af361d8..ecc8ccc 100644 --- a/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/service/OrderServiceTest.java +++ b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/service/OrderServiceTest.java @@ -1,12 +1,18 @@ package com.shopsphere.orderservice.service; +import com.shopsphere.common.events.OrderCreatedEvent; import com.shopsphere.orderservice.client.CartClient; import com.shopsphere.orderservice.client.CartDto; import com.shopsphere.orderservice.client.CartItemDto; import com.shopsphere.orderservice.client.InventoryClient; import com.shopsphere.orderservice.client.ProductClient; +import com.shopsphere.orderservice.client.ProductDto; +import com.shopsphere.orderservice.dto.AddressDto; import com.shopsphere.orderservice.dto.CreateOrderRequest; +import com.shopsphere.orderservice.dto.OrderResponse; +import com.shopsphere.orderservice.entity.Address; import com.shopsphere.orderservice.entity.Order; +import com.shopsphere.orderservice.entity.OrderStatus; import com.shopsphere.orderservice.exception.InsufficientStockException; import com.shopsphere.orderservice.exception.OrderAccessDeniedException; import com.shopsphere.orderservice.exception.OrderNotFoundException; @@ -14,6 +20,7 @@ import com.shopsphere.orderservice.repository.OrderRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -23,8 +30,12 @@ import java.util.List; import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -39,6 +50,85 @@ class OrderServiceTest { @InjectMocks OrderService orderService; + private static AddressDto validAddress() { + return AddressDto.builder() + .recipientName(" Jane Doe ") + .phone("+1 416 555 0199") + .line1("123 King St W") + .line2(" ") // blank optional field -> stored as null + .city("Toronto") + .state("ON") + .postalCode("m5v 3l9") // normalised to upper case + .country("ca") // normalised to upper case + .build(); + } + + private static CreateOrderRequest requestWithAddress() { + return CreateOrderRequest.builder().shippingAddress(validAddress()).build(); + } + + @Test + void createOrder_happyPath_persistsShippingAddressAndPublishesItInEvent() { + CartDto cart = new CartDto(1L, + List.of(new CartItemDto(10L, "Keyboard", new BigDecimal("50.00"), 2)), + new BigDecimal("100.00"), 2); + when(cartClient.getCart(1L)).thenReturn(cart); + + var ia = new InventoryClient.ItemAvailability(10L, 2, 5, true, null); + when(inventoryClient.checkAvailability(any())) + .thenReturn(new InventoryClient.AvailabilityResult(true, List.of(ia))); + + when(productClient.fetchProduct(10L)) + .thenReturn(new ProductDto(10L, "Keyboard", new BigDecimal("50.00"), 5, true)); + + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> { + Order o = inv.getArgument(0); + o.setId(42L); + return o; + }); + + OrderResponse response = orderService.createOrder(1L, requestWithAddress()); + + // Persisted entity carries the normalised address + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(Order.class); + verify(orderRepository).save(orderCaptor.capture()); + Address saved = orderCaptor.getValue().getShippingAddress(); + assertThat(saved).isNotNull(); + assertThat(saved.getRecipientName()).isEqualTo("Jane Doe"); + assertThat(saved.getLine2()).isNull(); + assertThat(saved.getPostalCode()).isEqualTo("M5V 3L9"); + assertThat(saved.getCountry()).isEqualTo("CA"); + assertThat(orderCaptor.getValue().getStatus()).isEqualTo(OrderStatus.PENDING_PAYMENT); + + // order.created payload carries the same address + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + OrderCreatedEvent event = ((OrderService.OrderPersisted) eventCaptor.getValue()).event(); + assertThat(event.getOrderId()).isEqualTo(42L); + assertThat(event.getShippingAddress()).isNotNull(); + assertThat(event.getShippingAddress().getRecipientName()).isEqualTo("Jane Doe"); + assertThat(event.getShippingAddress().getLine1()).isEqualTo("123 King St W"); + assertThat(event.getShippingAddress().getCity()).isEqualTo("Toronto"); + assertThat(event.getShippingAddress().getPostalCode()).isEqualTo("M5V 3L9"); + assertThat(event.getShippingAddress().getCountry()).isEqualTo("CA"); + + // API response exposes it too + assertThat(response.getShippingAddress()).isNotNull(); + assertThat(response.getShippingAddress().getCity()).isEqualTo("Toronto"); + assertThat(response.getTotalAmount()).isEqualByComparingTo("100.00"); + + verify(cartClient).clearCart(1L); + } + + @Test + void createOrder_missingShippingAddress_failsFastWithoutRemoteCalls() { + assertThatThrownBy(() -> orderService.createOrder(1L, new CreateOrderRequest())) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("shippingAddress"); + + verifyNoInteractions(cartClient, inventoryClient, productClient, orderRepository); + } + @Test void createOrder_stockShort_throwsAndDoesNotPersist() { CartDto cart = new CartDto(1L, @@ -50,8 +140,35 @@ void createOrder_stockShort_throwsAndDoesNotPersist() { var result = new InventoryClient.AvailabilityResult(false, List.of(ia)); when(inventoryClient.checkAvailability(any())).thenReturn(result); - assertThatThrownBy(() -> orderService.createOrder(1L, new CreateOrderRequest())) + assertThatThrownBy(() -> orderService.createOrder(1L, requestWithAddress())) .isInstanceOf(InsufficientStockException.class); + + verify(orderRepository, never()).save(any()); + } + + @Test + void getOrderById_returnsShippingAddress() { + Order order = Order.builder() + .id(1L).userId(1L).status(OrderStatus.PAID) + .totalAmount(new BigDecimal("10.00")).itemCount(1) + .shippingAddress(validAddress().toEntity()) + .build(); + when(orderRepository.findWithItemsById(1L)).thenReturn(Optional.of(order)); + + OrderResponse response = orderService.getOrderById(1L, 1L); + + assertThat(response.getShippingAddress().getCountry()).isEqualTo("CA"); + } + + @Test + void getOrderById_legacyOrderWithoutAddress_returnsNullAddress() { + Order order = Order.builder() + .id(2L).userId(1L).status(OrderStatus.PAID) + .totalAmount(new BigDecimal("10.00")).itemCount(1) + .build(); + when(orderRepository.findWithItemsById(2L)).thenReturn(Optional.of(order)); + + assertThat(orderService.getOrderById(1L, 2L).getShippingAddress()).isNull(); } @Test diff --git a/shopsphere/shipping-service/.config/dotnet-tools.json b/shopsphere/shipping-service/.config/dotnet-tools.json new file mode 100644 index 0000000..eb89411 --- /dev/null +++ b/shopsphere/shipping-service/.config/dotnet-tools.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "8.0.10", + "commands": ["dotnet-ef"] + } + } +} diff --git a/shopsphere/shipping-service/.dockerignore b/shopsphere/shipping-service/.dockerignore new file mode 100644 index 0000000..d315498 --- /dev/null +++ b/shopsphere/shipping-service/.dockerignore @@ -0,0 +1,5 @@ +**/bin/ +**/obj/ +**/TestResults/ +.vs/ +tests/ diff --git a/shopsphere/shipping-service/.gitignore b/shopsphere/shipping-service/.gitignore new file mode 100644 index 0000000..a5ef86d --- /dev/null +++ b/shopsphere/shipping-service/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +*.user +*.suo +.vs/ +TestResults/ diff --git a/shopsphere/shipping-service/Dockerfile b/shopsphere/shipping-service/Dockerfile new file mode 100644 index 0000000..f9c1ad3 --- /dev/null +++ b/shopsphere/shipping-service/Dockerfile @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1 +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +# Restore first for layer caching +COPY src/ShippingService.Api/ShippingService.Api.csproj src/ShippingService.Api/ +RUN dotnet restore src/ShippingService.Api/ShippingService.Api.csproj + +COPY src/ src/ +RUN dotnet publish src/ShippingService.Api/ShippingService.Api.csproj \ + -c Release -o /app/publish --no-restore /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +USER app +ENTRYPOINT ["dotnet", "ShippingService.Api.dll"] diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Contracts/ShipmentResponse.cs b/shopsphere/shipping-service/src/ShippingService.Api/Contracts/ShipmentResponse.cs new file mode 100644 index 0000000..e44ac2e --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Contracts/ShipmentResponse.cs @@ -0,0 +1,36 @@ +using ShippingService.Api.Domain; + +namespace ShippingService.Api.Contracts; + +public sealed record AddressResponse( + string RecipientName, + string? Phone, + string Line1, + string? Line2, + string City, + string? State, + string PostalCode, + string Country) +{ + public static AddressResponse From(ShippingAddress a) => + new(a.RecipientName, a.Phone, a.Line1, a.Line2, a.City, a.State, a.PostalCode, a.Country); +} + +public sealed record ShipmentResponse( + long Id, + long OrderId, + long UserId, + ShipmentStatus Status, + string? Carrier, + string? TrackingNumber, + AddressResponse ShippingAddress, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + DateTimeOffset? ShippedAt, + DateTimeOffset? DeliveredAt) +{ + public static ShipmentResponse From(Shipment s) => + new(s.Id, s.OrderId, s.UserId, s.Status, s.Carrier, s.TrackingNumber, + AddressResponse.From(s.ShippingAddress), + s.CreatedAt, s.UpdatedAt, s.ShippedAt, s.DeliveredAt); +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Data/ShipmentConfiguration.cs b/shopsphere/shipping-service/src/ShippingService.Api/Data/ShipmentConfiguration.cs new file mode 100644 index 0000000..532997d --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Data/ShipmentConfiguration.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using ShippingService.Api.Domain; + +namespace ShippingService.Api.Data; + +internal sealed class ShipmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("shipments"); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).HasColumnName("id"); + + builder.Property(s => s.OrderId).HasColumnName("order_id").IsRequired(); + builder.HasIndex(s => s.OrderId).IsUnique().HasDatabaseName("uk_shipments_order"); + + builder.Property(s => s.UserId).HasColumnName("user_id").IsRequired(); + builder.HasIndex(s => s.UserId).HasDatabaseName("idx_shipments_user"); + + builder.Property(s => s.Status) + .HasColumnName("status") + .HasMaxLength(30) + .HasConversion( + v => v.ToString().ToUpperInvariant(), + v => Enum.Parse(v, true)) + .IsRequired(); + builder.HasIndex(s => s.Status).HasDatabaseName("idx_shipments_status"); + + builder.Property(s => s.Carrier).HasColumnName("carrier").HasMaxLength(100); + builder.Property(s => s.TrackingNumber).HasColumnName("tracking_number").HasMaxLength(100); + + builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired(); + builder.Property(s => s.UpdatedAt).HasColumnName("updated_at").IsRequired(); + builder.Property(s => s.ShippedAt).HasColumnName("shipped_at"); + builder.Property(s => s.DeliveredAt).HasColumnName("delivered_at"); + + builder.OwnsOne(s => s.ShippingAddress, address => + { + address.Property(a => a.RecipientName).HasColumnName("shipping_recipient_name") + .HasMaxLength(ShippingAddress.RecipientNameMaxLength).IsRequired(); + address.Property(a => a.Phone).HasColumnName("shipping_phone") + .HasMaxLength(ShippingAddress.PhoneMaxLength); + address.Property(a => a.Line1).HasColumnName("shipping_line1") + .HasMaxLength(ShippingAddress.LineMaxLength).IsRequired(); + address.Property(a => a.Line2).HasColumnName("shipping_line2") + .HasMaxLength(ShippingAddress.LineMaxLength); + address.Property(a => a.City).HasColumnName("shipping_city") + .HasMaxLength(ShippingAddress.CityMaxLength).IsRequired(); + address.Property(a => a.State).HasColumnName("shipping_state") + .HasMaxLength(ShippingAddress.StateMaxLength); + address.Property(a => a.PostalCode).HasColumnName("shipping_postal_code") + .HasMaxLength(ShippingAddress.PostalCodeMaxLength).IsRequired(); + address.Property(a => a.Country).HasColumnName("shipping_country") + .HasMaxLength(ShippingAddress.CountryLength).IsRequired(); + }); + builder.Navigation(s => s.ShippingAddress).IsRequired(); + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Data/ShippingDbContext.cs b/shopsphere/shipping-service/src/ShippingService.Api/Data/ShippingDbContext.cs new file mode 100644 index 0000000..248ef5f --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Data/ShippingDbContext.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using ShippingService.Api.Domain; + +namespace ShippingService.Api.Data; + +public sealed class ShippingDbContext(DbContextOptions options) : DbContext(options) +{ + /// Schema-per-service inside the shared "shopsphere" database, like the Java services. + public const string Schema = "shopsphere_shipping"; + + public DbSet Shipments => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(Schema); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ShippingDbContext).Assembly); + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + StampTimestamps(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + StampTimestamps(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + private void StampTimestamps() + { + var now = DateTimeOffset.UtcNow; + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Property(s => s.CreatedAt).CurrentValue = now; + entry.Property(s => s.UpdatedAt).CurrentValue = now; + } + else if (entry.State == EntityState.Modified) + { + entry.Property(s => s.UpdatedAt).CurrentValue = now; + } + } + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Data/ShippingDbContextFactory.cs b/shopsphere/shipping-service/src/ShippingService.Api/Data/ShippingDbContextFactory.cs new file mode 100644 index 0000000..f17320a --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Data/ShippingDbContextFactory.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace ShippingService.Api.Data; + +/// +/// Used only by `dotnet ef` at design time so migrations don't boot the app +/// (and don't try to reach Eureka / Config Server). +/// +internal sealed class ShippingDbContextFactory : IDesignTimeDbContextFactory +{ + public ShippingDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__ShippingDb") + ?? "Host=localhost;Port=5432;Database=shopsphere;Username=shopsphere;Password=shopsphere"; + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npgsql => + npgsql.MigrationsHistoryTable("__EFMigrationsHistory", ShippingDbContext.Schema)) + .Options; + + return new ShippingDbContext(options); + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Domain/Shipment.cs b/shopsphere/shipping-service/src/ShippingService.Api/Domain/Shipment.cs new file mode 100644 index 0000000..7dcf133 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Domain/Shipment.cs @@ -0,0 +1,71 @@ +namespace ShippingService.Api.Domain; + +/// +/// One shipment per order. Lifecycle: PENDING → SHIPPED → DELIVERED, or PENDING → CANCELLED. +/// Timestamps (CreatedAt/UpdatedAt) are stamped by ShippingDbContext on save. +/// +public sealed class Shipment +{ + private Shipment() { } // EF Core + + public long Id { get; private set; } + public long OrderId { get; private set; } + public long UserId { get; private set; } + public ShipmentStatus Status { get; private set; } + public string? Carrier { get; private set; } + public string? TrackingNumber { get; private set; } + public ShippingAddress ShippingAddress { get; private set; } = null!; + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset UpdatedAt { get; private set; } + public DateTimeOffset? ShippedAt { get; private set; } + public DateTimeOffset? DeliveredAt { get; private set; } + + public static Shipment Create(long orderId, long userId, ShippingAddress shippingAddress) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(orderId); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(userId); + ArgumentNullException.ThrowIfNull(shippingAddress); + + return new Shipment + { + OrderId = orderId, + UserId = userId, + ShippingAddress = shippingAddress, + Status = ShipmentStatus.Pending + }; + } + + public void MarkShipped(string carrier, string trackingNumber, DateTimeOffset shippedAt) + { + EnsureStatus(ShipmentStatus.Pending, "ship"); + ArgumentException.ThrowIfNullOrWhiteSpace(carrier); + ArgumentException.ThrowIfNullOrWhiteSpace(trackingNumber); + + Carrier = carrier.Trim(); + TrackingNumber = trackingNumber.Trim(); + ShippedAt = shippedAt; + Status = ShipmentStatus.Shipped; + } + + public void MarkDelivered(DateTimeOffset deliveredAt) + { + EnsureStatus(ShipmentStatus.Shipped, "deliver"); + DeliveredAt = deliveredAt; + Status = ShipmentStatus.Delivered; + } + + public void Cancel() + { + EnsureStatus(ShipmentStatus.Pending, "cancel"); + Status = ShipmentStatus.Cancelled; + } + + private void EnsureStatus(ShipmentStatus expected, string action) + { + if (Status != expected) + { + throw new InvalidOperationException( + $"Cannot {action} shipment {Id} in status {Status}; expected {expected}."); + } + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Domain/ShipmentStatus.cs b/shopsphere/shipping-service/src/ShippingService.Api/Domain/ShipmentStatus.cs new file mode 100644 index 0000000..bd806a9 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Domain/ShipmentStatus.cs @@ -0,0 +1,10 @@ +namespace ShippingService.Api.Domain; + +/// Stored and serialised upper case (PENDING, SHIPPED, ...) to match the Java services. +public enum ShipmentStatus +{ + Pending, + Shipped, + Delivered, + Cancelled +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Domain/ShippingAddress.cs b/shopsphere/shipping-service/src/ShippingService.Api/Domain/ShippingAddress.cs new file mode 100644 index 0000000..10081ae --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Domain/ShippingAddress.cs @@ -0,0 +1,89 @@ +using System.Text.RegularExpressions; + +namespace ShippingService.Api.Domain; + +/// +/// Owned value object. Mirrors the order-service Address (US33) so the order.created +/// payload maps across 1:1. +/// +public sealed class ShippingAddress +{ + public const int RecipientNameMaxLength = 100; + public const int PhoneMaxLength = 20; + public const int LineMaxLength = 200; + public const int CityMaxLength = 100; + public const int StateMaxLength = 100; + public const int PostalCodeMaxLength = 20; + public const int CountryLength = 2; + + private static readonly Regex CountryPattern = new("^[A-Z]{2}$", RegexOptions.Compiled); + + private ShippingAddress() { } // EF Core + + public string RecipientName { get; private set; } = null!; + public string? Phone { get; private set; } + public string Line1 { get; private set; } = null!; + public string? Line2 { get; private set; } + public string City { get; private set; } = null!; + public string? State { get; private set; } + public string PostalCode { get; private set; } = null!; + /// ISO 3166-1 alpha-2, upper case. + public string Country { get; private set; } = null!; + + public static ShippingAddress Create( + string recipientName, + string line1, + string city, + string postalCode, + string country, + string? line2 = null, + string? state = null, + string? phone = null) + { + var normalisedCountry = Required(country, nameof(country), CountryLength).ToUpperInvariant(); + if (!CountryPattern.IsMatch(normalisedCountry)) + { + throw new ArgumentException("country must be a 2-letter ISO 3166-1 alpha-2 code", nameof(country)); + } + + return new ShippingAddress + { + RecipientName = Required(recipientName, nameof(recipientName), RecipientNameMaxLength), + Phone = Optional(phone, nameof(phone), PhoneMaxLength), + Line1 = Required(line1, nameof(line1), LineMaxLength), + Line2 = Optional(line2, nameof(line2), LineMaxLength), + City = Required(city, nameof(city), CityMaxLength), + State = Optional(state, nameof(state), StateMaxLength), + PostalCode = Required(postalCode, nameof(postalCode), PostalCodeMaxLength).ToUpperInvariant(), + Country = normalisedCountry + }; + } + + private static string Required(string? value, string name, int maxLength) + { + var trimmed = value?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + throw new ArgumentException($"{name} is required", name); + } + if (trimmed.Length > maxLength) + { + throw new ArgumentException($"{name} must be at most {maxLength} characters", name); + } + return trimmed; + } + + private static string? Optional(string? value, string name, int maxLength) + { + var trimmed = value?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + return null; + } + if (trimmed.Length > maxLength) + { + throw new ArgumentException($"{name} must be at most {maxLength} characters", name); + } + return trimmed; + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Endpoints/ShipmentEndpoints.cs b/shopsphere/shipping-service/src/ShippingService.Api/Endpoints/ShipmentEndpoints.cs new file mode 100644 index 0000000..72ba597 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Endpoints/ShipmentEndpoints.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using ShippingService.Api.Contracts; +using ShippingService.Api.Data; +using ShippingService.Api.Domain; + +namespace ShippingService.Api.Endpoints; + +/// +/// Read-only for now. Shipments are created from order/payment events in a later story. +/// X-User-Id is injected by the API Gateway from the JWT (same contract as order-service). +/// +public static class ShipmentEndpoints +{ + public static IEndpointRouteBuilder MapShipmentEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/shipments").WithTags("Shipments"); + + group.MapGet("/{shipmentId:long}", GetByIdAsync) + .WithName("GetShipmentById") + .WithSummary("Get a shipment by id (owner only)"); + + group.MapGet("/order/{orderId:long}", GetByOrderIdAsync) + .WithName("GetShipmentByOrderId") + .WithSummary("Get the shipment for an order (owner only)"); + + return app; + } + + internal static async Task GetByIdAsync( + long shipmentId, + [FromHeader(Name = "X-User-Id")] long userId, + ShippingDbContext db, + CancellationToken ct) + { + var shipment = await db.Shipments.AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == shipmentId, ct); + return ToResult(shipment, userId, $"Shipment {shipmentId} not found"); + } + + internal static async Task GetByOrderIdAsync( + long orderId, + [FromHeader(Name = "X-User-Id")] long userId, + ShippingDbContext db, + CancellationToken ct) + { + var shipment = await db.Shipments.AsNoTracking() + .FirstOrDefaultAsync(s => s.OrderId == orderId, ct); + return ToResult(shipment, userId, $"No shipment found for order {orderId}"); + } + + private static IResult ToResult(Shipment? shipment, long userId, string notFoundMessage) + { + if (shipment is null) + { + return TypedResults.Problem(title: "Not Found", detail: notFoundMessage, + statusCode: StatusCodes.Status404NotFound); + } + if (shipment.UserId != userId) + { + return TypedResults.Problem(title: "Forbidden", detail: "Shipment belongs to another user", + statusCode: StatusCodes.Status403Forbidden); + } + return TypedResults.Ok(ShipmentResponse.From(shipment)); + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Health/HealthResponseWriter.cs b/shopsphere/shipping-service/src/ShippingService.Api/Health/HealthResponseWriter.cs new file mode 100644 index 0000000..6d6d00d --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Health/HealthResponseWriter.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ShippingService.Api.Health; + +/// Spring-style health JSON (UP / DOWN) so all services report the same way. +public static class HealthResponseWriter +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public static Task WriteAsync(HttpContext context, HealthReport report) + { + context.Response.ContentType = "application/json; charset=utf-8"; + + var payload = new + { + service = "shipping-service", + status = ToStatus(report.Status), + totalDurationMs = Math.Round(report.TotalDuration.TotalMilliseconds, 2), + checks = report.Entries.Select(e => new + { + name = e.Key, + status = ToStatus(e.Value.Status), + description = e.Value.Description, + durationMs = Math.Round(e.Value.Duration.TotalMilliseconds, 2) + }) + }; + + return context.Response.WriteAsync(JsonSerializer.Serialize(payload, JsonOptions)); + } + + public static string ToStatus(HealthStatus status) => status switch + { + HealthStatus.Healthy => "UP", + HealthStatus.Degraded => "DEGRADED", + _ => "DOWN" + }; +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Program.cs b/shopsphere/shipping-service/src/ShippingService.Api/Program.cs new file mode 100644 index 0000000..deccdad --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Program.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ShippingService.Api.Data; +using ShippingService.Api.Endpoints; +using ShippingService.Api.Health; +using Steeltoe.Discovery.Client; +using Steeltoe.Extensions.Configuration.ConfigServer; + +var builder = WebApplication.CreateBuilder(args); + +// ---- Spring Cloud Config client (config-service :8888) ---- +// Pulls shared application.yml + shipping-service.yml. Toggle with spring:cloud:config:enabled. +builder.AddConfigServer(); + +// ---- Eureka registration + discovery (service-registry :8761) ---- +builder.Services.AddDiscoveryClient(builder.Configuration); + +// ---- EF Core + Postgres (schema shopsphere_shipping) ---- +var connectionString = builder.Configuration.GetConnectionString("ShippingDb") + ?? throw new InvalidOperationException("Connection string 'ShippingDb' is not configured."); + +builder.Services.AddDbContext(options => + options.UseNpgsql(connectionString, npgsql => + { + npgsql.MigrationsHistoryTable("__EFMigrationsHistory", ShippingDbContext.Schema); + npgsql.EnableRetryOnFailure(maxRetryCount: 5); + })); + +// ---- Health checks ---- +builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy("shipping-service is running"), tags: ["live"]) + .AddDbContextCheck("database", tags: ["ready"]); + +// ---- HTTP plumbing ---- +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper))); +builder.Services.AddProblemDetails(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +app.UseExceptionHandler(); +app.UseStatusCodePages(); + +app.UseSwagger(); +app.UseSwaggerUI(); + +// /health -> all checks (used by Eureka + Docker) +// /health/live -> process only, no DB +// /api/v1/shipments/health -> same as /health, reachable through the API Gateway +app.MapHealthChecks("/health", new HealthCheckOptions { ResponseWriter = HealthResponseWriter.WriteAsync }); +app.MapHealthChecks("/health/live", new HealthCheckOptions +{ + Predicate = r => r.Tags.Contains("live"), + ResponseWriter = HealthResponseWriter.WriteAsync +}); +app.MapHealthChecks("/api/v1/shipments/health", new HealthCheckOptions { ResponseWriter = HealthResponseWriter.WriteAsync }); + +app.MapShipmentEndpoints(); + +await ApplyMigrationsAsync(app); + +await app.RunAsync(); + +static async Task ApplyMigrationsAsync(WebApplication app) +{ + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (db.Database.IsRelational()) + { + await db.Database.MigrateAsync(); + } +} + +// Exposed for WebApplicationFactory in the test project. +public partial class Program { } diff --git a/shopsphere/shipping-service/src/ShippingService.Api/ShippingService.Api.csproj b/shopsphere/shipping-service/src/ShippingService.Api/ShippingService.Api.csproj new file mode 100644 index 0000000..96e93fb --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/ShippingService.Api.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + ShippingService.Api + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/shopsphere/shipping-service/src/ShippingService.Api/appsettings.Development.json b/shopsphere/shipping-service/src/ShippingService.Api/appsettings.Development.json new file mode 100644 index 0000000..df15b8d --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "ShippingService": "Debug", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/appsettings.Testing.json b/shopsphere/shipping-service/src/ShippingService.Api/appsettings.Testing.json new file mode 100644 index 0000000..edd5a68 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/appsettings.Testing.json @@ -0,0 +1,15 @@ +{ + "spring": { + "cloud": { + "config": { + "enabled": false + } + } + }, + "eureka": { + "client": { + "shouldRegisterWithEureka": false, + "shouldFetchRegistry": false + } + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/appsettings.json b/shopsphere/shipping-service/src/ShippingService.Api/appsettings.json new file mode 100644 index 0000000..b83761e --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/appsettings.json @@ -0,0 +1,43 @@ +{ + "Urls": "http://0.0.0.0:8088", + "AllowedHosts": "*", + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning", + "Steeltoe": "Information" + } + }, + "ConnectionStrings": { + "ShippingDb": "Host=localhost;Port=5432;Database=shopsphere;Username=shopsphere;Password=shopsphere" + }, + "spring": { + "application": { + "name": "shipping-service" + }, + "cloud": { + "config": { + "uri": "http://localhost:8888", + "enabled": true, + "failFast": false, + "validateCertificates": false + } + } + }, + "eureka": { + "client": { + "serviceUrl": "http://localhost:8761/eureka/", + "shouldRegisterWithEureka": true, + "shouldFetchRegistry": true, + "validateCertificates": false + }, + "instance": { + "appName": "shipping-service", + "port": 8088, + "preferIpAddress": true, + "healthCheckUrlPath": "/health", + "statusPageUrlPath": "/health" + } + } +} diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/HealthEndpointTests.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/HealthEndpointTests.cs new file mode 100644 index 0000000..4c48094 --- /dev/null +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/HealthEndpointTests.cs @@ -0,0 +1,50 @@ +using System.Net; +using System.Text.Json; + +namespace ShippingService.Tests; + +public sealed class HealthEndpointTests(ShippingApiFactory factory) : IClassFixture +{ + [Theory] + [InlineData("/health")] + [InlineData("/health/live")] + [InlineData("/api/v1/shipments/health")] + public async Task HealthEndpoints_ReturnUp(string path) + { + var client = factory.CreateClient(); + + var response = await client.GetAsync(path); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("UP", json.RootElement.GetProperty("status").GetString()); + Assert.Equal("shipping-service", json.RootElement.GetProperty("service").GetString()); + } + + [Fact] + public async Task Health_IncludesDatabaseCheck() + { + var client = factory.CreateClient(); + + using var json = JsonDocument.Parse(await client.GetStringAsync("/health")); + var checks = json.RootElement.GetProperty("checks").EnumerateArray() + .Select(c => c.GetProperty("name").GetString()) + .ToList(); + + Assert.Contains("self", checks); + Assert.Contains("database", checks); + } + + [Fact] + public async Task LiveHealth_ExcludesDatabaseCheck() + { + var client = factory.CreateClient(); + + using var json = JsonDocument.Parse(await client.GetStringAsync("/health/live")); + var checks = json.RootElement.GetProperty("checks").EnumerateArray() + .Select(c => c.GetProperty("name").GetString()) + .ToList(); + + Assert.Equal(["self"], checks); + } +} diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentEndpointsTests.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentEndpointsTests.cs new file mode 100644 index 0000000..1582786 --- /dev/null +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentEndpointsTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Text.Json; +using ShippingService.Api.Domain; + +namespace ShippingService.Tests; + +public sealed class ShipmentEndpointsTests(ShippingApiFactory factory) : IClassFixture +{ + private static Shipment NewShipment(long orderId, long userId) => + Shipment.Create(orderId, userId, + ShippingAddress.Create("Jane Doe", "123 King St W", "Toronto", "m5v 3l9", "ca", state: "ON")); + + private HttpClient ClientFor(long userId) + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-User-Id", userId.ToString()); + return client; + } + + [Fact] + public async Task GetByOrderId_Owner_ReturnsShipment() + { + await factory.SeedAsync(NewShipment(orderId: 1001, userId: 7)); + + var response = await ClientFor(7).GetAsync("/api/v1/shipments/order/1001"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = json.RootElement; + Assert.Equal(1001, root.GetProperty("orderId").GetInt64()); + Assert.Equal("PENDING", root.GetProperty("status").GetString()); + Assert.Equal("CA", root.GetProperty("shippingAddress").GetProperty("country").GetString()); + Assert.Equal("M5V 3L9", root.GetProperty("shippingAddress").GetProperty("postalCode").GetString()); + } + + [Fact] + public async Task GetById_Owner_ReturnsShipment() + { + var seeded = await factory.SeedAsync(NewShipment(orderId: 1002, userId: 7)); + + var response = await ClientFor(7).GetAsync($"/api/v1/shipments/{seeded.Id}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetByOrderId_OtherUser_Returns403() + { + await factory.SeedAsync(NewShipment(orderId: 1003, userId: 7)); + + var response = await ClientFor(99).GetAsync("/api/v1/shipments/order/1003"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task GetByOrderId_Unknown_Returns404() + { + var response = await ClientFor(7).GetAsync("/api/v1/shipments/order/999999"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetByOrderId_MissingUserHeader_Returns400() + { + var response = await factory.CreateClient().GetAsync("/api/v1/shipments/order/1001"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentTests.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentTests.cs new file mode 100644 index 0000000..8326357 --- /dev/null +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentTests.cs @@ -0,0 +1,97 @@ +using ShippingService.Api.Domain; + +namespace ShippingService.Tests; + +public sealed class ShipmentTests +{ + private static ShippingAddress ValidAddress() => + ShippingAddress.Create(" Jane Doe ", "123 King St W", "Toronto", "m5v 3l9", "ca", line2: " ", state: "ON"); + + [Fact] + public void Create_StartsPending_WithNormalisedAddress() + { + var shipment = Shipment.Create(1, 2, ValidAddress()); + + Assert.Equal(ShipmentStatus.Pending, shipment.Status); + Assert.Equal("Jane Doe", shipment.ShippingAddress.RecipientName); + Assert.Null(shipment.ShippingAddress.Line2); + Assert.Equal("M5V 3L9", shipment.ShippingAddress.PostalCode); + Assert.Equal("CA", shipment.ShippingAddress.Country); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(1, 0)] + [InlineData(-1, 1)] + public void Create_InvalidIds_Throws(long orderId, long userId) + { + Assert.Throws(() => Shipment.Create(orderId, userId, ValidAddress())); + } + + [Fact] + public void Create_NullAddress_Throws() + { + Assert.Throws(() => Shipment.Create(1, 1, null!)); + } + + [Fact] + public void HappyPath_PendingToShippedToDelivered() + { + var shipment = Shipment.Create(1, 2, ValidAddress()); + var shippedAt = DateTimeOffset.UtcNow; + + shipment.MarkShipped(" ShopSphere Express ", " TRK123 ", shippedAt); + Assert.Equal(ShipmentStatus.Shipped, shipment.Status); + Assert.Equal("ShopSphere Express", shipment.Carrier); + Assert.Equal("TRK123", shipment.TrackingNumber); + Assert.Equal(shippedAt, shipment.ShippedAt); + + shipment.MarkDelivered(shippedAt.AddDays(2)); + Assert.Equal(ShipmentStatus.Delivered, shipment.Status); + Assert.NotNull(shipment.DeliveredAt); + } + + [Fact] + public void MarkDelivered_WhenPending_Throws() + { + var shipment = Shipment.Create(1, 2, ValidAddress()); + + Assert.Throws(() => shipment.MarkDelivered(DateTimeOffset.UtcNow)); + } + + [Fact] + public void Cancel_AfterShipping_Throws() + { + var shipment = Shipment.Create(1, 2, ValidAddress()); + shipment.MarkShipped("Carrier", "TRK", DateTimeOffset.UtcNow); + + Assert.Throws(() => shipment.Cancel()); + } + + [Fact] + public void Cancel_WhenPending_Cancels() + { + var shipment = Shipment.Create(1, 2, ValidAddress()); + + shipment.Cancel(); + + Assert.Equal(ShipmentStatus.Cancelled, shipment.Status); + } + + [Theory] + [InlineData("CAN")] + [InlineData("C")] + [InlineData("1A")] + public void Address_InvalidCountry_Throws(string country) + { + Assert.Throws(() => + ShippingAddress.Create("Jane", "Line 1", "Toronto", "M5V3L9", country)); + } + + [Fact] + public void Address_MissingRequiredField_Throws() + { + Assert.Throws(() => + ShippingAddress.Create("Jane", " ", "Toronto", "M5V3L9", "CA")); + } +} diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/ShippingApiFactory.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/ShippingApiFactory.cs new file mode 100644 index 0000000..db6fdb4 --- /dev/null +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/ShippingApiFactory.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ShippingService.Api.Data; +using ShippingService.Api.Domain; + +namespace ShippingService.Tests; + +/// +/// Boots the real app in the "Testing" environment (Config Server + Eureka disabled via +/// appsettings.Testing.json) and swaps Postgres for an isolated in-memory database. +/// +public sealed class ShippingApiFactory : WebApplicationFactory +{ + private readonly string _databaseName = $"shipping-tests-{Guid.NewGuid()}"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.ConfigureTestServices(services => + { + services.RemoveAll>(); + services.AddDbContext(options => options.UseInMemoryDatabase(_databaseName)); + }); + } + + public async Task SeedAsync(Shipment shipment) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Shipments.Add(shipment); + await db.SaveChangesAsync(); + return shipment; + } +} diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/ShippingService.Tests.csproj b/shopsphere/shipping-service/tests/ShippingService.Tests/ShippingService.Tests.csproj new file mode 100644 index 0000000..80f9599 --- /dev/null +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/ShippingService.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + From b14b96720c9634845de36a7eec60f933faaa6362 Mon Sep 17 00:00:00 2001 From: Dhruv Patel Date: Sat, 26 Sep 2026 03:04:59 -0400 Subject: [PATCH 3/8] Create-shipment-on-payment.successful --- .../common/events/PaymentSuccessfulEvent.java | 6 + .../events/ShipmentDispatchedEvent.java | 35 ++++ .../com/shopsphere/common/events/Topics.java | 10 +- .../resources/config/shipping-service.yml | 6 +- shopsphere/docker-compose.yml | 3 + .../paymentservice/dto/PaymentRequest.java | 4 + .../service/PaymentService.java | 11 +- .../service/PaymentServiceTest.java | 72 +++++++- .../Data/ProcessedEventConfiguration.cs | 22 +++ .../Data/ShipmentConfiguration.cs | 2 + .../Data/ShippingDbContext.cs | 1 + .../Messaging/EventJson.cs | 16 ++ .../Messaging/Events/EventAddress.cs | 18 ++ .../Events/PaymentSuccessfulEvent.cs | 17 ++ .../Events/ShipmentDispatchedEvent.cs | 48 +++++ .../Messaging/IShipmentEventPublisher.cs | 9 + .../Messaging/InvalidEventException.cs | 4 + .../Messaging/KafkaShipmentEventPublisher.cs | 53 ++++++ .../Messaging/PaymentSuccessfulConsumer.cs | 133 ++++++++++++++ .../ShippingService.Api/Messaging/Topics.cs | 8 + .../Options/KafkaOptions.cs | 11 ++ .../Options/ShippingOptions.cs | 15 ++ .../src/ShippingService.Api/Program.cs | 22 +++ .../Shipping/HandleOutcome.cs | 11 ++ .../Shipping/PaymentSuccessfulHandler.cs | 164 ++++++++++++++++++ .../ShippingService.Api.csproj | 3 + .../Tracking/ITrackingNumberGenerator.cs | 6 + .../Tracking/TrackingNumberGenerator.cs | 29 ++++ .../appsettings.Testing.json | 3 + .../src/ShippingService.Api/appsettings.json | 9 + .../ShippingService.Tests/EventJsonTests.cs | 78 +++++++++ .../PaymentSuccessfulHandlerTests.cs | 156 +++++++++++++++++ .../ShippingApiFactory.cs | 9 +- .../ShippingService.Tests/TestDoubles.cs | 32 ++++ .../TrackingNumberGeneratorTests.cs | 26 +++ 35 files changed, 1036 insertions(+), 16 deletions(-) create mode 100644 shopsphere/common-lib/src/main/java/com/shopsphere/common/events/ShipmentDispatchedEvent.java create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Data/ProcessedEventConfiguration.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Messaging/EventJson.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/EventAddress.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/PaymentSuccessfulEvent.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/ShipmentDispatchedEvent.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Messaging/IShipmentEventPublisher.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Messaging/InvalidEventException.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Messaging/KafkaShipmentEventPublisher.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Messaging/PaymentSuccessfulConsumer.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Messaging/Topics.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Options/KafkaOptions.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Options/ShippingOptions.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Shipping/HandleOutcome.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Shipping/PaymentSuccessfulHandler.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Tracking/ITrackingNumberGenerator.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Tracking/TrackingNumberGenerator.cs create mode 100644 shopsphere/shipping-service/tests/ShippingService.Tests/EventJsonTests.cs create mode 100644 shopsphere/shipping-service/tests/ShippingService.Tests/PaymentSuccessfulHandlerTests.cs create mode 100644 shopsphere/shipping-service/tests/ShippingService.Tests/TestDoubles.cs create mode 100644 shopsphere/shipping-service/tests/ShippingService.Tests/TrackingNumberGeneratorTests.cs diff --git a/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/PaymentSuccessfulEvent.java b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/PaymentSuccessfulEvent.java index ee55e95..0242fc8 100644 --- a/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/PaymentSuccessfulEvent.java +++ b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/PaymentSuccessfulEvent.java @@ -25,4 +25,10 @@ public class PaymentSuccessfulEvent extends BaseEvent { /** Items so inventory can deduct without a callback. */ private List items; + + /** + * US35 — carried forward from order.created so shipping-service can create the + * shipment without calling order-service. Additive: type stays v1. + */ + private OrderCreatedEvent.ShippingAddress shippingAddress; } diff --git a/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/ShipmentDispatchedEvent.java b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/ShipmentDispatchedEvent.java new file mode 100644 index 0000000..85c06fa --- /dev/null +++ b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/ShipmentDispatchedEvent.java @@ -0,0 +1,35 @@ +package com.shopsphere.common.events; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.Instant; + +/** + * Published by the .NET shipping-service (US35) once a shipment has been created and + * handed to the carrier. Declared here so Java services can consume it with the same + * JsonDeserializer setup as every other event. + * + *

eventId is deterministic per shipment, so a re-publish after a failure carries the + * same id and consumers can de-duplicate.

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@SuperBuilder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ShipmentDispatchedEvent extends BaseEvent { + + public static final String TYPE = "shipment.dispatched.v1"; + + private Long shipmentId; + private Long orderId; + private Long userId; + private String carrier; + private String trackingNumber; + private Instant shippedAt; + private OrderCreatedEvent.ShippingAddress shippingAddress; +} diff --git a/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/Topics.java b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/Topics.java index 45069e2..b939b63 100644 --- a/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/Topics.java +++ b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/Topics.java @@ -3,13 +3,15 @@ /** * Centralized topic names. Every producer and consumer references constants here * — never hard-code topic names in services. Keeps schema/topic evolution sane. + * The .NET shipping-service mirrors these in ShippingService.Api.Messaging.Topics. */ public final class Topics { private Topics() {} - public static final String ORDER_CREATED = "order.created"; - public static final String PAYMENT_SUCCESSFUL = "payment.successful"; - public static final String PAYMENT_FAILED = "payment.failed"; - public static final String LOW_STOCK = "inventory.low-stock"; + public static final String ORDER_CREATED = "order.created"; + public static final String PAYMENT_SUCCESSFUL = "payment.successful"; + public static final String PAYMENT_FAILED = "payment.failed"; + public static final String LOW_STOCK = "inventory.low-stock"; + public static final String SHIPMENT_DISPATCHED = "shipment.dispatched"; } diff --git a/shopsphere/config-service/src/main/resources/config/shipping-service.yml b/shopsphere/config-service/src/main/resources/config/shipping-service.yml index c52a1d9..8ecac34 100644 --- a/shopsphere/config-service/src/main/resources/config/shipping-service.yml +++ b/shopsphere/config-service/src/main/resources/config/shipping-service.yml @@ -1,5 +1,9 @@ # Served to shipping-service (.NET 8 / Steeltoe Config Server client). -# Keys map to IConfiguration (Logging.LogLevel.X -> Logging:LogLevel:X). +# Keys map to IConfiguration (Shipping.DefaultCarrier -> Shipping:DefaultCarrier). Logging: LogLevel: ShippingService: Debug + +Shipping: + DefaultCarrier: ShopSphere Express + TrackingPrefix: SSX diff --git a/shopsphere/docker-compose.yml b/shopsphere/docker-compose.yml index 3008d83..df6d63b 100644 --- a/shopsphere/docker-compose.yml +++ b/shopsphere/docker-compose.yml @@ -259,12 +259,15 @@ services: environment: ASPNETCORE_ENVIRONMENT: Production ConnectionStrings__ShippingDb: Host=postgres;Port=5432;Database=shopsphere;Username=shopsphere;Password=shopsphere + Kafka__BootstrapServers: kafka:29092 spring__cloud__config__uri: http://config-service:8888 eureka__client__serviceUrl: http://service-registry:8761/eureka/ eureka__instance__hostName: shipping-service depends_on: postgres: condition: service_healthy + kafka: + condition: service_healthy service-registry: condition: service_healthy diff --git a/shopsphere/payment-service/src/main/java/com/shopsphere/paymentservice/dto/PaymentRequest.java b/shopsphere/payment-service/src/main/java/com/shopsphere/paymentservice/dto/PaymentRequest.java index 97c3595..f20687e 100644 --- a/shopsphere/payment-service/src/main/java/com/shopsphere/paymentservice/dto/PaymentRequest.java +++ b/shopsphere/payment-service/src/main/java/com/shopsphere/paymentservice/dto/PaymentRequest.java @@ -1,5 +1,6 @@ package com.shopsphere.paymentservice.dto; +import com.shopsphere.common.events.OrderCreatedEvent; import com.shopsphere.common.events.OrderItemSnapshot; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Digits; @@ -41,6 +42,9 @@ public class PaymentRequest { private List items; + /** US35 — optional; forwarded onto payment.successful for shipping-service. */ + private OrderCreatedEvent.ShippingAddress shippingAddress; + public enum SimulationMode { ALWAYS_SUCCEED, ALWAYS_FAIL, diff --git a/shopsphere/payment-service/src/main/java/com/shopsphere/paymentservice/service/PaymentService.java b/shopsphere/payment-service/src/main/java/com/shopsphere/paymentservice/service/PaymentService.java index e428160..613f900 100644 --- a/shopsphere/payment-service/src/main/java/com/shopsphere/paymentservice/service/PaymentService.java +++ b/shopsphere/payment-service/src/main/java/com/shopsphere/paymentservice/service/PaymentService.java @@ -1,5 +1,6 @@ package com.shopsphere.paymentservice.service; +import java.time.Instant; import java.util.List; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; @@ -60,6 +61,7 @@ public void processOrderCreated(OrderCreatedEvent event) { .amount(event.getTotalAmount()) .mode(PaymentRequest.SimulationMode.RANDOM) .items(items) + .shippingAddress(event.getShippingAddress()) // US35 .build(); simulate(request); @@ -115,20 +117,27 @@ public PaymentResponse simulate(PaymentRequest request) { log.info("Payment {} for order {} -> {}", payment.getPaymentReference(), payment.getOrderId(), payment.getStatus()); - // Publish payment event for downstream services + // Publish payment event for downstream services. + // US35 fix: eventId/occurredAt were never set (SuperBuilder bypasses BaseEvent's + // constructor), which broke consumer idempotency. Set them explicitly. if (succeeded) { PaymentSuccessfulEvent event = PaymentSuccessfulEvent.builder() + .eventId(UUID.randomUUID().toString()) .eventType(PaymentSuccessfulEvent.TYPE) + .occurredAt(Instant.now()) .orderId(request.getOrderId()) .userId(request.getUserId()) .paymentReference(payment.getPaymentReference()) .amount(request.getAmount()) .items(request.getItems()) + .shippingAddress(request.getShippingAddress()) .build(); paymentEventPublisher.publishSuccessful(event); } else { PaymentFailedEvent event = PaymentFailedEvent.builder() + .eventId(UUID.randomUUID().toString()) .eventType(PaymentFailedEvent.TYPE) + .occurredAt(Instant.now()) .orderId(request.getOrderId()) .userId(request.getUserId()) .paymentReference(payment.getPaymentReference()) diff --git a/shopsphere/payment-service/src/test/java/com/shopsphere/paymentservice/service/PaymentServiceTest.java b/shopsphere/payment-service/src/test/java/com/shopsphere/paymentservice/service/PaymentServiceTest.java index 81f52ee..a049e4f 100644 --- a/shopsphere/payment-service/src/test/java/com/shopsphere/paymentservice/service/PaymentServiceTest.java +++ b/shopsphere/payment-service/src/test/java/com/shopsphere/paymentservice/service/PaymentServiceTest.java @@ -1,5 +1,8 @@ package com.shopsphere.paymentservice.service; +import com.shopsphere.common.events.OrderCreatedEvent; +import com.shopsphere.common.events.PaymentFailedEvent; +import com.shopsphere.common.events.PaymentSuccessfulEvent; import com.shopsphere.paymentservice.dto.PaymentRequest; import com.shopsphere.paymentservice.dto.PaymentResponse; import com.shopsphere.paymentservice.entity.Payment; @@ -9,6 +12,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -16,9 +20,11 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -36,13 +42,17 @@ void setUp() { ReflectionTestUtils.setField(paymentService, "simulatedLatencyMs", 0L); } - @Test - void simulate_alwaysSucceed_returnsSuccessful() { + private void stubSave() { when(paymentRepository.save(any(Payment.class))).thenAnswer(inv -> { Payment p = inv.getArgument(0); p.setId(1L); return p; }); + } + + @Test + void simulate_alwaysSucceed_returnsSuccessful() { + stubSave(); PaymentRequest req = PaymentRequest.builder() .orderId(1L).userId(1L).amount(new BigDecimal("10.00")) @@ -55,12 +65,8 @@ void simulate_alwaysSucceed_returnsSuccessful() { } @Test - void simulate_alwaysFail_returnsFailed() { - when(paymentRepository.save(any(Payment.class))).thenAnswer(inv -> { - Payment p = inv.getArgument(0); - p.setId(1L); - return p; - }); + void simulate_alwaysFail_returnsFailed_andPublishesEventWithIdAndTimestamp() { + stubSave(); PaymentRequest req = PaymentRequest.builder() .orderId(1L).userId(1L).amount(new BigDecimal("10.00")) @@ -71,5 +77,55 @@ void simulate_alwaysFail_returnsFailed() { assertThat(response.getStatus()).isEqualTo(PaymentStatus.FAILED); assertThat(response.getMessage()).contains("simulated"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PaymentFailedEvent.class); + verify(paymentEventPublisher).publishFailed(captor.capture()); + assertThat(captor.getValue().getEventId()).isNotBlank(); + assertThat(captor.getValue().getOccurredAt()).isNotNull(); + assertThat(captor.getValue().getEventType()).isEqualTo(PaymentFailedEvent.TYPE); + } + + @Test + void processOrderCreated_success_forwardsShippingAddressAndSetsEventId() { + stubSave(); + ReflectionTestUtils.setField(paymentService, "successRate", 1.0); + + OrderCreatedEvent.ShippingAddress address = OrderCreatedEvent.ShippingAddress.builder() + .recipientName("Jane Doe") + .line1("123 King St W") + .city("Toronto") + .state("ON") + .postalCode("M5V 3L9") + .country("CA") + .build(); + + OrderCreatedEvent orderCreated = OrderCreatedEvent.builder() + .eventId("order-evt-1") + .eventType(OrderCreatedEvent.TYPE) + .orderId(42L) + .userId(7L) + .totalAmount(new BigDecimal("100.00")) + .itemCount(2) + .items(List.of(OrderCreatedEvent.Item.builder() + .productId(10L).productName("Keyboard") + .unitPrice(new BigDecimal("50.00")).quantity(2) + .build())) + .shippingAddress(address) + .build(); + + paymentService.processOrderCreated(orderCreated); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PaymentSuccessfulEvent.class); + verify(paymentEventPublisher).publishSuccessful(captor.capture()); + PaymentSuccessfulEvent published = captor.getValue(); + + assertThat(published.getEventId()).isNotBlank().isNotEqualTo("order-evt-1"); + assertThat(published.getOccurredAt()).isNotNull(); + assertThat(published.getEventType()).isEqualTo(PaymentSuccessfulEvent.TYPE); + assertThat(published.getOrderId()).isEqualTo(42L); + assertThat(published.getItems()).hasSize(1); + assertThat(published.getShippingAddress()).isNotNull(); + assertThat(published.getShippingAddress().getCity()).isEqualTo("Toronto"); + assertThat(published.getShippingAddress().getCountry()).isEqualTo("CA"); } } diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Data/ProcessedEventConfiguration.cs b/shopsphere/shipping-service/src/ShippingService.Api/Data/ProcessedEventConfiguration.cs new file mode 100644 index 0000000..cae3239 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Data/ProcessedEventConfiguration.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using ShippingService.Api.Domain; + +namespace ShippingService.Api.Data; + +internal sealed class ProcessedEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("processed_events"); + + builder.HasKey(e => e.EventId); + builder.Property(e => e.EventId).HasColumnName("event_id") + .HasMaxLength(ProcessedEvent.EventIdMaxLength).ValueGeneratedNever(); + builder.Property(e => e.EventType).HasColumnName("event_type") + .HasMaxLength(ProcessedEvent.EventTypeMaxLength).IsRequired(); + builder.Property(e => e.ProcessedAt).HasColumnName("processed_at").IsRequired(); + + builder.HasIndex(e => e.ProcessedAt).HasDatabaseName("idx_processed_events_processed_at"); + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Data/ShipmentConfiguration.cs b/shopsphere/shipping-service/src/ShippingService.Api/Data/ShipmentConfiguration.cs index 532997d..e8312d5 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Data/ShipmentConfiguration.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Data/ShipmentConfiguration.cs @@ -30,11 +30,13 @@ public void Configure(EntityTypeBuilder builder) builder.Property(s => s.Carrier).HasColumnName("carrier").HasMaxLength(100); builder.Property(s => s.TrackingNumber).HasColumnName("tracking_number").HasMaxLength(100); + builder.HasIndex(s => s.TrackingNumber).IsUnique().HasDatabaseName("uk_shipments_tracking_number"); builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired(); builder.Property(s => s.UpdatedAt).HasColumnName("updated_at").IsRequired(); builder.Property(s => s.ShippedAt).HasColumnName("shipped_at"); builder.Property(s => s.DeliveredAt).HasColumnName("delivered_at"); + builder.Property(s => s.DispatchPublishedAt).HasColumnName("dispatch_published_at"); builder.OwnsOne(s => s.ShippingAddress, address => { diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Data/ShippingDbContext.cs b/shopsphere/shipping-service/src/ShippingService.Api/Data/ShippingDbContext.cs index 248ef5f..e76cd85 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Data/ShippingDbContext.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Data/ShippingDbContext.cs @@ -9,6 +9,7 @@ public sealed class ShippingDbContext(DbContextOptions option public const string Schema = "shopsphere_shipping"; public DbSet Shipments => Set(); + public DbSet ProcessedEvents => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/EventJson.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/EventJson.cs new file mode 100644 index 0000000..3f0fe8a --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/EventJson.cs @@ -0,0 +1,16 @@ +using System.Text.Json; +using ShippingService.Api.Messaging.Events; + +namespace ShippingService.Api.Messaging; + +/// camelCase JSON, matching the Java services' Jackson output. +public static class EventJson +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + public static PaymentSuccessfulEvent DeserializePaymentSuccessful(string json) => + JsonSerializer.Deserialize(json, Options) + ?? throw new InvalidEventException("payment.successful payload was null"); + + public static string Serialize(T value) => JsonSerializer.Serialize(value, Options); +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/EventAddress.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/EventAddress.cs new file mode 100644 index 0000000..039959c --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/EventAddress.cs @@ -0,0 +1,18 @@ +using ShippingService.Api.Domain; + +namespace ShippingService.Api.Messaging.Events; + +/// Wire shape of OrderCreatedEvent.ShippingAddress (common-lib). +public sealed record EventAddress( + string? RecipientName, + string? Phone, + string? Line1, + string? Line2, + string? City, + string? State, + string? PostalCode, + string? Country) +{ + public static EventAddress From(ShippingAddress a) => + new(a.RecipientName, a.Phone, a.Line1, a.Line2, a.City, a.State, a.PostalCode, a.Country); +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/PaymentSuccessfulEvent.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/PaymentSuccessfulEvent.cs new file mode 100644 index 0000000..3a16d68 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/PaymentSuccessfulEvent.cs @@ -0,0 +1,17 @@ +namespace ShippingService.Api.Messaging.Events; + +/// +/// Consumer-side view of common-lib PaymentSuccessfulEvent. Only the fields shipping needs; +/// everything else (items, occurredAt, ...) is ignored on deserialisation. +/// +public sealed record PaymentSuccessfulEvent( + string? EventId, + string? EventType, + long OrderId, + long UserId, + string? PaymentReference, + decimal? Amount, + EventAddress? ShippingAddress) +{ + public const string Type = "payment.successful.v1"; +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/ShipmentDispatchedEvent.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/ShipmentDispatchedEvent.cs new file mode 100644 index 0000000..bb46e77 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/ShipmentDispatchedEvent.cs @@ -0,0 +1,48 @@ +using System.Security.Cryptography; +using System.Text; +using ShippingService.Api.Domain; + +namespace ShippingService.Api.Messaging.Events; + +/// +/// Wire shape of common-lib ShipmentDispatchedEvent. Timestamps are UTC DateTime so they +/// serialise with a trailing 'Z', which Jackson's Instant deserialiser accepts. +/// +public sealed record ShipmentDispatchedEvent( + string EventId, + string EventType, + DateTime OccurredAt, + long ShipmentId, + long OrderId, + long UserId, + string Carrier, + string TrackingNumber, + DateTime ShippedAt, + EventAddress ShippingAddress) +{ + public const string Type = "shipment.dispatched.v1"; + + public static ShipmentDispatchedEvent From(Shipment shipment, DateTimeOffset occurredAt) + { + if (shipment.Carrier is null || shipment.TrackingNumber is null || shipment.ShippedAt is null) + { + throw new InvalidOperationException($"Shipment {shipment.Id} has not been shipped yet."); + } + + return new ShipmentDispatchedEvent( + EventIdFor(shipment.Id), + Type, + occurredAt.UtcDateTime, + shipment.Id, + shipment.OrderId, + shipment.UserId, + shipment.Carrier, + shipment.TrackingNumber, + shipment.ShippedAt.Value.UtcDateTime, + EventAddress.From(shipment.ShippingAddress)); + } + + /// Deterministic per shipment, so a re-publish after a failure carries the same id. + public static string EventIdFor(long shipmentId) => + new Guid(MD5.HashData(Encoding.UTF8.GetBytes($"{Type}:{shipmentId}"))).ToString(); +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/IShipmentEventPublisher.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/IShipmentEventPublisher.cs new file mode 100644 index 0000000..31d538d --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/IShipmentEventPublisher.cs @@ -0,0 +1,9 @@ +using ShippingService.Api.Messaging.Events; + +namespace ShippingService.Api.Messaging; + +public interface IShipmentEventPublisher +{ + /// Completes only after the broker acknowledges the write (acks=all). + Task PublishDispatchedAsync(ShipmentDispatchedEvent dispatched, CancellationToken cancellationToken); +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/InvalidEventException.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/InvalidEventException.cs new file mode 100644 index 0000000..9c2d2ca --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/InvalidEventException.cs @@ -0,0 +1,4 @@ +namespace ShippingService.Api.Messaging; + +/// A message that can never be processed (poison). The consumer logs and skips it. +public sealed class InvalidEventException(string message) : Exception(message); diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/KafkaShipmentEventPublisher.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/KafkaShipmentEventPublisher.cs new file mode 100644 index 0000000..51f2284 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/KafkaShipmentEventPublisher.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using System.Text; +using Confluent.Kafka; +using Microsoft.Extensions.Options; +using ShippingService.Api.Messaging.Events; +using ShippingService.Api.Options; + +namespace ShippingService.Api.Messaging; + +public sealed class KafkaShipmentEventPublisher : IShipmentEventPublisher, IDisposable +{ + private readonly IProducer _producer; + private readonly ILogger _logger; + + public KafkaShipmentEventPublisher(IOptions options, ILogger logger) + { + _logger = logger; + var config = new ProducerConfig + { + BootstrapServers = options.Value.BootstrapServers, + Acks = Acks.All, // same durability as the Java producers + EnableIdempotence = true, // no duplicates from producer retries + LingerMs = 5, + MessageTimeoutMs = 30_000, + ClientId = "shipping-service" + }; + _producer = new ProducerBuilder(config) + .SetErrorHandler((_, e) => _logger.LogWarning("Kafka producer error: {Reason} (fatal={IsFatal})", e.Reason, e.IsFatal)) + .Build(); + } + + public async Task PublishDispatchedAsync(ShipmentDispatchedEvent dispatched, CancellationToken cancellationToken) + { + var message = new Message + { + Key = dispatched.OrderId.ToString(CultureInfo.InvariantCulture), // same partitioning key as order/payment events + Value = EventJson.Serialize(dispatched), + Headers = new Headers { { "eventType", Encoding.UTF8.GetBytes(dispatched.EventType) } } + }; + + var result = await _producer.ProduceAsync(Topics.ShipmentDispatched, message, cancellationToken); + + _logger.LogInformation( + "Published shipment.dispatched eventId={EventId} orderId={OrderId} tracking={TrackingNumber} to {TopicPartitionOffset}", + dispatched.EventId, dispatched.OrderId, dispatched.TrackingNumber, result.TopicPartitionOffset); + } + + public void Dispose() + { + _producer.Flush(TimeSpan.FromSeconds(5)); + _producer.Dispose(); + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/PaymentSuccessfulConsumer.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/PaymentSuccessfulConsumer.cs new file mode 100644 index 0000000..fd5680d --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/PaymentSuccessfulConsumer.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using Confluent.Kafka; +using Microsoft.Extensions.Options; +using ShippingService.Api.Messaging.Events; +using ShippingService.Api.Options; +using ShippingService.Api.Shipping; + +namespace ShippingService.Api.Messaging; + +/// +/// Consumes payment.successful with manual offset commits: an offset is committed only after +/// the handler finished (shipment saved + dispatched event acknowledged). Transient failures +/// are retried with capped exponential backoff, blocking the partition rather than losing a +/// shipment. Poison messages (unparseable / invalid) are logged and skipped. +/// +public sealed class PaymentSuccessfulConsumer( + IServiceScopeFactory scopeFactory, + IOptions kafkaOptions, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan MaxBackoff = TimeSpan.FromSeconds(30); + + // Consume() is blocking, so run the loop on its own long-running thread. + protected override Task ExecuteAsync(CancellationToken stoppingToken) => + Task.Factory.StartNew( + () => ConsumeLoopAsync(stoppingToken), + stoppingToken, + TaskCreationOptions.LongRunning, + TaskScheduler.Default) + .Unwrap(); + + private async Task ConsumeLoopAsync(CancellationToken ct) + { + var options = kafkaOptions.Value; + var config = new ConsumerConfig + { + BootstrapServers = options.BootstrapServers, + GroupId = options.GroupId, + AutoOffsetReset = AutoOffsetReset.Earliest, + EnableAutoCommit = false, + AllowAutoCreateTopics = true, + ClientId = "shipping-service" + }; + + using var consumer = new ConsumerBuilder(config) + .SetErrorHandler((_, e) => logger.LogWarning("Kafka consumer error: {Reason} (fatal={IsFatal})", e.Reason, e.IsFatal)) + .Build(); + + consumer.Subscribe(Topics.PaymentSuccessful); + logger.LogInformation("Subscribed to {Topic} as group {GroupId} on {Bootstrap}", + Topics.PaymentSuccessful, options.GroupId, options.BootstrapServers); + + try + { + while (!ct.IsCancellationRequested) + { + ConsumeResult? result; + try + { + result = consumer.Consume(ct); + } + catch (ConsumeException ex) + { + logger.LogError(ex, "Kafka consume error: {Reason}", ex.Error.Reason); + continue; + } + + if (result is null) + { + continue; + } + + if (result.Message?.Value is not null) + { + await ProcessWithRetryAsync(result, ct); + } + + consumer.Commit(result); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // shutting down + } + finally + { + consumer.Close(); + } + } + + private async Task ProcessWithRetryAsync(ConsumeResult result, CancellationToken ct) + { + PaymentSuccessfulEvent evt; + try + { + evt = EventJson.DeserializePaymentSuccessful(result.Message.Value); + } + catch (Exception ex) when (ex is JsonException or InvalidEventException) + { + logger.LogError(ex, "Skipping unreadable payment.successful at {TopicPartitionOffset}", result.TopicPartitionOffset); + return; + } + + for (var attempt = 1; ; attempt++) + { + ct.ThrowIfCancellationRequested(); + try + { + using var scope = scopeFactory.CreateScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + var outcome = await handler.HandleAsync(evt, ct); + + logger.LogInformation( + "payment.successful eventId={EventId} orderId={OrderId} -> {Outcome} ({TopicPartitionOffset})", + evt.EventId, evt.OrderId, outcome, result.TopicPartitionOffset); + return; + } + catch (InvalidEventException ex) + { + logger.LogError(ex, "Skipping invalid payment.successful at {TopicPartitionOffset}", result.TopicPartitionOffset); + return; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + var delay = TimeSpan.FromSeconds(Math.Min(MaxBackoff.TotalSeconds, Math.Pow(2, Math.Min(attempt, 5)))); + logger.LogWarning(ex, + "Attempt {Attempt} failed for payment.successful eventId={EventId} orderId={OrderId}; retrying in {Delay}", + attempt, evt.EventId, evt.OrderId, delay); + await Task.Delay(delay, ct); + } + } + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Topics.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Topics.cs new file mode 100644 index 0000000..15b3e52 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Topics.cs @@ -0,0 +1,8 @@ +namespace ShippingService.Api.Messaging; + +/// Mirrors com.shopsphere.common.events.Topics in common-lib. +public static class Topics +{ + public const string PaymentSuccessful = "payment.successful"; + public const string ShipmentDispatched = "shipment.dispatched"; +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Options/KafkaOptions.cs b/shopsphere/shipping-service/src/ShippingService.Api/Options/KafkaOptions.cs new file mode 100644 index 0000000..cec0a10 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Options/KafkaOptions.cs @@ -0,0 +1,11 @@ +namespace ShippingService.Api.Options; + +public sealed class KafkaOptions +{ + public const string SectionName = "Kafka"; + + /// When false the payment.successful consumer is not started (tests, local API-only runs). + public bool Enabled { get; set; } = true; + public string BootstrapServers { get; set; } = "localhost:9092"; + public string GroupId { get; set; } = "shipping-service"; +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Options/ShippingOptions.cs b/shopsphere/shipping-service/src/ShippingService.Api/Options/ShippingOptions.cs new file mode 100644 index 0000000..cd40838 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Options/ShippingOptions.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace ShippingService.Api.Options; + +public sealed class ShippingOptions +{ + public const string SectionName = "Shipping"; + + [Required, MaxLength(100)] + public string DefaultCarrier { get; set; } = "ShopSphere Express"; + + /// 2–5 upper-case letters prefixed to every tracking number. + [Required, RegularExpression("^[A-Z]{2,5}$")] + public string TrackingPrefix { get; set; } = "SSX"; +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Program.cs b/shopsphere/shipping-service/src/ShippingService.Api/Program.cs index deccdad..20adeff 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Program.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Program.cs @@ -6,6 +6,10 @@ using ShippingService.Api.Data; using ShippingService.Api.Endpoints; using ShippingService.Api.Health; +using ShippingService.Api.Messaging; +using ShippingService.Api.Options; +using ShippingService.Api.Shipping; +using ShippingService.Api.Tracking; using Steeltoe.Discovery.Client; using Steeltoe.Extensions.Configuration.ConfigServer; @@ -29,6 +33,24 @@ npgsql.EnableRetryOnFailure(maxRetryCount: 5); })); +// ---- Options ---- +builder.Services.Configure(builder.Configuration.GetSection(KafkaOptions.SectionName)); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(ShippingOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + +// ---- US35: shipment creation pipeline ---- +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +if (builder.Configuration.GetValue($"{KafkaOptions.SectionName}:Enabled", true)) +{ + builder.Services.AddHostedService(); +} + // ---- Health checks ---- builder.Services.AddHealthChecks() .AddCheck("self", () => HealthCheckResult.Healthy("shipping-service is running"), tags: ["live"]) diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Shipping/HandleOutcome.cs b/shopsphere/shipping-service/src/ShippingService.Api/Shipping/HandleOutcome.cs new file mode 100644 index 0000000..bd50767 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Shipping/HandleOutcome.cs @@ -0,0 +1,11 @@ +namespace ShippingService.Api.Shipping; + +public enum HandleOutcome +{ + /// Shipment created, tracking number assigned, shipment.dispatched published. + Created, + /// Event (or order) already handled; any missing dispatch publish was retried. + Duplicate, + /// No usable shipping address (e.g. legacy order or manual /simulate call). Recorded, not retried. + SkippedNoAddress +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Shipping/PaymentSuccessfulHandler.cs b/shopsphere/shipping-service/src/ShippingService.Api/Shipping/PaymentSuccessfulHandler.cs new file mode 100644 index 0000000..b0655f4 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Shipping/PaymentSuccessfulHandler.cs @@ -0,0 +1,164 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Npgsql; +using ShippingService.Api.Data; +using ShippingService.Api.Domain; +using ShippingService.Api.Messaging; +using ShippingService.Api.Messaging.Events; +using ShippingService.Api.Options; +using ShippingService.Api.Tracking; + +namespace ShippingService.Api.Shipping; + +/// +/// payment.successful → shipment (SHIPPED, with tracking number) → shipment.dispatched. +/// +/// Idempotency: +/// 1. processed_events has one row per consumed eventId, saved in the SAME SaveChanges +/// as the shipment, so they commit or roll back together. +/// 2. shipments.order_id is unique, so a different event for the same order can't create a +/// second shipment. +/// 3. dispatch_published_at records a successful publish. If the publish fails, the handler +/// throws, the offset isn't committed, and the redelivery re-publishes (same eventId). +/// +public sealed class PaymentSuccessfulHandler( + ShippingDbContext db, + ITrackingNumberGenerator trackingNumbers, + IShipmentEventPublisher publisher, + IOptions shippingOptions, + TimeProvider timeProvider, + ILogger logger) +{ + public async Task HandleAsync(PaymentSuccessfulEvent evt, CancellationToken ct) + { + Validate(evt); + var eventId = evt.EventId!; + + // 1. Same event seen before → just make sure its dispatch went out. + if (await db.ProcessedEvents.AsNoTracking().AnyAsync(p => p.EventId == eventId, ct)) + { + logger.LogInformation("Duplicate payment.successful eventId={EventId} orderId={OrderId}", eventId, evt.OrderId); + await EnsureDispatchPublishedAsync(evt.OrderId, ct); + return HandleOutcome.Duplicate; + } + + var now = timeProvider.GetUtcNow(); + var processed = ProcessedEvent.Create(eventId, evt.EventType ?? PaymentSuccessfulEvent.Type, now); + + // 2. Different event, same order → never ship twice. + if (await db.Shipments.AnyAsync(s => s.OrderId == evt.OrderId, ct)) + { + logger.LogWarning("Shipment already exists for orderId={OrderId}; recording eventId={EventId} as processed", + evt.OrderId, eventId); + db.ProcessedEvents.Add(processed); + await SaveIgnoringDuplicatesAsync(ct); + await EnsureDispatchPublishedAsync(evt.OrderId, ct); + return HandleOutcome.Duplicate; + } + + // 3. No usable address → record and skip (retrying won't help). + var address = TryMapAddress(evt.ShippingAddress, out var reason); + if (address is null) + { + logger.LogWarning("Skipping payment.successful eventId={EventId} orderId={OrderId}: {Reason}", + eventId, evt.OrderId, reason); + db.ProcessedEvents.Add(processed); + await SaveIgnoringDuplicatesAsync(ct); + return HandleOutcome.SkippedNoAddress; + } + + // 4. Create + dispatch. + var shipment = Shipment.Create(evt.OrderId, evt.UserId, address); + shipment.MarkShipped(shippingOptions.Value.DefaultCarrier, trackingNumbers.Next(), now); + + db.Shipments.Add(shipment); + db.ProcessedEvents.Add(processed); + + if (!await SaveIgnoringDuplicatesAsync(ct)) + { + // Lost a race with a concurrent delivery of the same event/order. + await EnsureDispatchPublishedAsync(evt.OrderId, ct); + return HandleOutcome.Duplicate; + } + + logger.LogInformation("Created shipment {ShipmentId} for orderId={OrderId} carrier={Carrier} tracking={TrackingNumber}", + shipment.Id, shipment.OrderId, shipment.Carrier, shipment.TrackingNumber); + + await PublishDispatchedAsync(shipment, ct); + return HandleOutcome.Created; + } + + private static void Validate(PaymentSuccessfulEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.EventId)) + { + throw new InvalidEventException("payment.successful is missing eventId"); + } + if (evt.EventId.Length > ProcessedEvent.EventIdMaxLength) + { + throw new InvalidEventException($"payment.successful eventId longer than {ProcessedEvent.EventIdMaxLength} chars"); + } + if (evt.OrderId <= 0) + { + throw new InvalidEventException($"payment.successful eventId={evt.EventId} has invalid orderId {evt.OrderId}"); + } + if (evt.UserId <= 0) + { + throw new InvalidEventException($"payment.successful eventId={evt.EventId} has invalid userId {evt.UserId}"); + } + } + + private static ShippingAddress? TryMapAddress(EventAddress? a, out string reason) + { + if (a is null) + { + reason = "no shippingAddress on event"; + return null; + } + try + { + reason = string.Empty; + return ShippingAddress.Create( + a.RecipientName!, a.Line1!, a.City!, a.PostalCode!, a.Country!, + line2: a.Line2, state: a.State, phone: a.Phone); + } + catch (ArgumentException ex) + { + reason = $"invalid shippingAddress: {ex.Message}"; + return null; + } + } + + private async Task EnsureDispatchPublishedAsync(long orderId, CancellationToken ct) + { + var shipment = await db.Shipments.FirstOrDefaultAsync(s => s.OrderId == orderId, ct); + if (shipment is { Status: ShipmentStatus.Shipped, DispatchPublishedAt: null }) + { + logger.LogInformation("Re-publishing shipment.dispatched for shipment {ShipmentId}", shipment.Id); + await PublishDispatchedAsync(shipment, ct); + } + } + + private async Task PublishDispatchedAsync(Shipment shipment, CancellationToken ct) + { + var now = timeProvider.GetUtcNow(); + await publisher.PublishDispatchedAsync(ShipmentDispatchedEvent.From(shipment, now), ct); + shipment.MarkDispatchPublished(now); + await db.SaveChangesAsync(ct); + } + + /// false if a unique constraint (event id / order id) was hit — someone else won. + private async Task SaveIgnoringDuplicatesAsync(CancellationToken ct) + { + try + { + await db.SaveChangesAsync(ct); + return true; + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { + db.ChangeTracker.Clear(); + return false; + } + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/ShippingService.Api.csproj b/shopsphere/shipping-service/src/ShippingService.Api/ShippingService.Api.csproj index 96e93fb..485d4f3 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/ShippingService.Api.csproj +++ b/shopsphere/shipping-service/src/ShippingService.Api/ShippingService.Api.csproj @@ -23,6 +23,9 @@ + + + diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Tracking/ITrackingNumberGenerator.cs b/shopsphere/shipping-service/src/ShippingService.Api/Tracking/ITrackingNumberGenerator.cs new file mode 100644 index 0000000..8764545 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Tracking/ITrackingNumberGenerator.cs @@ -0,0 +1,6 @@ +namespace ShippingService.Api.Tracking; + +public interface ITrackingNumberGenerator +{ + string Next(); +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Tracking/TrackingNumberGenerator.cs b/shopsphere/shipping-service/src/ShippingService.Api/Tracking/TrackingNumberGenerator.cs new file mode 100644 index 0000000..f80377e --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Tracking/TrackingNumberGenerator.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Options; +using ShippingService.Api.Options; + +namespace ShippingService.Api.Tracking; + +/// +/// Format: {PREFIX}{yyMMdd}{10 random Crockford base32 chars}, e.g. SSX2609264K7QZ9M2PA. +/// 32^10 ≈ 1.1e15 combinations per day; a unique index on tracking_number backs it up. +/// Crockford's alphabet drops I, L, O, U so numbers are easy to read out over the phone. +/// +public sealed class TrackingNumberGenerator(IOptions options, TimeProvider timeProvider) + : ITrackingNumberGenerator +{ + private const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + private const int RandomLength = 10; + + public string Next() + { + Span random = stackalloc char[RandomLength]; + for (var i = 0; i < RandomLength; i++) + { + random[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]; + } + + var date = timeProvider.GetUtcNow().ToString("yyMMdd"); + return string.Concat(options.Value.TrackingPrefix, date, random); + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/appsettings.Testing.json b/shopsphere/shipping-service/src/ShippingService.Api/appsettings.Testing.json index edd5a68..b2f5b6c 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/appsettings.Testing.json +++ b/shopsphere/shipping-service/src/ShippingService.Api/appsettings.Testing.json @@ -1,4 +1,7 @@ { + "Kafka": { + "Enabled": false + }, "spring": { "cloud": { "config": { diff --git a/shopsphere/shipping-service/src/ShippingService.Api/appsettings.json b/shopsphere/shipping-service/src/ShippingService.Api/appsettings.json index b83761e..02fca47 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/appsettings.json +++ b/shopsphere/shipping-service/src/ShippingService.Api/appsettings.json @@ -12,6 +12,15 @@ "ConnectionStrings": { "ShippingDb": "Host=localhost;Port=5432;Database=shopsphere;Username=shopsphere;Password=shopsphere" }, + "Kafka": { + "Enabled": true, + "BootstrapServers": "localhost:9092", + "GroupId": "shipping-service" + }, + "Shipping": { + "DefaultCarrier": "ShopSphere Express", + "TrackingPrefix": "SSX" + }, "spring": { "application": { "name": "shipping-service" diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/EventJsonTests.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/EventJsonTests.cs new file mode 100644 index 0000000..4da6413 --- /dev/null +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/EventJsonTests.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using ShippingService.Api.Messaging; +using ShippingService.Api.Messaging.Events; + +namespace ShippingService.Tests; + +public sealed class EventJsonTests +{ + [Fact] + public void DeserializePaymentSuccessful_AcceptsJavaProducedPayload() + { + // Shape produced by Spring Kafka's JsonSerializer for common-lib PaymentSuccessfulEvent. + const string json = """ + { + "eventId": "5b6c3f7e-2c1a-4b7e-9a51-0f7a1f0b9c11", + "eventType": "payment.successful.v1", + "occurredAt": 1790424000.123456789, + "orderId": 42, + "userId": 7, + "paymentReference": "pay-ref-1", + "amount": 100.50, + "items": [ { "productId": 10, "quantity": 2, "unitPrice": 50.25 } ], + "shippingAddress": { + "recipientName": "Jane Doe", + "line1": "123 King St W", + "city": "Toronto", + "state": "ON", + "postalCode": "M5V 3L9", + "country": "CA" + } + } + """; + + var evt = EventJson.DeserializePaymentSuccessful(json); + + Assert.Equal("5b6c3f7e-2c1a-4b7e-9a51-0f7a1f0b9c11", evt.EventId); + Assert.Equal(42, evt.OrderId); + Assert.Equal(7, evt.UserId); + Assert.Equal(100.50m, evt.Amount); + Assert.NotNull(evt.ShippingAddress); + Assert.Equal("Toronto", evt.ShippingAddress!.City); + Assert.Null(evt.ShippingAddress.Line2); + } + + [Fact] + public void DeserializePaymentSuccessful_MalformedJson_Throws() + { + Assert.Throws(() => EventJson.DeserializePaymentSuccessful("{ not json")); + } + + [Fact] + public void Serialize_ShipmentDispatched_IsCamelCaseWithUtcTimestamps() + { + var evt = new ShipmentDispatchedEvent( + ShipmentDispatchedEvent.EventIdFor(5), ShipmentDispatchedEvent.Type, + new DateTime(2026, 9, 26, 12, 0, 0, DateTimeKind.Utc), + 5, 42, 7, "ShopSphere Express", "SSX2609264K7QZ9M2PA", + new DateTime(2026, 9, 26, 12, 0, 0, DateTimeKind.Utc), + new EventAddress("Jane Doe", null, "123 King St W", null, "Toronto", "ON", "M5V 3L9", "CA")); + + using var json = JsonDocument.Parse(EventJson.Serialize(evt)); + var root = json.RootElement; + + Assert.Equal("shipment.dispatched.v1", root.GetProperty("eventType").GetString()); + Assert.Equal(42, root.GetProperty("orderId").GetInt64()); + Assert.Equal("SSX2609264K7QZ9M2PA", root.GetProperty("trackingNumber").GetString()); + Assert.EndsWith("Z", root.GetProperty("occurredAt").GetString()); + Assert.EndsWith("Z", root.GetProperty("shippedAt").GetString()); + Assert.Equal("CA", root.GetProperty("shippingAddress").GetProperty("country").GetString()); + } + + [Fact] + public void EventIdFor_IsDeterministicPerShipment() + { + Assert.Equal(ShipmentDispatchedEvent.EventIdFor(5), ShipmentDispatchedEvent.EventIdFor(5)); + Assert.NotEqual(ShipmentDispatchedEvent.EventIdFor(5), ShipmentDispatchedEvent.EventIdFor(6)); + } +} diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/PaymentSuccessfulHandlerTests.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/PaymentSuccessfulHandlerTests.cs new file mode 100644 index 0000000..e3321cf --- /dev/null +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/PaymentSuccessfulHandlerTests.cs @@ -0,0 +1,156 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using ShippingService.Api.Data; +using ShippingService.Api.Domain; +using ShippingService.Api.Messaging; +using ShippingService.Api.Messaging.Events; +using ShippingService.Api.Options; +using ShippingService.Api.Shipping; +using ShippingService.Api.Tracking; +using MsOptions = Microsoft.Extensions.Options.Options; + +namespace ShippingService.Tests; + +public sealed class PaymentSuccessfulHandlerTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 0, 0, TimeSpan.Zero); + + private readonly ShippingDbContext _db; + private readonly FakeShipmentEventPublisher _publisher = new(); + private readonly PaymentSuccessfulHandler _handler; + + public PaymentSuccessfulHandlerTests() + { + var dbOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"handler-{Guid.NewGuid()}") + .Options; + _db = new ShippingDbContext(dbOptions); + + var shippingOptions = MsOptions.Create(new ShippingOptions { DefaultCarrier = "ShopSphere Express", TrackingPrefix = "SSX" }); + var time = new FixedTimeProvider(Now); + + _handler = new PaymentSuccessfulHandler( + _db, + new TrackingNumberGenerator(shippingOptions, time), + _publisher, + shippingOptions, + time, + NullLogger.Instance); + } + + public void Dispose() => _db.Dispose(); + + private static EventAddress Address(string country = "CA") => + new("Jane Doe", "+1 416 555 0199", "123 King St W", null, "Toronto", "ON", "M5V 3L9", country); + + private static PaymentSuccessfulEvent Event(string eventId = "evt-1", long orderId = 42, long userId = 7, EventAddress? address = null) => + new(eventId, PaymentSuccessfulEvent.Type, orderId, userId, "pay-ref", 100.00m, address ?? Address()); + + [Fact] + public async Task NewEvent_CreatesShippedShipmentWithTracking_AndPublishesDispatched() + { + var outcome = await _handler.HandleAsync(Event(), CancellationToken.None); + + Assert.Equal(HandleOutcome.Created, outcome); + + var shipment = await _db.Shipments.SingleAsync(); + Assert.Equal(42, shipment.OrderId); + Assert.Equal(7, shipment.UserId); + Assert.Equal(ShipmentStatus.Shipped, shipment.Status); + Assert.Equal("ShopSphere Express", shipment.Carrier); + Assert.Matches("^SSX260926[0-9A-HJKMNP-TV-Z]{10}$", shipment.TrackingNumber); + Assert.Equal(Now, shipment.ShippedAt); + Assert.Equal(Now, shipment.DispatchPublishedAt); + Assert.Equal("Toronto", shipment.ShippingAddress.City); + + Assert.True(await _db.ProcessedEvents.AnyAsync(p => p.EventId == "evt-1")); + + var dispatched = Assert.Single(_publisher.Published); + Assert.Equal(ShipmentDispatchedEvent.Type, dispatched.EventType); + Assert.Equal(shipment.Id, dispatched.ShipmentId); + Assert.Equal(42, dispatched.OrderId); + Assert.Equal(shipment.TrackingNumber, dispatched.TrackingNumber); + Assert.Equal("CA", dispatched.ShippingAddress.Country); + Assert.Equal(DateTimeKind.Utc, dispatched.ShippedAt.Kind); + } + + [Fact] + public async Task SameEventTwice_CreatesOneShipment_AndPublishesOnce() + { + await _handler.HandleAsync(Event(), CancellationToken.None); + var second = await _handler.HandleAsync(Event(), CancellationToken.None); + + Assert.Equal(HandleOutcome.Duplicate, second); + Assert.Equal(1, await _db.Shipments.CountAsync()); + Assert.Single(_publisher.Published); + } + + [Fact] + public async Task DifferentEventForSameOrder_DoesNotCreateSecondShipment() + { + await _handler.HandleAsync(Event(eventId: "evt-1"), CancellationToken.None); + var second = await _handler.HandleAsync(Event(eventId: "evt-2"), CancellationToken.None); + + Assert.Equal(HandleOutcome.Duplicate, second); + Assert.Equal(1, await _db.Shipments.CountAsync()); + Assert.Equal(2, await _db.ProcessedEvents.CountAsync()); + Assert.Single(_publisher.Published); + } + + [Fact] + public async Task PublishFails_ThenRedelivered_RepublishesWithSameEventId() + { + _publisher.FailNext = true; + + await Assert.ThrowsAsync(() => _handler.HandleAsync(Event(), CancellationToken.None)); + + var shipment = await _db.Shipments.SingleAsync(); + Assert.Null(shipment.DispatchPublishedAt); + Assert.Empty(_publisher.Published); + + // Offset was not committed → Kafka redelivers the same event. + var outcome = await _handler.HandleAsync(Event(), CancellationToken.None); + + Assert.Equal(HandleOutcome.Duplicate, outcome); + Assert.Equal(1, await _db.Shipments.CountAsync()); + Assert.Single(_publisher.Published); + Assert.Equal(2, _publisher.Attempts.Count); + Assert.Equal(_publisher.Attempts[0].EventId, _publisher.Attempts[1].EventId); + Assert.NotNull((await _db.Shipments.SingleAsync()).DispatchPublishedAt); + } + + [Fact] + public async Task MissingAddress_SkipsAndRecordsEvent() + { + var evt = new PaymentSuccessfulEvent("evt-no-addr", PaymentSuccessfulEvent.Type, 43, 7, "ref", 10m, null); + + var outcome = await _handler.HandleAsync(evt, CancellationToken.None); + + Assert.Equal(HandleOutcome.SkippedNoAddress, outcome); + Assert.Empty(await _db.Shipments.ToListAsync()); + Assert.True(await _db.ProcessedEvents.AnyAsync(p => p.EventId == "evt-no-addr")); + Assert.Empty(_publisher.Attempts); + } + + [Fact] + public async Task InvalidAddress_SkipsAndRecordsEvent() + { + var outcome = await _handler.HandleAsync(Event(eventId: "evt-bad-addr", address: Address(country: "CAN")), CancellationToken.None); + + Assert.Equal(HandleOutcome.SkippedNoAddress, outcome); + Assert.Empty(await _db.Shipments.ToListAsync()); + Assert.Empty(_publisher.Attempts); + } + + [Theory] + [InlineData("", 42, 7)] + [InlineData("evt-1", 0, 7)] + [InlineData("evt-1", 42, 0)] + public async Task InvalidEvent_ThrowsInvalidEventException(string eventId, long orderId, long userId) + { + var evt = new PaymentSuccessfulEvent(eventId, PaymentSuccessfulEvent.Type, orderId, userId, "ref", 10m, Address()); + + await Assert.ThrowsAsync(() => _handler.HandleAsync(evt, CancellationToken.None)); + Assert.Empty(await _db.Shipments.ToListAsync()); + } +} diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/ShippingApiFactory.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/ShippingApiFactory.cs index db6fdb4..5b4eb8d 100644 --- a/shopsphere/shipping-service/tests/ShippingService.Tests/ShippingApiFactory.cs +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/ShippingApiFactory.cs @@ -6,12 +6,14 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using ShippingService.Api.Data; using ShippingService.Api.Domain; +using ShippingService.Api.Messaging; namespace ShippingService.Tests; /// -/// Boots the real app in the "Testing" environment (Config Server + Eureka disabled via -/// appsettings.Testing.json) and swaps Postgres for an isolated in-memory database. +/// Boots the real app in the "Testing" environment (Config Server, Eureka and the Kafka +/// consumer disabled via appsettings.Testing.json), swaps Postgres for an isolated in-memory +/// database and Kafka publishing for a fake. /// public sealed class ShippingApiFactory : WebApplicationFactory { @@ -24,6 +26,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { services.RemoveAll>(); services.AddDbContext(options => options.UseInMemoryDatabase(_databaseName)); + + services.RemoveAll(); + services.AddSingleton(); }); } diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/TestDoubles.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/TestDoubles.cs new file mode 100644 index 0000000..018a4b4 --- /dev/null +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/TestDoubles.cs @@ -0,0 +1,32 @@ +using ShippingService.Api.Messaging; +using ShippingService.Api.Messaging.Events; + +namespace ShippingService.Tests; + +public sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() => now; +} + +public sealed class FakeShipmentEventPublisher : IShipmentEventPublisher +{ + /// Every publish attempt, including failed ones. + public List Attempts { get; } = []; + + /// Successfully "acknowledged" publishes. + public List Published { get; } = []; + + public bool FailNext { get; set; } + + public Task PublishDispatchedAsync(ShipmentDispatchedEvent dispatched, CancellationToken cancellationToken) + { + Attempts.Add(dispatched); + if (FailNext) + { + FailNext = false; + throw new InvalidOperationException("Simulated broker failure"); + } + Published.Add(dispatched); + return Task.CompletedTask; + } +} diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/TrackingNumberGeneratorTests.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/TrackingNumberGeneratorTests.cs new file mode 100644 index 0000000..38c919a --- /dev/null +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/TrackingNumberGeneratorTests.cs @@ -0,0 +1,26 @@ +using ShippingService.Api.Options; +using ShippingService.Api.Tracking; +using MsOptions = Microsoft.Extensions.Options.Options; + +namespace ShippingService.Tests; + +public sealed class TrackingNumberGeneratorTests +{ + private readonly TrackingNumberGenerator _generator = new( + MsOptions.Create(new ShippingOptions { TrackingPrefix = "SSX" }), + new FixedTimeProvider(new DateTimeOffset(2026, 9, 26, 23, 59, 0, TimeSpan.Zero))); + + [Fact] + public void Next_HasPrefixDateAndCrockfordSuffix() + { + Assert.Matches("^SSX260926[0-9A-HJKMNP-TV-Z]{10}$", _generator.Next()); + } + + [Fact] + public void Next_IsUniqueAcrossManyCalls() + { + var numbers = Enumerable.Range(0, 10_000).Select(_ => _generator.Next()).ToHashSet(); + + Assert.Equal(10_000, numbers.Count); + } +} From 2d53d383df9d0f632bda7574b5a43651870e3167 Mon Sep 17 00:00:00 2001 From: Dhruv Patel Date: Sat, 26 Sep 2026 03:53:31 -0400 Subject: [PATCH 4/8] Re-trigger CI From ad3004cf221ed2d89397ba2bc4d825e5d4314538 Mon Sep 17 00:00:00 2001 From: Dhruv Patel Date: Sat, 26 Sep 2026 04:22:35 -0400 Subject: [PATCH 5/8] Add missing ProcessedEvent entity for shipping-service --- .../Domain/ProcessedEvent.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Domain/ProcessedEvent.cs diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Domain/ProcessedEvent.cs b/shopsphere/shipping-service/src/ShippingService.Api/Domain/ProcessedEvent.cs new file mode 100644 index 0000000..c1f7c63 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Domain/ProcessedEvent.cs @@ -0,0 +1,31 @@ +namespace ShippingService.Api.Domain; + +/// +/// Records a consumed Kafka event so redeliveries of the same EventId are skipped (idempotent consumer). +/// +public sealed class ProcessedEvent +{ + public const int EventIdMaxLength = 100; + public const int EventTypeMaxLength = 100; + + private ProcessedEvent() { } // EF Core + + public string EventId { get; private set; } = null!; + public string EventType { get; private set; } = null!; + public DateTimeOffset ProcessedAt { get; private set; } + + public static ProcessedEvent Create(string eventId, string eventType, DateTimeOffset processedAt) + { + ArgumentException.ThrowIfNullOrWhiteSpace(eventId); + ArgumentException.ThrowIfNullOrWhiteSpace(eventType); + ArgumentOutOfRangeException.ThrowIfGreaterThan(eventId.Length, EventIdMaxLength); + ArgumentOutOfRangeException.ThrowIfGreaterThan(eventType.Length, EventTypeMaxLength); + + return new ProcessedEvent + { + EventId = eventId, + EventType = eventType, + ProcessedAt = processedAt + }; + } +} From bdb41abdb6aa3c769f7e9ac3ae90f4a56fe3b309 Mon Sep 17 00:00:00 2001 From: Dhruv Patel Date: Sat, 26 Sep 2026 04:38:40 -0400 Subject: [PATCH 6/8] Add missing ProcessedEvent entity for shipping-service --- .../src/ShippingService.Api/Domain/Shipment.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Domain/Shipment.cs b/shopsphere/shipping-service/src/ShippingService.Api/Domain/Shipment.cs index 7dcf133..ad5aad8 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Domain/Shipment.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Domain/Shipment.cs @@ -19,6 +19,8 @@ private Shipment() { } // EF Core public DateTimeOffset UpdatedAt { get; private set; } public DateTimeOffset? ShippedAt { get; private set; } public DateTimeOffset? DeliveredAt { get; private set; } + /// Set once shipment.dispatched is acknowledged by Kafka; null means it still needs (re)publishing. + public DateTimeOffset? DispatchPublishedAt { get; private set; } public static Shipment Create(long orderId, long userId, ShippingAddress shippingAddress) { @@ -47,6 +49,12 @@ public void MarkShipped(string carrier, string trackingNumber, DateTimeOffset sh Status = ShipmentStatus.Shipped; } + public void MarkDispatchPublished(DateTimeOffset publishedAt) + { + EnsureStatus(ShipmentStatus.Shipped, "record dispatch publish for"); + DispatchPublishedAt = publishedAt; + } + public void MarkDelivered(DateTimeOffset deliveredAt) { EnsureStatus(ShipmentStatus.Shipped, "deliver"); From ce53d01cbe8312fa73fcfd0ae71fd6100b9a2da6 Mon Sep 17 00:00:00 2001 From: Dhruv Patel Date: Sat, 26 Sep 2026 21:18:17 -0400 Subject: [PATCH 7/8] Order reservation expiry and cancel order --- .../src/main/resources/application.yml | 1 + .../common/events/OrderCancelledEvent.java | 38 ++++ .../events/ReservationExpiredEvent.java | 30 +++ .../common/events/ShipmentDeliveredEvent.java | 30 +++ .../com/shopsphere/common/events/Topics.java | 13 +- .../src/main/resources/config/api-gateway.yml | 1 + shopsphere/docker-compose.yml | 3 + shopsphere/inventory-service/pom.xml | 7 + .../inventoryservice/config/ClockConfig.java | 16 ++ .../config/SchedulingConfig.java | 12 + .../entity/ReservationStatus.java | 12 + .../entity/StockReservation.java | 104 +++++++++ .../messaging/InventoryEventPublisher.java | 16 ++ .../StockReservationRepository.java | 31 +++ .../service/InventoryService.java | 165 +++++++++++--- .../service/ReservationExpiryService.java | 104 +++++++++ .../service/ReservationExpirySweeper.java | 72 ++++++ .../src/main/resources/application.yml | 7 + .../service/InventoryServiceTest.java | 162 +++++++++++-- ...eservationExpiryOptimisticLockingTest.java | 214 ++++++++++++++++++ shopsphere/notification-service/pom.xml | 9 + .../config/KafkaConsumerConfig.java | 53 +++-- .../entity/NotificationType.java | 6 +- .../messaging/NotificationEventConsumer.java | 48 ++++ .../messaging/OrderCancelledConsumer.java | 44 ++++ .../service/NotificationService.java | 78 ++++++- .../src/main/resources/application.yml | 13 +- .../V1__baseline_notifications_schema.sql | 19 ++ .../V2__allow_shipment_notification_types.sql | 14 ++ ...llow_order_cancelled_notification_type.sql | 13 ++ .../service/NotificationServiceTest.java | 64 ++++++ .../OrderCancelledNotificationTest.java | 70 ++++++ shopsphere/order-service/pom.xml | 27 +++ .../config/KafkaConsumerConfig.java | 69 ++++++ .../orderservice/config/KafkaTopicConfig.java | 40 +++- .../orderservice/dto/OrderResponse.java | 11 + .../entity/CancellationReason.java | 17 ++ .../shopsphere/orderservice/entity/Order.java | 36 +++ .../orderservice/entity/OrderStatus.java | 21 +- .../orderservice/entity/ProcessedEvent.java | 35 +++ .../messaging/OrderEventPublisher.java | 16 ++ .../messaging/ReservationExpiredConsumer.java | 43 ++++ .../messaging/ShipmentEventConsumer.java | 66 ++++++ .../repository/ProcessedEventRepository.java | 9 + .../service/OrderCancellationService.java | 106 +++++++++ .../orderservice/service/OrderService.java | 9 + .../service/OrderShipmentService.java | 112 +++++++++ ...shipment_tracking_and_processed_events.sql | 16 ++ .../db/migration/V4__order_cancellation.sql | 5 + .../orderservice/entity/OrderStatusTest.java | 39 ++++ ...tionExpiryCompensationIntegrationTest.java | 183 +++++++++++++++ .../service/OrderCancellationServiceTest.java | 133 +++++++++++ .../service/OrderShipmentServiceTest.java | 151 ++++++++++++ .../Data/ShipmentConfiguration.cs | 1 + .../ShippingService.Api/Domain/Shipment.cs | 25 +- .../Endpoints/ShipmentEndpoints.cs | 78 ++++++- .../Events/ShipmentDeliveredEvent.cs | 43 ++++ .../Messaging/IShipmentEventPublisher.cs | 3 + .../Messaging/KafkaShipmentEventPublisher.cs | 24 +- .../Messaging/ShipmentOutboxRelay.cs | 45 ++++ .../ShippingService.Api/Messaging/Topics.cs | 1 + .../Options/KafkaOptions.cs | 5 +- .../src/ShippingService.Api/Program.cs | 5 +- .../Shipping/PaymentSuccessfulHandler.cs | 16 +- .../Shipping/ShipmentEventOutbox.cs | 92 ++++++++ .../Shipping/ShipmentLifecycleService.cs | 86 +++++++ .../PaymentSuccessfulHandlerTests.cs | 3 +- .../ShipmentEndpointsTests.cs | 123 +++++++++- .../ShipmentEventOutboxTests.cs | 106 +++++++++ .../ShippingService.Tests/TestDoubles.cs | 34 ++- 70 files changed, 3173 insertions(+), 130 deletions(-) create mode 100644 shopsphere/common-lib/src/main/java/com/shopsphere/common/events/OrderCancelledEvent.java create mode 100644 shopsphere/common-lib/src/main/java/com/shopsphere/common/events/ReservationExpiredEvent.java create mode 100644 shopsphere/common-lib/src/main/java/com/shopsphere/common/events/ShipmentDeliveredEvent.java create mode 100644 shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/config/ClockConfig.java create mode 100644 shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/config/SchedulingConfig.java create mode 100644 shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/entity/ReservationStatus.java create mode 100644 shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/entity/StockReservation.java create mode 100644 shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/repository/StockReservationRepository.java create mode 100644 shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/service/ReservationExpiryService.java create mode 100644 shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/service/ReservationExpirySweeper.java create mode 100644 shopsphere/inventory-service/src/test/java/com/shopsphere/inventoryservice/service/ReservationExpiryOptimisticLockingTest.java create mode 100644 shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/messaging/OrderCancelledConsumer.java create mode 100644 shopsphere/notification-service/src/main/resources/db/migration/V1__baseline_notifications_schema.sql create mode 100644 shopsphere/notification-service/src/main/resources/db/migration/V2__allow_shipment_notification_types.sql create mode 100644 shopsphere/notification-service/src/main/resources/db/migration/V3__allow_order_cancelled_notification_type.sql create mode 100644 shopsphere/notification-service/src/test/java/com/shopsphere/notificationservice/service/OrderCancelledNotificationTest.java create mode 100644 shopsphere/order-service/src/main/java/com/shopsphere/orderservice/config/KafkaConsumerConfig.java create mode 100644 shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/CancellationReason.java create mode 100644 shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/ProcessedEvent.java create mode 100644 shopsphere/order-service/src/main/java/com/shopsphere/orderservice/messaging/ReservationExpiredConsumer.java create mode 100644 shopsphere/order-service/src/main/java/com/shopsphere/orderservice/messaging/ShipmentEventConsumer.java create mode 100644 shopsphere/order-service/src/main/java/com/shopsphere/orderservice/repository/ProcessedEventRepository.java create mode 100644 shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderCancellationService.java create mode 100644 shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderShipmentService.java create mode 100644 shopsphere/order-service/src/main/resources/db/migration/V3__order_shipment_tracking_and_processed_events.sql create mode 100644 shopsphere/order-service/src/main/resources/db/migration/V4__order_cancellation.sql create mode 100644 shopsphere/order-service/src/test/java/com/shopsphere/orderservice/entity/OrderStatusTest.java create mode 100644 shopsphere/order-service/src/test/java/com/shopsphere/orderservice/integration/ReservationExpiryCompensationIntegrationTest.java create mode 100644 shopsphere/order-service/src/test/java/com/shopsphere/orderservice/service/OrderCancellationServiceTest.java create mode 100644 shopsphere/order-service/src/test/java/com/shopsphere/orderservice/service/OrderShipmentServiceTest.java create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/ShipmentDeliveredEvent.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Messaging/ShipmentOutboxRelay.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Shipping/ShipmentEventOutbox.cs create mode 100644 shopsphere/shipping-service/src/ShippingService.Api/Shipping/ShipmentLifecycleService.cs create mode 100644 shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentEventOutboxTests.cs diff --git a/shopsphere/api-gateway/src/main/resources/application.yml b/shopsphere/api-gateway/src/main/resources/application.yml index 25c93cf..f79083e 100644 --- a/shopsphere/api-gateway/src/main/resources/application.yml +++ b/shopsphere/api-gateway/src/main/resources/application.yml @@ -91,6 +91,7 @@ app: - /api/v1/products - /api/v1/categories - /api/v1/inventory + - /api/v1/shipments management: endpoints: diff --git a/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/OrderCancelledEvent.java b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/OrderCancelledEvent.java new file mode 100644 index 0000000..fc737c2 --- /dev/null +++ b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/OrderCancelledEvent.java @@ -0,0 +1,38 @@ +package com.shopsphere.common.events; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +/** + * US38 — published by order-service after an order is cancelled (committed). + * Carries enough for notification now, and for refunds/stock compensation later. + */ +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@SuperBuilder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OrderCancelledEvent extends BaseEvent { + + public static final String TYPE = "order.cancelled.v1"; + + private Long orderId; + private Long userId; + + /** Machine-readable reason code, e.g. RESERVATION_EXPIRED. */ + private String reason; + + /** Customer-facing explanation. */ + private String reasonDescription; + + private Instant cancelledAt; + private BigDecimal totalAmount; + private List items; +} diff --git a/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/ReservationExpiredEvent.java b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/ReservationExpiredEvent.java new file mode 100644 index 0000000..1811cd0 --- /dev/null +++ b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/ReservationExpiredEvent.java @@ -0,0 +1,30 @@ +package com.shopsphere.common.events; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.Instant; +import java.util.List; + +/** + * US37 — published by inventory-service when an order's stock holds time out before + * payment settled. One event per order (keyed by orderId), listing every released line. + */ +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@SuperBuilder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ReservationExpiredEvent extends BaseEvent { + + public static final String TYPE = "inventory.reservation-expired.v1"; + + private Long orderId; + private Instant expiredAt; + + /** productId + quantity released (unitPrice not set). */ + private List items; +} diff --git a/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/ShipmentDeliveredEvent.java b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/ShipmentDeliveredEvent.java new file mode 100644 index 0000000..579bde5 --- /dev/null +++ b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/ShipmentDeliveredEvent.java @@ -0,0 +1,30 @@ +package com.shopsphere.common.events; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.Instant; + +/** + * Published by the .NET shipping-service (US36) when a shipment is marked DELIVERED. + * eventId is deterministic per shipment so re-publishes can be de-duplicated. + */ +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@SuperBuilder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ShipmentDeliveredEvent extends BaseEvent { + + public static final String TYPE = "shipment.delivered.v1"; + + private Long shipmentId; + private Long orderId; + private Long userId; + private String carrier; + private String trackingNumber; + private Instant deliveredAt; +} diff --git a/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/Topics.java b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/Topics.java index b939b63..726c88a 100644 --- a/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/Topics.java +++ b/shopsphere/common-lib/src/main/java/com/shopsphere/common/events/Topics.java @@ -9,9 +9,12 @@ public final class Topics { private Topics() {} - public static final String ORDER_CREATED = "order.created"; - public static final String PAYMENT_SUCCESSFUL = "payment.successful"; - public static final String PAYMENT_FAILED = "payment.failed"; - public static final String LOW_STOCK = "inventory.low-stock"; - public static final String SHIPMENT_DISPATCHED = "shipment.dispatched"; + public static final String ORDER_CREATED = "order.created"; + public static final String ORDER_CANCELLED = "order.cancelled"; + public static final String PAYMENT_SUCCESSFUL = "payment.successful"; + public static final String PAYMENT_FAILED = "payment.failed"; + public static final String LOW_STOCK = "inventory.low-stock"; + public static final String RESERVATION_EXPIRED = "inventory.reservation-expired"; + public static final String SHIPMENT_DISPATCHED = "shipment.dispatched"; + public static final String SHIPMENT_DELIVERED = "shipment.delivered"; } diff --git a/shopsphere/config-service/src/main/resources/config/api-gateway.yml b/shopsphere/config-service/src/main/resources/config/api-gateway.yml index af665ca..f6ce739 100644 --- a/shopsphere/config-service/src/main/resources/config/api-gateway.yml +++ b/shopsphere/config-service/src/main/resources/config/api-gateway.yml @@ -19,3 +19,4 @@ app: - /api/v1/products - /api/v1/categories - /api/v1/inventory + - /api/v1/shipments diff --git a/shopsphere/docker-compose.yml b/shopsphere/docker-compose.yml index df6d63b..a3ad5b0 100644 --- a/shopsphere/docker-compose.yml +++ b/shopsphere/docker-compose.yml @@ -44,6 +44,9 @@ services: kafka: image: confluentinc/cp-kafka:7.6.1 container_name: shopsphere-kafka + # On a quick restart ZooKeeper may still hold the old broker registration (NodeExists) + # until its session times out (~18s); retrying lets Kafka come up once it expires. + restart: on-failure depends_on: - zookeeper ports: diff --git a/shopsphere/inventory-service/pom.xml b/shopsphere/inventory-service/pom.xml index 550e3cd..eefd719 100644 --- a/shopsphere/inventory-service/pom.xml +++ b/shopsphere/inventory-service/pom.xml @@ -57,6 +57,13 @@ org.springframework.cloud spring-cloud-starter-config + + + + com.h2database + h2 + test + diff --git a/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/config/ClockConfig.java b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/config/ClockConfig.java new file mode 100644 index 0000000..3cbc016 --- /dev/null +++ b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/config/ClockConfig.java @@ -0,0 +1,16 @@ +package com.shopsphere.inventoryservice.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +/** Injectable clock so reservation expiry is testable. */ +@Configuration +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/config/SchedulingConfig.java b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/config/SchedulingConfig.java new file mode 100644 index 0000000..8511886 --- /dev/null +++ b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/config/SchedulingConfig.java @@ -0,0 +1,12 @@ +package com.shopsphere.inventoryservice.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** US37 — enables the reservation expiry sweeper. Disable with RESERVATION_SWEEPER_ENABLED=false. */ +@Configuration +@EnableScheduling +@ConditionalOnProperty(name = "app.inventory.reservation.sweeper-enabled", havingValue = "true", matchIfMissing = true) +public class SchedulingConfig { +} diff --git a/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/entity/ReservationStatus.java b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/entity/ReservationStatus.java new file mode 100644 index 0000000..756f1f7 --- /dev/null +++ b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/entity/ReservationStatus.java @@ -0,0 +1,12 @@ +package com.shopsphere.inventoryservice.entity; + +public enum ReservationStatus { + /** Units held against an unpaid order; counted in Inventory.reservedQuantity. */ + RESERVED, + /** Payment succeeded; units deducted from available stock. */ + COMMITTED, + /** Payment failed; hold returned to sellable stock. */ + RELEASED, + /** Hold timed out before payment settled; returned to sellable stock by the sweeper. */ + EXPIRED +} diff --git a/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/entity/StockReservation.java b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/entity/StockReservation.java new file mode 100644 index 0000000..843a740 --- /dev/null +++ b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/entity/StockReservation.java @@ -0,0 +1,104 @@ +package com.shopsphere.inventoryservice.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; + +/** + * US37 — one hold per (order, product). Inventory.reservedQuantity is the sum of RESERVED rows; + * this table is what lets a hold expire individually. + * + *

{@code @Version} plus Inventory's own {@code @Version} make concurrent expiry safe: two + * sweepers (or a sweeper and a payment) touching the same hold can't both win.

+ */ +@Entity +@Table(name = "stock_reservations", + uniqueConstraints = @UniqueConstraint( + name = "uk_stock_reservations_order_product", + columnNames = {"order_id", "product_id"}), + indexes = { + @Index(name = "idx_stock_reservations_status_expires", columnList = "status, reservation_expires_at"), + @Index(name = "idx_stock_reservations_order", columnList = "order_id") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class StockReservation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer quantity; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ReservationStatus status; + + /** When this hold is released if payment hasn't settled. */ + @Column(name = "reservation_expires_at", nullable = false) + private Instant reservationExpiresAt; + + /** When the hold left RESERVED (committed / released / expired). */ + @Column(name = "resolved_at") + private Instant resolvedAt; + + @Version + @Column(nullable = false) + private Long version; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private Instant updatedAt; + + public boolean isExpiredAt(Instant now) { + return status == ReservationStatus.RESERVED && !reservationExpiresAt.isAfter(now); + } + + public void markExpired(Instant at) { + requireReserved("expire"); + status = ReservationStatus.EXPIRED; + resolvedAt = at; + } + + public void markReleased(Instant at) { + requireReserved("release"); + status = ReservationStatus.RELEASED; + resolvedAt = at; + } + + /** Allowed from RESERVED, and from EXPIRED/RELEASED for a late payment. */ + public void markCommitted(Instant at) { + if (status == ReservationStatus.COMMITTED) { + throw new IllegalStateException("Reservation " + id + " is already COMMITTED"); + } + status = ReservationStatus.COMMITTED; + resolvedAt = at; + } + + private void requireReserved(String action) { + if (status != ReservationStatus.RESERVED) { + throw new IllegalStateException("Cannot " + action + " reservation " + id + " in status " + status); + } + } +} diff --git a/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/messaging/InventoryEventPublisher.java b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/messaging/InventoryEventPublisher.java index 9ae95d6..9ddb047 100644 --- a/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/messaging/InventoryEventPublisher.java +++ b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/messaging/InventoryEventPublisher.java @@ -1,6 +1,7 @@ package com.shopsphere.inventoryservice.messaging; import com.shopsphere.common.events.LowStockEvent; +import com.shopsphere.common.events.ReservationExpiredEvent; import com.shopsphere.common.events.Topics; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,4 +28,19 @@ public void publishLowStock(LowStockEvent event) { } }); } + + /** US37 — keyed by orderId so it stays ordered with the order's other events. */ + public void publishReservationExpired(ReservationExpiredEvent event) { + String key = String.valueOf(event.getOrderId()); + kafkaTemplate.send(Topics.RESERVATION_EXPIRED, key, event) + .whenComplete((res, ex) -> { + if (ex != null) { + log.error("Failed to publish inventory.reservation-expired for orderId={}: {}", + event.getOrderId(), ex.getMessage(), ex); + } else { + log.info("Published inventory.reservation-expired eventId={} orderId={} lines={}", + event.getEventId(), event.getOrderId(), event.getItems().size()); + } + }); + } } diff --git a/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/repository/StockReservationRepository.java b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/repository/StockReservationRepository.java new file mode 100644 index 0000000..747455e --- /dev/null +++ b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/repository/StockReservationRepository.java @@ -0,0 +1,31 @@ +package com.shopsphere.inventoryservice.repository; + +import com.shopsphere.inventoryservice.entity.ReservationStatus; +import com.shopsphere.inventoryservice.entity.StockReservation; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Repository +public interface StockReservationRepository extends JpaRepository { + + Optional findByOrderIdAndProductId(Long orderId, Long productId); + + List findByOrderIdAndStatus(Long orderId, ReservationStatus status); + + /** Orders with at least one overdue hold, oldest order id first. */ + @Query(""" + SELECT DISTINCT r.orderId FROM StockReservation r + WHERE r.status = :status AND r.reservationExpiresAt <= :now + ORDER BY r.orderId + """) + List findOrderIdsWithExpiredReservations(@Param("status") ReservationStatus status, + @Param("now") Instant now, + Pageable pageable); +} diff --git a/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/service/InventoryService.java b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/service/InventoryService.java index c016044..3556f37 100644 --- a/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/service/InventoryService.java +++ b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/service/InventoryService.java @@ -1,6 +1,7 @@ package com.shopsphere.inventoryservice.service; import com.shopsphere.common.events.OrderCreatedEvent; +import com.shopsphere.common.events.OrderItemSnapshot; import com.shopsphere.common.events.PaymentFailedEvent; import com.shopsphere.common.events.PaymentSuccessfulEvent; import com.shopsphere.inventoryservice.dto.CheckAvailabilityRequest; @@ -9,19 +10,29 @@ import com.shopsphere.inventoryservice.dto.StockResponse; import com.shopsphere.inventoryservice.dto.StockUpdateRequest; import com.shopsphere.inventoryservice.entity.Inventory; +import com.shopsphere.inventoryservice.entity.ReservationStatus; +import com.shopsphere.inventoryservice.entity.StockReservation; import com.shopsphere.inventoryservice.exception.InsufficientStockException; import com.shopsphere.inventoryservice.exception.InventoryAlreadyExistsException; import com.shopsphere.inventoryservice.exception.InventoryNotFoundException; import com.shopsphere.inventoryservice.repository.InventoryRepository; +import com.shopsphere.inventoryservice.repository.StockReservationRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.function.Function; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -29,10 +40,16 @@ public class InventoryService { private final InventoryRepository inventoryRepository; + private final StockReservationRepository reservationRepository; + private final Clock clock; @Value("${app.inventory.low-stock-threshold}") private int lowStockThreshold; + /** US37 — how long a hold survives without payment. */ + @Value("${app.inventory.reservation.ttl:15m}") + private Duration reservationTtl; + /* ---------------------------------------------------------------- */ /* Initialization */ /* ---------------------------------------------------------------- */ @@ -157,62 +174,143 @@ public StockResponse updateStock(Long productId, StockUpdateRequest request) { } /* ---------------------------------------------------------------- */ - /* Kafka event handlers: US17+ — Stock reservation lifecycle */ + /* Kafka event handlers — stock reservation lifecycle (US17 + US37) */ + /* Products are processed in ascending id order so concurrent */ + /* transactions take row locks in the same order (no deadlocks). */ /* ---------------------------------------------------------------- */ @Transactional public void reserveForOrder(OrderCreatedEvent event) { - log.info("Reserving inventory for order eventId={} orderId={} itemCount={}", - event.getEventId(), event.getOrderId(), event.getItemCount()); - - for (OrderCreatedEvent.Item item : event.getItems()) { - Inventory inv = inventoryRepository.findByProductIdForUpdate(item.getProductId()) - .orElseThrow(() -> new InventoryNotFoundException(item.getProductId())); + Instant now = clock.instant(); + Instant expiresAt = now.plus(reservationTtl); + log.info("Reserving inventory for order eventId={} orderId={} itemCount={} expiresAt={}", + event.getEventId(), event.getOrderId(), event.getItemCount(), expiresAt); + + Map quantities = quantitiesByProduct( + event.getItems(), OrderCreatedEvent.Item::getProductId, OrderCreatedEvent.Item::getQuantity); + + for (Map.Entry line : quantities.entrySet()) { + Long productId = line.getKey(); + int quantity = line.getValue(); + + // Idempotent: a redelivered order.created must not reserve twice. + if (reservationRepository.findByOrderIdAndProductId(event.getOrderId(), productId).isPresent()) { + log.info("Order {} already holds product {} — skipping duplicate reservation", + event.getOrderId(), productId); + continue; + } - int newReserved = inv.getReservedQuantity() + item.getQuantity(); + Inventory inv = lockInventory(productId); + int newReserved = inv.getReservedQuantity() + quantity; if (inv.getAvailableQuantity() < newReserved) { - throw new InsufficientStockException( - item.getProductId(), item.getQuantity(), - inv.getAvailableQuantity() - inv.getReservedQuantity()); + throw new InsufficientStockException(productId, quantity, inv.sellable()); } inv.setReservedQuantity(newReserved); inventoryRepository.save(inv); - log.debug("Reserved {} units of product {} for order {}", - item.getQuantity(), item.getProductId(), event.getOrderId()); + reservationRepository.save(StockReservation.builder() + .orderId(event.getOrderId()) + .productId(productId) + .quantity(quantity) + .status(ReservationStatus.RESERVED) + .reservationExpiresAt(expiresAt) + .build()); + + log.debug("Reserved {} units of product {} for order {} until {}", + quantity, productId, event.getOrderId(), expiresAt); } } @Transactional public void commitReservation(PaymentSuccessfulEvent event) { + Instant now = clock.instant(); log.info("Committing reservation for payment eventId={} orderId={}", event.getEventId(), event.getOrderId()); - for (com.shopsphere.common.events.OrderItemSnapshot item : event.getItems()) { - Inventory inv = inventoryRepository.findByProductIdForUpdate(item.getProductId()) - .orElseThrow(() -> new InventoryNotFoundException(item.getProductId())); + Map quantities = quantitiesByProduct( + event.getItems(), OrderItemSnapshot::getProductId, OrderItemSnapshot::getQuantity); + + for (Map.Entry line : quantities.entrySet()) { + Long productId = line.getKey(); + Inventory inv = lockInventory(productId); + Optional maybeHold = + reservationRepository.findByOrderIdAndProductId(event.getOrderId(), productId); + + if (maybeHold.isEmpty()) { + // Hold taken before US37 — only tracked in the aggregate. + int quantity = line.getValue(); + inv.setAvailableQuantity(inv.getAvailableQuantity() - quantity); + inv.setReservedQuantity(Math.max(0, inv.getReservedQuantity() - quantity)); + inventoryRepository.save(inv); + continue; + } + + StockReservation hold = maybeHold.get(); + int quantity = hold.getQuantity(); + + switch (hold.getStatus()) { + case COMMITTED -> { + log.info("Order {} product {} already committed — skipping duplicate", event.getOrderId(), productId); + continue; + } + case RESERVED -> { + inv.setAvailableQuantity(inv.getAvailableQuantity() - quantity); + inv.setReservedQuantity(Math.max(0, inv.getReservedQuantity() - quantity)); + } + case EXPIRED, RELEASED -> { + // Payment settled after the hold was released — take it from sellable stock if we still can. + if (inv.sellable() >= quantity) { + inv.setAvailableQuantity(inv.getAvailableQuantity() - quantity); + log.warn("Late payment for order {}: hold on product {} was {}, committed {} from sellable stock", + event.getOrderId(), productId, hold.getStatus(), quantity); + } else { + log.error("OVERSOLD: late payment for order {} needs {} x product {} but only {} sellable — manual follow-up required", + event.getOrderId(), quantity, productId, inv.sellable()); + } + } + } - inv.setAvailableQuantity(inv.getAvailableQuantity() - item.getQuantity()); - inv.setReservedQuantity(inv.getReservedQuantity() - item.getQuantity()); + hold.markCommitted(now); inventoryRepository.save(inv); - log.debug("Committed {} units of product {} for order {}", - item.getQuantity(), item.getProductId(), event.getOrderId()); + reservationRepository.save(hold); + log.debug("Committed {} units of product {} for order {}", quantity, productId, event.getOrderId()); } } @Transactional public void releaseReservation(PaymentFailedEvent event) { + Instant now = clock.instant(); log.info("Releasing reservation for payment eventId={} orderId={}", event.getEventId(), event.getOrderId()); - for (com.shopsphere.common.events.OrderItemSnapshot item : event.getItems()) { - Inventory inv = inventoryRepository.findByProductIdForUpdate(item.getProductId()) - .orElseThrow(() -> new InventoryNotFoundException(item.getProductId())); + Map quantities = quantitiesByProduct( + event.getItems(), OrderItemSnapshot::getProductId, OrderItemSnapshot::getQuantity); - inv.setReservedQuantity(Math.max(0, inv.getReservedQuantity() - item.getQuantity())); + for (Map.Entry line : quantities.entrySet()) { + Long productId = line.getKey(); + Inventory inv = lockInventory(productId); + Optional maybeHold = + reservationRepository.findByOrderIdAndProductId(event.getOrderId(), productId); + + if (maybeHold.isEmpty()) { + // Hold taken before US37 — only tracked in the aggregate. + inv.setReservedQuantity(Math.max(0, inv.getReservedQuantity() - line.getValue())); + inventoryRepository.save(inv); + continue; + } + + StockReservation hold = maybeHold.get(); + if (hold.getStatus() != ReservationStatus.RESERVED) { + // Already expired/released/committed — releasing again would steal another order's hold. + log.info("Order {} product {} hold is {} — nothing to release", event.getOrderId(), productId, hold.getStatus()); + continue; + } + + inv.setReservedQuantity(Math.max(0, inv.getReservedQuantity() - hold.getQuantity())); + hold.markReleased(now); inventoryRepository.save(inv); - log.debug("Released {} units of product {} for order {}", - item.getQuantity(), item.getProductId(), event.getOrderId()); + reservationRepository.save(hold); + log.debug("Released {} units of product {} for order {}", hold.getQuantity(), productId, event.getOrderId()); } } @@ -220,6 +318,21 @@ public void releaseReservation(PaymentFailedEvent event) { /* Helpers */ /* ---------------------------------------------------------------- */ + private Inventory lockInventory(Long productId) { + return inventoryRepository.findByProductIdForUpdate(productId) + .orElseThrow(() -> new InventoryNotFoundException(productId)); + } + + /** Merges duplicate product lines and sorts by product id (consistent lock order). */ + private static Map quantitiesByProduct(List items, + Function productId, + Function quantity) { + if (items == null || items.isEmpty()) { + return Map.of(); + } + return items.stream().collect(Collectors.toMap(productId, quantity, Integer::sum, TreeMap::new)); + } + private StockResponse toResponse(Inventory inv) { int sellable = inv.sellable(); return StockResponse.builder() diff --git a/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/service/ReservationExpiryService.java b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/service/ReservationExpiryService.java new file mode 100644 index 0000000..2a2c3b9 --- /dev/null +++ b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/service/ReservationExpiryService.java @@ -0,0 +1,104 @@ +package com.shopsphere.inventoryservice.service; + +import com.shopsphere.common.events.OrderItemSnapshot; +import com.shopsphere.common.events.ReservationExpiredEvent; +import com.shopsphere.inventoryservice.entity.Inventory; +import com.shopsphere.inventoryservice.entity.ReservationStatus; +import com.shopsphere.inventoryservice.entity.StockReservation; +import com.shopsphere.inventoryservice.exception.InventoryNotFoundException; +import com.shopsphere.inventoryservice.messaging.InventoryEventPublisher; +import com.shopsphere.inventoryservice.repository.InventoryRepository; +import com.shopsphere.inventoryservice.repository.StockReservationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** + * US37 — releases overdue stock holds, one order per transaction. + * + *

Concurrency is handled optimistically: Inventory and StockReservation both carry + * {@code @Version}. If another transaction (a payment, a restock, a second sweeper instance) + * changed either row since we read it, the flush fails with an + * {@link org.springframework.orm.ObjectOptimisticLockingFailureException}, the whole order's + * expiry rolls back, no event is published, and the next sweep re-evaluates from fresh state.

+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ReservationExpiryService { + + private final StockReservationRepository reservationRepository; + private final InventoryRepository inventoryRepository; + private final InventoryEventPublisher eventPublisher; + private final Clock clock; + + /** @return true if at least one hold for the order was expired. */ + @Transactional + public boolean expireOrder(Long orderId) { + Instant now = clock.instant(); + List overdue = reservationRepository + .findByOrderIdAndStatus(orderId, ReservationStatus.RESERVED).stream() + .filter(r -> r.isExpiredAt(now)) + .toList(); + return applyExpiry(orderId, overdue, now); + } + + /** + * Applies expiry to reservations already loaded in the caller's transaction. + * Public (MANDATORY) so tests can interleave a concurrent writer between read and write. + */ + @Transactional(propagation = Propagation.MANDATORY) + public boolean applyExpiry(Long orderId, List overdue, Instant now) { + if (overdue.isEmpty()) { + return false; + } + + for (StockReservation reservation : overdue) { + // Plain read (no FOR UPDATE): rely on @Version to detect concurrent changes. + Inventory inv = inventoryRepository.findByProductId(reservation.getProductId()) + .orElseThrow(() -> new InventoryNotFoundException(reservation.getProductId())); + + inv.setReservedQuantity(Math.max(0, inv.getReservedQuantity() - reservation.getQuantity())); + reservation.markExpired(now); + + reservationRepository.saveAndFlush(reservation); + inventoryRepository.saveAndFlush(inv); + + log.info("Expired hold of {} x product {} for order {} (was due {})", + reservation.getQuantity(), reservation.getProductId(), orderId, + reservation.getReservationExpiresAt()); + } + + ReservationExpiredEvent event = ReservationExpiredEvent.builder() + .eventId(UUID.randomUUID().toString()) + .eventType(ReservationExpiredEvent.TYPE) + .occurredAt(now) + .orderId(orderId) + .expiredAt(now) + .items(overdue.stream() + .map(r -> OrderItemSnapshot.builder() + .productId(r.getProductId()) + .quantity(r.getQuantity()) + .build()) + .toList()) + .build(); + + // Only tell the world once the release is durable. + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + eventPublisher.publishReservationExpired(event); + } + }); + return true; + } +} diff --git a/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/service/ReservationExpirySweeper.java b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/service/ReservationExpirySweeper.java new file mode 100644 index 0000000..5280fba --- /dev/null +++ b/shopsphere/inventory-service/src/main/java/com/shopsphere/inventoryservice/service/ReservationExpirySweeper.java @@ -0,0 +1,72 @@ +package com.shopsphere.inventoryservice.service; + +import com.shopsphere.inventoryservice.entity.ReservationStatus; +import com.shopsphere.inventoryservice.repository.StockReservationRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.util.List; + +/** + * US37 — periodically releases overdue holds. Safe to run on every instance: each order is + * expired in its own optimistic transaction, so racing sweepers can't double-release. + */ +@Component +@Slf4j +public class ReservationExpirySweeper { + + private final StockReservationRepository reservationRepository; + private final ReservationExpiryService expiryService; + private final Clock clock; + private final int batchSize; + + public ReservationExpirySweeper(StockReservationRepository reservationRepository, + ReservationExpiryService expiryService, + Clock clock, + @Value("${app.inventory.reservation.sweep-batch-size:100}") int batchSize) { + this.reservationRepository = reservationRepository; + this.expiryService = expiryService; + this.clock = clock; + this.batchSize = batchSize; + } + + @Scheduled(fixedDelayString = "${app.inventory.reservation.sweep-interval-ms:60000}", + initialDelayString = "${app.inventory.reservation.sweep-initial-delay-ms:30000}") + public void scheduledSweep() { + sweep(); + } + + /** @return number of orders whose holds were expired in this pass. */ + public int sweep() { + List orderIds = reservationRepository.findOrderIdsWithExpiredReservations( + ReservationStatus.RESERVED, clock.instant(), PageRequest.of(0, batchSize)); + if (orderIds.isEmpty()) { + return 0; + } + + int expired = 0; + int conflicts = 0; + for (Long orderId : orderIds) { + try { + if (expiryService.expireOrder(orderId)) { + expired++; + } + } catch (OptimisticLockingFailureException ex) { + // Someone else touched the stock/hold first — re-evaluated on the next pass. + conflicts++; + log.info("Optimistic lock conflict expiring order {} — will retry next sweep", orderId); + } catch (Exception ex) { + log.error("Failed to expire reservations for order {}: {}", orderId, ex.getMessage(), ex); + } + } + + log.info("Reservation sweep: {} candidate order(s), {} expired, {} conflict(s)", + orderIds.size(), expired, conflicts); + return expired; + } +} diff --git a/shopsphere/inventory-service/src/main/resources/application.yml b/shopsphere/inventory-service/src/main/resources/application.yml index 4548da2..e334f41 100644 --- a/shopsphere/inventory-service/src/main/resources/application.yml +++ b/shopsphere/inventory-service/src/main/resources/application.yml @@ -41,6 +41,13 @@ spring: app: inventory: low-stock-threshold: ${LOW_STOCK_THRESHOLD:10} + # US37 — stock holds for unpaid orders time out and are released by a sweeper + reservation: + ttl: ${RESERVATION_TTL:15m} + sweeper-enabled: ${RESERVATION_SWEEPER_ENABLED:true} + sweep-interval-ms: ${RESERVATION_SWEEP_INTERVAL_MS:60000} + sweep-initial-delay-ms: ${RESERVATION_SWEEP_INITIAL_DELAY_MS:30000} + sweep-batch-size: ${RESERVATION_SWEEP_BATCH_SIZE:100} management: endpoints: diff --git a/shopsphere/inventory-service/src/test/java/com/shopsphere/inventoryservice/service/InventoryServiceTest.java b/shopsphere/inventory-service/src/test/java/com/shopsphere/inventoryservice/service/InventoryServiceTest.java index ff31741..2f10c12 100644 --- a/shopsphere/inventory-service/src/test/java/com/shopsphere/inventoryservice/service/InventoryServiceTest.java +++ b/shopsphere/inventory-service/src/test/java/com/shopsphere/inventoryservice/service/InventoryServiceTest.java @@ -1,40 +1,70 @@ package com.shopsphere.inventoryservice.service; +import com.shopsphere.common.events.OrderCreatedEvent; +import com.shopsphere.common.events.OrderItemSnapshot; +import com.shopsphere.common.events.PaymentFailedEvent; +import com.shopsphere.common.events.PaymentSuccessfulEvent; import com.shopsphere.inventoryservice.dto.StockUpdateRequest; import com.shopsphere.inventoryservice.entity.Inventory; +import com.shopsphere.inventoryservice.entity.ReservationStatus; +import com.shopsphere.inventoryservice.entity.StockReservation; import com.shopsphere.inventoryservice.exception.InsufficientStockException; import com.shopsphere.inventoryservice.exception.InventoryNotFoundException; -import com.shopsphere.inventoryservice.messaging.InventoryEventPublisher; import com.shopsphere.inventoryservice.repository.InventoryRepository; -import com.shopsphere.inventoryservice.repository.ProcessedEventRepository; +import com.shopsphere.inventoryservice.repository.StockReservationRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class InventoryServiceTest { + private static final Instant NOW = Instant.parse("2026-09-26T12:00:00Z"); + @Mock InventoryRepository inventoryRepository; - @Mock ProcessedEventRepository processedEventRepository; - @Mock InventoryEventPublisher inventoryEventPublisher; + @Mock StockReservationRepository reservationRepository; - @InjectMocks InventoryService inventoryService; + InventoryService inventoryService; @BeforeEach void setUp() { + inventoryService = new InventoryService(inventoryRepository, reservationRepository, + Clock.fixed(NOW, ZoneOffset.UTC)); ReflectionTestUtils.setField(inventoryService, "lowStockThreshold", 10); + ReflectionTestUtils.setField(inventoryService, "reservationTtl", Duration.ofMinutes(15)); + } + + private static Inventory inventory(int available, int reserved) { + return Inventory.builder().id(1L).productId(1L) + .availableQuantity(available).reservedQuantity(reserved).build(); } + private static StockReservation hold(ReservationStatus status, int quantity) { + return StockReservation.builder().id(5L).orderId(42L).productId(1L) + .quantity(quantity).status(status) + .reservationExpiresAt(NOW.minusSeconds(60)).build(); + } + + /* ---------------------------- stock updates ---------------------------- */ + @Test void updateStock_notFound_throws() { when(inventoryRepository.findByProductIdForUpdate(99L)).thenReturn(Optional.empty()); @@ -47,11 +77,7 @@ void updateStock_notFound_throws() { @Test void updateStock_decrementBelowReserved_throws() { - Inventory inv = Inventory.builder() - .id(1L).productId(1L) - .availableQuantity(10).reservedQuantity(8) - .build(); - when(inventoryRepository.findByProductIdForUpdate(1L)).thenReturn(Optional.of(inv)); + when(inventoryRepository.findByProductIdForUpdate(1L)).thenReturn(Optional.of(inventory(10, 8))); StockUpdateRequest req = new StockUpdateRequest(StockUpdateRequest.Operation.DECREMENT, 5); @@ -61,16 +87,118 @@ void updateStock_decrementBelowReserved_throws() { @Test void updateStock_increment_succeeds() { - Inventory inv = Inventory.builder() - .id(1L).productId(1L) - .availableQuantity(10).reservedQuantity(0) - .build(); - when(inventoryRepository.findByProductIdForUpdate(1L)).thenReturn(Optional.of(inv)); + when(inventoryRepository.findByProductIdForUpdate(1L)).thenReturn(Optional.of(inventory(10, 0))); when(inventoryRepository.save(any(Inventory.class))).thenAnswer(i -> i.getArgument(0)); StockUpdateRequest req = new StockUpdateRequest(StockUpdateRequest.Operation.INCREMENT, 5); var response = inventoryService.updateStock(1L, req); - org.assertj.core.api.Assertions.assertThat(response.getAvailableQuantity()).isEqualTo(15); + assertThat(response.getAvailableQuantity()).isEqualTo(15); + } + + /* ------------------------------ reserve ------------------------------ */ + + private static OrderCreatedEvent orderCreated(int quantity) { + return OrderCreatedEvent.builder() + .eventId("oc-1").orderId(42L).userId(7L).itemCount(quantity) + .items(List.of(OrderCreatedEvent.Item.builder().productId(1L).quantity(quantity).build())) + .build(); + } + + @Test + void reserve_createsHoldWithExpiry_andIncreasesReserved() { + Inventory inv = inventory(10, 0); + when(reservationRepository.findByOrderIdAndProductId(42L, 1L)).thenReturn(Optional.empty()); + when(inventoryRepository.findByProductIdForUpdate(1L)).thenReturn(Optional.of(inv)); + + inventoryService.reserveForOrder(orderCreated(3)); + + assertThat(inv.getReservedQuantity()).isEqualTo(3); + ArgumentCaptor captor = ArgumentCaptor.forClass(StockReservation.class); + verify(reservationRepository).save(captor.capture()); + StockReservation saved = captor.getValue(); + assertThat(saved.getStatus()).isEqualTo(ReservationStatus.RESERVED); + assertThat(saved.getQuantity()).isEqualTo(3); + assertThat(saved.getReservationExpiresAt()).isEqualTo(NOW.plus(Duration.ofMinutes(15))); + } + + @Test + void reserve_redeliveredEvent_doesNotReserveTwice() { + when(reservationRepository.findByOrderIdAndProductId(42L, 1L)) + .thenReturn(Optional.of(hold(ReservationStatus.RESERVED, 3))); + + inventoryService.reserveForOrder(orderCreated(3)); + + verify(inventoryRepository, never()).findByProductIdForUpdate(any()); + verify(reservationRepository, never()).save(any()); + } + + /* ------------------------------ commit ------------------------------ */ + + private static PaymentSuccessfulEvent paymentSuccessful(int quantity) { + return PaymentSuccessfulEvent.builder() + .eventId("ps-1").orderId(42L).userId(7L) + .items(List.of(OrderItemSnapshot.builder().productId(1L).quantity(quantity).build())) + .build(); + } + + @Test + void commit_reservedHold_deductsAvailableAndReserved() { + Inventory inv = inventory(10, 3); + StockReservation hold = hold(ReservationStatus.RESERVED, 3); + when(inventoryRepository.findByProductIdForUpdate(1L)).thenReturn(Optional.of(inv)); + when(reservationRepository.findByOrderIdAndProductId(42L, 1L)).thenReturn(Optional.of(hold)); + + inventoryService.commitReservation(paymentSuccessful(3)); + + assertThat(inv.getAvailableQuantity()).isEqualTo(7); + assertThat(inv.getReservedQuantity()).isZero(); + assertThat(hold.getStatus()).isEqualTo(ReservationStatus.COMMITTED); + } + + @Test + void commit_afterExpiry_takesFromSellableStockOnly() { + Inventory inv = inventory(10, 2); // 2 held by some other order + StockReservation hold = hold(ReservationStatus.EXPIRED, 3); + when(inventoryRepository.findByProductIdForUpdate(1L)).thenReturn(Optional.of(inv)); + when(reservationRepository.findByOrderIdAndProductId(42L, 1L)).thenReturn(Optional.of(hold)); + + inventoryService.commitReservation(paymentSuccessful(3)); + + assertThat(inv.getAvailableQuantity()).isEqualTo(7); + assertThat(inv.getReservedQuantity()).isEqualTo(2); // other order's hold untouched + assertThat(hold.getStatus()).isEqualTo(ReservationStatus.COMMITTED); + } + + @Test + void commit_afterExpiry_withoutStock_doesNotGoNegative() { + Inventory inv = inventory(5, 5); // nothing sellable + StockReservation hold = hold(ReservationStatus.EXPIRED, 3); + when(inventoryRepository.findByProductIdForUpdate(1L)).thenReturn(Optional.of(inv)); + when(reservationRepository.findByOrderIdAndProductId(42L, 1L)).thenReturn(Optional.of(hold)); + + inventoryService.commitReservation(paymentSuccessful(3)); + + assertThat(inv.getAvailableQuantity()).isEqualTo(5); + assertThat(inv.getReservedQuantity()).isEqualTo(5); + assertThat(hold.getStatus()).isEqualTo(ReservationStatus.COMMITTED); + } + + /* ------------------------------ release ------------------------------ */ + + @Test + void release_afterExpiry_doesNotStealOtherOrdersHolds() { + Inventory inv = inventory(10, 2); // 2 held by another order + when(inventoryRepository.findByProductIdForUpdate(1L)).thenReturn(Optional.of(inv)); + when(reservationRepository.findByOrderIdAndProductId(42L, 1L)) + .thenReturn(Optional.of(hold(ReservationStatus.EXPIRED, 3))); + + inventoryService.releaseReservation(PaymentFailedEvent.builder() + .eventId("pf-1").orderId(42L).userId(7L) + .items(List.of(OrderItemSnapshot.builder().productId(1L).quantity(3).build())) + .build()); + + assertThat(inv.getReservedQuantity()).isEqualTo(2); + verify(inventoryRepository, never()).save(any()); } } diff --git a/shopsphere/inventory-service/src/test/java/com/shopsphere/inventoryservice/service/ReservationExpiryOptimisticLockingTest.java b/shopsphere/inventory-service/src/test/java/com/shopsphere/inventoryservice/service/ReservationExpiryOptimisticLockingTest.java new file mode 100644 index 0000000..e134a72 --- /dev/null +++ b/shopsphere/inventory-service/src/test/java/com/shopsphere/inventoryservice/service/ReservationExpiryOptimisticLockingTest.java @@ -0,0 +1,214 @@ +package com.shopsphere.inventoryservice.service; + +import com.shopsphere.common.events.OrderItemSnapshot; +import com.shopsphere.common.events.PaymentFailedEvent; +import com.shopsphere.common.events.PaymentSuccessfulEvent; +import com.shopsphere.common.events.ReservationExpiredEvent; +import com.shopsphere.inventoryservice.config.ClockConfig; +import com.shopsphere.inventoryservice.entity.Inventory; +import com.shopsphere.inventoryservice.entity.ReservationStatus; +import com.shopsphere.inventoryservice.entity.StockReservation; +import com.shopsphere.inventoryservice.messaging.InventoryEventPublisher; +import com.shopsphere.inventoryservice.repository.InventoryRepository; +import com.shopsphere.inventoryservice.repository.StockReservationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * US37 — reservation expiry against a real database, so @Version checks actually fire. + * + * Seed for every test (product 1: available 10, reserved 5): + * order 42 → 3 units, hold EXPIRED 1 minute ago (sweeper target) + * order 44 → 2 units, hold still valid for 10 min (must never be touched) + */ +@DataJpaTest(properties = { + "spring.cloud.config.enabled=false", + "spring.datasource.url=jdbc:h2:mem:inventory-expiry;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;INIT=CREATE SCHEMA IF NOT EXISTS shopsphere_inventory", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Transactional(propagation = Propagation.NOT_SUPPORTED) // tests drive their own commits +@Import({ReservationExpiryService.class, ReservationExpirySweeper.class, InventoryService.class, ClockConfig.class}) +class ReservationExpiryOptimisticLockingTest { + + private static final long PRODUCT = 1L; + private static final long EXPIRED_ORDER = 42L; + private static final long ACTIVE_ORDER = 44L; + + @Autowired InventoryRepository inventoryRepository; + @Autowired StockReservationRepository reservationRepository; + @Autowired ReservationExpiryService expiryService; + @Autowired ReservationExpirySweeper sweeper; + @Autowired InventoryService inventoryService; + @Autowired PlatformTransactionManager transactionManager; + + @MockBean InventoryEventPublisher eventPublisher; + + private TransactionTemplate tx; + private TransactionTemplate concurrentTx; + + @BeforeEach + void seed() { + tx = new TransactionTemplate(transactionManager); + concurrentTx = new TransactionTemplate(transactionManager); + concurrentTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + + tx.executeWithoutResult(s -> { + // Bulk deletes run immediately; deleteAll() would be flushed after the inserts below + // (Hibernate orders INSERTs before DELETEs) and trip uk_inventory_product. + reservationRepository.deleteAllInBatch(); + inventoryRepository.deleteAllInBatch(); + + inventoryRepository.save(Inventory.builder() + .productId(PRODUCT).availableQuantity(10).reservedQuantity(5).build()); + + Instant now = Instant.now(); + reservationRepository.save(StockReservation.builder() + .orderId(EXPIRED_ORDER).productId(PRODUCT).quantity(3) + .status(ReservationStatus.RESERVED) + .reservationExpiresAt(now.minus(Duration.ofMinutes(1))) + .build()); + reservationRepository.save(StockReservation.builder() + .orderId(ACTIVE_ORDER).productId(PRODUCT).quantity(2) + .status(ReservationStatus.RESERVED) + .reservationExpiresAt(now.plus(Duration.ofMinutes(10))) + .build()); + }); + } + + private Inventory inventory() { + return inventoryRepository.findByProductId(PRODUCT).orElseThrow(); + } + + private ReservationStatus statusOf(long orderId) { + return reservationRepository.findByOrderIdAndProductId(orderId, PRODUCT).orElseThrow().getStatus(); + } + + @Test + void sweep_releasesOnlyOverdueHolds_andPublishesEvent() { + int expired = sweeper.sweep(); + + assertThat(expired).isEqualTo(1); + assertThat(statusOf(EXPIRED_ORDER)).isEqualTo(ReservationStatus.EXPIRED); + assertThat(statusOf(ACTIVE_ORDER)).isEqualTo(ReservationStatus.RESERVED); + assertThat(inventory().getReservedQuantity()).isEqualTo(2); + assertThat(inventory().getAvailableQuantity()).isEqualTo(10); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReservationExpiredEvent.class); + verify(eventPublisher).publishReservationExpired(captor.capture()); + ReservationExpiredEvent event = captor.getValue(); + assertThat(event.getOrderId()).isEqualTo(EXPIRED_ORDER); + assertThat(event.getEventId()).isNotBlank(); + assertThat(event.getItems()).extracting(OrderItemSnapshot::getQuantity).containsExactly(3); + + assertThat(sweeper.sweep()).isZero(); // nothing left to do + } + + @Test + void concurrentStockUpdate_causesOptimisticLockFailure_expiryRollsBack_andNextSweepSucceeds() { + assertThatThrownBy(() -> tx.executeWithoutResult(status -> { + // Sweeper reads the hold and the inventory row (version N) ... + List overdue = + reservationRepository.findByOrderIdAndStatus(EXPIRED_ORDER, ReservationStatus.RESERVED); + Inventory staleView = inventoryRepository.findByProductId(PRODUCT).orElseThrow(); + assertThat(staleView.getAvailableQuantity()).isEqualTo(10); + + // ... meanwhile an admin restock commits first (version N+1) ... + concurrentTx.executeWithoutResult(s -> { + Inventory fresh = inventoryRepository.findByProductId(PRODUCT).orElseThrow(); + fresh.setAvailableQuantity(fresh.getAvailableQuantity() + 5); + inventoryRepository.save(fresh); + }); + + // ... so the sweeper's write against version N must fail. + expiryService.applyExpiry(EXPIRED_ORDER, overdue, Instant.now()); + })).isInstanceOf(OptimisticLockingFailureException.class); + + // Rolled back: hold still RESERVED, restock kept, no event. + assertThat(statusOf(EXPIRED_ORDER)).isEqualTo(ReservationStatus.RESERVED); + assertThat(inventory().getAvailableQuantity()).isEqualTo(15); + assertThat(inventory().getReservedQuantity()).isEqualTo(5); + verify(eventPublisher, never()).publishReservationExpired(any()); + + // Next pass works from fresh state. + assertThat(sweeper.sweep()).isEqualTo(1); + assertThat(statusOf(EXPIRED_ORDER)).isEqualTo(ReservationStatus.EXPIRED); + assertThat(inventory().getAvailableQuantity()).isEqualTo(15); + assertThat(inventory().getReservedQuantity()).isEqualTo(2); + verify(eventPublisher, times(1)).publishReservationExpired(any()); + } + + @Test + void twoSweepersRacing_onlyOneReleasesTheHold() { + assertThatThrownBy(() -> tx.executeWithoutResult(status -> { + // Sweeper instance A reads the overdue hold (version N) ... + List overdue = + reservationRepository.findByOrderIdAndStatus(EXPIRED_ORDER, ReservationStatus.RESERVED); + + // ... instance B expires the same order and commits first ... + concurrentTx.executeWithoutResult(s -> assertThat(expiryService.expireOrder(EXPIRED_ORDER)).isTrue()); + + // ... so A's write against version N must fail instead of releasing a second time. + expiryService.applyExpiry(EXPIRED_ORDER, overdue, Instant.now()); + })).isInstanceOf(OptimisticLockingFailureException.class); + + assertThat(statusOf(EXPIRED_ORDER)).isEqualTo(ReservationStatus.EXPIRED); + assertThat(statusOf(ACTIVE_ORDER)).isEqualTo(ReservationStatus.RESERVED); + assertThat(inventory().getReservedQuantity()).isEqualTo(2); // released once, not twice + verify(eventPublisher, times(1)).publishReservationExpired(any()); + } + + @Test + void latePayment_afterExpiry_commitsFromSellableStock() { + sweeper.sweep(); + + inventoryService.commitReservation(PaymentSuccessfulEvent.builder() + .eventId("ps-late").orderId(EXPIRED_ORDER).userId(7L) + .items(List.of(OrderItemSnapshot.builder().productId(PRODUCT).quantity(3).build())) + .build()); + + assertThat(statusOf(EXPIRED_ORDER)).isEqualTo(ReservationStatus.COMMITTED); + assertThat(inventory().getAvailableQuantity()).isEqualTo(7); + assertThat(inventory().getReservedQuantity()).isEqualTo(2); // order 44's hold intact + } + + @Test + void paymentFailed_afterExpiry_doesNotReleaseTwice() { + sweeper.sweep(); + + inventoryService.releaseReservation(PaymentFailedEvent.builder() + .eventId("pf-late").orderId(EXPIRED_ORDER).userId(7L) + .items(List.of(OrderItemSnapshot.builder().productId(PRODUCT).quantity(3).build())) + .build()); + + assertThat(statusOf(EXPIRED_ORDER)).isEqualTo(ReservationStatus.EXPIRED); + assertThat(inventory().getReservedQuantity()).isEqualTo(2); // not 0 — order 44 still holds 2 + } +} diff --git a/shopsphere/notification-service/pom.xml b/shopsphere/notification-service/pom.xml index 83981ae..2dea8cc 100644 --- a/shopsphere/notification-service/pom.xml +++ b/shopsphere/notification-service/pom.xml @@ -35,6 +35,15 @@ postgresql runtime + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + org.projectlombok lombok diff --git a/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/config/KafkaConsumerConfig.java b/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/config/KafkaConsumerConfig.java index 10e2ddc..f3b4d2c 100644 --- a/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/config/KafkaConsumerConfig.java +++ b/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/config/KafkaConsumerConfig.java @@ -1,9 +1,12 @@ package com.shopsphere.notificationservice.config; import com.shopsphere.common.events.LowStockEvent; +import com.shopsphere.common.events.OrderCancelledEvent; import com.shopsphere.common.events.OrderCreatedEvent; import com.shopsphere.common.events.PaymentFailedEvent; import com.shopsphere.common.events.PaymentSuccessfulEvent; +import com.shopsphere.common.events.ShipmentDeliveredEvent; +import com.shopsphere.common.events.ShipmentDispatchedEvent; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.common.serialization.StringDeserializer; import org.springframework.beans.factory.annotation.Value; @@ -41,43 +44,53 @@ private Map baseProps(Class targetType) { return props; } + private ConcurrentKafkaListenerContainerFactory factory(Class type, int concurrency) { + var factory = new ConcurrentKafkaListenerContainerFactory(); + factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(baseProps(type))); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + factory.setConcurrency(concurrency); + return factory; + } + @Bean public ConcurrentKafkaListenerContainerFactory orderCreatedListenerContainerFactory() { - var factory = new ConcurrentKafkaListenerContainerFactory(); - factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(baseProps(OrderCreatedEvent.class))); - factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); - factory.setConcurrency(3); - return factory; + return factory(OrderCreatedEvent.class, 3); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory + orderCancelledListenerContainerFactory() { + return factory(OrderCancelledEvent.class, 3); } @Bean public ConcurrentKafkaListenerContainerFactory paymentSuccessfulListenerContainerFactory() { - var factory = new ConcurrentKafkaListenerContainerFactory(); - factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(baseProps(PaymentSuccessfulEvent.class))); - factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); - factory.setConcurrency(3); - return factory; + return factory(PaymentSuccessfulEvent.class, 3); } @Bean public ConcurrentKafkaListenerContainerFactory paymentFailedListenerContainerFactory() { - var factory = new ConcurrentKafkaListenerContainerFactory(); - factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(baseProps(PaymentFailedEvent.class))); - factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); - factory.setConcurrency(3); - return factory; + return factory(PaymentFailedEvent.class, 3); } @Bean public ConcurrentKafkaListenerContainerFactory lowStockListenerContainerFactory() { - var factory = new ConcurrentKafkaListenerContainerFactory(); - factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(baseProps(LowStockEvent.class))); - factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); - factory.setConcurrency(1); - return factory; + return factory(LowStockEvent.class, 1); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory + shipmentDispatchedListenerContainerFactory() { + return factory(ShipmentDispatchedEvent.class, 3); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory + shipmentDeliveredListenerContainerFactory() { + return factory(ShipmentDeliveredEvent.class, 3); } } diff --git a/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/entity/NotificationType.java b/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/entity/NotificationType.java index 83c9577..148b836 100644 --- a/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/entity/NotificationType.java +++ b/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/entity/NotificationType.java @@ -1,8 +1,12 @@ package com.shopsphere.notificationservice.entity; +/** Adding a value? Add a Flyway migration widening notifications_type_check too. */ public enum NotificationType { ORDER_PLACED, + ORDER_CANCELLED, PAYMENT_SUCCESSFUL, PAYMENT_FAILED, - LOW_STOCK_ALERT + LOW_STOCK_ALERT, + SHIPMENT_DISPATCHED, + SHIPMENT_DELIVERED } diff --git a/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/messaging/NotificationEventConsumer.java b/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/messaging/NotificationEventConsumer.java index 9972792..f728a49 100644 --- a/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/messaging/NotificationEventConsumer.java +++ b/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/messaging/NotificationEventConsumer.java @@ -4,6 +4,8 @@ import com.shopsphere.common.events.OrderCreatedEvent; import com.shopsphere.common.events.PaymentFailedEvent; import com.shopsphere.common.events.PaymentSuccessfulEvent; +import com.shopsphere.common.events.ShipmentDeliveredEvent; +import com.shopsphere.common.events.ShipmentDispatchedEvent; import com.shopsphere.common.events.Topics; import com.shopsphere.notificationservice.service.NotificationService; import lombok.RequiredArgsConstructor; @@ -111,4 +113,50 @@ public void onLowStock( throw ex; } } + + /* ---------------- US36 — shipment notifications ---------------- */ + + @KafkaListener( + topics = Topics.SHIPMENT_DISPATCHED, + groupId = "notification-service", + containerFactory = "shipmentDispatchedListenerContainerFactory" + ) + public void onShipmentDispatched( + @Payload ShipmentDispatchedEvent event, + @Header(KafkaHeaders.OFFSET) long offset, + Acknowledgment ack) { + + log.info("Notification received shipment.dispatched eventId={} orderId={} offset={}", + event.getEventId(), event.getOrderId(), offset); + try { + notificationService.handleShipmentDispatched(event); + ack.acknowledge(); + } catch (Exception ex) { + log.error("Failed to handle shipment.dispatched eventId={}: {}", + event.getEventId(), ex.getMessage(), ex); + throw ex; + } + } + + @KafkaListener( + topics = Topics.SHIPMENT_DELIVERED, + groupId = "notification-service", + containerFactory = "shipmentDeliveredListenerContainerFactory" + ) + public void onShipmentDelivered( + @Payload ShipmentDeliveredEvent event, + @Header(KafkaHeaders.OFFSET) long offset, + Acknowledgment ack) { + + log.info("Notification received shipment.delivered eventId={} orderId={} offset={}", + event.getEventId(), event.getOrderId(), offset); + try { + notificationService.handleShipmentDelivered(event); + ack.acknowledge(); + } catch (Exception ex) { + log.error("Failed to handle shipment.delivered eventId={}: {}", + event.getEventId(), ex.getMessage(), ex); + throw ex; + } + } } diff --git a/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/messaging/OrderCancelledConsumer.java b/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/messaging/OrderCancelledConsumer.java new file mode 100644 index 0000000..00e156a --- /dev/null +++ b/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/messaging/OrderCancelledConsumer.java @@ -0,0 +1,44 @@ +package com.shopsphere.notificationservice.messaging; + +import com.shopsphere.common.events.OrderCancelledEvent; +import com.shopsphere.common.events.Topics; +import com.shopsphere.notificationservice.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; + +/** US38 — tells the customer their order was cancelled. */ +@Component +@RequiredArgsConstructor +@Slf4j +public class OrderCancelledConsumer { + + private final NotificationService notificationService; + + @KafkaListener( + topics = Topics.ORDER_CANCELLED, + groupId = "notification-service", + containerFactory = "orderCancelledListenerContainerFactory" + ) + public void onOrderCancelled( + @Payload OrderCancelledEvent event, + @Header(KafkaHeaders.OFFSET) long offset, + Acknowledgment ack) { + + log.info("Notification received order.cancelled eventId={} orderId={} reason={} offset={}", + event.getEventId(), event.getOrderId(), event.getReason(), offset); + try { + notificationService.handleOrderCancelled(event); + ack.acknowledge(); + } catch (Exception ex) { + log.error("Failed to handle order.cancelled eventId={}: {}", + event.getEventId(), ex.getMessage(), ex); + throw ex; + } + } +} diff --git a/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/service/NotificationService.java b/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/service/NotificationService.java index ccd2cac..b7bd1c6 100644 --- a/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/service/NotificationService.java +++ b/shopsphere/notification-service/src/main/java/com/shopsphere/notificationservice/service/NotificationService.java @@ -1,9 +1,12 @@ package com.shopsphere.notificationservice.service; import com.shopsphere.common.events.LowStockEvent; +import com.shopsphere.common.events.OrderCancelledEvent; import com.shopsphere.common.events.OrderCreatedEvent; import com.shopsphere.common.events.PaymentFailedEvent; import com.shopsphere.common.events.PaymentSuccessfulEvent; +import com.shopsphere.common.events.ShipmentDeliveredEvent; +import com.shopsphere.common.events.ShipmentDispatchedEvent; import com.shopsphere.notificationservice.dto.NotificationResponse; import com.shopsphere.notificationservice.entity.Notification; import com.shopsphere.notificationservice.entity.NotificationType; @@ -15,11 +18,18 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + @Service @RequiredArgsConstructor @Slf4j public class NotificationService { + private static final DateTimeFormatter WHEN = + DateTimeFormatter.ofPattern("MMM d, yyyy 'at' HH:mm 'UTC'").withZone(ZoneOffset.UTC); + private final NotificationRepository notificationRepository; /* ---------------------------------------------------------------- */ @@ -43,6 +53,28 @@ public void handleOrderCreated(OrderCreatedEvent event) { event.getOrderId(), subject, body, event.getEventId()); } + /** US38 */ + @Transactional + public void handleOrderCancelled(OrderCancelledEvent event) { + if (notificationRepository.existsBySourceEventId(event.getEventId())) { + log.info("Skipping order.cancelled eventId={} — already processed", event.getEventId()); + return; + } + + String why = event.getReasonDescription() != null + ? event.getReasonDescription() + : "It could not be completed."; + + String subject = "Order #" + event.getOrderId() + " has been cancelled"; + String body = String.format( + "We're sorry — your order #%d (total $%s) was cancelled %s. %s " + + "You're welcome to place the order again.", + event.getOrderId(), event.getTotalAmount(), format(event.getCancelledAt()), why); + + persistAndSimulateSend(NotificationType.ORDER_CANCELLED, event.getUserId(), + event.getOrderId(), subject, body, event.getEventId()); + } + @Transactional public void handlePaymentSuccessful(PaymentSuccessfulEvent event) { if (notificationRepository.existsBySourceEventId(event.getEventId())) { @@ -97,6 +129,46 @@ public void handleLowStock(LowStockEvent event) { null, subject, body, event.getEventId()); } + /* ---------------- US36 — shipment notifications ---------------- */ + + @Transactional + public void handleShipmentDispatched(ShipmentDispatchedEvent event) { + if (notificationRepository.existsBySourceEventId(event.getEventId())) { + log.info("Skipping shipment.dispatched eventId={} — already processed", event.getEventId()); + return; + } + + String destination = event.getShippingAddress() == null + ? "your shipping address" + : event.getShippingAddress().getCity() + ", " + event.getShippingAddress().getCountry(); + + String subject = "Order #" + event.getOrderId() + " has shipped"; + String body = String.format( + "Good news! Your order #%d is on its way to %s with %s. " + + "Tracking number: %s. Shipped %s.", + event.getOrderId(), destination, event.getCarrier(), + event.getTrackingNumber(), format(event.getShippedAt())); + + persistAndSimulateSend(NotificationType.SHIPMENT_DISPATCHED, event.getUserId(), + event.getOrderId(), subject, body, event.getEventId()); + } + + @Transactional + public void handleShipmentDelivered(ShipmentDeliveredEvent event) { + if (notificationRepository.existsBySourceEventId(event.getEventId())) { + log.info("Skipping shipment.delivered eventId={} — already processed", event.getEventId()); + return; + } + + String subject = "Order #" + event.getOrderId() + " has been delivered"; + String body = String.format( + "Your order #%d was delivered %s (tracking number %s). We hope you enjoy it!", + event.getOrderId(), format(event.getDeliveredAt()), event.getTrackingNumber()); + + persistAndSimulateSend(NotificationType.SHIPMENT_DELIVERED, event.getUserId(), + event.getOrderId(), subject, body, event.getEventId()); + } + /* ---------------------------------------------------------------- */ /* Reads */ /* ---------------------------------------------------------------- */ @@ -117,6 +189,10 @@ public long countNotifications() { /* Helpers */ /* ---------------------------------------------------------------- */ + private static String format(Instant instant) { + return instant == null ? "just now" : "on " + WHEN.format(instant); + } + private void persistAndSimulateSend(NotificationType type, Long userId, Long orderId, String subject, String body, String sourceEventId) { Notification notification = Notification.builder() @@ -128,7 +204,7 @@ private void persistAndSimulateSend(NotificationType type, Long userId, Long ord .sourceEventId(sourceEventId) .deliveryStatus("SIMULATED") .build(); - Notification saved = notificationRepository.save(notification); + notificationRepository.save(notification); // The "send" — in a real system, this would call SendGrid / SES / Twilio. // Simulating with a structured log line that's grep-able for audits/demos. diff --git a/shopsphere/notification-service/src/main/resources/application.yml b/shopsphere/notification-service/src/main/resources/application.yml index cb27c30..ba4329e 100644 --- a/shopsphere/notification-service/src/main/resources/application.yml +++ b/shopsphere/notification-service/src/main/resources/application.yml @@ -15,9 +15,19 @@ spring: hikari: connection-init-sql: CREATE SCHEMA IF NOT EXISTS shopsphere_notifications + # US36 — Flyway owns the schema. Existing (Hibernate-created) DBs are baselined at V1. + flyway: + enabled: true + schemas: shopsphere_notifications + default-schema: shopsphere_notifications + locations: classpath:db/migration + baseline-on-migrate: true + baseline-version: 1 + baseline-description: Pre-Flyway Hibernate-managed schema + jpa: hibernate: - ddl-auto: update + ddl-auto: validate show-sql: true properties: hibernate: @@ -48,6 +58,7 @@ logging: level: com.shopsphere.notificationservice: DEBUG org.hibernate.SQL: DEBUG + org.flywaydb: INFO eureka: client: diff --git a/shopsphere/notification-service/src/main/resources/db/migration/V1__baseline_notifications_schema.sql b/shopsphere/notification-service/src/main/resources/db/migration/V1__baseline_notifications_schema.sql new file mode 100644 index 0000000..60c2b22 --- /dev/null +++ b/shopsphere/notification-service/src/main/resources/db/migration/V1__baseline_notifications_schema.sql @@ -0,0 +1,19 @@ +-- V1: Baseline — the notifications table as Hibernate generated it before Flyway. +-- Existing databases are baselined at V1 and skip this file; fresh databases run it. + +CREATE TABLE IF NOT EXISTS notifications ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + type VARCHAR(30) NOT NULL + CHECK (type IN ('ORDER_PLACED', 'PAYMENT_SUCCESSFUL', 'PAYMENT_FAILED', 'LOW_STOCK_ALERT')), + user_id BIGINT, + subject VARCHAR(200) NOT NULL, + body VARCHAR(2000) NOT NULL, + order_id BIGINT, + source_event_id VARCHAR(36) NOT NULL, + delivery_status VARCHAR(20) NOT NULL, + created_at TIMESTAMP(6) WITH TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_type ON notifications (type); +CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications (created_at); diff --git a/shopsphere/notification-service/src/main/resources/db/migration/V2__allow_shipment_notification_types.sql b/shopsphere/notification-service/src/main/resources/db/migration/V2__allow_shipment_notification_types.sql new file mode 100644 index 0000000..32c32c7 --- /dev/null +++ b/shopsphere/notification-service/src/main/resources/db/migration/V2__allow_shipment_notification_types.sql @@ -0,0 +1,14 @@ +-- V2 (US36): allow SHIPMENT_DISPATCHED / SHIPMENT_DELIVERED. +-- Hibernate's inline enum CHECK is auto-named notifications_type_check by Postgres, +-- and ddl-auto=update never widens it — hence this migration. + +ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_type_check; + +ALTER TABLE notifications ADD CONSTRAINT notifications_type_check CHECK (type IN ( + 'ORDER_PLACED', + 'PAYMENT_SUCCESSFUL', + 'PAYMENT_FAILED', + 'LOW_STOCK_ALERT', + 'SHIPMENT_DISPATCHED', + 'SHIPMENT_DELIVERED' +)); diff --git a/shopsphere/notification-service/src/main/resources/db/migration/V3__allow_order_cancelled_notification_type.sql b/shopsphere/notification-service/src/main/resources/db/migration/V3__allow_order_cancelled_notification_type.sql new file mode 100644 index 0000000..c10c39b --- /dev/null +++ b/shopsphere/notification-service/src/main/resources/db/migration/V3__allow_order_cancelled_notification_type.sql @@ -0,0 +1,13 @@ +-- V3 (US38): allow ORDER_CANCELLED. + +ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_type_check; + +ALTER TABLE notifications ADD CONSTRAINT notifications_type_check CHECK (type IN ( + 'ORDER_PLACED', + 'ORDER_CANCELLED', + 'PAYMENT_SUCCESSFUL', + 'PAYMENT_FAILED', + 'LOW_STOCK_ALERT', + 'SHIPMENT_DISPATCHED', + 'SHIPMENT_DELIVERED' +)); diff --git a/shopsphere/notification-service/src/test/java/com/shopsphere/notificationservice/service/NotificationServiceTest.java b/shopsphere/notification-service/src/test/java/com/shopsphere/notificationservice/service/NotificationServiceTest.java index cc36170..77988ed 100644 --- a/shopsphere/notification-service/src/test/java/com/shopsphere/notificationservice/service/NotificationServiceTest.java +++ b/shopsphere/notification-service/src/test/java/com/shopsphere/notificationservice/service/NotificationServiceTest.java @@ -1,6 +1,8 @@ package com.shopsphere.notificationservice.service; import com.shopsphere.common.events.OrderCreatedEvent; +import com.shopsphere.common.events.ShipmentDeliveredEvent; +import com.shopsphere.common.events.ShipmentDispatchedEvent; import com.shopsphere.notificationservice.entity.Notification; import com.shopsphere.notificationservice.entity.NotificationType; import com.shopsphere.notificationservice.repository.NotificationRepository; @@ -64,4 +66,66 @@ void handleOrderCreated_persistsNotificationOnce() { assertThat(saved.getSourceEventId()).isEqualTo("evt-2"); assertThat(saved.getSubject()).contains("#7"); } + + @Test + void handleShipmentDispatched_notifiesCustomerWithTracking() { + when(notificationRepository.existsBySourceEventId("ship-1")).thenReturn(false); + when(notificationRepository.save(any(Notification.class))).thenAnswer(i -> i.getArgument(0)); + + ShipmentDispatchedEvent event = ShipmentDispatchedEvent.builder() + .eventId("ship-1").eventType(ShipmentDispatchedEvent.TYPE) + .shipmentId(5L).orderId(42L).userId(7L) + .carrier("ShopSphere Express").trackingNumber("SSX2609264K7QZ9M2PA") + .shippedAt(Instant.parse("2026-09-26T12:00:00Z")) + .shippingAddress(OrderCreatedEvent.ShippingAddress.builder() + .city("Toronto").country("CA").build()) + .build(); + + notificationService.handleShipmentDispatched(event); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + verify(notificationRepository).save(captor.capture()); + Notification saved = captor.getValue(); + assertThat(saved.getType()).isEqualTo(NotificationType.SHIPMENT_DISPATCHED); + assertThat(saved.getUserId()).isEqualTo(7L); + assertThat(saved.getOrderId()).isEqualTo(42L); + assertThat(saved.getSourceEventId()).isEqualTo("ship-1"); + assertThat(saved.getSubject()).isEqualTo("Order #42 has shipped"); + assertThat(saved.getBody()) + .contains("SSX2609264K7QZ9M2PA") + .contains("ShopSphere Express") + .contains("Toronto, CA") + .contains("Sep 26, 2026"); + } + + @Test + void handleShipmentDelivered_notifiesCustomer() { + when(notificationRepository.existsBySourceEventId("del-1")).thenReturn(false); + when(notificationRepository.save(any(Notification.class))).thenAnswer(i -> i.getArgument(0)); + + ShipmentDeliveredEvent event = ShipmentDeliveredEvent.builder() + .eventId("del-1").eventType(ShipmentDeliveredEvent.TYPE) + .shipmentId(5L).orderId(42L).userId(7L) + .trackingNumber("SSX2609264K7QZ9M2PA") + .deliveredAt(Instant.parse("2026-09-28T15:30:00Z")) + .build(); + + notificationService.handleShipmentDelivered(event); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + verify(notificationRepository).save(captor.capture()); + assertThat(captor.getValue().getType()).isEqualTo(NotificationType.SHIPMENT_DELIVERED); + assertThat(captor.getValue().getSubject()).isEqualTo("Order #42 has been delivered"); + assertThat(captor.getValue().getBody()).contains("Sep 28, 2026").contains("SSX2609264K7QZ9M2PA"); + } + + @Test + void handleShipmentDelivered_duplicate_isSkipped() { + when(notificationRepository.existsBySourceEventId("del-1")).thenReturn(true); + + notificationService.handleShipmentDelivered(ShipmentDeliveredEvent.builder() + .eventId("del-1").orderId(42L).userId(7L).build()); + + verify(notificationRepository, never()).save(any()); + } } diff --git a/shopsphere/notification-service/src/test/java/com/shopsphere/notificationservice/service/OrderCancelledNotificationTest.java b/shopsphere/notification-service/src/test/java/com/shopsphere/notificationservice/service/OrderCancelledNotificationTest.java new file mode 100644 index 0000000..6afbeae --- /dev/null +++ b/shopsphere/notification-service/src/test/java/com/shopsphere/notificationservice/service/OrderCancelledNotificationTest.java @@ -0,0 +1,70 @@ +package com.shopsphere.notificationservice.service; + +import com.shopsphere.common.events.OrderCancelledEvent; +import com.shopsphere.notificationservice.entity.Notification; +import com.shopsphere.notificationservice.entity.NotificationType; +import com.shopsphere.notificationservice.repository.NotificationRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderCancelledNotificationTest { + + @Mock NotificationRepository notificationRepository; + + @InjectMocks NotificationService notificationService; + + private static OrderCancelledEvent cancelled(String eventId) { + return OrderCancelledEvent.builder() + .eventId(eventId).eventType(OrderCancelledEvent.TYPE) + .orderId(42L).userId(7L) + .reason("RESERVATION_EXPIRED") + .reasonDescription("Payment wasn't confirmed in time, so the stock we were holding for this order was released.") + .cancelledAt(Instant.parse("2026-09-26T22:15:00Z")) + .totalAmount(new BigDecimal("100.00")) + .build(); + } + + @Test + void orderCancelled_notifiesCustomerWithReason() { + when(notificationRepository.existsBySourceEventId("oc-1")).thenReturn(false); + when(notificationRepository.save(any(Notification.class))).thenAnswer(i -> i.getArgument(0)); + + notificationService.handleOrderCancelled(cancelled("oc-1")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + verify(notificationRepository).save(captor.capture()); + Notification saved = captor.getValue(); + assertThat(saved.getType()).isEqualTo(NotificationType.ORDER_CANCELLED); + assertThat(saved.getUserId()).isEqualTo(7L); + assertThat(saved.getOrderId()).isEqualTo(42L); + assertThat(saved.getSourceEventId()).isEqualTo("oc-1"); + assertThat(saved.getSubject()).isEqualTo("Order #42 has been cancelled"); + assertThat(saved.getBody()) + .contains("Payment wasn't confirmed in time") + .contains("Sep 26, 2026") + .contains("$100.00"); + } + + @Test + void orderCancelled_duplicate_isSkipped() { + when(notificationRepository.existsBySourceEventId("oc-1")).thenReturn(true); + + notificationService.handleOrderCancelled(cancelled("oc-1")); + + verify(notificationRepository, never()).save(any()); + } +} diff --git a/shopsphere/order-service/pom.xml b/shopsphere/order-service/pom.xml index c797a99..9acfefe 100644 --- a/shopsphere/order-service/pom.xml +++ b/shopsphere/order-service/pom.xml @@ -66,6 +66,33 @@ org.springframework.cloud spring-cloud-starter-config + + + + org.springframework.kafka + spring-kafka-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + postgresql + test + + + org.testcontainers + junit-jupiter + test + + + org.awaitility + awaitility + test + diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/config/KafkaConsumerConfig.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/config/KafkaConsumerConfig.java new file mode 100644 index 0000000..9b76c12 --- /dev/null +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/config/KafkaConsumerConfig.java @@ -0,0 +1,69 @@ +package com.shopsphere.orderservice.config; + +import com.shopsphere.common.events.ReservationExpiredEvent; +import com.shopsphere.common.events.ShipmentDeliveredEvent; +import com.shopsphere.common.events.ShipmentDispatchedEvent; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer; +import org.springframework.kafka.support.serializer.JsonDeserializer; + +import java.util.HashMap; +import java.util.Map; + +/** order-service consumers: shipment events (US36) and reservation expiry (US38). */ +@Configuration +@EnableKafka +public class KafkaConsumerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + private Map baseProps(Class targetType) { + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-service"); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class); + props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class); + props.put(JsonDeserializer.TRUSTED_PACKAGES, "com.shopsphere.common.events"); + props.put(JsonDeserializer.VALUE_DEFAULT_TYPE, targetType.getName()); + props.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); + return props; + } + + private ConcurrentKafkaListenerContainerFactory factory(Class type) { + var factory = new ConcurrentKafkaListenerContainerFactory(); + factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(baseProps(type))); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + factory.setConcurrency(3); + return factory; + } + + @Bean + public ConcurrentKafkaListenerContainerFactory + shipmentDispatchedListenerContainerFactory() { + return factory(ShipmentDispatchedEvent.class); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory + shipmentDeliveredListenerContainerFactory() { + return factory(ShipmentDeliveredEvent.class); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory + reservationExpiredListenerContainerFactory() { + return factory(ReservationExpiredEvent.class); + } +} diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/config/KafkaTopicConfig.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/config/KafkaTopicConfig.java index 3bb49b0..757910f 100644 --- a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/config/KafkaTopicConfig.java +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/config/KafkaTopicConfig.java @@ -10,32 +10,33 @@ * Topics are declared programmatically here so that order-service ensures they exist on boot. * In production this would be done via infrastructure (Terraform, etc), but for the project * Spring's KafkaAdmin will create any missing topics at startup. + * Everything order-scoped is keyed by orderId, so per-order events stay ordered. */ @Configuration public class KafkaTopicConfig { + private static NewTopic orderScoped(String name) { + return TopicBuilder.name(name).partitions(3).replicas(1).build(); + } + @Bean public NewTopic orderCreatedTopic() { - return TopicBuilder.name(Topics.ORDER_CREATED) - .partitions(3) - .replicas(1) - .build(); + return orderScoped(Topics.ORDER_CREATED); + } + + @Bean + public NewTopic orderCancelledTopic() { + return orderScoped(Topics.ORDER_CANCELLED); } @Bean public NewTopic paymentSuccessfulTopic() { - return TopicBuilder.name(Topics.PAYMENT_SUCCESSFUL) - .partitions(3) - .replicas(1) - .build(); + return orderScoped(Topics.PAYMENT_SUCCESSFUL); } @Bean public NewTopic paymentFailedTopic() { - return TopicBuilder.name(Topics.PAYMENT_FAILED) - .partitions(3) - .replicas(1) - .build(); + return orderScoped(Topics.PAYMENT_FAILED); } @Bean @@ -45,4 +46,19 @@ public NewTopic lowStockTopic() { .replicas(1) .build(); } + + @Bean + public NewTopic reservationExpiredTopic() { + return orderScoped(Topics.RESERVATION_EXPIRED); + } + + @Bean + public NewTopic shipmentDispatchedTopic() { + return orderScoped(Topics.SHIPMENT_DISPATCHED); + } + + @Bean + public NewTopic shipmentDeliveredTopic() { + return orderScoped(Topics.SHIPMENT_DELIVERED); + } } diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/OrderResponse.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/OrderResponse.java index 4a1a983..2149e00 100644 --- a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/OrderResponse.java +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/dto/OrderResponse.java @@ -1,5 +1,6 @@ package com.shopsphere.orderservice.dto; +import com.shopsphere.orderservice.entity.CancellationReason; import com.shopsphere.orderservice.entity.OrderStatus; import lombok.AllArgsConstructor; import lombok.Builder; @@ -22,6 +23,16 @@ public class OrderResponse { private Integer itemCount; /** Null only for orders placed before US33. */ private AddressDto shippingAddress; + /** US36 — null until shipping-service dispatches the order. */ + private Long shipmentId; + private String carrier; + private String trackingNumber; + private Instant shippedAt; + private Instant deliveredAt; + /** US38 — set only when status is CANCELLED. */ + private CancellationReason cancellationReason; + private String cancellationDescription; + private Instant cancelledAt; private List items; private Instant createdAt; private Instant updatedAt; diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/CancellationReason.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/CancellationReason.java new file mode 100644 index 0000000..da7a4e2 --- /dev/null +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/CancellationReason.java @@ -0,0 +1,17 @@ +package com.shopsphere.orderservice.entity; + +/** Why an order was cancelled. Stored as its name; add values freely (no DB CHECK). */ +public enum CancellationReason { + + RESERVATION_EXPIRED("Payment wasn't confirmed in time, so the stock we were holding for this order was released."); + + private final String description; + + CancellationReason(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/Order.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/Order.java index 01f67d6..ad0d5ea 100644 --- a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/Order.java +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/Order.java @@ -63,6 +63,32 @@ public class Order { }) private Address shippingAddress; + /* ---------- US36: shipment tracking, filled from shipping-service events ---------- */ + + @Column(name = "shipment_id") + private Long shipmentId; + + @Column(name = "carrier", length = 100) + private String carrier; + + @Column(name = "tracking_number", length = 100) + private String trackingNumber; + + @Column(name = "shipped_at") + private Instant shippedAt; + + @Column(name = "delivered_at") + private Instant deliveredAt; + + /* ---------- US38: cancellation ---------- */ + + @Enumerated(EnumType.STRING) + @Column(name = "cancellation_reason", length = 40) + private CancellationReason cancellationReason; + + @Column(name = "cancelled_at") + private Instant cancelledAt; + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private List items = new ArrayList<>(); @@ -80,4 +106,14 @@ public void addItem(OrderItem item) { items.add(item); item.setOrder(this); } + + /** US38 — moves the order to CANCELLED, recording why and when. */ + public void cancel(CancellationReason reason, Instant at) { + if (!status.canTransitionTo(OrderStatus.CANCELLED)) { + throw new IllegalStateException("Order " + id + " cannot be cancelled from status " + status); + } + this.status = OrderStatus.CANCELLED; + this.cancellationReason = reason; + this.cancelledAt = at; + } } diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/OrderStatus.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/OrderStatus.java index 8fd548a..bc485ab 100644 --- a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/OrderStatus.java +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/OrderStatus.java @@ -12,5 +12,24 @@ public enum OrderStatus { /** Order completed. */ DELIVERED, /** Cancelled by customer or system. */ - CANCELLED + CANCELLED; + + /** + * Forward-only state machine. + * + *

PENDING_PAYMENT may jump straight to SHIPPED/DELIVERED because order-service does + * not consume payment events yet — a shipment only exists after payment.successful, so + * a shipment event is itself proof of payment. Terminal states never move.

+ */ + public boolean canTransitionTo(OrderStatus target) { + if (target == null || target == this) { + return false; + } + return switch (this) { + case PENDING_PAYMENT -> target != PENDING_PAYMENT; + case PAID -> target == SHIPPED || target == DELIVERED || target == CANCELLED; + case SHIPPED -> target == DELIVERED; + case PAYMENT_FAILED, DELIVERED, CANCELLED -> false; + }; + } } diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/ProcessedEvent.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/ProcessedEvent.java new file mode 100644 index 0000000..a82a93d --- /dev/null +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/entity/ProcessedEvent.java @@ -0,0 +1,35 @@ +package com.shopsphere.orderservice.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +/** Idempotency ledger for consumed Kafka events (same pattern as inventory/shipping). */ +@Entity +@Table(name = "processed_events") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ProcessedEvent { + + @Id + @Column(name = "event_id", length = 100) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 100) + private String eventType; + + @Column(name = "processed_at", nullable = false) + private Instant processedAt; + + public static ProcessedEvent of(String eventId, String eventType) { + return new ProcessedEvent(eventId, eventType, Instant.now()); + } +} diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/messaging/OrderEventPublisher.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/messaging/OrderEventPublisher.java index 5301c00..a053a11 100644 --- a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/messaging/OrderEventPublisher.java +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/messaging/OrderEventPublisher.java @@ -1,5 +1,6 @@ package com.shopsphere.orderservice.messaging; +import com.shopsphere.common.events.OrderCancelledEvent; import com.shopsphere.common.events.OrderCreatedEvent; import com.shopsphere.common.events.Topics; import lombok.RequiredArgsConstructor; @@ -28,4 +29,19 @@ public void publishOrderCreated(OrderCreatedEvent event) { } }); } + + /** US38 */ + public void publishOrderCancelled(OrderCancelledEvent event) { + kafkaTemplate.send(Topics.ORDER_CANCELLED, String.valueOf(event.getOrderId()), event) + .whenComplete((result, ex) -> { + if (ex != null) { + log.error("Failed to publish OrderCancelledEvent for order {}: {}", + event.getOrderId(), ex.getMessage()); + } else { + log.info("Published OrderCancelledEvent for order {} reason={} to partition {}", + event.getOrderId(), event.getReason(), + result.getRecordMetadata().partition()); + } + }); + } } diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/messaging/ReservationExpiredConsumer.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/messaging/ReservationExpiredConsumer.java new file mode 100644 index 0000000..27e5692 --- /dev/null +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/messaging/ReservationExpiredConsumer.java @@ -0,0 +1,43 @@ +package com.shopsphere.orderservice.messaging; + +import com.shopsphere.common.events.ReservationExpiredEvent; +import com.shopsphere.common.events.Topics; +import com.shopsphere.orderservice.service.OrderCancellationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ReservationExpiredConsumer { + + private final OrderCancellationService orderCancellationService; + + @KafkaListener( + topics = Topics.RESERVATION_EXPIRED, + groupId = "order-service", + containerFactory = "reservationExpiredListenerContainerFactory" + ) + public void onReservationExpired( + @Payload ReservationExpiredEvent event, + @Header(KafkaHeaders.OFFSET) long offset, + Acknowledgment ack) { + + log.info("Order received inventory.reservation-expired eventId={} orderId={} offset={}", + event.getEventId(), event.getOrderId(), offset); + try { + orderCancellationService.onReservationExpired(event); + ack.acknowledge(); + } catch (Exception ex) { + log.error("Failed to handle inventory.reservation-expired eventId={}: {}", + event.getEventId(), ex.getMessage(), ex); + throw ex; + } + } +} diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/messaging/ShipmentEventConsumer.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/messaging/ShipmentEventConsumer.java new file mode 100644 index 0000000..7f59b68 --- /dev/null +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/messaging/ShipmentEventConsumer.java @@ -0,0 +1,66 @@ +package com.shopsphere.orderservice.messaging; + +import com.shopsphere.common.events.ShipmentDeliveredEvent; +import com.shopsphere.common.events.ShipmentDispatchedEvent; +import com.shopsphere.common.events.Topics; +import com.shopsphere.orderservice.service.OrderShipmentService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ShipmentEventConsumer { + + private final OrderShipmentService orderShipmentService; + + @KafkaListener( + topics = Topics.SHIPMENT_DISPATCHED, + groupId = "order-service", + containerFactory = "shipmentDispatchedListenerContainerFactory" + ) + public void onShipmentDispatched( + @Payload ShipmentDispatchedEvent event, + @Header(KafkaHeaders.OFFSET) long offset, + Acknowledgment ack) { + + log.info("Order received shipment.dispatched eventId={} orderId={} tracking={} offset={}", + event.getEventId(), event.getOrderId(), event.getTrackingNumber(), offset); + try { + orderShipmentService.onShipmentDispatched(event); + ack.acknowledge(); + } catch (Exception ex) { + log.error("Failed to handle shipment.dispatched eventId={}: {}", + event.getEventId(), ex.getMessage(), ex); + throw ex; + } + } + + @KafkaListener( + topics = Topics.SHIPMENT_DELIVERED, + groupId = "order-service", + containerFactory = "shipmentDeliveredListenerContainerFactory" + ) + public void onShipmentDelivered( + @Payload ShipmentDeliveredEvent event, + @Header(KafkaHeaders.OFFSET) long offset, + Acknowledgment ack) { + + log.info("Order received shipment.delivered eventId={} orderId={} offset={}", + event.getEventId(), event.getOrderId(), offset); + try { + orderShipmentService.onShipmentDelivered(event); + ack.acknowledge(); + } catch (Exception ex) { + log.error("Failed to handle shipment.delivered eventId={}: {}", + event.getEventId(), ex.getMessage(), ex); + throw ex; + } + } +} diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/repository/ProcessedEventRepository.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/repository/ProcessedEventRepository.java new file mode 100644 index 0000000..c26c780 --- /dev/null +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/repository/ProcessedEventRepository.java @@ -0,0 +1,9 @@ +package com.shopsphere.orderservice.repository; + +import com.shopsphere.orderservice.entity.ProcessedEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProcessedEventRepository extends JpaRepository { +} diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderCancellationService.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderCancellationService.java new file mode 100644 index 0000000..4b07af3 --- /dev/null +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderCancellationService.java @@ -0,0 +1,106 @@ +package com.shopsphere.orderservice.service; + +import com.shopsphere.common.events.OrderCancelledEvent; +import com.shopsphere.common.events.OrderItemSnapshot; +import com.shopsphere.common.events.ReservationExpiredEvent; +import com.shopsphere.orderservice.entity.CancellationReason; +import com.shopsphere.orderservice.entity.Order; +import com.shopsphere.orderservice.entity.OrderStatus; +import com.shopsphere.orderservice.entity.ProcessedEvent; +import com.shopsphere.orderservice.messaging.OrderEventPublisher; +import com.shopsphere.orderservice.repository.OrderRepository; +import com.shopsphere.orderservice.repository.ProcessedEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.Instant; +import java.util.UUID; + +/** + * US38 — compensation for an order whose stock hold expired before payment settled: + * PENDING_PAYMENT → CANCELLED (reason RESERVATION_EXPIRED), then order.cancelled. + * + *

Only unpaid orders are cancelled. If the order has already moved on (e.g. a late + * payment was committed and the order shipped), the expiry is recorded and ignored.

+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class OrderCancellationService { + + private final OrderRepository orderRepository; + private final ProcessedEventRepository processedEventRepository; + private final ApplicationEventPublisher applicationEventPublisher; + private final OrderEventPublisher orderEventPublisher; + + @Transactional + public void onReservationExpired(ReservationExpiredEvent event) { + String eventId = event.getEventId(); + if (eventId == null || eventId.isBlank()) { + log.warn("Ignoring inventory.reservation-expired for order {} without eventId", event.getOrderId()); + return; + } + if (processedEventRepository.existsById(eventId)) { + log.info("Skipping inventory.reservation-expired eventId={} — already processed", eventId); + return; + } + + orderRepository.findById(event.getOrderId()).ifPresentOrElse( + order -> cancelIfUnpaid(order, CancellationReason.RESERVATION_EXPIRED, eventId), + () -> log.warn("inventory.reservation-expired eventId={} references unknown order {}", + eventId, event.getOrderId())); + + processedEventRepository.save(ProcessedEvent.of(eventId, + event.getEventType() != null ? event.getEventType() : ReservationExpiredEvent.TYPE)); + } + + private void cancelIfUnpaid(Order order, CancellationReason reason, String sourceEventId) { + if (order.getStatus() != OrderStatus.PENDING_PAYMENT) { + log.info("Order {} is {} — not cancelling for {} (eventId={})", + order.getId(), order.getStatus(), reason, sourceEventId); + return; + } + + Instant now = Instant.now(); + order.cancel(reason, now); + orderRepository.save(order); + log.info("Order {} CANCELLED — reason={} (eventId={})", order.getId(), reason, sourceEventId); + + // Built inside the transaction (items are lazy); sent only after commit. + applicationEventPublisher.publishEvent(new OrderCancellationCommitted(buildEvent(order, reason, now))); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onCancellationCommitted(OrderCancellationCommitted committed) { + orderEventPublisher.publishOrderCancelled(committed.event()); + } + + /** Internal Spring event wrapper. Package-private so unit tests can inspect the payload. */ + record OrderCancellationCommitted(OrderCancelledEvent event) {} + + private static OrderCancelledEvent buildEvent(Order order, CancellationReason reason, Instant at) { + return OrderCancelledEvent.builder() + .eventId(UUID.randomUUID().toString()) + .eventType(OrderCancelledEvent.TYPE) + .occurredAt(at) + .orderId(order.getId()) + .userId(order.getUserId()) + .reason(reason.name()) + .reasonDescription(reason.getDescription()) + .cancelledAt(at) + .totalAmount(order.getTotalAmount()) + .items(order.getItems().stream() + .map(i -> OrderItemSnapshot.builder() + .productId(i.getProductId()) + .quantity(i.getQuantity()) + .unitPrice(i.getUnitPrice()) + .build()) + .toList()) + .build(); + } +} diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderService.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderService.java index a0e51b6..e49da31 100644 --- a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderService.java +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderService.java @@ -237,6 +237,15 @@ public OrderResponse toResponse(Order order) { .totalAmount(order.getTotalAmount()) .itemCount(order.getItemCount()) .shippingAddress(AddressDto.from(order.getShippingAddress())) + .shipmentId(order.getShipmentId()) + .carrier(order.getCarrier()) + .trackingNumber(order.getTrackingNumber()) + .shippedAt(order.getShippedAt()) + .deliveredAt(order.getDeliveredAt()) + .cancellationReason(order.getCancellationReason()) + .cancellationDescription(order.getCancellationReason() == null + ? null : order.getCancellationReason().getDescription()) + .cancelledAt(order.getCancelledAt()) .createdAt(order.getCreatedAt()) .updatedAt(order.getUpdatedAt()) .items(order.getItems().stream() diff --git a/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderShipmentService.java b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderShipmentService.java new file mode 100644 index 0000000..319874b --- /dev/null +++ b/shopsphere/order-service/src/main/java/com/shopsphere/orderservice/service/OrderShipmentService.java @@ -0,0 +1,112 @@ +package com.shopsphere.orderservice.service; + +import com.shopsphere.common.events.ShipmentDeliveredEvent; +import com.shopsphere.common.events.ShipmentDispatchedEvent; +import com.shopsphere.orderservice.entity.Order; +import com.shopsphere.orderservice.entity.OrderStatus; +import com.shopsphere.orderservice.entity.ProcessedEvent; +import com.shopsphere.orderservice.repository.OrderRepository; +import com.shopsphere.orderservice.repository.ProcessedEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * US36 — applies shipping-service events to orders. + * Idempotent per eventId; transitions follow {@link OrderStatus#canTransitionTo}. + * Shipment details (carrier, tracking, timestamps) are filled in even when the status + * can't move (e.g. a late dispatched event after delivery), but never overwritten. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class OrderShipmentService { + + private final OrderRepository orderRepository; + private final ProcessedEventRepository processedEventRepository; + + @Transactional + public void onShipmentDispatched(ShipmentDispatchedEvent event) { + if (!shouldProcess(event.getEventId(), "shipment.dispatched")) { + return; + } + + orderRepository.findById(event.getOrderId()).ifPresentOrElse(order -> { + fillShipmentDetails(order, event.getShipmentId(), event.getCarrier(), event.getTrackingNumber()); + if (order.getShippedAt() == null) { + order.setShippedAt(event.getShippedAt()); + } + transition(order, OrderStatus.SHIPPED, event.getEventId()); + orderRepository.save(order); + }, () -> log.warn("shipment.dispatched eventId={} references unknown order {}", + event.getEventId(), event.getOrderId())); + + markProcessed(event.getEventId(), event.getEventType(), ShipmentDispatchedEvent.TYPE); + } + + @Transactional + public void onShipmentDelivered(ShipmentDeliveredEvent event) { + if (!shouldProcess(event.getEventId(), "shipment.delivered")) { + return; + } + + orderRepository.findById(event.getOrderId()).ifPresentOrElse(order -> { + fillShipmentDetails(order, event.getShipmentId(), event.getCarrier(), event.getTrackingNumber()); + if (order.getDeliveredAt() == null) { + order.setDeliveredAt(event.getDeliveredAt()); + } + transition(order, OrderStatus.DELIVERED, event.getEventId()); + orderRepository.save(order); + }, () -> log.warn("shipment.delivered eventId={} references unknown order {}", + event.getEventId(), event.getOrderId())); + + markProcessed(event.getEventId(), event.getEventType(), ShipmentDeliveredEvent.TYPE); + } + + /* ---------------------------------------------------------------- */ + + private boolean shouldProcess(String eventId, String topic) { + if (eventId == null || eventId.isBlank()) { + log.warn("Ignoring {} without eventId", topic); + return false; + } + if (processedEventRepository.existsById(eventId)) { + log.info("Skipping {} eventId={} — already processed", topic, eventId); + return false; + } + return true; + } + + private void markProcessed(String eventId, String eventType, String fallbackType) { + processedEventRepository.save(ProcessedEvent.of(eventId, eventType != null ? eventType : fallbackType)); + } + + private static void fillShipmentDetails(Order order, Long shipmentId, String carrier, String trackingNumber) { + if (order.getShipmentId() == null) { + order.setShipmentId(shipmentId); + } + if (order.getCarrier() == null) { + order.setCarrier(carrier); + } + if (order.getTrackingNumber() == null) { + order.setTrackingNumber(trackingNumber); + } + } + + private void transition(Order order, OrderStatus target, String eventId) { + OrderStatus current = order.getStatus(); + if (current == target) { + return; + } + if (!current.canTransitionTo(target)) { + log.warn("Order {} is {} — ignoring transition to {} (eventId={})", + order.getId(), current, target, eventId); + return; + } + order.setStatus(target); + log.info("Order {} {} -> {} (eventId={}, at={})", order.getId(), current, target, eventId, Instant.now()); + } +} diff --git a/shopsphere/order-service/src/main/resources/db/migration/V3__order_shipment_tracking_and_processed_events.sql b/shopsphere/order-service/src/main/resources/db/migration/V3__order_shipment_tracking_and_processed_events.sql new file mode 100644 index 0000000..cffe7c1 --- /dev/null +++ b/shopsphere/order-service/src/main/resources/db/migration/V3__order_shipment_tracking_and_processed_events.sql @@ -0,0 +1,16 @@ +-- V3 (US36): shipment tracking on orders + idempotency ledger for shipment events. + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS shipment_id BIGINT, + ADD COLUMN IF NOT EXISTS carrier VARCHAR(100), + ADD COLUMN IF NOT EXISTS tracking_number VARCHAR(100), + ADD COLUMN IF NOT EXISTS shipped_at TIMESTAMP(6) WITH TIME ZONE, + ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMP(6) WITH TIME ZONE; + +CREATE INDEX IF NOT EXISTS idx_orders_tracking_number ON orders (tracking_number); + +CREATE TABLE IF NOT EXISTS processed_events ( + event_id VARCHAR(100) NOT NULL PRIMARY KEY, + event_type VARCHAR(100) NOT NULL, + processed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL +); diff --git a/shopsphere/order-service/src/main/resources/db/migration/V4__order_cancellation.sql b/shopsphere/order-service/src/main/resources/db/migration/V4__order_cancellation.sql new file mode 100644 index 0000000..fc993b8 --- /dev/null +++ b/shopsphere/order-service/src/main/resources/db/migration/V4__order_cancellation.sql @@ -0,0 +1,5 @@ +-- V4 (US38): record why and when an order was cancelled. + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS cancellation_reason VARCHAR(40), + ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMP(6) WITH TIME ZONE; diff --git a/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/entity/OrderStatusTest.java b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/entity/OrderStatusTest.java new file mode 100644 index 0000000..98a0b58 --- /dev/null +++ b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/entity/OrderStatusTest.java @@ -0,0 +1,39 @@ +package com.shopsphere.orderservice.entity; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderStatusTest { + + @Test + void forwardShippingPath_isAllowed() { + assertThat(OrderStatus.PENDING_PAYMENT.canTransitionTo(OrderStatus.SHIPPED)).isTrue(); + assertThat(OrderStatus.PAID.canTransitionTo(OrderStatus.SHIPPED)).isTrue(); + assertThat(OrderStatus.SHIPPED.canTransitionTo(OrderStatus.DELIVERED)).isTrue(); + assertThat(OrderStatus.PAID.canTransitionTo(OrderStatus.DELIVERED)).isTrue(); + } + + @Test + void backwardsMoves_areRejected() { + assertThat(OrderStatus.DELIVERED.canTransitionTo(OrderStatus.SHIPPED)).isFalse(); + assertThat(OrderStatus.SHIPPED.canTransitionTo(OrderStatus.PAID)).isFalse(); + assertThat(OrderStatus.SHIPPED.canTransitionTo(OrderStatus.CANCELLED)).isFalse(); + } + + @ParameterizedTest + @EnumSource(value = OrderStatus.class, names = {"DELIVERED", "CANCELLED", "PAYMENT_FAILED"}) + void terminalStates_neverMove(OrderStatus terminal) { + for (OrderStatus target : OrderStatus.values()) { + assertThat(terminal.canTransitionTo(target)).isFalse(); + } + } + + @ParameterizedTest + @EnumSource(OrderStatus.class) + void sameState_isNotATransition(OrderStatus status) { + assertThat(status.canTransitionTo(status)).isFalse(); + } +} diff --git a/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/integration/ReservationExpiryCompensationIntegrationTest.java b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/integration/ReservationExpiryCompensationIntegrationTest.java new file mode 100644 index 0000000..c760483 --- /dev/null +++ b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/integration/ReservationExpiryCompensationIntegrationTest.java @@ -0,0 +1,183 @@ +package com.shopsphere.orderservice.integration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.shopsphere.common.events.OrderItemSnapshot; +import com.shopsphere.common.events.ReservationExpiredEvent; +import com.shopsphere.common.events.Topics; +import com.shopsphere.orderservice.entity.CancellationReason; +import com.shopsphere.orderservice.entity.Order; +import com.shopsphere.orderservice.entity.OrderItem; +import com.shopsphere.orderservice.entity.OrderStatus; +import com.shopsphere.orderservice.repository.OrderRepository; +import com.shopsphere.orderservice.repository.ProcessedEventRepository; +import org.apache.kafka.clients.consumer.Consumer; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.test.EmbeddedKafkaBroker; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.kafka.test.utils.KafkaTestUtils; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * US38 — compensation path through the real order-service: + * inventory.reservation-expired (Kafka) → listener → Flyway-managed Postgres (CANCELLED + reason) + * → AFTER_COMMIT → order.cancelled (Kafka). + * + * Postgres via Testcontainers (skipped when Docker isn't available), Kafka embedded in-JVM. + */ +@SpringBootTest(properties = { + "spring.cloud.config.enabled=false", + "spring.cloud.discovery.enabled=false", + "eureka.client.enabled=false", + "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}" +}) +@EmbeddedKafka(partitions = 1) +@Testcontainers(disabledWithoutDocker = true) +class ReservationExpiryCompensationIntegrationTest { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine"); + + @Autowired OrderRepository orderRepository; + @Autowired ProcessedEventRepository processedEventRepository; + @Autowired KafkaTemplate kafkaTemplate; + @Autowired EmbeddedKafkaBroker embeddedKafka; + @Autowired ObjectMapper objectMapper; + + private Consumer cancelledConsumer; + private final List cancelledEvents = new ArrayList<>(); + + @BeforeEach + void subscribeToOrderCancelled() { + Map props = KafkaTestUtils.consumerProps("it-" + UUID.randomUUID(), "true", embeddedKafka); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + cancelledConsumer = new DefaultKafkaConsumerFactory(props).createConsumer(); + embeddedKafka.consumeFromAnEmbeddedTopic(cancelledConsumer, Topics.ORDER_CANCELLED); + cancelledEvents.clear(); + } + + @AfterEach + void closeConsumer() { + cancelledConsumer.close(); + } + + @Test + void expiredReservation_cancelsPendingOrder_andPublishesOrderCancelledOnce() throws Exception { + Order order = orderRepository.save(order(OrderStatus.PENDING_PAYMENT)); + ReservationExpiredEvent expired = expiredEvent(order.getId()); + + kafkaTemplate.send(Topics.RESERVATION_EXPIRED, key(order), expired).get(10, TimeUnit.SECONDS); + + // 1. Order is compensated in the database + await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { + Order reloaded = orderRepository.findById(order.getId()).orElseThrow(); + assertThat(reloaded.getStatus()).isEqualTo(OrderStatus.CANCELLED); + assertThat(reloaded.getCancellationReason()).isEqualTo(CancellationReason.RESERVATION_EXPIRED); + assertThat(reloaded.getCancelledAt()).isNotNull(); + }); + + // 2. order.cancelled goes out with the reason + await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { + drain(Duration.ofMillis(500)); + assertThat(cancelledFor(order.getId())).hasSize(1); + }); + JsonNode cancelled = cancelledFor(order.getId()).get(0); + assertThat(cancelled.get("eventType").asText()).isEqualTo("order.cancelled.v1"); + assertThat(cancelled.get("userId").asLong()).isEqualTo(7L); + assertThat(cancelled.get("reason").asText()).isEqualTo("RESERVATION_EXPIRED"); + assertThat(cancelled.get("reasonDescription").asText()).isNotBlank(); + assertThat(cancelled.get("items")).hasSize(1); + + // 3. Redelivery of the same expiry is a no-op (idempotent) + kafkaTemplate.send(Topics.RESERVATION_EXPIRED, key(order), expired).get(10, TimeUnit.SECONDS); + drain(Duration.ofSeconds(5)); + assertThat(cancelledFor(order.getId())).hasSize(1); + } + + @Test + void expiredReservation_doesNotCancelAnOrderThatAlreadyShipped() throws Exception { + Order order = orderRepository.save(order(OrderStatus.SHIPPED)); + ReservationExpiredEvent expired = expiredEvent(order.getId()); + + kafkaTemplate.send(Topics.RESERVATION_EXPIRED, key(order), expired).get(10, TimeUnit.SECONDS); + + await().atMost(Duration.ofSeconds(30)) + .until(() -> processedEventRepository.existsById(expired.getEventId())); + + Order reloaded = orderRepository.findById(order.getId()).orElseThrow(); + assertThat(reloaded.getStatus()).isEqualTo(OrderStatus.SHIPPED); + assertThat(reloaded.getCancellationReason()).isNull(); + + drain(Duration.ofSeconds(3)); + assertThat(cancelledFor(order.getId())).isEmpty(); + } + + /* ------------------------------ helpers ------------------------------ */ + + private static String key(Order order) { + return String.valueOf(order.getId()); + } + + private static Order order(OrderStatus status) { + Order order = Order.builder() + .userId(7L).status(status) + .totalAmount(new BigDecimal("100.00")).itemCount(2) + .build(); + order.addItem(OrderItem.builder() + .productId(10L).productName("Keyboard") + .unitPrice(new BigDecimal("50.00")).quantity(2) + .lineTotal(new BigDecimal("100.00")) + .build()); + return order; + } + + private static ReservationExpiredEvent expiredEvent(Long orderId) { + Instant now = Instant.now(); + return ReservationExpiredEvent.builder() + .eventId(UUID.randomUUID().toString()) + .eventType(ReservationExpiredEvent.TYPE) + .occurredAt(now) + .orderId(orderId) + .expiredAt(now) + .items(List.of(OrderItemSnapshot.builder().productId(10L).quantity(2).build())) + .build(); + } + + private void drain(Duration window) throws Exception { + for (ConsumerRecord record : KafkaTestUtils.getRecords(cancelledConsumer, window)) { + cancelledEvents.add(objectMapper.readTree(record.value())); + } + } + + private List cancelledFor(Long orderId) { + return cancelledEvents.stream() + .filter(e -> e.path("orderId").asLong() == orderId) + .toList(); + } +} diff --git a/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/service/OrderCancellationServiceTest.java b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/service/OrderCancellationServiceTest.java new file mode 100644 index 0000000..583ec5d --- /dev/null +++ b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/service/OrderCancellationServiceTest.java @@ -0,0 +1,133 @@ +package com.shopsphere.orderservice.service; + +import com.shopsphere.common.events.OrderCancelledEvent; +import com.shopsphere.common.events.OrderItemSnapshot; +import com.shopsphere.common.events.ReservationExpiredEvent; +import com.shopsphere.orderservice.entity.CancellationReason; +import com.shopsphere.orderservice.entity.Order; +import com.shopsphere.orderservice.entity.OrderItem; +import com.shopsphere.orderservice.entity.OrderStatus; +import com.shopsphere.orderservice.entity.ProcessedEvent; +import com.shopsphere.orderservice.messaging.OrderEventPublisher; +import com.shopsphere.orderservice.repository.OrderRepository; +import com.shopsphere.orderservice.repository.ProcessedEventRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderCancellationServiceTest { + + @Mock OrderRepository orderRepository; + @Mock ProcessedEventRepository processedEventRepository; + @Mock ApplicationEventPublisher applicationEventPublisher; + @Mock OrderEventPublisher orderEventPublisher; + + @InjectMocks OrderCancellationService service; + + private static Order order(OrderStatus status) { + Order order = Order.builder() + .id(42L).userId(7L).status(status) + .totalAmount(new BigDecimal("100.00")).itemCount(2) + .build(); + order.addItem(OrderItem.builder() + .productId(10L).productName("Keyboard") + .unitPrice(new BigDecimal("50.00")).quantity(2) + .lineTotal(new BigDecimal("100.00")) + .build()); + return order; + } + + private static ReservationExpiredEvent expired(String eventId) { + return ReservationExpiredEvent.builder() + .eventId(eventId).eventType(ReservationExpiredEvent.TYPE) + .orderId(42L).expiredAt(Instant.now()) + .items(List.of(OrderItemSnapshot.builder().productId(10L).quantity(2).build())) + .build(); + } + + @Test + void pendingOrder_isCancelledWithReason_andCancellationEventQueuedForAfterCommit() { + Order order = order(OrderStatus.PENDING_PAYMENT); + when(processedEventRepository.existsById("rx-1")).thenReturn(false); + when(orderRepository.findById(42L)).thenReturn(Optional.of(order)); + + service.onReservationExpired(expired("rx-1")); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + assertThat(order.getCancellationReason()).isEqualTo(CancellationReason.RESERVATION_EXPIRED); + assertThat(order.getCancelledAt()).isNotNull(); + verify(orderRepository).save(order); + verify(processedEventRepository).save(any(ProcessedEvent.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); + verify(applicationEventPublisher).publishEvent(captor.capture()); + OrderCancelledEvent event = ((OrderCancellationService.OrderCancellationCommitted) captor.getValue()).event(); + assertThat(event.getEventId()).isNotBlank(); + assertThat(event.getEventType()).isEqualTo(OrderCancelledEvent.TYPE); + assertThat(event.getOrderId()).isEqualTo(42L); + assertThat(event.getUserId()).isEqualTo(7L); + assertThat(event.getReason()).isEqualTo("RESERVATION_EXPIRED"); + assertThat(event.getReasonDescription()).contains("Payment wasn't confirmed in time"); + assertThat(event.getItems()).extracting(OrderItemSnapshot::getProductId).containsExactly(10L); + + // Kafka publish happens only in the AFTER_COMMIT listener. + verifyNoInteractions(orderEventPublisher); + service.onCancellationCommitted((OrderCancellationService.OrderCancellationCommitted) captor.getValue()); + verify(orderEventPublisher).publishOrderCancelled(event); + } + + @ParameterizedTest + @EnumSource(value = OrderStatus.class, names = {"PAID", "SHIPPED", "DELIVERED", "CANCELLED", "PAYMENT_FAILED"}) + void nonPendingOrders_areLeftAlone_butEventIsRecorded(OrderStatus status) { + Order order = order(status); + when(processedEventRepository.existsById("rx-2")).thenReturn(false); + when(orderRepository.findById(42L)).thenReturn(Optional.of(order)); + + service.onReservationExpired(expired("rx-2")); + + assertThat(order.getStatus()).isEqualTo(status); + verify(orderRepository, never()).save(any()); + verifyNoInteractions(applicationEventPublisher); + verify(processedEventRepository).save(any(ProcessedEvent.class)); + } + + @Test + void duplicateEvent_isSkipped() { + when(processedEventRepository.existsById("rx-1")).thenReturn(true); + + service.onReservationExpired(expired("rx-1")); + + verifyNoInteractions(orderRepository, applicationEventPublisher); + verify(processedEventRepository, never()).save(any()); + } + + @Test + void unknownOrder_isRecordedWithoutCancelling() { + when(processedEventRepository.existsById("rx-3")).thenReturn(false); + when(orderRepository.findById(42L)).thenReturn(Optional.empty()); + + service.onReservationExpired(expired("rx-3")); + + verifyNoInteractions(applicationEventPublisher); + verify(processedEventRepository).save(any(ProcessedEvent.class)); + } +} diff --git a/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/service/OrderShipmentServiceTest.java b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/service/OrderShipmentServiceTest.java new file mode 100644 index 0000000..b590d9e --- /dev/null +++ b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/service/OrderShipmentServiceTest.java @@ -0,0 +1,151 @@ +package com.shopsphere.orderservice.service; + +import com.shopsphere.common.events.ShipmentDeliveredEvent; +import com.shopsphere.common.events.ShipmentDispatchedEvent; +import com.shopsphere.orderservice.entity.Order; +import com.shopsphere.orderservice.entity.OrderStatus; +import com.shopsphere.orderservice.entity.ProcessedEvent; +import com.shopsphere.orderservice.repository.OrderRepository; +import com.shopsphere.orderservice.repository.ProcessedEventRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderShipmentServiceTest { + + @Mock OrderRepository orderRepository; + @Mock ProcessedEventRepository processedEventRepository; + + @InjectMocks OrderShipmentService service; + + private static final Instant SHIPPED_AT = Instant.parse("2026-09-26T12:00:00Z"); + private static final Instant DELIVERED_AT = Instant.parse("2026-09-28T15:30:00Z"); + + private static Order order(OrderStatus status) { + return Order.builder() + .id(42L).userId(7L).status(status) + .totalAmount(new BigDecimal("100.00")).itemCount(2) + .build(); + } + + private static ShipmentDispatchedEvent dispatched(String eventId) { + return ShipmentDispatchedEvent.builder() + .eventId(eventId).eventType(ShipmentDispatchedEvent.TYPE).occurredAt(SHIPPED_AT) + .shipmentId(5L).orderId(42L).userId(7L) + .carrier("ShopSphere Express").trackingNumber("SSX2609264K7QZ9M2PA") + .shippedAt(SHIPPED_AT) + .build(); + } + + private static ShipmentDeliveredEvent delivered(String eventId) { + return ShipmentDeliveredEvent.builder() + .eventId(eventId).eventType(ShipmentDeliveredEvent.TYPE).occurredAt(DELIVERED_AT) + .shipmentId(5L).orderId(42L).userId(7L) + .carrier("ShopSphere Express").trackingNumber("SSX2609264K7QZ9M2PA") + .deliveredAt(DELIVERED_AT) + .build(); + } + + @Test + void dispatched_movesPendingOrderToShipped_andStoresTracking() { + Order order = order(OrderStatus.PENDING_PAYMENT); + when(processedEventRepository.existsById("d-1")).thenReturn(false); + when(orderRepository.findById(42L)).thenReturn(Optional.of(order)); + + service.onShipmentDispatched(dispatched("d-1")); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.SHIPPED); + assertThat(order.getShipmentId()).isEqualTo(5L); + assertThat(order.getCarrier()).isEqualTo("ShopSphere Express"); + assertThat(order.getTrackingNumber()).isEqualTo("SSX2609264K7QZ9M2PA"); + assertThat(order.getShippedAt()).isEqualTo(SHIPPED_AT); + verify(orderRepository).save(order); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ProcessedEvent.class); + verify(processedEventRepository).save(captor.capture()); + assertThat(captor.getValue().getEventId()).isEqualTo("d-1"); + assertThat(captor.getValue().getEventType()).isEqualTo(ShipmentDispatchedEvent.TYPE); + } + + @Test + void dispatched_duplicateEvent_isSkipped() { + when(processedEventRepository.existsById("d-1")).thenReturn(true); + + service.onShipmentDispatched(dispatched("d-1")); + + verifyNoInteractions(orderRepository); + verify(processedEventRepository, never()).save(any()); + } + + @Test + void delivered_movesShippedOrderToDelivered() { + Order order = order(OrderStatus.SHIPPED); + order.setTrackingNumber("SSX2609264K7QZ9M2PA"); + when(processedEventRepository.existsById("v-1")).thenReturn(false); + when(orderRepository.findById(42L)).thenReturn(Optional.of(order)); + + service.onShipmentDelivered(delivered("v-1")); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.DELIVERED); + assertThat(order.getDeliveredAt()).isEqualTo(DELIVERED_AT); + verify(orderRepository).save(order); + verify(processedEventRepository).save(any(ProcessedEvent.class)); + } + + @Test + void dispatched_afterDelivery_doesNotMoveStatusBackwards() { + Order order = order(OrderStatus.DELIVERED); + when(processedEventRepository.existsById("d-late")).thenReturn(false); + when(orderRepository.findById(42L)).thenReturn(Optional.of(order)); + + service.onShipmentDispatched(dispatched("d-late")); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.DELIVERED); + assertThat(order.getTrackingNumber()).isEqualTo("SSX2609264K7QZ9M2PA"); // details still filled + verify(processedEventRepository).save(any(ProcessedEvent.class)); + } + + @Test + void delivered_onCancelledOrder_isIgnored() { + Order order = order(OrderStatus.CANCELLED); + when(processedEventRepository.existsById("v-2")).thenReturn(false); + when(orderRepository.findById(42L)).thenReturn(Optional.of(order)); + + service.onShipmentDelivered(delivered("v-2")); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @Test + void unknownOrder_isRecordedAsProcessed_withoutSaving() { + when(processedEventRepository.existsById("d-9")).thenReturn(false); + when(orderRepository.findById(42L)).thenReturn(Optional.empty()); + + service.onShipmentDispatched(dispatched("d-9")); + + verify(orderRepository, never()).save(any()); + verify(processedEventRepository).save(any(ProcessedEvent.class)); + } + + @Test + void eventWithoutId_isIgnored() { + service.onShipmentDispatched(dispatched(null)); + + verifyNoInteractions(orderRepository, processedEventRepository); + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Data/ShipmentConfiguration.cs b/shopsphere/shipping-service/src/ShippingService.Api/Data/ShipmentConfiguration.cs index e8312d5..26e2437 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Data/ShipmentConfiguration.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Data/ShipmentConfiguration.cs @@ -37,6 +37,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(s => s.ShippedAt).HasColumnName("shipped_at"); builder.Property(s => s.DeliveredAt).HasColumnName("delivered_at"); builder.Property(s => s.DispatchPublishedAt).HasColumnName("dispatch_published_at"); + builder.Property(s => s.DeliveredPublishedAt).HasColumnName("delivered_published_at"); builder.OwnsOne(s => s.ShippingAddress, address => { diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Domain/Shipment.cs b/shopsphere/shipping-service/src/ShippingService.Api/Domain/Shipment.cs index ad5aad8..a07f277 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Domain/Shipment.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Domain/Shipment.cs @@ -3,6 +3,7 @@ namespace ShippingService.Api.Domain; /// /// One shipment per order. Lifecycle: PENDING → SHIPPED → DELIVERED, or PENDING → CANCELLED. /// Timestamps (CreatedAt/UpdatedAt) are stamped by ShippingDbContext on save. +/// *PublishedAt columns form a small outbox: null means the event still has to go out. /// public sealed class Shipment { @@ -19,9 +20,13 @@ private Shipment() { } // EF Core public DateTimeOffset UpdatedAt { get; private set; } public DateTimeOffset? ShippedAt { get; private set; } public DateTimeOffset? DeliveredAt { get; private set; } - /// Set once shipment.dispatched is acknowledged by Kafka; null means it still needs (re)publishing. + + /// US35 — set once shipment.dispatched has been acknowledged by Kafka. public DateTimeOffset? DispatchPublishedAt { get; private set; } + /// US36 — set once shipment.delivered has been acknowledged by Kafka. + public DateTimeOffset? DeliveredPublishedAt { get; private set; } + public static Shipment Create(long orderId, long userId, ShippingAddress shippingAddress) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(orderId); @@ -51,8 +56,12 @@ public void MarkShipped(string carrier, string trackingNumber, DateTimeOffset sh public void MarkDispatchPublished(DateTimeOffset publishedAt) { - EnsureStatus(ShipmentStatus.Shipped, "record dispatch publish for"); - DispatchPublishedAt = publishedAt; + if (Status != ShipmentStatus.Shipped && Status != ShipmentStatus.Delivered) + { + throw new InvalidOperationException( + $"Cannot record dispatch publication for shipment {Id} in status {Status}."); + } + DispatchPublishedAt ??= publishedAt; } public void MarkDelivered(DateTimeOffset deliveredAt) @@ -62,6 +71,16 @@ public void MarkDelivered(DateTimeOffset deliveredAt) Status = ShipmentStatus.Delivered; } + public void MarkDeliveredPublished(DateTimeOffset publishedAt) + { + if (Status != ShipmentStatus.Delivered) + { + throw new InvalidOperationException( + $"Cannot record delivery publication for shipment {Id} in status {Status}."); + } + DeliveredPublishedAt ??= publishedAt; + } + public void Cancel() { EnsureStatus(ShipmentStatus.Pending, "cancel"); diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Endpoints/ShipmentEndpoints.cs b/shopsphere/shipping-service/src/ShippingService.Api/Endpoints/ShipmentEndpoints.cs index 72ba597..838112e 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Endpoints/ShipmentEndpoints.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Endpoints/ShipmentEndpoints.cs @@ -3,64 +3,122 @@ using ShippingService.Api.Contracts; using ShippingService.Api.Data; using ShippingService.Api.Domain; +using ShippingService.Api.Shipping; namespace ShippingService.Api.Endpoints; /// -/// Read-only for now. Shipments are created from order/payment events in a later story. -/// X-User-Id is injected by the API Gateway from the JWT (same contract as order-service). +/// X-User-Id / X-User-Role are injected by the API Gateway from the JWT. +/// Reads: owner (or ADMIN). Transitions: ADMIN only — enforced at the gateway AND here. /// public static class ShipmentEndpoints { + private const string AdminRole = "ADMIN"; + public static IEndpointRouteBuilder MapShipmentEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/v1/shipments").WithTags("Shipments"); group.MapGet("/{shipmentId:long}", GetByIdAsync) .WithName("GetShipmentById") - .WithSummary("Get a shipment by id (owner only)"); + .WithSummary("Get a shipment by id (owner or ADMIN)"); group.MapGet("/order/{orderId:long}", GetByOrderIdAsync) .WithName("GetShipmentByOrderId") - .WithSummary("Get the shipment for an order (owner only)"); + .WithSummary("Get the shipment for an order (owner or ADMIN)"); + + group.MapPost("/{shipmentId:long}/deliver", DeliverAsync) + .WithName("DeliverShipment") + .WithSummary("Mark a SHIPPED shipment as DELIVERED and publish shipment.delivered (ADMIN)"); + + group.MapPost("/{shipmentId:long}/cancel", CancelAsync) + .WithName("CancelShipment") + .WithSummary("Cancel a PENDING shipment (ADMIN)"); return app; } + /* ------------------------------ reads ------------------------------ */ + internal static async Task GetByIdAsync( long shipmentId, [FromHeader(Name = "X-User-Id")] long userId, + [FromHeader(Name = "X-User-Role")] string? role, ShippingDbContext db, CancellationToken ct) { var shipment = await db.Shipments.AsNoTracking() .FirstOrDefaultAsync(s => s.Id == shipmentId, ct); - return ToResult(shipment, userId, $"Shipment {shipmentId} not found"); + return ToReadResult(shipment, userId, role, $"Shipment {shipmentId} not found"); } internal static async Task GetByOrderIdAsync( long orderId, [FromHeader(Name = "X-User-Id")] long userId, + [FromHeader(Name = "X-User-Role")] string? role, ShippingDbContext db, CancellationToken ct) { var shipment = await db.Shipments.AsNoTracking() .FirstOrDefaultAsync(s => s.OrderId == orderId, ct); - return ToResult(shipment, userId, $"No shipment found for order {orderId}"); + return ToReadResult(shipment, userId, role, $"No shipment found for order {orderId}"); + } + + /* --------------------------- transitions --------------------------- */ + + internal static async Task DeliverAsync( + long shipmentId, + [FromHeader(Name = "X-User-Role")] string? role, + ShipmentLifecycleService lifecycle, + CancellationToken ct) + { + if (!IsAdmin(role)) + { + return Forbidden("ADMIN role required to change shipment status"); + } + return ToTransitionResult(await lifecycle.DeliverAsync(shipmentId, ct)); } - private static IResult ToResult(Shipment? shipment, long userId, string notFoundMessage) + internal static async Task CancelAsync( + long shipmentId, + [FromHeader(Name = "X-User-Role")] string? role, + ShipmentLifecycleService lifecycle, + CancellationToken ct) + { + if (!IsAdmin(role)) + { + return Forbidden("ADMIN role required to change shipment status"); + } + return ToTransitionResult(await lifecycle.CancelAsync(shipmentId, ct)); + } + + /* ----------------------------- helpers ----------------------------- */ + + private static bool IsAdmin(string? role) => string.Equals(role, AdminRole, StringComparison.OrdinalIgnoreCase); + + private static IResult Forbidden(string detail) => + TypedResults.Problem(title: "Forbidden", detail: detail, statusCode: StatusCodes.Status403Forbidden); + + private static IResult ToReadResult(Shipment? shipment, long userId, string? role, string notFoundMessage) { if (shipment is null) { return TypedResults.Problem(title: "Not Found", detail: notFoundMessage, statusCode: StatusCodes.Status404NotFound); } - if (shipment.UserId != userId) + if (shipment.UserId != userId && !IsAdmin(role)) { - return TypedResults.Problem(title: "Forbidden", detail: "Shipment belongs to another user", - statusCode: StatusCodes.Status403Forbidden); + return Forbidden("Shipment belongs to another user"); } return TypedResults.Ok(ShipmentResponse.From(shipment)); } + + private static IResult ToTransitionResult(TransitionResult result) => result.Status switch + { + TransitionStatus.Ok => TypedResults.Ok(ShipmentResponse.From(result.Shipment!)), + TransitionStatus.NotFound => TypedResults.Problem(title: "Not Found", detail: result.Error, + statusCode: StatusCodes.Status404NotFound), + _ => TypedResults.Problem(title: "Conflict", detail: result.Error, + statusCode: StatusCodes.Status409Conflict) + }; } diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/ShipmentDeliveredEvent.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/ShipmentDeliveredEvent.cs new file mode 100644 index 0000000..ac2b114 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Events/ShipmentDeliveredEvent.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; +using ShippingService.Api.Domain; + +namespace ShippingService.Api.Messaging.Events; + +/// Wire shape of common-lib ShipmentDeliveredEvent. UTC DateTime → ISO with 'Z'. +public sealed record ShipmentDeliveredEvent( + string EventId, + string EventType, + DateTime OccurredAt, + long ShipmentId, + long OrderId, + long UserId, + string? Carrier, + string? TrackingNumber, + DateTime DeliveredAt) +{ + public const string Type = "shipment.delivered.v1"; + + public static ShipmentDeliveredEvent From(Shipment shipment, DateTimeOffset occurredAt) + { + if (shipment.DeliveredAt is null) + { + throw new InvalidOperationException($"Shipment {shipment.Id} has not been delivered yet."); + } + + return new ShipmentDeliveredEvent( + EventIdFor(shipment.Id), + Type, + occurredAt.UtcDateTime, + shipment.Id, + shipment.OrderId, + shipment.UserId, + shipment.Carrier, + shipment.TrackingNumber, + shipment.DeliveredAt.Value.UtcDateTime); + } + + /// Deterministic per shipment, so a re-publish carries the same id. + public static string EventIdFor(long shipmentId) => + new Guid(MD5.HashData(Encoding.UTF8.GetBytes($"{Type}:{shipmentId}"))).ToString(); +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/IShipmentEventPublisher.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/IShipmentEventPublisher.cs index 31d538d..b392ea6 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/IShipmentEventPublisher.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/IShipmentEventPublisher.cs @@ -6,4 +6,7 @@ public interface IShipmentEventPublisher { /// Completes only after the broker acknowledges the write (acks=all). Task PublishDispatchedAsync(ShipmentDispatchedEvent dispatched, CancellationToken cancellationToken); + + /// Completes only after the broker acknowledges the write (acks=all). + Task PublishDeliveredAsync(ShipmentDeliveredEvent delivered, CancellationToken cancellationToken); } diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/KafkaShipmentEventPublisher.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/KafkaShipmentEventPublisher.cs index 51f2284..14bf677 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/KafkaShipmentEventPublisher.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/KafkaShipmentEventPublisher.cs @@ -29,20 +29,28 @@ public KafkaShipmentEventPublisher(IOptions options, ILogger + ProduceAsync(Topics.ShipmentDispatched, dispatched.OrderId, dispatched.EventId, dispatched.EventType, + EventJson.Serialize(dispatched), cancellationToken); + + public Task PublishDeliveredAsync(ShipmentDeliveredEvent delivered, CancellationToken cancellationToken) => + ProduceAsync(Topics.ShipmentDelivered, delivered.OrderId, delivered.EventId, delivered.EventType, + EventJson.Serialize(delivered), cancellationToken); + + private async Task ProduceAsync(string topic, long orderId, string eventId, string eventType, string payload, + CancellationToken cancellationToken) { var message = new Message { - Key = dispatched.OrderId.ToString(CultureInfo.InvariantCulture), // same partitioning key as order/payment events - Value = EventJson.Serialize(dispatched), - Headers = new Headers { { "eventType", Encoding.UTF8.GetBytes(dispatched.EventType) } } + Key = orderId.ToString(CultureInfo.InvariantCulture), // same partitioning key as order/payment events + Value = payload, + Headers = new Headers { { "eventType", Encoding.UTF8.GetBytes(eventType) } } }; - var result = await _producer.ProduceAsync(Topics.ShipmentDispatched, message, cancellationToken); + var result = await _producer.ProduceAsync(topic, message, cancellationToken); - _logger.LogInformation( - "Published shipment.dispatched eventId={EventId} orderId={OrderId} tracking={TrackingNumber} to {TopicPartitionOffset}", - dispatched.EventId, dispatched.OrderId, dispatched.TrackingNumber, result.TopicPartitionOffset); + _logger.LogInformation("Published {EventType} eventId={EventId} orderId={OrderId} to {TopicPartitionOffset}", + eventType, eventId, orderId, result.TopicPartitionOffset); } public void Dispose() diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/ShipmentOutboxRelay.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/ShipmentOutboxRelay.cs new file mode 100644 index 0000000..b59060c --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/ShipmentOutboxRelay.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Options; +using ShippingService.Api.Options; +using ShippingService.Api.Shipping; + +namespace ShippingService.Api.Messaging; + +/// Periodically re-publishes shipment events that failed to go out. +public sealed class ShipmentOutboxRelay( + IServiceScopeFactory scopeFactory, + IOptions kafkaOptions, + ILogger logger) : BackgroundService +{ + private const int BatchSize = 100; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var interval = TimeSpan.FromSeconds(Math.Max(5, kafkaOptions.Value.OutboxRelayIntervalSeconds)); + using var timer = new PeriodicTimer(interval); + + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + using var scope = scopeFactory.CreateScope(); + var outbox = scope.ServiceProvider.GetRequiredService(); + var caughtUp = await outbox.RepublishPendingAsync(interval, BatchSize, stoppingToken); + if (caughtUp > 0) + { + logger.LogInformation("Outbox relay re-published events for {Count} shipment(s)", caughtUp); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Outbox relay pass failed"); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // shutting down + } + } +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Topics.cs b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Topics.cs index 15b3e52..82b4e3e 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Topics.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Messaging/Topics.cs @@ -5,4 +5,5 @@ public static class Topics { public const string PaymentSuccessful = "payment.successful"; public const string ShipmentDispatched = "shipment.dispatched"; + public const string ShipmentDelivered = "shipment.delivered"; } diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Options/KafkaOptions.cs b/shopsphere/shipping-service/src/ShippingService.Api/Options/KafkaOptions.cs index cec0a10..bddf6b2 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Options/KafkaOptions.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Options/KafkaOptions.cs @@ -4,8 +4,11 @@ public sealed class KafkaOptions { public const string SectionName = "Kafka"; - /// When false the payment.successful consumer is not started (tests, local API-only runs). + /// When false the consumer and outbox relay are not started (tests, API-only runs). public bool Enabled { get; set; } = true; public string BootstrapServers { get; set; } = "localhost:9092"; public string GroupId { get; set; } = "shipping-service"; + + /// How often unpublished shipment events are retried. Minimum 5s. + public int OutboxRelayIntervalSeconds { get; set; } = 30; } diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Program.cs b/shopsphere/shipping-service/src/ShippingService.Api/Program.cs index 20adeff..363b4c9 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Program.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Program.cs @@ -40,15 +40,18 @@ .ValidateDataAnnotations() .ValidateOnStart(); -// ---- US35: shipment creation pipeline ---- +// ---- Shipment pipeline (US35 + US36) ---- builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); if (builder.Configuration.GetValue($"{KafkaOptions.SectionName}:Enabled", true)) { builder.Services.AddHostedService(); + builder.Services.AddHostedService(); } // ---- Health checks ---- diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Shipping/PaymentSuccessfulHandler.cs b/shopsphere/shipping-service/src/ShippingService.Api/Shipping/PaymentSuccessfulHandler.cs index b0655f4..ba3c78b 100644 --- a/shopsphere/shipping-service/src/ShippingService.Api/Shipping/PaymentSuccessfulHandler.cs +++ b/shopsphere/shipping-service/src/ShippingService.Api/Shipping/PaymentSuccessfulHandler.cs @@ -24,7 +24,7 @@ namespace ShippingService.Api.Shipping; public sealed class PaymentSuccessfulHandler( ShippingDbContext db, ITrackingNumberGenerator trackingNumbers, - IShipmentEventPublisher publisher, + ShipmentEventOutbox outbox, IOptions shippingOptions, TimeProvider timeProvider, ILogger logger) @@ -84,7 +84,7 @@ public async Task HandleAsync(PaymentSuccessfulEvent evt, Cancell logger.LogInformation("Created shipment {ShipmentId} for orderId={OrderId} carrier={Carrier} tracking={TrackingNumber}", shipment.Id, shipment.OrderId, shipment.Carrier, shipment.TrackingNumber); - await PublishDispatchedAsync(shipment, ct); + await outbox.PublishDispatchedAsync(shipment, ct); return HandleOutcome.Created; } @@ -132,21 +132,13 @@ private static void Validate(PaymentSuccessfulEvent evt) private async Task EnsureDispatchPublishedAsync(long orderId, CancellationToken ct) { var shipment = await db.Shipments.FirstOrDefaultAsync(s => s.OrderId == orderId, ct); - if (shipment is { Status: ShipmentStatus.Shipped, DispatchPublishedAt: null }) + if (shipment is not null && ShipmentEventOutbox.NeedsDispatchPublish(shipment)) { logger.LogInformation("Re-publishing shipment.dispatched for shipment {ShipmentId}", shipment.Id); - await PublishDispatchedAsync(shipment, ct); + await outbox.PublishDispatchedAsync(shipment, ct); } } - private async Task PublishDispatchedAsync(Shipment shipment, CancellationToken ct) - { - var now = timeProvider.GetUtcNow(); - await publisher.PublishDispatchedAsync(ShipmentDispatchedEvent.From(shipment, now), ct); - shipment.MarkDispatchPublished(now); - await db.SaveChangesAsync(ct); - } - /// false if a unique constraint (event id / order id) was hit — someone else won. private async Task SaveIgnoringDuplicatesAsync(CancellationToken ct) { diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Shipping/ShipmentEventOutbox.cs b/shopsphere/shipping-service/src/ShippingService.Api/Shipping/ShipmentEventOutbox.cs new file mode 100644 index 0000000..8411b9f --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Shipping/ShipmentEventOutbox.cs @@ -0,0 +1,92 @@ +using Microsoft.EntityFrameworkCore; +using ShippingService.Api.Data; +using ShippingService.Api.Domain; +using ShippingService.Api.Messaging; +using ShippingService.Api.Messaging.Events; + +namespace ShippingService.Api.Shipping; + +/// +/// Publishes shipment events and records that they went out (dispatch_published_at / +/// delivered_published_at). State changes are committed first; events follow. Anything +/// left unpublished is picked up by ShipmentOutboxRelay. Event ids are deterministic, so +/// the occasional duplicate publish is harmless for idempotent consumers. +/// +public sealed class ShipmentEventOutbox( + ShippingDbContext db, + IShipmentEventPublisher publisher, + TimeProvider timeProvider, + ILogger logger) +{ + public async Task PublishDispatchedAsync(Shipment shipment, CancellationToken ct) + { + var now = timeProvider.GetUtcNow(); + await publisher.PublishDispatchedAsync(ShipmentDispatchedEvent.From(shipment, now), ct); + shipment.MarkDispatchPublished(now); + await db.SaveChangesAsync(ct); + } + + public async Task PublishDeliveredAsync(Shipment shipment, CancellationToken ct) + { + var now = timeProvider.GetUtcNow(); + await publisher.PublishDeliveredAsync(ShipmentDeliveredEvent.From(shipment, now), ct); + shipment.MarkDeliveredPublished(now); + await db.SaveChangesAsync(ct); + } + + /// Publishes whatever is outstanding for this shipment, in lifecycle order. + /// false if a publish failed (the relay will retry later). + public async Task TryPublishPendingAsync(Shipment shipment, CancellationToken ct) + { + try + { + if (NeedsDispatchPublish(shipment)) + { + await PublishDispatchedAsync(shipment, ct); + } + if (NeedsDeliveredPublish(shipment)) + { + await PublishDeliveredAsync(shipment, ct); + } + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Publishing events for shipment {ShipmentId} failed; the outbox relay will retry", + shipment.Id); + return false; + } + } + + /// Re-publishes events for shipments untouched for at least . + /// Number of shipments fully caught up. + public async Task RepublishPendingAsync(TimeSpan minAge, int batchSize, CancellationToken ct) + { + var cutoff = DateTimeOffset.UtcNow - minAge; + + var pending = await db.Shipments + .Where(s => s.UpdatedAt <= cutoff && + ((s.DispatchPublishedAt == null && + (s.Status == ShipmentStatus.Shipped || s.Status == ShipmentStatus.Delivered)) || + (s.DeliveredPublishedAt == null && s.Status == ShipmentStatus.Delivered))) + .OrderBy(s => s.Id) + .Take(batchSize) + .ToListAsync(ct); + + var caughtUp = 0; + foreach (var shipment in pending) + { + if (await TryPublishPendingAsync(shipment, ct)) + { + caughtUp++; + } + } + return caughtUp; + } + + internal static bool NeedsDispatchPublish(Shipment s) => + s.DispatchPublishedAt is null && s.Status is ShipmentStatus.Shipped or ShipmentStatus.Delivered; + + internal static bool NeedsDeliveredPublish(Shipment s) => + s.DeliveredPublishedAt is null && s.Status == ShipmentStatus.Delivered; +} diff --git a/shopsphere/shipping-service/src/ShippingService.Api/Shipping/ShipmentLifecycleService.cs b/shopsphere/shipping-service/src/ShippingService.Api/Shipping/ShipmentLifecycleService.cs new file mode 100644 index 0000000..2ebc002 --- /dev/null +++ b/shopsphere/shipping-service/src/ShippingService.Api/Shipping/ShipmentLifecycleService.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore; +using ShippingService.Api.Data; +using ShippingService.Api.Domain; + +namespace ShippingService.Api.Shipping; + +public enum TransitionStatus +{ + Ok, + NotFound, + Conflict +} + +public sealed record TransitionResult(TransitionStatus Status, Shipment? Shipment, string? Error) +{ + public static TransitionResult Ok(Shipment s) => new(TransitionStatus.Ok, s, null); + public static TransitionResult NotFound(long id) => new(TransitionStatus.NotFound, null, $"Shipment {id} not found"); + public static TransitionResult Conflict(Shipment s, string error) => new(TransitionStatus.Conflict, s, error); +} + +/// +/// US36 — manual/carrier-driven status transitions. Both operations are idempotent: +/// repeating a transition that already happened returns 200 and re-tries any unpublished event. +/// The state change is committed before publishing; a failed publish is retried by the relay. +/// +public sealed class ShipmentLifecycleService( + ShippingDbContext db, + ShipmentEventOutbox outbox, + TimeProvider timeProvider, + ILogger logger) +{ + public async Task DeliverAsync(long shipmentId, CancellationToken ct) + { + var shipment = await db.Shipments.FirstOrDefaultAsync(s => s.Id == shipmentId, ct); + if (shipment is null) + { + return TransitionResult.NotFound(shipmentId); + } + + if (shipment.Status == ShipmentStatus.Delivered) + { + await outbox.TryPublishPendingAsync(shipment, ct); + return TransitionResult.Ok(shipment); + } + + if (shipment.Status != ShipmentStatus.Shipped) + { + return TransitionResult.Conflict(shipment, + $"Shipment {shipmentId} is {Name(shipment.Status)}; only SHIPPED shipments can be delivered."); + } + + shipment.MarkDelivered(timeProvider.GetUtcNow()); + await db.SaveChangesAsync(ct); + logger.LogInformation("Shipment {ShipmentId} for order {OrderId} marked DELIVERED", shipment.Id, shipment.OrderId); + + await outbox.TryPublishPendingAsync(shipment, ct); + return TransitionResult.Ok(shipment); + } + + public async Task CancelAsync(long shipmentId, CancellationToken ct) + { + var shipment = await db.Shipments.FirstOrDefaultAsync(s => s.Id == shipmentId, ct); + if (shipment is null) + { + return TransitionResult.NotFound(shipmentId); + } + + if (shipment.Status == ShipmentStatus.Cancelled) + { + return TransitionResult.Ok(shipment); + } + + if (shipment.Status != ShipmentStatus.Pending) + { + return TransitionResult.Conflict(shipment, + $"Shipment {shipmentId} is {Name(shipment.Status)}; only PENDING shipments can be cancelled."); + } + + shipment.Cancel(); + await db.SaveChangesAsync(ct); + logger.LogInformation("Shipment {ShipmentId} for order {OrderId} CANCELLED", shipment.Id, shipment.OrderId); + return TransitionResult.Ok(shipment); + } + + private static string Name(ShipmentStatus status) => status.ToString().ToUpperInvariant(); +} diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/PaymentSuccessfulHandlerTests.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/PaymentSuccessfulHandlerTests.cs index e3321cf..76da283 100644 --- a/shopsphere/shipping-service/tests/ShippingService.Tests/PaymentSuccessfulHandlerTests.cs +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/PaymentSuccessfulHandlerTests.cs @@ -28,11 +28,12 @@ public PaymentSuccessfulHandlerTests() var shippingOptions = MsOptions.Create(new ShippingOptions { DefaultCarrier = "ShopSphere Express", TrackingPrefix = "SSX" }); var time = new FixedTimeProvider(Now); + var outbox = new ShipmentEventOutbox(_db, _publisher, time, NullLogger.Instance); _handler = new PaymentSuccessfulHandler( _db, new TrackingNumberGenerator(shippingOptions, time), - _publisher, + outbox, shippingOptions, time, NullLogger.Instance); diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentEndpointsTests.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentEndpointsTests.cs index 1582786..209f0fc 100644 --- a/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentEndpointsTests.cs +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentEndpointsTests.cs @@ -1,22 +1,45 @@ using System.Net; using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; using ShippingService.Api.Domain; +using ShippingService.Api.Messaging; namespace ShippingService.Tests; public sealed class ShipmentEndpointsTests(ShippingApiFactory factory) : IClassFixture { - private static Shipment NewShipment(long orderId, long userId) => - Shipment.Create(orderId, userId, + private static Shipment NewShipment(long orderId, long userId, bool shipped = false) + { + var shipment = Shipment.Create(orderId, userId, ShippingAddress.Create("Jane Doe", "123 King St W", "Toronto", "m5v 3l9", "ca", state: "ON")); + if (shipped) + { + shipment.MarkShipped("ShopSphere Express", $"SSXE2E{orderId}", DateTimeOffset.UtcNow); + } + return shipment; + } - private HttpClient ClientFor(long userId) + private HttpClient ClientFor(long userId, string role = "CUSTOMER") { var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("X-User-Id", userId.ToString()); + client.DefaultRequestHeaders.Add("X-User-Role", role); return client; } + private HttpClient Admin() => ClientFor(1, "ADMIN"); + + private FakeShipmentEventPublisher Publisher => + (FakeShipmentEventPublisher)factory.Services.GetRequiredService(); + + private static async Task JsonAsync(HttpResponseMessage response) + { + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return doc.RootElement.Clone(); + } + + /* ------------------------------ reads ------------------------------ */ + [Fact] public async Task GetByOrderId_Owner_ReturnsShipment() { @@ -25,8 +48,7 @@ public async Task GetByOrderId_Owner_ReturnsShipment() var response = await ClientFor(7).GetAsync("/api/v1/shipments/order/1001"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - var root = json.RootElement; + var root = await JsonAsync(response); Assert.Equal(1001, root.GetProperty("orderId").GetInt64()); Assert.Equal("PENDING", root.GetProperty("status").GetString()); Assert.Equal("CA", root.GetProperty("shippingAddress").GetProperty("country").GetString()); @@ -53,6 +75,16 @@ public async Task GetByOrderId_OtherUser_Returns403() Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } + [Fact] + public async Task GetByOrderId_Admin_CanReadAnyShipment() + { + await factory.SeedAsync(NewShipment(orderId: 1004, userId: 7)); + + var response = await Admin().GetAsync("/api/v1/shipments/order/1004"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + [Fact] public async Task GetByOrderId_Unknown_Returns404() { @@ -68,4 +100,85 @@ public async Task GetByOrderId_MissingUserHeader_Returns400() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + + /* --------------------------- transitions --------------------------- */ + + [Fact] + public async Task Deliver_Admin_MarksDelivered_AndPublishesShipmentDelivered() + { + var seeded = await factory.SeedAsync(NewShipment(orderId: 2001, userId: 7, shipped: true)); + + var response = await Admin().PostAsync($"/api/v1/shipments/{seeded.Id}/deliver", null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var root = await JsonAsync(response); + Assert.Equal("DELIVERED", root.GetProperty("status").GetString()); + Assert.Equal(JsonValueKind.String, root.GetProperty("deliveredAt").ValueKind); + + var delivered = Assert.Single(Publisher.Delivered, e => e.ShipmentId == seeded.Id); + Assert.Equal(2001, delivered.OrderId); + Assert.Equal("shipment.delivered.v1", delivered.EventType); + } + + [Fact] + public async Task Deliver_Twice_IsIdempotent_AndPublishesOnce() + { + var seeded = await factory.SeedAsync(NewShipment(orderId: 2002, userId: 7, shipped: true)); + + var first = await Admin().PostAsync($"/api/v1/shipments/{seeded.Id}/deliver", null); + var second = await Admin().PostAsync($"/api/v1/shipments/{seeded.Id}/deliver", null); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Single(Publisher.Delivered, e => e.ShipmentId == seeded.Id); + } + + [Fact] + public async Task Deliver_NonAdmin_Returns403() + { + var seeded = await factory.SeedAsync(NewShipment(orderId: 2003, userId: 7, shipped: true)); + + var response = await ClientFor(7).PostAsync($"/api/v1/shipments/{seeded.Id}/deliver", null); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Deliver_PendingShipment_Returns409() + { + var seeded = await factory.SeedAsync(NewShipment(orderId: 2004, userId: 7)); + + var response = await Admin().PostAsync($"/api/v1/shipments/{seeded.Id}/deliver", null); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task Deliver_Unknown_Returns404() + { + var response = await Admin().PostAsync("/api/v1/shipments/987654/deliver", null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Cancel_PendingShipment_Cancels() + { + var seeded = await factory.SeedAsync(NewShipment(orderId: 2005, userId: 7)); + + var response = await Admin().PostAsync($"/api/v1/shipments/{seeded.Id}/cancel", null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("CANCELLED", (await JsonAsync(response)).GetProperty("status").GetString()); + } + + [Fact] + public async Task Cancel_ShippedShipment_Returns409() + { + var seeded = await factory.SeedAsync(NewShipment(orderId: 2006, userId: 7, shipped: true)); + + var response = await Admin().PostAsync($"/api/v1/shipments/{seeded.Id}/cancel", null); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } } diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentEventOutboxTests.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentEventOutboxTests.cs new file mode 100644 index 0000000..aa2aa9d --- /dev/null +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/ShipmentEventOutboxTests.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using ShippingService.Api.Data; +using ShippingService.Api.Domain; +using ShippingService.Api.Messaging.Events; +using ShippingService.Api.Shipping; + +namespace ShippingService.Tests; + +public sealed class ShipmentEventOutboxTests : IDisposable +{ + private readonly ShippingDbContext _db; + private readonly FakeShipmentEventPublisher _publisher = new(); + private readonly ShipmentEventOutbox _outbox; + private readonly ShipmentLifecycleService _lifecycle; + + public ShipmentEventOutboxTests() + { + _db = new ShippingDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase($"outbox-{Guid.NewGuid()}") + .Options); + _outbox = new ShipmentEventOutbox(_db, _publisher, TimeProvider.System, NullLogger.Instance); + _lifecycle = new ShipmentLifecycleService(_db, _outbox, TimeProvider.System, NullLogger.Instance); + } + + public void Dispose() => _db.Dispose(); + + private async Task SeedShippedAsync(long orderId) + { + var shipment = Shipment.Create(orderId, 7, + ShippingAddress.Create("Jane Doe", "123 King St W", "Toronto", "M5V 3L9", "CA")); + shipment.MarkShipped("ShopSphere Express", $"SSXTEST{orderId}", DateTimeOffset.UtcNow); + _db.Shipments.Add(shipment); + await _db.SaveChangesAsync(); + return shipment; + } + + [Fact] + public async Task Relay_PublishesDispatchedThenDelivered_InOrder_ThenNothing() + { + var shipment = await SeedShippedAsync(100); + shipment.MarkDelivered(DateTimeOffset.UtcNow); + await _db.SaveChangesAsync(); + + var first = await _outbox.RepublishPendingAsync(TimeSpan.Zero, 100, CancellationToken.None); + + Assert.Equal(1, first); + Assert.Single(_publisher.Published); + Assert.Single(_publisher.Delivered); + Assert.NotNull(shipment.DispatchPublishedAt); + Assert.NotNull(shipment.DeliveredPublishedAt); + Assert.Equal(ShipmentDeliveredEvent.EventIdFor(shipment.Id), _publisher.Delivered[0].EventId); + + var second = await _outbox.RepublishPendingAsync(TimeSpan.Zero, 100, CancellationToken.None); + + Assert.Equal(0, second); + Assert.Single(_publisher.Published); + Assert.Single(_publisher.Delivered); + } + + [Fact] + public async Task Relay_FailedPublish_IsRetriedOnNextPass() + { + await SeedShippedAsync(101); + _publisher.FailNext = true; + + var first = await _outbox.RepublishPendingAsync(TimeSpan.Zero, 100, CancellationToken.None); + Assert.Equal(0, first); + Assert.Empty(_publisher.Published); + + var second = await _outbox.RepublishPendingAsync(TimeSpan.Zero, 100, CancellationToken.None); + Assert.Equal(1, second); + Assert.Single(_publisher.Published); + } + + [Fact] + public async Task Relay_SkipsRecentlyUpdatedShipments() + { + await SeedShippedAsync(102); + + var published = await _outbox.RepublishPendingAsync(TimeSpan.FromMinutes(5), 100, CancellationToken.None); + + Assert.Equal(0, published); + Assert.Empty(_publisher.Published); + } + + [Fact] + public async Task Deliver_PublishFails_StateStillCommitted_AndRelayCatchesUp() + { + var shipment = await SeedShippedAsync(103); + await _outbox.PublishDispatchedAsync(shipment, CancellationToken.None); + _publisher.FailNext = true; + + var result = await _lifecycle.DeliverAsync(shipment.Id, CancellationToken.None); + + Assert.Equal(TransitionStatus.Ok, result.Status); + Assert.Equal(ShipmentStatus.Delivered, shipment.Status); + Assert.Null(shipment.DeliveredPublishedAt); + Assert.Empty(_publisher.Delivered); + + await _outbox.RepublishPendingAsync(TimeSpan.Zero, 100, CancellationToken.None); + + Assert.Single(_publisher.Delivered); + Assert.NotNull(shipment.DeliveredPublishedAt); + } +} diff --git a/shopsphere/shipping-service/tests/ShippingService.Tests/TestDoubles.cs b/shopsphere/shipping-service/tests/ShippingService.Tests/TestDoubles.cs index 018a4b4..be378dc 100644 --- a/shopsphere/shipping-service/tests/ShippingService.Tests/TestDoubles.cs +++ b/shopsphere/shipping-service/tests/ShippingService.Tests/TestDoubles.cs @@ -10,23 +10,47 @@ public sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider public sealed class FakeShipmentEventPublisher : IShipmentEventPublisher { - /// Every publish attempt, including failed ones. + private readonly object _gate = new(); + + /// Every dispatched publish attempt, including failed ones. public List Attempts { get; } = []; - /// Successfully "acknowledged" publishes. + /// Successfully "acknowledged" dispatched publishes. public List Published { get; } = []; + /// Successfully "acknowledged" delivered publishes. + public List Delivered { get; } = []; + + /// Fail the next publish (of any type) once. public bool FailNext { get; set; } public Task PublishDispatchedAsync(ShipmentDispatchedEvent dispatched, CancellationToken cancellationToken) { - Attempts.Add(dispatched); + lock (_gate) + { + Attempts.Add(dispatched); + ThrowIfFailing(); + Published.Add(dispatched); + } + return Task.CompletedTask; + } + + public Task PublishDeliveredAsync(ShipmentDeliveredEvent delivered, CancellationToken cancellationToken) + { + lock (_gate) + { + ThrowIfFailing(); + Delivered.Add(delivered); + } + return Task.CompletedTask; + } + + private void ThrowIfFailing() + { if (FailNext) { FailNext = false; throw new InvalidOperationException("Simulated broker failure"); } - Published.Add(dispatched); - return Task.CompletedTask; } } From 71e1be05767ba84f6797e306532a0fb4a52eb843 Mon Sep 17 00:00:00 2001 From: Dhruv Patel Date: Sat, 26 Sep 2026 21:23:41 -0400 Subject: [PATCH 8/8] Declare embedded Kafka topics in reservation expiry integration test --- .../ReservationExpiryCompensationIntegrationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/integration/ReservationExpiryCompensationIntegrationTest.java b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/integration/ReservationExpiryCompensationIntegrationTest.java index c760483..0ca4dad 100644 --- a/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/integration/ReservationExpiryCompensationIntegrationTest.java +++ b/shopsphere/order-service/src/test/java/com/shopsphere/orderservice/integration/ReservationExpiryCompensationIntegrationTest.java @@ -55,7 +55,7 @@ "eureka.client.enabled=false", "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}" }) -@EmbeddedKafka(partitions = 1) +@EmbeddedKafka(partitions = 1, topics = {Topics.RESERVATION_EXPIRED, Topics.ORDER_CANCELLED}) @Testcontainers(disabledWithoutDocker = true) class ReservationExpiryCompensationIntegrationTest {