From 08fcbba580633efc211b7a5d08b259a2505cb716 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 19:15:01 +0000 Subject: [PATCH 1/4] 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: { -- 2.45.2 From a0cefb26463fc6360d7c35c396f179856e1d9771 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 19:33:31 +0000 Subject: [PATCH 2/4] 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)); + } + } -- 2.45.2 From be069aa92c327b978788bf901f00a0e936819ca2 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 20:32:54 +0000 Subject: [PATCH 3/4] 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 -- 2.45.2 From afe70125f1f1fb0170f74b4654d14ea4f0b1982e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 20:47:54 +0000 Subject: [PATCH 4/4] 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); -- 2.45.2