From 6381b6fd63ce6e4ecea97a6dd7ebeffd2f6acc83 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 19:15:01 +0000 Subject: [PATCH 1/5] feat(guest): guest checkout without login (Swish + QR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an anonymous guest checkout flow so a customer can order a bilhälsning without creating an account. Payment via Swish (QR + payment link). Backend: - GuestOrderController: POST /api/guest-orders (public, no auth) - CreateGuestOrderRequest / GuestOrderResponse DTOs - Order entity: guest_email, guest_token (UUID), nullable user_id - OrderRepository: findByGuestToken, findByGuestEmail - OrderService: createGuestOrder, getGuestOrder by token - SecurityConfig: /api/guest-orders/** permitAll - V12 migration: drops user_id NOT NULL, adds guest_email + guest_token with partial unique index (backfill-safe for existing user orders) Frontend: - GuestCheckoutPage: plate lookup + order form (no login) - GuestPaymentRedirect: Swish QR + payment link + status polling - GuestOrderPage: order status by guest token - guestOrders.ts API client - router: /guest/* public routes - vite.config: dev proxy for /api/guest-orders Verification: - [x] vue-tsc type-check passes (exit 0) - [ ] Backend Java compiles (no JDK/docker in agent sandbox) - [ ] Flyway V12 migration applies cleanly - [ ] End-to-end POST /api/guest-orders -> 201 -> Swish -> status Frontend type-checks but backend has NOT been compiled or run yet. This PR is for review; backend smoke test pending in a docker environment. --- .../se/bilhalsning/config/SecurityConfig.java | 1 + .../controller/GuestOrderController.java | 75 ++++ .../dto/CreateGuestOrderRequest.java | 27 ++ .../bilhalsning/dto/GuestOrderResponse.java | 24 ++ .../java/se/bilhalsning/entity/Order.java | 43 +- .../repository/OrderRepository.java | 4 + .../se/bilhalsning/service/OrderService.java | 50 +++ .../V12__add_guest_order_columns.sql | 24 ++ frontend/src/api/guestOrders.ts | 39 ++ frontend/src/pages/GuestCheckoutPage.vue | 188 +++++++++ frontend/src/pages/GuestOrderPage.vue | 198 +++++++++ frontend/src/pages/GuestPaymentRedirect.vue | 386 ++++++++++++++++++ frontend/src/router/index.ts | 21 + frontend/vite.config.ts | 6 +- 14 files changed, 1084 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/se/bilhalsning/controller/GuestOrderController.java create mode 100644 backend/src/main/java/se/bilhalsning/dto/CreateGuestOrderRequest.java create mode 100644 backend/src/main/java/se/bilhalsning/dto/GuestOrderResponse.java create mode 100644 backend/src/main/resources/db/migration/V12__add_guest_order_columns.sql create mode 100644 frontend/src/api/guestOrders.ts create mode 100644 frontend/src/pages/GuestCheckoutPage.vue create mode 100644 frontend/src/pages/GuestOrderPage.vue create mode 100644 frontend/src/pages/GuestPaymentRedirect.vue diff --git a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java index cdc2962..1150ad2 100644 --- a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java +++ b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java @@ -55,6 +55,7 @@ public class SecurityConfig { .permitAll() .requestMatchers("/api/webhooks/**").permitAll() .requestMatchers("/api/payment/swish-info").permitAll() + .requestMatchers("/api/guest-orders/**").permitAll() .requestMatchers("/api/vehicles/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) diff --git a/backend/src/main/java/se/bilhalsning/controller/GuestOrderController.java b/backend/src/main/java/se/bilhalsning/controller/GuestOrderController.java new file mode 100644 index 0000000..de8c3ed --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/controller/GuestOrderController.java @@ -0,0 +1,75 @@ +package se.bilhalsning.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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.CreateGuestOrderRequest; +import se.bilhalsning.dto.GuestOrderResponse; +import se.bilhalsning.entity.Order; +import se.bilhalsning.service.OrderService; + +import java.util.UUID; + +/** + * Public (no-JWT) endpoints for placing and paying for orders without an + * account — guest checkout. + * + * Auth: white-listed in {@code SecurityConfig} so no JWT filter runs on + * these paths. The guest token (UUID v4, generated at order create time + * in {@link Order#onCreate()}) is the only credential the client holds + * and must pass as a path variable. Token brute-force resistance: 122 + * bits of entropy. + * + * Routes parallel {@link OrderController} deliberately so the JWT path + * stays clean and unmodified. + */ +@RestController +@RequestMapping("/api/guest-orders") +@RequiredArgsConstructor +public class GuestOrderController { + + private final OrderService orderService; + + @PostMapping + public ResponseEntity create( + @Valid @RequestBody CreateGuestOrderRequest request) { + Order order = orderService.createGuestOrder( + request.plate(), + request.letterText(), + request.email() + ); + return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order)); + } + + @GetMapping("/{token}") + public ResponseEntity get(@PathVariable UUID token) { + Order order = orderService.getOrderByGuestToken(token); + return ResponseEntity.ok(toResponse(order)); + } + + @PostMapping("/{token}/pay") + public ResponseEntity pay(@PathVariable UUID token) { + Order order = orderService.confirmGuestPayment(token); + return ResponseEntity.ok(toResponse(order)); + } + + private GuestOrderResponse toResponse(Order order) { + return new GuestOrderResponse( + order.getId(), + order.getPlate(), + order.getLetterText(), + order.getStatus().getValue(), + order.getTrackingId(), + order.getAmountPaid(), + order.getCreatedAt(), + order.getGuestToken() + ); + } +} diff --git a/backend/src/main/java/se/bilhalsning/dto/CreateGuestOrderRequest.java b/backend/src/main/java/se/bilhalsning/dto/CreateGuestOrderRequest.java new file mode 100644 index 0000000..651c522 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/CreateGuestOrderRequest.java @@ -0,0 +1,27 @@ +package se.bilhalsning.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Create an order without an account (guest checkout). + * + * The {@code plate} validation mirrors {@link CreateOrderRequest} so the + * guest path accepts the same Swedish 3-letter + 3-char plate format. + */ +public record CreateGuestOrderRequest( + @NotBlank(message = "Registreringsnummer krävs") + @Pattern(regexp = "^[A-Za-z]{3}\\d{2}[A-Za-z0-9]$", message = "Ogiltigt registreringsnummer") + String plate, + + @NotBlank(message = "Brevtext krävs") + @Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken") + String letterText, + + @NotBlank(message = "E-post krävs") + @Email(message = "Ogiltig e-postadress") + @Size(max = 255, message = "E-postadressen är för lång") + String email +) {} diff --git a/backend/src/main/java/se/bilhalsning/dto/GuestOrderResponse.java b/backend/src/main/java/se/bilhalsning/dto/GuestOrderResponse.java new file mode 100644 index 0000000..6e1a120 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/GuestOrderResponse.java @@ -0,0 +1,24 @@ +package se.bilhalsning.dto; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +/** + * Response shape for guest-order endpoints. + * + * Identical to {@link OrderResponse} plus {@link #guestToken}, which the + * client needs to (a) build the payment page URL and (b) look up the + * order again without an account. Token is returned only on create and + * by-token lookup — it is never re-exposed by order ID alone. + */ +public record GuestOrderResponse( + UUID id, + String plate, + String letterText, + String status, + String trackingId, + BigDecimal amountPaid, + Instant createdAt, + UUID guestToken +) {} diff --git a/backend/src/main/java/se/bilhalsning/entity/Order.java b/backend/src/main/java/se/bilhalsning/entity/Order.java index 9438da7..d3cbdd2 100644 --- a/backend/src/main/java/se/bilhalsning/entity/Order.java +++ b/backend/src/main/java/se/bilhalsning/entity/Order.java @@ -21,7 +21,12 @@ public class Order { @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) private UUID id; - @Column(name = "user_id", nullable = false, columnDefinition = "uuid") + /** + * Null for guest orders (no registered user). FK to {@code users(id)} + * is still in place — NULL is FK-legal. Either {@code userId} or + * {@code guestToken} is set; never both, never neither. + */ + @Column(name = "user_id", columnDefinition = "uuid") private UUID userId; @ManyToOne(fetch = FetchType.LAZY) @@ -55,11 +60,31 @@ public class Order { @Column(name = "updated_at", nullable = false) private Instant updatedAt; + /** + * Guest contact email. Stored at order create time so a magic link + * (carrying {@code guestToken}) can be emailed to the customer later. + */ + @Column(name = "guest_email", length = 255) + private String guestEmail; + + /** + * Opaque UUID v4 token. The only credential a guest holds. Used as + * the path variable on all {@code /api/guest-orders/{token}/...} + * endpoints. Generated in {@link #onCreate()} for guest orders only. + */ + @Column(name = "guest_token", columnDefinition = "uuid") + private UUID guestToken; + @PrePersist void onCreate() { if (this.id == null) { this.id = UUID.randomUUID(); } + // Guest orders (no userId) get a token for unauthenticated lookup. + // User-owned orders never get one — they go through JWT + userId. + if (this.guestToken == null && this.userId == null) { + this.guestToken = UUID.randomUUID(); + } Instant now = Instant.now(); if (this.createdAt == null) { this.createdAt = now; @@ -159,4 +184,20 @@ public class Order { public Instant getUpdatedAt() { return updatedAt; } + + public String getGuestEmail() { + return guestEmail; + } + + public void setGuestEmail(String guestEmail) { + this.guestEmail = guestEmail; + } + + public UUID getGuestToken() { + return guestToken; + } + + public void setGuestToken(UUID guestToken) { + this.guestToken = guestToken; + } } diff --git a/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java b/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java index 68441a3..bfa9337 100644 --- a/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java +++ b/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java @@ -21,4 +21,8 @@ public interface OrderRepository extends JpaRepository { @EntityGraph(attributePaths = {"user"}) Optional findWithUserById(UUID id); + + // Guest checkout — looks up by opaque token, no JWT. Partial-unique + // index on (guest_token) WHERE NOT NULL enforces uniqueness on writes. + Optional findByGuestToken(UUID guestToken); } diff --git a/backend/src/main/java/se/bilhalsning/service/OrderService.java b/backend/src/main/java/se/bilhalsning/service/OrderService.java index fccdc11..34528e9 100644 --- a/backend/src/main/java/se/bilhalsning/service/OrderService.java +++ b/backend/src/main/java/se/bilhalsning/service/OrderService.java @@ -27,6 +27,56 @@ public class OrderService { return orderRepository.save(order); } + /** + * Guest checkout — creates an order with no registered user. The + * {@code guestToken} is generated in {@link Order#onCreate()} so the + * caller does not need to handle token creation logic. Returned + * order's {@link Order#getGuestToken()} is the only credential the + * client receives. + */ + public Order createGuestOrder(String plate, String letterText, String email) { + Order order = new Order(); + // userId stays null — guest order. guestToken auto-generated in PrePersist. + order.setGuestEmail(email == null ? null : email.trim().toLowerCase()); + order.setPlate(plate.toUpperCase().trim()); + order.setLetterText(letterText); + order.setStatus(OrderStatus.PENDING_PAYMENT); + return orderRepository.save(order); + } + + /** + * Guest-path order lookup by opaque token. Never exposes user-owned + * orders via this path: if a guest token resolves to an order with a + * non-null {@code userId} (corrupted data, manual SQL insert, etc.), + * treat as not-found rather than leak the order. + */ + public Order getOrderByGuestToken(UUID guestToken) { + Order order = orderRepository.findByGuestToken(guestToken) + .orElseThrow(() -> new OrderNotFoundException(guestToken)); + if (order.getUserId() != null) { + // Token points at a user-owned order — refuse to serve it. + throw new OrderNotFoundException(guestToken); + } + return order; + } + + /** + * Honor-system payment confirmation for guest orders. Mirrors + * {@link #confirmPayment(UUID, UUID)} but authenticates via the + * guest token instead of {@code userId}. + */ + public Order confirmGuestPayment(UUID guestToken) { + Order order = getOrderByGuestToken(guestToken); + if (order.getStatus() != OrderStatus.PENDING_PAYMENT) { + throw new InvalidOrderStateException( + "Beställningen kan inte ändras i detta tillstånd"); + } + order.setStatus(OrderStatus.PROCESSING); + Order saved = orderRepository.save(order); + orderNotificationService.notifyOrderProcessing(saved); + return saved; + } + public List getOrdersByUserId(UUID userId) { return orderRepository.findByUserIdOrderByCreatedAtDesc(userId); } diff --git a/backend/src/main/resources/db/migration/V12__add_guest_order_columns.sql b/backend/src/main/resources/db/migration/V12__add_guest_order_columns.sql new file mode 100644 index 0000000..f508fed --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__add_guest_order_columns.sql @@ -0,0 +1,24 @@ +-- Allows orders without a registered user (guest checkout). +-- Users can place and pay for letters without creating an account. +-- +-- user_id: previously NOT NULL — drop the constraint so guest orders +-- can be created without a registered user. The FK stays in +-- place (NULL user_id is FK-legal). +-- guest_email: contact address for the guest. Used to send the magic +-- link that lets them revisit their order status. +-- guest_token: opaque UUID v4 — the only credential a guest has. Acts +-- as their session token for order lookup + payment confirm. + +ALTER TABLE orders ALTER COLUMN user_id DROP NOT NULL; +ALTER TABLE orders ADD COLUMN guest_email VARCHAR(255); +ALTER TABLE orders ADD COLUMN guest_token UUID; + +-- Partial unique index: only enforce uniqueness on non-NULL tokens. +-- Multiple NULLs allowed — existing user-owned orders have no token, +-- and that's fine. +CREATE UNIQUE INDEX idx_orders_guest_token + ON orders(guest_token) + WHERE guest_token IS NOT NULL; +CREATE INDEX idx_orders_guest_email + ON orders(guest_email) + WHERE guest_email IS NOT NULL; diff --git a/frontend/src/api/guestOrders.ts b/frontend/src/api/guestOrders.ts new file mode 100644 index 0000000..4b8e3a6 --- /dev/null +++ b/frontend/src/api/guestOrders.ts @@ -0,0 +1,39 @@ +import { request } from './client' + +/** + * Guest order — placed and paid for without an account. {@link guestToken} + * is the only credential the client holds; it is returned only on create + * and from a by-token lookup. Pass it as the query string on the next + * page so a refresh keeps the session alive. + */ +export interface GuestOrder { + id: string + plate: string + letterText: string + status: string + trackingId: string | null + amountPaid: number | null + createdAt: string + guestToken: string +} + +export function createGuestOrder( + plate: string, + letterText: string, + email: string, +): Promise { + return request('/guest-orders', { + method: 'POST', + body: JSON.stringify({ plate, letterText, email }), + }) +} + +export function fetchGuestOrder(token: string): Promise { + return request(`/guest-orders/${token}`) +} + +export function payGuestOrder(token: string): Promise { + return request(`/guest-orders/${token}/pay`, { + method: 'POST', + }) +} diff --git a/frontend/src/pages/GuestCheckoutPage.vue b/frontend/src/pages/GuestCheckoutPage.vue new file mode 100644 index 0000000..8a625fa --- /dev/null +++ b/frontend/src/pages/GuestCheckoutPage.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/frontend/src/pages/GuestOrderPage.vue b/frontend/src/pages/GuestOrderPage.vue new file mode 100644 index 0000000..5cbd9a4 --- /dev/null +++ b/frontend/src/pages/GuestOrderPage.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/frontend/src/pages/GuestPaymentRedirect.vue b/frontend/src/pages/GuestPaymentRedirect.vue new file mode 100644 index 0000000..f3261b2 --- /dev/null +++ b/frontend/src/pages/GuestPaymentRedirect.vue @@ -0,0 +1,386 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7e63f11..cc34a32 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -20,6 +20,9 @@ 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 GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue' +import GuestPaymentRedirect from '@/pages/GuestPaymentRedirect.vue' +import GuestOrderPage from '@/pages/GuestOrderPage.vue' import { useAuthStore } from '@/stores/authStore' import { getActivePinia } from 'pinia' @@ -88,6 +91,24 @@ const router = createRouter({ component: PaymentRedirect, meta: { requiresAuth: true }, }, + { + // Guest checkout — no account required to place and pay for an order. + path: '/gast-bestallning', + name: 'guest-checkout', + component: GuestCheckoutPage, + }, + { + // Guest payment page — token carried in query so refresh keeps the session. + path: '/gast-betalning/:orderId', + name: 'guest-payment', + component: GuestPaymentRedirect, + }, + { + // Magic-link landing — order status by opaque token. + path: '/gast-order/:token', + name: 'guest-order', + component: GuestOrderPage, + }, { path: '/registrera', name: 'register', diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5c35fd0..ca73e74 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,8 +12,12 @@ export default defineConfig({ }, server: { port: 3000, + host: true, proxy: { - '/api': 'http://backend:8080', + // Allow running Vite locally outside Docker (set + // VITE_API_PROXY_TARGET=http://localhost:8080 npm run dev) by pointing + // the proxy at the host port instead of the compose service name. + '/api': process.env.VITE_API_PROXY_TARGET || 'http://backend:8080', }, }, preview: { From 33ffc8851d8a2dc0b77cb22dcdef95d35fffaa61 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 19:33:31 +0000 Subject: [PATCH 2/5] test(guest): add backend unit tests for guest checkout to fix CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest checkout PR (#17) added new backend code without any unit tests, causing the jacocoTestCoverageVerification CI step to fail (coverage dropped below 70% line / 60% branch thresholds). OrderServiceTest — 10 new tests covering: - createGuestOrder: correct fields, plate normalization, email normalization (lowercase+trim), null email handling - getOrderByGuestToken: successful lookup, not-found, security check (refuses to serve user-owned orders via guest token) - confirmGuestPayment: success path (status→PROCESSING, notification), non-pending order throws, unknown token throws GuestOrderControllerTest — new file, 8 tests covering: - POST /api/guest-orders: create without auth (201), validation (bad plate, blank email, invalid email, blank letter text) - GET /api/guest-orders/{token}: lookup (200), not-found (404) - POST /api/guest-orders/{token}/pay: confirm (200), conflict (409), not-found (404) All tests follow existing patterns (MockitoExtension for service, SpringBootTest+MockMvc for controller). Cannot run backend tests locally (no JDK in agent sandbox) — CI will verify. --- .../controller/GuestOrderControllerTest.java | 179 ++++++++++++++++++ .../bilhalsning/service/OrderServiceTest.java | 118 ++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java diff --git a/backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java new file mode 100644 index 0000000..ddd505b --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java @@ -0,0 +1,179 @@ +package se.bilhalsning.controller; + +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.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import se.bilhalsning.entity.Order; +import se.bilhalsning.entity.OrderStatus; +import se.bilhalsning.exception.InvalidOrderStateException; +import se.bilhalsning.exception.OrderNotFoundException; +import se.bilhalsning.service.OrderService; +import se.bilhalsning.service.UserService; + +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!") +class GuestOrderControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private OrderService orderService; + + @MockitoBean + private UserService userService; + + // --- POST /api/guest-orders (create) --- + + @Test + void shouldCreateGuestOrderWithoutAuth() throws Exception { + UUID orderId = UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + UUID guestToken = UUID.fromString("e2eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + + Order savedOrder = new Order(); + savedOrder.setId(orderId); + savedOrder.setPlate("ABC123"); + savedOrder.setLetterText("Hej fin bil!"); + savedOrder.setStatus(OrderStatus.PENDING_PAYMENT); + savedOrder.setGuestEmail("guest@example.com"); + savedOrder.setGuestToken(guestToken); + + when(orderService.createGuestOrder("ABC123", "Hej fin bil!", "guest@example.com")) + .thenReturn(savedOrder); + + mockMvc.perform(post("/api/guest-orders") + .contentType("application/json") + .content("{\"plate\":\"ABC123\",\"letterText\":\"Hej fin bil!\",\"email\":\"guest@example.com\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(orderId.toString())) + .andExpect(jsonPath("$.plate").value("ABC123")) + .andExpect(jsonPath("$.letterText").value("Hej fin bil!")) + .andExpect(jsonPath("$.status").value("pending_payment")) + .andExpect(jsonPath("$.guestToken").value(guestToken.toString())); + } + + @Test + void shouldRejectInvalidPlateFormat() throws Exception { + mockMvc.perform(post("/api/guest-orders") + .contentType("application/json") + .content("{\"plate\":\"INVALID\",\"letterText\":\"Hej\",\"email\":\"guest@example.com\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldRejectBlankEmail() throws Exception { + mockMvc.perform(post("/api/guest-orders") + .contentType("application/json") + .content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\",\"email\":\"\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldRejectInvalidEmail() throws Exception { + mockMvc.perform(post("/api/guest-orders") + .contentType("application/json") + .content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\",\"email\":\"not-an-email\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldRejectBlankLetterText() throws Exception { + mockMvc.perform(post("/api/guest-orders") + .contentType("application/json") + .content("{\"plate\":\"ABC123\",\"letterText\":\"\",\"email\":\"guest@example.com\"}")) + .andExpect(status().isBadRequest()); + } + + // --- GET /api/guest-orders/{token} --- + + @Test + void shouldGetGuestOrderByToken() throws Exception { + UUID token = UUID.fromString("e2eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + UUID orderId = UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + + Order order = new Order(); + order.setId(orderId); + order.setPlate("ABC123"); + order.setLetterText("Hej bil!"); + order.setStatus(OrderStatus.PROCESSING); + order.setGuestToken(token); + order.setAmountPaid(new BigDecimal("49.00")); + order.setCreatedAt(Instant.parse("2026-06-19T19:00:00Z")); + + when(orderService.getOrderByGuestToken(token)).thenReturn(order); + + mockMvc.perform(get("/api/guest-orders/{token}", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(orderId.toString())) + .andExpect(jsonPath("$.plate").value("ABC123")) + .andExpect(jsonPath("$.status").value("processing")) + .andExpect(jsonPath("$.guestToken").value(token.toString())) + .andExpect(jsonPath("$.amountPaid").value(49.00)); + } + + @Test + void shouldReturn404WhenGuestOrderTokenNotFound() throws Exception { + UUID token = UUID.randomUUID(); + when(orderService.getOrderByGuestToken(token)) + .thenThrow(new OrderNotFoundException(token)); + + mockMvc.perform(get("/api/guest-orders/{token}", token)) + .andExpect(status().isNotFound()); + } + + // --- POST /api/guest-orders/{token}/pay --- + + @Test + void shouldConfirmGuestPayment() throws Exception { + UUID token = UUID.fromString("e2eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + UUID orderId = UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + + Order order = new Order(); + order.setId(orderId); + order.setPlate("ABC123"); + order.setLetterText("Hej bil!"); + order.setStatus(OrderStatus.PROCESSING); + order.setGuestToken(token); + + when(orderService.confirmGuestPayment(token)).thenReturn(order); + + mockMvc.perform(post("/api/guest-orders/{token}/pay", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(orderId.toString())) + .andExpect(jsonPath("$.status").value("processing")); + } + + @Test + void shouldReturn409WhenPaymentAlreadyConfirmed() throws Exception { + UUID token = UUID.randomUUID(); + when(orderService.confirmGuestPayment(token)) + .thenThrow(new InvalidOrderStateException("Beställningen kan inte ändras i detta tillstånd")); + + mockMvc.perform(post("/api/guest-orders/{token}/pay", token)) + .andExpect(status().isConflict()); + } + + @Test + void shouldReturn404WhenPayingWithUnknownToken() throws Exception { + UUID token = UUID.randomUUID(); + when(orderService.confirmGuestPayment(token)) + .thenThrow(new OrderNotFoundException(token)); + + mockMvc.perform(post("/api/guest-orders/{token}/pay", token)) + .andExpect(status().isNotFound()); + } +} diff --git a/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java b/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java index 1c61aa5..48a3a51 100644 --- a/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java +++ b/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java @@ -253,4 +253,122 @@ class OrderServiceTest { () -> orderService.confirmPayment(orderId, otherUserId)); } + // --- Guest order: createGuestOrder --- + + @Test + void shouldCreateGuestOrderWithPendingPaymentStatus() { + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.createGuestOrder("ABC123", "Hej fin bil!", "guest@example.com"); + + assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus()); + assertEquals("ABC123", result.getPlate()); + assertEquals("Hej fin bil!", result.getLetterText()); + assertEquals("guest@example.com", result.getGuestEmail()); + assertNull(result.getUserId()); + } + + @Test + void shouldNormalizeGuestPlateToUppercaseAndTrim() { + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.createGuestOrder(" abc123 ", "Test", "guest@example.com"); + + assertEquals("ABC123", result.getPlate()); + } + + @Test + void shouldNormalizeGuestEmailToLowercaseAndTrim() { + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.createGuestOrder("ABC123", "Test", " Guest@EXAMPLE.COM "); + + assertEquals("guest@example.com", result.getGuestEmail()); + } + + @Test + void shouldHandleNullGuestEmail() { + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.createGuestOrder("ABC123", "Test", null); + + assertNull(result.getGuestEmail()); + } + + // --- Guest order: getOrderByGuestToken --- + + @Test + void shouldGetGuestOrderByToken() { + UUID token = UUID.randomUUID(); + Order order = new Order(); + order.setGuestToken(token); + order.setStatus(OrderStatus.PENDING_PAYMENT); + when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order)); + + Order result = orderService.getOrderByGuestToken(token); + + assertSame(order, result); + } + + @Test + void shouldThrowWhenGuestTokenNotFound() { + UUID token = UUID.randomUUID(); + when(orderRepository.findByGuestToken(token)).thenReturn(Optional.empty()); + + assertThrows(OrderNotFoundException.class, + () -> orderService.getOrderByGuestToken(token)); + } + + @Test + void shouldThrowWhenGuestTokenPointsToUserOwnedOrder() { + UUID token = UUID.randomUUID(); + Order order = new Order(); + order.setGuestToken(token); + order.setUserId(UUID.randomUUID()); // security: user-owned order must not be exposed + when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order)); + + assertThrows(OrderNotFoundException.class, + () -> orderService.getOrderByGuestToken(token)); + } + + // --- Guest order: confirmGuestPayment --- + + @Test + void shouldConfirmGuestPaymentForPendingOrder() { + UUID token = UUID.randomUUID(); + Order order = new Order(); + order.setGuestToken(token); + order.setStatus(OrderStatus.PENDING_PAYMENT); + when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.confirmGuestPayment(token); + + assertEquals(OrderStatus.PROCESSING, result.getStatus()); + verify(orderNotificationService).notifyOrderProcessing(result); + } + + @Test + void shouldThrowWhenConfirmingGuestPaymentForNonPendingOrder() { + UUID token = UUID.randomUUID(); + Order order = new Order(); + order.setGuestToken(token); + order.setStatus(OrderStatus.CANCELLED); + when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order)); + + assertThrows(InvalidOrderStateException.class, + () -> orderService.confirmGuestPayment(token)); + verify(orderRepository, never()).save(any(Order.class)); + verify(orderNotificationService, never()).notifyOrderProcessing(any()); + } + + @Test + void shouldThrowWhenConfirmingGuestPaymentForUnknownToken() { + UUID token = UUID.randomUUID(); + when(orderRepository.findByGuestToken(token)).thenReturn(Optional.empty()); + + assertThrows(OrderNotFoundException.class, + () -> orderService.confirmGuestPayment(token)); + } + } From 8ff71f8e32d1454bb5a13b50c1a5f3f49035426b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 20:32:54 +0000 Subject: [PATCH 3/5] fix(test): remove non-existent setCreatedAt call that broke compileTestJava MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GuestOrderControllerTest#shouldGetGuestOrderByToken called Order.setCreatedAt(Instant.parse(...)), but the Order entity has no setCreatedAt setter — createdAt is assigned only inside @PrePersist onCreate(). This was a compile error (cannot find symbol) that made compileTestJava fail, so the jacocoTestCoverageVerification CI step aborted before running any tests — hence coverage never improved and CI stayed red after the previous commit (a0cefb2). Why E2E passed but lint-and-test failed: the e2e backend image builds with `./gradlew :backend:bootJar`, which compiles only main sources and never compiles/runs tests. The lint-and-test job runs `./gradlew :backend:jacocoTestCoverageVerification`, which depends on test -> compileTestJava, which is where this failed. Changes: - Remove the Order.setCreatedAt(Instant) call (no such method). - Remove the order.setAmountPaid(BigDecimal) setup line. - Remove the jsonPath $.amountPaid assertion (depended on that setup; the test still asserts id, plate, status, guestToken). - Drop the now-unused java.math.BigDecimal and java.time.Instant imports. No behavioral change to production code; test-only fix. --- .../bilhalsning/controller/GuestOrderControllerTest.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java index ddd505b..2ec009d 100644 --- a/backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java @@ -6,8 +6,6 @@ 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.math.BigDecimal; -import java.time.Instant; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -111,8 +109,6 @@ class GuestOrderControllerTest { order.setLetterText("Hej bil!"); order.setStatus(OrderStatus.PROCESSING); order.setGuestToken(token); - order.setAmountPaid(new BigDecimal("49.00")); - order.setCreatedAt(Instant.parse("2026-06-19T19:00:00Z")); when(orderService.getOrderByGuestToken(token)).thenReturn(order); @@ -121,8 +117,7 @@ class GuestOrderControllerTest { .andExpect(jsonPath("$.id").value(orderId.toString())) .andExpect(jsonPath("$.plate").value("ABC123")) .andExpect(jsonPath("$.status").value("processing")) - .andExpect(jsonPath("$.guestToken").value(token.toString())) - .andExpect(jsonPath("$.amountPaid").value(49.00)); + .andExpect(jsonPath("$.guestToken").value(token.toString())); } @Test From d16f04863009f76f8ad4e6bce03a7426c9365b3d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 20:47:54 +0000 Subject: [PATCH 4/5] fix(db): make V12 migration H2-compatible (drop partial-index WHERE clauses) V12__add_guest_order_columns.sql used PostgreSQL partial indexes: CREATE UNIQUE INDEX ... ON orders(guest_token) WHERE guest_token IS NOT NULL CREATE INDEX ... ON orders(guest_email) WHERE guest_email IS NOT NULL H2 (the in-memory DB used by tests/dev, per application.yml) does not support partial indexes -- the WHERE clause throws JdbcSQLSyntaxErrorException. Flyway therefore failed to run V12 at Spring context startup, so the ApplicationContext could not load, failing all 80 @SpringBootTest tests (:backend:test) and aborting CI before coverage verification ever ran. This was the actual root cause of the PR's red CI -- not a coverage shortfall. Verified locally (Temurin JDK 21): ./gradlew :backend:jacocoTestCoverageVerification now BUILD SUCCESSFUL; all 188 tests pass; bundle coverage 80.8% line / 64.9% branch (thresholds 70% / 60%). Semantics preserved: both H2 and PostgreSQL treat NULLs as distinct in a UNIQUE index, so user-owned orders (NULL guest_token) never collide while non-NULL guest tokens stay unique -- the same guarantee the partial index provided, but portable across both databases. Migration is not yet on master, so editing V12 in this PR is safe (no checksum mismatch against origin/master). --- .../V12__add_guest_order_columns.sql | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/backend/src/main/resources/db/migration/V12__add_guest_order_columns.sql b/backend/src/main/resources/db/migration/V12__add_guest_order_columns.sql index f508fed..0b4412e 100644 --- a/backend/src/main/resources/db/migration/V12__add_guest_order_columns.sql +++ b/backend/src/main/resources/db/migration/V12__add_guest_order_columns.sql @@ -1,24 +1,25 @@ -- Allows orders without a registered user (guest checkout). -- Users can place and pay for letters without creating an account. -- --- user_id: previously NOT NULL — drop the constraint so guest orders +-- user_id: previously NOT NULL - drop the constraint so guest orders -- can be created without a registered user. The FK stays in -- place (NULL user_id is FK-legal). -- guest_email: contact address for the guest. Used to send the magic -- link that lets them revisit their order status. --- guest_token: opaque UUID v4 — the only credential a guest has. Acts +-- guest_token: opaque UUID v4 - the only credential a guest has. Acts -- as their session token for order lookup + payment confirm. ALTER TABLE orders ALTER COLUMN user_id DROP NOT NULL; ALTER TABLE orders ADD COLUMN guest_email VARCHAR(255); ALTER TABLE orders ADD COLUMN guest_token UUID; --- Partial unique index: only enforce uniqueness on non-NULL tokens. --- Multiple NULLs allowed — existing user-owned orders have no token, --- and that's fine. +-- Unique index on guest_token. Both H2 (tests/dev) and PostgreSQL (prod) +-- treat NULLs as distinct in a UNIQUE index, so user-owned orders (which +-- have a NULL token) never collide, while non-NULL guest tokens are +-- enforced unique. A plain index is used instead of a partial +-- (WHERE guest_token IS NOT NULL) index because H2 does not support +-- partial indexes, and the plain form preserves the intended semantics. CREATE UNIQUE INDEX idx_orders_guest_token - ON orders(guest_token) - WHERE guest_token IS NOT NULL; + ON orders(guest_token); CREATE INDEX idx_orders_guest_email - ON orders(guest_email) - WHERE guest_email IS NOT NULL; + ON orders(guest_email); From 2ef093ba2bfdf7dda391f7c0ee2c4e2d92ad18ba Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 22 Jun 2026 10:44:34 +0000 Subject: [PATCH 5/5] test(guest): add frontend unit tests for guest checkout flow Add 41 tests covering all four guest checkout modules that had 0% function coverage in CI, raising overall frontend coverage from 76.69% to 84.64% statements, well above the 70% threshold. New test files: - guestOrders.spec.ts: verify createGuestOrder posts correct body, fetchGuestOrder GETs /guest-orders/:token, payGuestOrder POSTs /guest-orders/:token/pay; error propagation - GuestCheckoutPage.spec.ts: form validation (plate regex, email regex, button disabled state), character counter, successful submission navigates to guest-payment with token+plate query, API failure shows Swedish error message - GuestOrderPage.spec.ts: loading state, order detail rendering, all six status labels (pending_payment, paid, sent, delivered, failed, cancelled), payment link visibility, fetch error handling - GuestPaymentRedirect.spec.ts: Swish number/amount display, QR code rendering, Swish payment link, confirmation dialog flow, payGuestOrder call on confirm, navigation on success, error for missing token and failed fetch, redirect when already paid, magic order link Also includes a rebase onto origin/master (PR #16 Swish QR fixes, payment unit tests, e2e retry improvements) which resolves the 'not up to date with master' from the review comment. --- .../src/__tests__/GuestCheckoutPage.spec.ts | 185 +++++++++++ frontend/src/__tests__/GuestOrderPage.spec.ts | 191 +++++++++++ .../__tests__/GuestPaymentRedirect.spec.ts | 307 ++++++++++++++++++ frontend/src/__tests__/guestOrders.spec.ts | 104 ++++++ 4 files changed, 787 insertions(+) create mode 100644 frontend/src/__tests__/GuestCheckoutPage.spec.ts create mode 100644 frontend/src/__tests__/GuestOrderPage.spec.ts create mode 100644 frontend/src/__tests__/GuestPaymentRedirect.spec.ts create mode 100644 frontend/src/__tests__/guestOrders.spec.ts diff --git a/frontend/src/__tests__/GuestCheckoutPage.spec.ts b/frontend/src/__tests__/GuestCheckoutPage.spec.ts new file mode 100644 index 0000000..0bce7d2 --- /dev/null +++ b/frontend/src/__tests__/GuestCheckoutPage.spec.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createRouter, createMemoryHistory } from 'vue-router' + +const mocks = vi.hoisted(() => ({ + mockCreateGuestOrder: vi.fn(), +})) + +vi.mock('@/api/guestOrders', () => ({ + createGuestOrder: mocks.mockCreateGuestOrder, +})) + +import GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue' +import { createGuestOrder } from '@/api/guestOrders' +const mockCreateGuestOrder = vi.mocked(createGuestOrder) + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
Home
' } }, + { + path: '/gast-kassa', + name: 'guest-checkout', + component: GuestCheckoutPage, + }, + { + path: '/gast-betalning/:orderId', + name: 'guest-payment', + component: { template: '
Payment
' }, + }, + { + path: '/logga-in', + name: 'login', + component: { template: '
Login
' }, + }, + ], + }) +} + +async function mountPage() { + const pinia = createPinia() + setActivePinia(pinia) + const router = createTestRouter() + await router.push({ name: 'guest-checkout' }) + await router.isReady() + + const wrapper = mount(GuestCheckoutPage, { + global: { plugins: [router, pinia] }, + }) + + return { wrapper, router } +} + +describe('GuestCheckoutPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders heading and price', async () => { + const { wrapper } = await mountPage() + expect(wrapper.text()).toContain('Skicka ett brev') + expect(wrapper.text()).toContain('49 kr') + expect(wrapper.text()).toContain('Inget konto behövs') + }) + + it('disables submit button when fields are empty', async () => { + const { wrapper } = await mountPage() + const submit = wrapper.find('.guest-checkout__submit') + expect(submit.attributes('disabled')).toBeDefined() + }) + + it('enables submit button when plate, letter, and email are valid', async () => { + const { wrapper } = await mountPage() + const inputs = wrapper.findAll('input') + const textarea = wrapper.find('textarea') + + await inputs[0].setValue('ABC123') + await textarea.setValue('Hej, din bil står fel!') + await inputs[1].setValue('test@example.se') + + const submit = wrapper.find('.guest-checkout__submit') + expect(submit.attributes('disabled')).toBeUndefined() + }) + + it('keeps submit disabled with an invalid plate', async () => { + const { wrapper } = await mountPage() + const inputs = wrapper.findAll('input') + const textarea = wrapper.find('textarea') + + await inputs[0].setValue('ABC') // too short + await textarea.setValue('Hej') + await inputs[1].setValue('test@example.se') + + const submit = wrapper.find('.guest-checkout__submit') + expect(submit.attributes('disabled')).toBeDefined() + }) + + it('keeps submit disabled with an invalid email', async () => { + const { wrapper } = await mountPage() + const inputs = wrapper.findAll('input') + const textarea = wrapper.find('textarea') + + await inputs[0].setValue('ABC123') + await textarea.setValue('Hej') + await inputs[1].setValue('not-an-email') + + const submit = wrapper.find('.guest-checkout__submit') + expect(submit.attributes('disabled')).toBeDefined() + }) + + it('shows character count that updates with input', async () => { + const { wrapper } = await mountPage() + const textarea = wrapper.find('textarea') + + await textarea.setValue('Hej') + + expect(wrapper.text()).toContain('3 / 1000 tecken') + }) + + it('shows link to login page', async () => { + const { wrapper } = await mountPage() + expect(wrapper.text()).toContain('Har du redan ett konto?') + expect(wrapper.text()).toContain('Logga in') + }) + + it('calls createGuestOrder and navigates on submit', async () => { + mockCreateGuestOrder.mockResolvedValue({ + id: 'order-123', + plate: 'ABC123', + letterText: 'Hej', + status: 'pending_payment', + trackingId: null, + amountPaid: null, + createdAt: '2025-01-01T00:00:00Z', + guestToken: 'token-abc', + }) + + const { wrapper, router } = await mountPage() + const inputs = wrapper.findAll('input') + const textarea = wrapper.find('textarea') + + await inputs[0].setValue('ABC123') + await textarea.setValue('Hej, din bil står fel!') + await inputs[1].setValue('test@example.se') + + await wrapper.find('form').trigger('submit.prevent') + + await vi.waitFor(() => { + expect(mockCreateGuestOrder).toHaveBeenCalledWith( + 'ABC123', + 'Hej, din bil står fel!', + 'test@example.se', + ) + }) + + await vi.waitFor(() => { + expect(router.currentRoute.value.name).toBe('guest-payment') + expect(router.currentRoute.value.params.orderId).toBe('order-123') + expect(router.currentRoute.value.query.token).toBe('token-abc') + expect(router.currentRoute.value.query.plate).toBe('ABC123') + }) + }) + + it('shows error message when order creation fails', async () => { + mockCreateGuestOrder.mockRejectedValue(new Error('Network error')) + + const { wrapper } = await mountPage() + const inputs = wrapper.findAll('input') + const textarea = wrapper.find('textarea') + + await inputs[0].setValue('ABC123') + await textarea.setValue('Hej') + await inputs[1].setValue('test@example.se') + + await wrapper.find('form').trigger('submit.prevent') + + await vi.waitFor(() => { + expect(wrapper.text()).toContain( + 'Kunde inte skapa beställningen. Försök igen senare.', + ) + }) + }) +}) diff --git a/frontend/src/__tests__/GuestOrderPage.spec.ts b/frontend/src/__tests__/GuestOrderPage.spec.ts new file mode 100644 index 0000000..d5f62b6 --- /dev/null +++ b/frontend/src/__tests__/GuestOrderPage.spec.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createRouter, createMemoryHistory } from 'vue-router' + +const mocks = vi.hoisted(() => ({ + mockFetchGuestOrder: vi.fn(), +})) + +vi.mock('@/api/guestOrders', () => ({ + fetchGuestOrder: mocks.mockFetchGuestOrder, +})) + +import GuestOrderPage from '@/pages/GuestOrderPage.vue' + +const mockOrder = { + id: 'order-123', + plate: 'ABC123', + letterText: 'Hej, din bil står fel!', + status: 'pending_payment', + trackingId: null, + amountPaid: null, + createdAt: '2025-01-01T12:00:00Z', + guestToken: 'token-abc', +} + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
Home
' } }, + { + path: '/gast-order/:token', + name: 'guest-order', + component: GuestOrderPage, + }, + { + path: '/gast-betalning/:orderId', + name: 'guest-payment', + component: { template: '
Payment
' }, + }, + ], + }) +} + +async function mountPage(token = 'token-abc') { + const pinia = createPinia() + setActivePinia(pinia) + const router = createTestRouter() + await router.push({ name: 'guest-order', params: { token } }) + await router.isReady() + + const wrapper = mount(GuestOrderPage, { + global: { plugins: [router, pinia] }, + }) + + return { wrapper, router } +} + +describe('GuestOrderPage', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.mockFetchGuestOrder.mockResolvedValue(mockOrder) + }) + + it('shows loading state initially', async () => { + // Never resolve so we can see the loading state + mocks.mockFetchGuestOrder.mockReturnValue(new Promise(() => {})) + + const { wrapper } = await mountPage() + expect(wrapper.text()).toContain('Laddar') + }) + + it('renders order details after loading', async () => { + const { wrapper } = await mountPage() + + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Din beställning') + }) + + expect(wrapper.text()).toContain('ABC123') + expect(wrapper.text()).toContain('order-123') + expect(wrapper.text()).toContain('Hej, din bil står fel!') + }) + + it('displays human-readable status labels', async () => { + mocks.mockFetchGuestOrder.mockResolvedValue({ + ...mockOrder, + status: 'pending_payment', + }) + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Väntar på betalning') + }) + }) + + it('shows "Behandlas" for paid status', async () => { + mocks.mockFetchGuestOrder.mockResolvedValue({ + ...mockOrder, + status: 'paid', + }) + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Behandlas') + }) + }) + + it('shows "Skickat" for sent status', async () => { + mocks.mockFetchGuestOrder.mockResolvedValue({ + ...mockOrder, + status: 'sent', + }) + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Skickat') + }) + }) + + it('shows "Levererat" for delivered status', async () => { + mocks.mockFetchGuestOrder.mockResolvedValue({ + ...mockOrder, + status: 'delivered', + }) + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Levererat') + }) + }) + + it('shows "Misslyckades" for failed status', async () => { + mocks.mockFetchGuestOrder.mockResolvedValue({ + ...mockOrder, + status: 'failed', + }) + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Misslyckades') + }) + }) + + it('shows "Avbrutet" for cancelled status', async () => { + mocks.mockFetchGuestOrder.mockResolvedValue({ + ...mockOrder, + status: 'cancelled', + }) + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Avbrutet') + }) + }) + + it('shows payment link when status is pending_payment', async () => { + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Gå till betalningssidan') + }) + }) + + it('hides payment link when order is not pending_payment', async () => { + mocks.mockFetchGuestOrder.mockResolvedValue({ + ...mockOrder, + status: 'sent', + }) + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).not.toContain('Gå till betalningssidan') + }) + }) + + it('shows error when fetch fails', async () => { + mocks.mockFetchGuestOrder.mockRejectedValue(new Error('Not found')) + + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain( + 'Kunde inte hitta beställningen. Kontrollera länken.', + ) + }) + }) + + it('does not fetch order when token is missing in params', async () => { + // The component guards against an empty token in onMounted. + // Vue Router requires the :token param so we simulate by checking + // that fetchGuestOrder is only called once per mount with a token. + const { wrapper } = await mountPage('token-abc') + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Din beställning') + }) + expect(mocks.mockFetchGuestOrder).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/__tests__/GuestPaymentRedirect.spec.ts b/frontend/src/__tests__/GuestPaymentRedirect.spec.ts new file mode 100644 index 0000000..7e31b4b --- /dev/null +++ b/frontend/src/__tests__/GuestPaymentRedirect.spec.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createRouter, createMemoryHistory } from 'vue-router' + +const mocks = vi.hoisted(() => ({ + mockFetchGuestOrder: vi.fn(), + mockPayGuestOrder: vi.fn(), + mockFetchSwishInfo: vi.fn(), + mockBuildSwishPaymentUrl: vi.fn(), + mockToDataURL: vi.fn(), +})) + +vi.mock('@/api/guestOrders', () => ({ + fetchGuestOrder: mocks.mockFetchGuestOrder, + payGuestOrder: mocks.mockPayGuestOrder, +})) + +vi.mock('@/api/payment', () => ({ + fetchSwishInfo: mocks.mockFetchSwishInfo, + buildSwishPaymentUrl: mocks.mockBuildSwishPaymentUrl, +})) + +vi.mock('qrcode', () => ({ + default: { + toDataURL: mocks.mockToDataURL, + }, +})) + +import GuestPaymentRedirect from '@/pages/GuestPaymentRedirect.vue' +import QRCode from 'qrcode' + +const mockToDataURL = vi.mocked(QRCode.toDataURL) + +const mockOrder = { + id: 'order-123', + plate: 'ABC123', + letterText: 'Hej', + status: 'pending_payment', + trackingId: null, + amountPaid: null, + createdAt: '2025-01-01T00:00:00Z', + guestToken: 'token-abc', +} + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
Home
' } }, + { + path: '/gast-betalning/:orderId', + name: 'guest-payment', + component: GuestPaymentRedirect, + }, + { + path: '/gast-order/:token', + name: 'guest-order', + component: { template: '
Order
' }, + }, + ], + }) +} + +async function mountPage( + orderId = 'order-123', + token = 'token-abc', + plate = 'ABC123', +) { + const pinia = createPinia() + setActivePinia(pinia) + const router = createTestRouter() + await router.push({ + name: 'guest-payment', + params: { orderId }, + query: { token, plate }, + }) + await router.isReady() + + const wrapper = mount(GuestPaymentRedirect, { + global: { plugins: [router, pinia] }, + }) + + return { wrapper, router } +} + +function setupDefaultMocks() { + mocks.mockFetchSwishInfo.mockResolvedValue({ + number: '0701234567', + amount: 49, + }) + mocks.mockFetchGuestOrder.mockResolvedValue(mockOrder) + mocks.mockBuildSwishPaymentUrl.mockReturnValue( + 'https://app.swish.nu/1/p/sw/?sw=46701234567&amt=49.00&msg=order-123', + ) + mocks.mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr') +} + +describe('GuestPaymentRedirect', () => { + beforeEach(() => { + vi.clearAllMocks() + setupDefaultMocks() + }) + + it('renders heading and plate', async () => { + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Betalning') + }) + expect(wrapper.text()).toContain('ABC123') + }) + + it('displays order id', async () => { + const { wrapper } = await mountPage('order-456') + await vi.waitFor(() => { + expect(wrapper.text()).toContain('order-456') + }) + }) + + it('shows the amount to pay', async () => { + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain('49 kr') + }) + }) + + it('renders QR code after loading swish info', async () => { + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.find('.payment__qr-img').exists()).toBe(true) + }) + expect(mockToDataURL).toHaveBeenCalledTimes(1) + }) + + it('renders Swish payment link', async () => { + const { wrapper } = await mountPage() + await vi.waitFor(() => { + const link = wrapper.find('.payment__swish-link') + expect(link.exists()).toBe(true) + expect(link.attributes('href')).toContain('app.swish.nu') + }) + }) + + it('displays Swish number from API', async () => { + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain('0701234567') + }) + }) + + it('shows the "Jag har betalat" button', async () => { + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.find('.payment__submit').exists()).toBe(true) + expect(wrapper.text()).toContain('Jag har betalat') + }) + }) + + it('shows confirmation dialog when clicking "Jag har betalat"', async () => { + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.find('.payment__submit').exists()).toBe(true) + }) + + await wrapper.find('.payment__submit').trigger('click') + + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat') + expect(wrapper.text()).toContain('0701234567') + }) + }) + + it('can cancel the confirmation dialog', async () => { + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.find('.payment__submit').exists()).toBe(true) + }) + + await wrapper.find('.payment__submit').trigger('click') + await vi.waitFor(() => { + expect(wrapper.find('.payment__confirm-cancel').exists()).toBe(true) + }) + + await wrapper.find('.payment__confirm-cancel').trigger('click') + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Jag har betalat') + expect(wrapper.text()).not.toContain('Jag bekräftar') + }) + }) + + it('calls payGuestOrder on confirmation', async () => { + mocks.mockPayGuestOrder.mockResolvedValue({ + ...mockOrder, + status: 'processing', + }) + + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.find('.payment__submit').exists()).toBe(true) + }) + + await wrapper.find('.payment__submit').trigger('click') + await vi.waitFor(() => { + expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe( + true, + ) + }) + + await wrapper.find('.payment__confirm .btn--primary').trigger('click') + + expect(mocks.mockPayGuestOrder).toHaveBeenCalledWith('token-abc') + }) + + it('navigates to guest-order after successful payment', async () => { + mocks.mockPayGuestOrder.mockResolvedValue({ + ...mockOrder, + status: 'processing', + }) + + const { wrapper, router } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.find('.payment__submit').exists()).toBe(true) + }) + + await wrapper.find('.payment__submit').trigger('click') + await vi.waitFor(() => { + expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe( + true, + ) + }) + + await wrapper.find('.payment__confirm .btn--primary').trigger('click') + + await vi.waitFor(() => { + expect(router.currentRoute.value.name).toBe('guest-order') + expect(router.currentRoute.value.params.token).toBe('token-abc') + }) + }) + + it('shows error when payment confirmation fails', async () => { + mocks.mockPayGuestOrder.mockRejectedValue(new Error('Network error')) + + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.find('.payment__submit').exists()).toBe(true) + }) + + await wrapper.find('.payment__submit').trigger('click') + await vi.waitFor(() => { + expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe( + true, + ) + }) + + await wrapper.find('.payment__confirm .btn--primary').trigger('click') + + await vi.waitFor(() => { + expect(wrapper.text()).toContain( + 'Kunde inte bekräfta betalningen. Försök igen.', + ) + }) + }) + + it('shows error when swish info fetch fails', async () => { + mocks.mockFetchSwishInfo.mockRejectedValue(new Error('Network error')) + + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain( + 'Kunde inte ladda betalningsinformation. Försök igen senare.', + ) + }) + }) + + it('shows error when token is missing', async () => { + const { wrapper } = await mountPage('order-123', '', 'ABC123') + await vi.waitFor(() => { + expect(wrapper.text()).toContain( + 'Saknar order-token. Gå tillbaka och försök igen.', + ) + }) + expect(mocks.mockFetchSwishInfo).not.toHaveBeenCalled() + }) + + it('redirects to guest-order when order is already paid', async () => { + mocks.mockFetchGuestOrder.mockResolvedValue({ + ...mockOrder, + status: 'processing', + }) + + const { router } = await mountPage() + await vi.waitFor(() => { + expect(router.currentRoute.value.name).toBe('guest-order') + expect(router.currentRoute.value.params.token).toBe('token-abc') + }) + }) + + it('shows the magic order link', async () => { + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.text()).toContain('Din orderlänk') + }) + expect(wrapper.find('.guest-payment__magic-url').text()).toContain( + 'token-abc', + ) + }) +}) diff --git a/frontend/src/__tests__/guestOrders.spec.ts b/frontend/src/__tests__/guestOrders.spec.ts new file mode 100644 index 0000000..05215ed --- /dev/null +++ b/frontend/src/__tests__/guestOrders.spec.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const mocks = vi.hoisted(() => ({ + mockRequest: vi.fn(), +})) + +vi.mock('@/api/client', () => ({ + request: mocks.mockRequest, +})) + +import { + createGuestOrder, + fetchGuestOrder, + payGuestOrder, +} from '@/api/guestOrders' + +describe('guestOrders API', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mocks.mockRequest.mockReset() + }) + + describe('createGuestOrder', () => { + it('sends a POST to /guest-orders with plate, letterText, and email', async () => { + const mockOrder = { + id: 'order-1', + plate: 'ABC123', + letterText: 'Hej', + status: 'pending_payment', + trackingId: null, + amountPaid: null, + createdAt: '2025-01-01T00:00:00Z', + guestToken: 'token-abc', + } + mocks.mockRequest.mockResolvedValue(mockOrder) + + const result = await createGuestOrder('ABC123', 'Hej', 'test@example.se') + + expect(mocks.mockRequest).toHaveBeenCalledWith('/guest-orders', { + method: 'POST', + body: JSON.stringify({ + plate: 'ABC123', + letterText: 'Hej', + email: 'test@example.se', + }), + }) + expect(result).toEqual(mockOrder) + }) + + it('propagates errors from the API client', async () => { + mocks.mockRequest.mockRejectedValue(new Error('Server error')) + + await expect( + createGuestOrder('ABC123', 'Hej', 'test@example.se'), + ).rejects.toThrow('Server error') + }) + }) + + describe('fetchGuestOrder', () => { + it('sends a GET to /guest-orders/:token', async () => { + const mockOrder = { + id: 'order-1', + plate: 'ABC123', + letterText: 'Hej', + status: 'pending_payment', + trackingId: null, + amountPaid: null, + createdAt: '2025-01-01T00:00:00Z', + guestToken: 'token-abc', + } + mocks.mockRequest.mockResolvedValue(mockOrder) + + const result = await fetchGuestOrder('token-abc') + + expect(mocks.mockRequest).toHaveBeenCalledWith('/guest-orders/token-abc') + expect(result).toEqual(mockOrder) + }) + }) + + describe('payGuestOrder', () => { + it('sends a POST to /guest-orders/:token/pay', async () => { + const mockOrder = { + id: 'order-1', + plate: 'ABC123', + letterText: 'Hej', + status: 'processing', + trackingId: null, + amountPaid: 49, + createdAt: '2025-01-01T00:00:00Z', + guestToken: 'token-abc', + } + mocks.mockRequest.mockResolvedValue(mockOrder) + + const result = await payGuestOrder('token-abc') + + expect(mocks.mockRequest).toHaveBeenCalledWith( + '/guest-orders/token-abc/pay', + { method: 'POST' }, + ) + expect(result).toEqual(mockOrder) + }) + }) +})