Merge pull request 'Add admin order fulfillment tracking.' (#7) from feature/admin-fulfillment-tracking into master
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/7
This commit is contained in:
commit
0b2c58fa82
30 changed files with 1317 additions and 315 deletions
66
AGENTS.md
66
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<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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,5 +12,7 @@ public record AdminOrderResponse(
|
|||
String status,
|
||||
String trackingId,
|
||||
BigDecimal amountPaid,
|
||||
Instant shippedAt,
|
||||
String adminNotes,
|
||||
Instant createdAt
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record UpdateAdminNotesRequest(
|
||||
String adminNotes
|
||||
) {}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record UpdateTrackingRequest(
|
||||
String trackingId
|
||||
) {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
ALTER TABLE orders ADD COLUMN admin_notes TEXT;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(" "));
|
||||
}
|
||||
}
|
||||
|
|
@ -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...';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
99
frontend/e2e/admin-fulfillment.spec.ts
Normal file
99
frontend/e2e/admin-fulfillment.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,14 +110,59 @@ 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)) {
|
||||
trackingInputValues[orderId] = order.trackingId ?? ''
|
||||
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',
|
||||
}"
|
||||
:aria-expanded="expandedOrderId === order.id"
|
||||
:title="
|
||||
expandedOrderId === order.id
|
||||
? 'Klicka för att dölja detaljer'
|
||||
: 'Klicka för att visa utskick och detaljer'
|
||||
"
|
||||
@click="toggleExpand(order.id)"
|
||||
>
|
||||
<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="
|
||||
expandedOrderId === order.id
|
||||
? 'Dölj detaljer'
|
||||
: 'Visa detaljer'
|
||||
"
|
||||
@click.stop="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 få 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 få 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>
|
||||
|
|
|
|||
15
scripts/install-pre-commit-hook.sh
Executable file
15
scripts/install-pre-commit-hook.sh
Executable 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
20
scripts/pre-commit-check.sh
Executable 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."
|
||||
Loading…
Reference in a new issue