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/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 e4c6c4b..e84039e 100644 --- a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java @@ -51,6 +51,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/docs/production-email-checklist.md b/docs/production-email-checklist.md index 36dddb3..fa4580f 100644 --- a/docs/production-email-checklist.md +++ b/docs/production-email-checklist.md @@ -54,3 +54,30 @@ 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. 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. 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 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 | +|---------|---------|-----------------| +| `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/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..b672fe4 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() @@ -91,4 +91,38 @@ test.describe('Order history', () => { const trackingLink2 = page.getByRole('link', { name: 'PN987654321' }) await expect(trackingLink2).toBeVisible() }) + + test('can cancel pending order', async ({ page }) => { + const plate = 'CAN999' + + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill('test@bilhej.se') + await page.getByLabel('Lösenord').fill('test1234') + await page.getByRole('button', { name: 'Logga in' }).click() + await page.waitForURL('/') + + await page.goto(`/compose?plate=${plate}`) + await page.getByLabel('Ditt meddelande').fill('E2E-test: ska kunna avbrytas.') + await page.getByRole('button', { name: 'Fortsätt till betalning' }).click() + await expect(page).toHaveURL(/\/betalning\//) + + await page.goto('/orders') + + const pendingCard = page.locator('.orders__card', { hasText: plate }) + await expect(pendingCard.getByText('Väntar på betalning')).toBeVisible() + await expect( + pendingCard.getByRole('link', { name: 'Betala 49 kr' }), + ).toBeVisible() + + page.once('dialog', (dialog) => dialog.accept()) + await pendingCard.getByRole('button', { name: 'Avbryt beställning' }).click() + + await expect(pendingCard.getByText('Avbruten')).toBeVisible() + await expect( + pendingCard.getByRole('link', { name: 'Betala 49 kr' }), + ).not.toBeVisible() + await expect( + pendingCard.getByRole('button', { name: 'Avbryt beställning' }), + ).not.toBeVisible() + }) }) diff --git a/frontend/src/__tests__/AboutPage.spec.ts b/frontend/src/__tests__/AboutPage.spec.ts index ac4ebd8..8563c1f 100644 --- a/frontend/src/__tests__/AboutPage.spec.ts +++ b/frontend/src/__tests__/AboutPage.spec.ts @@ -1,10 +1,44 @@ import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' import AboutPage from '@/pages/AboutPage.vue' +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/om-oss', name: 'about', component: AboutPage }, + { path: '/', name: 'home', component: { template: '
Home
' } }, + ], + }) +} + describe('AboutPage', () => { - it('renders heading', () => { - const wrapper = mount(AboutPage) + it('renders heading and lead', () => { + const router = createTestRouter() + const wrapper = mount(AboutPage, { + global: { plugins: [router] }, + }) expect(wrapper.text()).toContain('Om Bilhej') + expect(wrapper.text()).toContain('Bilhej gör det enkelt') + }) + + it('renders how-it-works steps', () => { + const router = createTestRouter() + const wrapper = mount(AboutPage, { + global: { plugins: [router] }, + }) + expect(wrapper.text()).toContain('Skriv brevet här') + expect(wrapper.text()).toContain('Vi postar åt dig') + }) + + it('links to home page', () => { + const router = createTestRouter() + const wrapper = mount(AboutPage, { + global: { plugins: [router] }, + }) + const cta = wrapper.find('a.about__cta-btn') + expect(cta.exists()).toBe(true) + expect(cta.attributes('href')).toBe('/') }) }) diff --git a/frontend/src/__tests__/AppFooter.spec.ts b/frontend/src/__tests__/AppFooter.spec.ts index 42587d1..80f9b85 100644 --- a/frontend/src/__tests__/AppFooter.spec.ts +++ b/frontend/src/__tests__/AppFooter.spec.ts @@ -8,10 +8,14 @@ function createTestRouter() { history: createMemoryHistory(), routes: [ { - path: '/om', + path: '/om-oss', name: 'about', component: { template: '
About
' }, }, + { + path: '/om', + redirect: '/om-oss', + }, { path: '/kontakt', name: 'contact', @@ -40,7 +44,7 @@ describe('AppFooter', () => { const links = wrapper.findAll('a') expect(links[0].text()).toBe('Om oss') - expect(links[0].attributes('href')).toBe('/om') + expect(links[0].attributes('href')).toBe('/om-oss') expect(links[1].text()).toBe('Kontakt') expect(links[1].attributes('href')).toBe('/kontakt') diff --git a/frontend/src/__tests__/ContactPage.spec.ts b/frontend/src/__tests__/ContactPage.spec.ts index a317239..30e922b 100644 --- a/frontend/src/__tests__/ContactPage.spec.ts +++ b/frontend/src/__tests__/ContactPage.spec.ts @@ -3,8 +3,32 @@ 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 support email', () => { + const wrapper = mount(ContactPage) + 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) + 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(link.text()).toBe('support@bilhej.se') + expect(link.attributes('aria-label')).toBe('Skicka till support: support@bilhej.se') }) }) 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..32e8f94 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
' } }, ], }) @@ -58,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 () => { @@ -82,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)) @@ -110,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') }) @@ -125,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"]') @@ -137,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.') }) @@ -148,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') @@ -170,19 +212,35 @@ 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 () => { 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__pay-btn') + expect(payLink?.exists()).toBe(true) + expect(payLink?.text()).toBe('Betala 49 kr') - 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 +252,71 @@ 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__pay-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 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') + 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), + ) + + 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 brev') + expect(sentCard?.text()).not.toContain('Avbryt beställning') }) it('renders processing status correctly', async () => { @@ -208,13 +330,49 @@ 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') 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/__tests__/PrivacyPolicyPage.spec.ts b/frontend/src/__tests__/PrivacyPolicyPage.spec.ts new file mode 100644 index 0000000..f7f0d2c --- /dev/null +++ b/frontend/src/__tests__/PrivacyPolicyPage.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import PrivacyPolicyPage from '@/pages/PrivacyPolicyPage.vue' + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/integritetspolicy', + name: 'privacy', + component: PrivacyPolicyPage, + }, + { + path: '/kontakt', + name: 'contact', + component: { template: '
Kontakt
' }, + }, + ], + }) +} + +describe('PrivacyPolicyPage', () => { + it('renders title and lead', () => { + const router = createTestRouter() + const wrapper = mount(PrivacyPolicyPage, { + global: { plugins: [router] }, + }) + expect(wrapper.text()).toContain('Integritetspolicy') + expect(wrapper.text()).toContain('personuppgifter') + }) + + it('describes sender and recipient data handling', () => { + const router = createTestRouter() + const wrapper = mount(PrivacyPolicyPage, { + global: { plugins: [router] }, + }) + expect(wrapper.text()).toContain('Mottagarens postadress') + expect(wrapper.text()).toContain('sparas inte efter utskick') + expect(wrapper.text()).toContain('varken vi eller obehöriga') + }) + + it('links to contact email and contact page', () => { + const router = createTestRouter() + const wrapper = mount(PrivacyPolicyPage, { + global: { plugins: [router] }, + }) + expect(wrapper.find('a[href="mailto:kontakt@bilhej.se"]').exists()).toBe( + true, + ) + expect(wrapper.find('a.policy__link').attributes('href')).toBe('/kontakt') + }) +}) diff --git a/frontend/src/__tests__/Router.spec.ts b/frontend/src/__tests__/Router.spec.ts index 0e1a6d5..fdc3f05 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/__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/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/components/AppFooter.vue b/frontend/src/components/AppFooter.vue index 0d11d4c..b83473f 100644 --- a/frontend/src/components/AppFooter.vue +++ b/frontend/src/components/AppFooter.vue @@ -6,11 +6,10 @@ import { RouterLink } from 'vue-router'