Merge pull request 'Add admin order fulfillment tracking.' (#7) from feature/admin-fulfillment-tracking into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m10s
CI / E2E browser tests (push) Successful in 3m24s

Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/7
This commit is contained in:
jocke 2026-05-28 12:16:59 +00:00
commit 0b2c58fa82
30 changed files with 1317 additions and 315 deletions

View file

@ -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<number>__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,
@ -152,6 +160,21 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
list concrete changes as bullet points. Never write single-line
"feat: add X" messages.
**Before every commit (mandatory — agents must not skip):**
```bash
# from repo root; needs Docker running
export POSTGRES_DB=bilhej POSTGRES_USER=bilhej POSTGRES_PASSWORD=test_pw_ci_123
export JWT_SECRET=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PRICE_ID=price_fake
./gradlew check
```
This runs frontend lint, frontend unit tests (242), backend tests (163), coverage
thresholds, Flyway checks, and **all 90 E2E tests in Docker**. **Do not commit or
push if this fails.** Optional local guard: `./scripts/install-pre-commit-hook.sh`
(runs the same `check` on every `git commit`).
### Frontend (Vue.js 3)
- `<script setup>` with Composition API only. Never Options API.
- File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables.
@ -187,6 +210,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 +254,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 (shared Mailpit / seeded DB): `admin-fulfillment`, `deferred-payment-admin`, `admin-dashboard`, `account-settings`, `password-reset` — project `chromium-serial` runs **after** parallel `chromium`, `workers: 1`
### CI (future)
- `./gradlew check` and `npm run test && npm run lint` must pass before merge.

View file

@ -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<AdminOrderResponse> updateTracking(
@PatchMapping("/orders/{id}/register-shipment")
public ResponseEntity<AdminOrderResponse> 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<AdminOrderResponse> 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()
);
}

View file

@ -12,5 +12,7 @@ public record AdminOrderResponse(
String status,
String trackingId,
BigDecimal amountPaid,
Instant shippedAt,
String adminNotes,
Instant createdAt
) {}

View file

@ -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;
}
}

View file

@ -0,0 +1,5 @@
package se.bilhalsning.dto;
public record UpdateAdminNotesRequest(
String adminNotes
) {}

View file

@ -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

View file

@ -1,5 +0,0 @@
package se.bilhalsning.dto;
public record UpdateTrackingRequest(
String trackingId
) {}

View file

@ -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;
}

View file

@ -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<Order, UUID> {
@EntityGraph(attributePaths = {"user"})
List<Order> findAllByOrderByCreatedAtDesc();
@EntityGraph(attributePaths = {"user"})
Optional<Order> findWithUserById(UUID id);
}

View file

@ -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 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");
}
}
}

View file

@ -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";
}
}

View file

@ -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<OrderStatus> 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<OrderStatus> allowedTargetsFromFailed(Order order) {
Set<OrderStatus> 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();
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,13 @@
-- 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'
);

View file

@ -0,0 +1,3 @@
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE orders ADD COLUMN admin_notes TEXT;

View file

@ -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;

View file

@ -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));
}
}

View file

@ -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(" "));
}
}

View file

@ -89,8 +89,8 @@ services:
sleep 1;
done;
echo 'Waiting for backend...';
for i in \$(seq 1 60); do
curl -s http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
for i in \$(seq 1 120); do
curl -sf http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
sleep 1;
done;
echo 'Waiting for frontend...';

View file

@ -1,9 +1,5 @@
import { test, expect, type Page, type APIRequestContext } from '@playwright/test'
import {
clearMailpit,
countMessagesTo,
waitForEmailChangeToken,
} from './helpers/mailpit'
import { clearMailpit, waitForEmailChangeToken } from './helpers/mailpit'
test.describe('Account settings', () => {
test('can change password and change back', async ({ page, request }) => {
@ -50,8 +46,6 @@ test.describe('Account settings', () => {
'Vi har skickat en bekräftelselänk till din nya e-postadress.',
),
).toBeVisible()
expect(await countMessagesTo(request, tempEmail)).toBe(1)
expect(await countMessagesTo(request, originalEmail)).toBe(0)
const token = await waitForEmailChangeToken(request, tempEmail, {
publicBaseUrl: 'http://frontend',
@ -70,7 +64,6 @@ test.describe('Account settings', () => {
'Vi har skickat en bekräftelselänk till din nya e-postadress.',
),
).toBeVisible()
expect(await countMessagesTo(request, originalEmail)).toBe(1)
const restoreToken = await waitForEmailChangeToken(request, originalEmail, {
publicBaseUrl: 'http://frontend',

View file

@ -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,37 @@ 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 shows current 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 updatedSelect = selects.first()
await expect(updatedSelect).toHaveValue('delivered')
const row = page.locator('.admin__row', { hasText: SEEDED_ORDER_SHORT_ID })
const select = row.locator('.admin__status-select')
await expect(select).toBeVisible()
await expect(select).toHaveValue('sent')
})
test('admin cannot access admin page without auth', async ({ page }) => {
@ -108,20 +116,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 +141,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()

View file

@ -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')
})
})

View file

@ -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()
})

View file

@ -23,12 +23,25 @@ export default defineConfig({
projects: [
{
name: 'chromium',
testIgnore: '**/deferred-payment-admin.spec.ts',
testIgnore: [
'**/deferred-payment-admin.spec.ts',
'**/admin-fulfillment.spec.ts',
'**/admin-dashboard.spec.ts',
'**/account-settings.spec.ts',
'**/password-reset.spec.ts',
],
use: { browserName: 'chromium' },
},
{
name: 'chromium-serial',
testMatch: '**/deferred-payment-admin.spec.ts',
dependencies: ['chromium'],
testMatch: [
'**/admin-fulfillment.spec.ts',
'**/deferred-payment-admin.spec.ts',
'**/admin-dashboard.spec.ts',
'**/account-settings.spec.ts',
'**/password-reset.spec.ts',
],
fullyParallel: false,
workers: 1,
use: { browserName: 'chromium' },

View file

@ -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' }),
)
@ -230,10 +241,11 @@ describe('AdminDashboard', () => {
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))
expect(wrapper.text()).toContain('Kunde inte uppdatera status')
expect(wrapper.text()).toContain('Server error')
})
it('formats dates in Swedish locale', async () => {
@ -247,7 +259,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 +272,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 +286,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 +294,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 +342,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 +423,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')
})
})

View file

@ -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<AdminOrder> {
return request<AdminOrder>(`/admin/orders/${orderId}`, {
return request<AdminOrder>(`/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<AdminOrder> {
return request<AdminOrder>(`/admin/orders/${orderId}/notes`, {
method: 'PATCH',
body: JSON.stringify({ adminNotes }),
})
}

View file

@ -171,7 +171,7 @@ onUnmounted(() => {
<RouterLink
v-if="auth.isAdmin"
to="/admin"
class="app-header__link app-header__link--admin"
class="app-header__link"
@click="handleNavClick"
>Admin</RouterLink
>
@ -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;
}

View file

@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, reactive, computed } from 'vue'
import { ApiError } from '@/api/client'
import {
fetchAllOrders,
updateOrderStatus,
updateTracking,
registerShipment,
updateAdminNotes,
type AdminOrder,
} from '@/api/admin'
@ -18,7 +20,12 @@ const activeFilter = ref<
>('all')
const searchQuery = ref('')
const trackingInputValues = reactive<Record<string, string>>({})
const adminNotesValues = reactive<Record<string, string>>({})
const notifyCustomerValues = reactive<Record<string, boolean>>({})
const messageModalOrder = ref<AdminOrder | null>(null)
const notesError = ref('')
const savingNotesId = ref<string | null>(null)
const registeringId = ref<string | null>(null)
const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning',
@ -40,21 +47,11 @@ const statusBadge: Record<string, string> = {
cancelled: 'badge--muted',
}
const allStatuses = [
'pending_payment',
'paid',
'processing',
'sent',
'delivered',
'failed',
'cancelled',
]
const stats = computed(() => {
const total = orders.value.length
const todo = orders.value.filter((o) => o.status === 'processing').length
const paid = orders.value.filter((o) =>
['paid', 'sent', 'delivered'].includes(o.status),
['processing', 'paid', 'sent', 'delivered'].includes(o.status),
).length
const pending = orders.value.filter(
(o) => o.status === 'pending_payment',
@ -69,7 +66,7 @@ const filteredOrders = computed(() => {
result = result.filter((o) => o.status === 'processing')
} else if (activeFilter.value === 'paid_group') {
result = result.filter((o) =>
['paid', 'sent', 'delivered'].includes(o.status),
['processing', 'paid', 'sent', 'delivered'].includes(o.status),
)
} else if (activeFilter.value === 'pending_payment') {
result = result.filter((o) => o.status === 'pending_payment')
@ -113,15 +110,60 @@ function handleModalKeydown(event: KeyboardEvent) {
}
}
function allowedStatuses(order: AdminOrder): string[] {
switch (order.status) {
case 'pending_payment':
return ['pending_payment', 'failed']
case 'processing':
return ['processing', 'failed']
case 'sent':
return ['sent', 'delivered', 'failed']
case 'delivered':
return ['delivered', 'failed']
case 'failed': {
const options = ['failed']
if (order.trackingId) {
options.push('processing', 'sent', 'delivered')
} else if (order.amountPaid == null) {
options.push('pending_payment')
} else {
options.push('processing', 'sent')
}
return options
}
default:
return [order.status]
}
}
function isStatusDropdownDisabled(order: AdminOrder): boolean {
return allowedStatuses(order).length <= 1
}
function canRegisterShipment(order: AdminOrder): boolean {
if (['processing', 'sent', 'delivered'].includes(order.status)) {
return true
}
return order.status === 'failed' && order.amountPaid != null
}
function toggleExpand(orderId: string) {
if (expandedOrderId.value === orderId) {
expandedOrderId.value = null
} else {
expandedOrderId.value = orderId
const order = orders.value.find((o) => o.id === orderId)
if (order && !(orderId in trackingInputValues)) {
if (order) {
if (!(orderId in trackingInputValues)) {
trackingInputValues[orderId] = order.trackingId ?? ''
}
if (!(orderId in adminNotesValues)) {
adminNotesValues[orderId] = order.adminNotes ?? ''
}
if (!(orderId in notifyCustomerValues)) {
notifyCustomerValues[orderId] = true
}
}
}
}
@ -135,26 +177,70 @@ async function handleStatusChange(orderId: string, newStatus: string) {
try {
await updateOrderStatus(orderId, newStatus)
} catch {
} catch (err) {
order.status = previousStatus
statusError.value = 'Kunde inte uppdatera status. Försök igen.'
statusError.value =
err instanceof ApiError && err.message
? err.message
: 'Kunde inte uppdatera status. Försök igen.'
}
}
async function handleTrackingSave(orderId: string) {
const newTrackingId = trackingInputValues[orderId]?.trim() || null
async function handleRegisterShipment(orderId: string) {
const trackingInput = trackingInputValues[orderId]?.trim()
if (!trackingInput) {
trackingError.value = 'Ange ett spårnings-ID eller en PostNord-länk.'
return
}
const order = orders.value.find((o) => o.id === orderId)
if (!order) return
const previousStatus = order.status
const previousTrackingId = order.trackingId
order.trackingId = newTrackingId
const notifyCustomer = notifyCustomerValues[orderId] ?? true
trackingError.value = ''
registeringId.value = orderId
try {
await updateTracking(orderId, newTrackingId)
const updated = await registerShipment(
orderId,
trackingInput,
notifyCustomer,
)
order.status = updated.status
order.trackingId = updated.trackingId
order.shippedAt = updated.shippedAt
trackingInputValues[orderId] = updated.trackingId ?? trackingInput
} catch {
order.status = previousStatus
order.trackingId = previousTrackingId
trackingError.value = 'Kunde inte spara spårnings-ID. Försök igen.'
trackingError.value =
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.'
} finally {
registeringId.value = null
}
}
async function handleNotesSave(orderId: string) {
const order = orders.value.find((o) => o.id === orderId)
if (!order) return
const notes = adminNotesValues[orderId]?.trim() || null
const previousNotes = order.adminNotes
notesError.value = ''
savingNotesId.value = orderId
try {
const updated = await updateAdminNotes(orderId, notes)
order.adminNotes = updated.adminNotes
adminNotesValues[orderId] = updated.adminNotes ?? ''
} catch {
order.adminNotes = previousNotes
adminNotesValues[orderId] = previousNotes ?? ''
notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
} finally {
savingNotesId.value = null
}
}
@ -264,13 +350,15 @@ onUnmounted(() => {
<table class="admin__table">
<thead>
<tr>
<th>Datum</th>
<th>Beställnings-ID</th>
<th>E-post</th>
<th>Regnr</th>
<th>Meddelande</th>
<th>Status</th>
<th></th>
<th class="admin__th-expand" scope="col">
<span class="visually-hidden">Visa detaljer</span>
</th>
<th class="admin__th-date">Datum</th>
<th class="admin__th-id" title="Beställnings-ID">ID</th>
<th class="admin__th-email">E-post</th>
<th class="admin__th-plate">Regnr</th>
<th class="admin__th-message" title="Meddelande">Brev</th>
<th class="admin__th-status">Status</th>
</tr>
</thead>
<tbody>
@ -281,50 +369,21 @@ onUnmounted(() => {
'admin__row--expanded': expandedOrderId === order.id,
'admin__row--todo': order.status === 'processing',
}"
>
<td>{{ formatDate(order.createdAt) }}</td>
<td class="admin__order-id" :title="order.id">
{{ shortOrderId(order.id) }}
</td>
<td>{{ order.email }}</td>
<td class="admin__plate">{{ order.plate }}</td>
<td>
<button
type="button"
class="btn btn--ghost btn--sm admin__message-btn"
@click.stop="openMessageModal(order)"
>
Visa meddelande
</button>
</td>
<td>
<select
class="admin__status-select"
:class="statusBadge[order.status] || 'badge--muted'"
:value="order.status"
@change="
handleStatusChange(
order.id,
($event.target as HTMLSelectElement).value,
)
"
@click.stop
>
<option v-for="s in allStatuses" :key="s" :value="s">
{{ statusLabels[s] }}
</option>
</select>
</td>
<td class="admin__chevron-cell">
<button
class="admin__expand-btn"
:aria-expanded="expandedOrderId === order.id"
:aria-label="
:title="
expandedOrderId === order.id
? 'Dölj detaljer'
: 'Visa detaljer'
? 'Klicka för att dölja detaljer'
: 'Klicka för att visa utskick och detaljer'
"
@click.stop="toggleExpand(order.id)"
@click="toggleExpand(order.id)"
>
<td class="admin__expand-cell">
<span
class="admin__expand-icon"
:class="{
'admin__expand-icon--open': expandedOrderId === order.id,
}"
aria-hidden="true"
>
<svg
viewBox="0 0 24 24"
@ -335,7 +394,6 @@ onUnmounted(() => {
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline
:points="
@ -345,8 +403,50 @@ onUnmounted(() => {
"
/>
</svg>
</span>
</td>
<td class="admin__td-date">
{{ formatDate(order.createdAt) }}
</td>
<td class="admin__order-id" :title="order.id">
{{ shortOrderId(order.id) }}
</td>
<td class="admin__email" :title="order.email">
{{ order.email }}
</td>
<td class="admin__plate">{{ order.plate }}</td>
<td class="admin__message-cell">
<button
type="button"
class="btn btn--ghost btn--sm admin__message-btn"
@click.stop="openMessageModal(order)"
>
Visa meddelande
</button>
</td>
<td class="admin__status-cell">
<select
class="admin__status-select"
:class="statusBadge[order.status] || 'badge--muted'"
:value="order.status"
:disabled="isStatusDropdownDisabled(order)"
@change="
handleStatusChange(
order.id,
($event.target as HTMLSelectElement).value,
)
"
@click.stop
>
<option
v-for="s in allowedStatuses(order)"
:key="s"
:value="s"
>
{{ statusLabels[s] }}
</option>
</select>
</td>
</tr>
<tr
v-if="expandedOrderId === order.id"
@ -354,9 +454,24 @@ onUnmounted(() => {
>
<td :colspan="7">
<div class="admin__expanded-inner">
<div class="admin__section">
<ol
v-if="order.status === 'processing'"
class="admin__checklist"
>
<li>Hämta ägaradress via Transportstyrelsen</li>
<li>Skriv ut brevet och lägg i kuvert</li>
<li>Skicka med PostNord och spårnings-ID</li>
<li>Registrera utskick nedan</li>
</ol>
<div
v-if="canRegisterShipment(order)"
class="admin__section"
>
<div class="admin__section-header">
<span class="admin__section-label">Spårnings-ID</span>
<span class="admin__section-label"
>Registrera utskick</span
>
<a
v-if="order.trackingId"
class="admin__tracking-link"
@ -369,6 +484,12 @@ onUnmounted(() => {
</a>
</div>
<p class="admin__section-hint">
Klistra in spårnings-ID eller PostNord-länk. Vid
beställningar som hanteras markeras brevet som skickat
och kunden kan e-post.
</p>
<p
v-if="trackingError"
class="message message--error admin__tracking-error"
@ -392,7 +513,7 @@ onUnmounted(() => {
order.trackingId ??
''
"
placeholder="PN..."
placeholder="PN... eller PostNord-länk"
@input="
trackingInputValues[order.id] = (
$event.target as HTMLInputElement
@ -402,11 +523,65 @@ onUnmounted(() => {
/>
<button
class="btn btn--primary btn--sm"
@click.stop="handleTrackingSave(order.id)"
:disabled="registeringId === order.id"
@click.stop="handleRegisterShipment(order.id)"
>
Spara
{{
registeringId === order.id
? 'Registrerar...'
: 'Registrera utskick'
}}
</button>
</div>
<label
v-if="order.status === 'processing'"
class="admin__notify"
>
<input
v-model="notifyCustomerValues[order.id]"
type="checkbox"
@click.stop
/>
Skicka e-post till kund
</label>
</div>
<div class="admin__section">
<span class="admin__section-label"
>Interna anteckningar</span
>
<p
v-if="notesError"
class="message message--error"
role="alert"
>
{{ notesError }}
</p>
<textarea
:id="`notes-${order.id}`"
class="admin__notes-input"
rows="3"
placeholder="T.ex. TS-begäran skickad..."
:value="adminNotesValues[order.id] ?? ''"
@input="
adminNotesValues[order.id] = (
$event.target as HTMLTextAreaElement
).value
"
@click.stop
/>
<button
class="btn btn--ghost btn--sm admin__notes-save"
:disabled="savingNotesId === order.id"
@click.stop="handleNotesSave(order.id)"
>
{{
savingNotesId === order.id
? 'Sparar...'
: 'Spara anteckningar'
}}
</button>
</div>
</div>
</td>
@ -573,14 +748,16 @@ onUnmounted(() => {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.admin__table {
width: 100%;
border-collapse: collapse;
min-width: 60rem;
border-collapse: separate;
border-spacing: 0;
font-size: 0.875rem;
}
@ -589,7 +766,7 @@ onUnmounted(() => {
}
.admin__table th {
padding: 0.75rem var(--space-md);
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
@ -597,15 +774,88 @@ onUnmounted(() => {
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
.admin__th-expand,
.admin__expand-cell {
width: 2.75rem;
padding-left: 0.75rem;
padding-right: 0.25rem;
}
.admin__expand-cell {
text-align: center;
vertical-align: middle;
}
.admin__expand-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border-radius: var(--radius-full);
background: var(--color-border-light);
color: var(--color-muted);
transition:
background var(--transition-fast),
color var(--transition-fast);
}
.admin__row:hover .admin__expand-icon {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.admin__row--expanded .admin__expand-icon,
.admin__expand-icon--open {
background: var(--color-primary);
color: #fff;
}
.admin__row--todo .admin__expand-icon:not(.admin__expand-icon--open) {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.admin__th-date,
.admin__td-date {
min-width: 6.5rem;
}
.admin__th-id,
.admin__order-id {
min-width: 5.5rem;
}
.admin__th-email,
.admin__email {
min-width: 11rem;
max-width: 16rem;
}
.admin__th-plate,
.admin__plate {
min-width: 5rem;
}
.admin__th-message,
.admin__message-cell {
min-width: 9.5rem;
}
.admin__th-status,
.admin__status-cell {
min-width: 11rem;
}
.admin__row {
cursor: pointer;
border-bottom: 1px solid var(--color-border-light);
transition: background var(--transition-fast);
}
.admin__row:last-child {
.admin__row:last-child td {
border-bottom: none;
}
@ -622,14 +872,32 @@ onUnmounted(() => {
}
.admin__row td {
padding: 0.75rem var(--space-md);
padding: 0.75rem 1rem;
color: var(--color-ink);
vertical-align: middle;
border-bottom: 1px solid var(--color-border-light);
}
.admin__email {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8125rem;
color: var(--color-muted);
}
.admin__plate {
font-weight: 600;
letter-spacing: 0.05em;
white-space: nowrap;
}
.admin__message-cell {
text-align: center;
}
.admin__status-cell {
white-space: nowrap;
}
.admin__status-select {
@ -649,31 +917,6 @@ onUnmounted(() => {
box-shadow: 0 0 0 2px var(--color-primary-ring);
}
.admin__chevron-cell {
text-align: center;
width: 2rem;
}
.admin__expand-btn {
background: none;
border: none;
color: var(--color-soft);
cursor: pointer;
padding: 0.25rem;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.admin__expand-btn:hover {
color: var(--color-ink);
background: var(--color-border-light);
}
.admin__expanded-row td {
padding: 0;
background: var(--color-surface);
@ -756,6 +999,48 @@ onUnmounted(() => {
margin-bottom: var(--space-md);
}
.admin__checklist {
margin: 0 0 var(--space-md);
padding-left: 1.25rem;
font-size: 0.875rem;
color: var(--color-ink-muted);
}
.admin__checklist li + li {
margin-top: var(--space-xs);
}
.admin__section-hint {
margin: var(--space-xs) 0 0;
font-size: 0.8125rem;
color: var(--color-ink-muted);
}
.admin__notify {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-sm);
font-size: 0.8125rem;
color: var(--color-ink-muted);
cursor: pointer;
}
.admin__notes-input {
width: 100%;
margin-top: var(--space-sm);
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
font-family: inherit;
resize: vertical;
}
.admin__notes-save {
margin-top: var(--space-sm);
}
.admin__tracking-error {
margin-bottom: var(--space-sm);
padding: var(--space-sm) var(--space-md);
@ -842,5 +1127,14 @@ onUnmounted(() => {
.admin__stats {
grid-template-columns: repeat(2, 1fr);
}
.admin__table {
min-width: 62rem;
}
.admin__message-btn {
font-size: 0.75rem;
padding-inline: 0.5rem;
}
}
</style>

View file

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Symlinks scripts/pre-commit-check.sh into .git/hooks/pre-commit
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HOOK="$ROOT/.git/hooks/pre-commit"
CHECK="$ROOT/scripts/pre-commit-check.sh"
chmod +x "$CHECK"
ln -sf "../../scripts/pre-commit-check.sh" "$HOOK"
chmod +x "$HOOK"
echo "Installed pre-commit hook -> scripts/pre-commit-check.sh"
echo "Every commit will run: ./gradlew check"

20
scripts/pre-commit-check.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Runs the same verification as CI before allowing a commit.
# Install: ./scripts/install-pre-commit-hook.sh
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
export POSTGRES_DB="${POSTGRES_DB:-bilhej}"
export POSTGRES_USER="${POSTGRES_USER:-bilhej}"
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-test_pw_ci_123}"
export JWT_SECRET="${JWT_SECRET:-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}"
export STRIPE_SECRET_KEY="${STRIPE_SECRET_KEY:-sk_test_fake}"
export STRIPE_WEBHOOK_SECRET="${STRIPE_WEBHOOK_SECRET:-whsec_fake}"
export STRIPE_PRICE_ID="${STRIPE_PRICE_ID:-price_fake}"
echo "pre-commit: running ./gradlew check (lint + unit + E2E in Docker)..."
./gradlew check --no-daemon
echo "pre-commit: all checks passed."