From 1c9269699e4c94be190e8bc59554197cad140ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Wed, 27 May 2026 12:21:17 +0200 Subject: [PATCH 1/7] Add admin order fulfillment tracking. Register PostNord shipments, admin notes, and guarded status transitions with customer emails. Expandable admin UI, V11 migration, serial E2E suite, and AGENTS.md Docker-only E2E guidance. Co-authored-by: Cursor --- AGENTS.md | 51 +- .../controller/AdminController.java | 24 +- .../bilhalsning/dto/AdminOrderResponse.java | 2 + .../dto/RegisterShipmentRequest.java | 13 + .../dto/UpdateAdminNotesRequest.java | 5 + .../bilhalsning/dto/UpdateStatusRequest.java | 2 +- .../dto/UpdateTrackingRequest.java | 5 - .../java/se/bilhalsning/entity/Order.java | 22 + .../repository/OrderRepository.java | 4 + .../se/bilhalsning/service/EmailService.java | 67 +++ .../service/OrderNotificationService.java | 69 +++ .../se/bilhalsning/service/OrderService.java | 118 +++- .../util/PostNordTrackingNormalizer.java | 45 ++ .../V7__seed_processing_order.sql | 14 + .../V11__add_order_fulfillment_columns.sql | 3 + .../controller/AdminControllerTest.java | 144 ++--- .../bilhalsning/service/OrderServiceTest.java | 150 ++++++ .../util/PostNordTrackingNormalizerTest.java | 26 + frontend/e2e/admin-dashboard.spec.ts | 57 +- frontend/e2e/admin-fulfillment.spec.ts | 99 ++++ frontend/e2e/order-history.spec.ts | 2 +- frontend/playwright.config.ts | 10 +- frontend/src/__tests__/AdminDashboard.spec.ts | 81 ++- frontend/src/api/admin.ts | 21 +- frontend/src/components/AppHeader.vue | 13 +- frontend/src/pages/AdminPage.vue | 507 ++++++++++++++---- 26 files changed, 1251 insertions(+), 303 deletions(-) create mode 100644 backend/src/main/java/se/bilhalsning/dto/RegisterShipmentRequest.java create mode 100644 backend/src/main/java/se/bilhalsning/dto/UpdateAdminNotesRequest.java delete mode 100644 backend/src/main/java/se/bilhalsning/dto/UpdateTrackingRequest.java create mode 100644 backend/src/main/java/se/bilhalsning/service/OrderNotificationService.java create mode 100644 backend/src/main/java/se/bilhalsning/util/PostNordTrackingNormalizer.java create mode 100644 backend/src/main/resources/db/dev-migration/V7__seed_processing_order.sql create mode 100644 backend/src/main/resources/db/migration/V11__add_order_fulfillment_columns.sql create mode 100644 backend/src/test/java/se/bilhalsning/util/PostNordTrackingNormalizerTest.java create mode 100644 frontend/e2e/admin-fulfillment.spec.ts diff --git a/AGENTS.md b/AGENTS.md index b433aea..4071863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,14 @@ stripe listen --forward-to localhost:8080/api/webhooks/stripe Flyway migrations run automatically on Spring Boot startup. Migration files live in `backend/src/main/resources/db/migration/`. Naming: `V__descriptive_name.sql`. +**Before adding a migration:** run `./scripts/next-flyway-version.sh` and use that +version. Never reuse a version number already on `master`. Never edit a migration +after it has merged — add a new higher version instead. CI runs +`scripts/check-flyway-migrations.sh` against `origin/master`. + +If local dev Postgres fails with Flyway checksum / “migration not resolved locally” +after switching branches, run `./gradlew reset` (wipes the Docker DB volume). + To reset: `docker compose down -v && docker compose up -d`. Flyway schema migrations live in `db/migration/`; dev-only seeds (test users, @@ -187,6 +195,9 @@ After the address is used to mail the letter, it must be deleted. The Order entity must NOT have an address field. The address lookup and mailing are external/human processes in Phase 0. +### E2E must use Docker (not host Playwright) +See **Testing Approach → E2E (Playwright) — Docker only** above. Do not run `npx playwright install` or `npm run test:e2e` on the host when verifying E2E. + ### Local email (Mailpit) `docker compose up` includes Mailpit (`ghcr.io/axllent/mailpit:v1.28`); password-reset mail appears at http://localhost:8025. E2E verifies SMTP via Mailpit API (`frontend/e2e/helpers/mailpit.ts`). Production uses Resend SMTP—see docs/production-email-checklist.md. @@ -228,12 +239,40 @@ the same PR — never merge code without corresponding tests. - Component tests with Vue Test Utils where needed. - E2E tests with Playwright in `frontend/e2e/`. -### E2E (Playwright) -- `npm run test:e2e` — runs all Playwright tests (headless Chromium). -- Requires `docker compose up` (backend + frontend running). -- Config: `frontend/playwright.config.ts`. -- Tests: `frontend/e2e/*.spec.ts`. -- Docker CI: `npm run test:e2e:ci` — runs tests inside official Playwright Docker container. Starts postgres, backend, frontend, and Playwright via `docker-compose.ci.yml`. Use for consistent environment or CI pipelines. +### E2E (Playwright) — **Docker only** + +**Agents and humans: never run Playwright on the host.** + +| Do **not** run | Why | +|----------------|-----| +| `npx playwright test` | Wrong environment; needs Docker stack | +| `npm run test:e2e` | Same — host Playwright, not supported for agents | +| `npx playwright install` | Do not install browsers on the host; the Playwright image already includes them | + +**Always use Docker** (`docker-compose.e2e.yml`): isolated postgres (tmpfs), backend, frontend, Mailpit, and the official Playwright container on the `e2e` network (`PLAYWRIGHT_BASE_URL=http://frontend`). + +**Full E2E suite** (same as Forgejo CI / `./gradlew check`): + +```bash +# from repo root — set env (or use .env; see .env.example) +cd frontend && npm run test:e2e:ci +# equivalent: +./gradlew frontendE2E +``` + +**Single spec or project** (stack must be reachable on the `e2e` network): + +```bash +# from repo root, after exporting the same vars as frontendE2E / .env +docker compose -f docker-compose.e2e.yml up -d --build postgres mailpit backend frontend +docker compose -f docker-compose.e2e.yml run --rm --build playwright \ + sh -c 'npx playwright test admin-fulfillment.spec.ts --project=chromium-serial --reporter=list' +docker compose -f docker-compose.e2e.yml down +``` + +- Config: `frontend/playwright.config.ts` +- Tests: `frontend/e2e/*.spec.ts` +- Serial specs (`deferred-payment-admin`, `admin-fulfillment`): Playwright project `chromium-serial`, `workers: 1` ### CI (future) - `./gradlew check` and `npm run test && npm run lint` must pass before merge. diff --git a/backend/src/main/java/se/bilhalsning/controller/AdminController.java b/backend/src/main/java/se/bilhalsning/controller/AdminController.java index 3bc1726..fc8776c 100644 --- a/backend/src/main/java/se/bilhalsning/controller/AdminController.java +++ b/backend/src/main/java/se/bilhalsning/controller/AdminController.java @@ -10,8 +10,9 @@ 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.AdminOrderResponse; +import se.bilhalsning.dto.RegisterShipmentRequest; +import se.bilhalsning.dto.UpdateAdminNotesRequest; import se.bilhalsning.dto.UpdateStatusRequest; -import se.bilhalsning.dto.UpdateTrackingRequest; import se.bilhalsning.entity.Order; import se.bilhalsning.service.OrderService; @@ -41,11 +42,22 @@ public class AdminController { return ResponseEntity.ok(toAdminResponse(order)); } - @PatchMapping("/orders/{id}") - public ResponseEntity updateTracking( + @PatchMapping("/orders/{id}/register-shipment") + public ResponseEntity registerShipment( @PathVariable UUID id, - @Valid @RequestBody UpdateTrackingRequest request) { - Order order = orderService.updateTracking(id, request.trackingId()); + @Valid @RequestBody RegisterShipmentRequest request) { + Order order = orderService.registerShipment( + id, + request.trackingInput(), + request.notifyCustomerOrDefault()); + return ResponseEntity.ok(toAdminResponse(order)); + } + + @PatchMapping("/orders/{id}/notes") + public ResponseEntity updateNotes( + @PathVariable UUID id, + @RequestBody UpdateAdminNotesRequest request) { + Order order = orderService.updateAdminNotes(id, request.adminNotes()); return ResponseEntity.ok(toAdminResponse(order)); } @@ -59,6 +71,8 @@ public class AdminController { order.getStatus().getValue(), order.getTrackingId(), order.getAmountPaid(), + order.getShippedAt(), + order.getAdminNotes(), order.getCreatedAt() ); } diff --git a/backend/src/main/java/se/bilhalsning/dto/AdminOrderResponse.java b/backend/src/main/java/se/bilhalsning/dto/AdminOrderResponse.java index fc65709..f031264 100644 --- a/backend/src/main/java/se/bilhalsning/dto/AdminOrderResponse.java +++ b/backend/src/main/java/se/bilhalsning/dto/AdminOrderResponse.java @@ -12,5 +12,7 @@ public record AdminOrderResponse( String status, String trackingId, BigDecimal amountPaid, + Instant shippedAt, + String adminNotes, Instant createdAt ) {} diff --git a/backend/src/main/java/se/bilhalsning/dto/RegisterShipmentRequest.java b/backend/src/main/java/se/bilhalsning/dto/RegisterShipmentRequest.java new file mode 100644 index 0000000..d57f185 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/RegisterShipmentRequest.java @@ -0,0 +1,13 @@ +package se.bilhalsning.dto; + +import jakarta.validation.constraints.NotBlank; + +public record RegisterShipmentRequest( + @NotBlank(message = "Spårnings-ID krävs") + String trackingInput, + Boolean notifyCustomer +) { + public boolean notifyCustomerOrDefault() { + return notifyCustomer == null || notifyCustomer; + } +} diff --git a/backend/src/main/java/se/bilhalsning/dto/UpdateAdminNotesRequest.java b/backend/src/main/java/se/bilhalsning/dto/UpdateAdminNotesRequest.java new file mode 100644 index 0000000..6062dcb --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/UpdateAdminNotesRequest.java @@ -0,0 +1,5 @@ +package se.bilhalsning.dto; + +public record UpdateAdminNotesRequest( + String adminNotes +) {} diff --git a/backend/src/main/java/se/bilhalsning/dto/UpdateStatusRequest.java b/backend/src/main/java/se/bilhalsning/dto/UpdateStatusRequest.java index 02044b1..5b61dd7 100644 --- a/backend/src/main/java/se/bilhalsning/dto/UpdateStatusRequest.java +++ b/backend/src/main/java/se/bilhalsning/dto/UpdateStatusRequest.java @@ -6,7 +6,7 @@ import jakarta.validation.constraints.Pattern; public record UpdateStatusRequest( @NotBlank(message = "Status krävs") @Pattern( - regexp = "pending_payment|paid|processing|sent|delivered|failed", + regexp = "pending_payment|paid|processing|sent|delivered|failed|cancelled", message = "Ogiltig status" ) String status diff --git a/backend/src/main/java/se/bilhalsning/dto/UpdateTrackingRequest.java b/backend/src/main/java/se/bilhalsning/dto/UpdateTrackingRequest.java deleted file mode 100644 index 1eabfbe..0000000 --- a/backend/src/main/java/se/bilhalsning/dto/UpdateTrackingRequest.java +++ /dev/null @@ -1,5 +0,0 @@ -package se.bilhalsning.dto; - -public record UpdateTrackingRequest( - String trackingId -) {} diff --git a/backend/src/main/java/se/bilhalsning/entity/Order.java b/backend/src/main/java/se/bilhalsning/entity/Order.java index b3fb5cc..9438da7 100644 --- a/backend/src/main/java/se/bilhalsning/entity/Order.java +++ b/backend/src/main/java/se/bilhalsning/entity/Order.java @@ -43,6 +43,12 @@ public class Order { @Column(name = "tracking_id", length = 100) private String trackingId; + @Column(name = "shipped_at") + private Instant shippedAt; + + @Column(name = "admin_notes", columnDefinition = "text") + private String adminNotes; + @Column(name = "created_at", nullable = false) private Instant createdAt; @@ -130,6 +136,22 @@ public class Order { this.trackingId = trackingId; } + public Instant getShippedAt() { + return shippedAt; + } + + public void setShippedAt(Instant shippedAt) { + this.shippedAt = shippedAt; + } + + public String getAdminNotes() { + return adminNotes; + } + + public void setAdminNotes(String adminNotes) { + this.adminNotes = adminNotes; + } + public Instant getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java b/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java index 5faacb8..68441a3 100644 --- a/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java +++ b/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java @@ -7,6 +7,7 @@ import se.bilhalsning.entity.Order; import se.bilhalsning.entity.OrderStatus; import java.util.List; +import java.util.Optional; import java.util.UUID; @Repository @@ -17,4 +18,7 @@ public interface OrderRepository extends JpaRepository { @EntityGraph(attributePaths = {"user"}) List findAllByOrderByCreatedAtDesc(); + + @EntityGraph(attributePaths = {"user"}) + Optional findWithUserById(UUID id); } diff --git a/backend/src/main/java/se/bilhalsning/service/EmailService.java b/backend/src/main/java/se/bilhalsning/service/EmailService.java index 4874ecb..bfe8bea 100644 --- a/backend/src/main/java/se/bilhalsning/service/EmailService.java +++ b/backend/src/main/java/se/bilhalsning/service/EmailService.java @@ -93,4 +93,71 @@ public class EmailService { throw new IllegalStateException("Kunde inte skicka e-post just nu"); } } + + public void sendOrderProcessingEmail(String toEmail, String plate, String ordersUrl) { + String subject = "Din beställning hanteras – BilHej"; + String body = """ + Hej, + + Tack för din betalning! Vi har tagit emot din beställning för fordonet %s och börjar hantera brevet. + + Du kan följa status på dina beställningar här: + %s + + Vänliga hälsningar, + BilHej + """.formatted(plate, ordersUrl); + sendPlainText(toEmail, subject, body); + } + + public void sendOrderSentEmail(String toEmail, String plate, String trackingId, String trackingUrl) { + String subject = "Ditt brev är skickat – BilHej"; + String body = """ + Hej, + + Ditt brev till fordonet %s har skickats med PostNord. + + Spårnings-ID: %s + Spåra brevet: %s + + Vänliga hälsningar, + BilHej + """.formatted(plate, trackingId, trackingUrl); + sendPlainText(toEmail, subject, body); + } + + public void sendOrderFailedEmail(String toEmail, String plate, String ordersUrl) { + String subject = "Din beställning kunde inte slutföras – BilHej"; + String body = """ + Hej, + + Tyvärr kunde vi inte slutföra din beställning för fordonet %s. Vi återkommer om återbetalning behövs. + + Se dina beställningar här: + %s + + Vänliga hälsningar, + BilHej + """.formatted(plate, ordersUrl); + sendPlainText(toEmail, subject, body); + } + + private void sendPlainText(String toEmail, String subject, String body) { + if (mailHost == null || mailHost.isBlank() || mailSender == null) { + log.info("SMTP not configured. Email to {} — subject: {}", toEmail, subject); + return; + } + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(mailFrom); + message.setTo(toEmail); + message.setSubject(subject); + message.setText(body); + try { + mailSender.send(message); + } catch (MailException ex) { + log.error("Failed to send email to {}", toEmail, ex); + throw new IllegalStateException("Kunde inte skicka e-post just nu"); + } + } } diff --git a/backend/src/main/java/se/bilhalsning/service/OrderNotificationService.java b/backend/src/main/java/se/bilhalsning/service/OrderNotificationService.java new file mode 100644 index 0000000..f0cadde --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/service/OrderNotificationService.java @@ -0,0 +1,69 @@ +package se.bilhalsning.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import se.bilhalsning.entity.Order; +import se.bilhalsning.entity.User; +import se.bilhalsning.repository.UserRepository; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OrderNotificationService { + + private final UserRepository userRepository; + private final EmailService emailService; + + @Value("${app.public-base-url:http://localhost:3000}") + private String publicBaseUrl; + + public void notifyOrderProcessing(Order order) { + String email = resolveCustomerEmail(order); + if (email.isBlank()) { + return; + } + emailService.sendOrderProcessingEmail( + email, + order.getPlate(), + ordersPageUrl()); + } + + public void notifyOrderSent(Order order, String trackingId) { + String email = resolveCustomerEmail(order); + if (email.isBlank()) { + return; + } + String trackingUrl = "https://www.postnord.se/verktyg/spara/?id=" + trackingId; + emailService.sendOrderSentEmail(email, order.getPlate(), trackingId, trackingUrl); + } + + public void notifyOrderFailed(Order order) { + String email = resolveCustomerEmail(order); + if (email.isBlank()) { + return; + } + emailService.sendOrderFailedEmail(email, order.getPlate(), ordersPageUrl()); + } + + private String resolveCustomerEmail(Order order) { + if (order.getUser() != null && order.getUser().getEmail() != null) { + return order.getUser().getEmail(); + } + UUID userId = order.getUserId(); + if (userId == null) { + return ""; + } + return userRepository.findById(userId) + .map(User::getEmail) + .orElse(""); + } + + private String ordersPageUrl() { + String base = publicBaseUrl.endsWith("/") + ? publicBaseUrl.substring(0, publicBaseUrl.length() - 1) + : publicBaseUrl; + return base + "/mina-bestallningar"; + } +} diff --git a/backend/src/main/java/se/bilhalsning/service/OrderService.java b/backend/src/main/java/se/bilhalsning/service/OrderService.java index a4a6d68..12742f5 100644 --- a/backend/src/main/java/se/bilhalsning/service/OrderService.java +++ b/backend/src/main/java/se/bilhalsning/service/OrderService.java @@ -7,8 +7,11 @@ import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.exception.InvalidOrderStateException; import se.bilhalsning.exception.OrderNotFoundException; import se.bilhalsning.repository.OrderRepository; +import se.bilhalsning.util.PostNordTrackingNormalizer; +import java.time.Instant; import java.util.List; +import java.util.Set; import java.util.UUID; @Service @@ -16,6 +19,7 @@ import java.util.UUID; public class OrderService { private final OrderRepository orderRepository; + private final OrderNotificationService orderNotificationService; public Order createOrder(UUID userId, String plate, String letterText) { Order order = new Order(); @@ -40,26 +44,68 @@ public class OrderService { } public Order updateOrderStatus(UUID orderId, String statusString) { - Order order = orderRepository.findById(orderId) - .orElseThrow(() -> new OrderNotFoundException(orderId)); - - OrderStatus newStatus = OrderStatus.valueOf(statusString.toUpperCase()); + Order order = requireOrder(orderId); + OrderStatus newStatus = parseStatus(statusString); + OrderStatus previousStatus = order.getStatus(); + validateAdminStatusTransition(order, newStatus); order.setStatus(newStatus); - return orderRepository.save(order); + if (newStatus == OrderStatus.SENT + && previousStatus == OrderStatus.FAILED + && order.getShippedAt() == null) { + order.setShippedAt(Instant.now()); + } + Order saved = orderRepository.save(order); + if (newStatus == OrderStatus.FAILED && previousStatus != OrderStatus.FAILED) { + orderNotificationService.notifyOrderFailed(saved); + } + return saved; } - public Order updateTracking(UUID orderId, String trackingId) { - Order order = orderRepository.findById(orderId) - .orElseThrow(() -> new OrderNotFoundException(orderId)); + public Order registerShipment(UUID orderId, String rawTrackingInput, boolean notifyCustomer) { + String trackingId = PostNordTrackingNormalizer.normalize(rawTrackingInput); + Order order = requireOrder(orderId); + OrderStatus previousStatus = order.getStatus(); + if (previousStatus == OrderStatus.FAILED && order.getAmountPaid() == null) { + throw new InvalidOrderStateException( + "Obetalda misslyckade beställningar kan inte registreras som utskickade"); + } + + if (previousStatus != OrderStatus.PROCESSING + && previousStatus != OrderStatus.SENT + && previousStatus != OrderStatus.DELIVERED + && previousStatus != OrderStatus.FAILED) { + throw new InvalidOrderStateException( + "Beställningen kan inte registreras som utskickad i detta tillstånd"); + } + + boolean firstShipment = previousStatus == OrderStatus.PROCESSING + || previousStatus == OrderStatus.FAILED; order.setTrackingId(trackingId); + if (firstShipment) { + order.setStatus(OrderStatus.SENT); + order.setShippedAt(Instant.now()); + } + + Order saved = orderRepository.save(order); + if (notifyCustomer && firstShipment) { + orderNotificationService.notifyOrderSent(saved, trackingId); + } + return saved; + } + + public Order updateAdminNotes(UUID orderId, String adminNotes) { + Order order = requireOrder(orderId); + order.setAdminNotes(adminNotes); return orderRepository.save(order); } public Order confirmPayment(UUID orderId, UUID userId) { Order order = requirePendingOwnedBy(orderId, userId); order.setStatus(OrderStatus.PROCESSING); - return orderRepository.save(order); + Order saved = orderRepository.save(order); + orderNotificationService.notifyOrderProcessing(saved); + return saved; } public Order cancelOrder(UUID orderId, UUID userId) { @@ -74,6 +120,11 @@ public class OrderService { return orderRepository.save(order); } + private Order requireOrder(UUID orderId) { + return orderRepository.findWithUserById(orderId) + .orElseThrow(() -> new OrderNotFoundException(orderId)); + } + private Order requirePendingOwnedBy(UUID orderId, UUID userId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); @@ -89,4 +140,53 @@ public class OrderService { return order; } + + private static OrderStatus parseStatus(String statusString) { + try { + return OrderStatus.valueOf(statusString.toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException("Ogiltig status"); + } + } + + private static void validateAdminStatusTransition(Order order, OrderStatus to) { + OrderStatus from = order.getStatus(); + if (from == to) { + return; + } + + Set allowed = switch (from) { + case PENDING_PAYMENT -> Set.of(OrderStatus.FAILED); + case PROCESSING -> Set.of(OrderStatus.FAILED); + case SENT -> Set.of(OrderStatus.DELIVERED, OrderStatus.FAILED); + case DELIVERED -> Set.of(OrderStatus.FAILED); + case FAILED -> allowedTargetsFromFailed(order); + default -> Set.of(); + }; + + if (!allowed.contains(to)) { + throw new InvalidOrderStateException( + "Status kan inte ändras från " + from.getValue() + " till " + to.getValue()); + } + } + + private static Set allowedTargetsFromFailed(Order order) { + Set allowed = new java.util.HashSet<>(); + if (hasTrackingId(order)) { + allowed.add(OrderStatus.PROCESSING); + allowed.add(OrderStatus.SENT); + allowed.add(OrderStatus.DELIVERED); + } else if (order.getAmountPaid() == null) { + allowed.add(OrderStatus.PENDING_PAYMENT); + } else { + allowed.add(OrderStatus.PROCESSING); + allowed.add(OrderStatus.SENT); + } + return allowed; + } + + private static boolean hasTrackingId(Order order) { + String trackingId = order.getTrackingId(); + return trackingId != null && !trackingId.isBlank(); + } } diff --git a/backend/src/main/java/se/bilhalsning/util/PostNordTrackingNormalizer.java b/backend/src/main/java/se/bilhalsning/util/PostNordTrackingNormalizer.java new file mode 100644 index 0000000..8c592ba --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/util/PostNordTrackingNormalizer.java @@ -0,0 +1,45 @@ +package se.bilhalsning.util; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +public final class PostNordTrackingNormalizer { + + private PostNordTrackingNormalizer() { + } + + public static String normalize(String raw) { + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException("Spårnings-ID krävs"); + } + + String trimmed = raw.trim(); + if (trimmed.toLowerCase().contains("postnord")) { + String fromUrl = extractIdFromPostNordUrl(trimmed); + if (fromUrl != null && !fromUrl.isBlank()) { + trimmed = fromUrl; + } + } + + return trimmed.replaceAll("\\s+", ""); + } + + private static String extractIdFromPostNordUrl(String url) { + try { + URI uri = URI.create(url); + String query = uri.getQuery(); + if (query == null) { + return null; + } + for (String param : query.split("&")) { + if (param.startsWith("id=")) { + return URLDecoder.decode(param.substring(3), StandardCharsets.UTF_8).trim(); + } + } + } catch (IllegalArgumentException ignored) { + return null; + } + return null; + } +} diff --git a/backend/src/main/resources/db/dev-migration/V7__seed_processing_order.sql b/backend/src/main/resources/db/dev-migration/V7__seed_processing_order.sql new file mode 100644 index 0000000..d652424 --- /dev/null +++ b/backend/src/main/resources/db/dev-migration/V7__seed_processing_order.sql @@ -0,0 +1,14 @@ +-- Dev/CI: order in "processing" for admin fulfillment testing +INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at) +VALUES ( + 'c4eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + 'JKL012', + 'Hej! Bara en påminnelse om serviceboken.', + 'processing', + 49.00, + NULL, + TIMESTAMP '2026-05-16 09:00:00', + TIMESTAMP '2026-05-16 09:00:00' +) +ON CONFLICT (id) DO NOTHING; diff --git a/backend/src/main/resources/db/migration/V11__add_order_fulfillment_columns.sql b/backend/src/main/resources/db/migration/V11__add_order_fulfillment_columns.sql new file mode 100644 index 0000000..713ddf9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__add_order_fulfillment_columns.sql @@ -0,0 +1,3 @@ +ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMP WITH TIME ZONE; + +ALTER TABLE orders ADD COLUMN admin_notes TEXT; diff --git a/backend/src/test/java/se/bilhalsning/controller/AdminControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/AdminControllerTest.java index 3dd324a..c2d7b91 100644 --- a/backend/src/test/java/se/bilhalsning/controller/AdminControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/AdminControllerTest.java @@ -1,6 +1,6 @@ package se.bilhalsning.controller; -import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -23,6 +23,7 @@ import org.springframework.test.web.servlet.MockMvc; import se.bilhalsning.entity.Order; import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.entity.User; +import se.bilhalsning.exception.InvalidOrderStateException; import se.bilhalsning.exception.OrderNotFoundException; import se.bilhalsning.service.OrderService; @@ -61,151 +62,93 @@ class AdminControllerTest { .andExpect(jsonPath("$[0].id").value(order.getId().toString())) .andExpect(jsonPath("$[0].email").value("test@bilhej.se")) .andExpect(jsonPath("$[0].plate").value("ABC123")) - .andExpect(jsonPath("$[0].letterText").value("Test letter")) .andExpect(jsonPath("$[0].status").value("sent")); } - @Test - @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") - void shouldReturnEmptyArrayWhenNoOrders() throws Exception { - when(orderService.getAllOrders()).thenReturn(List.of()); - - mockMvc.perform(get("/api/admin/orders")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$").isEmpty()); - } - - @Test - void shouldReturn403WhenPatchingStatusWithoutAuth() throws Exception { - mockMvc.perform(patch("/api/admin/orders/{id}/status", - "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\":\"paid\"}")) - .andExpect(status().isForbidden()); - } - - @Test - @WithMockUser(username = "test@bilhej.se", roles = "USER") - void shouldReturn403WhenPatchingStatusAsNonAdmin() throws Exception { - mockMvc.perform(patch("/api/admin/orders/{id}/status", - "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\":\"paid\"}")) - .andExpect(status().isForbidden()); - } - @Test @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") void shouldUpdateOrderStatusSuccessfully() throws Exception { UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); - Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PAID); + Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.FAILED); - when(orderService.updateOrderStatus(eq(orderId), eq("paid"))).thenReturn(order); + when(orderService.updateOrderStatus(eq(orderId), eq("failed"))).thenReturn(order); mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\":\"paid\"}")) + .content("{\"status\":\"failed\"}")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(orderId.toString())) - .andExpect(jsonPath("$.status").value("paid")); + .andExpect(jsonPath("$.status").value("failed")); } @Test @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") - void shouldReturn400WhenStatusIsInvalid() throws Exception { - mockMvc.perform(patch("/api/admin/orders/{id}/status", - "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\":\"invalid_status\"}")) - .andExpect(status().isBadRequest()); - } - - @Test - @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") - void shouldReturn400WhenStatusIsBlank() throws Exception { - mockMvc.perform(patch("/api/admin/orders/{id}/status", - "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\":\"\"}")) - .andExpect(status().isBadRequest()); - } - - @Test - @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") - void shouldReturn404WhenOrderNotFound() throws Exception { + void shouldReturn409WhenStatusTransitionInvalid() throws Exception { UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); - when(orderService.updateOrderStatus(eq(orderId), eq("paid"))) - .thenThrow(new OrderNotFoundException(orderId)); + when(orderService.updateOrderStatus(eq(orderId), eq("delivered"))) + .thenThrow(new InvalidOrderStateException("Ogiltig övergång")); mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\":\"paid\"}")) - .andExpect(status().isNotFound()); - } - - @Test - void shouldReturn403WhenPatchingTrackingWithoutAuth() throws Exception { - mockMvc.perform(patch("/api/admin/orders/{id}", - "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"trackingId\":\"PN123456789\"}")) - .andExpect(status().isForbidden()); - } - - @Test - @WithMockUser(username = "test@bilhej.se", roles = "USER") - void shouldReturn403WhenPatchingTrackingAsNonAdmin() throws Exception { - mockMvc.perform(patch("/api/admin/orders/{id}", - "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"trackingId\":\"PN123456789\"}")) - .andExpect(status().isForbidden()); + .content("{\"status\":\"delivered\"}")) + .andExpect(status().isConflict()); } @Test @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") - void shouldUpdateTrackingSuccessfully() throws Exception { + void shouldRegisterShipmentSuccessfully() throws Exception { UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT); order.setTrackingId("PN123456789"); + order.setShippedAt(Instant.parse("2026-05-13T12:00:00Z")); - when(orderService.updateTracking(eq(orderId), eq("PN123456789"))).thenReturn(order); + when(orderService.registerShipment(eq(orderId), eq("PN123456789"), eq(true))) + .thenReturn(order); - mockMvc.perform(patch("/api/admin/orders/{id}", orderId) + mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId) .contentType(MediaType.APPLICATION_JSON) - .content("{\"trackingId\":\"PN123456789\"}")) + .content("{\"trackingInput\":\"PN123456789\",\"notifyCustomer\":true}")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(orderId.toString())) - .andExpect(jsonPath("$.trackingId").value("PN123456789")); + .andExpect(jsonPath("$.trackingId").value("PN123456789")) + .andExpect(jsonPath("$.status").value("sent")); } @Test @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") - void shouldClearTrackingWhenNull() throws Exception { + void shouldReturn400WhenTrackingInputBlank() throws Exception { UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); - Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT); - order.setTrackingId(null); - when(orderService.updateTracking(eq(orderId), eq(null))).thenReturn(order); - - mockMvc.perform(patch("/api/admin/orders/{id}", orderId) + mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId) .contentType(MediaType.APPLICATION_JSON) - .content("{\"trackingId\":null}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.trackingId").doesNotExist()); + .content("{\"trackingInput\":\"\"}")) + .andExpect(status().isBadRequest()); } @Test @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") - void shouldReturn404WhenOrderNotFoundForTracking() throws Exception { + void shouldUpdateAdminNotes() throws Exception { UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); - when(orderService.updateTracking(eq(orderId), eq("PN123456789"))) + Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PROCESSING); + order.setAdminNotes("Kontaktat TS"); + + when(orderService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order); + + mockMvc.perform(patch("/api/admin/orders/{id}/notes", orderId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"adminNotes\":\"Kontaktat TS\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.adminNotes").value("Kontaktat TS")); + } + + @Test + @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") + void shouldReturn404WhenOrderNotFoundForRegisterShipment() throws Exception { + UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + when(orderService.registerShipment(eq(orderId), eq("PN123"), anyBoolean())) .thenThrow(new OrderNotFoundException(orderId)); - mockMvc.perform(patch("/api/admin/orders/{id}", orderId) + mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId) .contentType(MediaType.APPLICATION_JSON) - .content("{\"trackingId\":\"PN123456789\"}")) + .content("{\"trackingInput\":\"PN123\"}")) .andExpect(status().isNotFound()); } @@ -219,7 +162,6 @@ class AdminControllerTest { order.setPlate(plate); order.setLetterText("Test letter"); order.setStatus(status); - order.setTrackingId(null); order.setAmountPaid(new BigDecimal("49.00")); return order; diff --git a/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java b/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java index 3a88972..388eeda 100644 --- a/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java +++ b/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java @@ -27,6 +27,9 @@ class OrderServiceTest { @Mock private OrderRepository orderRepository; + @Mock + private OrderNotificationService orderNotificationService; + @InjectMocks private OrderService orderService; @@ -249,4 +252,151 @@ class OrderServiceTest { assertThrows(OrderNotFoundException.class, () -> orderService.confirmPayment(orderId, otherUserId)); } + + @Test + void shouldRegisterShipmentFromProcessingAndSetSent() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.PROCESSING); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.registerShipment(orderId, "PN123456789", true); + + assertEquals(OrderStatus.SENT, result.getStatus()); + assertEquals("PN123456789", result.getTrackingId()); + assertNotNull(result.getShippedAt()); + verify(orderNotificationService).notifyOrderSent(result, "PN123456789"); + } + + @Test + void shouldRejectRegisterShipmentWhenPendingPayment() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.PENDING_PAYMENT); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + + assertThrows(InvalidOrderStateException.class, + () -> orderService.registerShipment(orderId, "PN123", true)); + } + + @Test + void shouldRejectInvalidAdminStatusTransition() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.PROCESSING); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + + assertThrows(InvalidOrderStateException.class, + () -> orderService.updateOrderStatus(orderId, "delivered")); + } + + @Test + void shouldMarkPendingPaymentAsFailed() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.PENDING_PAYMENT); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.updateOrderStatus(orderId, "failed"); + + assertEquals(OrderStatus.FAILED, result.getStatus()); + verify(orderNotificationService).notifyOrderFailed(result); + } + + @Test + void shouldRevertFailedToPendingPaymentWhenUnpaid() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.FAILED); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.updateOrderStatus(orderId, "pending_payment"); + + assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus()); + } + + @Test + void shouldRevertFailedToProcessingWhenPaidWithoutTracking() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.FAILED); + order.setAmountPaid(new java.math.BigDecimal("49.00")); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.updateOrderStatus(orderId, "processing"); + + assertEquals(OrderStatus.PROCESSING, result.getStatus()); + verify(orderNotificationService, never()).notifyOrderFailed(any(Order.class)); + } + + @Test + void shouldRevertFailedToSentWhenPaidWithoutTracking() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.FAILED); + order.setAmountPaid(new java.math.BigDecimal("49.00")); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.updateOrderStatus(orderId, "sent"); + + assertEquals(OrderStatus.SENT, result.getStatus()); + assertNotNull(result.getShippedAt()); + } + + @Test + void shouldRegisterShipmentFromFailedWhenPaid() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.FAILED); + order.setAmountPaid(new java.math.BigDecimal("49.00")); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.registerShipment(orderId, "PN888", true); + + assertEquals(OrderStatus.SENT, result.getStatus()); + assertEquals("PN888", result.getTrackingId()); + } + + @Test + void shouldRevertFailedToSentWhenTrackingExists() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.FAILED); + order.setTrackingId("PN123"); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.updateOrderStatus(orderId, "sent"); + + assertEquals(OrderStatus.SENT, result.getStatus()); + } + + @Test + void shouldNotifyCustomerOnFailedStatusFromProcessing() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus(OrderStatus.PROCESSING); + when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + orderService.updateOrderStatus(orderId, "failed"); + + verify(orderNotificationService).notifyOrderFailed(any(Order.class)); + } } diff --git a/backend/src/test/java/se/bilhalsning/util/PostNordTrackingNormalizerTest.java b/backend/src/test/java/se/bilhalsning/util/PostNordTrackingNormalizerTest.java new file mode 100644 index 0000000..ec9ccda --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/util/PostNordTrackingNormalizerTest.java @@ -0,0 +1,26 @@ +package se.bilhalsning.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PostNordTrackingNormalizerTest { + + @Test + void shouldTrimAndRemoveWhitespaceFromPlainId() { + assertEquals("PN123456789", PostNordTrackingNormalizer.normalize(" PN 123 456 789 ")); + } + + @Test + void shouldExtractIdFromPostNordUrl() { + String url = "https://www.postnord.se/verktyg/spara/?id=PN987654321&utm=foo"; + assertEquals("PN987654321", PostNordTrackingNormalizer.normalize(url)); + } + + @Test + void shouldThrowWhenInputIsBlank() { + assertThrows(IllegalArgumentException.class, + () -> PostNordTrackingNormalizer.normalize(" ")); + } +} diff --git a/frontend/e2e/admin-dashboard.spec.ts b/frontend/e2e/admin-dashboard.spec.ts index 699c9b7..c3f15da 100644 --- a/frontend/e2e/admin-dashboard.spec.ts +++ b/frontend/e2e/admin-dashboard.spec.ts @@ -2,6 +2,13 @@ import { test, expect } from '@playwright/test' const SEEDED_ORDER_ID = 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' const SEEDED_ORDER_SHORT_ID = SEEDED_ORDER_ID.slice(0, 8) +const PROCESSING_PLATE = 'JKL012' + +function rowByPlate(page: import('@playwright/test').Page, plate: string) { + return page.locator('.admin__row').filter({ + has: page.locator('.admin__plate', { hasText: plate }), + }) +} test.describe('Admin dashboard', () => { test.beforeEach(async ({ page }) => { @@ -37,9 +44,7 @@ test.describe('Admin dashboard', () => { await page.goto('/admin') await expect(page.getByRole('columnheader', { name: 'Datum' })).toBeVisible() - await expect( - page.getByRole('columnheader', { name: 'Beställnings-ID' }), - ).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'ID' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'E-post' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'Regnr' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible() @@ -69,34 +74,38 @@ test.describe('Admin dashboard', () => { await expect(dialog).not.toBeVisible() }) - test('click expand button shows tracking section', async ({ page }) => { + test('click row shows tracking section', async ({ page }) => { await page.goto('/admin') + await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 }) - const expandBtns = page.locator('.admin__expand-btn') - await expandBtns.first().click() + await rowByPlate(page, PROCESSING_PLATE).click() - await expect(page.getByText('Spårnings-ID').first()).toBeVisible() + await expect(page.getByText('Registrera utskick').first()).toBeVisible() }) - test('click expand button again collapses it', async ({ page }) => { + test('click row again collapses it', async ({ page }) => { await page.goto('/admin') + await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 }) - const expandBtns = page.locator('.admin__expand-btn') - await expandBtns.first().click() + const row = rowByPlate(page, PROCESSING_PLATE) + await row.click() await expect(page.locator('.admin__tracking-input').first()).toBeVisible() - await expandBtns.first().click() + await row.click() await expect(page.locator('.admin__tracking-input').first()).not.toBeVisible() }) - test('status dropdown changes update order status', async ({ page }) => { + test('status dropdown changes update order status for sent orders', async ({ + page, + }) => { await page.goto('/admin') + await page.locator('#admin-order-search').fill(SEEDED_ORDER_SHORT_ID) - const selects = page.locator('.admin__status-select') - await selects.first().selectOption('delivered') + const row = page.locator('.admin__row', { hasText: SEEDED_ORDER_SHORT_ID }) + const select = row.locator('.admin__status-select') + await select.selectOption('delivered') - const updatedSelect = selects.first() - await expect(updatedSelect).toHaveValue('delivered') + await expect(select).toHaveValue('delivered') }) test('admin cannot access admin page without auth', async ({ page }) => { @@ -108,20 +117,21 @@ test.describe('Admin dashboard', () => { test('expanded row shows tracking input and save button', async ({ page }) => { await page.goto('/admin') + await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 }) - const expandBtns = page.locator('.admin__expand-btn') - await expandBtns.first().click() + await rowByPlate(page, PROCESSING_PLATE).click() - await expect(page.getByText('Spårnings-ID').first()).toBeVisible() + await expect(page.getByText('Registrera utskick').first()).toBeVisible() await expect(page.locator('.admin__tracking-input')).toBeVisible() - await expect(page.getByRole('button', { name: 'Spara' })).toBeVisible() + await expect( + page.getByRole('button', { name: 'Registrera utskick' }), + ).toBeVisible() }) test('shows PostNord link when trackingId exists', async ({ page }) => { await page.goto('/admin') - const expandBtns = page.locator('.admin__expand-btn') - await expandBtns.last().click() + await page.locator('.admin__row').last().click() const trackingLink = page.locator('.admin__tracking-link') await expect(trackingLink).toBeVisible() @@ -132,8 +142,7 @@ test.describe('Admin dashboard', () => { await page.goto('/admin') const defRow = page.locator('.admin__row', { hasText: 'DEF456' }).first() - const expandBtn = defRow.locator('.admin__expand-btn') - await expandBtn.click() + await defRow.click() const trackingLink = page.locator('.admin__tracking-link') await expect(trackingLink).not.toBeVisible() diff --git a/frontend/e2e/admin-fulfillment.spec.ts b/frontend/e2e/admin-fulfillment.spec.ts new file mode 100644 index 0000000..1f136b4 --- /dev/null +++ b/frontend/e2e/admin-fulfillment.spec.ts @@ -0,0 +1,99 @@ +import { test, expect } from '@playwright/test' + +/** + * Admin order status and shipment flows (serial — mutates seeded orders). + * Requires docker e2e stack with dev seeds (DEF456, JKL012, ABC123). + */ +test.describe.configure({ mode: 'serial' }) + +const PENDING_ORDER_SHORT_ID = 'c2eebc99' +const PROCESSING_PLATE = 'JKL012' +const SENT_ORDER_SHORT_ID = 'c1eebc99' + +async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill('admin@bilhalsning.se') + await page.getByLabel('Lösenord').fill('test1234') + await page.getByRole('button', { name: 'Logga in' }).click() + await page.waitForURL('/') +} + +async function openAdmin(page: import('@playwright/test').Page) { + await page.goto('/admin') + await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 }) +} + +function orderRowByPlate(page: import('@playwright/test').Page, plate: string) { + return page.locator('.admin__row').filter({ + has: page.locator('.admin__plate', { hasText: plate }), + }) +} + +function orderRowByShortId( + page: import('@playwright/test').Page, + shortId: string, +) { + return page.locator('.admin__row', { hasText: shortId }) +} + +test.describe('Admin fulfillment flows', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page) + await openAdmin(page) + }) + + test('can mark unpaid order as failed', async ({ page }) => { + await page.locator('#admin-order-search').fill(PENDING_ORDER_SHORT_ID) + const row = orderRowByShortId(page, PENDING_ORDER_SHORT_ID) + const select = row.locator('.admin__status-select') + await select.selectOption('failed') + await expect(select).toHaveValue('failed') + await expect(page.getByRole('alert')).not.toBeVisible() + }) + + test('can revert unpaid failed order to pending payment', async ({ + page, + }) => { + await page.locator('#admin-order-search').fill(PENDING_ORDER_SHORT_ID) + const row = orderRowByShortId(page, PENDING_ORDER_SHORT_ID) + const select = row.locator('.admin__status-select') + await expect(select).toHaveValue('failed') + await select.selectOption('pending_payment') + await expect(select).toHaveValue('pending_payment') + }) + + test('can register shipment for processing order', async ({ page }) => { + const row = orderRowByPlate(page, PROCESSING_PLATE) + await row.click() + await page + .locator('.admin__tracking-input') + .fill('PN-E2E-FULFILLMENT-001') + await page.getByRole('button', { name: 'Registrera utskick' }).click() + await expect(row.locator('.admin__status-select')).toHaveValue('sent') + await expect(page.locator('.admin__tracking-link')).toBeVisible() + }) + + test('can mark sent order as delivered', async ({ page }) => { + await page.locator('#admin-order-search').fill(SENT_ORDER_SHORT_ID) + const row = orderRowByShortId(page, SENT_ORDER_SHORT_ID) + const select = row.locator('.admin__status-select') + if ((await select.inputValue()) !== 'delivered') { + await select.selectOption('delivered') + } + await expect(select).toHaveValue('delivered') + }) + + test('can mark delivered order as failed then back to sent when tracking exists', async ({ + page, + }) => { + await page.locator('#admin-order-search').fill(SENT_ORDER_SHORT_ID) + const row = orderRowByShortId(page, SENT_ORDER_SHORT_ID) + const select = row.locator('.admin__status-select') + + await select.selectOption('failed') + await expect(select).toHaveValue('failed') + + await select.selectOption('sent') + await expect(select).toHaveValue('sent') + }) +}) diff --git a/frontend/e2e/order-history.spec.ts b/frontend/e2e/order-history.spec.ts index b672fe4..a9deeb8 100644 --- a/frontend/e2e/order-history.spec.ts +++ b/frontend/e2e/order-history.spec.ts @@ -49,7 +49,7 @@ test.describe('Order history', () => { await page.goto('/orders') - await expect(page.getByText('Skickat')).toBeVisible() + await expect(page.getByText('Skickat').first()).toBeVisible() await expect(page.getByText('Väntar på betalning').first()).toBeVisible() await expect(page.getByText('Levererat').first()).toBeVisible() }) diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 4c5a328..3226301 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -23,12 +23,18 @@ export default defineConfig({ projects: [ { name: 'chromium', - testIgnore: '**/deferred-payment-admin.spec.ts', + testIgnore: [ + '**/deferred-payment-admin.spec.ts', + '**/admin-fulfillment.spec.ts', + ], use: { browserName: 'chromium' }, }, { name: 'chromium-serial', - testMatch: '**/deferred-payment-admin.spec.ts', + testMatch: [ + '**/deferred-payment-admin.spec.ts', + '**/admin-fulfillment.spec.ts', + ], fullyParallel: false, workers: 1, use: { browserName: 'chromium' }, diff --git a/frontend/src/__tests__/AdminDashboard.spec.ts b/frontend/src/__tests__/AdminDashboard.spec.ts index 5e8f351..74f5e32 100644 --- a/frontend/src/__tests__/AdminDashboard.spec.ts +++ b/frontend/src/__tests__/AdminDashboard.spec.ts @@ -43,6 +43,8 @@ const mockOrders = [ status: 'sent', trackingId: 'PN123456789', amountPaid: 49.0, + shippedAt: '2026-05-13T12:00:00Z', + adminNotes: null, createdAt: '2026-05-11T12:00:00Z', }, { @@ -53,6 +55,8 @@ const mockOrders = [ status: 'processing', trackingId: null, amountPaid: null, + shippedAt: null, + adminNotes: null, createdAt: '2026-05-14T13:00:00Z', }, { @@ -63,16 +67,22 @@ const mockOrders = [ status: 'pending_payment', trackingId: null, amountPaid: null, + shippedAt: null, + adminNotes: null, createdAt: '2026-05-15T14:00:00Z', }, ] +function freshMockOrders() { + return mockOrders.map((order) => ({ ...order })) +} + describe('AdminDashboard', () => { beforeEach(() => { localStorage.clear() globalThis.fetch = vi.fn() vi.mocked(globalThis.fetch).mockResolvedValue( - mockFetchResponse(200, mockOrders), + mockFetchResponse(200, freshMockOrders()), ) }) @@ -101,10 +111,10 @@ describe('AdminDashboard', () => { const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) expect(wrapper.text()).toContain('Datum') - expect(wrapper.text()).toContain('Beställnings-ID') + expect(wrapper.text()).toContain('ID') expect(wrapper.text()).toContain('E-post') expect(wrapper.text()).toContain('Regnr') - expect(wrapper.text()).toContain('Meddelande') + expect(wrapper.text()).toContain('Brev') expect(wrapper.text()).toContain('Status') }) @@ -163,7 +173,7 @@ describe('AdminDashboard', () => { const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) - const expandBtns = wrapper.findAll('.admin__expand-btn') + const expandBtns = wrapper.findAll('.admin__row') await expandBtns[0].trigger('click') await new Promise((r) => setTimeout(r, 50)) expect(wrapper.find('.admin__expanded-row').exists()).toBe(true) @@ -177,7 +187,7 @@ describe('AdminDashboard', () => { const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) - const expandBtns = wrapper.findAll('.admin__expand-btn') + const expandBtns = wrapper.findAll('.admin__row') await expandBtns[0].trigger('click') await new Promise((r) => setTimeout(r, 50)) expect(wrapper.findAll('.admin__expanded-row')).toHaveLength(1) @@ -198,15 +208,16 @@ describe('AdminDashboard', () => { it('fires status update API on dropdown change', async () => { vi.mocked(globalThis.fetch) - .mockResolvedValueOnce(mockFetchResponse(200, mockOrders)) + .mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders())) .mockResolvedValueOnce( - mockFetchResponse(200, { ...mockOrders[0], status: 'paid' }), + mockFetchResponse(200, { ...mockOrders[0], status: 'delivered' }), ) const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) const selects = wrapper.findAll('.admin__status-select') + await selects[0].setValue('delivered') await selects[0].trigger('change') await new Promise((r) => setTimeout(r, 50)) @@ -214,14 +225,14 @@ describe('AdminDashboard', () => { '/api/admin/orders/c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11/status', expect.objectContaining({ method: 'PATCH', - body: '{"status":"sent"}', + body: '{"status":"delivered"}', }), ) }) it('shows status error on failed update', async () => { vi.mocked(globalThis.fetch) - .mockResolvedValueOnce(mockFetchResponse(200, mockOrders)) + .mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders())) .mockResolvedValueOnce( mockFetchResponse(500, { message: 'Server error' }), ) @@ -247,7 +258,7 @@ describe('AdminDashboard', () => { const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) - const expandBtns = wrapper.findAll('.admin__expand-btn') + const expandBtns = wrapper.findAll('.admin__row') await expandBtns[0].trigger('click') await new Promise((r) => setTimeout(r, 50)) @@ -260,7 +271,7 @@ describe('AdminDashboard', () => { const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) - const expandBtns = wrapper.findAll('.admin__expand-btn') + const expandBtns = wrapper.findAll('.admin__row') await expandBtns[0].trigger('click') await new Promise((r) => setTimeout(r, 50)) @@ -274,7 +285,7 @@ describe('AdminDashboard', () => { const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) - const expandBtns = wrapper.findAll('.admin__expand-btn') + const expandBtns = wrapper.findAll('.admin__row') await expandBtns[1].trigger('click') await new Promise((r) => setTimeout(r, 50)) @@ -282,32 +293,47 @@ describe('AdminDashboard', () => { expect(link.exists()).toBe(false) }) - it('fires PATCH on tracking save button click', async () => { - vi.mocked(globalThis.fetch).mockResolvedValueOnce( - mockFetchResponse(200, mockOrders), - ) + it('fires register-shipment API on register button click', async () => { + vi.mocked(globalThis.fetch) + .mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders())) + .mockResolvedValueOnce( + mockFetchResponse(200, { + ...mockOrders[1], + status: 'sent', + trackingId: 'PN999', + }), + ) const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) - const expandBtns = wrapper.findAll('.admin__expand-btn') + const expandBtns = wrapper.findAll('.admin__row') await expandBtns[1].trigger('click') await new Promise((r) => setTimeout(r, 50)) - await wrapper.find('.btn--primary').trigger('click') + await wrapper.find('.admin__tracking-input').setValue('PN999') + const registerBtn = wrapper + .findAll('button') + .find((btn) => btn.text() === 'Registrera utskick') + expect(registerBtn).toBeDefined() + await registerBtn!.trigger('click') await new Promise((r) => setTimeout(r, 50)) expect(globalThis.fetch).toHaveBeenCalledWith( - '/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', + '/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12/register-shipment', expect.objectContaining({ method: 'PATCH', + body: JSON.stringify({ + trackingInput: 'PN999', + notifyCustomer: true, + }), }), ) }) it('shows tracking error on failed save', async () => { vi.mocked(globalThis.fetch) - .mockResolvedValueOnce(mockFetchResponse(200, mockOrders)) + .mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders())) .mockResolvedValueOnce( mockFetchResponse(500, { message: 'Server error' }), ) @@ -315,14 +341,18 @@ describe('AdminDashboard', () => { const { wrapper } = mountPage() await new Promise((r) => setTimeout(r, 50)) - const expandBtns = wrapper.findAll('.admin__expand-btn') + const expandBtns = wrapper.findAll('.admin__row') await expandBtns[1].trigger('click') await new Promise((r) => setTimeout(r, 50)) - await wrapper.find('.btn--primary').trigger('click') + await wrapper.find('.admin__tracking-input').setValue('PN999') + const registerBtn = wrapper + .findAll('button') + .find((btn) => btn.text() === 'Registrera utskick') + await registerBtn!.trigger('click') await new Promise((r) => setTimeout(r, 50)) - expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID') + expect(wrapper.text()).toContain('Kunde inte registrera utskick') }) it('shows Att göra stat for processing orders', async () => { @@ -392,7 +422,10 @@ describe('AdminDashboard', () => { await new Promise((r) => setTimeout(r, 50)) const rows = wrapper.findAll('.admin__row') - const processingRow = rows.find((row) => row.text().includes('XYZ789')) + const processingRow = rows.find( + (row) => + row.text().includes('XYZ789') && row.classes().includes('admin__row'), + ) expect(processingRow?.classes()).toContain('admin__row--todo') }) }) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 09cd9eb..50f1481 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -8,6 +8,8 @@ export interface AdminOrder { status: string trackingId: string | null amountPaid: number | null + shippedAt: string | null + adminNotes: string | null createdAt: string } @@ -25,12 +27,23 @@ export function updateOrderStatus( }) } -export function updateTracking( +export function registerShipment( orderId: string, - trackingId: string | null, + trackingInput: string, + notifyCustomer = true, ): Promise { - return request(`/admin/orders/${orderId}`, { + return request(`/admin/orders/${orderId}/register-shipment`, { method: 'PATCH', - body: JSON.stringify({ trackingId }), + body: JSON.stringify({ trackingInput, notifyCustomer }), + }) +} + +export function updateAdminNotes( + orderId: string, + adminNotes: string | null, +): Promise { + return request(`/admin/orders/${orderId}/notes`, { + method: 'PATCH', + body: JSON.stringify({ adminNotes }), }) } diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index df82fb9..269c733 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -171,7 +171,7 @@ onUnmounted(() => { Admin @@ -348,17 +348,6 @@ onUnmounted(() => { background: var(--color-primary-soft); } -.app-header__link--admin { - background: var(--color-primary-soft); - color: var(--color-primary); - font-weight: 600; -} - -.app-header__link--admin:hover { - background: #e9d5ff; - color: var(--color-primary-dark); -} - .app-header__link--settings-mobile { display: none; } diff --git a/frontend/src/pages/AdminPage.vue b/frontend/src/pages/AdminPage.vue index 902291a..abd8a23 100644 --- a/frontend/src/pages/AdminPage.vue +++ b/frontend/src/pages/AdminPage.vue @@ -1,9 +1,11 @@