From e984904ec623e3cf3a3b271da792ab64f1bef40f Mon Sep 17 00:00:00 2001 From: vanilson muhongo Date: Tue, 14 Jul 2026 19:30:16 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Fase=205=20F5=20status=20=E2=80=94=20Ca?= =?UTF-8?q?tegory=20Management:=20BROWSER-VERIFIED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/orders.api.ts | 9 + frontend/src/api/types.ts | 12 +- .../src/components/order/OrderStatusBadge.tsx | 4 + frontend/src/pages/admin/AdminOrdersPage.tsx | 152 ++++++++++ frontend/src/pages/seller/OrderManagement.tsx | 112 +++++++- frontend/src/routes/index.tsx | 2 + frontend/src/routes/layouts/AdminLayout.tsx | 2 + frontend/src/utils/constants.ts | 2 + .../with/vanilson/orderservice/Order.java | 10 + .../orderservice/OrderController.java | 20 ++ .../vanilson/orderservice/OrderService.java | 86 +++++- .../vanilson/orderservice/OrderStatus.java | 31 ++- .../UpdateOrderStatusRequest.java | 19 ++ .../OrderGlobalExceptionHandler.java | 43 +++ .../V1_14__add_fulfillment_timestamps.sql | 15 + .../src/main/resources/messages.properties | 6 + .../OrderControllerSecurityTest.java | 75 +++++ .../orderservice/OrderControllerTest.java | 111 ++++++++ .../OrderServiceFindByIdTest.java | 3 +- .../OrderServiceManualStatusTest.java | 260 ++++++++++++++++++ .../orderservice/OrderStatusTest.java | 154 +++++++++++ .../bdd/OrderFulfillmentStepDefinitions.java | 162 +++++++++++ .../bdd/OrderStepDefinitions.java | 3 +- .../OrderFulfillmentIntegrationTest.java | 186 +++++++++++++ .../features/order_fulfillment.feature | 33 +++ 25 files changed, 1497 insertions(+), 15 deletions(-) create mode 100644 frontend/src/pages/admin/AdminOrdersPage.tsx create mode 100644 order-service/src/main/java/code/with/vanilson/orderservice/UpdateOrderStatusRequest.java create mode 100644 order-service/src/main/resources/db/migration/V1_14__add_fulfillment_timestamps.sql create mode 100644 order-service/src/test/java/code/with/vanilson/orderservice/OrderServiceManualStatusTest.java create mode 100644 order-service/src/test/java/code/with/vanilson/orderservice/OrderStatusTest.java create mode 100644 order-service/src/test/java/code/with/vanilson/orderservice/bdd/OrderFulfillmentStepDefinitions.java create mode 100644 order-service/src/test/java/code/with/vanilson/orderservice/integration/OrderFulfillmentIntegrationTest.java create mode 100644 order-service/src/test/resources/features/order_fulfillment.feature diff --git a/frontend/src/api/orders.api.ts b/frontend/src/api/orders.api.ts index 3bdf5a9f..383c5f39 100644 --- a/frontend/src/api/orders.api.ts +++ b/frontend/src/api/orders.api.ts @@ -1,5 +1,6 @@ import apiClient from './client'; import type { + FulfillmentStatus, OrderCreateResponse, OrderLineResponse, OrderRequest, @@ -42,4 +43,12 @@ export const ordersApi = { getMyOrders: (signal?: AbortSignal) => apiClient.get('/orders/my', { signal }).then((r) => r.data), + + // Fase 5 — SELLER (must own a line in the order) or ADMIN (any order) advances + // fulfillment. Backend accepts only SHIPPED/DELIVERED here; the saga still owns + // CONFIRMED/CANCELLED and refunds arrive via the payment saga. + updateStatus: (orderId: number, status: FulfillmentStatus) => + apiClient + .patch(`/orders/${orderId}/status`, { status }) + .then((r) => r.data), }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f7fd0a6e..683ffde0 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -147,7 +147,17 @@ export interface CartResponse { // ── Order ── export type PaymentMethod = 'PAYPAL' | 'CREDIT_CARD' | 'VISA' | 'MASTER_CARD' | 'BITCOIN'; -export type OrderStatus = 'REQUESTED' | 'INVENTORY_RESERVED' | 'CONFIRMED' | 'CANCELLED'; +export type OrderStatus = + | 'REQUESTED' + | 'INVENTORY_RESERVED' + | 'CONFIRMED' + | 'CANCELLED' + | 'SHIPPED' + | 'DELIVERED' + | 'REFUNDED'; + +/** Fase 5: the only statuses a seller/admin may set via PATCH /orders/{id}/status. */ +export type FulfillmentStatus = 'SHIPPED' | 'DELIVERED'; export interface OrderRequest { reference?: string; diff --git a/frontend/src/components/order/OrderStatusBadge.tsx b/frontend/src/components/order/OrderStatusBadge.tsx index 9ca3d2f0..016cbe97 100644 --- a/frontend/src/components/order/OrderStatusBadge.tsx +++ b/frontend/src/components/order/OrderStatusBadge.tsx @@ -6,6 +6,10 @@ const STATUS_CONFIG: Record = { INVENTORY_RESERVED: { label: 'Inventory Reserved', color: 'var(--status-warning)' }, CONFIRMED: { label: 'Confirmed', color: 'var(--status-success)' }, CANCELLED: { label: 'Cancelled', color: 'var(--status-error)' }, + // Fase 5 — fulfillment states set by a seller/admin after the saga confirms the order. + SHIPPED: { label: 'Shipped', color: 'var(--status-info)' }, + DELIVERED: { label: 'Delivered', color: 'var(--status-success)' }, + REFUNDED: { label: 'Refunded', color: 'var(--status-warning)' }, }; export default function OrderStatusBadge({ status }: { status: OrderStatus | string }) { diff --git a/frontend/src/pages/admin/AdminOrdersPage.tsx b/frontend/src/pages/admin/AdminOrdersPage.tsx new file mode 100644 index 00000000..d18dcbb6 --- /dev/null +++ b/frontend/src/pages/admin/AdminOrdersPage.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import { Box, Button, Container, Typography } from '@mui/material'; +import { LocalShipping, TaskAlt } from '@mui/icons-material'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; +import { ordersApi } from '@api/orders.api'; +import { normalizeError } from '@api/client'; +import DataTable, { type Column } from '@components/data-display/DataTable'; +import { TableSkeleton } from '@components/feedback/LoadingSkeleton'; +import ConfirmDialog from '@components/feedback/ConfirmDialog'; +import OrderStatusBadge from '@components/order/OrderStatusBadge'; +import { useUIStore } from '@stores/ui.store'; +import { QUERY_KEYS } from '@utils/constants'; +import { formatCurrency } from '@utils/format'; +import type { FulfillmentStatus, OrderResponse } from '@api/types'; + +/** + * Fase 5 — /admin/orders: every order across the platform (GET /orders is ADMIN-only). + * An admin may advance fulfillment on ANY order, unlike a seller who must own a line. + * Only the single legal next step is offered: CONFIRMED → SHIPPED → DELIVERED. + */ +function nextStep(status: string): { label: string; to: FulfillmentStatus } | null { + if (status === 'CONFIRMED') return { label: 'Mark shipped', to: 'SHIPPED' }; + if (status === 'SHIPPED') return { label: 'Mark delivered', to: 'DELIVERED' }; + return null; +} + +export default function AdminOrdersPage() { + const qc = useQueryClient(); + const addToast = useUIStore((s) => s.addToast); + + const [target, setTarget] = useState<{ order: OrderResponse; to: FulfillmentStatus } | null>(null); + + const { data: orders, isLoading } = useQuery({ + queryKey: [QUERY_KEYS.ADMIN_ORDERS], + queryFn: ({ signal }) => ordersApi.getAll(signal), + staleTime: 30_000, + }); + + const statusMut = useMutation({ + mutationFn: ({ id, status }: { id: number; status: FulfillmentStatus }) => + ordersApi.updateStatus(id, status), + onSuccess: (_, vars) => { + qc.invalidateQueries({ queryKey: [QUERY_KEYS.ADMIN_ORDERS] }); + qc.invalidateQueries({ queryKey: [QUERY_KEYS.SELLER_ORDERS] }); + qc.invalidateQueries({ queryKey: [QUERY_KEYS.ORDERS] }); + addToast({ + message: vars.status === 'SHIPPED' ? 'Order marked as shipped' : 'Order marked as delivered', + variant: 'success', + }); + setTarget(null); + }, + onError: (err: unknown) => { + addToast({ message: normalizeError(err).message, variant: 'error' }); + setTarget(null); + }, + }); + + const COLUMNS: Column[] = [ + { + key: 'reference', + label: 'Reference', + render: (r) => ( + + {r.reference} + + ), + }, + { + key: 'customerId', + label: 'Customer', + render: (r) => { + const name = [r.customerFirstname, r.customerLastname].filter(Boolean).join(' '); + return ( + + {name && {name}} + + {r.customerId} + + + ); + }, + }, + { + key: 'amount', + label: 'Amount', + align: 'right', + render: (r) => ( + + {formatCurrency(r.amount)} + + ), + }, + { key: 'paymentMethod', label: 'Payment' }, + { + key: 'status', + label: 'Status', + render: (r) => , + }, + { + key: 'fulfillment', + label: 'Fulfillment', + render: (r) => { + const step = nextStep(r.status); + if (!step) return ; + return ( + + ); + }, + }, + ]; + + return ( + + + Orders + + Every order across the platform. Fulfillment can be advanced on any order — + sellers can only advance orders containing their own products. + + + {isLoading ? ( + + ) : ( + + )} + + target && statusMut.mutate({ id: target.order.id, status: target.to })} + onCancel={() => setTarget(null)} + /> + + + ); +} diff --git a/frontend/src/pages/seller/OrderManagement.tsx b/frontend/src/pages/seller/OrderManagement.tsx index 57180677..1bbfc9ea 100644 --- a/frontend/src/pages/seller/OrderManagement.tsx +++ b/frontend/src/pages/seller/OrderManagement.tsx @@ -1,25 +1,83 @@ -import { Container, Typography } from '@mui/material'; -import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { Button, Container, Typography } from '@mui/material'; +import { LocalShipping, TaskAlt } from '@mui/icons-material'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; import { ordersApi } from '@api/orders.api'; +import { normalizeError } from '@api/client'; import { QUERY_KEYS, ROUTES } from '@utils/constants'; import DataTable from '@components/data-display/DataTable'; import { TableSkeleton } from '@components/feedback/LoadingSkeleton'; +import ConfirmDialog from '@components/feedback/ConfirmDialog'; +import OrderStatusBadge from '@components/order/OrderStatusBadge'; +import { useUIStore } from '@stores/ui.store'; import { formatCurrency } from '@utils/format'; -import type { OrderResponse } from '@api/types'; +import type { FulfillmentStatus, OrderResponse } from '@api/types'; + +/** + * Fase 5 — the seller advances fulfillment on orders containing their own products. + * The backend enforces line-ownership (403 otherwise); the UI only offers the single + * legal next step for the current status: CONFIRMED → SHIPPED → DELIVERED. + */ +function nextStep(status: string): { label: string; to: FulfillmentStatus } | null { + if (status === 'CONFIRMED') return { label: 'Mark shipped', to: 'SHIPPED' }; + if (status === 'SHIPPED') return { label: 'Mark delivered', to: 'DELIVERED' }; + return null; +} export default function OrderManagement() { const navigate = useNavigate(); + const qc = useQueryClient(); + const addToast = useUIStore((s) => s.addToast); + + const [target, setTarget] = useState<{ order: OrderResponse; to: FulfillmentStatus } | null>(null); + const { data: orders, isLoading } = useQuery({ queryKey: [QUERY_KEYS.SELLER_ORDERS], queryFn: ({ signal }) => ordersApi.getSeller(signal), staleTime: 30 * 1000, }); + const statusMut = useMutation({ + mutationFn: ({ id, status }: { id: number; status: FulfillmentStatus }) => + ordersApi.updateStatus(id, status), + onSuccess: (_, vars) => { + qc.invalidateQueries({ queryKey: [QUERY_KEYS.SELLER_ORDERS] }); + // The customer's own order views reflect the same status. + qc.invalidateQueries({ queryKey: [QUERY_KEYS.ORDERS] }); + addToast({ + message: vars.status === 'SHIPPED' ? 'Order marked as shipped' : 'Order marked as delivered', + variant: 'success', + }); + setTarget(null); + }, + onError: (err: unknown) => { + addToast({ message: normalizeError(err).message, variant: 'error' }); + setTarget(null); + }, + }); + const COLUMNS = [ - { key: 'reference', label: 'Reference', render: (r: OrderResponse) => {r.reference} }, - { key: 'amount', label: 'Amount', align: 'right' as const, render: (r: OrderResponse) => {formatCurrency(r.amount)} }, + { + key: 'reference', + label: 'Reference', + render: (r: OrderResponse) => ( + + {r.reference} + + ), + }, + { + key: 'amount', + label: 'Amount', + align: 'right' as const, + render: (r: OrderResponse) => ( + + {formatCurrency(r.amount)} + + ), + }, { key: 'paymentMethod', label: 'Payment' }, { key: 'customerId', @@ -29,7 +87,31 @@ export default function OrderManagement() { return name ? ( {name} ) : ( - {r.customerId.slice(0, 12)}… + + {r.customerId.slice(0, 12)}… + + ); + }, + }, + { + key: 'status', + label: 'Status', + render: (r: OrderResponse) => , + }, + { + key: 'fulfillment', + label: 'Fulfillment', + render: (r: OrderResponse) => { + const step = nextStep(r.status); + if (!step) return ; + return ( + ); }, }, @@ -40,7 +122,7 @@ export default function OrderManagement() { Orders {isLoading ? ( - + ) : ( navigate(ROUTES.SELLER_ORDER_DETAIL(row.id))} /> )} + + target && statusMut.mutate({ id: target.order.id, status: target.to })} + onCancel={() => setTarget(null)} + /> ); diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 2b00b211..a3841056 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -61,6 +61,7 @@ const TenantDetailPage = () => lazy_page(() => import('@pages/admin/TenantDetail const UsersPage = () => lazy_page(() => import('@pages/admin/UsersPage')); const AdminProductsPage = () => lazy_page(() => import('@pages/admin/AdminProductsPage')); const AdminCategoriesPage = () => lazy_page(() => import('@pages/admin/AdminCategoriesPage')); +const AdminOrdersPage = () => lazy_page(() => import('@pages/admin/AdminOrdersPage')); const PaymentsPage = () => lazy_page(() => import('@pages/admin/PaymentsPage')); const AnalyticsPage = () => lazy_page(() => import('@pages/admin/AnalyticsPage')); @@ -135,6 +136,7 @@ export const router = createBrowserRouter([ { path: '/admin/users', element: }, { path: '/admin/products', element: }, { path: '/admin/categories', element: }, + { path: '/admin/orders', element: }, { path: '/admin/payments', element: }, { path: '/admin/analytics', element: }, { path: '/admin/account', element: }, diff --git a/frontend/src/routes/layouts/AdminLayout.tsx b/frontend/src/routes/layouts/AdminLayout.tsx index 315846b2..50159c7e 100644 --- a/frontend/src/routes/layouts/AdminLayout.tsx +++ b/frontend/src/routes/layouts/AdminLayout.tsx @@ -11,6 +11,7 @@ import { People, Inventory2, Category, + Receipt, CreditCard, BarChart, ManageAccounts, @@ -22,6 +23,7 @@ const ADMIN_NAV = [ { label: 'Users', to: ROUTES.ADMIN_USERS, Icon: People }, { label: 'Products', to: ROUTES.ADMIN_PRODUCTS, Icon: Inventory2 }, { label: 'Categories', to: ROUTES.ADMIN_CATEGORIES, Icon: Category }, + { label: 'Orders', to: ROUTES.ADMIN_ORDERS, Icon: Receipt }, { label: 'Payments', to: ROUTES.ADMIN_PAYMENTS, Icon: CreditCard }, { label: 'Analytics', to: ROUTES.ADMIN_ANALYTICS, Icon: BarChart }, { label: 'Account', to: ROUTES.ADMIN_ACCOUNT, Icon: ManageAccounts }, diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 98fc45af..a092ba00 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -27,6 +27,7 @@ export const ROUTES = { ADMIN_USERS: '/admin/users', ADMIN_PRODUCTS: '/admin/products', ADMIN_CATEGORIES: '/admin/categories', + ADMIN_ORDERS: '/admin/orders', ADMIN_PAYMENTS: '/admin/payments', ADMIN_ANALYTICS: '/admin/analytics', ADMIN_ACCOUNT: '/admin/account', @@ -40,6 +41,7 @@ export const QUERY_KEYS = { CART: 'cart', ORDERS: 'orders', SELLER_ORDERS: 'seller-orders', + ADMIN_ORDERS: 'admin-orders', ORDER: 'order', ORDER_STATUS: 'order-status', ORDER_LINES: 'order-lines', diff --git a/order-service/src/main/java/code/with/vanilson/orderservice/Order.java b/order-service/src/main/java/code/with/vanilson/orderservice/Order.java index f2338a0a..275e8b53 100644 --- a/order-service/src/main/java/code/with/vanilson/orderservice/Order.java +++ b/order-service/src/main/java/code/with/vanilson/orderservice/Order.java @@ -150,6 +150,16 @@ public class Order { @Builder.Default private OrderStatus status = OrderStatus.REQUESTED; + /** + * Fase 5 (fulfillment) — set when a seller/admin marks the order SHIPPED / DELIVERED + * via the manual status endpoint. Nullable: legacy and not-yet-shipped orders have none. + */ + @Column(name = "shipped_at") + private LocalDateTime shippedAt; + + @Column(name = "delivered_at") + private LocalDateTime deliveredAt; + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private List orderLines; diff --git a/order-service/src/main/java/code/with/vanilson/orderservice/OrderController.java b/order-service/src/main/java/code/with/vanilson/orderservice/OrderController.java index ad1b5461..8e2b4464 100644 --- a/order-service/src/main/java/code/with/vanilson/orderservice/OrderController.java +++ b/order-service/src/main/java/code/with/vanilson/orderservice/OrderController.java @@ -11,6 +11,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -126,4 +127,23 @@ public ResponseEntity> findMyOrders() { public ResponseEntity findById(@PathVariable("order-id") Integer orderId) { return ResponseEntity.ok(orderService.findById(orderId)); } + + @Operation(summary = "Advance an order's fulfillment status (SELLER/ADMIN)", + description = "Marks an order SHIPPED or DELIVERED. ADMIN may advance any order; a SELLER " + + "must own at least one line in it. Only SHIPPED/DELIVERED are settable here — the " + + "saga remains the only path to CONFIRMED/CANCELLED and refunds arrive via the payment saga.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Status advanced"), + @ApiResponse(responseCode = "400", description = "Status not settable via this endpoint / invalid body"), + @ApiResponse(responseCode = "403", description = "Seller does not own a line in this order"), + @ApiResponse(responseCode = "404", description = "Order not found"), + @ApiResponse(responseCode = "409", description = "Illegal status transition") + }) + @PreAuthorize("hasAnyRole('SELLER','ADMIN')") + @PatchMapping("/{order-id}/status") + public ResponseEntity updateOrderStatus( + @PathVariable("order-id") Integer orderId, + @RequestBody @Valid UpdateOrderStatusRequest request) { + return ResponseEntity.ok(orderService.updateStatusManually(orderId, request.status())); + } } diff --git a/order-service/src/main/java/code/with/vanilson/orderservice/OrderService.java b/order-service/src/main/java/code/with/vanilson/orderservice/OrderService.java index 1054649e..00f26ed3 100644 --- a/order-service/src/main/java/code/with/vanilson/orderservice/OrderService.java +++ b/order-service/src/main/java/code/with/vanilson/orderservice/OrderService.java @@ -10,8 +10,11 @@ import code.with.vanilson.orderservice.exception.OrderForbiddenException; import code.with.vanilson.orderservice.exception.OrderIllegalStateTransitionException; import code.with.vanilson.orderservice.exception.OrderNotFoundException; +import code.with.vanilson.orderservice.exception.OrderValidationException; +import code.with.vanilson.orderservice.event.OrderStatusChangedEvent; import code.with.vanilson.tenantcontext.security.SecurityPrincipal; import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.context.ApplicationEventPublisher; import code.with.vanilson.orderservice.kafka.OrderRequestedEvent; import code.with.vanilson.orderservice.orderLine.OrderLineRequest; import code.with.vanilson.orderservice.orderLine.OrderLineService; @@ -31,6 +34,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -68,6 +72,7 @@ public class OrderService { private final ObjectMapper objectMapper; private final TenantHibernateFilterActivator filterActivator; private final MeterRegistry meterRegistry; + private final ApplicationEventPublisher eventPublisher; public OrderService(OrderRepository orderRepository, OrderMapper orderMapper, @@ -77,7 +82,8 @@ public OrderService(OrderRepository orderRepository, OutboxRepository outboxRepository, MessageSource messageSource, TenantHibernateFilterActivator filterActivator, - MeterRegistry meterRegistry) { + MeterRegistry meterRegistry, + ApplicationEventPublisher eventPublisher) { this.orderRepository = orderRepository; this.orderMapper = orderMapper; this.customerClient = customerClient; @@ -87,6 +93,7 @@ public OrderService(OrderRepository orderRepository, this.messageSource = messageSource; this.filterActivator = filterActivator; this.meterRegistry = meterRegistry; + this.eventPublisher = eventPublisher; // ObjectMapper with JSR310 for Instant serialisation this.objectMapper = new ObjectMapper(); this.objectMapper.registerModule(new JavaTimeModule()); @@ -245,6 +252,83 @@ public void updateStatus(String correlationId, OrderStatus newStatus) { }); } + // ------------------------------------------------------- + // MANUAL FULFILLMENT STATUS UPDATE (Fase 5) — seller/admin driven + // ------------------------------------------------------- + + /** + * Advances an order's fulfillment status via the manual endpoint + * ({@code PATCH /api/v1/orders/{id}/status}). Distinct from {@link #updateStatus} (the + * Kafka saga path): this is caller-authorised and keyed by the internal {@code orderId}. + *

+ * Rules: + *

    + *
  • Whitelist — only {@code SHIPPED} / {@code DELIVERED} are settable here; the saga + * remains the only path to CONFIRMED/CANCELLED and refunds arrive via the payment saga + * (Fase 6), so any other target → {@code 400 order.status.update.not.allowed}.
  • + *
  • Authorisation — ADMIN may advance any order; a SELLER must own at least one line + * in it ({@code order_line.seller_id}), else {@code 403 order.status.update.forbidden}.
  • + *
  • Transition — must satisfy {@link OrderStatus#canTransitionTo} + * (e.g. DELIVERED before SHIPPED → {@code 409 order.status.transition.invalid}).
  • + *
+ * Records the fulfillment timestamp and republishes {@link OrderStatusChangedEvent} so the + * existing SSE stream / notification listeners react exactly as they do for saga updates. + * Idempotent: re-sending the current status is a no-op that returns the order unchanged. + */ + @Transactional + public OrderResponse updateStatusManually(Integer orderId, OrderStatus newStatus) { + if (newStatus != OrderStatus.SHIPPED && newStatus != OrderStatus.DELIVERED) { + throw new OrderValidationException( + msg("order.status.update.not.allowed", newStatus), + "order.status.update.not.allowed"); + } + + filterActivator.activateFilter(); + Order order = (TenantContext.isPresent() + ? orderRepository.findByOrderIdAndTenantId(orderId, TenantContext.getCurrentTenantId()) + : orderRepository.findById(orderId)) + .orElseThrow(() -> new OrderNotFoundException( + msg("order.not.found", orderId), "order.not.found")); + + SecurityPrincipal principal = currentPrincipal(); + if (principal != null && !"ADMIN".equals(principal.role())) { + boolean sellerOwnsLine = principal.isSeller() + && orderLineService.sellerOwnsLineInOrder(orderId, String.valueOf(principal.userId())); + if (!sellerOwnsLine) { + throw new OrderForbiddenException( + msg("order.status.update.forbidden"), "order.status.update.forbidden"); + } + } + + OrderStatus current = order.getStatus(); + if (current == newStatus) { + log.info("[OrderService] Manual status already {} — no-op: orderId=[{}]", newStatus, orderId); + CustomerSnapshot snap = snapshotRepository.findById(order.getCustomerId()).orElse(null); + return orderMapper.fromOrder(order, snap); + } + if (!current.canTransitionTo(newStatus)) { + throw new OrderIllegalStateTransitionException( + msg("order.status.transition.invalid", current, newStatus, order.getCorrelationId()), + "order.status.transition.invalid"); + } + + order.setStatus(newStatus); + if (newStatus == OrderStatus.SHIPPED) { + order.setShippedAt(LocalDateTime.now()); + } else { + order.setDeliveredAt(LocalDateTime.now()); + } + Order saved = orderRepository.save(order); + + log.info("[OrderService] Manual fulfillment update: orderId=[{}] {} → {}", + orderId, current, newStatus); + eventPublisher.publishEvent(new OrderStatusChangedEvent( + saved.getCorrelationId(), newStatus.name(), saved.getReference(), Instant.now())); + + CustomerSnapshot snapshot = snapshotRepository.findById(saved.getCustomerId()).orElse(null); + return orderMapper.fromOrder(saved, snapshot); + } + // ------------------------------------------------------- // READ // ------------------------------------------------------- diff --git a/order-service/src/main/java/code/with/vanilson/orderservice/OrderStatus.java b/order-service/src/main/java/code/with/vanilson/orderservice/OrderStatus.java index 72e52114..2f8c38eb 100644 --- a/order-service/src/main/java/code/with/vanilson/orderservice/OrderStatus.java +++ b/order-service/src/main/java/code/with/vanilson/orderservice/OrderStatus.java @@ -12,10 +12,17 @@ *

* PENDING_PAYMENT: payment-service was unreachable — order is persisted, * payment retried from DLQ when payment-service recovers. + *

+ * Fase 5 (fulfillment) extends the machine PAST confirmation. CONFIRMED is no longer + * terminal — a seller/admin advances it manually: + * CONFIRMED → SHIPPED → DELIVERED, and any of CONFIRMED/SHIPPED/DELIVERED → REFUNDED + * (refund arrives via the saga in Fase 6). REFUNDED and CANCELLED are terminal. + * The saga path (Kafka) still only ever drives an order up to CONFIRMED/CANCELLED; + * SHIPPED/DELIVERED are reachable ONLY through the manual PATCH endpoint (Task 5.2). *

* * @author vamuhong - * @version 3.0 + * @version 4.0 */ public enum OrderStatus { @@ -44,12 +51,25 @@ public enum OrderStatus { PENDING_PAYMENT, /** Saga took too long to complete — order cancelled. */ - TIMEOUT; + TIMEOUT, + + /** Fulfillment: seller/admin marked the confirmed order as shipped. */ + SHIPPED, + + /** Fulfillment: order delivered to the customer. */ + DELIVERED, + + /** Order refunded (Fase 6, arrives via the payment saga). Terminal. */ + REFUNDED; /** - * Explicit allowed transitions. CONFIRMED and CANCELLED are terminal. + * Explicit allowed transitions. CANCELLED and REFUNDED are terminal. * REQUESTED may jump straight to CONFIRMED/CANCELLED because the current * choreography does not persist the intermediate saga states. + *

+ * Fase 5: CONFIRMED is no longer terminal — it advances through the fulfillment + * chain (SHIPPED → DELIVERED) via the manual status endpoint, and any post-confirmation + * state can be REFUNDED. */ public boolean canTransitionTo(OrderStatus target) { return switch (this) { @@ -74,7 +94,10 @@ public boolean canTransitionTo(OrderStatus target) { || target == CANCELLED || target == TIMEOUT; case PAYMENT_FAILED, INVENTORY_INSUFFICIENT, TIMEOUT -> target == CANCELLED; - case CONFIRMED, CANCELLED -> false; + case CONFIRMED -> target == SHIPPED || target == REFUNDED; + case SHIPPED -> target == DELIVERED || target == REFUNDED; + case DELIVERED -> target == REFUNDED; + case CANCELLED, REFUNDED -> false; }; } } diff --git a/order-service/src/main/java/code/with/vanilson/orderservice/UpdateOrderStatusRequest.java b/order-service/src/main/java/code/with/vanilson/orderservice/UpdateOrderStatusRequest.java new file mode 100644 index 00000000..eca65b2b --- /dev/null +++ b/order-service/src/main/java/code/with/vanilson/orderservice/UpdateOrderStatusRequest.java @@ -0,0 +1,19 @@ +package code.with.vanilson.orderservice; + +import jakarta.validation.constraints.NotNull; + +/** + * UpdateOrderStatusRequest — Presentation DTO (Fase 5, fulfillment). + *

+ * Body of {@code PATCH /api/v1/orders/{order-id}/status}. Only the fulfillment states + * {@code SHIPPED} / {@code DELIVERED} are accepted through this endpoint — the whitelist is + * enforced in {@link OrderService#updateStatusManually}. An unknown enum name (e.g. + * {@code "PAUSED"}) fails Jackson binding and is mapped to {@code 400 order.status.invalid} + * by the global handler; a missing value fails {@code @NotNull} → {@code 400}. + * + * @author vamuhong + * @version 1.0 + */ +public record UpdateOrderStatusRequest( + @NotNull(message = "{order.status.required}") OrderStatus status) { +} diff --git a/order-service/src/main/java/code/with/vanilson/orderservice/exception/OrderGlobalExceptionHandler.java b/order-service/src/main/java/code/with/vanilson/orderservice/exception/OrderGlobalExceptionHandler.java index 2accb4a2..c1ea874b 100644 --- a/order-service/src/main/java/code/with/vanilson/orderservice/exception/OrderGlobalExceptionHandler.java +++ b/order-service/src/main/java/code/with/vanilson/orderservice/exception/OrderGlobalExceptionHandler.java @@ -1,14 +1,18 @@ package code.with.vanilson.orderservice.exception; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import lombok.extern.slf4j.Slf4j; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.access.AccessDeniedException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.servlet.resource.NoResourceFoundException; + +import code.with.vanilson.orderservice.OrderStatus; import code.with.vanilson.tenantcontext.exception.MissingTenantException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -152,6 +156,45 @@ public ResponseEntity> handleMissingTenant( return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), "order.tenant.missing", request); } + /** + * Fallback for any {@link OrderBaseException} subtype that lacks a dedicated handler + * (e.g. {@link OrderIllegalStateTransitionException} → 409). Maps the exception's own + * {@code httpStatus} + message key — without this the subtype would fall through to the + * generic {@code Exception} handler and be masked as a 500. Specific handlers above still + * win by type specificity, so their behaviour is unchanged. + */ + @ExceptionHandler(OrderBaseException.class) + public ResponseEntity> handleOrderBase( + OrderBaseException ex, WebRequest request) { + log.warn("[OrderExceptionHandler] Order error: key=[{}] status=[{}] message=[{}]", + ex.getMessageKey(), ex.getHttpStatus().value(), ex.getMessage()); + return buildResponse(ex.getHttpStatus(), ex.getMessage(), ex.getMessageKey(), request); + } + + /** + * Handles an unreadable/malformed JSON body. The common case on the fulfillment endpoint is + * an unknown {@link OrderStatus} name (e.g. {@code {"status":"PAUSED"}}), which Jackson raises + * as {@link InvalidFormatException} — mapped to {@code 400 order.status.invalid}. Any other + * unreadable body → generic {@code 400 order.request.unreadable}. Without this the request + * would fall to the 500 catch-all. + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleUnreadableBody( + HttpMessageNotReadableException ex, WebRequest request) { + if (ex.getCause() instanceof InvalidFormatException ife + && ife.getTargetType() != null + && ife.getTargetType().isAssignableFrom(OrderStatus.class)) { + String message = messageSource.getMessage( + "order.status.invalid", null, LocaleContextHolder.getLocale()); + log.warn("[OrderExceptionHandler] Invalid order status value in request body"); + return buildResponse(HttpStatus.BAD_REQUEST, message, "order.status.invalid", request); + } + String message = messageSource.getMessage( + "order.request.unreadable", null, LocaleContextHolder.getLocale()); + log.warn("[OrderExceptionHandler] Unreadable request body: {}", ex.getMessage()); + return buildResponse(HttpStatus.BAD_REQUEST, message, "order.request.unreadable", request); + } + /** * Handles NullPointerException specifically. * NPEs are always bugs and should be logged with priority but clean format. diff --git a/order-service/src/main/resources/db/migration/V1_14__add_fulfillment_timestamps.sql b/order-service/src/main/resources/db/migration/V1_14__add_fulfillment_timestamps.sql new file mode 100644 index 00000000..730f00da --- /dev/null +++ b/order-service/src/main/resources/db/migration/V1_14__add_fulfillment_timestamps.sql @@ -0,0 +1,15 @@ +-- V1_14__add_fulfillment_timestamps.sql +-- Fase 5 (order fulfillment). OrderStatus gains SHIPPED / DELIVERED / REFUNDED and CONFIRMED +-- stops being terminal. When a seller/admin advances a confirmed order we record WHEN it was +-- shipped and delivered, so the invoice/tracking view can show the fulfillment timeline. +-- +-- Both nullable: legacy orders and any order not yet shipped have no timestamp. The status +-- column itself is unchanged (already VARCHAR(30), wide enough for the new enum names). + +BEGIN; + +ALTER TABLE customer_order + ADD COLUMN IF NOT EXISTS shipped_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMP; + +COMMIT; diff --git a/order-service/src/main/resources/messages.properties b/order-service/src/main/resources/messages.properties index 5bc95abe..c7ff607a 100644 --- a/order-service/src/main/resources/messages.properties +++ b/order-service/src/main/resources/messages.properties @@ -69,3 +69,9 @@ order.log.circuit.breaker.payment=Circuit breaker activated for payment-service. order.log.circuit.breaker.product=Circuit breaker activated for product-service. Order [{0}] in fallback. order.log.circuit.breaker.customer=Circuit breaker activated for customer-service. Order [{0}] in fallback. order.status.transition.invalid=Invalid order status transition {0} → {1} for correlationId=[{2}] +# Fase 5 — manual fulfillment status update (PATCH /orders/{id}/status) +order.status.required=Order status is required. +order.status.invalid=Invalid order status value. Allowed fulfillment values are SHIPPED and DELIVERED. +order.status.update.not.allowed=Order status [{0}] cannot be set through this endpoint. Only SHIPPED and DELIVERED are allowed. +order.status.update.forbidden=You are not allowed to update this order. Only an ADMIN or a seller with a line in the order can change its fulfillment status. +order.request.unreadable=The request body could not be read. Please check the format and try again. diff --git a/order-service/src/test/java/code/with/vanilson/orderservice/OrderControllerSecurityTest.java b/order-service/src/test/java/code/with/vanilson/orderservice/OrderControllerSecurityTest.java index 8c5d7a11..64fb6038 100644 --- a/order-service/src/test/java/code/with/vanilson/orderservice/OrderControllerSecurityTest.java +++ b/order-service/src/test/java/code/with/vanilson/orderservice/OrderControllerSecurityTest.java @@ -36,6 +36,7 @@ import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -314,4 +315,78 @@ void not_found_returns_404() throws Exception { .andExpect(status().isNotFound()); } } + + // ------------------------------------------------------- + // PATCH /orders/{id}/status — fulfillment (SELLER or ADMIN) — Fase 5 + // ------------------------------------------------------- + + @Nested + @DisplayName("PATCH /orders/{id}/status — fulfillment (SELLER or ADMIN)") + class PatchStatus { + + private static final String STATUS_BODY = "{\"status\":\"SHIPPED\"}"; + + @Test + @DisplayName("401 when unauthenticated") + void unauthenticated_gets_401() throws Exception { + mockMvc.perform(patch(BASE + "/1/status") + .header(TENANT_HDR, TENANT_VAL) + .contentType(MediaType.APPLICATION_JSON) + .content(STATUS_BODY)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("403 when USER tries to advance fulfillment status") + void user_cannot_update_status() throws Exception { + mockMvc.perform(patch(BASE + "/1/status") + .header(TENANT_HDR, TENANT_VAL) + .contentType(MediaType.APPLICATION_JSON) + .content(STATUS_BODY) + .with(authentication(authAs(42L, "USER")))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("200 when SELLER advances status (service authorises line ownership)") + void seller_updates_status() throws Exception { + when(orderService.updateStatusManually(anyInt(), any())).thenReturn( + new OrderResponse(1, "REF-001", BigDecimal.valueOf(100), "CREDIT_CARD", "42", "SHIPPED")); + + mockMvc.perform(patch(BASE + "/1/status") + .header(TENANT_HDR, TENANT_VAL) + .contentType(MediaType.APPLICATION_JSON) + .content(STATUS_BODY) + .with(authentication(authAs(7L, "SELLER")))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("200 when ADMIN advances status") + void admin_updates_status() throws Exception { + when(orderService.updateStatusManually(anyInt(), any())).thenReturn( + new OrderResponse(1, "REF-001", BigDecimal.valueOf(100), "CREDIT_CARD", "42", "SHIPPED")); + + mockMvc.perform(patch(BASE + "/1/status") + .header(TENANT_HDR, TENANT_VAL) + .contentType(MediaType.APPLICATION_JSON) + .content(STATUS_BODY) + .with(authentication(authAs(1L, "ADMIN")))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("403 when SELLER does not own a line (service throws)") + void seller_without_line_gets_403() throws Exception { + doThrow(new OrderForbiddenException("order.status.update.forbidden", "order.status.update.forbidden")) + .when(orderService).updateStatusManually(anyInt(), any()); + + mockMvc.perform(patch(BASE + "/9/status") + .header(TENANT_HDR, TENANT_VAL) + .contentType(MediaType.APPLICATION_JSON) + .content(STATUS_BODY) + .with(authentication(authAs(7L, "SELLER")))) + .andExpect(status().isForbidden()); + } + } } diff --git a/order-service/src/test/java/code/with/vanilson/orderservice/OrderControllerTest.java b/order-service/src/test/java/code/with/vanilson/orderservice/OrderControllerTest.java index bfd664c1..2bc89f99 100644 --- a/order-service/src/test/java/code/with/vanilson/orderservice/OrderControllerTest.java +++ b/order-service/src/test/java/code/with/vanilson/orderservice/OrderControllerTest.java @@ -1,6 +1,8 @@ package code.with.vanilson.orderservice; import code.with.vanilson.orderservice.exception.CustomerServiceUnavailableException; +import code.with.vanilson.orderservice.exception.OrderForbiddenException; +import code.with.vanilson.orderservice.exception.OrderIllegalStateTransitionException; import code.with.vanilson.orderservice.exception.OrderNotFoundException; import code.with.vanilson.orderservice.payment.PaymentMethod; import code.with.vanilson.orderservice.product.ProductPurchaseRequest; @@ -27,8 +29,10 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -285,4 +289,111 @@ void shouldReturn404WhenNotFound() throws Exception { .andExpect(status().isNotFound()); } } + + // ------------------------------------------------------- + // PATCH /api/v1/orders/{order-id}/status (Fase 5 fulfillment) + // ------------------------------------------------------- + @Nested + @DisplayName("PATCH /api/v1/orders/{order-id}/status") + class UpdateOrderStatus { + + @Test + @DisplayName("should return 200 with updated order when status is SHIPPED") + void shouldReturn200WhenShipped() throws Exception { + OrderResponse shipped = new OrderResponse( + 42, "REF-001", BigDecimal.valueOf(299.99), "CREDIT_CARD", "cust-001", "SHIPPED"); + when(orderService.updateStatusManually(eq(42), eq(OrderStatus.SHIPPED))).thenReturn(shipped); + + mockMvc.perform(patch("/api/v1/orders/{order-id}/status", 42) + .header("X-Tenant-ID", "test-tenant-123") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new UpdateOrderStatusRequest(OrderStatus.SHIPPED)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(42))) + .andExpect(jsonPath("$.status", is("SHIPPED"))); + } + + @Test + @DisplayName("should return 400 when status value is unknown (bad enum body)") + void shouldReturn400WhenStatusValueUnknown() throws Exception { + mockMvc.perform(patch("/api/v1/orders/{order-id}/status", 42) + .header("X-Tenant-ID", "test-tenant-123") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"PAUSED\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode", is("order.status.invalid"))); + } + + @Test + @DisplayName("should return 400 when status is missing (@NotNull)") + void shouldReturn400WhenStatusMissing() throws Exception { + mockMvc.perform(patch("/api/v1/orders/{order-id}/status", 42) + .header("X-Tenant-ID", "test-tenant-123") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("should return 400 when status not settable via this endpoint") + void shouldReturn400WhenStatusNotAllowed() throws Exception { + when(orderService.updateStatusManually(eq(42), eq(OrderStatus.CONFIRMED))) + .thenThrow(new code.with.vanilson.orderservice.exception.OrderValidationException( + "not allowed", "order.status.update.not.allowed")); + + mockMvc.perform(patch("/api/v1/orders/{order-id}/status", 42) + .header("X-Tenant-ID", "test-tenant-123") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new UpdateOrderStatusRequest(OrderStatus.CONFIRMED)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode", is("order.status.update.not.allowed"))); + } + + @Test + @DisplayName("should return 403 when seller does not own a line in the order") + void shouldReturn403WhenSellerNotOwner() throws Exception { + when(orderService.updateStatusManually(eq(42), eq(OrderStatus.SHIPPED))) + .thenThrow(new OrderForbiddenException("forbidden", "order.status.update.forbidden")); + + mockMvc.perform(patch("/api/v1/orders/{order-id}/status", 42) + .header("X-Tenant-ID", "test-tenant-123") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new UpdateOrderStatusRequest(OrderStatus.SHIPPED)))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode", is("order.status.update.forbidden"))); + } + + @Test + @DisplayName("should return 404 when order does not exist") + void shouldReturn404WhenOrderMissing() throws Exception { + when(orderService.updateStatusManually(eq(999), eq(OrderStatus.SHIPPED))) + .thenThrow(new OrderNotFoundException("not found", "order.not.found")); + + mockMvc.perform(patch("/api/v1/orders/{order-id}/status", 999) + .header("X-Tenant-ID", "test-tenant-123") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new UpdateOrderStatusRequest(OrderStatus.SHIPPED)))) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("should return 409 when the transition is illegal (maps OrderBaseException status)") + void shouldReturn409WhenIllegalTransition() throws Exception { + when(orderService.updateStatusManually(eq(42), eq(OrderStatus.DELIVERED))) + .thenThrow(new OrderIllegalStateTransitionException( + "illegal", "order.status.transition.invalid")); + + mockMvc.perform(patch("/api/v1/orders/{order-id}/status", 42) + .header("X-Tenant-ID", "test-tenant-123") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new UpdateOrderStatusRequest(OrderStatus.DELIVERED)))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.errorCode", is("order.status.transition.invalid"))); + } + } } diff --git a/order-service/src/test/java/code/with/vanilson/orderservice/OrderServiceFindByIdTest.java b/order-service/src/test/java/code/with/vanilson/orderservice/OrderServiceFindByIdTest.java index bfc9a2aa..decf933b 100644 --- a/order-service/src/test/java/code/with/vanilson/orderservice/OrderServiceFindByIdTest.java +++ b/order-service/src/test/java/code/with/vanilson/orderservice/OrderServiceFindByIdTest.java @@ -72,7 +72,8 @@ void setUp() { lenient().when(snapshotRepository.findById(anyString())).thenReturn(Optional.empty()); orderService = new OrderService(orderRepository, orderMapper, customerClient, snapshotRepository, - orderLineService, outboxRepository, messageSource, filterActivator, meterRegistry); + orderLineService, outboxRepository, messageSource, filterActivator, meterRegistry, + mock(org.springframework.context.ApplicationEventPublisher.class)); } @AfterEach diff --git a/order-service/src/test/java/code/with/vanilson/orderservice/OrderServiceManualStatusTest.java b/order-service/src/test/java/code/with/vanilson/orderservice/OrderServiceManualStatusTest.java new file mode 100644 index 00000000..5e7f2331 --- /dev/null +++ b/order-service/src/test/java/code/with/vanilson/orderservice/OrderServiceManualStatusTest.java @@ -0,0 +1,260 @@ +package code.with.vanilson.orderservice; + +import code.with.vanilson.orderservice.customer.CustomerClient; +import code.with.vanilson.orderservice.customer.CustomerSnapshotRepository; +import code.with.vanilson.orderservice.event.OrderStatusChangedEvent; +import code.with.vanilson.orderservice.exception.OrderForbiddenException; +import code.with.vanilson.orderservice.exception.OrderIllegalStateTransitionException; +import code.with.vanilson.orderservice.exception.OrderNotFoundException; +import code.with.vanilson.orderservice.exception.OrderValidationException; +import code.with.vanilson.orderservice.orderLine.OrderLineService; +import code.with.vanilson.orderservice.outbox.OutboxRepository; +import code.with.vanilson.orderservice.payment.PaymentMethod; +import code.with.vanilson.tenantcontext.TenantContext; +import code.with.vanilson.tenantcontext.TenantHibernateFilterActivator; +import code.with.vanilson.tenantcontext.security.SecurityPrincipal; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.MessageSource; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.math.BigDecimal; +import java.util.Locale; +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.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * OrderServiceManualStatusTest — Unit tests for the Fase 5 manual fulfillment update + * ({@link OrderService#updateStatusManually}). + *

+ * Covers the whitelist (only SHIPPED/DELIVERED), authorisation (ADMIN any / SELLER must own a + * line / SELLER without a line → 403), the transition guard (illegal transition → 409), + * timestamp stamping, event publication for SSE, and idempotent no-op on a repeated status. + * No tenant is bound, so the plain {@code findById} lookup path is exercised. + * + * @author vamuhong + */ +@DisplayName("OrderService.updateStatusManually — fulfillment (unit)") +class OrderServiceManualStatusTest { + + private static final Integer ORDER_ID = 1; + + private OrderRepository orderRepository; + private OrderMapper orderMapper; + private OrderLineService orderLineService; + private CustomerSnapshotRepository snapshotRepository; + private ApplicationEventPublisher eventPublisher; + private OrderService orderService; + + @BeforeEach + void setUp() { + // updateStatusManually reads the AMBIENT tenant to choose its repo call + // (findByOrderIdAndTenantId vs findById). TenantContext is a ThreadLocal, so a + // previous test on this thread could leak one in. Clear it so these tests + // deterministically exercise the plain findById branch they stub. + TenantContext.clear(); + SecurityContextHolder.clearContext(); + + orderRepository = mock(OrderRepository.class); + orderMapper = mock(OrderMapper.class); + CustomerClient customerClient = mock(CustomerClient.class); + snapshotRepository = mock(CustomerSnapshotRepository.class); + orderLineService = mock(OrderLineService.class); + OutboxRepository outboxRepository = mock(OutboxRepository.class); + MessageSource messageSource = mock(MessageSource.class); + TenantHibernateFilterActivator filterActivator = mock(TenantHibernateFilterActivator.class); + MeterRegistry meterRegistry = mock(MeterRegistry.class); + eventPublisher = mock(ApplicationEventPublisher.class); + + lenient().when(messageSource.getMessage(anyString(), any(), any(Locale.class))) + .thenAnswer(inv -> inv.getArgument(0)); + lenient().when(snapshotRepository.findById(anyString())).thenReturn(Optional.empty()); + lenient().when(orderMapper.fromOrder(any(), any())).thenAnswer(inv -> { + Order o = inv.getArgument(0); + return new OrderResponse(o.getOrderId(), o.getReference(), o.getTotalAmount(), + "CREDIT_CARD", o.getCustomerId(), o.getStatus().name()); + }); + + orderService = new OrderService(orderRepository, orderMapper, customerClient, snapshotRepository, + orderLineService, outboxRepository, messageSource, filterActivator, meterRegistry, + eventPublisher); + } + + @AfterEach + void clear() { + TenantContext.clear(); + SecurityContextHolder.clearContext(); + } + + private static Order orderWithStatus(OrderStatus status) { + return Order.builder() + .orderId(ORDER_ID) + .tenantId("default") + .correlationId("corr-1") + .reference("ORD-1") + .totalAmount(BigDecimal.valueOf(99.90)) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .customerId("42") + .status(status) + .build(); + } + + private void authenticateAs(long userId, String role) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + new SecurityPrincipal("user@test.com", userId, "default", role), null)); + } + + @Nested + @DisplayName("Whitelist") + class Whitelist { + + @Test + @DisplayName("rejects a non-fulfillment status (CONFIRMED) with 400 before any lookup") + void rejectsNonFulfillmentStatus() { + authenticateAs(1L, "ADMIN"); + + assertThatThrownBy(() -> orderService.updateStatusManually(ORDER_ID, OrderStatus.CONFIRMED)) + .isInstanceOf(OrderValidationException.class) + .hasMessageContaining("order.status.update.not.allowed"); + + verify(orderRepository, never()).findById(anyInt()); + } + + @Test + @DisplayName("rejects REFUNDED via this endpoint (400)") + void rejectsRefunded() { + authenticateAs(1L, "ADMIN"); + + assertThatThrownBy(() -> orderService.updateStatusManually(ORDER_ID, OrderStatus.REFUNDED)) + .isInstanceOf(OrderValidationException.class); + } + } + + @Nested + @DisplayName("Authorisation") + class Authorisation { + + @Test + @DisplayName("ADMIN advances any confirmed order to SHIPPED — stamps shippedAt + publishes event") + void adminShipsConfirmedOrder() { + authenticateAs(1L, "ADMIN"); + Order order = orderWithStatus(OrderStatus.CONFIRMED); + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + OrderResponse response = orderService.updateStatusManually(ORDER_ID, OrderStatus.SHIPPED); + + assertThat(response.status()).isEqualTo("SHIPPED"); + assertThat(order.getStatus()).isEqualTo(OrderStatus.SHIPPED); + assertThat(order.getShippedAt()).isNotNull(); + verify(orderRepository).save(order); + verify(eventPublisher).publishEvent(any(OrderStatusChangedEvent.class)); + } + + @Test + @DisplayName("SELLER owning a line in the order may advance it") + void sellerOwningLineShipsOrder() { + authenticateAs(7L, "SELLER"); + Order order = orderWithStatus(OrderStatus.CONFIRMED); + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.of(order)); + when(orderLineService.sellerOwnsLineInOrder(ORDER_ID, "7")).thenReturn(true); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + OrderResponse response = orderService.updateStatusManually(ORDER_ID, OrderStatus.SHIPPED); + + assertThat(response.status()).isEqualTo("SHIPPED"); + verify(orderRepository).save(order); + } + + @Test + @DisplayName("SELLER without a line in the order is forbidden (403)") + void sellerWithoutLineForbidden() { + authenticateAs(8L, "SELLER"); + Order order = orderWithStatus(OrderStatus.CONFIRMED); + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.of(order)); + when(orderLineService.sellerOwnsLineInOrder(ORDER_ID, "8")).thenReturn(false); + + assertThatThrownBy(() -> orderService.updateStatusManually(ORDER_ID, OrderStatus.SHIPPED)) + .isInstanceOf(OrderForbiddenException.class) + .hasMessageContaining("order.status.update.forbidden"); + + verify(orderRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + } + } + + @Nested + @DisplayName("Transition guard + lifecycle") + class TransitionGuard { + + @Test + @DisplayName("throws 404 when the order does not exist") + void notFound() { + authenticateAs(1L, "ADMIN"); + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> orderService.updateStatusManually(ORDER_ID, OrderStatus.SHIPPED)) + .isInstanceOf(OrderNotFoundException.class); + } + + @Test + @DisplayName("rejects an illegal transition (REQUESTED → SHIPPED) with 409") + void illegalTransition() { + authenticateAs(1L, "ADMIN"); + Order order = orderWithStatus(OrderStatus.REQUESTED); + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.of(order)); + + assertThatThrownBy(() -> orderService.updateStatusManually(ORDER_ID, OrderStatus.SHIPPED)) + .isInstanceOf(OrderIllegalStateTransitionException.class) + .hasMessageContaining("order.status.transition.invalid"); + + verify(orderRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("SHIPPED → DELIVERED stamps deliveredAt") + void deliveredStampsTimestamp() { + authenticateAs(1L, "ADMIN"); + Order order = orderWithStatus(OrderStatus.SHIPPED); + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + orderService.updateStatusManually(ORDER_ID, OrderStatus.DELIVERED); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.DELIVERED); + assertThat(order.getDeliveredAt()).isNotNull(); + } + + @Test + @DisplayName("re-sending the current status is an idempotent no-op (no save, no event)") + void idempotentNoOp() { + authenticateAs(1L, "ADMIN"); + Order order = orderWithStatus(OrderStatus.SHIPPED); + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.of(order)); + + OrderResponse response = orderService.updateStatusManually(ORDER_ID, OrderStatus.SHIPPED); + + assertThat(response.status()).isEqualTo("SHIPPED"); + verify(orderRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + } + } +} diff --git a/order-service/src/test/java/code/with/vanilson/orderservice/OrderStatusTest.java b/order-service/src/test/java/code/with/vanilson/orderservice/OrderStatusTest.java new file mode 100644 index 00000000..b92eac47 --- /dev/null +++ b/order-service/src/test/java/code/with/vanilson/orderservice/OrderStatusTest.java @@ -0,0 +1,154 @@ +package code.with.vanilson.orderservice; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.EnumSet; +import java.util.Set; + +import static code.with.vanilson.orderservice.OrderStatus.CANCELLED; +import static code.with.vanilson.orderservice.OrderStatus.CONFIRMED; +import static code.with.vanilson.orderservice.OrderStatus.DELIVERED; +import static code.with.vanilson.orderservice.OrderStatus.INVENTORY_INSUFFICIENT; +import static code.with.vanilson.orderservice.OrderStatus.INVENTORY_RESERVED; +import static code.with.vanilson.orderservice.OrderStatus.PAYMENT_AUTHORIZED; +import static code.with.vanilson.orderservice.OrderStatus.PAYMENT_FAILED; +import static code.with.vanilson.orderservice.OrderStatus.PENDING_PAYMENT; +import static code.with.vanilson.orderservice.OrderStatus.REFUNDED; +import static code.with.vanilson.orderservice.OrderStatus.REQUESTED; +import static code.with.vanilson.orderservice.OrderStatus.SHIPPED; +import static code.with.vanilson.orderservice.OrderStatus.TIMEOUT; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * OrderStatusTest — Unit tests for the {@link OrderStatus#canTransitionTo} state machine (Fase 5). + *

+ * Fase 5 extends the machine past confirmation: CONFIRMED stops being terminal and gains a + * fulfillment chain (SHIPPED → DELIVERED) plus a refund exit (→ REFUNDED) reachable from any + * post-confirmation state. These tests pin BOTH the new transitions AND — critically — that the + * pre-existing saga transitions are unchanged, so growing the enum never silently reopens a + * terminal state or breaks the choreography path. + * + * @author vamuhong + */ +@DisplayName("OrderStatus.canTransitionTo — state machine (unit)") +class OrderStatusTest { + + @Nested + @DisplayName("Fase 5 fulfillment transitions") + class FulfillmentTransitions { + + @Test + @DisplayName("CONFIRMED advances only to SHIPPED or REFUNDED") + void confirmedAdvancesToShippedOrRefunded() { + assertThat(CONFIRMED.canTransitionTo(SHIPPED)).isTrue(); + assertThat(CONFIRMED.canTransitionTo(REFUNDED)).isTrue(); + assertThat(CONFIRMED.canTransitionTo(DELIVERED)).isFalse(); + assertThat(CONFIRMED.canTransitionTo(CANCELLED)).isFalse(); + assertThat(CONFIRMED.canTransitionTo(REQUESTED)).isFalse(); + } + + @Test + @DisplayName("SHIPPED advances only to DELIVERED or REFUNDED") + void shippedAdvancesToDeliveredOrRefunded() { + assertThat(SHIPPED.canTransitionTo(DELIVERED)).isTrue(); + assertThat(SHIPPED.canTransitionTo(REFUNDED)).isTrue(); + assertThat(SHIPPED.canTransitionTo(CONFIRMED)).isFalse(); + assertThat(SHIPPED.canTransitionTo(CANCELLED)).isFalse(); + } + + @Test + @DisplayName("DELIVERED advances only to REFUNDED") + void deliveredAdvancesToRefundedOnly() { + assertThat(DELIVERED.canTransitionTo(REFUNDED)).isTrue(); + assertThat(DELIVERED.canTransitionTo(SHIPPED)).isFalse(); + assertThat(DELIVERED.canTransitionTo(CONFIRMED)).isFalse(); + assertThat(DELIVERED.canTransitionTo(CANCELLED)).isFalse(); + } + } + + @Nested + @DisplayName("Terminal states") + class TerminalStates { + + @ParameterizedTest + @EnumSource(OrderStatus.class) + @DisplayName("CANCELLED accepts no transition") + void cancelledIsTerminal(OrderStatus target) { + assertThat(CANCELLED.canTransitionTo(target)).isFalse(); + } + + @ParameterizedTest + @EnumSource(OrderStatus.class) + @DisplayName("REFUNDED accepts no transition") + void refundedIsTerminal(OrderStatus target) { + assertThat(REFUNDED.canTransitionTo(target)).isFalse(); + } + } + + @Nested + @DisplayName("Existing saga transitions unchanged (regression)") + class SagaTransitionsUnchanged { + + @Test + @DisplayName("REQUESTED still reaches the reservation/confirmation/cancel/timeout set") + void requestedTransitionsUnchanged() { + assertThat(allowedTargetsOf(REQUESTED)).isEqualTo(EnumSet.of( + INVENTORY_RESERVED, INVENTORY_INSUFFICIENT, PENDING_PAYMENT, + CONFIRMED, CANCELLED, TIMEOUT)); + } + + @Test + @DisplayName("INVENTORY_RESERVED still reaches the payment/confirm/cancel/timeout set") + void inventoryReservedTransitionsUnchanged() { + assertThat(allowedTargetsOf(INVENTORY_RESERVED)).isEqualTo(EnumSet.of( + PAYMENT_AUTHORIZED, PAYMENT_FAILED, PENDING_PAYMENT, + CONFIRMED, CANCELLED, TIMEOUT)); + } + + @Test + @DisplayName("PAYMENT_AUTHORIZED still reaches only CONFIRMED/CANCELLED/TIMEOUT") + void paymentAuthorizedTransitionsUnchanged() { + assertThat(allowedTargetsOf(PAYMENT_AUTHORIZED)) + .isEqualTo(EnumSet.of(CONFIRMED, CANCELLED, TIMEOUT)); + } + + @Test + @DisplayName("PENDING_PAYMENT still reaches the payment/confirm/cancel/timeout set") + void pendingPaymentTransitionsUnchanged() { + assertThat(allowedTargetsOf(PENDING_PAYMENT)).isEqualTo(EnumSet.of( + PAYMENT_AUTHORIZED, PAYMENT_FAILED, CONFIRMED, CANCELLED, TIMEOUT)); + } + + @Test + @DisplayName("Failure/timeout states still collapse only to CANCELLED") + void failureStatesTransitionToCancelledOnly() { + assertThat(allowedTargetsOf(PAYMENT_FAILED)).isEqualTo(EnumSet.of(CANCELLED)); + assertThat(allowedTargetsOf(INVENTORY_INSUFFICIENT)).isEqualTo(EnumSet.of(CANCELLED)); + assertThat(allowedTargetsOf(TIMEOUT)).isEqualTo(EnumSet.of(CANCELLED)); + } + + @Test + @DisplayName("No state may transition to itself") + void noSelfTransition() { + for (OrderStatus s : OrderStatus.values()) { + assertThat(s.canTransitionTo(s)) + .as("self-transition for %s", s) + .isFalse(); + } + } + } + + private static Set allowedTargetsOf(OrderStatus from) { + EnumSet allowed = EnumSet.noneOf(OrderStatus.class); + for (OrderStatus target : OrderStatus.values()) { + if (from.canTransitionTo(target)) { + allowed.add(target); + } + } + return allowed; + } +} diff --git a/order-service/src/test/java/code/with/vanilson/orderservice/bdd/OrderFulfillmentStepDefinitions.java b/order-service/src/test/java/code/with/vanilson/orderservice/bdd/OrderFulfillmentStepDefinitions.java new file mode 100644 index 00000000..453999cf --- /dev/null +++ b/order-service/src/test/java/code/with/vanilson/orderservice/bdd/OrderFulfillmentStepDefinitions.java @@ -0,0 +1,162 @@ +package code.with.vanilson.orderservice.bdd; + +import code.with.vanilson.orderservice.Order; +import code.with.vanilson.orderservice.OrderMapper; +import code.with.vanilson.orderservice.OrderRepository; +import code.with.vanilson.orderservice.OrderResponse; +import code.with.vanilson.orderservice.OrderService; +import code.with.vanilson.orderservice.OrderStatus; +import code.with.vanilson.orderservice.customer.CustomerClient; +import code.with.vanilson.orderservice.customer.CustomerSnapshotRepository; +import code.with.vanilson.orderservice.exception.OrderForbiddenException; +import code.with.vanilson.orderservice.exception.OrderIllegalStateTransitionException; +import code.with.vanilson.orderservice.exception.OrderValidationException; +import code.with.vanilson.orderservice.orderLine.OrderLineService; +import code.with.vanilson.orderservice.outbox.OutboxRepository; +import code.with.vanilson.orderservice.payment.PaymentMethod; +import code.with.vanilson.tenantcontext.TenantContext; +import code.with.vanilson.tenantcontext.TenantHibernateFilterActivator; +import code.with.vanilson.tenantcontext.security.SecurityPrincipal; +import io.cucumber.java.After; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.MessageSource; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.math.BigDecimal; +import java.util.Locale; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * BDD step definitions for the Fase 5 order fulfillment status flow. POJO + Mockito glue + * (no Spring context) — drives the real {@link OrderService#updateStatusManually} over mocked + * collaborators, so the whitelist / authorisation / transition rules are exercised behaviourally. + */ +public class OrderFulfillmentStepDefinitions { + + private static final Integer ORDER_ID = 500; + private static final String OWNER_SELLER = "7"; + + private OrderRepository orderRepository; + private OrderLineService orderLineService; + private OrderService orderService; + private Order order; + + private OrderResponse result; + private RuntimeException caught; + + @After + public void cleanup() { + SecurityContextHolder.clearContext(); + TenantContext.clear(); + } + + @Given("a confirmed order owned by seller {string}") + public void aConfirmedOrderOwnedBySeller(String sellerId) { + orderRepository = mock(OrderRepository.class); + orderLineService = mock(OrderLineService.class); + OrderMapper orderMapper = mock(OrderMapper.class); + CustomerClient customerClient = mock(CustomerClient.class); + CustomerSnapshotRepository snapshotRepository = mock(CustomerSnapshotRepository.class); + OutboxRepository outboxRepository = mock(OutboxRepository.class); + MessageSource messageSource = mock(MessageSource.class); + TenantHibernateFilterActivator filterActivator = mock(TenantHibernateFilterActivator.class); + MeterRegistry meterRegistry = mock(MeterRegistry.class); + ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class); + + lenient().when(messageSource.getMessage(anyString(), any(), any(Locale.class))) + .thenAnswer(inv -> inv.getArgument(0)); + lenient().when(snapshotRepository.findById(anyString())).thenReturn(Optional.empty()); + lenient().when(orderMapper.fromOrder(any(), any())).thenAnswer(inv -> { + Order o = inv.getArgument(0); + return new OrderResponse(o.getOrderId(), o.getReference(), o.getTotalAmount(), + "CREDIT_CARD", o.getCustomerId(), o.getStatus().name()); + }); + + order = Order.builder() + .orderId(ORDER_ID) + .tenantId("default") + .correlationId("corr-500") + .reference("ORD-500") + .totalAmount(BigDecimal.valueOf(120)) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .customerId("42") + .status(OrderStatus.CONFIRMED) + .build(); + + // updateStatusManually picks its repo call from the AMBIENT tenant: + // TenantContext.isPresent() ? findByOrderIdAndTenantId(id, tenant) : findById(id) + // Cucumber instantiates every glue class in this package per scenario, so another + // step-def class (OrderStepDefinitions) may already have bound a tenant on this thread. + // Stub BOTH branches so the scenario is deterministic regardless of hook order. + lenient().when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.of(order)); + lenient().when(orderRepository.findByOrderIdAndTenantId(eq(ORDER_ID), anyString())) + .thenReturn(Optional.of(order)); + lenient().when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + lenient().when(orderLineService.sellerOwnsLineInOrder(eq(ORDER_ID), anyString())) + .thenAnswer(inv -> sellerId.equals(inv.getArgument(1))); + + orderService = new OrderService(orderRepository, orderMapper, customerClient, snapshotRepository, + orderLineService, outboxRepository, messageSource, filterActivator, meterRegistry, + eventPublisher); + + result = null; + caught = null; + } + + @Given("the current actor is {string} with id {long}") + public void theCurrentActorIs(String role, long id) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + new SecurityPrincipal("actor@test.com", id, "default", role), null)); + } + + @When("the actor sets the order status to {string}") + public void theActorSetsTheOrderStatusTo(String status) { + try { + result = orderService.updateStatusManually(ORDER_ID, OrderStatus.valueOf(status)); + } catch (RuntimeException ex) { + caught = ex; + } + } + + @Then("the order status becomes {string}") + public void theOrderStatusBecomes(String status) { + assertThat(caught).isNull(); + assertThat(result).isNotNull(); + assertThat(result.status()).isEqualTo(status); + assertThat(order.getStatus()).isEqualTo(OrderStatus.valueOf(status)); + } + + @Then("the shipped timestamp is recorded") + public void theShippedTimestampIsRecorded() { + assertThat(order.getShippedAt()).isNotNull(); + } + + @Then("the update is forbidden") + public void theUpdateIsForbidden() { + assertThat(caught).isInstanceOf(OrderForbiddenException.class); + } + + @Then("the update is rejected as not allowed") + public void theUpdateIsRejectedAsNotAllowed() { + assertThat(caught).isInstanceOf(OrderValidationException.class); + } + + @Then("the update is rejected as an illegal transition") + public void theUpdateIsRejectedAsAnIllegalTransition() { + assertThat(caught).isInstanceOf(OrderIllegalStateTransitionException.class); + } +} diff --git a/order-service/src/test/java/code/with/vanilson/orderservice/bdd/OrderStepDefinitions.java b/order-service/src/test/java/code/with/vanilson/orderservice/bdd/OrderStepDefinitions.java index 0f187545..4b78ff0a 100644 --- a/order-service/src/test/java/code/with/vanilson/orderservice/bdd/OrderStepDefinitions.java +++ b/order-service/src/test/java/code/with/vanilson/orderservice/bdd/OrderStepDefinitions.java @@ -85,7 +85,8 @@ public void setUp() { orderService = new OrderService( orderRepository, orderMapper, customerClient, snapshotRepository, - orderLineService, outboxRepository, messageSource, filterActivator, meterRegistry); + orderLineService, outboxRepository, messageSource, filterActivator, meterRegistry, + mock(org.springframework.context.ApplicationEventPublisher.class)); TenantContext.setCurrentTenantId("test-tenant"); diff --git a/order-service/src/test/java/code/with/vanilson/orderservice/integration/OrderFulfillmentIntegrationTest.java b/order-service/src/test/java/code/with/vanilson/orderservice/integration/OrderFulfillmentIntegrationTest.java new file mode 100644 index 00000000..97754db5 --- /dev/null +++ b/order-service/src/test/java/code/with/vanilson/orderservice/integration/OrderFulfillmentIntegrationTest.java @@ -0,0 +1,186 @@ +package code.with.vanilson.orderservice.integration; + +import code.with.vanilson.orderservice.Order; +import code.with.vanilson.orderservice.OrderRepository; +import code.with.vanilson.orderservice.OrderResponse; +import code.with.vanilson.orderservice.OrderService; +import code.with.vanilson.orderservice.OrderStatus; +import code.with.vanilson.orderservice.customer.CustomerClient; +import code.with.vanilson.orderservice.exception.OrderForbiddenException; +import code.with.vanilson.orderservice.exception.OrderIllegalStateTransitionException; +import code.with.vanilson.orderservice.orderLine.OrderLine; +import code.with.vanilson.orderservice.orderLine.OrderLineRepository; +import code.with.vanilson.orderservice.payment.PaymentMethod; +import code.with.vanilson.tenantcontext.TenantContext; +import code.with.vanilson.tenantcontext.security.SecurityPrincipal; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * OrderFulfillmentIntegrationTest — Fase 5 manual fulfillment against real PostgreSQL. + *

+ * Proves the {@code V1_14} migration end-to-end: an order advanced to SHIPPED/DELIVERED + * round-trips through the {@code shipped_at} / {@code delivered_at} columns, the seller + * line-ownership query authorises a seller who owns a line and rejects one who does not, and + * the transition guard is enforced on real persisted state. + */ +@SpringBootTest(properties = { + "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}", + "spring.kafka.consumer.group-id=order-fulfillment-group-test" +}) +@Testcontainers +@EmbeddedKafka( + partitions = 1, + topics = {"order-topic"}, + brokerProperties = { + "auto.create.topics.enable=true", + "offsets.topic.num.partitions=1", + "transaction.state.log.num.partitions=1", + "transaction.state.log.replication.factor=1", + "transaction.state.log.min.isr=1", + "log.index.size.max.bytes=1048576" + }) +@ActiveProfiles("test") +@DisplayName("Order Fulfillment Integration — Fase 5 (Testcontainers PostgreSQL)") +class OrderFulfillmentIntegrationTest { + + @Container + static PostgreSQLContainer postgres = + new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("order_fulfillment_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureDataSource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + private static final String TENANT = "test-tenant"; + + @Autowired OrderService orderService; + @Autowired OrderRepository orderRepository; + @Autowired OrderLineRepository orderLineRepository; + + @MockBean CustomerClient customerClient; + + @BeforeEach + void setUp() { + TenantContext.setCurrentTenantId(TENANT); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + orderLineRepository.deleteAll(); + orderRepository.deleteAll(); + TenantContext.clear(); + } + + private Order persistConfirmedOrder(String sellerId) { + Order order = Order.builder() + .tenantId(TENANT) + .correlationId(UUID.randomUUID().toString()) + .reference("ORD-" + UUID.randomUUID().toString().substring(0, 8)) + .totalAmount(BigDecimal.valueOf(120.00)) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .customerId("42") + .status(OrderStatus.CONFIRMED) + .build(); + Order saved = orderRepository.save(order); + + OrderLine line = OrderLine.builder() + .tenantId(TENANT) + .order(saved) + .productId(1) + .quantity(2.0) + .sellerId(sellerId) + .build(); + orderLineRepository.save(line); + return saved; + } + + private void authenticateAs(long userId, String role) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + new SecurityPrincipal("user@test.com", userId, TENANT, role), null, List.of())); + } + + @Test + @DisplayName("ADMIN ships then delivers — timestamps persist through V1_14 columns") + void adminShipThenDeliverPersistsTimestamps() { + Order order = persistConfirmedOrder("7"); + authenticateAs(1L, "ADMIN"); + + OrderResponse shipped = orderService.updateStatusManually(order.getOrderId(), OrderStatus.SHIPPED); + assertThat(shipped.status()).isEqualTo("SHIPPED"); + + Order afterShip = orderRepository.findById(order.getOrderId()).orElseThrow(); + assertThat(afterShip.getStatus()).isEqualTo(OrderStatus.SHIPPED); + assertThat(afterShip.getShippedAt()).isNotNull(); + assertThat(afterShip.getDeliveredAt()).isNull(); + + orderService.updateStatusManually(order.getOrderId(), OrderStatus.DELIVERED); + Order afterDeliver = orderRepository.findById(order.getOrderId()).orElseThrow(); + assertThat(afterDeliver.getStatus()).isEqualTo(OrderStatus.DELIVERED); + assertThat(afterDeliver.getDeliveredAt()).isNotNull(); + } + + @Test + @DisplayName("SELLER owning a line may ship the order") + void sellerOwningLineMayShip() { + Order order = persistConfirmedOrder("7"); + authenticateAs(7L, "SELLER"); + + OrderResponse shipped = orderService.updateStatusManually(order.getOrderId(), OrderStatus.SHIPPED); + + assertThat(shipped.status()).isEqualTo("SHIPPED"); + assertThat(orderRepository.findById(order.getOrderId()).orElseThrow().getShippedAt()).isNotNull(); + } + + @Test + @DisplayName("SELLER without a line in the order is forbidden") + void foreignSellerForbidden() { + Order order = persistConfirmedOrder("7"); + authenticateAs(99L, "SELLER"); // owns no line + + assertThatThrownBy(() -> orderService.updateStatusManually(order.getOrderId(), OrderStatus.SHIPPED)) + .isInstanceOf(OrderForbiddenException.class); + + assertThat(orderRepository.findById(order.getOrderId()).orElseThrow().getStatus()) + .isEqualTo(OrderStatus.CONFIRMED); + } + + @Test + @DisplayName("illegal transition (DELIVERED before SHIPPED) is rejected on persisted state") + void illegalTransitionRejected() { + Order order = persistConfirmedOrder("7"); + authenticateAs(1L, "ADMIN"); + + assertThatThrownBy(() -> orderService.updateStatusManually(order.getOrderId(), OrderStatus.DELIVERED)) + .isInstanceOf(OrderIllegalStateTransitionException.class); + } +} diff --git a/order-service/src/test/resources/features/order_fulfillment.feature b/order-service/src/test/resources/features/order_fulfillment.feature new file mode 100644 index 00000000..dc6cd216 --- /dev/null +++ b/order-service/src/test/resources/features/order_fulfillment.feature @@ -0,0 +1,33 @@ +Feature: Order fulfillment status (Fase 5) + As a seller or admin + I want to advance an order through SHIPPED and DELIVERED + So that customers can track fulfillment after the saga confirms the order + + Background: + Given a confirmed order owned by seller "7" + + Scenario: Admin ships a confirmed order + Given the current actor is "ADMIN" with id 1 + When the actor sets the order status to "SHIPPED" + Then the order status becomes "SHIPPED" + And the shipped timestamp is recorded + + Scenario: Seller who owns a line ships the order + Given the current actor is "SELLER" with id 7 + When the actor sets the order status to "SHIPPED" + Then the order status becomes "SHIPPED" + + Scenario: Seller without a line in the order is forbidden + Given the current actor is "SELLER" with id 99 + When the actor sets the order status to "SHIPPED" + Then the update is forbidden + + Scenario: A status outside the fulfillment whitelist is rejected + Given the current actor is "ADMIN" with id 1 + When the actor sets the order status to "CONFIRMED" + Then the update is rejected as not allowed + + Scenario: Delivering before shipping is an illegal transition + Given the current actor is "ADMIN" with id 1 + When the actor sets the order status to "DELIVERED" + Then the update is rejected as an illegal transition