From 3d0b7fe7999173df01462d8fb84f67ed0998e5f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 22 May 2026 11:21:47 +0200 Subject: [PATCH 1/2] Allow users to edit or cancel unpaid orders before payment. Adds backend endpoints and frontend edit page so pending orders can be updated or soft-cancelled without admin intervention. Co-authored-by: Cursor --- .../controller/OrderController.java | 44 +++ .../controller/PaymentController.java | 16 +- .../bilhalsning/dto/UpdateOrderRequest.java | 10 + .../se/bilhalsning/entity/OrderStatus.java | 3 +- .../exception/GlobalExceptionHandler.java | 7 + .../exception/InvalidOrderStateException.java | 7 + .../se/bilhalsning/service/OrderService.java | 35 +- .../controller/OrderControllerTest.java | 113 ++++++ .../controller/PaymentControllerTest.java | 25 +- .../bilhalsning/service/OrderServiceTest.java | 122 +++++++ frontend/src/__tests__/EditOrderPage.spec.ts | 149 ++++++++ frontend/src/__tests__/OrdersPage.spec.ts | 95 ++++- frontend/src/api/orders.ts | 17 + frontend/src/pages/AdminPage.vue | 3 + frontend/src/pages/EditOrderPage.vue | 339 ++++++++++++++++++ frontend/src/pages/OrdersPage.vue | 184 +++++++--- frontend/src/router/index.ts | 7 + 17 files changed, 1106 insertions(+), 70 deletions(-) create mode 100644 backend/src/main/java/se/bilhalsning/dto/UpdateOrderRequest.java create mode 100644 backend/src/main/java/se/bilhalsning/exception/InvalidOrderStateException.java create mode 100644 frontend/src/__tests__/EditOrderPage.spec.ts create mode 100644 frontend/src/pages/EditOrderPage.vue diff --git a/backend/src/main/java/se/bilhalsning/controller/OrderController.java b/backend/src/main/java/se/bilhalsning/controller/OrderController.java index 0c93aee..56b9fe7 100644 --- a/backend/src/main/java/se/bilhalsning/controller/OrderController.java +++ b/backend/src/main/java/se/bilhalsning/controller/OrderController.java @@ -7,12 +7,15 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; 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; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import se.bilhalsning.dto.CreateOrderRequest; import se.bilhalsning.dto.OrderResponse; +import se.bilhalsning.dto.UpdateOrderRequest; import se.bilhalsning.entity.Order; import se.bilhalsning.entity.User; import se.bilhalsning.exception.InvalidCredentialsException; @@ -20,6 +23,7 @@ import se.bilhalsning.service.OrderService; import se.bilhalsning.service.UserService; import java.util.List; +import java.util.UUID; @RestController @RequestMapping("/api/orders") @@ -41,6 +45,21 @@ public class OrderController { return ResponseEntity.ok(orders); } + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable UUID id, + @AuthenticationPrincipal UserDetails userDetails) { + User user = userService.findByEmail(userDetails.getUsername()) + .orElseThrow(InvalidCredentialsException::new); + + Order order = orderService.getOrderById(id); + + if (!order.getUserId().equals(user.getId())) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(toResponse(order)); + } + @PostMapping public ResponseEntity create( @Valid @RequestBody CreateOrderRequest request, @@ -57,6 +76,31 @@ public class OrderController { return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order)); } + @PatchMapping("/{id}") + public ResponseEntity update( + @PathVariable UUID id, + @Valid @RequestBody UpdateOrderRequest request, + @AuthenticationPrincipal UserDetails userDetails) { + User user = userService.findByEmail(userDetails.getUsername()) + .orElseThrow(InvalidCredentialsException::new); + + Order order = orderService.updatePendingOrder(id, user.getId(), request.letterText()); + + return ResponseEntity.ok(toResponse(order)); + } + + @PostMapping("/{id}/cancel") + public ResponseEntity cancel( + @PathVariable UUID id, + @AuthenticationPrincipal UserDetails userDetails) { + User user = userService.findByEmail(userDetails.getUsername()) + .orElseThrow(InvalidCredentialsException::new); + + Order order = orderService.cancelOrder(id, user.getId()); + + return ResponseEntity.ok(toResponse(order)); + } + private OrderResponse toResponse(Order order) { return new OrderResponse( order.getId(), diff --git a/backend/src/main/java/se/bilhalsning/controller/PaymentController.java b/backend/src/main/java/se/bilhalsning/controller/PaymentController.java index 63712e0..8b215d0 100644 --- a/backend/src/main/java/se/bilhalsning/controller/PaymentController.java +++ b/backend/src/main/java/se/bilhalsning/controller/PaymentController.java @@ -5,6 +5,8 @@ import java.util.UUID; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -12,28 +14,38 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import se.bilhalsning.dto.OrderResponse; import se.bilhalsning.entity.Order; +import se.bilhalsning.entity.User; +import se.bilhalsning.exception.InvalidCredentialsException; import se.bilhalsning.service.OrderService; +import se.bilhalsning.service.UserService; @RestController @RequestMapping("/api/payment") public class PaymentController { private final OrderService orderService; + private final UserService userService; private final String swishNumber; private final int letterPrice; public PaymentController( OrderService orderService, + UserService userService, @Value("${app.payment.swish-number}") String swishNumber, @Value("${app.payment.letter-price}") int letterPrice) { this.orderService = orderService; + this.userService = userService; this.swishNumber = swishNumber; this.letterPrice = letterPrice; } @PostMapping("/{orderId}/pay") - public ResponseEntity pay(@PathVariable UUID orderId) { - Order order = orderService.confirmPayment(orderId); + public ResponseEntity pay(@PathVariable UUID orderId, + @AuthenticationPrincipal UserDetails userDetails) { + User user = userService.findByEmail(userDetails.getUsername()) + .orElseThrow(InvalidCredentialsException::new); + + Order order = orderService.confirmPayment(orderId, user.getId()); return ResponseEntity.ok(toResponse(order)); } diff --git a/backend/src/main/java/se/bilhalsning/dto/UpdateOrderRequest.java b/backend/src/main/java/se/bilhalsning/dto/UpdateOrderRequest.java new file mode 100644 index 0000000..6b103d7 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/UpdateOrderRequest.java @@ -0,0 +1,10 @@ +package se.bilhalsning.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateOrderRequest( + @NotBlank(message = "Brevtext krävs") + @Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken") + String letterText +) {} diff --git a/backend/src/main/java/se/bilhalsning/entity/OrderStatus.java b/backend/src/main/java/se/bilhalsning/entity/OrderStatus.java index 092a9f5..221fa60 100644 --- a/backend/src/main/java/se/bilhalsning/entity/OrderStatus.java +++ b/backend/src/main/java/se/bilhalsning/entity/OrderStatus.java @@ -6,7 +6,8 @@ public enum OrderStatus { PROCESSING("processing"), SENT("sent"), DELIVERED("delivered"), - FAILED("failed"); + FAILED("failed"), + CANCELLED("cancelled"); private final String value; diff --git a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java index 5fd4054..1e6d4e0 100644 --- a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java @@ -36,6 +36,13 @@ public class GlobalExceptionHandler { .body(new ErrorResponse("E-postadressen är redan registrerad")); } + @ExceptionHandler(InvalidOrderStateException.class) + public ResponseEntity handleInvalidOrderState(InvalidOrderStateException ex) { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(new ErrorResponse(ex.getMessage())); + } + @ExceptionHandler(OrderNotFoundException.class) public ResponseEntity handleOrderNotFound(OrderNotFoundException ex) { return ResponseEntity diff --git a/backend/src/main/java/se/bilhalsning/exception/InvalidOrderStateException.java b/backend/src/main/java/se/bilhalsning/exception/InvalidOrderStateException.java new file mode 100644 index 0000000..3115a11 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/exception/InvalidOrderStateException.java @@ -0,0 +1,7 @@ +package se.bilhalsning.exception; + +public class InvalidOrderStateException extends RuntimeException { + public InvalidOrderStateException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/se/bilhalsning/service/OrderService.java b/backend/src/main/java/se/bilhalsning/service/OrderService.java index e0fc3b4..a4a6d68 100644 --- a/backend/src/main/java/se/bilhalsning/service/OrderService.java +++ b/backend/src/main/java/se/bilhalsning/service/OrderService.java @@ -4,6 +4,7 @@ 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; @@ -55,11 +56,37 @@ public class OrderService { return orderRepository.save(order); } - public Order confirmPayment(UUID orderId) { - Order order = orderRepository.findById(orderId) - .orElseThrow(() -> new OrderNotFoundException(orderId)); - + public Order confirmPayment(UUID orderId, UUID userId) { + Order order = requirePendingOwnedBy(orderId, userId); order.setStatus(OrderStatus.PROCESSING); return orderRepository.save(order); } + + public Order cancelOrder(UUID orderId, UUID userId) { + Order order = requirePendingOwnedBy(orderId, userId); + order.setStatus(OrderStatus.CANCELLED); + return orderRepository.save(order); + } + + public Order updatePendingOrder(UUID orderId, UUID userId, String letterText) { + Order order = requirePendingOwnedBy(orderId, userId); + order.setLetterText(letterText); + return orderRepository.save(order); + } + + private Order requirePendingOwnedBy(UUID orderId, UUID userId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new OrderNotFoundException(orderId)); + + if (!order.getUserId().equals(userId)) { + throw new OrderNotFoundException(orderId); + } + + if (order.getStatus() != OrderStatus.PENDING_PAYMENT) { + throw new InvalidOrderStateException( + "Beställningen kan inte ändras i detta tillstånd"); + } + + return order; + } } diff --git a/backend/src/test/java/se/bilhalsning/controller/OrderControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/OrderControllerTest.java index 53e9fe5..a42e9ab 100644 --- a/backend/src/test/java/se/bilhalsning/controller/OrderControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/OrderControllerTest.java @@ -1,7 +1,9 @@ package se.bilhalsning.controller; +import static org.mockito.ArgumentMatchers.any; 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; @@ -163,4 +165,115 @@ class OrderControllerTest { .content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}")) .andExpect(status().isBadRequest()); } + + @Test + @WithMockUser(username = "test@bilhej.se") + void shouldGetSingleOrderForOwner() throws Exception { + UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + User user = new User(); + user.setId(userId); + user.setEmail("test@bilhej.se"); + + when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user)); + + se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order(); + order.setId(orderId); + order.setUserId(userId); + order.setPlate("ABC123"); + order.setLetterText("Test letter"); + order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT); + + when(orderService.getOrderById(orderId)).thenReturn(order); + + mockMvc.perform(get("/api/orders/" + orderId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(orderId.toString())) + .andExpect(jsonPath("$.plate").value("ABC123")) + .andExpect(jsonPath("$.status").value("pending_payment")); + } + + @Test + @WithMockUser(username = "test@bilhej.se") + void shouldReturn404WhenGettingOtherUsersOrder() throws Exception { + UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + User user = new User(); + user.setId(userId); + user.setEmail("test@bilhej.se"); + + when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user)); + + se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order(); + order.setId(orderId); + order.setUserId(UUID.randomUUID()); + + when(orderService.getOrderById(orderId)).thenReturn(order); + + mockMvc.perform(get("/api/orders/" + orderId)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(username = "test@bilhej.se") + void shouldPatchOrderSuccessfully() throws Exception { + UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + User user = new User(); + user.setId(userId); + user.setEmail("test@bilhej.se"); + + when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user)); + + se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order(); + order.setId(orderId); + order.setUserId(userId); + order.setPlate("ABC123"); + order.setLetterText("Updated text"); + order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT); + + when(orderService.updatePendingOrder(any(), any(), any())).thenReturn(order); + + mockMvc.perform(patch("/api/orders/" + orderId) + .contentType("application/json") + .content("{\"letterText\":\"Updated text\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.letterText").value("Updated text")); + } + + @Test + @WithMockUser(username = "test@bilhej.se") + void shouldRejectPatchWithEmptyLetterText() throws Exception { + UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + + mockMvc.perform(patch("/api/orders/" + orderId) + .contentType("application/json") + .content("{\"letterText\":\"\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(username = "test@bilhej.se") + void shouldCancelOrderSuccessfully() throws Exception { + UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + User user = new User(); + user.setId(userId); + user.setEmail("test@bilhej.se"); + + when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user)); + + se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order(); + order.setId(orderId); + order.setUserId(userId); + order.setPlate("ABC123"); + order.setLetterText("Test letter"); + order.setStatus(se.bilhalsning.entity.OrderStatus.CANCELLED); + + when(orderService.cancelOrder(orderId, userId)).thenReturn(order); + + mockMvc.perform(post("/api/orders/" + orderId + "/cancel")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("cancelled")); + } } diff --git a/backend/src/test/java/se/bilhalsning/controller/PaymentControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/PaymentControllerTest.java index 5d5dd22..be187ad 100644 --- a/backend/src/test/java/se/bilhalsning/controller/PaymentControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/PaymentControllerTest.java @@ -1,5 +1,6 @@ package se.bilhalsning.controller; +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; @@ -7,6 +8,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -18,8 +20,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import se.bilhalsning.entity.Order; import se.bilhalsning.entity.OrderStatus; +import se.bilhalsning.entity.User; import se.bilhalsning.exception.OrderNotFoundException; import se.bilhalsning.service.OrderService; +import se.bilhalsning.service.UserService; @SpringBootTest @AutoConfigureMockMvc @@ -31,6 +35,9 @@ class PaymentControllerTest { @MockitoBean private OrderService orderService; + @MockitoBean + private UserService userService; + @Test void shouldReturn403WhenNotAuthenticated() throws Exception { mockMvc.perform(post("/api/payment/{orderId}/pay", @@ -42,12 +49,19 @@ class PaymentControllerTest { @WithMockUser(username = "test@bilhej.se") void shouldConfirmPaymentSuccessfully() throws Exception { UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + User user = new User(); + user.setId(userId); + user.setEmail("test@bilhej.se"); + + when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user)); + Order order = new Order(); order.setId(orderId); order.setPlate("ABC123"); order.setStatus(OrderStatus.PROCESSING); - when(orderService.confirmPayment(eq(orderId))).thenReturn(order); + when(orderService.confirmPayment(eq(orderId), eq(userId))).thenReturn(order); mockMvc.perform(post("/api/payment/{orderId}/pay", orderId) .contentType(MediaType.APPLICATION_JSON)) @@ -60,7 +74,14 @@ class PaymentControllerTest { @WithMockUser(username = "test@bilhej.se") void shouldReturn404WhenOrderNotFound() throws Exception { UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); - when(orderService.confirmPayment(eq(orderId))) + UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + User user = new User(); + user.setId(userId); + user.setEmail("test@bilhej.se"); + + when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user)); + + when(orderService.confirmPayment(eq(orderId), eq(userId))) .thenThrow(new OrderNotFoundException(orderId)); mockMvc.perform(post("/api/payment/{orderId}/pay", orderId) diff --git a/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java b/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java index 32fbb68..3a88972 100644 --- a/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java +++ b/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java @@ -9,6 +9,7 @@ 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.exception.OrderNotFoundException; import se.bilhalsning.repository.OrderRepository; @@ -127,4 +128,125 @@ class OrderServiceTest { assertThrows(OrderNotFoundException.class, () -> orderService.getOrderById(orderId)); } + + @Test + void shouldCancelOrderWhenPendingPayment() { + UUID orderId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setUserId(userId); + order.setStatus(OrderStatus.PENDING_PAYMENT); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.cancelOrder(orderId, userId); + + assertEquals(OrderStatus.CANCELLED, result.getStatus()); + } + + @Test + void shouldThrowWhenCancellingNonPendingOrder() { + UUID orderId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setUserId(userId); + order.setStatus(OrderStatus.PROCESSING); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + + assertThrows(InvalidOrderStateException.class, + () -> orderService.cancelOrder(orderId, userId)); + } + + @Test + void shouldThrowWhenCancellingOtherUsersOrder() { + UUID orderId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + UUID otherUserId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setUserId(ownerId); + order.setStatus(OrderStatus.PENDING_PAYMENT); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + + assertThrows(OrderNotFoundException.class, + () -> orderService.cancelOrder(orderId, otherUserId)); + } + + @Test + void shouldUpdatePendingOrderLetterText() { + UUID orderId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setUserId(userId); + order.setStatus(OrderStatus.PENDING_PAYMENT); + order.setLetterText("Old text"); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.updatePendingOrder(orderId, userId, "New text"); + + assertEquals("New text", result.getLetterText()); + } + + @Test + void shouldThrowWhenUpdatingNonPendingOrder() { + UUID orderId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setUserId(userId); + order.setStatus(OrderStatus.PROCESSING); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + + assertThrows(InvalidOrderStateException.class, + () -> orderService.updatePendingOrder(orderId, userId, "New text")); + } + + @Test + void shouldConfirmPaymentForPendingOrder() { + UUID orderId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setUserId(userId); + order.setStatus(OrderStatus.PENDING_PAYMENT); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.confirmPayment(orderId, userId); + + assertEquals(OrderStatus.PROCESSING, result.getStatus()); + } + + @Test + void shouldThrowWhenConfirmingPaymentForNonPendingOrder() { + UUID orderId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setUserId(userId); + order.setStatus(OrderStatus.CANCELLED); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + + assertThrows(InvalidOrderStateException.class, + () -> orderService.confirmPayment(orderId, userId)); + } + + @Test + void shouldThrowWhenConfirmingPaymentForOtherUsersOrder() { + UUID orderId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + UUID otherUserId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setUserId(ownerId); + order.setStatus(OrderStatus.PENDING_PAYMENT); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + + assertThrows(OrderNotFoundException.class, + () -> orderService.confirmPayment(orderId, otherUserId)); + } } diff --git a/frontend/src/__tests__/EditOrderPage.spec.ts b/frontend/src/__tests__/EditOrderPage.spec.ts new file mode 100644 index 0000000..2ae5e0c --- /dev/null +++ b/frontend/src/__tests__/EditOrderPage.spec.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createRouter, createMemoryHistory } from 'vue-router' +import EditOrderPage from '@/pages/EditOrderPage.vue' +import PaymentRedirect from '@/pages/PaymentRedirect.vue' + +vi.mock('@/api/orders', () => ({ + fetchOrder: vi.fn(), + updateOrder: vi.fn(), +})) + +import { fetchOrder, updateOrder } from '@/api/orders' + +const mockFetchOrder = vi.mocked(fetchOrder) +const mockUpdateOrder = vi.mocked(updateOrder) + +const pendingOrder = { + id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', + plate: 'DEF456', + letterText: 'Vill köpa din bil.', + status: 'pending_payment', + trackingId: null, + amountPaid: null, + createdAt: '2026-05-14T13:00:00Z', +} + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/orders', + name: 'orders', + component: { template: '
Orders
' }, + }, + { + path: '/bestallning/:orderId/redigera', + name: 'edit-order', + component: EditOrderPage, + }, + { + path: '/betalning/:orderId', + name: 'payment', + component: PaymentRedirect, + }, + ], + }) +} + +async function mountPage(orderId = pendingOrder.id) { + const pinia = createPinia() + setActivePinia(pinia) + + const router = createTestRouter() + await router.push({ name: 'edit-order', params: { orderId } }) + await router.isReady() + + const wrapper = mount(EditOrderPage, { + global: { + plugins: [router, pinia], + }, + }) + + return { wrapper, router } +} + +describe('EditOrderPage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchOrder.mockResolvedValue(pendingOrder) + mockUpdateOrder.mockResolvedValue(pendingOrder) + }) + + it('shows loading state while fetching', async () => { + mockFetchOrder.mockImplementation(() => new Promise(() => {})) + const { wrapper } = await mountPage() + expect(wrapper.text()).toContain('Laddar beställning...') + }) + + it('loads order and pre-fills textarea', async () => { + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(mockFetchOrder).toHaveBeenCalledWith(pendingOrder.id) + }) + + const textarea = wrapper.find('textarea') + expect(textarea.element.value).toBe('Vill köpa din bil.') + expect(wrapper.text()).toContain('DEF456') + expect(wrapper.text()).toContain('Redigera brev') + }) + + it('shows error when order is not pending_payment', async () => { + mockFetchOrder.mockResolvedValue({ + ...pendingOrder, + status: 'sent', + }) + + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain( + 'Den här beställningen kan inte redigeras', + ) + }) + + expect(wrapper.find('textarea').exists()).toBe(false) + expect(wrapper.text()).toContain('Tillbaka till beställningar') + }) + + it('submit calls updateOrder and navigates to payment', async () => { + const { wrapper, router } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.find('textarea').exists()).toBe(true) + }) + + const textarea = wrapper.find('textarea') + await textarea.setValue('Uppdaterat meddelande') + const button = wrapper.find('button[type="submit"]') + await button.trigger('submit') + + await vi.waitFor(() => { + expect(mockUpdateOrder).toHaveBeenCalledWith( + pendingOrder.id, + 'Uppdaterat meddelande', + ) + expect(router.currentRoute.value.name).toBe('payment') + expect(router.currentRoute.value.params.orderId).toBe(pendingOrder.id) + expect(router.currentRoute.value.query.plate).toBe('DEF456') + }) + }) + + it('shows error message on update failure', async () => { + mockUpdateOrder.mockRejectedValue(new Error('Network error')) + + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.find('textarea').exists()).toBe(true) + }) + + const textarea = wrapper.find('textarea') + await textarea.setValue('Uppdaterat meddelande') + const button = wrapper.find('button[type="submit"]') + await button.trigger('submit') + + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Kunde inte spara ändringarna') + }) + }) +}) diff --git a/frontend/src/__tests__/OrdersPage.spec.ts b/frontend/src/__tests__/OrdersPage.spec.ts index a57a73c..404f78f 100644 --- a/frontend/src/__tests__/OrdersPage.spec.ts +++ b/frontend/src/__tests__/OrdersPage.spec.ts @@ -22,6 +22,11 @@ function createTestRouter() { name: 'payment', component: { template: '
Payment
' }, }, + { + path: '/bestallning/:orderId/redigera', + name: 'edit-order', + component: { template: '
Edit
' }, + }, { path: '/', name: 'home', component: { template: '
Home
' } }, ], }) @@ -178,11 +183,14 @@ describe('OrdersPage', () => { const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) - const payLinks = wrapper.findAll('.orders__pay-btn') - expect(payLinks).toHaveLength(1) - expect(payLinks[0].text()).toBe('Betala nu') + const pendingCard = wrapper + .findAll('.orders__card') + .find((card) => card.text().includes('DEF456')) + const payLink = pendingCard?.find('a.orders__action-btn') + expect(payLink?.exists()).toBe(true) + expect(payLink?.text()).toBe('Betala nu') - const href = payLinks[0].attributes('href') + const href = payLink?.attributes('href') expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12') expect(href).toContain('plate=DEF456') }) @@ -194,7 +202,84 @@ describe('OrdersPage', () => { const sentCard = wrapper .findAll('.orders__card') .find((card) => card.text().includes('ABC123')) - expect(sentCard?.find('.orders__pay-btn').exists()).toBe(false) + expect(sentCard?.find('a.orders__action-btn').exists()).toBe(false) + }) + + it('shows edit link for pending payment orders', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + const pendingCard = wrapper + .findAll('.orders__card') + .find((card) => card.text().includes('DEF456')) + const editLinks = pendingCard?.findAll('a.orders__action-btn') ?? [] + const editLink = editLinks.find((link) => link.text() === 'Redigera') + expect(editLink?.exists()).toBe(true) + + const href = editLink?.attributes('href') + expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12') + expect(href).toContain('redigera') + }) + + it('shows cancel button for pending payment orders', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + const pendingCard = wrapper + .findAll('.orders__card') + .find((card) => card.text().includes('DEF456')) + const cancelBtn = pendingCard?.find('.orders__cancel-btn') + expect(cancelBtn?.exists()).toBe(true) + expect(cancelBtn?.text()).toBe('Avbryt beställning') + }) + + it('calls cancel API and updates status to Avbruten', async () => { + vi.stubGlobal( + 'confirm', + vi.fn(() => true), + ) + + vi.mocked(globalThis.fetch) + .mockResolvedValueOnce(mockFetchResponse(200, mockOrders)) + .mockResolvedValueOnce( + mockFetchResponse(200, { + id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', + plate: 'DEF456', + letterText: 'Vill köpa din bil.', + status: 'cancelled', + trackingId: null, + createdAt: '2026-05-14T13:00:00Z', + }), + ) + + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + const pendingCard = wrapper + .findAll('.orders__card') + .find((card) => card.text().includes('DEF456')) + await pendingCard?.find('.orders__cancel-btn').trigger('click') + await new Promise((r) => setTimeout(r, 50)) + + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12/cancel', + expect.objectContaining({ method: 'POST' }), + ) + expect(wrapper.text()).toContain('Avbruten') + + vi.unstubAllGlobals() + }) + + it('does not show edit or cancel actions for non-pending orders', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + const sentCard = wrapper + .findAll('.orders__card') + .find((card) => card.text().includes('ABC123')) + expect(sentCard?.find('.orders__cancel-btn').exists()).toBe(false) + expect(sentCard?.text()).not.toContain('Redigera') + expect(sentCard?.text()).not.toContain('Avbryt beställning') }) it('renders processing status correctly', async () => { diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 2dd34d8..e160a8b 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -14,9 +14,26 @@ export function fetchOrders(): Promise { return request('/orders') } +export function fetchOrder(id: string): Promise { + return request(`/orders/${id}`) +} + export function createOrder(plate: string, letterText: string): Promise { return request('/orders', { method: 'POST', body: JSON.stringify({ plate, letterText }), }) } + +export function updateOrder(id: string, letterText: string): Promise { + return request(`/orders/${id}`, { + method: 'PATCH', + body: JSON.stringify({ letterText }), + }) +} + +export function cancelOrder(id: string): Promise { + return request(`/orders/${id}/cancel`, { + method: 'POST', + }) +} diff --git a/frontend/src/pages/AdminPage.vue b/frontend/src/pages/AdminPage.vue index 9fab0b0..902291a 100644 --- a/frontend/src/pages/AdminPage.vue +++ b/frontend/src/pages/AdminPage.vue @@ -27,6 +27,7 @@ const statusLabels: Record = { sent: 'Skickat', delivered: 'Levererat', failed: 'Misslyckad', + cancelled: 'Avbruten', } const statusBadge: Record = { @@ -36,6 +37,7 @@ const statusBadge: Record = { sent: 'badge--success', delivered: 'badge--success', failed: 'badge--danger', + cancelled: 'badge--muted', } const allStatuses = [ @@ -45,6 +47,7 @@ const allStatuses = [ 'sent', 'delivered', 'failed', + 'cancelled', ] const stats = computed(() => { diff --git a/frontend/src/pages/EditOrderPage.vue b/frontend/src/pages/EditOrderPage.vue new file mode 100644 index 0000000..66b5ade --- /dev/null +++ b/frontend/src/pages/EditOrderPage.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/frontend/src/pages/OrdersPage.vue b/frontend/src/pages/OrdersPage.vue index 7689165..04b0078 100644 --- a/frontend/src/pages/OrdersPage.vue +++ b/frontend/src/pages/OrdersPage.vue @@ -1,11 +1,13 @@ @@ -153,6 +210,10 @@ onMounted(async () => { color: var(--color-muted); } +.orders__action-error { + margin-bottom: var(--space-md); +} + .orders__list { display: flex; flex-direction: column; @@ -226,16 +287,27 @@ onMounted(async () => { } .orders__card-actions { + display: flex; + flex-direction: column; + gap: var(--space-sm); padding: var(--space-md) var(--space-lg); border-top: 1px solid var(--color-border); background: var(--color-border-light); } -.orders__pay-btn { +.orders__action-btn { width: 100%; justify-content: center; } +.orders__cancel-btn { + color: var(--color-danger); +} + +.orders__cancel-btn:hover:not(:disabled) { + background: #fef2f2; +} + .orders__empty { padding: var(--space-2xl) 0; text-align: center; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1ac2844..6ef68a6 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -9,6 +9,7 @@ import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue' import ResetPasswordPage from '@/pages/ResetPasswordPage.vue' import ChangePasswordPage from '@/pages/ChangePasswordPage.vue' import OrdersPage from '@/pages/OrdersPage.vue' +import EditOrderPage from '@/pages/EditOrderPage.vue' import AdminPage from '@/pages/AdminPage.vue' import PaymentRedirect from '@/pages/PaymentRedirect.vue' import { useAuthStore } from '@/stores/authStore' @@ -34,6 +35,12 @@ const router = createRouter({ component: OrdersPage, meta: { requiresAuth: true }, }, + { + path: '/bestallning/:orderId/redigera', + name: 'edit-order', + component: EditOrderPage, + meta: { requiresAuth: true }, + }, { path: '/andra-losenord', name: 'change-password', From ca5ce12812eda07cff9272d7487e599565838bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 22 May 2026 11:38:26 +0200 Subject: [PATCH 2/2] Polish orders page UI for pending and completed cards. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesigns the order list so unpaid and paid orders share a consistent card layout, with clearer payment context and labeled metadata users need before paying via Swish. - Split list into Obetalda/Tidigare sections with pending orders first - Pending cards: preview box, labeled Beställnings-ID, price row, Betala 49 kr - Completed cards: same header/preview layout, prominent Spåra brev button - Replace em-dash pay label and update unit/E2E selectors Co-authored-by: Cursor --- frontend/e2e/deferred-payment-admin.spec.ts | 6 +- frontend/e2e/order-history.spec.ts | 4 +- frontend/src/__tests__/OrdersPage.spec.ts | 107 +++-- frontend/src/pages/OrdersPage.vue | 422 ++++++++++++++------ 4 files changed, 381 insertions(+), 158 deletions(-) diff --git a/frontend/e2e/deferred-payment-admin.spec.ts b/frontend/e2e/deferred-payment-admin.spec.ts index de5aa64..29bb593 100644 --- a/frontend/e2e/deferred-payment-admin.spec.ts +++ b/frontend/e2e/deferred-payment-admin.spec.ts @@ -59,15 +59,15 @@ test.describe('Deferred payment and admin lookup', () => { const orderCard = page.locator('.orders__card', { hasText: orderId }) await expect(orderCard.getByText(plate)).toBeVisible() await expect(orderCard.locator('.badge')).toHaveText('Väntar på betalning') - await expect(orderCard.getByRole('link', { name: 'Betala nu' })).toBeVisible() + await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).toBeVisible() - await orderCard.getByRole('link', { name: 'Betala nu' }).click() + await orderCard.getByRole('link', { name: 'Betala 49 kr' }).click() await expect(page).toHaveURL(new RegExp(`/betalning/${orderId}`)) await completeSwishPayment(page) await expect(page).toHaveURL('/orders') await expect(orderCard.locator('.badge')).toHaveText('Hanteras') - await expect(orderCard.getByRole('link', { name: 'Betala nu' })).not.toBeVisible() + await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).not.toBeVisible() }) test('admin finds paid order under Att göra when searching partial order id', async ({ diff --git a/frontend/e2e/order-history.spec.ts b/frontend/e2e/order-history.spec.ts index c692752..89b6750 100644 --- a/frontend/e2e/order-history.spec.ts +++ b/frontend/e2e/order-history.spec.ts @@ -66,8 +66,8 @@ test.describe('Order history', () => { await page.goto('/orders') const unpaidCard = page.locator('.orders__card', { hasText: 'DEF456' }) - await expect(unpaidCard.getByRole('link', { name: 'Betala nu' })).toBeVisible() - await unpaidCard.getByRole('link', { name: 'Betala nu' }).click() + await expect(unpaidCard.getByRole('link', { name: 'Betala 49 kr' })).toBeVisible() + await unpaidCard.getByRole('link', { name: 'Betala 49 kr' }).click() await expect(page).toHaveURL(/\/betalning\/c2eebc99/) await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible() diff --git a/frontend/src/__tests__/OrdersPage.spec.ts b/frontend/src/__tests__/OrdersPage.spec.ts index 404f78f..1c87843 100644 --- a/frontend/src/__tests__/OrdersPage.spec.ts +++ b/frontend/src/__tests__/OrdersPage.spec.ts @@ -63,13 +63,39 @@ const mockOrders = [ }, ] +function mockOrdersFetch(orders: unknown) { + vi.mocked(globalThis.fetch).mockImplementation((url, init) => { + const urlStr = String(url) + const method = init?.method ?? 'GET' + + if (urlStr.includes('/payment/swish-info')) { + return mockFetchResponse(200, { number: '1234567890', amount: 49 }) + } + + if (urlStr.includes('/cancel') && method === 'POST') { + return mockFetchResponse(200, { + id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', + plate: 'DEF456', + letterText: 'Vill köpa din bil.', + status: 'cancelled', + trackingId: null, + createdAt: '2026-05-14T13:00:00Z', + }) + } + + if (urlStr.includes('/orders')) { + return mockFetchResponse(200, orders) + } + + return mockFetchResponse(404, { message: 'Not found' }) + }) +} + describe('OrdersPage', () => { beforeEach(() => { localStorage.clear() globalThis.fetch = vi.fn() - vi.mocked(globalThis.fetch).mockResolvedValue( - mockFetchResponse(200, mockOrders), - ) + mockOrdersFetch(mockOrders) }) it('renders heading and subtitle', async () => { @@ -87,6 +113,13 @@ describe('OrdersPage', () => { expect(wrapper.text()).toContain('Laddar beställningar...') }) + it('shows section headings when pending and completed orders exist', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('Obetalda beställningar') + expect(wrapper.text()).toContain('Tidigare beställningar') + }) + it('fetches orders from API on mount', async () => { mountPage() await new Promise((r) => setTimeout(r, 50)) @@ -115,7 +148,9 @@ describe('OrdersPage', () => { await new Promise((r) => setTimeout(r, 50)) const link = wrapper.find('a[href*="postnord"]') expect(link.exists()).toBe(true) + expect(link.classes()).toContain('orders__tracking-btn') expect(link.text()).toContain('PN123456789') + expect(link.text()).toContain('Spåra brev') expect(link.attributes('target')).toBe('_blank') }) @@ -130,9 +165,7 @@ describe('OrdersPage', () => { createdAt: '2026-05-14T13:00:00Z', }, ] - vi.mocked(globalThis.fetch).mockResolvedValue( - mockFetchResponse(200, ordersWithoutTracking), - ) + mockOrdersFetch(ordersWithoutTracking) const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) const link = wrapper.find('a[href*="postnord"]') @@ -142,9 +175,8 @@ describe('OrdersPage', () => { it('renders order id and message', async () => { const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) - expect(wrapper.text()).toContain('Beställnings-ID') expect(wrapper.text()).toContain('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11') - expect(wrapper.text()).toContain('Meddelande') + expect(wrapper.text()).toContain('Beställnings-ID') expect(wrapper.text()).toContain('Hej fin bil!') expect(wrapper.text()).toContain('Vill köpa din bil.') }) @@ -153,19 +185,24 @@ describe('OrdersPage', () => { const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) expect(wrapper.text()).toContain('2026') + expect(wrapper.text()).toContain('Skapad') }) it('shows empty state when no orders', async () => { - vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, [])) + mockOrdersFetch([]) const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) expect(wrapper.text()).toContain('Inga beställningar ännu') }) it('shows error state on API failure', async () => { - vi.mocked(globalThis.fetch).mockResolvedValue( - mockFetchResponse(500, { message: 'Internal server error' }), - ) + vi.mocked(globalThis.fetch).mockImplementation((url) => { + const urlStr = String(url) + if (urlStr.includes('/payment/swish-info')) { + return mockFetchResponse(200, { number: '1234567890', amount: 49 }) + } + return mockFetchResponse(500, { message: 'Internal server error' }) + }) const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) expect(wrapper.text()).toContain('Kunde inte hämta beställningar') @@ -175,8 +212,21 @@ describe('OrdersPage', () => { const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) const badges = wrapper.findAll('.badge') - expect(badges[0].classes()).toContain('badge--success') - expect(badges[1].classes()).toContain('badge--muted') + expect(badges[0].classes()).toContain('badge--warning') + expect(badges[1].classes()).toContain('badge--success') + }) + + it('shows order id on pending payment orders', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + const pendingCard = wrapper + .findAll('.orders__card') + .find((card) => card.text().includes('DEF456')) + expect(pendingCard?.text()).toContain('Beställnings-ID') + expect(pendingCard?.text()).toContain( + 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', + ) }) it('shows pay button only for pending payment orders', async () => { @@ -186,9 +236,9 @@ describe('OrdersPage', () => { const pendingCard = wrapper .findAll('.orders__card') .find((card) => card.text().includes('DEF456')) - const payLink = pendingCard?.find('a.orders__action-btn') + const payLink = pendingCard?.find('a.orders__pay-btn') expect(payLink?.exists()).toBe(true) - expect(payLink?.text()).toBe('Betala nu') + expect(payLink?.text()).toBe('Betala 49 kr') const href = payLink?.attributes('href') expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12') @@ -202,7 +252,7 @@ describe('OrdersPage', () => { const sentCard = wrapper .findAll('.orders__card') .find((card) => card.text().includes('ABC123')) - expect(sentCard?.find('a.orders__action-btn').exists()).toBe(false) + expect(sentCard?.find('a.orders__pay-btn').exists()).toBe(false) }) it('shows edit link for pending payment orders', async () => { @@ -212,9 +262,9 @@ describe('OrdersPage', () => { const pendingCard = wrapper .findAll('.orders__card') .find((card) => card.text().includes('DEF456')) - const editLinks = pendingCard?.findAll('a.orders__action-btn') ?? [] - const editLink = editLinks.find((link) => link.text() === 'Redigera') + const editLink = pendingCard?.find('a.orders__edit-btn') expect(editLink?.exists()).toBe(true) + expect(editLink?.text()).toBe('Redigera brev') const href = editLink?.attributes('href') expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12') @@ -239,19 +289,6 @@ describe('OrdersPage', () => { vi.fn(() => true), ) - vi.mocked(globalThis.fetch) - .mockResolvedValueOnce(mockFetchResponse(200, mockOrders)) - .mockResolvedValueOnce( - mockFetchResponse(200, { - id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', - plate: 'DEF456', - letterText: 'Vill köpa din bil.', - status: 'cancelled', - trackingId: null, - createdAt: '2026-05-14T13:00:00Z', - }), - ) - const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) @@ -278,7 +315,7 @@ describe('OrdersPage', () => { .findAll('.orders__card') .find((card) => card.text().includes('ABC123')) expect(sentCard?.find('.orders__cancel-btn').exists()).toBe(false) - expect(sentCard?.text()).not.toContain('Redigera') + expect(sentCard?.text()).not.toContain('Redigera brev') expect(sentCard?.text()).not.toContain('Avbryt beställning') }) @@ -293,9 +330,7 @@ describe('OrdersPage', () => { createdAt: '2026-05-15T10:00:00Z', }, ] - vi.mocked(globalThis.fetch).mockResolvedValue( - mockFetchResponse(200, ordersWithProcessing), - ) + mockOrdersFetch(ordersWithProcessing) const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) expect(wrapper.text()).toContain('Hanteras') diff --git a/frontend/src/pages/OrdersPage.vue b/frontend/src/pages/OrdersPage.vue index 04b0078..b23bec4 100644 --- a/frontend/src/pages/OrdersPage.vue +++ b/frontend/src/pages/OrdersPage.vue @@ -1,14 +1,26 @@