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..0b4412e --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__add_guest_order_columns.sql @@ -0,0 +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 +-- 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; + +-- 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); +CREATE INDEX idx_orders_guest_email + ON orders(guest_email); 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..2ec009d --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java @@ -0,0 +1,174 @@ +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.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); + + 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())); + } + + @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)); + } + } 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: {