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..0ca4dad --- /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, topics = {Topics.RESERVATION_EXPIRED, Topics.ORDER_CANCELLED}) +@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; } }