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 01/14] 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 02/14] 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 @@ + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6ef68a6..86fa1d2 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -84,10 +84,14 @@ const router = createRouter({ meta: { guestOnly: true }, }, { - path: '/om', + path: '/om-oss', name: 'about', component: AboutPage, }, + { + path: '/om', + redirect: '/om-oss', + }, { path: '/kontakt', name: 'contact', From 60cb07fc898a9d207e32561ecef40ad6999cc224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 22 May 2026 12:47:46 +0200 Subject: [PATCH 06/14] Redesign contact page with separate support and complaint channels. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace single placeholder mailto with two contact cards - Show kontakt@bilhej.se for general questions and service issues - Show jcamorling@gmail.com for complaints with clearer labeling - Add tips section pointing users to Mina beställningar first - Extend ContactPage tests for both email addresses Co-authored-by: Cursor --- frontend/src/__tests__/ContactPage.spec.ts | 17 +- frontend/src/pages/ContactPage.vue | 221 +++++++++++++++++++-- 2 files changed, 219 insertions(+), 19 deletions(-) diff --git a/frontend/src/__tests__/ContactPage.spec.ts b/frontend/src/__tests__/ContactPage.spec.ts index a317239..86f39f2 100644 --- a/frontend/src/__tests__/ContactPage.spec.ts +++ b/frontend/src/__tests__/ContactPage.spec.ts @@ -3,8 +3,23 @@ import { mount } from '@vue/test-utils' import ContactPage from '@/pages/ContactPage.vue' describe('ContactPage', () => { - it('renders heading', () => { + it('renders heading and lead', () => { const wrapper = mount(ContactPage) expect(wrapper.text()).toContain('Kontakta oss') + expect(wrapper.text()).toContain('klagomål') + }) + + it('renders general support email', () => { + const wrapper = mount(ContactPage) + const link = wrapper.find('a[href="mailto:kontakt@bilhej.se"]') + expect(link.exists()).toBe(true) + expect(link.text()).toBe('kontakt@bilhej.se') + }) + + it('renders complaints email', () => { + const wrapper = mount(ContactPage) + const link = wrapper.find('a[href="mailto:jcamorling@gmail.com"]') + expect(link.exists()).toBe(true) + expect(wrapper.text()).toContain('jcamorling@gmail.com') }) }) diff --git a/frontend/src/pages/ContactPage.vue b/frontend/src/pages/ContactPage.vue index 9029413..e3ba60e 100644 --- a/frontend/src/pages/ContactPage.vue +++ b/frontend/src/pages/ContactPage.vue @@ -1,25 +1,143 @@ - + From 162002dfb1b657ec552330655fc5bb2109043aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 22 May 2026 12:47:46 +0200 Subject: [PATCH 07/14] Fix printed letter footer contact address to kontakt@bilhej.se. - Replace obsolete hej@bilhalsning.se in ComposePage GDPR footer - Apply the same correction on EditOrderPage for edited orders Co-authored-by: Cursor --- frontend/src/pages/ComposePage.vue | 2 +- frontend/src/pages/EditOrderPage.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/ComposePage.vue b/frontend/src/pages/ComposePage.vue index 089c15f..f08134a 100644 --- a/frontend/src/pages/ComposePage.vue +++ b/frontend/src/pages/ComposePage.vue @@ -22,7 +22,7 @@ const canSubmit = computed( ) const GDPR_FOOTER = - 'Detta brev skickades via Bilhej. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se' + 'Detta brev skickades via Bilhej. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: kontakt@bilhej.se' function handleTemplateSelect(template: LetterTemplate) { letterText.value = template.body diff --git a/frontend/src/pages/EditOrderPage.vue b/frontend/src/pages/EditOrderPage.vue index 66b5ade..e4fa64a 100644 --- a/frontend/src/pages/EditOrderPage.vue +++ b/frontend/src/pages/EditOrderPage.vue @@ -28,7 +28,7 @@ const canSubmit = computed( ) const GDPR_FOOTER = - 'Detta brev skickades via Bilhej. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se' + 'Detta brev skickades via Bilhej. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: kontakt@bilhej.se' function handleTemplateSelect(template: LetterTemplate) { letterText.value = template.body From 081a1f90d35d0752f9d98128e5c217b68be3ed7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 22 May 2026 12:47:46 +0200 Subject: [PATCH 08/14] Add expand/collapse for long letter previews on orders page. - Truncate previews over 120 characters with a Visa mer toggle - Allow per-order expand state on pending and completed cards - Add styles for expanded preview and toggle button - Cover long and short message behavior in OrdersPage tests Co-authored-by: Cursor --- frontend/src/__tests__/OrdersPage.spec.ts | 38 +++++++++++ frontend/src/pages/OrdersPage.vue | 78 ++++++++++++++++++++++- 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/OrdersPage.spec.ts b/frontend/src/__tests__/OrdersPage.spec.ts index 1c87843..32e8f94 100644 --- a/frontend/src/__tests__/OrdersPage.spec.ts +++ b/frontend/src/__tests__/OrdersPage.spec.ts @@ -337,4 +337,42 @@ describe('OrdersPage', () => { const badge = wrapper.find('.badge') expect(badge.classes()).toContain('badge--primary') }) + + it('shows expand toggle for long messages and reveals full text', async () => { + const longText = + 'Hej! Jag ville nämna en situation i trafiken där vi båda kanske blev lite frustrerade. Det är lätt att det blir så när man kör bil i rusningstid och tempot blir högt.' + const ordersWithLongMessage = [ + { + id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + plate: 'ABC123', + letterText: longText, + status: 'processing', + trackingId: null, + createdAt: '2026-05-11T12:00:00Z', + }, + ] + mockOrdersFetch(ordersWithLongMessage) + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + const card = wrapper.find('.orders__card') + const preview = card.find('.orders__preview') + const toggle = card.find('.orders__preview-toggle') + + expect(toggle.exists()).toBe(true) + expect(toggle.text()).toBe('Visa mer') + expect(preview.classes()).not.toContain('orders__preview--expanded') + + await toggle.trigger('click') + + expect(preview.classes()).toContain('orders__preview--expanded') + expect(toggle.text()).toBe('Visa mindre') + expect(card.text()).toContain(longText) + }) + + it('does not show expand toggle for short messages', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false) + }) }) diff --git a/frontend/src/pages/OrdersPage.vue b/frontend/src/pages/OrdersPage.vue index b23bec4..bb84c35 100644 --- a/frontend/src/pages/OrdersPage.vue +++ b/frontend/src/pages/OrdersPage.vue @@ -12,6 +12,27 @@ const loading = ref(true) const error = ref('') const actionError = ref('') const cancellingId = ref(null) +const expandedPreviewIds = ref>(new Set()) + +const PREVIEW_CHAR_LIMIT = 120 + +function isLongMessage(text: string): boolean { + return text.length > PREVIEW_CHAR_LIMIT +} + +function isPreviewExpanded(orderId: string): boolean { + return expandedPreviewIds.value.has(orderId) +} + +function togglePreview(orderId: string) { + const next = new Set(expandedPreviewIds.value) + if (next.has(orderId)) { + next.delete(orderId) + } else { + next.add(orderId) + } + expandedPreviewIds.value = next +} const pendingOrders = computed(() => orders.value.filter((order) => order.status === 'pending_payment'), @@ -150,7 +171,22 @@ onMounted(loadOrders)
-

{{ order.letterText }}

+

+ {{ order.letterText }} +

+

@@ -233,7 +269,22 @@ onMounted(loadOrders)

-

{{ order.letterText }}

+

+ {{ order.letterText }} +

+

@@ -374,6 +425,29 @@ onMounted(loadOrders) overflow: hidden; } +.orders__preview--expanded { + display: block; + -webkit-line-clamp: unset; + line-clamp: unset; + overflow: visible; +} + +.orders__preview-toggle { + margin-top: var(--space-sm); + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-primary); + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +.orders__preview-toggle:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + .orders__order-date { margin: 0 0 var(--space-sm) 0; font-size: 0.8125rem; From 255095e6bd12b3ad43aeeafa690d07888378c1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 22 May 2026 12:59:49 +0200 Subject: [PATCH 09/14] Document kontakt@bilhej.se receiving and fix stale contact address in requirements. - Add production checklist section for Resend inbound on bilhej.se - Note that mail is read in the Resend dashboard unless a webhook is added later - Update GDPR letter footer example in REQUIREMENTS.md to kontakt@bilhej.se Co-authored-by: Cursor --- REQUIREMENTS.md | 2 +- docs/production-email-checklist.md | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 22ff751..14da9f6 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -446,7 +446,7 @@ Gross margin: 14 SEK | Is a license plate personal data? | Yes (it directly identifies a vehicle owner). | | Is an address personal data? | Yes. | | What if we only process address transiently? | Data minimization is a GDPR principle (Art. 5(1)(c)). Transient processing with immediate deletion is a strong compliance posture. | -| Do we need to inform the recipient? | Yes, GDPR Art. 14 requires informing the data subject. The letter itself can serve this purpose — include a footer like: _"Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se"_ | +| Do we need to inform the recipient? | Yes, GDPR Art. 14 requires informing the data subject. The letter itself can serve this purpose — include a footer like: _"Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: kontakt@bilhej.se"_ | ### 11.2 Transportstyrelsen Access diff --git a/docs/production-email-checklist.md b/docs/production-email-checklist.md index 36dddb3..aed77fa 100644 --- a/docs/production-email-checklist.md +++ b/docs/production-email-checklist.md @@ -54,3 +54,24 @@ Fallback: reset links still log when `MAIL_HOST` is empty. Keep using Mailpit (`docker compose up`, http://localhost:8025). Do not point local Docker at Resend unless you intend to send real mail. + +## 5. Contact email (`kontakt@bilhej.se`) + +Inbound mail uses **Resend Receiving** on the root domain `bilhej.se`. No mailbox is created in +Strato; the MX record routes all `@bilhej.se` addresses to Resend. + +**Setup (done once):** + +1. Resend → **Domains** → `bilhej.se` → enable **Receiving** +2. Strato → **DNS** → add the receiving MX record (e.g. `inbound-smtp.eu-west-1.amazonaws.com`) +3. Wait until Resend shows receiving as **Verified** +4. Send a test mail to `kontakt@bilhej.se` and confirm it appears under **Emails → Receiving** + +**Reading mail:** open the [Resend Receiving inbox](https://resend.com/emails/receiving). There is +no automatic forward to Gmail unless you add a webhook handler later. + +| Address | Purpose | Where mail goes | +|---------|---------|-----------------| +| `kontakt@bilhej.se` | General questions (site, orders, support) | Resend dashboard | +| `jcamorling@gmail.com` | Complaints (shown on `/kontakt` only) | Gmail directly | +| `noreply@bilhej.se` | Outbound only (password reset) | Not an inbox | From c0c32b718b2ff54d27964d35cfd157223d37e09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 22 May 2026 13:51:11 +0200 Subject: [PATCH 10/14] Merge about page prose into hero and drop redundant section heading. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the three explanatory paragraphs into the hero card under the lead - Remove the separate "Vad vi gör" section that repeated the same framing - Add a light divider between lead and body text for readability Co-authored-by: Cursor --- frontend/src/pages/AboutPage.vue | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/AboutPage.vue b/frontend/src/pages/AboutPage.vue index 034999c..600d764 100644 --- a/frontend/src/pages/AboutPage.vue +++ b/frontend/src/pages/AboutPage.vue @@ -29,10 +29,6 @@ const highlights = [ Bilhej gör det enkelt att nå en bilägare med ett fysiskt brev. Du skriver meddelandet, vi sköter utskick och post.

- - -
-

Vad vi gör

Många situationer i trafiken eller på parkeringen är enklare att lösa @@ -126,12 +122,17 @@ const highlights = [ } .about__lead { - margin: 0; + margin: 0 0 var(--space-lg) 0; font-size: 1.0625rem; line-height: 1.75; color: var(--color-muted); } +.about__prose { + padding-top: var(--space-lg); + border-top: 1px solid var(--color-border); +} + .about__section { margin-bottom: var(--space-2xl); } From bce24472383e5f6ab5faccd3eebfd7c0f29c2bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 22 May 2026 13:51:20 +0200 Subject: [PATCH 11/14] Rework contact page emails and simplify mailto actions. - Add support@bilhej.se for orders and technical issues - Move complaints to klagomal@bilhej.se instead of personal Gmail - Show one mailto chip per card instead of duplicate link and button - Update ContactPage tests and production email checklist for all @bilhej.se addresses Co-authored-by: Cursor --- docs/production-email-checklist.md | 16 ++++-- frontend/src/__tests__/ContactPage.spec.ts | 21 +++++--- frontend/src/pages/ContactPage.vue | 60 +++++++++++++--------- 3 files changed, 62 insertions(+), 35 deletions(-) diff --git a/docs/production-email-checklist.md b/docs/production-email-checklist.md index aed77fa..fa4580f 100644 --- a/docs/production-email-checklist.md +++ b/docs/production-email-checklist.md @@ -55,23 +55,29 @@ Fallback: reset links still log when `MAIL_HOST` is empty. Keep using Mailpit (`docker compose up`, http://localhost:8025). Do not point local Docker at Resend unless you intend to send real mail. -## 5. Contact email (`kontakt@bilhej.se`) +## 5. Inbound email on bilhej.se Inbound mail uses **Resend Receiving** on the root domain `bilhej.se`. No mailbox is created in -Strato; the MX record routes all `@bilhej.se` addresses to Resend. +Strato; the MX record routes all `@bilhej.se` addresses to Resend. You do not create each address +separately in Resend. **Setup (done once):** 1. Resend → **Domains** → `bilhej.se` → enable **Receiving** 2. Strato → **DNS** → add the receiving MX record (e.g. `inbound-smtp.eu-west-1.amazonaws.com`) 3. Wait until Resend shows receiving as **Verified** -4. Send a test mail to `kontakt@bilhej.se` and confirm it appears under **Emails → Receiving** +4. Send test mail to `support@bilhej.se` and `kontakt@bilhej.se`; confirm both appear under **Emails → Receiving** **Reading mail:** open the [Resend Receiving inbox](https://resend.com/emails/receiving). There is no automatic forward to Gmail unless you add a webhook handler later. | Address | Purpose | Where mail goes | |---------|---------|-----------------| -| `kontakt@bilhej.se` | General questions (site, orders, support) | Resend dashboard | -| `jcamorling@gmail.com` | Complaints (shown on `/kontakt` only) | Gmail directly | +| `support@bilhej.se` | Orders, Swish, status, technical issues | Resend dashboard | +| `kontakt@bilhej.se` | General contact, printed letter footer | Resend dashboard | +| `klagomal@bilhej.se` | Complaints (shown on `/kontakt`) | Resend dashboard | | `noreply@bilhej.se` | Outbound only (password reset) | Not an inbox | + +**Optional later (same Resend inbox, no extra DNS):** `abuse@bilhej.se` if you want a published +address for misuse reports; `privacy@bilhej.se` if integritetspolicy asks for a dedicated +data-protection contact. diff --git a/frontend/src/__tests__/ContactPage.spec.ts b/frontend/src/__tests__/ContactPage.spec.ts index 86f39f2..30e922b 100644 --- a/frontend/src/__tests__/ContactPage.spec.ts +++ b/frontend/src/__tests__/ContactPage.spec.ts @@ -9,17 +9,26 @@ describe('ContactPage', () => { expect(wrapper.text()).toContain('klagomål') }) - it('renders general support email', () => { + it('renders support email', () => { const wrapper = mount(ContactPage) - const link = wrapper.find('a[href="mailto:kontakt@bilhej.se"]') - expect(link.exists()).toBe(true) - expect(link.text()).toBe('kontakt@bilhej.se') + expect(wrapper.text()).toContain('support@bilhej.se') + }) + + it('renders general contact email', () => { + const wrapper = mount(ContactPage) + expect(wrapper.text()).toContain('kontakt@bilhej.se') }) it('renders complaints email', () => { const wrapper = mount(ContactPage) - const link = wrapper.find('a[href="mailto:jcamorling@gmail.com"]') + expect(wrapper.text()).toContain('klagomal@bilhej.se') + }) + + it('links support to mailto', () => { + const wrapper = mount(ContactPage) + const link = wrapper.find('a[href="mailto:support@bilhej.se"]') expect(link.exists()).toBe(true) - expect(wrapper.text()).toContain('jcamorling@gmail.com') + expect(link.text()).toBe('support@bilhej.se') + expect(link.attributes('aria-label')).toBe('Skicka till support: support@bilhej.se') }) }) diff --git a/frontend/src/pages/ContactPage.vue b/frontend/src/pages/ContactPage.vue index e3ba60e..462fed1 100644 --- a/frontend/src/pages/ContactPage.vue +++ b/frontend/src/pages/ContactPage.vue @@ -1,19 +1,27 @@ + + + + From ec62ba7673aa449d6364734752fad5eb8e44c85d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 22 May 2026 13:51:20 +0200 Subject: [PATCH 13/14] =?UTF-8?q?Add=20anv=C3=A4ndarvillkor=20page=20for?= =?UTF-8?q?=20Bilhej=20service=20terms.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create TermsOfServicePage covering Swish payment, letter content rules, and liability - Describe edit/cancel before payment and refund expectations after posting - Link to integritetspolicy and support contact in footer CTA - Add TermsOfServicePage unit tests Co-authored-by: Cursor --- .../src/__tests__/TermsOfServicePage.spec.ts | 58 ++++ frontend/src/pages/TermsOfServicePage.vue | 258 ++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 frontend/src/__tests__/TermsOfServicePage.spec.ts create mode 100644 frontend/src/pages/TermsOfServicePage.vue diff --git a/frontend/src/__tests__/TermsOfServicePage.spec.ts b/frontend/src/__tests__/TermsOfServicePage.spec.ts new file mode 100644 index 0000000..c21e0b2 --- /dev/null +++ b/frontend/src/__tests__/TermsOfServicePage.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import TermsOfServicePage from '@/pages/TermsOfServicePage.vue' + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/villkor', + name: 'terms', + component: TermsOfServicePage, + }, + { + path: '/integritetspolicy', + name: 'privacy', + component: { template: '

Integritet
' }, + }, + { + path: '/kontakt', + name: 'contact', + component: { template: '
Kontakt
' }, + }, + ], + }) +} + +describe('TermsOfServicePage', () => { + it('renders title and lead', () => { + const router = createTestRouter() + const wrapper = mount(TermsOfServicePage, { + global: { plugins: [router] }, + }) + expect(wrapper.text()).toContain('Användarvillkor') + expect(wrapper.text()).toContain('49 kr') + }) + + it('describes payment and order rules', () => { + const router = createTestRouter() + const wrapper = mount(TermsOfServicePage, { + global: { plugins: [router] }, + }) + expect(wrapper.text()).toContain('Swish') + expect(wrapper.text()).toContain('Obetalda beställningar kan redigeras') + }) + + it('links to privacy policy and support email', () => { + const router = createTestRouter() + const wrapper = mount(TermsOfServicePage, { + global: { plugins: [router] }, + }) + expect(wrapper.find('a[href="/integritetspolicy"]').exists()).toBe(true) + expect(wrapper.find('a[href="mailto:support@bilhej.se"]').exists()).toBe( + true, + ) + }) +}) diff --git a/frontend/src/pages/TermsOfServicePage.vue b/frontend/src/pages/TermsOfServicePage.vue new file mode 100644 index 0000000..505eeb6 --- /dev/null +++ b/frontend/src/pages/TermsOfServicePage.vue @@ -0,0 +1,258 @@ + + + + + From a12e07ec1c210c049bd5321f7ca4be7821c365cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 22 May 2026 13:51:20 +0200 Subject: [PATCH 14/14] Register routes for integritetspolicy and villkor legal pages. - Add /integritetspolicy and /villkor to Vue Router - Add Router tests confirming both public legal routes resolve Co-authored-by: Cursor --- frontend/src/__tests__/Router.spec.ts | 12 ++++++++++++ frontend/src/router/index.ts | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/frontend/src/__tests__/Router.spec.ts b/frontend/src/__tests__/Router.spec.ts index 257431a..3a5f1de 100644 --- a/frontend/src/__tests__/Router.spec.ts +++ b/frontend/src/__tests__/Router.spec.ts @@ -32,6 +32,18 @@ describe('Router', () => { expect(router.currentRoute.value.name).toBe('forgot-password') }) + it('resolves /integritetspolicy to PrivacyPolicyPage', async () => { + await router.push('/integritetspolicy') + await router.isReady() + expect(router.currentRoute.value.name).toBe('privacy') + }) + + it('resolves /villkor to TermsOfServicePage', async () => { + await router.push('/villkor') + await router.isReady() + expect(router.currentRoute.value.name).toBe('terms') + }) + it('resolves /aterstall-losenord to ResetPasswordPage', async () => { await router.push('/aterstall-losenord?token=abc') await router.isReady() diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 86fa1d2..3ec2a98 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -3,6 +3,8 @@ import HomePage from '@/pages/HomePage.vue' import ComposePage from '@/pages/ComposePage.vue' import AboutPage from '@/pages/AboutPage.vue' import ContactPage from '@/pages/ContactPage.vue' +import PrivacyPolicyPage from '@/pages/PrivacyPolicyPage.vue' +import TermsOfServicePage from '@/pages/TermsOfServicePage.vue' import RegisterPage from '@/pages/RegisterPage.vue' import LoginPage from '@/pages/LoginPage.vue' import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue' @@ -97,6 +99,16 @@ const router = createRouter({ name: 'contact', component: ContactPage, }, + { + path: '/integritetspolicy', + name: 'privacy', + component: PrivacyPolicyPage, + }, + { + path: '/villkor', + name: 'terms', + component: TermsOfServicePage, + }, ], })