From c7eeaf6a6b30075df99dd1dbe605ca9f1cb9fcc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Thu, 28 May 2026 14:34:03 +0200 Subject: [PATCH] Refactor admin fulfillment into focused modules. Extract AdminOrderWorkflowService and status rules API; split AdminPage into composables and components; share order status constants; update tests. Co-authored-by: Cursor --- .../controller/AdminController.java | 33 +- .../se/bilhalsning/dto/AdminOrderMapper.java | 26 + .../bilhalsning/dto/AdminOrderResponse.java | 5 +- .../service/AdminOrderStatusRules.java | 81 +++ .../service/AdminOrderWorkflowService.java | 88 +++ .../se/bilhalsning/service/OrderService.java | 114 --- .../controller/AdminControllerTest.java | 19 +- .../service/AdminOrderStatusRulesTest.java | 48 ++ .../AdminOrderWorkflowServiceTest.java | 179 +++++ .../bilhalsning/service/OrderServiceTest.java | 146 ---- frontend/e2e/admin-dashboard.spec.ts | 7 +- frontend/e2e/admin-fulfillment.spec.ts | 16 +- frontend/e2e/deferred-payment-admin.spec.ts | 4 +- frontend/e2e/helpers/admin.ts | 15 + frontend/src/__tests__/AdminDashboard.spec.ts | 6 + frontend/src/api/admin.ts | 2 + .../admin/AdminOrderDetailPanel.vue | 133 ++++ .../admin/AdminOrderMessageModal.vue | 55 ++ .../src/components/admin/AdminOrdersTable.vue | 176 +++++ .../src/components/admin/AdminStatsBar.vue | 71 ++ .../src/composables/useAdminOrderActions.ts | 153 ++++ frontend/src/composables/useAdminOrders.ts | 89 +++ frontend/src/constants/orderStatus.ts | 30 + frontend/src/pages/AdminPage.vue | 681 +++--------------- frontend/src/pages/OrdersPage.vue | 30 +- frontend/src/utils/orderDisplay.ts | 11 + 26 files changed, 1285 insertions(+), 933 deletions(-) create mode 100644 backend/src/main/java/se/bilhalsning/dto/AdminOrderMapper.java create mode 100644 backend/src/main/java/se/bilhalsning/service/AdminOrderStatusRules.java create mode 100644 backend/src/main/java/se/bilhalsning/service/AdminOrderWorkflowService.java create mode 100644 backend/src/test/java/se/bilhalsning/service/AdminOrderStatusRulesTest.java create mode 100644 backend/src/test/java/se/bilhalsning/service/AdminOrderWorkflowServiceTest.java create mode 100644 frontend/e2e/helpers/admin.ts create mode 100644 frontend/src/components/admin/AdminOrderDetailPanel.vue create mode 100644 frontend/src/components/admin/AdminOrderMessageModal.vue create mode 100644 frontend/src/components/admin/AdminOrdersTable.vue create mode 100644 frontend/src/components/admin/AdminStatsBar.vue create mode 100644 frontend/src/composables/useAdminOrderActions.ts create mode 100644 frontend/src/composables/useAdminOrders.ts create mode 100644 frontend/src/constants/orderStatus.ts create mode 100644 frontend/src/utils/orderDisplay.ts diff --git a/backend/src/main/java/se/bilhalsning/controller/AdminController.java b/backend/src/main/java/se/bilhalsning/controller/AdminController.java index fc8776c..b6d0518 100644 --- a/backend/src/main/java/se/bilhalsning/controller/AdminController.java +++ b/backend/src/main/java/se/bilhalsning/controller/AdminController.java @@ -9,11 +9,13 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import se.bilhalsning.dto.AdminOrderMapper; import se.bilhalsning.dto.AdminOrderResponse; import se.bilhalsning.dto.RegisterShipmentRequest; import se.bilhalsning.dto.UpdateAdminNotesRequest; import se.bilhalsning.dto.UpdateStatusRequest; import se.bilhalsning.entity.Order; +import se.bilhalsning.service.AdminOrderWorkflowService; import se.bilhalsning.service.OrderService; import java.util.List; @@ -25,11 +27,12 @@ import java.util.UUID; public class AdminController { private final OrderService orderService; + private final AdminOrderWorkflowService adminOrderWorkflowService; @GetMapping("/orders") public ResponseEntity> listAllOrders() { List orders = orderService.getAllOrders().stream() - .map(this::toAdminResponse) + .map(AdminOrderMapper::toResponse) .toList(); return ResponseEntity.ok(orders); } @@ -38,42 +41,26 @@ public class AdminController { public ResponseEntity updateStatus( @PathVariable UUID id, @Valid @RequestBody UpdateStatusRequest request) { - Order order = orderService.updateOrderStatus(id, request.status()); - return ResponseEntity.ok(toAdminResponse(order)); + Order order = adminOrderWorkflowService.updateOrderStatus(id, request.status()); + return ResponseEntity.ok(AdminOrderMapper.toResponse(order)); } @PatchMapping("/orders/{id}/register-shipment") public ResponseEntity registerShipment( @PathVariable UUID id, @Valid @RequestBody RegisterShipmentRequest request) { - Order order = orderService.registerShipment( + Order order = adminOrderWorkflowService.registerShipment( id, request.trackingInput(), request.notifyCustomerOrDefault()); - return ResponseEntity.ok(toAdminResponse(order)); + return ResponseEntity.ok(AdminOrderMapper.toResponse(order)); } @PatchMapping("/orders/{id}/notes") public ResponseEntity updateNotes( @PathVariable UUID id, @RequestBody UpdateAdminNotesRequest request) { - Order order = orderService.updateAdminNotes(id, request.adminNotes()); - return ResponseEntity.ok(toAdminResponse(order)); - } - - private AdminOrderResponse toAdminResponse(Order order) { - String email = order.getUser() != null ? order.getUser().getEmail() : ""; - return new AdminOrderResponse( - order.getId(), - email, - order.getPlate(), - order.getLetterText(), - order.getStatus().getValue(), - order.getTrackingId(), - order.getAmountPaid(), - order.getShippedAt(), - order.getAdminNotes(), - order.getCreatedAt() - ); + Order order = adminOrderWorkflowService.updateAdminNotes(id, request.adminNotes()); + return ResponseEntity.ok(AdminOrderMapper.toResponse(order)); } } diff --git a/backend/src/main/java/se/bilhalsning/dto/AdminOrderMapper.java b/backend/src/main/java/se/bilhalsning/dto/AdminOrderMapper.java new file mode 100644 index 0000000..6c87726 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/AdminOrderMapper.java @@ -0,0 +1,26 @@ +package se.bilhalsning.dto; + +import se.bilhalsning.entity.Order; +import se.bilhalsning.service.AdminOrderStatusRules; + +public final class AdminOrderMapper { + + private AdminOrderMapper() {} + + public static AdminOrderResponse toResponse(Order order) { + String email = order.getUser() != null ? order.getUser().getEmail() : ""; + return new AdminOrderResponse( + order.getId(), + email, + order.getPlate(), + order.getLetterText(), + order.getStatus().getValue(), + order.getTrackingId(), + order.getAmountPaid(), + order.getShippedAt(), + order.getAdminNotes(), + order.getCreatedAt(), + AdminOrderStatusRules.allowedStatusValues(order), + AdminOrderStatusRules.canRegisterShipment(order)); + } +} diff --git a/backend/src/main/java/se/bilhalsning/dto/AdminOrderResponse.java b/backend/src/main/java/se/bilhalsning/dto/AdminOrderResponse.java index f031264..0d2444f 100644 --- a/backend/src/main/java/se/bilhalsning/dto/AdminOrderResponse.java +++ b/backend/src/main/java/se/bilhalsning/dto/AdminOrderResponse.java @@ -2,6 +2,7 @@ package se.bilhalsning.dto; import java.math.BigDecimal; import java.time.Instant; +import java.util.List; import java.util.UUID; public record AdminOrderResponse( @@ -14,5 +15,7 @@ public record AdminOrderResponse( BigDecimal amountPaid, Instant shippedAt, String adminNotes, - Instant createdAt + Instant createdAt, + List allowedStatuses, + boolean canRegisterShipment ) {} diff --git a/backend/src/main/java/se/bilhalsning/service/AdminOrderStatusRules.java b/backend/src/main/java/se/bilhalsning/service/AdminOrderStatusRules.java new file mode 100644 index 0000000..605a07d --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/service/AdminOrderStatusRules.java @@ -0,0 +1,81 @@ +package se.bilhalsning.service; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import se.bilhalsning.entity.Order; +import se.bilhalsning.entity.OrderStatus; +import se.bilhalsning.exception.InvalidOrderStateException; + +/** + * Admin status transitions and UI affordances. Single source of truth for + * {@link AdminOrderResponse#allowedStatuses()} and {@link AdminOrderResponse#canRegisterShipment()}. + */ +public final class AdminOrderStatusRules { + + private AdminOrderStatusRules() {} + + public static List allowedStatusValues(Order order) { + OrderStatus current = order.getStatus(); + LinkedHashSet options = new LinkedHashSet<>(); + options.add(current); + for (OrderStatus target : allowedTargets(current, order)) { + options.add(target); + } + List values = new ArrayList<>(); + for (OrderStatus status : options) { + values.add(status.getValue()); + } + return values; + } + + public static boolean canRegisterShipment(Order order) { + OrderStatus status = order.getStatus(); + if (status == OrderStatus.PROCESSING + || status == OrderStatus.SENT + || status == OrderStatus.DELIVERED) { + return true; + } + return status == OrderStatus.FAILED && order.getAmountPaid() != null; + } + + public static void validateTransition(Order order, OrderStatus to) { + OrderStatus from = order.getStatus(); + if (from == to) { + return; + } + if (!allowedTargets(from, order).contains(to)) { + throw new InvalidOrderStateException( + "Status kan inte ändras från " + from.getValue() + " till " + to.getValue()); + } + } + + private static List allowedTargets(OrderStatus from, Order order) { + return switch (from) { + case PENDING_PAYMENT -> List.of(OrderStatus.FAILED); + case PROCESSING -> List.of(OrderStatus.FAILED); + case SENT -> List.of(OrderStatus.DELIVERED, OrderStatus.FAILED); + case DELIVERED -> List.of(OrderStatus.FAILED); + case FAILED -> allowedTargetsFromFailed(order); + default -> List.of(); + }; + } + + private static List allowedTargetsFromFailed(Order order) { + if (hasTrackingId(order)) { + return List.of( + OrderStatus.PROCESSING, + OrderStatus.SENT, + OrderStatus.DELIVERED); + } + if (order.getAmountPaid() == null) { + return List.of(OrderStatus.PENDING_PAYMENT); + } + return List.of(OrderStatus.PROCESSING, OrderStatus.SENT); + } + + private static boolean hasTrackingId(Order order) { + String trackingId = order.getTrackingId(); + return trackingId != null && !trackingId.isBlank(); + } +} diff --git a/backend/src/main/java/se/bilhalsning/service/AdminOrderWorkflowService.java b/backend/src/main/java/se/bilhalsning/service/AdminOrderWorkflowService.java new file mode 100644 index 0000000..5d6ef10 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/service/AdminOrderWorkflowService.java @@ -0,0 +1,88 @@ +package se.bilhalsning.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import se.bilhalsning.entity.Order; +import se.bilhalsning.entity.OrderStatus; +import se.bilhalsning.exception.InvalidOrderStateException; +import se.bilhalsning.exception.OrderNotFoundException; +import se.bilhalsning.repository.OrderRepository; +import se.bilhalsning.util.PostNordTrackingNormalizer; + +import java.time.Instant; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class AdminOrderWorkflowService { + + private final OrderRepository orderRepository; + private final OrderNotificationService orderNotificationService; + + public Order updateOrderStatus(UUID orderId, String statusString) { + Order order = requireOrder(orderId); + OrderStatus newStatus = parseStatus(statusString); + OrderStatus previousStatus = order.getStatus(); + AdminOrderStatusRules.validateTransition(order, newStatus); + order.setStatus(newStatus); + if (newStatus == OrderStatus.SENT + && previousStatus == OrderStatus.FAILED + && order.getShippedAt() == null) { + order.setShippedAt(Instant.now()); + } + Order saved = orderRepository.save(order); + if (newStatus == OrderStatus.FAILED && previousStatus != OrderStatus.FAILED) { + orderNotificationService.notifyOrderFailed(saved); + } + return saved; + } + + public Order registerShipment(UUID orderId, String rawTrackingInput, boolean notifyCustomer) { + String trackingId = PostNordTrackingNormalizer.normalize(rawTrackingInput); + Order order = requireOrder(orderId); + OrderStatus previousStatus = order.getStatus(); + + if (!AdminOrderStatusRules.canRegisterShipment(order)) { + throw new InvalidOrderStateException( + "Beställningen kan inte registreras som utskickad i detta tillstånd"); + } + + if (previousStatus == OrderStatus.FAILED && order.getAmountPaid() == null) { + throw new InvalidOrderStateException( + "Obetalda misslyckade beställningar kan inte registreras som utskickade"); + } + + boolean firstShipment = previousStatus == OrderStatus.PROCESSING + || previousStatus == OrderStatus.FAILED; + order.setTrackingId(trackingId); + if (firstShipment) { + order.setStatus(OrderStatus.SENT); + order.setShippedAt(Instant.now()); + } + + Order saved = orderRepository.save(order); + if (notifyCustomer && firstShipment) { + orderNotificationService.notifyOrderSent(saved, trackingId); + } + return saved; + } + + public Order updateAdminNotes(UUID orderId, String adminNotes) { + Order order = requireOrder(orderId); + order.setAdminNotes(adminNotes); + return orderRepository.save(order); + } + + private Order requireOrder(UUID orderId) { + return orderRepository.findWithUserById(orderId) + .orElseThrow(() -> new OrderNotFoundException(orderId)); + } + + private static OrderStatus parseStatus(String statusString) { + try { + return OrderStatus.valueOf(statusString.toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException("Ogiltig status"); + } + } +} diff --git a/backend/src/main/java/se/bilhalsning/service/OrderService.java b/backend/src/main/java/se/bilhalsning/service/OrderService.java index 12742f5..fccdc11 100644 --- a/backend/src/main/java/se/bilhalsning/service/OrderService.java +++ b/backend/src/main/java/se/bilhalsning/service/OrderService.java @@ -7,11 +7,8 @@ import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.exception.InvalidOrderStateException; import se.bilhalsning.exception.OrderNotFoundException; import se.bilhalsning.repository.OrderRepository; -import se.bilhalsning.util.PostNordTrackingNormalizer; -import java.time.Instant; import java.util.List; -import java.util.Set; import java.util.UUID; @Service @@ -43,63 +40,6 @@ public class OrderService { return orderRepository.findAllByOrderByCreatedAtDesc(); } - public Order updateOrderStatus(UUID orderId, String statusString) { - Order order = requireOrder(orderId); - OrderStatus newStatus = parseStatus(statusString); - OrderStatus previousStatus = order.getStatus(); - validateAdminStatusTransition(order, newStatus); - order.setStatus(newStatus); - if (newStatus == OrderStatus.SENT - && previousStatus == OrderStatus.FAILED - && order.getShippedAt() == null) { - order.setShippedAt(Instant.now()); - } - Order saved = orderRepository.save(order); - if (newStatus == OrderStatus.FAILED && previousStatus != OrderStatus.FAILED) { - orderNotificationService.notifyOrderFailed(saved); - } - return saved; - } - - public Order registerShipment(UUID orderId, String rawTrackingInput, boolean notifyCustomer) { - String trackingId = PostNordTrackingNormalizer.normalize(rawTrackingInput); - Order order = requireOrder(orderId); - OrderStatus previousStatus = order.getStatus(); - - if (previousStatus == OrderStatus.FAILED && order.getAmountPaid() == null) { - throw new InvalidOrderStateException( - "Obetalda misslyckade beställningar kan inte registreras som utskickade"); - } - - if (previousStatus != OrderStatus.PROCESSING - && previousStatus != OrderStatus.SENT - && previousStatus != OrderStatus.DELIVERED - && previousStatus != OrderStatus.FAILED) { - throw new InvalidOrderStateException( - "Beställningen kan inte registreras som utskickad i detta tillstånd"); - } - - boolean firstShipment = previousStatus == OrderStatus.PROCESSING - || previousStatus == OrderStatus.FAILED; - order.setTrackingId(trackingId); - if (firstShipment) { - order.setStatus(OrderStatus.SENT); - order.setShippedAt(Instant.now()); - } - - Order saved = orderRepository.save(order); - if (notifyCustomer && firstShipment) { - orderNotificationService.notifyOrderSent(saved, trackingId); - } - return saved; - } - - public Order updateAdminNotes(UUID orderId, String adminNotes) { - Order order = requireOrder(orderId); - order.setAdminNotes(adminNotes); - return orderRepository.save(order); - } - public Order confirmPayment(UUID orderId, UUID userId) { Order order = requirePendingOwnedBy(orderId, userId); order.setStatus(OrderStatus.PROCESSING); @@ -120,11 +60,6 @@ public class OrderService { return orderRepository.save(order); } - private Order requireOrder(UUID orderId) { - return orderRepository.findWithUserById(orderId) - .orElseThrow(() -> new OrderNotFoundException(orderId)); - } - private Order requirePendingOwnedBy(UUID orderId, UUID userId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); @@ -140,53 +75,4 @@ public class OrderService { return order; } - - private static OrderStatus parseStatus(String statusString) { - try { - return OrderStatus.valueOf(statusString.toUpperCase()); - } catch (IllegalArgumentException ex) { - throw new IllegalArgumentException("Ogiltig status"); - } - } - - private static void validateAdminStatusTransition(Order order, OrderStatus to) { - OrderStatus from = order.getStatus(); - if (from == to) { - return; - } - - Set allowed = switch (from) { - case PENDING_PAYMENT -> Set.of(OrderStatus.FAILED); - case PROCESSING -> Set.of(OrderStatus.FAILED); - case SENT -> Set.of(OrderStatus.DELIVERED, OrderStatus.FAILED); - case DELIVERED -> Set.of(OrderStatus.FAILED); - case FAILED -> allowedTargetsFromFailed(order); - default -> Set.of(); - }; - - if (!allowed.contains(to)) { - throw new InvalidOrderStateException( - "Status kan inte ändras från " + from.getValue() + " till " + to.getValue()); - } - } - - private static Set allowedTargetsFromFailed(Order order) { - Set allowed = new java.util.HashSet<>(); - if (hasTrackingId(order)) { - allowed.add(OrderStatus.PROCESSING); - allowed.add(OrderStatus.SENT); - allowed.add(OrderStatus.DELIVERED); - } else if (order.getAmountPaid() == null) { - allowed.add(OrderStatus.PENDING_PAYMENT); - } else { - allowed.add(OrderStatus.PROCESSING); - allowed.add(OrderStatus.SENT); - } - return allowed; - } - - private static boolean hasTrackingId(Order order) { - String trackingId = order.getTrackingId(); - return trackingId != null && !trackingId.isBlank(); - } } diff --git a/backend/src/test/java/se/bilhalsning/controller/AdminControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/AdminControllerTest.java index c2d7b91..37026b5 100644 --- a/backend/src/test/java/se/bilhalsning/controller/AdminControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/AdminControllerTest.java @@ -25,6 +25,7 @@ import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.entity.User; import se.bilhalsning.exception.InvalidOrderStateException; import se.bilhalsning.exception.OrderNotFoundException; +import se.bilhalsning.service.AdminOrderWorkflowService; import se.bilhalsning.service.OrderService; @SpringBootTest @@ -37,6 +38,9 @@ class AdminControllerTest { @MockitoBean private OrderService orderService; + @MockitoBean + private AdminOrderWorkflowService adminOrderWorkflowService; + @Test void shouldReturn403WhenNotAuthenticated() throws Exception { mockMvc.perform(get("/api/admin/orders")) @@ -62,7 +66,9 @@ class AdminControllerTest { .andExpect(jsonPath("$[0].id").value(order.getId().toString())) .andExpect(jsonPath("$[0].email").value("test@bilhej.se")) .andExpect(jsonPath("$[0].plate").value("ABC123")) - .andExpect(jsonPath("$[0].status").value("sent")); + .andExpect(jsonPath("$[0].status").value("sent")) + .andExpect(jsonPath("$[0].allowedStatuses").isArray()) + .andExpect(jsonPath("$[0].canRegisterShipment").value(true)); } @Test @@ -71,7 +77,8 @@ class AdminControllerTest { UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.FAILED); - when(orderService.updateOrderStatus(eq(orderId), eq("failed"))).thenReturn(order); + when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("failed"))) + .thenReturn(order); mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId) .contentType(MediaType.APPLICATION_JSON) @@ -84,7 +91,7 @@ class AdminControllerTest { @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") void shouldReturn409WhenStatusTransitionInvalid() throws Exception { UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); - when(orderService.updateOrderStatus(eq(orderId), eq("delivered"))) + when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("delivered"))) .thenThrow(new InvalidOrderStateException("Ogiltig övergång")); mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId) @@ -101,7 +108,7 @@ class AdminControllerTest { order.setTrackingId("PN123456789"); order.setShippedAt(Instant.parse("2026-05-13T12:00:00Z")); - when(orderService.registerShipment(eq(orderId), eq("PN123456789"), eq(true))) + when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123456789"), eq(true))) .thenReturn(order); mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId) @@ -130,7 +137,7 @@ class AdminControllerTest { Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PROCESSING); order.setAdminNotes("Kontaktat TS"); - when(orderService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order); + when(adminOrderWorkflowService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order); mockMvc.perform(patch("/api/admin/orders/{id}/notes", orderId) .contentType(MediaType.APPLICATION_JSON) @@ -143,7 +150,7 @@ class AdminControllerTest { @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") void shouldReturn404WhenOrderNotFoundForRegisterShipment() throws Exception { UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); - when(orderService.registerShipment(eq(orderId), eq("PN123"), anyBoolean())) + when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123"), anyBoolean())) .thenThrow(new OrderNotFoundException(orderId)); mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId) diff --git a/backend/src/test/java/se/bilhalsning/service/AdminOrderStatusRulesTest.java b/backend/src/test/java/se/bilhalsning/service/AdminOrderStatusRulesTest.java new file mode 100644 index 0000000..d57c02c --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/service/AdminOrderStatusRulesTest.java @@ -0,0 +1,48 @@ +package se.bilhalsning.service; + +import org.junit.jupiter.api.Test; +import se.bilhalsning.entity.Order; +import se.bilhalsning.entity.OrderStatus; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.*; + +class AdminOrderStatusRulesTest { + + @Test + void shouldIncludeCurrentAndTargetsForSentOrder() { + Order order = orderWithStatus(OrderStatus.SENT); + + assertEquals( + java.util.List.of("sent", "delivered", "failed"), + AdminOrderStatusRules.allowedStatusValues(order)); + assertTrue(AdminOrderStatusRules.canRegisterShipment(order)); + } + + @Test + void shouldAllowOnlyFailedFromPendingPayment() { + Order order = orderWithStatus(OrderStatus.PENDING_PAYMENT); + + assertEquals( + java.util.List.of("pending_payment", "failed"), + AdminOrderStatusRules.allowedStatusValues(order)); + assertFalse(AdminOrderStatusRules.canRegisterShipment(order)); + } + + @Test + void shouldExposeFailedRecoveryOptionsWhenTrackingExists() { + Order order = orderWithStatus(OrderStatus.FAILED); + order.setTrackingId("PN123"); + order.setAmountPaid(new BigDecimal("49.00")); + + assertTrue(AdminOrderStatusRules.allowedStatusValues(order).contains("sent")); + assertTrue(AdminOrderStatusRules.canRegisterShipment(order)); + } + + private static Order orderWithStatus(OrderStatus status) { + Order order = new Order(); + order.setStatus(status); + return order; + } +} diff --git a/backend/src/test/java/se/bilhalsning/service/AdminOrderWorkflowServiceTest.java b/backend/src/test/java/se/bilhalsning/service/AdminOrderWorkflowServiceTest.java new file mode 100644 index 0000000..842cd1a --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/service/AdminOrderWorkflowServiceTest.java @@ -0,0 +1,179 @@ +package se.bilhalsning.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import se.bilhalsning.entity.Order; +import se.bilhalsning.entity.OrderStatus; +import se.bilhalsning.exception.InvalidOrderStateException; +import se.bilhalsning.repository.OrderRepository; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AdminOrderWorkflowServiceTest { + + @Mock + private OrderRepository orderRepository; + + @Mock + private OrderNotificationService orderNotificationService; + + @InjectMocks + private AdminOrderWorkflowService adminOrderWorkflowService; + + @Test + void shouldRegisterShipmentFromProcessingAndSetSent() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.PROCESSING); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = adminOrderWorkflowService.registerShipment(orderId, "PN123456789", true); + + assertEquals(OrderStatus.SENT, result.getStatus()); + assertEquals("PN123456789", result.getTrackingId()); + assertNotNull(result.getShippedAt()); + verify(orderNotificationService).notifyOrderSent(result, "PN123456789"); + } + + @Test + void shouldRejectRegisterShipmentWhenPendingPayment() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.PENDING_PAYMENT); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + + assertThrows(InvalidOrderStateException.class, + () -> adminOrderWorkflowService.registerShipment(orderId, "PN123", true)); + } + + @Test + void shouldRejectInvalidAdminStatusTransition() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.PROCESSING); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + + assertThrows(InvalidOrderStateException.class, + () -> adminOrderWorkflowService.updateOrderStatus(orderId, "delivered")); + } + + @Test + void shouldMarkPendingPaymentAsFailed() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.PENDING_PAYMENT); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "failed"); + + assertEquals(OrderStatus.FAILED, result.getStatus()); + verify(orderNotificationService).notifyOrderFailed(result); + } + + @Test + void shouldRevertFailedToPendingPaymentWhenUnpaid() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.FAILED); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "pending_payment"); + + assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus()); + } + + @Test + void shouldRevertFailedToProcessingWhenPaidWithoutTracking() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.FAILED); + order.setAmountPaid(new BigDecimal("49.00")); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "processing"); + + assertEquals(OrderStatus.PROCESSING, result.getStatus()); + verify(orderNotificationService, never()).notifyOrderFailed(any(Order.class)); + } + + @Test + void shouldRevertFailedToSentWhenPaidWithoutTracking() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.FAILED); + order.setAmountPaid(new BigDecimal("49.00")); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "sent"); + + assertEquals(OrderStatus.SENT, result.getStatus()); + assertNotNull(result.getShippedAt()); + } + + @Test + void shouldRegisterShipmentFromFailedWhenPaid() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.FAILED); + order.setAmountPaid(new BigDecimal("49.00")); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = adminOrderWorkflowService.registerShipment(orderId, "PN888", true); + + assertEquals(OrderStatus.SENT, result.getStatus()); + assertEquals("PN888", result.getTrackingId()); + } + + @Test + void shouldRevertFailedToSentWhenTrackingExists() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.FAILED); + order.setTrackingId("PN123"); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "sent"); + + assertEquals(OrderStatus.SENT, result.getStatus()); + } + + @Test + void shouldNotifyCustomerOnFailedStatusFromProcessing() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.PROCESSING); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + adminOrderWorkflowService.updateOrderStatus(orderId, "failed"); + + verify(orderNotificationService).notifyOrderFailed(any(Order.class)); + } +} diff --git a/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java b/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java index 388eeda..1c61aa5 100644 --- a/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java +++ b/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java @@ -253,150 +253,4 @@ class OrderServiceTest { () -> orderService.confirmPayment(orderId, otherUserId)); } - @Test - void shouldRegisterShipmentFromProcessingAndSetSent() { - UUID orderId = UUID.randomUUID(); - Order order = new Order(); - order.setId(orderId); - order.setStatus(OrderStatus.PROCESSING); - when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); - when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); - - Order result = orderService.registerShipment(orderId, "PN123456789", true); - - assertEquals(OrderStatus.SENT, result.getStatus()); - assertEquals("PN123456789", result.getTrackingId()); - assertNotNull(result.getShippedAt()); - verify(orderNotificationService).notifyOrderSent(result, "PN123456789"); - } - - @Test - void shouldRejectRegisterShipmentWhenPendingPayment() { - UUID orderId = UUID.randomUUID(); - Order order = new Order(); - order.setId(orderId); - order.setStatus(OrderStatus.PENDING_PAYMENT); - when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); - - assertThrows(InvalidOrderStateException.class, - () -> orderService.registerShipment(orderId, "PN123", true)); - } - - @Test - void shouldRejectInvalidAdminStatusTransition() { - UUID orderId = UUID.randomUUID(); - Order order = new Order(); - order.setId(orderId); - order.setStatus(OrderStatus.PROCESSING); - when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); - - assertThrows(InvalidOrderStateException.class, - () -> orderService.updateOrderStatus(orderId, "delivered")); - } - - @Test - void shouldMarkPendingPaymentAsFailed() { - UUID orderId = UUID.randomUUID(); - Order order = new Order(); - order.setId(orderId); - order.setStatus(OrderStatus.PENDING_PAYMENT); - when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); - when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); - - Order result = orderService.updateOrderStatus(orderId, "failed"); - - assertEquals(OrderStatus.FAILED, result.getStatus()); - verify(orderNotificationService).notifyOrderFailed(result); - } - - @Test - void shouldRevertFailedToPendingPaymentWhenUnpaid() { - UUID orderId = UUID.randomUUID(); - Order order = new Order(); - order.setId(orderId); - order.setStatus(OrderStatus.FAILED); - when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); - when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); - - Order result = orderService.updateOrderStatus(orderId, "pending_payment"); - - assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus()); - } - - @Test - void shouldRevertFailedToProcessingWhenPaidWithoutTracking() { - UUID orderId = UUID.randomUUID(); - Order order = new Order(); - order.setId(orderId); - order.setStatus(OrderStatus.FAILED); - order.setAmountPaid(new java.math.BigDecimal("49.00")); - when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); - when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); - - Order result = orderService.updateOrderStatus(orderId, "processing"); - - assertEquals(OrderStatus.PROCESSING, result.getStatus()); - verify(orderNotificationService, never()).notifyOrderFailed(any(Order.class)); - } - - @Test - void shouldRevertFailedToSentWhenPaidWithoutTracking() { - UUID orderId = UUID.randomUUID(); - Order order = new Order(); - order.setId(orderId); - order.setStatus(OrderStatus.FAILED); - order.setAmountPaid(new java.math.BigDecimal("49.00")); - when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); - when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); - - Order result = orderService.updateOrderStatus(orderId, "sent"); - - assertEquals(OrderStatus.SENT, result.getStatus()); - assertNotNull(result.getShippedAt()); - } - - @Test - void shouldRegisterShipmentFromFailedWhenPaid() { - UUID orderId = UUID.randomUUID(); - Order order = new Order(); - order.setId(orderId); - order.setStatus(OrderStatus.FAILED); - order.setAmountPaid(new java.math.BigDecimal("49.00")); - when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); - when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); - - Order result = orderService.registerShipment(orderId, "PN888", true); - - assertEquals(OrderStatus.SENT, result.getStatus()); - assertEquals("PN888", result.getTrackingId()); - } - - @Test - void shouldRevertFailedToSentWhenTrackingExists() { - UUID orderId = UUID.randomUUID(); - Order order = new Order(); - order.setId(orderId); - order.setStatus(OrderStatus.FAILED); - order.setTrackingId("PN123"); - when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); - when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); - - Order result = orderService.updateOrderStatus(orderId, "sent"); - - assertEquals(OrderStatus.SENT, result.getStatus()); - } - - @Test - void shouldNotifyCustomerOnFailedStatusFromProcessing() { - UUID orderId = UUID.randomUUID(); - Order order = new Order(); - order.setId(orderId); - order.setStatus(OrderStatus.PROCESSING); - when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); - when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); - - orderService.updateOrderStatus(orderId, "failed"); - - verify(orderNotificationService).notifyOrderFailed(any(Order.class)); - } } diff --git a/frontend/e2e/admin-dashboard.spec.ts b/frontend/e2e/admin-dashboard.spec.ts index 8da5306..8dcabdf 100644 --- a/frontend/e2e/admin-dashboard.spec.ts +++ b/frontend/e2e/admin-dashboard.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' +import { loginAsAdmin } from './helpers/admin' const SEEDED_ORDER_ID = 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' const SEEDED_ORDER_SHORT_ID = SEEDED_ORDER_ID.slice(0, 8) @@ -12,11 +13,7 @@ function rowByPlate(page: import('@playwright/test').Page, plate: string) { test.describe('Admin dashboard', () => { test.beforeEach(async ({ page }) => { - await page.goto('/logga-in') - await page.getByLabel('E-postadress').fill('admin@bilhalsning.se') - await page.getByLabel('Lösenord').fill('test1234') - await page.getByRole('button', { name: 'Logga in' }).click() - await page.waitForURL('/') + await loginAsAdmin(page) }) test('admin can navigate to admin page', async ({ page }) => { diff --git a/frontend/e2e/admin-fulfillment.spec.ts b/frontend/e2e/admin-fulfillment.spec.ts index 1f136b4..4ee359c 100644 --- a/frontend/e2e/admin-fulfillment.spec.ts +++ b/frontend/e2e/admin-fulfillment.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' +import { loginAsAdmin, openAdminDashboard } from './helpers/admin' /** * Admin order status and shipment flows (serial — mutates seeded orders). @@ -10,19 +11,6 @@ const PENDING_ORDER_SHORT_ID = 'c2eebc99' const PROCESSING_PLATE = 'JKL012' const SENT_ORDER_SHORT_ID = 'c1eebc99' -async function loginAsAdmin(page: import('@playwright/test').Page) { - await page.goto('/logga-in') - await page.getByLabel('E-postadress').fill('admin@bilhalsning.se') - await page.getByLabel('Lösenord').fill('test1234') - await page.getByRole('button', { name: 'Logga in' }).click() - await page.waitForURL('/') -} - -async function openAdmin(page: import('@playwright/test').Page) { - await page.goto('/admin') - await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 }) -} - function orderRowByPlate(page: import('@playwright/test').Page, plate: string) { return page.locator('.admin__row').filter({ has: page.locator('.admin__plate', { hasText: plate }), @@ -39,7 +27,7 @@ function orderRowByShortId( test.describe('Admin fulfillment flows', () => { test.beforeEach(async ({ page }) => { await loginAsAdmin(page) - await openAdmin(page) + await openAdminDashboard(page) }) test('can mark unpaid order as failed', async ({ page }) => { diff --git a/frontend/e2e/deferred-payment-admin.spec.ts b/frontend/e2e/deferred-payment-admin.spec.ts index 39dc39f..4924706 100644 --- a/frontend/e2e/deferred-payment-admin.spec.ts +++ b/frontend/e2e/deferred-payment-admin.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' +import { loginAsAdmin, openAdminDashboard } from './helpers/admin' test.describe.configure({ mode: 'serial' }) @@ -40,8 +41,7 @@ test.describe('Deferred payment and admin lookup', () => { } async function openAdminTodoBoard(page: import('@playwright/test').Page) { - await page.goto('/admin') - await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 }) + await openAdminDashboard(page) await page.getByRole('button', { name: /Att göra/ }).click() await expect(page.locator('.admin__stat--active')).toContainText('Att göra') } diff --git a/frontend/e2e/helpers/admin.ts b/frontend/e2e/helpers/admin.ts new file mode 100644 index 0000000..6fa26cb --- /dev/null +++ b/frontend/e2e/helpers/admin.ts @@ -0,0 +1,15 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export async function loginAsAdmin(page: Page) { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill('admin@bilhalsning.se') + await page.getByLabel('Lösenord').fill('test1234') + await page.getByRole('button', { name: 'Logga in' }).click() + await page.waitForURL('/') +} + +export async function openAdminDashboard(page: Page) { + await page.goto('/admin') + await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 }) +} diff --git a/frontend/src/__tests__/AdminDashboard.spec.ts b/frontend/src/__tests__/AdminDashboard.spec.ts index d2c190d..bf8e15a 100644 --- a/frontend/src/__tests__/AdminDashboard.spec.ts +++ b/frontend/src/__tests__/AdminDashboard.spec.ts @@ -46,6 +46,8 @@ const mockOrders = [ shippedAt: '2026-05-13T12:00:00Z', adminNotes: null, createdAt: '2026-05-11T12:00:00Z', + allowedStatuses: ['sent', 'delivered', 'failed'], + canRegisterShipment: true, }, { id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', @@ -58,6 +60,8 @@ const mockOrders = [ shippedAt: null, adminNotes: null, createdAt: '2026-05-14T13:00:00Z', + allowedStatuses: ['processing', 'failed'], + canRegisterShipment: true, }, { id: 'c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', @@ -70,6 +74,8 @@ const mockOrders = [ shippedAt: null, adminNotes: null, createdAt: '2026-05-15T14:00:00Z', + allowedStatuses: ['pending_payment', 'failed'], + canRegisterShipment: false, }, ] diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 50f1481..12df54b 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -11,6 +11,8 @@ export interface AdminOrder { shippedAt: string | null adminNotes: string | null createdAt: string + allowedStatuses: string[] + canRegisterShipment: boolean } export function fetchAllOrders(): Promise { diff --git a/frontend/src/components/admin/AdminOrderDetailPanel.vue b/frontend/src/components/admin/AdminOrderDetailPanel.vue new file mode 100644 index 0000000..32259cc --- /dev/null +++ b/frontend/src/components/admin/AdminOrderDetailPanel.vue @@ -0,0 +1,133 @@ + + +