Compare commits

..

No commits in common. "master" and "v0.1.0" have entirely different histories.

140 changed files with 1188 additions and 9580 deletions

View file

@ -1,66 +1,6 @@
# Exclude everything that isn't strictly needed to build or run the dev images.
# The dev Dockerfiles COPY source subpaths (frontend/, backend/, gradlew,
# settings.gradle, etc.), so without this the image would bloat with docs,
# scripts, git history, etc.
# Build artifacts and caches (mounted as named volumes at runtime)
.gradle .gradle
backend/build
frontend/dist
frontend/coverage
frontend/node_modules
backend/.gradle
# Test outputs
**/build/test-results
**/build/reports
**/coverage
**/.pytest_cache
frontend/playwright-report
frontend/test-results
# Local config and secrets
.env .env
.env.*
!.env.example
**/application-local.yml
# VCS and editor state
.git .git
.gitignore frontend/node_modules
.gitattributes backend/build
.github
.forgejo
.idea
.vscode
*.iml
.DS_Store
# Documentation (not needed at runtime)
README.md
REQUIREMENTS.md
AGENTS.md
CODING_GUIDELINES.md
docs/
# Ops scripts (not needed at runtime)
scripts/
# Test source dirs that aren't built into runtime artifacts
frontend/src/__tests__ frontend/src/__tests__
backend/src/test
# Docker metadata — Dockerfiles, .dockerignore, and compose files are not
# needed inside the running image. Keep docker/*.conf and docker/entrypoint.sh
# because frontend.prod.Dockerfile copies them into the production nginx image.
docker/*.Dockerfile
Dockerfile*
.dockerignore
docker-compose*.yml
# Misc
*.log
logs/
tmp/
*.bak
*.tmp

View file

@ -24,12 +24,6 @@ STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_... STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ---------- # ---------- Swish (Phase 0) ----------
# The Swish number customers pay to. Two formats accepted:
# - Swedish phone number: 0701234567 (normalised to 46… for the payment URL)
# - Swish Business number: 1234567890 (starts with 123, used as-is)
# A Swish Business number (123…) is recommended — get one from your bank
# via a "Swish Företag" agreement. No Swish Commerce API certificate needed;
# the frontend generates a pre-filled QR code + payment link automatically.
SWISH_NUMBER=0701234567 SWISH_NUMBER=0701234567
# ---------- App URL (password reset links in email) ---------- # ---------- App URL (password reset links in email) ----------
@ -49,10 +43,3 @@ APP_PUBLIC_BASE_URL=http://localhost:3000
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead. # Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
ADMIN_EMAIL=admin@bilhej.se ADMIN_EMAIL=admin@bilhej.se
ADMIN_PASSWORD=change_me_to_a_strong_password ADMIN_PASSWORD=change_me_to_a_strong_password
# ---------- Umami analytics (production frontend build only) ----------
# Baked into the frontend image at build time. Leave unset for local dev / docker compose up.
# Website ID from https://analytics.bilhej.se → Settings → Websites → BilHej
# See docs/umami-analytics.md
# VITE_UMAMI_WEBSITE_ID=
# VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js

View file

@ -17,10 +17,6 @@ jobs:
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/jocke/bilhej.git git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/jocke/bilhej.git
git fetch --depth 1 origin ${GITHUB_SHA} git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD git checkout FETCH_HEAD
git fetch --depth 1 origin master
- name: Check Flyway migrations
run: bash scripts/check-flyway-migrations.sh origin/master
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:

View file

@ -4,10 +4,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: 'Leave as "auto" to bump from latest git tag, or enter a specific version (e.g. v0.1.2)' description: 'Version tag (e.g., v0.1.0)'
required: false required: true
default: 'auto' default: 'v0.1.0'
type: string
jobs: jobs:
deploy: deploy:
@ -21,36 +20,12 @@ jobs:
git fetch --depth 1 origin ${GITHUB_SHA} git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD git checkout FETCH_HEAD
- name: Resolve version
run: |
INPUT_VERSION="${{ github.event.inputs.version }}"
if [ -z "$INPUT_VERSION" ] || [ "$INPUT_VERSION" = "auto" ]; then
git fetch --tags origin
LATEST=$(git tag --list 'v*' --sort=-v:refname | head -1)
if [ -z "$LATEST" ]; then LATEST="v0.0.0"; fi
BASE="${LATEST#v}"
MAJOR=$(echo "$BASE" | cut -d. -f1)
MINOR=$(echo "$BASE" | cut -d. -f2)
PATCH=$(echo "$BASE" | cut -d. -f3)
PATCH=$(( ${PATCH:-0} + 1 ))
VERSION="v${MAJOR:-0}.${MINOR:-0}.${PATCH}"
echo "Latest tag: $LATEST → auto-bumped to $VERSION"
else
VERSION="$INPUT_VERSION"
echo "Using manual version: $VERSION"
fi
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "ERROR: resolved version '$VERSION' is not valid semver (expected vX.Y.Z)"
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Tag version - name: Tag version
run: | run: |
git tag -d ${{ env.VERSION }} 2>/dev/null || true git tag -d ${{ github.event.inputs.version }} 2>/dev/null || true
git push origin --delete ${{ env.VERSION }} 2>/dev/null || true git push origin --delete ${{ github.event.inputs.version }} 2>/dev/null || true
git tag ${{ env.VERSION }} git tag ${{ github.event.inputs.version }}
git push origin ${{ env.VERSION }} git push origin ${{ github.event.inputs.version }}
- name: Write production .env - name: Write production .env
env: env:
@ -70,7 +45,6 @@ jobs:
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
MAIL_FROM: ${{ secrets.MAIL_FROM }} MAIL_FROM: ${{ secrets.MAIL_FROM }}
VITE_UMAMI_WEBSITE_ID: ${{ secrets.VITE_UMAMI_WEBSITE_ID }}
run: | run: |
# Docker Compose treats $ as variable interpolation in .env files. # Docker Compose treats $ as variable interpolation in .env files.
# Escape literal dollar signs (e.g. in passwords) as $$. # Escape literal dollar signs (e.g. in passwords) as $$.
@ -92,7 +66,6 @@ jobs:
printf 'MAIL_USERNAME=%s\n' "$(escape "$MAIL_USERNAME")" printf 'MAIL_USERNAME=%s\n' "$(escape "$MAIL_USERNAME")"
printf 'MAIL_PASSWORD=%s\n' "$(escape "$MAIL_PASSWORD")" printf 'MAIL_PASSWORD=%s\n' "$(escape "$MAIL_PASSWORD")"
printf 'MAIL_FROM=%s\n' "$(escape "${MAIL_FROM:-noreply@bilhej.se}")" printf 'MAIL_FROM=%s\n' "$(escape "${MAIL_FROM:-noreply@bilhej.se}")"
printf 'VITE_UMAMI_WEBSITE_ID=%s\n' "$(escape "$VITE_UMAMI_WEBSITE_ID")"
} > .env } > .env
- name: Build and start production stack - name: Build and start production stack
@ -158,7 +131,7 @@ jobs:
run: | run: |
echo "" echo ""
echo "═══════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════"
echo " Deployed ${{ env.VERSION }} to production" echo " Deployed ${{ github.event.inputs.version }} to production"
echo "═══════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════"
echo "" echo ""
docker compose -p bilhej-prod -f docker-compose.prod.yml ps docker compose -p bilhej-prod -f docker-compose.prod.yml ps

View file

@ -74,14 +74,6 @@ stripe listen --forward-to localhost:8080/api/webhooks/stripe
Flyway migrations run automatically on Spring Boot startup. Migration files Flyway migrations run automatically on Spring Boot startup. Migration files
live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descriptive_name.sql`. 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`. To reset: `docker compose down -v && docker compose up -d`.
Flyway schema migrations live in `db/migration/`; dev-only seeds (test users, Flyway schema migrations live in `db/migration/`; dev-only seeds (test users,
@ -160,26 +152,6 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
list concrete changes as bullet points. Never write single-line list concrete changes as bullet points. Never write single-line
"feat: add X" messages. "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, backend tests, coverage
thresholds, Flyway checks, and **all 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`).
**Note for agents:** The pre-commit hook runs the full `./gradlew check` which
takes ~3.5 minutes. If your tool enforces a default timeout (e.g. 120 s on
agent tool calls), increase it to ≥300 000 ms, or use `--no-verify` and run
`./gradlew check` manually before committing.
### Frontend (Vue.js 3) ### Frontend (Vue.js 3)
- `<script setup>` with Composition API only. Never Options API. - `<script setup>` with Composition API only. Never Options API.
- File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables. - File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables.
@ -215,9 +187,6 @@ 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 entity must NOT have an address field. The address lookup and mailing are
external/human processes in Phase 0. 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) ### 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. `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.
@ -259,40 +228,12 @@ the same PR — never merge code without corresponding tests.
- Component tests with Vue Test Utils where needed. - Component tests with Vue Test Utils where needed.
- E2E tests with Playwright in `frontend/e2e/`. - E2E tests with Playwright in `frontend/e2e/`.
### E2E (Playwright) — **Docker only** ### E2E (Playwright)
- `npm run test:e2e` — runs all Playwright tests (headless Chromium).
**Agents and humans: never run Playwright on the host.** - Requires `docker compose up` (backend + frontend running).
- Config: `frontend/playwright.config.ts`.
| Do **not** run | Why | - 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.
| `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) ### CI (future)
- `./gradlew check` and `npm run test && npm run lint` must pass before merge. - `./gradlew check` and `npm run test && npm run lint` must pass before merge.

View file

@ -342,7 +342,6 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
| `MAIL_USERNAME` | `resend` (literal string) | | `MAIL_USERNAME` | `resend` (literal string) |
| `MAIL_PASSWORD` | Resend API key (`re_...`; rotate if ever exposed) | | `MAIL_PASSWORD` | Resend API key (`re_...`; rotate if ever exposed) |
| `MAIL_FROM` | `noreply@bilhej.se` (must be on verified domain) | | `MAIL_FROM` | `noreply@bilhej.se` (must be on verified domain) |
| `VITE_UMAMI_WEBSITE_ID` | Umami website UUID for `bilhej.se` (see `docs/umami-analytics.md`) |
Passwords may contain `$` — the deploy workflow escapes these for Docker Compose. Passwords may contain `$` — the deploy workflow escapes these for Docker Compose.
Production does **not** seed `test@bilhej.se` or demo orders. On first start, the Production does **not** seed `test@bilhej.se` or demo orders. On first start, the
@ -387,9 +386,7 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
1. Go to **Actions → Deploy to Production** in Forgejo. 1. Go to **Actions → Deploy to Production** in Forgejo.
2. Click **Run workflow**. 2. Click **Run workflow**.
3. Fill in both fields (Forgejo requires `type` on inputs — see `deploy.yml`): 3. Enter a version tag (e.g., `v0.1.0`).
- **Use workflow from:** `master` (which commit to build). Do not confuse this with the deploy tag below.
- **Version tag:** label created by the pipeline (e.g. `v0.1.2`). Change this each release; default `v0.1.0` is only a placeholder.
4. Click **Run workflow**. 4. Click **Run workflow**.
### Deploy failed (backend health check) ### Deploy failed (backend health check)

View file

@ -446,7 +446,7 @@ Gross margin: 14 SEK
| Is a license plate personal data? | Yes (it directly identifies a vehicle owner). | | Is a license plate personal data? | Yes (it directly identifies a vehicle owner). |
| Is an address personal data? | Yes. | | Is an address personal data? | Yes. |
| What if we only process address transiently? | Data minimization is a GDPR principle (Art. 5(1)(c)). Transient processing with immediate deletion is a strong compliance posture. | | What if we only process address transiently? | Data minimization is a GDPR principle (Art. 5(1)(c)). Transient processing with immediate deletion is a strong compliance posture. |
| Do we need to inform the recipient? | Yes, GDPR Art. 14 requires informing the data subject. The letter itself can serve this purpose — include a footer like: _"Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: kontakt@bilhej.se"_ | | Do we need to inform the recipient? | Yes, GDPR Art. 14 requires informing the data subject. The letter itself can serve this purpose — include a footer like: _"Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se"_ |
### 11.2 Transportstyrelsen Access ### 11.2 Transportstyrelsen Access

View file

@ -80,15 +80,8 @@ jacocoTestCoverageVerification {
} }
} }
tasks.register('flywayMigrationCheck', Exec) {
group = 'verification'
description = 'Ensure Flyway migrations are unique, immutable, and use new version numbers'
workingDir = rootProject.projectDir
commandLine 'bash', 'scripts/check-flyway-migrations.sh'
}
tasks.named('check').configure { tasks.named('check').configure {
dependsOn jacocoTestCoverageVerification, flywayMigrationCheck dependsOn jacocoTestCoverageVerification
} }
tasks.register('hashPassword', JavaExec) { tasks.register('hashPassword', JavaExec) {

View file

@ -1,12 +1,8 @@
package se.bilhalsning.config; package se.bilhalsning.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
@ -14,7 +10,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import se.bilhalsning.dto.ErrorResponse;
import se.bilhalsning.security.JwtAuthenticationFilter; import se.bilhalsning.security.JwtAuthenticationFilter;
import se.bilhalsning.security.JwtService; import se.bilhalsning.security.JwtService;
@ -22,13 +17,6 @@ import se.bilhalsning.security.JwtService;
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
static final String UNAUTHENTICATED_MESSAGE =
"Din session har löpt ut eller är ogiltig. Logga in igen.";
static final String FORBIDDEN_MESSAGE =
"Du har inte behörighet att utföra denna åtgärd.";
private final ObjectMapper objectMapper = new ObjectMapper();
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
@ -50,29 +38,15 @@ public class SecurityConfig {
"/api/auth/register", "/api/auth/register",
"/api/auth/login", "/api/auth/login",
"/api/auth/forgot-password", "/api/auth/forgot-password",
"/api/auth/reset-password", "/api/auth/reset-password")
"/api/auth/confirm-email-change")
.permitAll() .permitAll()
.requestMatchers("/api/webhooks/**").permitAll() .requestMatchers("/api/webhooks/**").permitAll()
.requestMatchers("/api/payment/swish-info").permitAll() .requestMatchers("/api/payment/swish-info").permitAll()
.requestMatchers("/api/vehicles/**").permitAll() .requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) .anyRequest().authenticated())
.exceptionHandling(eh -> eh
.authenticationEntryPoint((request, response, ex) ->
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
.accessDeniedHandler((request, response, ex) ->
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
private void writeError(HttpServletResponse response, HttpStatus status, String message)
throws java.io.IOException {
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(message)));
}
} }

View file

@ -9,13 +9,10 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.AdminOrderMapper;
import se.bilhalsning.dto.AdminOrderResponse; import se.bilhalsning.dto.AdminOrderResponse;
import se.bilhalsning.dto.RegisterShipmentRequest;
import se.bilhalsning.dto.UpdateAdminNotesRequest;
import se.bilhalsning.dto.UpdateStatusRequest; import se.bilhalsning.dto.UpdateStatusRequest;
import se.bilhalsning.dto.UpdateTrackingRequest;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.service.AdminOrderWorkflowService;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
import java.util.List; import java.util.List;
@ -27,12 +24,11 @@ import java.util.UUID;
public class AdminController { public class AdminController {
private final OrderService orderService; private final OrderService orderService;
private final AdminOrderWorkflowService adminOrderWorkflowService;
@GetMapping("/orders") @GetMapping("/orders")
public ResponseEntity<List<AdminOrderResponse>> listAllOrders() { public ResponseEntity<List<AdminOrderResponse>> listAllOrders() {
List<AdminOrderResponse> orders = orderService.getAllOrders().stream() List<AdminOrderResponse> orders = orderService.getAllOrders().stream()
.map(AdminOrderMapper::toResponse) .map(this::toAdminResponse)
.toList(); .toList();
return ResponseEntity.ok(orders); return ResponseEntity.ok(orders);
} }
@ -41,26 +37,29 @@ public class AdminController {
public ResponseEntity<AdminOrderResponse> updateStatus( public ResponseEntity<AdminOrderResponse> updateStatus(
@PathVariable UUID id, @PathVariable UUID id,
@Valid @RequestBody UpdateStatusRequest request) { @Valid @RequestBody UpdateStatusRequest request) {
Order order = adminOrderWorkflowService.updateOrderStatus(id, request.status()); Order order = orderService.updateOrderStatus(id, request.status());
return ResponseEntity.ok(AdminOrderMapper.toResponse(order)); return ResponseEntity.ok(toAdminResponse(order));
} }
@PatchMapping("/orders/{id}/register-shipment") @PatchMapping("/orders/{id}")
public ResponseEntity<AdminOrderResponse> registerShipment( public ResponseEntity<AdminOrderResponse> updateTracking(
@PathVariable UUID id, @PathVariable UUID id,
@Valid @RequestBody RegisterShipmentRequest request) { @Valid @RequestBody UpdateTrackingRequest request) {
Order order = adminOrderWorkflowService.registerShipment( Order order = orderService.updateTracking(id, request.trackingId());
id, return ResponseEntity.ok(toAdminResponse(order));
request.trackingInput(),
request.notifyCustomerOrDefault());
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
} }
@PatchMapping("/orders/{id}/notes") private AdminOrderResponse toAdminResponse(Order order) {
public ResponseEntity<AdminOrderResponse> updateNotes( String email = order.getUser() != null ? order.getUser().getEmail() : "";
@PathVariable UUID id, return new AdminOrderResponse(
@RequestBody UpdateAdminNotesRequest request) { order.getId(),
Order order = adminOrderWorkflowService.updateAdminNotes(id, request.adminNotes()); email,
return ResponseEntity.ok(AdminOrderMapper.toResponse(order)); order.getPlate(),
order.getLetterText(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getAmountPaid(),
order.getCreatedAt()
);
} }
} }

View file

@ -1,7 +1,6 @@
package se.bilhalsning.controller; package se.bilhalsning.controller;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -12,10 +11,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.AuthResponse; import se.bilhalsning.dto.AuthResponse;
import se.bilhalsning.dto.ChangeEmailRequest;
import se.bilhalsning.dto.ChangeEmailResponse;
import se.bilhalsning.dto.ChangePasswordRequest; import se.bilhalsning.dto.ChangePasswordRequest;
import se.bilhalsning.dto.ConfirmEmailChangeRequest;
import se.bilhalsning.dto.ForgotPasswordRequest; import se.bilhalsning.dto.ForgotPasswordRequest;
import se.bilhalsning.dto.LoginRequest; import se.bilhalsning.dto.LoginRequest;
import se.bilhalsning.dto.ForgotPasswordResponse; import se.bilhalsning.dto.ForgotPasswordResponse;
@ -24,7 +20,6 @@ import se.bilhalsning.dto.RegisterRequest;
import se.bilhalsning.dto.ResetPasswordRequest; import se.bilhalsning.dto.ResetPasswordRequest;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService; import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.EmailChangeService;
import se.bilhalsning.service.PasswordResetService; import se.bilhalsning.service.PasswordResetService;
import se.bilhalsning.service.UserService; import se.bilhalsning.service.UserService;
@ -35,15 +30,11 @@ public class AuthController {
private final UserService userService; private final UserService userService;
private final PasswordResetService passwordResetService; private final PasswordResetService passwordResetService;
private final EmailChangeService emailChangeService;
private final JwtService jwtService; private final JwtService jwtService;
private static final String FORGOT_PASSWORD_MESSAGE = private static final String FORGOT_PASSWORD_MESSAGE =
"Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet."; "Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet.";
private static final String CHANGE_EMAIL_MESSAGE =
"Vi har skickat en bekräftelselänk till din nya e-postadress.";
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) { public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
userService.createUser(request.email(), request.password()); userService.createUser(request.email(), request.password());
@ -80,21 +71,4 @@ public class AuthController {
principal.getUsername(), request.currentPassword(), request.newPassword()); principal.getUsername(), request.currentPassword(), request.newPassword());
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats.")); return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats."));
} }
@PostMapping("/change-email")
public ResponseEntity<ChangeEmailResponse> changeEmail(
@Valid @RequestBody ChangeEmailRequest request,
@AuthenticationPrincipal UserDetails principal) {
Optional<String> testToken = emailChangeService.requestChange(
principal.getUsername(), request.password(), request.newEmail());
return ResponseEntity.ok(ChangeEmailResponse.of(CHANGE_EMAIL_MESSAGE, testToken));
}
@PostMapping("/confirm-email-change")
public ResponseEntity<AuthResponse> confirmEmailChange(
@Valid @RequestBody ConfirmEmailChangeRequest request) {
User user = emailChangeService.confirmChange(request.token(), request.password());
String token = jwtService.generateToken(user.getEmail(), user.getRole());
return ResponseEntity.ok(new AuthResponse(token));
}
} }

View file

@ -7,15 +7,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.CreateOrderRequest; import se.bilhalsning.dto.CreateOrderRequest;
import se.bilhalsning.dto.OrderResponse; import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.dto.UpdateOrderRequest;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.exception.InvalidCredentialsException; import se.bilhalsning.exception.InvalidCredentialsException;
@ -23,7 +20,6 @@ import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService; import se.bilhalsning.service.UserService;
import java.util.List; import java.util.List;
import java.util.UUID;
@RestController @RestController
@RequestMapping("/api/orders") @RequestMapping("/api/orders")
@ -45,21 +41,6 @@ public class OrderController {
return ResponseEntity.ok(orders); return ResponseEntity.ok(orders);
} }
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> get(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.getOrderById(id);
if (!order.getUserId().equals(user.getId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(toResponse(order));
}
@PostMapping @PostMapping
public ResponseEntity<OrderResponse> create( public ResponseEntity<OrderResponse> create(
@Valid @RequestBody CreateOrderRequest request, @Valid @RequestBody CreateOrderRequest request,
@ -76,31 +57,6 @@ public class OrderController {
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order)); return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
} }
@PatchMapping("/{id}")
public ResponseEntity<OrderResponse> update(
@PathVariable UUID id,
@Valid @RequestBody UpdateOrderRequest request,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.updatePendingOrder(id, user.getId(), request.letterText());
return ResponseEntity.ok(toResponse(order));
}
@PostMapping("/{id}/cancel")
public ResponseEntity<OrderResponse> cancel(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.cancelOrder(id, user.getId());
return ResponseEntity.ok(toResponse(order));
}
private OrderResponse toResponse(Order order) { private OrderResponse toResponse(Order order) {
return new OrderResponse( return new OrderResponse(
order.getId(), order.getId(),

View file

@ -5,8 +5,6 @@ import java.util.UUID;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -14,38 +12,28 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.OrderResponse; import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
@RestController @RestController
@RequestMapping("/api/payment") @RequestMapping("/api/payment")
public class PaymentController { public class PaymentController {
private final OrderService orderService; private final OrderService orderService;
private final UserService userService;
private final String swishNumber; private final String swishNumber;
private final int letterPrice; private final int letterPrice;
public PaymentController( public PaymentController(
OrderService orderService, OrderService orderService,
UserService userService,
@Value("${app.payment.swish-number}") String swishNumber, @Value("${app.payment.swish-number}") String swishNumber,
@Value("${app.payment.letter-price}") int letterPrice) { @Value("${app.payment.letter-price}") int letterPrice) {
this.orderService = orderService; this.orderService = orderService;
this.userService = userService;
this.swishNumber = swishNumber; this.swishNumber = swishNumber;
this.letterPrice = letterPrice; this.letterPrice = letterPrice;
} }
@PostMapping("/{orderId}/pay") @PostMapping("/{orderId}/pay")
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId, public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) {
@AuthenticationPrincipal UserDetails userDetails) { Order order = orderService.confirmPayment(orderId);
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.confirmPayment(orderId, user.getId());
return ResponseEntity.ok(toResponse(order)); return ResponseEntity.ok(toResponse(order));
} }

View file

@ -1,26 +0,0 @@
package se.bilhalsning.dto;
import se.bilhalsning.entity.Order;
import se.bilhalsning.service.AdminOrderStatusRules;
public final class AdminOrderMapper {
private AdminOrderMapper() {}
public static AdminOrderResponse toResponse(Order order) {
String email = order.getUser() != null ? order.getUser().getEmail() : "";
return new AdminOrderResponse(
order.getId(),
email,
order.getPlate(),
order.getLetterText(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getAmountPaid(),
order.getShippedAt(),
order.getAdminNotes(),
order.getCreatedAt(),
AdminOrderStatusRules.allowedStatusValues(order),
AdminOrderStatusRules.canRegisterShipment(order));
}
}

View file

@ -2,7 +2,6 @@ package se.bilhalsning.dto;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.UUID; import java.util.UUID;
public record AdminOrderResponse( public record AdminOrderResponse(
@ -13,9 +12,5 @@ public record AdminOrderResponse(
String status, String status,
String trackingId, String trackingId,
BigDecimal amountPaid, BigDecimal amountPaid,
Instant shippedAt, Instant createdAt
String adminNotes,
Instant createdAt,
List<String> allowedStatuses,
boolean canRegisterShipment
) {} ) {}

View file

@ -1,7 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record ChangeEmailRequest(
@NotBlank @Email String newEmail, @NotBlank String password) {}

View file

@ -1,12 +0,0 @@
package se.bilhalsning.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.Optional;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ChangeEmailResponse(String message, String testToken) {
public static ChangeEmailResponse of(String message, Optional<String> testToken) {
return new ChangeEmailResponse(message, testToken.orElse(null));
}
}

View file

@ -1,5 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
public record ConfirmEmailChangeRequest(@NotBlank String token, @NotBlank String password) {}

View file

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

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

View file

@ -1,10 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record UpdateOrderRequest(
@NotBlank(message = "Brevtext krävs")
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
String letterText
) {}

View file

@ -6,7 +6,7 @@ import jakarta.validation.constraints.Pattern;
public record UpdateStatusRequest( public record UpdateStatusRequest(
@NotBlank(message = "Status krävs") @NotBlank(message = "Status krävs")
@Pattern( @Pattern(
regexp = "pending_payment|paid|processing|sent|delivered|failed|cancelled", regexp = "pending_payment|paid|processing|sent|delivered|failed",
message = "Ogiltig status" message = "Ogiltig status"
) )
String status String status

View file

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

View file

@ -1,106 +0,0 @@
package se.bilhalsning.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "email_change_tokens")
public class EmailChangeToken {
@Id
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "new_email", nullable = false)
private String newEmail;
@Column(name = "token_hash", nullable = false, length = 64)
private String tokenHash;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "used_at")
private Instant usedAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
if (this.id == null) {
this.id = UUID.randomUUID();
}
if (this.createdAt == null) {
this.createdAt = Instant.now();
}
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getNewEmail() {
return newEmail;
}
public void setNewEmail(String newEmail) {
this.newEmail = newEmail;
}
public String getTokenHash() {
return tokenHash;
}
public void setTokenHash(String tokenHash) {
this.tokenHash = tokenHash;
}
public Instant getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Instant expiresAt) {
this.expiresAt = expiresAt;
}
public Instant getUsedAt() {
return usedAt;
}
public void setUsedAt(Instant usedAt) {
this.usedAt = usedAt;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}

View file

@ -43,12 +43,6 @@ public class Order {
@Column(name = "tracking_id", length = 100) @Column(name = "tracking_id", length = 100)
private String trackingId; 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) @Column(name = "created_at", nullable = false)
private Instant createdAt; private Instant createdAt;
@ -136,22 +130,6 @@ public class Order {
this.trackingId = trackingId; 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() { public Instant getCreatedAt() {
return createdAt; return createdAt;
} }

View file

@ -6,8 +6,7 @@ public enum OrderStatus {
PROCESSING("processing"), PROCESSING("processing"),
SENT("sent"), SENT("sent"),
DELIVERED("delivered"), DELIVERED("delivered"),
FAILED("failed"), FAILED("failed");
CANCELLED("cancelled");
private final String value; private final String value;

View file

@ -1,8 +0,0 @@
package se.bilhalsning.exception;
public class EmailChangeTokenInvalidException extends RuntimeException {
public EmailChangeTokenInvalidException() {
super("Bekräftelselänken är ogiltig eller har gått ut.");
}
}

View file

@ -29,21 +29,6 @@ public class GlobalExceptionHandler {
.body(new ErrorResponse(ex.getMessage())); .body(new ErrorResponse(ex.getMessage()));
} }
@ExceptionHandler(EmailChangeTokenInvalidException.class)
public ResponseEntity<ErrorResponse> handleEmailChangeTokenInvalid(
EmailChangeTokenInvalidException ex) {
return ResponseEntity
.badRequest()
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity
.badRequest()
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(EmailAlreadyExistsException.class) @ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) { public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
return ResponseEntity return ResponseEntity
@ -51,13 +36,6 @@ public class GlobalExceptionHandler {
.body(new ErrorResponse("E-postadressen är redan registrerad")); .body(new ErrorResponse("E-postadressen är redan registrerad"));
} }
@ExceptionHandler(InvalidOrderStateException.class)
public ResponseEntity<ErrorResponse> handleInvalidOrderState(InvalidOrderStateException ex) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(OrderNotFoundException.class) @ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) { public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
return ResponseEntity return ResponseEntity

View file

@ -1,7 +0,0 @@
package se.bilhalsning.exception;
public class InvalidOrderStateException extends RuntimeException {
public InvalidOrderStateException(String message) {
super(message);
}
}

View file

@ -1,18 +0,0 @@
package se.bilhalsning.repository;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import se.bilhalsning.entity.EmailChangeToken;
public interface EmailChangeTokenRepository extends JpaRepository<EmailChangeToken, UUID> {
Optional<EmailChangeToken> findByTokenHashAndUsedAtIsNull(String tokenHash);
@Modifying
@Query("DELETE FROM EmailChangeToken t WHERE t.user.id = :userId AND t.usedAt IS NULL")
void deleteUnusedByUserId(@Param("userId") UUID userId);
}

View file

@ -7,7 +7,6 @@ import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.entity.OrderStatus;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Repository @Repository
@ -18,7 +17,4 @@ public interface OrderRepository extends JpaRepository<Order, UUID> {
@EntityGraph(attributePaths = {"user"}) @EntityGraph(attributePaths = {"user"})
List<Order> findAllByOrderByCreatedAtDesc(); List<Order> findAllByOrderByCreatedAtDesc();
@EntityGraph(attributePaths = {"user"})
Optional<Order> findWithUserById(UUID id);
} }

View file

@ -18,7 +18,7 @@ public class JwtService {
this(secret, DEFAULT_EXPIRATION_MS); this(secret, DEFAULT_EXPIRATION_MS);
} }
public JwtService(String secret, long expirationMs) { JwtService(String secret, long expirationMs) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
this.expirationMs = expirationMs; this.expirationMs = expirationMs;
} }

View file

@ -1,81 +0,0 @@
package se.bilhalsning.service;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.InvalidOrderStateException;
/**
* Admin status transitions and UI affordances. Single source of truth for
* {@link AdminOrderResponse#allowedStatuses()} and {@link AdminOrderResponse#canRegisterShipment()}.
*/
public final class AdminOrderStatusRules {
private AdminOrderStatusRules() {}
public static List<String> allowedStatusValues(Order order) {
OrderStatus current = order.getStatus();
LinkedHashSet<OrderStatus> options = new LinkedHashSet<>();
options.add(current);
for (OrderStatus target : allowedTargets(current, order)) {
options.add(target);
}
List<String> values = new ArrayList<>();
for (OrderStatus status : options) {
values.add(status.getValue());
}
return values;
}
public static boolean canRegisterShipment(Order order) {
OrderStatus status = order.getStatus();
if (status == OrderStatus.PROCESSING
|| status == OrderStatus.SENT
|| status == OrderStatus.DELIVERED) {
return true;
}
return status == OrderStatus.FAILED && order.getAmountPaid() != null;
}
public static void validateTransition(Order order, OrderStatus to) {
OrderStatus from = order.getStatus();
if (from == to) {
return;
}
if (!allowedTargets(from, order).contains(to)) {
throw new InvalidOrderStateException(
"Status kan inte ändras från " + from.getValue() + " till " + to.getValue());
}
}
private static List<OrderStatus> allowedTargets(OrderStatus from, Order order) {
return switch (from) {
case PENDING_PAYMENT -> List.of(OrderStatus.FAILED);
case PROCESSING -> List.of(OrderStatus.FAILED);
case SENT -> List.of(OrderStatus.DELIVERED, OrderStatus.FAILED);
case DELIVERED -> List.of(OrderStatus.FAILED);
case FAILED -> allowedTargetsFromFailed(order);
default -> List.of();
};
}
private static List<OrderStatus> allowedTargetsFromFailed(Order order) {
if (hasTrackingId(order)) {
return List.of(
OrderStatus.PROCESSING,
OrderStatus.SENT,
OrderStatus.DELIVERED);
}
if (order.getAmountPaid() == null) {
return List.of(OrderStatus.PENDING_PAYMENT);
}
return List.of(OrderStatus.PROCESSING, OrderStatus.SENT);
}
private static boolean hasTrackingId(Order order) {
String trackingId = order.getTrackingId();
return trackingId != null && !trackingId.isBlank();
}
}

View file

@ -1,88 +0,0 @@
package se.bilhalsning.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import se.bilhalsning.entity.Order;
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.UUID;
@Service
@RequiredArgsConstructor
public class AdminOrderWorkflowService {
private final OrderRepository orderRepository;
private final OrderNotificationService orderNotificationService;
public Order updateOrderStatus(UUID orderId, String statusString) {
Order order = requireOrder(orderId);
OrderStatus newStatus = parseStatus(statusString);
OrderStatus previousStatus = order.getStatus();
AdminOrderStatusRules.validateTransition(order, newStatus);
order.setStatus(newStatus);
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 registerShipment(UUID orderId, String rawTrackingInput, boolean notifyCustomer) {
String trackingId = PostNordTrackingNormalizer.normalize(rawTrackingInput);
Order order = requireOrder(orderId);
OrderStatus previousStatus = order.getStatus();
if (!AdminOrderStatusRules.canRegisterShipment(order)) {
throw new InvalidOrderStateException(
"Beställningen kan inte registreras som utskickad i detta tillstånd");
}
if (previousStatus == OrderStatus.FAILED && order.getAmountPaid() == null) {
throw new InvalidOrderStateException(
"Obetalda misslyckade beställningar kan inte registreras som utskickade");
}
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);
}
private Order requireOrder(UUID orderId) {
return orderRepository.findWithUserById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
private static OrderStatus parseStatus(String statusString) {
try {
return OrderStatus.valueOf(statusString.toUpperCase());
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Ogiltig status");
}
}
}

View file

@ -1,70 +0,0 @@
package se.bilhalsning.service;
import java.time.Instant;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import se.bilhalsning.entity.EmailChangeToken;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailChangeTokenInvalidException;
import se.bilhalsning.repository.EmailChangeTokenRepository;
@Service
@RequiredArgsConstructor
public class EmailChangeService {
private static final long TOKEN_TTL_HOURS = 24;
private final UserService userService;
private final EmailChangeTokenRepository tokenRepository;
private final EmailService emailService;
private final PasswordResetService passwordResetService;
@Value("${app.public-base-url:http://localhost:3000}")
private String publicBaseUrl;
@Value("${app.email-change.expose-token:false}")
private boolean exposeToken;
@Transactional
public Optional<String> requestChange(String currentEmail, String password, String newEmail) {
User user = userService.authenticate(currentEmail, password);
userService.validateEmailAvailableForChange(user, newEmail);
String normalizedEmail = newEmail.toLowerCase().trim();
tokenRepository.deleteUnusedByUserId(user.getId());
String rawToken = passwordResetService.generateRawToken();
EmailChangeToken entity = new EmailChangeToken();
entity.setUser(user);
entity.setNewEmail(normalizedEmail);
entity.setTokenHash(PasswordResetService.hashToken(rawToken));
entity.setExpiresAt(Instant.now().plusSeconds(TOKEN_TTL_HOURS * 3600));
tokenRepository.save(entity);
String confirmUrl = publicBaseUrl.replaceAll("/$", "")
+ "/bekrafta-epost?token="
+ rawToken;
emailService.sendEmailChangeConfirmation(normalizedEmail, confirmUrl);
return exposeToken ? Optional.of(rawToken) : Optional.empty();
}
@Transactional
public User confirmChange(String rawToken, String password) {
EmailChangeToken token = tokenRepository
.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken(rawToken))
.filter(t -> t.getExpiresAt().isAfter(Instant.now()))
.orElseThrow(EmailChangeTokenInvalidException::new);
User user = token.getUser();
userService.authenticate(user.getEmail(), password);
User updated = userService.applyEmailChange(user, token.getNewEmail());
token.setUsedAt(Instant.now());
tokenRepository.deleteUnusedByUserId(user.getId());
tokenRepository.save(token);
return updated;
}
}

View file

@ -58,106 +58,4 @@ public class EmailService {
throw new IllegalStateException("Kunde inte skicka e-post just nu"); throw new IllegalStateException("Kunde inte skicka e-post just nu");
} }
} }
public void sendEmailChangeConfirmation(String toEmail, String confirmUrl) {
String subject = "Bekräfta din nya e-postadress BilHej";
String body = """
Hej,
Du har begärt att byta e-postadress för ditt BilHej-konto.
Öppna länken nedan och ange ditt lösenord för att bekräfta den nya adressen (giltig i 24 timmar):
%s
Om du inte begärde detta kan du ignorera det här meddelandet.
Vänliga hälsningar,
BilHej
""".formatted(confirmUrl);
if (mailHost == null || mailHost.isBlank() || mailSender == null) {
log.info("SMTP not configured. Email change confirmation link for {}: {}", toEmail, confirmUrl);
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 change confirmation to {}", toEmail, ex);
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

@ -1,69 +0,0 @@
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

@ -4,7 +4,6 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException; import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.repository.OrderRepository; import se.bilhalsning.repository.OrderRepository;
@ -16,7 +15,6 @@ import java.util.UUID;
public class OrderService { public class OrderService {
private final OrderRepository orderRepository; private final OrderRepository orderRepository;
private final OrderNotificationService orderNotificationService;
public Order createOrder(UUID userId, String plate, String letterText) { public Order createOrder(UUID userId, String plate, String letterText) {
Order order = new Order(); Order order = new Order();
@ -40,39 +38,28 @@ public class OrderService {
return orderRepository.findAllByOrderByCreatedAtDesc(); return orderRepository.findAllByOrderByCreatedAtDesc();
} }
public Order confirmPayment(UUID orderId, UUID userId) { public Order updateOrderStatus(UUID orderId, String statusString) {
Order order = requirePendingOwnedBy(orderId, userId);
order.setStatus(OrderStatus.PROCESSING);
Order saved = orderRepository.save(order);
orderNotificationService.notifyOrderProcessing(saved);
return saved;
}
public Order cancelOrder(UUID orderId, UUID userId) {
Order order = requirePendingOwnedBy(orderId, userId);
order.setStatus(OrderStatus.CANCELLED);
return orderRepository.save(order);
}
public Order updatePendingOrder(UUID orderId, UUID userId, String letterText) {
Order order = requirePendingOwnedBy(orderId, userId);
order.setLetterText(letterText);
return orderRepository.save(order);
}
private Order requirePendingOwnedBy(UUID orderId, UUID userId) {
Order order = orderRepository.findById(orderId) Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId)); .orElseThrow(() -> new OrderNotFoundException(orderId));
if (!order.getUserId().equals(userId)) { OrderStatus newStatus = OrderStatus.valueOf(statusString.toUpperCase());
throw new OrderNotFoundException(orderId); order.setStatus(newStatus);
return orderRepository.save(order);
} }
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) { public Order updateTracking(UUID orderId, String trackingId) {
throw new InvalidOrderStateException( Order order = orderRepository.findById(orderId)
"Beställningen kan inte ändras i detta tillstånd"); .orElseThrow(() -> new OrderNotFoundException(orderId));
order.setTrackingId(trackingId);
return orderRepository.save(order);
} }
return order; public Order confirmPayment(UUID orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.setStatus(OrderStatus.PROCESSING);
return orderRepository.save(order);
} }
} }

View file

@ -53,26 +53,4 @@ public class UserService {
} }
updatePassword(user, newPassword); updatePassword(user, newPassword);
} }
public void validateEmailAvailableForChange(User user, String newEmail) {
String normalizedEmail = newEmail.toLowerCase().trim();
if (normalizedEmail.equals(user.getEmail())) {
throw new IllegalArgumentException("Ny e-postadress måste skilja sig från nuvarande");
}
if (userRepository.existsByEmail(normalizedEmail)) {
throw new EmailAlreadyExistsException(normalizedEmail);
}
}
public User applyEmailChange(User user, String newEmail) {
String normalizedEmail = newEmail.toLowerCase().trim();
if (normalizedEmail.equals(user.getEmail())) {
return user;
}
if (userRepository.existsByEmail(normalizedEmail)) {
throw new EmailAlreadyExistsException(normalizedEmail);
}
user.setEmail(normalizedEmail);
return userRepository.save(user);
}
} }

View file

@ -1,45 +0,0 @@
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

@ -33,5 +33,3 @@ app:
# E2E only: never enable in production (see application-prod.yml). # E2E only: never enable in production (see application-prod.yml).
password-reset: password-reset:
expose-token: true expose-token: true
email-change:
expose-token: true

View file

@ -17,5 +17,3 @@ app:
password: ${ADMIN_PASSWORD} password: ${ADMIN_PASSWORD}
password-reset: password-reset:
expose-token: false expose-token: false
email-change:
expose-token: false

View file

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

@ -1,14 +0,0 @@
CREATE TABLE email_change_tokens (
id UUID NOT NULL,
user_id UUID NOT NULL,
new_email VARCHAR(255) NOT NULL,
token_hash VARCHAR(64) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_email_change_tokens PRIMARY KEY (id),
CONSTRAINT fk_email_change_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX idx_email_change_tokens_user_id ON email_change_tokens (user_id);
CREATE INDEX idx_email_change_tokens_token_hash ON email_change_tokens (token_hash);

View file

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

View file

@ -1,14 +0,0 @@
ALTER TABLE orders DROP CONSTRAINT ck_orders_status;
ALTER TABLE orders
ADD CONSTRAINT ck_orders_status CHECK (
status IN (
'pending_payment',
'paid',
'processing',
'sent',
'delivered',
'failed',
'cancelled'
)
);

View file

@ -1,45 +0,0 @@
package se.bilhalsning;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
class FlywayMigrationFilesTest {
private static final Pattern VERSION_PATTERN = Pattern.compile("^V(\\d+)__.+\\.sql$");
private static final Path MIGRATION_DIR = Path.of("src/main/resources/db/migration");
@Test
void shouldUseUniqueVersionNumbersAndValidNames() throws IOException {
assertTrue(
Files.isDirectory(MIGRATION_DIR),
() -> "Expected migration directory at " + MIGRATION_DIR.toAbsolutePath());
Set<Integer> versions = new HashSet<>();
try (Stream<Path> files = Files.list(MIGRATION_DIR)) {
for (Path file : files.filter(path -> path.getFileName().toString().endsWith(".sql")).toList()) {
String name = file.getFileName().toString();
Matcher matcher = VERSION_PATTERN.matcher(name);
assertTrue(matcher.matches(), () -> "Invalid migration filename: " + name);
int version = Integer.parseInt(matcher.group(1));
assertFalse(
versions.contains(version),
() -> "Duplicate Flyway version V" + version + " in " + MIGRATION_DIR);
versions.add(version);
}
}
assertFalse(versions.isEmpty(), "Expected at least one schema migration");
}
}

View file

@ -1,6 +1,6 @@
package se.bilhalsning.controller; package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ -23,9 +23,7 @@ import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException; import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.service.AdminOrderWorkflowService;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
@SpringBootTest @SpringBootTest
@ -38,22 +36,17 @@ class AdminControllerTest {
@MockitoBean @MockitoBean
private OrderService orderService; private OrderService orderService;
@MockitoBean
private AdminOrderWorkflowService adminOrderWorkflowService;
@Test @Test
void shouldReturn401WhenNotAuthenticated() throws Exception { void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/admin/orders")) mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@WithMockUser(username = "test@bilhej.se", roles = "USER") @WithMockUser(username = "test@bilhej.se", roles = "USER")
void shouldReturn403ForNonAdminUser() throws Exception { void shouldReturn403ForNonAdminUser() throws Exception {
mockMvc.perform(get("/api/admin/orders")) mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@ -68,96 +61,151 @@ class AdminControllerTest {
.andExpect(jsonPath("$[0].id").value(order.getId().toString())) .andExpect(jsonPath("$[0].id").value(order.getId().toString()))
.andExpect(jsonPath("$[0].email").value("test@bilhej.se")) .andExpect(jsonPath("$[0].email").value("test@bilhej.se"))
.andExpect(jsonPath("$[0].plate").value("ABC123")) .andExpect(jsonPath("$[0].plate").value("ABC123"))
.andExpect(jsonPath("$[0].status").value("sent")) .andExpect(jsonPath("$[0].letterText").value("Test letter"))
.andExpect(jsonPath("$[0].allowedStatuses").isArray()) .andExpect(jsonPath("$[0].status").value("sent"));
.andExpect(jsonPath("$[0].canRegisterShipment").value(true)); }
@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 @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateOrderStatusSuccessfully() throws Exception { void shouldUpdateOrderStatusSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.FAILED); Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PAID);
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("failed"))) when(orderService.updateOrderStatus(eq(orderId), eq("paid"))).thenReturn(order);
.thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId) mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"failed\"}")) .content("{\"status\":\"paid\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("failed")); .andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.status").value("paid"));
} }
@Test @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn409WhenStatusTransitionInvalid() throws Exception { void shouldReturn400WhenStatusIsInvalid() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); mockMvc.perform(patch("/api/admin/orders/{id}/status",
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("delivered"))) "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.thenThrow(new InvalidOrderStateException("Ogiltig övergång"));
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"delivered\"}")) .content("{\"status\":\"invalid_status\"}"))
.andExpect(status().isConflict());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
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(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123456789"), eq(true)))
.thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingInput\":\"PN123456789\",\"notifyCustomer\":true}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.trackingId").value("PN123456789"))
.andExpect(jsonPath("$.status").value("sent"));
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn400WhenTrackingInputBlank() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingInput\":\"\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@Test @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateAdminNotes() throws Exception { void shouldReturn400WhenStatusIsBlank() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); mockMvc.perform(patch("/api/admin/orders/{id}/status",
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PROCESSING); "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
order.setAdminNotes("Kontaktat TS");
when(adminOrderWorkflowService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}/notes", orderId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"adminNotes\":\"Kontaktat TS\"}")) .content("{\"status\":\"\"}"))
.andExpect(status().isOk()) .andExpect(status().isBadRequest());
.andExpect(jsonPath("$.adminNotes").value("Kontaktat TS"));
} }
@Test @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn404WhenOrderNotFoundForRegisterShipment() throws Exception { void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123"), anyBoolean())) when(orderService.updateOrderStatus(eq(orderId), eq("paid")))
.thenThrow(new OrderNotFoundException(orderId)); .thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId) mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingInput\":\"PN123\"}")) .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());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateTrackingSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
order.setTrackingId("PN123456789");
when(orderService.updateTracking(eq(orderId), eq("PN123456789"))).thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":\"PN123456789\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.trackingId").value("PN123456789"));
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldClearTrackingWhenNull() 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)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":null}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.trackingId").doesNotExist());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn404WhenOrderNotFoundForTracking() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.updateTracking(eq(orderId), eq("PN123456789")))
.thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(patch("/api/admin/orders/{id}", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":\"PN123456789\"}"))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
@ -171,6 +219,7 @@ class AdminControllerTest {
order.setPlate(plate); order.setPlate(plate);
order.setLetterText("Test letter"); order.setLetterText("Test letter");
order.setStatus(status); order.setStatus(status);
order.setTrackingId(null);
order.setAmountPaid(new BigDecimal("49.00")); order.setAmountPaid(new BigDecimal("49.00"));
return order; return order;

View file

@ -22,7 +22,6 @@ import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException; import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.security.JwtService; import se.bilhalsning.security.JwtService;
import java.util.Optional; import java.util.Optional;
import se.bilhalsning.service.EmailChangeService;
import se.bilhalsning.service.PasswordResetService; import se.bilhalsning.service.PasswordResetService;
import se.bilhalsning.service.UserService; import se.bilhalsning.service.UserService;
@ -41,9 +40,6 @@ class AuthControllerTest {
@MockitoBean @MockitoBean
private PasswordResetService passwordResetService; private PasswordResetService passwordResetService;
@MockitoBean
private EmailChangeService emailChangeService;
@MockitoBean @MockitoBean
private JwtService jwtService; private JwtService jwtService;
@ -225,46 +221,6 @@ class AuthControllerTest {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content( .content(
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}")) "{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
}
@Test
@WithMockUser(username = "user@example.com")
void shouldReturn200WhenChangeEmailRequestSucceeds() throws Exception {
when(emailChangeService.requestChange("user@example.com", "password123", "new@example.com"))
.thenReturn(Optional.of("test-token"));
mockMvc.perform(post("/api/auth/change-email")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message")
.value("Vi har skickat en bekräftelselänk till din nya e-postadress."))
.andExpect(jsonPath("$.testToken").value("test-token"));
}
@Test
void shouldReturn200AndNewTokenWhenConfirmEmailChangeSucceeds() throws Exception {
User user = new User();
user.setEmail("new@example.com");
user.setRole("user");
when(emailChangeService.confirmChange("confirm-token", "password123")).thenReturn(user);
when(jwtService.generateToken("new@example.com", "user")).thenReturn("new-jwt-token");
mockMvc.perform(post("/api/auth/confirm-email-change")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"confirm-token\",\"password\":\"password123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("new-jwt-token"));
}
@Test
void shouldRejectChangeEmailWithoutAuth() throws Exception {
mockMvc.perform(post("/api/auth/change-email")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
} }
} }

View file

@ -1,9 +1,7 @@
package se.bilhalsning.controller; package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -18,22 +16,16 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.OrderResponse; import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService; import se.bilhalsning.service.UserService;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
class OrderControllerTest { class OrderControllerTest {
private static final String TEST_SECRET =
"this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@ -44,10 +36,9 @@ class OrderControllerTest {
private UserService userService; private UserService userService;
@Test @Test
void shouldReturn401WhenNotAuthenticated() throws Exception { void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/orders")) mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@ -107,31 +98,11 @@ class OrderControllerTest {
} }
@Test @Test
void shouldReturn401WhenPostingWithoutAuth() throws Exception { void shouldReturn403WhenPostingWithoutAuth() throws Exception {
mockMvc.perform(post("/api/orders") mockMvc.perform(post("/api/orders")
.contentType("application/json") .contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}")) .content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
}
@Test
void shouldReturn401WithSwedishMessageWhenTokenExpired() throws Exception {
JwtService expiredJwtService = new JwtService(TEST_SECRET, -1000L);
String expiredToken = expiredJwtService.generateToken("test@bilhej.se");
mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
void shouldReturn401WithMessageWhenNoAuthHeader() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message")
.value(org.hamcrest.Matchers.containsString("session")));
} }
@Test @Test
@ -192,115 +163,4 @@ class OrderControllerTest {
.content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}")) .content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldGetSingleOrderForOwner() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Test letter");
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
when(orderService.getOrderById(orderId)).thenReturn(order);
mockMvc.perform(get("/api/orders/" + orderId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.plate").value("ABC123"))
.andExpect(jsonPath("$.status").value("pending_payment"));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldReturn404WhenGettingOtherUsersOrder() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(UUID.randomUUID());
when(orderService.getOrderById(orderId)).thenReturn(order);
mockMvc.perform(get("/api/orders/" + orderId))
.andExpect(status().isNotFound());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldPatchOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Updated text");
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
when(orderService.updatePendingOrder(any(), any(), any())).thenReturn(order);
mockMvc.perform(patch("/api/orders/" + orderId)
.contentType("application/json")
.content("{\"letterText\":\"Updated text\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.letterText").value("Updated text"));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectPatchWithEmptyLetterText() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
mockMvc.perform(patch("/api/orders/" + orderId)
.contentType("application/json")
.content("{\"letterText\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldCancelOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Test letter");
order.setStatus(se.bilhalsning.entity.OrderStatus.CANCELLED);
when(orderService.cancelOrder(orderId, userId)).thenReturn(order);
mockMvc.perform(post("/api/orders/" + orderId + "/cancel"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("cancelled"));
}
} }

View file

@ -1,6 +1,5 @@
package se.bilhalsning.controller; package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ -8,7 +7,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -20,10 +18,8 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.OrderNotFoundException; import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@ -35,34 +31,23 @@ class PaymentControllerTest {
@MockitoBean @MockitoBean
private OrderService orderService; private OrderService orderService;
@MockitoBean
private UserService userService;
@Test @Test
void shouldReturn401WhenNotAuthenticated() throws Exception { void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(post("/api/payment/{orderId}/pay", mockMvc.perform(post("/api/payment/{orderId}/pay",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")) "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@WithMockUser(username = "test@bilhej.se") @WithMockUser(username = "test@bilhej.se")
void shouldConfirmPaymentSuccessfully() throws Exception { void shouldConfirmPaymentSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
Order order = new Order(); Order order = new Order();
order.setId(orderId); order.setId(orderId);
order.setPlate("ABC123"); order.setPlate("ABC123");
order.setStatus(OrderStatus.PROCESSING); order.setStatus(OrderStatus.PROCESSING);
when(orderService.confirmPayment(eq(orderId), eq(userId))).thenReturn(order); when(orderService.confirmPayment(eq(orderId))).thenReturn(order);
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId) mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
@ -75,14 +60,7 @@ class PaymentControllerTest {
@WithMockUser(username = "test@bilhej.se") @WithMockUser(username = "test@bilhej.se")
void shouldReturn404WhenOrderNotFound() throws Exception { void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); when(orderService.confirmPayment(eq(orderId)))
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
when(orderService.confirmPayment(eq(orderId), eq(userId)))
.thenThrow(new OrderNotFoundException(orderId)); .thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId) mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)

View file

@ -1,100 +0,0 @@
package se.bilhalsning.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import se.bilhalsning.exception.InvalidCredentialsException;
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class AccountSettingsIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private EmailChangeService emailChangeService;
@Test
void shouldChangePasswordAndChangeBack() {
String email = "pw-settings-" + UUID.randomUUID() + "@bilhej.se";
String originalPassword = "original1234";
String changedPassword = "changed12345";
userService.createUser(email, originalPassword);
userService.changePassword(email, originalPassword, changedPassword);
assertDoesNotThrow(() -> userService.authenticate(email, changedPassword));
assertThrows(
InvalidCredentialsException.class,
() -> userService.authenticate(email, originalPassword));
userService.changePassword(email, changedPassword, originalPassword);
assertDoesNotThrow(() -> userService.authenticate(email, originalPassword));
assertThrows(
InvalidCredentialsException.class,
() -> userService.authenticate(email, changedPassword));
}
@Test
void shouldChangeEmailAfterConfirmationAndChangeBack() {
String suffix = UUID.randomUUID().toString();
String originalEmail = "email-settings-" + suffix + "@bilhej.se";
String tempEmail = "email-settings-" + suffix + "-new@bilhej.se";
String password = "password1234";
userService.createUser(originalEmail, password);
var firstToken = emailChangeService
.requestChange(originalEmail, password, tempEmail)
.orElseThrow();
emailChangeService.confirmChange(firstToken, password);
assertEquals(tempEmail, userService.findByEmail(tempEmail).orElseThrow().getEmail());
assertThrows(
InvalidCredentialsException.class,
() -> userService.authenticate(originalEmail, password));
var secondToken = emailChangeService
.requestChange(tempEmail, password, originalEmail)
.orElseThrow();
emailChangeService.confirmChange(secondToken, password);
assertEquals(
originalEmail, userService.findByEmail(originalEmail).orElseThrow().getEmail());
assertThrows(
InvalidCredentialsException.class, () -> userService.authenticate(tempEmail, password));
}
@Test
void shouldRejectEmailChangeConfirmWhenPasswordWrong() {
String suffix = UUID.randomUUID().toString();
String originalEmail = "email-wrongpw-" + suffix + "@bilhej.se";
String tempEmail = "email-wrongpw-" + suffix + "-new@bilhej.se";
String password = "password1234";
userService.createUser(originalEmail, password);
var token = emailChangeService
.requestChange(originalEmail, password, tempEmail)
.orElseThrow();
assertThrows(
InvalidCredentialsException.class,
() -> emailChangeService.confirmChange(token, "wrongpassword"));
assertEquals(
originalEmail, userService.findByEmail(originalEmail).orElseThrow().getEmail());
assertThrows(
InvalidCredentialsException.class,
() -> userService.authenticate(tempEmail, password));
}
}

View file

@ -1,48 +0,0 @@
package se.bilhalsning.service;
import org.junit.jupiter.api.Test;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
class AdminOrderStatusRulesTest {
@Test
void shouldIncludeCurrentAndTargetsForSentOrder() {
Order order = orderWithStatus(OrderStatus.SENT);
assertEquals(
java.util.List.of("sent", "delivered", "failed"),
AdminOrderStatusRules.allowedStatusValues(order));
assertTrue(AdminOrderStatusRules.canRegisterShipment(order));
}
@Test
void shouldAllowOnlyFailedFromPendingPayment() {
Order order = orderWithStatus(OrderStatus.PENDING_PAYMENT);
assertEquals(
java.util.List.of("pending_payment", "failed"),
AdminOrderStatusRules.allowedStatusValues(order));
assertFalse(AdminOrderStatusRules.canRegisterShipment(order));
}
@Test
void shouldExposeFailedRecoveryOptionsWhenTrackingExists() {
Order order = orderWithStatus(OrderStatus.FAILED);
order.setTrackingId("PN123");
order.setAmountPaid(new BigDecimal("49.00"));
assertTrue(AdminOrderStatusRules.allowedStatusValues(order).contains("sent"));
assertTrue(AdminOrderStatusRules.canRegisterShipment(order));
}
private static Order orderWithStatus(OrderStatus status) {
Order order = new Order();
order.setStatus(status);
return order;
}
}

View file

@ -1,179 +0,0 @@
package se.bilhalsning.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.repository.OrderRepository;
import java.math.BigDecimal;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AdminOrderWorkflowServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private OrderNotificationService orderNotificationService;
@InjectMocks
private AdminOrderWorkflowService adminOrderWorkflowService;
@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 = adminOrderWorkflowService.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,
() -> adminOrderWorkflowService.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,
() -> adminOrderWorkflowService.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 = adminOrderWorkflowService.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 = adminOrderWorkflowService.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 BigDecimal("49.00"));
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = adminOrderWorkflowService.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 BigDecimal("49.00"));
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = adminOrderWorkflowService.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 BigDecimal("49.00"));
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = adminOrderWorkflowService.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 = adminOrderWorkflowService.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));
adminOrderWorkflowService.updateOrderStatus(orderId, "failed");
verify(orderNotificationService).notifyOrderFailed(any(Order.class));
}
}

View file

@ -1,140 +0,0 @@
package se.bilhalsning.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import se.bilhalsning.entity.EmailChangeToken;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailChangeTokenInvalidException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.repository.EmailChangeTokenRepository;
@ExtendWith(MockitoExtension.class)
class EmailChangeServiceTest {
@Mock
private UserService userService;
@Mock
private EmailChangeTokenRepository tokenRepository;
@Mock
private EmailService emailService;
@Mock
private PasswordResetService passwordResetService;
@InjectMocks
private EmailChangeService emailChangeService;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(emailChangeService, "publicBaseUrl", "http://localhost:3000");
ReflectionTestUtils.setField(emailChangeService, "exposeToken", true);
}
@Test
void shouldSendConfirmationEmailWhenRequestIsValid() {
User user = new User();
user.setEmail("old@example.com");
user.setRole("user");
when(userService.authenticate("old@example.com", "password123")).thenReturn(user);
when(passwordResetService.generateRawToken()).thenReturn("raw-token");
Optional<String> testToken =
emailChangeService.requestChange("old@example.com", "password123", "new@example.com");
assertEquals(Optional.of("raw-token"), testToken);
verify(userService).validateEmailAvailableForChange(user, "new@example.com");
verify(tokenRepository).deleteUnusedByUserId(user.getId());
verify(tokenRepository).save(any(EmailChangeToken.class));
verify(emailService)
.sendEmailChangeConfirmation(
eq("new@example.com"),
eq("http://localhost:3000/bekrafta-epost?token=raw-token"));
}
@Test
void shouldRejectRequestWhenPasswordWrong() {
when(userService.authenticate("old@example.com", "wrong"))
.thenThrow(new InvalidCredentialsException());
assertThrows(
InvalidCredentialsException.class,
() -> emailChangeService.requestChange("old@example.com", "wrong", "new@example.com"));
verify(tokenRepository, never()).save(any(EmailChangeToken.class));
verify(emailService, never()).sendEmailChangeConfirmation(any(), any());
}
@Test
void shouldConfirmEmailChangeWhenTokenIsValid() {
User user = new User();
user.setEmail("old@example.com");
user.setRole("user");
EmailChangeToken token = new EmailChangeToken();
token.setUser(user);
token.setNewEmail("new@example.com");
token.setTokenHash(PasswordResetService.hashToken("raw-token"));
token.setExpiresAt(java.time.Instant.now().plusSeconds(3600));
when(tokenRepository.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken("raw-token")))
.thenReturn(Optional.of(token));
when(userService.authenticate("old@example.com", "password123")).thenReturn(user);
when(userService.applyEmailChange(user, "new@example.com")).thenReturn(user);
User result = emailChangeService.confirmChange("raw-token", "password123");
assertEquals(user, result);
verify(userService).authenticate("old@example.com", "password123");
verify(tokenRepository).deleteUnusedByUserId(user.getId());
verify(tokenRepository).save(token);
}
@Test
void shouldRejectConfirmWhenPasswordWrong() {
User user = new User();
user.setEmail("old@example.com");
EmailChangeToken token = new EmailChangeToken();
token.setUser(user);
token.setNewEmail("new@example.com");
token.setTokenHash(PasswordResetService.hashToken("raw-token"));
token.setExpiresAt(java.time.Instant.now().plusSeconds(3600));
when(tokenRepository.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken("raw-token")))
.thenReturn(Optional.of(token));
when(userService.authenticate("old@example.com", "wrong"))
.thenThrow(new InvalidCredentialsException());
assertThrows(
InvalidCredentialsException.class,
() -> emailChangeService.confirmChange("raw-token", "wrong"));
verify(userService, never()).applyEmailChange(any(), any());
}
@Test
void shouldRejectConfirmWhenTokenInvalid() {
when(tokenRepository.findByTokenHashAndUsedAtIsNull(any())).thenReturn(Optional.empty());
assertThrows(
EmailChangeTokenInvalidException.class,
() -> emailChangeService.confirmChange("bad-token", "password123"));
}
}

View file

@ -9,7 +9,6 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException; import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.repository.OrderRepository; import se.bilhalsning.repository.OrderRepository;
@ -27,9 +26,6 @@ class OrderServiceTest {
@Mock @Mock
private OrderRepository orderRepository; private OrderRepository orderRepository;
@Mock
private OrderNotificationService orderNotificationService;
@InjectMocks @InjectMocks
private OrderService orderService; private OrderService orderService;
@ -131,126 +127,4 @@ class OrderServiceTest {
assertThrows(OrderNotFoundException.class, assertThrows(OrderNotFoundException.class,
() -> orderService.getOrderById(orderId)); () -> orderService.getOrderById(orderId));
} }
@Test
void shouldCancelOrderWhenPendingPayment() {
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(userId);
order.setStatus(OrderStatus.PENDING_PAYMENT);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.cancelOrder(orderId, userId);
assertEquals(OrderStatus.CANCELLED, result.getStatus());
}
@Test
void shouldThrowWhenCancellingNonPendingOrder() {
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(userId);
order.setStatus(OrderStatus.PROCESSING);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
assertThrows(InvalidOrderStateException.class,
() -> orderService.cancelOrder(orderId, userId));
}
@Test
void shouldThrowWhenCancellingOtherUsersOrder() {
UUID orderId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
UUID otherUserId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(ownerId);
order.setStatus(OrderStatus.PENDING_PAYMENT);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
assertThrows(OrderNotFoundException.class,
() -> orderService.cancelOrder(orderId, otherUserId));
}
@Test
void shouldUpdatePendingOrderLetterText() {
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(userId);
order.setStatus(OrderStatus.PENDING_PAYMENT);
order.setLetterText("Old text");
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.updatePendingOrder(orderId, userId, "New text");
assertEquals("New text", result.getLetterText());
}
@Test
void shouldThrowWhenUpdatingNonPendingOrder() {
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(userId);
order.setStatus(OrderStatus.PROCESSING);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
assertThrows(InvalidOrderStateException.class,
() -> orderService.updatePendingOrder(orderId, userId, "New text"));
}
@Test
void shouldConfirmPaymentForPendingOrder() {
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(userId);
order.setStatus(OrderStatus.PENDING_PAYMENT);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.confirmPayment(orderId, userId);
assertEquals(OrderStatus.PROCESSING, result.getStatus());
}
@Test
void shouldThrowWhenConfirmingPaymentForNonPendingOrder() {
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(userId);
order.setStatus(OrderStatus.CANCELLED);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
assertThrows(InvalidOrderStateException.class,
() -> orderService.confirmPayment(orderId, userId));
}
@Test
void shouldThrowWhenConfirmingPaymentForOtherUsersOrder() {
UUID orderId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
UUID otherUserId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(ownerId);
order.setStatus(OrderStatus.PENDING_PAYMENT);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
assertThrows(OrderNotFoundException.class,
() -> orderService.confirmPayment(orderId, otherUserId));
}
} }

View file

@ -203,35 +203,4 @@ class UserServiceTest {
verify(userRepository, never()).save(any(User.class)); verify(userRepository, never()).save(any(User.class));
} }
@Test
void shouldApplyEmailChangeWhenNewEmailAvailable() {
User user = new User();
user.setEmail("old@example.com");
user.setPasswordHash("hash");
user.setRole("user");
when(userRepository.existsByEmail("new@example.com")).thenReturn(false);
when(userRepository.save(user)).thenReturn(user);
User result = userService.applyEmailChange(user, "new@example.com");
assertEquals("new@example.com", result.getEmail());
verify(userRepository).save(user);
}
@Test
void shouldRejectApplyEmailChangeWhenNewEmailTaken() {
User user = new User();
user.setEmail("old@example.com");
user.setPasswordHash("hash");
when(userRepository.existsByEmail("taken@example.com")).thenReturn(true);
assertThrows(
EmailAlreadyExistsException.class,
() -> userService.applyEmailChange(user, "taken@example.com"));
verify(userRepository, never()).save(any(User.class));
}
} }

View file

@ -1,26 +0,0 @@
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

@ -1,5 +1,3 @@
app: app:
password-reset: password-reset:
expose-token: true expose-token: true
email-change:
expose-token: true

View file

@ -1,106 +0,0 @@
# Bindless dev stack — standalone variant of docker-compose.yml.
#
# Why this exists as a standalone file (not an override):
# Docker Compose merges `volumes:` by list concatenation, not by entry
# replacement, so an override can't drop the bind mounts from the base file —
# only append to them. A standalone file lets us redefine services with only
# the volumes we want.
#
# Usage:
# docker compose -f docker-compose.dev-bindless.yml up -d --build
#
# Use this when the Docker daemon can't bind-mount the host repo correctly:
# - Docker-in-Docker setups (e.g. this Hermes sandbox)
# - rootless Docker with restricted mount paths
# - Some CI runners
#
# For normal local dev, use docker-compose.yml — it bind-mounts the repo for
# Vite HMR and gradle bootRun hot reload.
#
# Trade-off vs. the bind-mounted dev compose:
# - The image is "frozen" at build time. Editing source on the host does not
# affect the running container. Edit + rebuild + restart, or run
# `docker compose up -d --build` after changes.
# - All source lives inside the image (docker/backend.Dockerfile and
# docker/frontend.Dockerfile COPY it in at build time).
#
# What you still get:
# - Gradle caches in named volumes (.gradle, backend/build, gradle-cache)
# so dependency downloads persist between `up` cycles.
# - Postgres data persists across `down` (via the pgdata volume).
services:
postgres:
image: postgres:16
container_name: bilhej-postgres
ports:
- "5432:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
mailpit:
image: ghcr.io/axllent/mailpit:v1.28
container_name: bilhej-mailpit
ports:
- "1025:1025"
- "8025:8025"
backend:
image: bilhej-backend-dev
build:
dockerfile: docker/backend.Dockerfile
context: .
container_name: bilhej-backend
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: docker
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
SWISH_NUMBER: ${SWISH_NUMBER}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost:3000}
MAIL_HOST: mailpit
MAIL_PORT: "1025"
MAIL_USERNAME: ""
MAIL_PASSWORD: ""
MAIL_FROM: ${MAIL_FROM:-noreply@bilhej.se}
depends_on:
postgres:
condition: service_healthy
mailpit:
condition: service_started
volumes:
- backend-gradle-project:/app/.gradle
- backend-build:/app/backend/build
- gradle-cache:/root/.gradle
frontend:
image: bilhej-frontend-dev
build:
dockerfile: docker/frontend.Dockerfile
context: .
container_name: bilhej-frontend
ports:
- "3000:3000"
depends_on:
- backend
volumes:
pgdata:
gradle-cache:
backend-gradle-project:
backend-build:

View file

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

View file

@ -49,9 +49,6 @@ services:
build: build:
dockerfile: docker/frontend.prod.Dockerfile dockerfile: docker/frontend.prod.Dockerfile
context: . context: .
args:
VITE_UMAMI_WEBSITE_ID: ${VITE_UMAMI_WEBSITE_ID:-}
VITE_UMAMI_SCRIPT_URL: ${VITE_UMAMI_SCRIPT_URL:-https://analytics.bilhej.se/script.js}
container_name: bilhej-frontend-prod container_name: bilhej-frontend-prod
ports: ports:
- "3001:80" - "3001:80"

View file

@ -1,15 +1,3 @@
FROM eclipse-temurin:21-jdk FROM eclipse-temurin:21-jdk
WORKDIR /app WORKDIR /app
# Copy build configuration and wrapper first so this layer caches well.
COPY gradlew settings.gradle build.gradle ./
COPY gradle/ gradle/
RUN chmod +x gradlew
# Copy backend module. The dev compose overlays this with a host bind mount
# for live source changes; if the bind mount is absent (DinD, CI, k8s) the
# image is still self-contained and `gradlew :backend:bootRun` will work.
COPY backend/ backend/
EXPOSE 8080
ENTRYPOINT ["./gradlew", ":backend:bootRun", "--no-daemon"] ENTRYPOINT ["./gradlew", ":backend:bootRun", "--no-daemon"]

View file

@ -1,16 +1,7 @@
FROM node:24-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app
# Install dependencies first so this layer caches independently of source changes.
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm install RUN npm install
# Copy the rest of the frontend. The dev compose overlays individual paths
# (./frontend/src, ./frontend/public, ./frontend/index.html) with host bind
# mounts for live reload; if those bind mounts are absent (DinD, CI, k8s)
# the image is still self-contained and `npm run dev` will serve from the
# COPY'd files.
COPY frontend/ . COPY frontend/ .
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View file

@ -1,9 +1,5 @@
FROM node:24-alpine AS builder FROM node:24-alpine AS builder
WORKDIR /app WORKDIR /app
ARG VITE_UMAMI_WEBSITE_ID=
ARG VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
ENV VITE_UMAMI_WEBSITE_ID=$VITE_UMAMI_WEBSITE_ID
ENV VITE_UMAMI_SCRIPT_URL=$VITE_UMAMI_SCRIPT_URL
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci
COPY frontend/ . COPY frontend/ .

View file

@ -54,30 +54,3 @@ Fallback: reset links still log when `MAIL_HOST` is empty.
Keep using Mailpit (`docker compose up`, http://localhost:8025). Do not point local Docker at Keep using Mailpit (`docker compose up`, http://localhost:8025). Do not point local Docker at
Resend unless you intend to send real mail. Resend unless you intend to send real mail.
## 5. Inbound email on bilhej.se
Inbound mail uses **Resend Receiving** on the root domain `bilhej.se`. No mailbox is created in
Strato; the MX record routes all `@bilhej.se` addresses to Resend. You do not create each address
separately in Resend.
**Setup (done once):**
1. Resend → **Domains**`bilhej.se` → enable **Receiving**
2. Strato → **DNS** → add the receiving MX record (e.g. `inbound-smtp.eu-west-1.amazonaws.com`)
3. Wait until Resend shows receiving as **Verified**
4. Send test mail to `support@bilhej.se` and `kontakt@bilhej.se`; confirm both appear under **Emails → Receiving**
**Reading mail:** open the [Resend Receiving inbox](https://resend.com/emails/receiving). There is
no automatic forward to Gmail unless you add a webhook handler later.
| Address | Purpose | Where mail goes |
|---------|---------|-----------------|
| `support@bilhej.se` | Orders, Swish, status, technical issues | Resend dashboard |
| `kontakt@bilhej.se` | General contact, printed letter footer | Resend dashboard |
| `klagomal@bilhej.se` | Complaints (shown on `/kontakt`) | Resend dashboard |
| `noreply@bilhej.se` | Outbound only (password reset) | Not an inbox |
**Optional later (same Resend inbox, no extra DNS):** `abuse@bilhej.se` if you want a published
address for misuse reports; `privacy@bilhej.se` if integritetspolicy asks for a dedicated
data-protection contact.

View file

@ -1,81 +0,0 @@
# Umami analytics (BilHej app)
Privacy-friendly page analytics via self-hosted [Umami](https://umami.is/docs) on **`https://analytics.bilhej.se`**. Server install is live on the VPS; this doc is for **BilHej code and deploy config** only.
## Values (production)
| Item | Value |
|------|--------|
| Collector | `https://analytics.bilhej.se` |
| Tracker script | `https://analytics.bilhej.se/script.js` |
| Dashboard | `https://analytics.bilhej.se` (admin login) |
| Website in Umami | Name **BilHej**, domain **`bilhej.se`** |
| Website ID | `ce59614c-9f2a-4f99-8ba3-c5217f88c3f7` |
The Website ID is public in the browser (tracking snippet). Set it via **`VITE_UMAMI_WEBSITE_ID`** in production frontend build env — do not hardcode in source.
**Note:** Umami 3.1 on this server uses the default **`/script.js`** path. `TRACKER_SCRIPT_NAME` / custom `bilhej-stats.js` is not applied in this version.
### Example snippet (for reference)
```html
<script
defer
src="https://analytics.bilhej.se/script.js"
data-website-id="ce59614c-9f2a-4f99-8ba3-c5217f88c3f7"
></script>
```
## Frontend env
Production builds read this from the **Forgejo Actions secret** `VITE_UMAMI_WEBSITE_ID`. The deploy workflow writes it into `.env` on the server, then `docker compose -f docker-compose.prod.yml build` bakes it into the frontend image.
**Forgejo → Repository → Settings → Actions → Secrets:**
| Secret | Value |
|--------|--------|
| `VITE_UMAMI_WEBSITE_ID` | `ce59614c-9f2a-4f99-8ba3-c5217f88c3f7` |
Not a high-risk secret (the same ID appears in the browser), but keeping it in Forgejo matches other deploy config.
Manual deploy on the server (without Forgejo) works the same way: put the line in the project `.env` before `docker compose ... up --build`.
Optional override (default matches production):
```bash
# VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
```
Leave `VITE_UMAMI_WEBSITE_ID` unset in local dev unless you intentionally send traffic to production Umami.
## Implementation checklist
- [x] Load `script.js` with `data-website-id` from `VITE_UMAMI_WEBSITE_ID` (only when set).
- [x] Send **SPA pageviews** on Vue Router `afterEach` (`data-auto-track="false"`).
- [x] Update **integritetspolicy** — analytics, country-level stats, no IP stored in BilHej DB.
- [x] Admin link **Webbstatistik** → Umami dashboard (prod builds only).
Umami derives **country** from the visitor IP at ingest and does not show IP lists in the UI. BilHej must not store visitor IPs for analytics.
## Verify after deploy
1. Browse `https://bilhej.se` (several routes).
2. Umami → **BilHej****Realtime** / **Countries**.
## Server layout (reference)
| Item | Actual on VPS |
|------|----------------|
| Compose project | `~/umami` (`/home/jocke/umami`) |
| Public access | nginx → `http://umami:3000` on Docker network `web` (host port 3000 used by open-webui) |
| Database | `umami-db` on internal network `umami-internal` only |
```bash
cd ~/umami
docker compose ps
docker compose logs -f umami
docker compose pull && docker compose up -d # updates — read release notes first
docker exec umami-db pg_dump -U umami umami > ~/umami-backup-$(date +%F).sql
```
Country stats require nginx to pass **`X-Forwarded-For`** (already configured for this vhost).

View file

@ -1,175 +0,0 @@
import { test, expect, type Page, type APIRequestContext } from '@playwright/test'
import { clearMailpit, waitForEmailChangeToken } from './helpers/mailpit'
test.describe('Account settings', () => {
test('can change password and change back', async ({ page, request }) => {
const email = `pw-change-${Date.now()}@bilhej.se`
const originalPassword = 'original1234'
const changedPassword = 'changed12345'
await registerUser(request, email, originalPassword)
await loginViaUi(page, email, originalPassword)
await changePasswordViaUi(page, originalPassword, changedPassword)
await expect(page.getByText('Lösenordet har uppdaterats.')).toBeVisible()
await logoutViaHeader(page)
await expectLoginFails(page, email, originalPassword)
await loginViaUi(page, email, changedPassword)
await changePasswordViaUi(page, changedPassword, originalPassword)
await expect(page.getByText('Lösenordet har uppdaterats.')).toBeVisible()
await logoutViaHeader(page)
await expectLoginFails(page, email, changedPassword)
await loginViaUi(page, email, originalPassword)
})
test('can change email after confirming link sent to new address', async ({
page,
request,
}) => {
const suffix = Date.now()
const originalEmail = `email-change-${suffix}@bilhej.se`
const tempEmail = `email-change-${suffix}-new@bilhej.se`
const password = 'password1234'
await clearMailpit(request)
await registerUser(request, originalEmail, password)
await loginViaUi(page, originalEmail, password)
await page.goto('/andra-epost')
await changeEmailViaUi(page, tempEmail, password)
await expect(
page.getByText(
'Vi har skickat en bekräftelselänk till din nya e-postadress.',
),
).toBeVisible()
const token = await waitForEmailChangeToken(request, tempEmail, {
publicBaseUrl: 'http://frontend',
})
await confirmEmailChangeViaUi(page, token, password)
await expect(
page.getByText('Din e-postadress har uppdaterats.'),
).toBeVisible()
await expect(page.locator('header')).toContainText(tempEmail)
await clearMailpit(request)
await page.goto('/andra-epost')
await changeEmailViaUi(page, originalEmail, password)
await expect(
page.getByText(
'Vi har skickat en bekräftelselänk till din nya e-postadress.',
),
).toBeVisible()
const restoreToken = await waitForEmailChangeToken(request, originalEmail, {
publicBaseUrl: 'http://frontend',
})
await confirmEmailChangeViaUi(page, restoreToken, password)
await expect(
page.getByText('Din e-postadress har uppdaterats.'),
).toBeVisible()
await expect(page.locator('header')).toContainText(originalEmail)
})
test('does not change email when confirm password is wrong', async ({
page,
request,
}) => {
const suffix = Date.now()
const originalEmail = `email-wrongpw-e2e-${suffix}@bilhej.se`
const tempEmail = `email-wrongpw-e2e-${suffix}-new@bilhej.se`
const password = 'password1234'
await clearMailpit(request)
await registerUser(request, originalEmail, password)
await loginViaUi(page, originalEmail, password)
await page.goto('/andra-epost')
await changeEmailViaUi(page, tempEmail, password)
const token = await waitForEmailChangeToken(request, tempEmail, {
publicBaseUrl: 'http://frontend',
})
await page.goto(`/bekrafta-epost?token=${token}`)
await page.locator('#password').fill('wrongpassword')
await page.getByRole('button', { name: 'Bekräfta ny e-postadress' }).click()
await expect(page.getByText('Lösenordet är felaktigt')).toBeVisible()
await expect(page.locator('header')).toContainText(originalEmail)
const login = await request.post('/api/auth/login', {
data: { email: originalEmail, password },
})
expect(login.ok()).toBeTruthy()
const loginWithNewEmail = await request.post('/api/auth/login', {
data: { email: tempEmail, password },
})
expect(loginWithNewEmail.ok()).toBeFalsy()
})
})
async function registerUser(
request: APIRequestContext,
email: string,
password: string,
) {
const response = await request.post('/api/auth/register', {
data: { email, password },
})
expect(response.ok()).toBeTruthy()
}
async function loginViaUi(page: Page, email: string, password: string) {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill(email)
await page.getByLabel('Lösenord').fill(password)
await page.getByRole('button', { name: 'Logga in' }).click()
await expect(page).toHaveURL('/')
}
async function expectLoginFails(page: Page, email: string, password: string) {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill(email)
await page.getByLabel('Lösenord').fill(password)
await page.getByRole('button', { name: 'Logga in' }).click()
await expect(page.getByText('Felaktig e-post eller lösenord')).toBeVisible()
}
async function logoutViaHeader(page: Page) {
await page.locator('header').getByRole('button', { name: 'Logga ut' }).click()
await expect(page).toHaveURL('/')
}
async function changePasswordViaUi(
page: Page,
currentPassword: string,
newPassword: string,
) {
await page.goto('/andra-losenord')
await page.locator('#current-password').fill(currentPassword)
await page.locator('#password').fill(newPassword)
await page.locator('#confirm-password').fill(newPassword)
await page.getByRole('button', { name: 'Spara nytt lösenord' }).click()
}
async function changeEmailViaUi(page: Page, newEmail: string, password: string) {
await page.locator('#new-email').fill(newEmail)
await page.locator('#password').fill(password)
await page.getByRole('button', { name: 'Spara ny e-postadress' }).click()
}
async function confirmEmailChangeViaUi(
page: Page,
token: string,
password: string,
) {
await page.goto(`/bekrafta-epost?token=${token}`)
await page.locator('#password').fill(password)
await page.getByRole('button', { name: 'Bekräfta ny e-postadress' }).click()
}

View file

@ -1,19 +1,15 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import { loginAsAdmin } from './helpers/admin'
const SEEDED_ORDER_ID = 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' const SEEDED_ORDER_ID = 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'
const SEEDED_ORDER_SHORT_ID = SEEDED_ORDER_ID.slice(0, 8) 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.describe('Admin dashboard', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await loginAsAdmin(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('/')
}) })
test('admin can navigate to admin page', async ({ page }) => { test('admin can navigate to admin page', async ({ page }) => {
@ -41,7 +37,9 @@ test.describe('Admin dashboard', () => {
await page.goto('/admin') await page.goto('/admin')
await expect(page.getByRole('columnheader', { name: 'Datum' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'Datum' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'ID' })).toBeVisible() await expect(
page.getByRole('columnheader', { name: 'Beställnings-ID' }),
).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'E-post' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'E-post' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Regnr' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'Regnr' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
@ -71,37 +69,34 @@ test.describe('Admin dashboard', () => {
await expect(dialog).not.toBeVisible() await expect(dialog).not.toBeVisible()
}) })
test('click row shows tracking section', async ({ page }) => { test('click expand button shows tracking section', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
await rowByPlate(page, PROCESSING_PLATE).click() const expandBtns = page.locator('.admin__expand-btn')
await expandBtns.first().click()
await expect(page.getByText('Registrera utskick').first()).toBeVisible() await expect(page.getByText('Spårnings-ID').first()).toBeVisible()
}) })
test('click row again collapses it', async ({ page }) => { test('click expand button again collapses it', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
const row = rowByPlate(page, PROCESSING_PLATE) const expandBtns = page.locator('.admin__expand-btn')
await row.click() await expandBtns.first().click()
await expect(page.locator('.admin__tracking-input').first()).toBeVisible() await expect(page.locator('.admin__tracking-input').first()).toBeVisible()
await row.click() await expandBtns.first().click()
await expect(page.locator('.admin__tracking-input').first()).not.toBeVisible() await expect(page.locator('.admin__tracking-input').first()).not.toBeVisible()
}) })
test('status dropdown shows current status for sent orders', async ({ test('status dropdown changes update order status', async ({ page }) => {
page,
}) => {
await page.goto('/admin') await page.goto('/admin')
await page.locator('#admin-order-search').fill(SEEDED_ORDER_SHORT_ID)
const row = page.locator('.admin__row', { hasText: SEEDED_ORDER_SHORT_ID }) const selects = page.locator('.admin__status-select')
const select = row.locator('.admin__status-select') await selects.first().selectOption('delivered')
await expect(select).toBeVisible()
await expect(select).toHaveValue('sent') const updatedSelect = selects.first()
await expect(updatedSelect).toHaveValue('delivered')
}) })
test('admin cannot access admin page without auth', async ({ page }) => { test('admin cannot access admin page without auth', async ({ page }) => {
@ -113,21 +108,20 @@ test.describe('Admin dashboard', () => {
test('expanded row shows tracking input and save button', async ({ page }) => { test('expanded row shows tracking input and save button', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
await rowByPlate(page, PROCESSING_PLATE).click() const expandBtns = page.locator('.admin__expand-btn')
await expandBtns.first().click()
await expect(page.getByText('Registrera utskick').first()).toBeVisible() await expect(page.getByText('Spårnings-ID').first()).toBeVisible()
await expect(page.locator('.admin__tracking-input')).toBeVisible() await expect(page.locator('.admin__tracking-input')).toBeVisible()
await expect( await expect(page.getByRole('button', { name: 'Spara' })).toBeVisible()
page.getByRole('button', { name: 'Registrera utskick' }),
).toBeVisible()
}) })
test('shows PostNord link when trackingId exists', async ({ page }) => { test('shows PostNord link when trackingId exists', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
await page.locator('.admin__row').last().click() const expandBtns = page.locator('.admin__expand-btn')
await expandBtns.last().click()
const trackingLink = page.locator('.admin__tracking-link') const trackingLink = page.locator('.admin__tracking-link')
await expect(trackingLink).toBeVisible() await expect(trackingLink).toBeVisible()
@ -138,7 +132,8 @@ test.describe('Admin dashboard', () => {
await page.goto('/admin') await page.goto('/admin')
const defRow = page.locator('.admin__row', { hasText: 'DEF456' }).first() const defRow = page.locator('.admin__row', { hasText: 'DEF456' }).first()
await defRow.click() const expandBtn = defRow.locator('.admin__expand-btn')
await expandBtn.click()
const trackingLink = page.locator('.admin__tracking-link') const trackingLink = page.locator('.admin__tracking-link')
await expect(trackingLink).not.toBeVisible() await expect(trackingLink).not.toBeVisible()

View file

@ -1,87 +0,0 @@
import { test, expect } from '@playwright/test'
import { loginAsAdmin, openAdminDashboard } from './helpers/admin'
/**
* 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'
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 openAdminDashboard(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

@ -25,22 +25,6 @@ test.describe('Auth guards', () => {
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
}) })
test('redirects unauthenticated user from /andra-losenord to /logga-in', async ({
page,
}) => {
await page.goto('/andra-losenord')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/andra-losenord/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
})
test('redirects unauthenticated user from /andra-epost to /logga-in', async ({
page,
}) => {
await page.goto('/andra-epost')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/andra-epost/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
})
test('redirects authenticated user from /logga-in to home', async ({ test('redirects authenticated user from /logga-in to home', async ({
page, page,
}) => { }) => {
@ -70,13 +54,6 @@ test.describe('Auth guards', () => {
}) })
test('allows admin user to access /admin', async ({ page }) => { test('allows admin user to access /admin', async ({ page }) => {
await page.route('**/api/admin/orders', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: '[]',
}),
)
const jwt = makeJwt({ role: 'admin' }) const jwt = makeJwt({ role: 'admin' })
await page.goto('/') await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)

View file

@ -1,19 +1,14 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import { loginAsAdmin, openAdminDashboard } from './helpers/admin'
test.describe.configure({ mode: 'serial' }) test.describe.configure({ mode: 'serial' })
let plateCounter = 0
function uniquePlate(prefix: string): string { function uniquePlate(prefix: string): string {
plateCounter += 1 const digits = String((Date.now() % 90) + 10)
const digits = String(10 + (plateCounter % 90)) return `${prefix}${digits}E`
const letter = String.fromCharCode(65 + (plateCounter % 26))
return `${prefix}${digits}${letter}`
} }
test.describe('Deferred payment and admin lookup', () => { test.describe('Deferred payment and admin lookup', () => {
let plate = '' const plate = uniquePlate('LAT')
const letterText = 'E2E-test: betalar senare från orderhistoriken.' const letterText = 'E2E-test: betalar senare från orderhistoriken.'
let orderId = '' let orderId = ''
@ -40,26 +35,9 @@ test.describe('Deferred payment and admin lookup', () => {
await page.getByRole('button', { name: 'Ja, jag har betalat' }).click() await page.getByRole('button', { name: 'Ja, jag har betalat' }).click()
} }
async function openAdminTodoBoard(page: import('@playwright/test').Page) {
await openAdminDashboard(page)
await page.getByRole('button', { name: /Att göra/ }).click()
await expect(page.locator('.admin__stat--active')).toContainText('Att göra')
}
async function searchAdminOrders(
page: import('@playwright/test').Page,
query: string,
) {
const search = page.locator('#admin-order-search')
await search.click()
await search.fill(query)
await expect(search).toHaveValue(query)
}
test('user creates order, leaves payment, and pays later from orders', async ({ test('user creates order, leaves payment, and pays later from orders', async ({
page, page,
}) => { }) => {
plate = uniquePlate('LAT')
await loginAsTestUser(page) await loginAsTestUser(page)
await page.goto(`/compose?plate=${plate}`) await page.goto(`/compose?plate=${plate}`)
@ -81,42 +59,58 @@ test.describe('Deferred payment and admin lookup', () => {
const orderCard = page.locator('.orders__card', { hasText: orderId }) const orderCard = page.locator('.orders__card', { hasText: orderId })
await expect(orderCard.getByText(plate)).toBeVisible() await expect(orderCard.getByText(plate)).toBeVisible()
await expect(orderCard.locator('.badge')).toHaveText('Väntar på betalning') await expect(orderCard.locator('.badge')).toHaveText('Väntar på betalning')
await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).toBeVisible() await expect(orderCard.getByRole('link', { name: 'Betala nu' })).toBeVisible()
await orderCard.getByRole('link', { name: 'Betala 49 kr' }).click() await orderCard.getByRole('link', { name: 'Betala nu' }).click()
await expect(page).toHaveURL(new RegExp(`/betalning/${orderId}`)) await expect(page).toHaveURL(new RegExp(`/betalning/${orderId}`))
await completeSwishPayment(page) await completeSwishPayment(page)
await expect(page).toHaveURL('/orders') await expect(page).toHaveURL('/orders')
await expect(orderCard.locator('.badge')).toHaveText('Hanteras') await expect(orderCard.locator('.badge')).toHaveText('Hanteras')
await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).not.toBeVisible() await expect(orderCard.getByRole('link', { name: 'Betala nu' })).not.toBeVisible()
}) })
test('admin finds paid order under Att göra by order id and plate', async ({ test('admin finds paid order under Att göra when searching partial order id', async ({
page, page,
}) => { }) => {
await loginAsAdmin(page) await loginAsAdmin(page)
await openAdminTodoBoard(page) await page.goto('/admin')
await page.getByRole('button', { name: /Att göra/ }).click()
await page.locator('#admin-order-search').fill(shortOrderId)
await searchAdminOrders(page, shortOrderId)
const row = page.locator('.admin__row', { hasText: shortOrderId }) const row = page.locator('.admin__row', { hasText: shortOrderId })
await expect(row).toBeVisible({ timeout: 15_000 }) await expect(row).toBeVisible()
await expect(row).toHaveClass(/admin__row--todo/)
await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId) await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId)
const plateInAdmin = (await row.locator('.admin__plate').textContent())?.trim() await expect(row.locator('.admin__plate')).toHaveText(plate)
expect(plateInAdmin).toBeTruthy() await expect(row).toHaveClass(/admin__row--todo/)
await searchAdminOrders(page, orderId)
await expect(
page.locator('.admin__row', { hasText: shortOrderId }),
).toBeVisible()
await searchAdminOrders(page, plateInAdmin!)
const rowByPlate = page.locator('.admin__row').filter({
has: page.locator('.admin__plate', { hasText: plateInAdmin! }),
}) })
await expect(rowByPlate).toBeVisible()
await expect(rowByPlate.locator('.admin__order-id')).toHaveText(shortOrderId) test('admin finds paid order when searching full order id', async ({ page }) => {
await loginAsAdmin(page)
await page.goto('/admin')
await page.getByRole('button', { name: /Att göra/ }).click()
await page.locator('#admin-order-search').fill(orderId)
const row = page.locator('.admin__row', { hasText: shortOrderId })
await expect(row).toBeVisible()
await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId)
await expect(row.locator('.admin__plate')).toHaveText(plate)
})
test('admin finds paid order when searching registration number', async ({
page,
}) => {
await loginAsAdmin(page)
await page.goto('/admin')
await page.getByRole('button', { name: /Att göra/ }).click()
await page.locator('#admin-order-search').fill(plate)
const row = page.locator('.admin__row', { hasText: shortOrderId })
await expect(row).toBeVisible()
await expect(row.locator('.admin__plate')).toHaveText(plate)
}) })
test('admin does not show unpaid order under Att göra before payment', async ({ test('admin does not show unpaid order under Att göra before payment', async ({
@ -136,19 +130,15 @@ test.describe('Deferred payment and admin lookup', () => {
await page.evaluate(() => localStorage.clear()) await page.evaluate(() => localStorage.clear())
await loginAsAdmin(page) await loginAsAdmin(page)
await openAdminTodoBoard(page) await page.goto('/admin')
await page.getByRole('button', { name: /Att göra/ }).click()
const unpaidRow = page.locator('.admin__row', { hasText: unpaidShortId }) const unpaidRow = page.locator('.admin__row', { hasText: unpaidShortId })
await expect(unpaidRow).not.toBeVisible() await expect(unpaidRow).not.toBeVisible()
await page.getByRole('button', { name: /Väntar/ }).click() await page.getByRole('button', { name: /Väntar/ }).click()
await expect(page.locator('.admin__stat--active')).toContainText('Väntar') await page.locator('#admin-order-search').fill(unpaidPlate)
await searchAdminOrders(page, unpaidShortId)
await expect(unpaidRow).toBeVisible({ timeout: 15_000 })
const plateInAdmin = (await unpaidRow.locator('.admin__plate').textContent())?.trim()
expect(plateInAdmin).toBeTruthy()
await searchAdminOrders(page, plateInAdmin!)
await expect(unpaidRow).toBeVisible() await expect(unpaidRow).toBeVisible()
await expect(unpaidRow.locator('.admin__plate')).toHaveText(plateInAdmin!) await expect(unpaidRow.locator('.admin__plate')).toHaveText(unpaidPlate)
}) })
}) })

View file

@ -1,55 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Expired token logout', () => {
test('router guard redirects expired token to login and logs out', async ({
page,
}) => {
const past = Math.floor(Date.now() / 1000) - 3600
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: past })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/orders')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
const header = page.locator('header')
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).not.toBeVisible()
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
expect(stored).toBeNull()
})
test('API 401 logs out and redirects when guard accepts token but backend rejects it', async ({
page,
}) => {
const future = Math.floor(Date.now() / 1000) + 3600
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: future })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/orders')
await page.waitForURL(/\/logga-in\?redirect=\/orders/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
const header = page.locator('header')
await expect(header.getByRole('button', { name: 'Logga ut' })).not.toBeVisible()
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
expect(stored).toBeNull()
})
})
function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
const signature = 'test-sig'
return `${header}.${body}.${signature}`
}

View file

@ -100,13 +100,6 @@ test.describe('Header auth state', () => {
}) })
test('logout redirects to home page', async ({ page }) => { test('logout redirects to home page', async ({ page }) => {
await page.route('**/api/orders', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: '[]',
}),
)
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' }) const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/orders') await page.goto('/orders')
await page.evaluate( await page.evaluate(
@ -150,104 +143,8 @@ test.describe('Header auth state', () => {
header.getByRole('link', { name: 'Admin' }), header.getByRole('link', { name: 'Admin' }),
).not.toBeVisible() ).not.toBeVisible()
}) })
test('shows settings button when authenticated', async ({ page }) => {
await authenticateUser(page)
const header = page.locator('header')
await expect(
header.getByRole('button', { name: 'Inställningar' }),
).toBeVisible()
}) })
test('settings menu links to change email and password pages', async ({
page,
}) => {
await authenticateUser(page)
const header = page.locator('header')
const settingsButton = header.getByRole('button', { name: 'Inställningar' })
await settingsButton.click()
const menu = header.getByRole('menu')
await expect(
menu.getByRole('menuitem', { name: 'Byt e-postadress' }),
).toHaveAttribute('href', '/andra-epost')
await expect(
menu.getByRole('menuitem', { name: 'Byt lösenord' }),
).toHaveAttribute('href', '/andra-losenord')
})
test('highlights settings button on change password page', async ({
page,
}) => {
await authenticateUser(page)
await page.goto('/andra-losenord')
const settingsButton = page
.locator('header')
.getByRole('button', { name: 'Inställningar' })
await expect(settingsButton).toHaveClass(/app-header__settings-trigger--active/)
await expect(
page.getByRole('heading', { name: 'Byt lösenord' }),
).toBeVisible()
})
test('highlights settings button on change email page', async ({ page }) => {
await authenticateUser(page)
await page.goto('/andra-epost')
const settingsButton = page
.locator('header')
.getByRole('button', { name: 'Inställningar' })
await expect(settingsButton).toHaveClass(/app-header__settings-trigger--active/)
await expect(
page.getByRole('heading', { name: 'Byt e-postadress' }),
).toBeVisible()
})
})
test.describe('Header on mobile viewport', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('menu reveals navigation links when authenticated', async ({ page }) => {
await authenticateUser(page)
await page.goto('/')
const header = page.locator('header')
await expect(
header.getByRole('link', { name: 'Mina beställningar' }),
).not.toBeVisible()
await header.getByRole('button', { name: 'Öppna meny' }).click()
await expect(
header.getByRole('link', { name: 'Mina beställningar' }),
).toBeVisible()
await expect(
header.getByRole('link', { name: 'Byt e-postadress' }),
).toBeVisible()
})
test('home page has no horizontal overflow', async ({ page }) => {
await page.goto('/')
const scrollWidth = await page.evaluate(
() => document.documentElement.scrollWidth,
)
const clientWidth = await page.evaluate(
() => document.documentElement.clientWidth,
)
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1)
})
})
async function authenticateUser(page: import('@playwright/test').Page) {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/')
}
function makeJwt(payload: Record<string, unknown>): string { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View file

@ -1,15 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
export async function loginAsAdmin(page: 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('/')
}
export async function openAdminDashboard(page: Page) {
await page.goto('/admin')
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
}

View file

@ -100,65 +100,6 @@ function extractResetToken(body: string, publicBaseUrl?: string): string | null
return null return null
} }
export async function waitForEmailChangeToken(
request: APIRequestContext,
recipientEmail: string,
options: { timeoutMs?: number; publicBaseUrl?: string } = {},
): Promise<string> {
const timeoutMs = options.timeoutMs ?? 20_000
const deadline = Date.now() + timeoutMs
const normalizedRecipient = recipientEmail.toLowerCase().trim()
while (Date.now() < deadline) {
const listResponse = await request.get(`${mailpitApiBase}/api/v1/messages`)
if (!listResponse.ok()) {
await sleep(500)
continue
}
const list = (await listResponse.json()) as MailpitMessagesResponse
for (const summary of list.messages ?? []) {
const matchesRecipient = summary.To?.some(
(to) => to.Address.toLowerCase() === normalizedRecipient,
)
if (!matchesRecipient) continue
const detailResponse = await request.get(
`${mailpitApiBase}/api/v1/message/${summary.ID}`,
)
if (!detailResponse.ok()) continue
const detail = (await detailResponse.json()) as MailpitMessageDetail
const body = detail.Text ?? detail.HTML ?? ''
const token = extractEmailChangeToken(body, options.publicBaseUrl)
if (token) return token
}
await sleep(500)
}
throw new Error(
`No email change confirmation for ${recipientEmail} in Mailpit within ${timeoutMs}ms`,
)
}
function extractEmailChangeToken(body: string, publicBaseUrl?: string): string | null {
const pathPattern = /\/bekrafta-epost\?token=([A-Za-z0-9_-]+)/
const pathMatch = body.match(pathPattern)
if (pathMatch) return pathMatch[1]
if (publicBaseUrl) {
const escaped = publicBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const fullPattern = new RegExp(
`${escaped}/bekrafta-epost\\?token=([A-Za-z0-9_-]+)`,
)
const fullMatch = body.match(fullPattern)
if (fullMatch) return fullMatch[1]
}
return null
}
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms))
} }

View file

@ -49,7 +49,7 @@ test.describe('Order history', () => {
await page.goto('/orders') await page.goto('/orders')
await expect(page.getByText('Skickat').first()).toBeVisible() await expect(page.getByText('Skickat')).toBeVisible()
await expect(page.getByText('Väntar på betalning').first()).toBeVisible() await expect(page.getByText('Väntar på betalning').first()).toBeVisible()
await expect(page.getByText('Levererat').first()).toBeVisible() await expect(page.getByText('Levererat').first()).toBeVisible()
}) })
@ -66,8 +66,8 @@ test.describe('Order history', () => {
await page.goto('/orders') await page.goto('/orders')
const unpaidCard = page.locator('.orders__card', { hasText: 'DEF456' }) const unpaidCard = page.locator('.orders__card', { hasText: 'DEF456' })
await expect(unpaidCard.getByRole('link', { name: 'Betala 49 kr' })).toBeVisible() await expect(unpaidCard.getByRole('link', { name: 'Betala nu' })).toBeVisible()
await unpaidCard.getByRole('link', { name: 'Betala 49 kr' }).click() await unpaidCard.getByRole('link', { name: 'Betala nu' }).click()
await expect(page).toHaveURL(/\/betalning\/c2eebc99/) await expect(page).toHaveURL(/\/betalning\/c2eebc99/)
await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible()
@ -91,38 +91,4 @@ test.describe('Order history', () => {
const trackingLink2 = page.getByRole('link', { name: 'PN987654321' }) const trackingLink2 = page.getByRole('link', { name: 'PN987654321' })
await expect(trackingLink2).toBeVisible() await expect(trackingLink2).toBeVisible()
}) })
test('can cancel pending order', async ({ page }) => {
const plate = 'CAN999'
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto(`/compose?plate=${plate}`)
await page.getByLabel('Ditt meddelande').fill('E2E-test: ska kunna avbrytas.')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await expect(page).toHaveURL(/\/betalning\//)
await page.goto('/orders')
const pendingCard = page.locator('.orders__card', { hasText: plate })
await expect(pendingCard.getByText('Väntar på betalning')).toBeVisible()
await expect(
pendingCard.getByRole('link', { name: 'Betala 49 kr' }),
).toBeVisible()
page.once('dialog', (dialog) => dialog.accept())
await pendingCard.getByRole('button', { name: 'Avbryt beställning' }).click()
await expect(pendingCard.getByText('Avbruten')).toBeVisible()
await expect(
pendingCard.getByRole('link', { name: 'Betala 49 kr' }),
).not.toBeVisible()
await expect(
pendingCard.getByRole('button', { name: 'Avbryt beställning' }),
).not.toBeVisible()
})
}) })

View file

@ -49,31 +49,6 @@ test.describe('Payment redirect', () => {
await page.waitForURL(/\/betalning\//) await page.waitForURL(/\/betalning\//)
await expect(page.getByText('Swisha till')).toBeVisible() await expect(page.getByText('Swisha till')).toBeVisible()
await expect( await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible()
page.getByRole('button', { name: 'Jag har betalat' }),
).toBeVisible()
})
test('shows QR code for desktop scanning', async ({ page }) => {
await page.goto('/compose?plate=QRA222')
await page.getByLabel('Ditt meddelande').fill('Fin bil!')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//)
await expect(page.getByRole('img', { name: 'Swish QR-kod' })).toBeVisible()
await expect(page.getByText('Skanna QR-koden')).toBeVisible()
})
test('shows Swish payment link with pre-filled data', async ({ page }) => {
await page.goto('/compose?plate=MNO345')
await page.getByLabel('Ditt meddelande').fill('Hej där!')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//)
const swishLink = page.getByRole('link', { name: 'Betala med Swish' })
await expect(swishLink).toBeVisible()
const href = await swishLink.getAttribute('href')
expect(href).toContain('app.swish.nu')
expect(href).toContain('amt=49.00')
}) })
}) })

View file

@ -9,7 +9,6 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
@ -17,7 +16,6 @@
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
@ -793,6 +791,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -810,6 +811,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -827,6 +831,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -844,6 +851,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -861,6 +871,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -878,6 +891,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1038,16 +1054,6 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.1", "version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
@ -1956,15 +1962,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@ -1990,91 +1987,11 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@ -2087,6 +2004,7 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": { "node_modules/commander": {
@ -2218,15 +2136,6 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@ -2251,12 +2160,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -2815,15 +2718,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob": { "node_modules/glob": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@ -2969,6 +2863,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -3390,6 +3285,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3411,6 +3309,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3432,6 +3333,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3453,6 +3357,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3836,15 +3743,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -3889,6 +3787,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -4037,15 +3936,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.13", "version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
@ -4144,23 +4034,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/quansync": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@ -4211,15 +4084,6 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -4230,12 +4094,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@ -4350,12 +4208,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -5242,12 +5094,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -5390,12 +5236,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.3", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
@ -5411,134 +5251,6 @@
"url": "https://github.com/sponsors/eemeli" "url": "https://github.com/sponsors/eemeli"
} }
}, },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -17,7 +17,6 @@
}, },
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
@ -25,7 +24,6 @@
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",

View file

@ -23,27 +23,6 @@ export default defineConfig({
projects: [ projects: [
{ {
name: 'chromium', name: 'chromium',
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',
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' }, use: { browserName: 'chromium' },
}, },
], ],

View file

@ -1,44 +1,10 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import AboutPage from '@/pages/AboutPage.vue' import AboutPage from '@/pages/AboutPage.vue'
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/om-oss', name: 'about', component: AboutPage },
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
],
})
}
describe('AboutPage', () => { describe('AboutPage', () => {
it('renders heading and lead', () => { it('renders heading', () => {
const router = createTestRouter() const wrapper = mount(AboutPage)
const wrapper = mount(AboutPage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Om Bilhej') expect(wrapper.text()).toContain('Om Bilhej')
expect(wrapper.text()).toContain('Bilhej gör det enkelt')
})
it('renders how-it-works steps', () => {
const router = createTestRouter()
const wrapper = mount(AboutPage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Skriv brevet här')
expect(wrapper.text()).toContain('Vi postar åt dig')
})
it('links to home page', () => {
const router = createTestRouter()
const wrapper = mount(AboutPage, {
global: { plugins: [router] },
})
const cta = wrapper.find('a.about__cta-btn')
expect(cta.exists()).toBe(true)
expect(cta.attributes('href')).toBe('/')
}) })
}) })

View file

@ -43,11 +43,7 @@ const mockOrders = [
status: 'sent', status: 'sent',
trackingId: 'PN123456789', trackingId: 'PN123456789',
amountPaid: 49.0, amountPaid: 49.0,
shippedAt: '2026-05-13T12:00:00Z',
adminNotes: null,
createdAt: '2026-05-11T12:00:00Z', createdAt: '2026-05-11T12:00:00Z',
allowedStatuses: ['sent', 'delivered', 'failed'],
canRegisterShipment: true,
}, },
{ {
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
@ -57,11 +53,7 @@ const mockOrders = [
status: 'processing', status: 'processing',
trackingId: null, trackingId: null,
amountPaid: null, amountPaid: null,
shippedAt: null,
adminNotes: null,
createdAt: '2026-05-14T13:00:00Z', createdAt: '2026-05-14T13:00:00Z',
allowedStatuses: ['processing', 'failed'],
canRegisterShipment: true,
}, },
{ {
id: 'c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', id: 'c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13',
@ -71,24 +63,16 @@ const mockOrders = [
status: 'pending_payment', status: 'pending_payment',
trackingId: null, trackingId: null,
amountPaid: null, amountPaid: null,
shippedAt: null,
adminNotes: null,
createdAt: '2026-05-15T14:00:00Z', createdAt: '2026-05-15T14:00:00Z',
allowedStatuses: ['pending_payment', 'failed'],
canRegisterShipment: false,
}, },
] ]
function freshMockOrders() {
return mockOrders.map((order) => ({ ...order }))
}
describe('AdminDashboard', () => { describe('AdminDashboard', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
globalThis.fetch = vi.fn() globalThis.fetch = vi.fn()
vi.mocked(globalThis.fetch).mockResolvedValue( vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, freshMockOrders()), mockFetchResponse(200, mockOrders),
) )
}) })
@ -117,10 +101,10 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Datum') expect(wrapper.text()).toContain('Datum')
expect(wrapper.text()).toContain('ID') expect(wrapper.text()).toContain('Beställnings-ID')
expect(wrapper.text()).toContain('E-post') expect(wrapper.text()).toContain('E-post')
expect(wrapper.text()).toContain('Regnr') expect(wrapper.text()).toContain('Regnr')
expect(wrapper.text()).toContain('Brev') expect(wrapper.text()).toContain('Meddelande')
expect(wrapper.text()).toContain('Status') expect(wrapper.text()).toContain('Status')
}) })
@ -179,7 +163,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.find('.admin__expanded-row').exists()).toBe(true) expect(wrapper.find('.admin__expanded-row').exists()).toBe(true)
@ -193,7 +177,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.findAll('.admin__expanded-row')).toHaveLength(1) expect(wrapper.findAll('.admin__expanded-row')).toHaveLength(1)
@ -214,16 +198,15 @@ describe('AdminDashboard', () => {
it('fires status update API on dropdown change', async () => { it('fires status update API on dropdown change', async () => {
vi.mocked(globalThis.fetch) vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders())) .mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
.mockResolvedValueOnce( .mockResolvedValueOnce(
mockFetchResponse(200, { ...mockOrders[0], status: 'delivered' }), mockFetchResponse(200, { ...mockOrders[0], status: 'paid' }),
) )
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const selects = wrapper.findAll('.admin__status-select') const selects = wrapper.findAll('.admin__status-select')
await selects[0].setValue('delivered')
await selects[0].trigger('change') await selects[0].trigger('change')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -231,14 +214,14 @@ describe('AdminDashboard', () => {
'/api/admin/orders/c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11/status', '/api/admin/orders/c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11/status',
expect.objectContaining({ expect.objectContaining({
method: 'PATCH', method: 'PATCH',
body: '{"status":"delivered"}', body: '{"status":"sent"}',
}), }),
) )
}) })
it('shows status error on failed update', async () => { it('shows status error on failed update', async () => {
vi.mocked(globalThis.fetch) vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders())) .mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
.mockResolvedValueOnce( .mockResolvedValueOnce(
mockFetchResponse(500, { message: 'Server error' }), mockFetchResponse(500, { message: 'Server error' }),
) )
@ -247,11 +230,10 @@ describe('AdminDashboard', () => {
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const selects = wrapper.findAll('.admin__status-select') const selects = wrapper.findAll('.admin__status-select')
await selects[0].setValue('delivered')
await selects[0].trigger('change') await selects[0].trigger('change')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Server error') expect(wrapper.text()).toContain('Kunde inte uppdatera status')
}) })
it('formats dates in Swedish locale', async () => { it('formats dates in Swedish locale', async () => {
@ -265,7 +247,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -278,7 +260,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -292,7 +274,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[1].trigger('click') await expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -300,47 +282,32 @@ describe('AdminDashboard', () => {
expect(link.exists()).toBe(false) expect(link.exists()).toBe(false)
}) })
it('fires register-shipment API on register button click', async () => { it('fires PATCH on tracking save button click', async () => {
vi.mocked(globalThis.fetch) vi.mocked(globalThis.fetch).mockResolvedValueOnce(
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders())) mockFetchResponse(200, mockOrders),
.mockResolvedValueOnce(
mockFetchResponse(200, {
...mockOrders[1],
status: 'sent',
trackingId: 'PN999',
}),
) )
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[1].trigger('click') await expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
await wrapper.find('.admin__tracking-input').setValue('PN999') await wrapper.find('.btn--primary').trigger('click')
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)) await new Promise((r) => setTimeout(r, 50))
expect(globalThis.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12/register-shipment', '/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
expect.objectContaining({ expect.objectContaining({
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({
trackingInput: 'PN999',
notifyCustomer: true,
}),
}), }),
) )
}) })
it('shows tracking error on failed save', async () => { it('shows tracking error on failed save', async () => {
vi.mocked(globalThis.fetch) vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders())) .mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
.mockResolvedValueOnce( .mockResolvedValueOnce(
mockFetchResponse(500, { message: 'Server error' }), mockFetchResponse(500, { message: 'Server error' }),
) )
@ -348,18 +315,14 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[1].trigger('click') await expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
await wrapper.find('.admin__tracking-input').setValue('PN999') await wrapper.find('.btn--primary').trigger('click')
const registerBtn = wrapper
.findAll('button')
.find((btn) => btn.text() === 'Registrera utskick')
await registerBtn!.trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Kunde inte registrera utskick') expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID')
}) })
it('shows Att göra stat for processing orders', async () => { it('shows Att göra stat for processing orders', async () => {
@ -429,10 +392,7 @@ describe('AdminDashboard', () => {
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin__row') const rows = wrapper.findAll('.admin__row')
const processingRow = rows.find( const processingRow = rows.find((row) => row.text().includes('XYZ789'))
(row) =>
row.text().includes('XYZ789') && row.classes().includes('admin__row'),
)
expect(processingRow?.classes()).toContain('admin__row--todo') expect(processingRow?.classes()).toContain('admin__row--todo')
}) })
}) })

View file

@ -8,14 +8,10 @@ function createTestRouter() {
history: createMemoryHistory(), history: createMemoryHistory(),
routes: [ routes: [
{ {
path: '/om-oss', path: '/om',
name: 'about', name: 'about',
component: { template: '<div>About</div>' }, component: { template: '<div>About</div>' },
}, },
{
path: '/om',
redirect: '/om-oss',
},
{ {
path: '/kontakt', path: '/kontakt',
name: 'contact', name: 'contact',
@ -44,7 +40,7 @@ describe('AppFooter', () => {
const links = wrapper.findAll('a') const links = wrapper.findAll('a')
expect(links[0].text()).toBe('Om oss') expect(links[0].text()).toBe('Om oss')
expect(links[0].attributes('href')).toBe('/om-oss') expect(links[0].attributes('href')).toBe('/om')
expect(links[1].text()).toBe('Kontakt') expect(links[1].text()).toBe('Kontakt')
expect(links[1].attributes('href')).toBe('/kontakt') expect(links[1].attributes('href')).toBe('/kontakt')

View file

@ -30,11 +30,6 @@ function createTestRouter() {
name: 'change-password', name: 'change-password',
component: { template: '<div>Change password</div>' }, component: { template: '<div>Change password</div>' },
}, },
{
path: '/andra-epost',
name: 'change-email',
component: { template: '<div>Change email</div>' },
},
{ {
path: '/admin', path: '/admin',
name: 'admin', name: 'admin',
@ -105,7 +100,7 @@ describe('AppHeader', () => {
const wrapper = mount(AppHeader, { const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] }, global: { plugins: [router, createPinia()] },
}) })
expect(wrapper.find('.app-header__logout').exists()).toBe(false) expect(wrapper.find('button').exists()).toBe(false)
}) })
it('does not show user email', () => { it('does not show user email', () => {
@ -147,7 +142,7 @@ describe('AppHeader', () => {
it('shows logout button', () => { it('shows logout button', () => {
const { wrapper } = mountAuthenticated() const { wrapper } = mountAuthenticated()
const logoutButton = wrapper.find('.app-header__logout') const logoutButton = wrapper.find('button')
expect(logoutButton.exists()).toBe(true) expect(logoutButton.exists()).toBe(true)
expect(logoutButton.text()).toBe('Logga ut') expect(logoutButton.text()).toBe('Logga ut')
}) })
@ -176,62 +171,14 @@ describe('AppHeader', () => {
expect(ordersLink?.text()).toBe('Mina beställningar') expect(ordersLink?.text()).toBe('Mina beställningar')
}) })
it('shows settings menu with account links', async () => { it('shows change password link', () => {
const { wrapper } = mountAuthenticated() const { wrapper } = mountAuthenticated()
expect(wrapper.findAll('.app-header__settings-item')).toHaveLength(0) const links = wrapper.findAll('a')
const changeLink = links.find(
await wrapper.find('.app-header__settings-trigger').trigger('click') (a) => a.attributes('href') === '/andra-losenord',
const links = wrapper.findAll('.app-header__settings-item')
expect(links).toHaveLength(2)
expect(links[0].attributes('href')).toBe('/andra-epost')
expect(links[0].text()).toBe('Byt e-postadress')
expect(links[1].attributes('href')).toBe('/andra-losenord')
expect(links[1].text()).toBe('Byt lösenord')
})
it('toggles mobile menu open state when menu button is clicked', async () => {
const { wrapper } = mountAuthenticated()
await wrapper.find('.app-header__menu-toggle').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.classes()).toContain('app-header--menu-open')
expect(document.body.classList.contains('nav-menu-open')).toBe(true)
expect(wrapper.text()).toContain('Byt e-postadress')
})
it('highlights settings trigger on change password page', async () => {
const { wrapper, router } = mountAuthenticated()
await router.push('/andra-losenord')
await router.isReady()
await wrapper.vm.$nextTick()
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain(
'app-header__settings-trigger--active',
) )
}) expect(changeLink).toBeTruthy()
expect(changeLink?.text()).toBe('Byt lösenord')
it('highlights settings trigger on change email page', async () => {
const { wrapper, router } = mountAuthenticated()
await router.push('/andra-epost')
await router.isReady()
await wrapper.vm.$nextTick()
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain(
'app-header__settings-trigger--active',
)
})
it('does not highlight settings trigger on other pages', async () => {
const { wrapper, router } = mountAuthenticated()
await router.push('/orders')
await router.isReady()
await wrapper.vm.$nextTick()
expect(
wrapper.find('.app-header__settings-trigger').classes(),
).not.toContain('app-header__settings-trigger--active')
}) })
it('does not show admin link for regular user', () => { it('does not show admin link for regular user', () => {
@ -263,7 +210,7 @@ describe('AppHeader', () => {
resolve() resolve()
}) })
}) })
await wrapper.find('.app-header__logout').trigger('click') await wrapper.find('button').trigger('click')
await navigationDone await navigationDone
expect(auth.isAuthenticated).toBe(false) expect(auth.isAuthenticated).toBe(false)

View file

@ -1,57 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { setActivePinia, createPinia } from 'pinia'
import ChangeEmailPage from '@/pages/ChangeEmailPage.vue'
import { useAuthStore } from '@/stores/authStore'
function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
return `${header}.${body}.test-sig`
}
describe('ChangeEmailPage', () => {
it('renders current email and form fields', () => {
const pinia = createPinia()
setActivePinia(pinia)
localStorage.setItem(
'auth_token',
makeJwt({ sub: 'test@bilhej.se', role: 'user' }),
)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/andra-epost', component: ChangeEmailPage }],
})
const wrapper = mount(ChangeEmailPage, {
global: { plugins: [router, pinia] },
})
expect(wrapper.text()).toContain('Byt e-postadress')
expect(wrapper.text()).toContain('test@bilhej.se')
expect(wrapper.find('#new-email').exists()).toBe(true)
expect(wrapper.find('#password').exists()).toBe(true)
})
it('shows auth email from store', () => {
const pinia = createPinia()
setActivePinia(pinia)
localStorage.setItem(
'auth_token',
makeJwt({ sub: 'user@example.com', role: 'user' }),
)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/andra-epost', component: ChangeEmailPage }],
})
mount(ChangeEmailPage, {
global: { plugins: [router, pinia] },
})
expect(useAuthStore().email).toBe('user@example.com')
})
})

View file

@ -1,50 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { setActivePinia, createPinia } from 'pinia'
import ConfirmEmailChangePage from '@/pages/ConfirmEmailChangePage.vue'
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/bekrafta-epost',
name: 'confirm-email-change',
component: ConfirmEmailChangePage,
},
],
})
}
async function mountPage(initialPath: string) {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push(initialPath)
await router.isReady()
const wrapper = mount(ConfirmEmailChangePage, {
global: { plugins: [router, pinia] },
})
await flushPromises()
return { wrapper, router }
}
describe('ConfirmEmailChangePage', () => {
it('shows password form when token is present', async () => {
const { wrapper } = await mountPage('/bekrafta-epost?token=test-token')
expect(wrapper.text()).toContain('Bekräfta e-postadress')
expect(wrapper.text()).toContain('Ange ditt lösenord')
expect(wrapper.find('#password').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Bekräfta ny e-postadress',
)
})
it('shows error when token is missing', async () => {
const { wrapper } = await mountPage('/bekrafta-epost')
expect(wrapper.text()).toContain('Bekräftelselänken saknar en giltig kod.')
})
})

View file

@ -3,34 +3,8 @@ import { mount } from '@vue/test-utils'
import ContactPage from '@/pages/ContactPage.vue' import ContactPage from '@/pages/ContactPage.vue'
describe('ContactPage', () => { describe('ContactPage', () => {
it('renders heading and lead', () => { it('renders heading', () => {
const wrapper = mount(ContactPage) const wrapper = mount(ContactPage)
expect(wrapper.text()).toContain('Kontakta oss') expect(wrapper.text()).toContain('Kontakta oss')
expect(wrapper.text()).toContain('klagomål')
})
it('renders support email', () => {
const wrapper = mount(ContactPage)
expect(wrapper.text()).toContain('support@bilhej.se')
})
it('renders general contact email', () => {
const wrapper = mount(ContactPage)
expect(wrapper.text()).toContain('kontakt@bilhej.se')
})
it('renders complaints email', () => {
const wrapper = mount(ContactPage)
expect(wrapper.text()).toContain('klagomal@bilhej.se')
})
it('links support to mailto', () => {
const wrapper = mount(ContactPage)
const link = wrapper.find('a[href="mailto:support@bilhej.se"]')
expect(link.exists()).toBe(true)
expect(link.text()).toBe('support@bilhej.se')
expect(link.attributes('aria-label')).toBe(
'Skicka till support: support@bilhej.se',
)
}) })
}) })

View file

@ -1,149 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import EditOrderPage from '@/pages/EditOrderPage.vue'
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
vi.mock('@/api/orders', () => ({
fetchOrder: vi.fn(),
updateOrder: vi.fn(),
}))
import { fetchOrder, updateOrder } from '@/api/orders'
const mockFetchOrder = vi.mocked(fetchOrder)
const mockUpdateOrder = vi.mocked(updateOrder)
const pendingOrder = {
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
plate: 'DEF456',
letterText: 'Vill köpa din bil.',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2026-05-14T13:00:00Z',
}
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/orders',
name: 'orders',
component: { template: '<div>Orders</div>' },
},
{
path: '/bestallning/:orderId/redigera',
name: 'edit-order',
component: EditOrderPage,
},
{
path: '/betalning/:orderId',
name: 'payment',
component: PaymentRedirect,
},
],
})
}
async function mountPage(orderId = pendingOrder.id) {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({ name: 'edit-order', params: { orderId } })
await router.isReady()
const wrapper = mount(EditOrderPage, {
global: {
plugins: [router, pinia],
},
})
return { wrapper, router }
}
describe('EditOrderPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchOrder.mockResolvedValue(pendingOrder)
mockUpdateOrder.mockResolvedValue(pendingOrder)
})
it('shows loading state while fetching', async () => {
mockFetchOrder.mockImplementation(() => new Promise(() => {}))
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Laddar beställning...')
})
it('loads order and pre-fills textarea', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(mockFetchOrder).toHaveBeenCalledWith(pendingOrder.id)
})
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toBe('Vill köpa din bil.')
expect(wrapper.text()).toContain('DEF456')
expect(wrapper.text()).toContain('Redigera brev')
})
it('shows error when order is not pending_payment', async () => {
mockFetchOrder.mockResolvedValue({
...pendingOrder,
status: 'sent',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Den här beställningen kan inte redigeras',
)
})
expect(wrapper.find('textarea').exists()).toBe(false)
expect(wrapper.text()).toContain('Tillbaka till beställningar')
})
it('submit calls updateOrder and navigates to payment', async () => {
const { wrapper, router } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('textarea').exists()).toBe(true)
})
const textarea = wrapper.find('textarea')
await textarea.setValue('Uppdaterat meddelande')
const button = wrapper.find('button[type="submit"]')
await button.trigger('submit')
await vi.waitFor(() => {
expect(mockUpdateOrder).toHaveBeenCalledWith(
pendingOrder.id,
'Uppdaterat meddelande',
)
expect(router.currentRoute.value.name).toBe('payment')
expect(router.currentRoute.value.params.orderId).toBe(pendingOrder.id)
expect(router.currentRoute.value.query.plate).toBe('DEF456')
})
})
it('shows error message on update failure', async () => {
mockUpdateOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('textarea').exists()).toBe(true)
})
const textarea = wrapper.find('textarea')
await textarea.setValue('Uppdaterat meddelande')
const button = wrapper.find('button[type="submit"]')
await button.trigger('submit')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte spara ändringarna')
})
})
})

View file

@ -4,26 +4,6 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import OrdersPage from '@/pages/OrdersPage.vue' import OrdersPage from '@/pages/OrdersPage.vue'
const sessionMocks = vi.hoisted(() => {
const mockLogout = vi.fn()
const mockPush = vi.fn()
return {
mockLogout,
mockPush,
mockAuth: { isAuthenticated: true, logout: mockLogout },
}
})
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => sessionMocks.mockAuth,
}))
vi.mock('@/router', () => ({
default: {
currentRoute: { value: { fullPath: '/orders' } },
push: sessionMocks.mockPush,
},
}))
function mockFetchResponse(status: number, body: unknown) { function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({ return Promise.resolve({
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
@ -42,11 +22,6 @@ function createTestRouter() {
name: 'payment', name: 'payment',
component: { template: '<div>Payment</div>' }, component: { template: '<div>Payment</div>' },
}, },
{
path: '/bestallning/:orderId/redigera',
name: 'edit-order',
component: { template: '<div>Edit</div>' },
},
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } }, { path: '/', name: 'home', component: { template: '<div>Home</div>' } },
], ],
}) })
@ -83,39 +58,13 @@ const mockOrders = [
}, },
] ]
function mockOrdersFetch(orders: unknown) {
vi.mocked(globalThis.fetch).mockImplementation((url, init) => {
const urlStr = String(url)
const method = init?.method ?? 'GET'
if (urlStr.includes('/payment/swish-info')) {
return mockFetchResponse(200, { number: '1234567890', amount: 49 })
}
if (urlStr.includes('/cancel') && method === 'POST') {
return mockFetchResponse(200, {
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
plate: 'DEF456',
letterText: 'Vill köpa din bil.',
status: 'cancelled',
trackingId: null,
createdAt: '2026-05-14T13:00:00Z',
})
}
if (urlStr.includes('/orders')) {
return mockFetchResponse(200, orders)
}
return mockFetchResponse(404, { message: 'Not found' })
})
}
describe('OrdersPage', () => { describe('OrdersPage', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
globalThis.fetch = vi.fn() globalThis.fetch = vi.fn()
mockOrdersFetch(mockOrders) vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, mockOrders),
)
}) })
it('renders heading and subtitle', async () => { it('renders heading and subtitle', async () => {
@ -133,13 +82,6 @@ describe('OrdersPage', () => {
expect(wrapper.text()).toContain('Laddar beställningar...') expect(wrapper.text()).toContain('Laddar beställningar...')
}) })
it('shows section headings when pending and completed orders exist', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Obetalda beställningar')
expect(wrapper.text()).toContain('Tidigare beställningar')
})
it('fetches orders from API on mount', async () => { it('fetches orders from API on mount', async () => {
mountPage() mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -168,9 +110,7 @@ describe('OrdersPage', () => {
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const link = wrapper.find('a[href*="postnord"]') const link = wrapper.find('a[href*="postnord"]')
expect(link.exists()).toBe(true) expect(link.exists()).toBe(true)
expect(link.classes()).toContain('orders__tracking-btn')
expect(link.text()).toContain('PN123456789') expect(link.text()).toContain('PN123456789')
expect(link.text()).toContain('Spåra brev')
expect(link.attributes('target')).toBe('_blank') expect(link.attributes('target')).toBe('_blank')
}) })
@ -185,7 +125,9 @@ describe('OrdersPage', () => {
createdAt: '2026-05-14T13:00:00Z', createdAt: '2026-05-14T13:00:00Z',
}, },
] ]
mockOrdersFetch(ordersWithoutTracking) vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, ordersWithoutTracking),
)
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const link = wrapper.find('a[href*="postnord"]') const link = wrapper.find('a[href*="postnord"]')
@ -195,8 +137,9 @@ describe('OrdersPage', () => {
it('renders order id and message', async () => { it('renders order id and message', async () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11')
expect(wrapper.text()).toContain('Beställnings-ID') expect(wrapper.text()).toContain('Beställnings-ID')
expect(wrapper.text()).toContain('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11')
expect(wrapper.text()).toContain('Meddelande')
expect(wrapper.text()).toContain('Hej fin bil!') expect(wrapper.text()).toContain('Hej fin bil!')
expect(wrapper.text()).toContain('Vill köpa din bil.') expect(wrapper.text()).toContain('Vill köpa din bil.')
}) })
@ -205,24 +148,19 @@ describe('OrdersPage', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('2026') expect(wrapper.text()).toContain('2026')
expect(wrapper.text()).toContain('Skapad')
}) })
it('shows empty state when no orders', async () => { it('shows empty state when no orders', async () => {
mockOrdersFetch([]) vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, []))
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Inga beställningar ännu') expect(wrapper.text()).toContain('Inga beställningar ännu')
}) })
it('shows error state on API failure', async () => { it('shows error state on API failure', async () => {
vi.mocked(globalThis.fetch).mockImplementation((url) => { vi.mocked(globalThis.fetch).mockResolvedValue(
const urlStr = String(url) mockFetchResponse(500, { message: 'Internal server error' }),
if (urlStr.includes('/payment/swish-info')) { )
return mockFetchResponse(200, { number: '1234567890', amount: 49 })
}
return mockFetchResponse(500, { message: 'Internal server error' })
})
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Kunde inte hämta beställningar') expect(wrapper.text()).toContain('Kunde inte hämta beställningar')
@ -232,35 +170,19 @@ describe('OrdersPage', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const badges = wrapper.findAll('.badge') const badges = wrapper.findAll('.badge')
expect(badges[0].classes()).toContain('badge--warning') expect(badges[0].classes()).toContain('badge--success')
expect(badges[1].classes()).toContain('badge--success') expect(badges[1].classes()).toContain('badge--muted')
})
it('shows order id on pending payment orders', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const pendingCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('DEF456'))
expect(pendingCard?.text()).toContain('Beställnings-ID')
expect(pendingCard?.text()).toContain(
'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
)
}) })
it('shows pay button only for pending payment orders', async () => { it('shows pay button only for pending payment orders', async () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const pendingCard = wrapper const payLinks = wrapper.findAll('.orders__pay-btn')
.findAll('.orders__card') expect(payLinks).toHaveLength(1)
.find((card) => card.text().includes('DEF456')) expect(payLinks[0].text()).toBe('Betala nu')
const payLink = pendingCard?.find('a.orders__pay-btn')
expect(payLink?.exists()).toBe(true)
expect(payLink?.text()).toBe('Betala 49 kr')
const href = payLink?.attributes('href') const href = payLinks[0].attributes('href')
expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12') expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12')
expect(href).toContain('plate=DEF456') expect(href).toContain('plate=DEF456')
}) })
@ -272,71 +194,7 @@ describe('OrdersPage', () => {
const sentCard = wrapper const sentCard = wrapper
.findAll('.orders__card') .findAll('.orders__card')
.find((card) => card.text().includes('ABC123')) .find((card) => card.text().includes('ABC123'))
expect(sentCard?.find('a.orders__pay-btn').exists()).toBe(false) expect(sentCard?.find('.orders__pay-btn').exists()).toBe(false)
})
it('shows edit link for pending payment orders', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const pendingCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('DEF456'))
const editLink = pendingCard?.find('a.orders__edit-btn')
expect(editLink?.exists()).toBe(true)
expect(editLink?.text()).toBe('Redigera brev')
const href = editLink?.attributes('href')
expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12')
expect(href).toContain('redigera')
})
it('shows cancel button for pending payment orders', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const pendingCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('DEF456'))
const cancelBtn = pendingCard?.find('.orders__cancel-btn')
expect(cancelBtn?.exists()).toBe(true)
expect(cancelBtn?.text()).toBe('Avbryt beställning')
})
it('calls cancel API and updates status to Avbruten', async () => {
vi.stubGlobal(
'confirm',
vi.fn(() => true),
)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const pendingCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('DEF456'))
await pendingCard?.find('.orders__cancel-btn').trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12/cancel',
expect.objectContaining({ method: 'POST' }),
)
expect(wrapper.text()).toContain('Avbruten')
vi.unstubAllGlobals()
})
it('does not show edit or cancel actions for non-pending orders', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const sentCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('ABC123'))
expect(sentCard?.find('.orders__cancel-btn').exists()).toBe(false)
expect(sentCard?.text()).not.toContain('Redigera brev')
expect(sentCard?.text()).not.toContain('Avbryt beställning')
}) })
it('renders processing status correctly', async () => { it('renders processing status correctly', async () => {
@ -350,80 +208,13 @@ describe('OrdersPage', () => {
createdAt: '2026-05-15T10:00:00Z', createdAt: '2026-05-15T10:00:00Z',
}, },
] ]
mockOrdersFetch(ordersWithProcessing) vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, ordersWithProcessing),
)
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hanteras') expect(wrapper.text()).toContain('Hanteras')
const badge = wrapper.find('.badge') const badge = wrapper.find('.badge')
expect(badge.classes()).toContain('badge--primary') expect(badge.classes()).toContain('badge--primary')
}) })
it('shows expand toggle for long messages and reveals full text', async () => {
const longText =
'Hej! Jag ville nämna en situation i trafiken där vi båda kanske blev lite frustrerade. Det är lätt att det blir så när man kör bil i rusningstid och tempot blir högt.'
const ordersWithLongMessage = [
{
id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
plate: 'ABC123',
letterText: longText,
status: 'processing',
trackingId: null,
createdAt: '2026-05-11T12:00:00Z',
},
]
mockOrdersFetch(ordersWithLongMessage)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const card = wrapper.find('.orders__card')
const preview = card.find('.orders__preview')
const toggle = card.find('.orders__preview-toggle')
expect(toggle.exists()).toBe(true)
expect(toggle.text()).toBe('Visa mer')
expect(preview.classes()).not.toContain('orders__preview--expanded')
await toggle.trigger('click')
expect(preview.classes()).toContain('orders__preview--expanded')
expect(toggle.text()).toBe('Visa mindre')
expect(card.text()).toContain(longText)
})
it('does not show expand toggle for short messages', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false)
})
})
describe('OrdersPage — expired session (401)', () => {
beforeEach(() => {
localStorage.clear()
globalThis.fetch = vi.fn()
sessionMocks.mockLogout.mockClear()
sessionMocks.mockPush.mockClear()
sessionMocks.mockAuth.isAuthenticated = true
})
it('does not show generic error and triggers global logout/redirect on 401', async () => {
vi.mocked(globalThis.fetch).mockImplementation((url) => {
const urlStr = String(url)
if (urlStr.includes('/payment/swish-info')) {
return mockFetchResponse(200, { number: '123', amount: 49 })
}
return mockFetchResponse(401, { message: 'Din session har löpt ut.' })
})
localStorage.setItem('auth_token', 'expired-token')
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).not.toContain('Kunde inte hämta beställningar')
expect(sessionMocks.mockLogout).toHaveBeenCalledTimes(1)
expect(sessionMocks.mockPush).toHaveBeenCalledWith({
name: 'login',
query: { redirect: '/orders' },
})
})
}) })

View file

@ -5,26 +5,14 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import PaymentRedirect from '@/pages/PaymentRedirect.vue' import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import OrdersPage from '@/pages/OrdersPage.vue' import OrdersPage from '@/pages/OrdersPage.vue'
vi.mock('qrcode', () => ({
default: {
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,mock-qr'),
},
}))
vi.mock('@/api/payment', () => ({ vi.mock('@/api/payment', () => ({
payOrder: vi.fn(), payOrder: vi.fn(),
fetchSwishInfo: vi.fn(), fetchSwishInfo: vi.fn(),
buildSwishPaymentUrl: vi.fn(
(number: string, amount: number, message: string) =>
`https://app.swish.nu/1/p/sw/?sw=${number}&amt=${amount.toFixed(2)}&msg=${message}`,
),
})) }))
import { payOrder, fetchSwishInfo } from '@/api/payment' import { payOrder, fetchSwishInfo } from '@/api/payment'
import QRCode from 'qrcode'
const mockPayOrder = vi.mocked(payOrder) const mockPayOrder = vi.mocked(payOrder)
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo) const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
const mockToDataURL = vi.mocked(QRCode.toDataURL)
function createTestRouter() { function createTestRouter() {
return createRouter({ return createRouter({
@ -71,7 +59,6 @@ describe('PaymentRedirect', () => {
number: '0701234567', number: '0701234567',
amount: 49, amount: 49,
}) })
mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
}) })
it('renders heading and amount', async () => { it('renders heading and amount', async () => {
@ -94,7 +81,7 @@ describe('PaymentRedirect', () => {
expect(wrapper.text()).toContain('Beställnings-ID') expect(wrapper.text()).toContain('Beställnings-ID')
expect(wrapper.text()).toContain(orderId) expect(wrapper.text()).toContain(orderId)
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain(
'fylls i automatiskt via QR-kod eller länk', 'Ange beställnings-ID ovan som meddelande i Swish-appen',
) )
}) })
}) })
@ -106,30 +93,13 @@ describe('PaymentRedirect', () => {
}) })
}) })
it('renders QR code after fetching swish info', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
})
expect(mockToDataURL).toHaveBeenCalledTimes(1)
})
it('renders a Swish payment link', async () => {
const { wrapper } = await mountPage('test-order', 'ABC123')
await vi.waitFor(() => {
const link = wrapper.find('.payment__swish-link')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toContain('app.swish.nu')
})
})
it('shows confirmation dialog after clicking pay button', async () => { it('shows confirmation dialog after clicking pay button', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat') expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
expect(wrapper.text()).toContain('0701234567') expect(wrapper.text()).toContain('0701234567')
@ -140,15 +110,15 @@ describe('PaymentRedirect', () => {
it('can cancel confirmation dialog', async () => { it('can cancel confirmation dialog', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Avbryt') expect(wrapper.text()).toContain('Avbryt')
}) })
await wrapper.find('.payment__confirm-cancel').trigger('click') await wrapper.find('.btn--ghost').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Swisha till') expect(wrapper.text()).toContain('Swisha till')
expect(wrapper.text()).not.toContain('Avbryt') expect(wrapper.text()).not.toContain('Avbryt')
@ -167,15 +137,16 @@ describe('PaymentRedirect', () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') expect(wrapper.text()).toContain('Ja, jag har betalat')
}) })
await wrapper.find('.payment__confirm .btn--primary').trigger('click') const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
expect(mockPayOrder).toHaveBeenCalledWith('order-1') expect(mockPayOrder).toHaveBeenCalledWith('order-1')
}) })
@ -185,15 +156,16 @@ describe('PaymentRedirect', () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') expect(wrapper.text()).toContain('Ja, jag har betalat')
}) })
await wrapper.find('.payment__confirm .btn--primary').trigger('click') const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen') expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen')
@ -212,15 +184,16 @@ describe('PaymentRedirect', () => {
const { wrapper, router } = await mountPage() const { wrapper, router } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') expect(wrapper.text()).toContain('Ja, jag har betalat')
}) })
await wrapper.find('.payment__confirm .btn--primary').trigger('click') const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders') expect(router.currentRoute.value.name).toBe('orders')

View file

@ -1,64 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import PrivacyPolicyPage from '@/pages/PrivacyPolicyPage.vue'
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/integritetspolicy',
name: 'privacy',
component: PrivacyPolicyPage,
},
{
path: '/kontakt',
name: 'contact',
component: { template: '<div>Kontakt</div>' },
},
],
})
}
describe('PrivacyPolicyPage', () => {
it('renders title and lead', () => {
const router = createTestRouter()
const wrapper = mount(PrivacyPolicyPage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Integritetspolicy')
expect(wrapper.text()).toContain('personuppgifter')
})
it('describes sender and recipient data handling', () => {
const router = createTestRouter()
const wrapper = mount(PrivacyPolicyPage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Mottagarens postadress')
expect(wrapper.text()).toContain('sparas inte efter utskick')
expect(wrapper.text()).toContain('varken vi eller obehöriga')
})
it('describes web analytics', () => {
const router = createTestRouter()
const wrapper = mount(PrivacyPolicyPage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Webbstatistik')
expect(wrapper.text()).toContain('analytics.bilhej.se')
expect(wrapper.text()).toContain('IP-adresser')
})
it('links to contact email and contact page', () => {
const router = createTestRouter()
const wrapper = mount(PrivacyPolicyPage, {
global: { plugins: [router] },
})
expect(wrapper.find('a[href="mailto:kontakt@bilhej.se"]').exists()).toBe(
true,
)
expect(wrapper.find('a.policy__link').attributes('href')).toBe('/kontakt')
})
})

View file

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import router, { scrollBehavior } from '@/router' import router from '@/router'
describe('Router', () => { describe('Router', () => {
beforeEach(() => { beforeEach(() => {
@ -8,25 +8,6 @@ describe('Router', () => {
localStorage.clear() localStorage.clear()
}) })
it('scrolls to top on route change without hash', () => {
const position = scrollBehavior(
{ hash: '' } as Parameters<typeof scrollBehavior>[0],
{ hash: '' } as Parameters<typeof scrollBehavior>[1],
null,
)
expect(position).toEqual({ top: 0, left: 0 })
})
it('restores saved position when using browser back', () => {
const saved = { top: 120, left: 0 }
const position = scrollBehavior(
{ hash: '' } as Parameters<typeof scrollBehavior>[0],
{ hash: '' } as Parameters<typeof scrollBehavior>[1],
saved,
)
expect(position).toBe(saved)
})
it('resolves / to HomePage', async () => { it('resolves / to HomePage', async () => {
await router.push('/') await router.push('/')
await router.isReady() await router.isReady()
@ -51,18 +32,6 @@ describe('Router', () => {
expect(router.currentRoute.value.name).toBe('forgot-password') expect(router.currentRoute.value.name).toBe('forgot-password')
}) })
it('resolves /integritetspolicy to PrivacyPolicyPage', async () => {
await router.push('/integritetspolicy')
await router.isReady()
expect(router.currentRoute.value.name).toBe('privacy')
})
it('resolves /villkor to TermsOfServicePage', async () => {
await router.push('/villkor')
await router.isReady()
expect(router.currentRoute.value.name).toBe('terms')
})
it('resolves /aterstall-losenord to ResetPasswordPage', async () => { it('resolves /aterstall-losenord to ResetPasswordPage', async () => {
await router.push('/aterstall-losenord?token=abc') await router.push('/aterstall-losenord?token=abc')
await router.isReady() await router.isReady()
@ -83,13 +52,6 @@ describe('Router', () => {
expect(router.currentRoute.value.name).toBe('change-password') expect(router.currentRoute.value.name).toBe('change-password')
}) })
it('resolves /andra-epost to ChangeEmailPage when authenticated', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/andra-epost')
await router.isReady()
expect(router.currentRoute.value.name).toBe('change-email')
})
it('resolves /admin to AdminPage for admin user', async () => { it('resolves /admin to AdminPage for admin user', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'admin' })) localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
await router.push('/admin') await router.push('/admin')
@ -131,19 +93,6 @@ describe('Router guards', () => {
expect(router.currentRoute.value.query.redirect).toBe('/andra-losenord') expect(router.currentRoute.value.query.redirect).toBe('/andra-losenord')
}) })
it('redirects unauthenticated user from /andra-epost to /logga-in', async () => {
await router.push('/andra-epost')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/andra-epost')
})
it('resolves /bekrafta-epost to ConfirmEmailChangePage', async () => {
await router.push('/bekrafta-epost?token=abc')
await router.isReady()
expect(router.currentRoute.value.name).toBe('confirm-email-change')
})
it('redirects unauthenticated user from /admin to /logga-in', async () => { it('redirects unauthenticated user from /admin to /logga-in', async () => {
await router.push('/admin') await router.push('/admin')
await router.isReady() await router.isReady()
@ -214,64 +163,6 @@ describe('Router guards', () => {
}) })
}) })
describe('Router guards — expired tokens', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('redirects expired-token user from /orders to /logga-in with redirect query', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/orders')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/orders')
})
it('clears the expired token from localStorage on redirect', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/orders')
await router.isReady()
expect(localStorage.getItem('auth_token')).toBeNull()
})
it('allows access with a token whose exp is in the future', async () => {
const future = Math.floor(Date.now() / 1000) + 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
await router.push('/orders')
await router.isReady()
expect(router.currentRoute.value.name).toBe('orders')
})
it('lets expired-token user open /logga-in instead of bouncing to home', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/logga-in')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
})
it('lets expired-token user open /registrera instead of bouncing to home', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/registrera')
await router.isReady()
expect(router.currentRoute.value.name).toBe('register')
})
})
function makeJwt(payload: Record<string, unknown>): string { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View file

@ -1,58 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import TermsOfServicePage from '@/pages/TermsOfServicePage.vue'
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/villkor',
name: 'terms',
component: TermsOfServicePage,
},
{
path: '/integritetspolicy',
name: 'privacy',
component: { template: '<div>Integritet</div>' },
},
{
path: '/kontakt',
name: 'contact',
component: { template: '<div>Kontakt</div>' },
},
],
})
}
describe('TermsOfServicePage', () => {
it('renders title and lead', () => {
const router = createTestRouter()
const wrapper = mount(TermsOfServicePage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Användarvillkor')
expect(wrapper.text()).toContain('49 kr')
})
it('describes payment and order rules', () => {
const router = createTestRouter()
const wrapper = mount(TermsOfServicePage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Swish')
expect(wrapper.text()).toContain('Obetalda beställningar kan redigeras')
})
it('links to privacy policy and support email', () => {
const router = createTestRouter()
const wrapper = mount(TermsOfServicePage, {
global: { plugins: [router] },
})
expect(wrapper.find('a[href="/integritetspolicy"]').exists()).toBe(true)
expect(wrapper.find('a[href="mailto:support@bilhej.se"]').exists()).toBe(
true,
)
})
})

View file

@ -212,52 +212,6 @@ describe('authStore', () => {
}) })
}) })
describe('authStore.isTokenExpired', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('returns true when there is no token', () => {
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(true)
})
it('returns true for a token with an expired exp claim', () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(true)
})
it('returns false for a token with a future exp claim', () => {
const future = Math.floor(Date.now() / 1000) + 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(false)
})
it('returns false for a token without an exp claim', () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(false)
})
it('returns true after logout clears the token', async () => {
const future = Math.floor(Date.now() / 1000) + 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(false)
store.logout()
expect(store.isTokenExpired()).toBe(true)
})
})
function makeJwt(payload: Record<string, unknown>): string { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View file

@ -1,125 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mocks = vi.hoisted(() => {
const mockLogout = vi.fn()
const mockPush = vi.fn()
return {
mockLogout,
mockPush,
mockAuth: { isAuthenticated: true, logout: mockLogout },
}
})
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => mocks.mockAuth,
}))
vi.mock('@/router', () => ({
default: {
currentRoute: { value: { fullPath: '/orders' } },
push: mocks.mockPush,
},
}))
import { request, ApiError, isSessionExpired, isForbidden } from '@/api/client'
function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
})
}
describe('api client', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
globalThis.fetch = vi.fn()
mocks.mockLogout.mockClear()
mocks.mockPush.mockClear()
mocks.mockAuth.isAuthenticated = true
})
it('logs out and redirects to login on 401 from a protected endpoint', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
)
localStorage.setItem('auth_token', 'expired-token')
await expect(request('/orders')).rejects.toThrow('Din session har löpt ut.')
expect(mocks.mockLogout).toHaveBeenCalledTimes(1)
expect(mocks.mockPush).toHaveBeenCalledWith({
name: 'login',
query: { redirect: '/orders' },
})
})
it('still throws ApiError with 401 status after handling expired session', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
)
try {
await request('/orders')
throw new Error('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(ApiError)
expect((err as ApiError).status).toBe(401)
}
})
it('does not log out on 401 from an auth endpoint (wrong credentials)', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
)
await expect(request('/auth/login', { method: 'POST' })).rejects.toThrow(
'Felaktig e-post eller lösenord',
)
expect(mocks.mockLogout).not.toHaveBeenCalled()
expect(mocks.mockPush).not.toHaveBeenCalled()
})
it('does not log out on 403 (forbidden is not session expiry)', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(403, { message: 'Du har inte behörighet' }),
)
localStorage.setItem('auth_token', 'valid-token')
await expect(request('/admin/orders')).rejects.toThrow(
'Du har inte behörighet',
)
expect(mocks.mockLogout).not.toHaveBeenCalled()
expect(mocks.mockPush).not.toHaveBeenCalled()
})
it('does not redirect when there is no token on 401', async () => {
mocks.mockAuth.isAuthenticated = false
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
)
await expect(request('/orders')).rejects.toThrow()
expect(mocks.mockLogout).not.toHaveBeenCalled()
expect(mocks.mockPush).not.toHaveBeenCalled()
})
it('isSessionExpired returns true only for a 401 ApiError', () => {
expect(isSessionExpired(new ApiError(401, 'x'))).toBe(true)
expect(isSessionExpired(new ApiError(403, 'x'))).toBe(false)
expect(isSessionExpired(new ApiError(500, 'x'))).toBe(false)
expect(isSessionExpired(new Error('x'))).toBe(false)
expect(isSessionExpired(null)).toBe(false)
})
it('isForbidden returns true only for a 403 ApiError', () => {
expect(isForbidden(new ApiError(403, 'x'))).toBe(true)
expect(isForbidden(new ApiError(401, 'x'))).toBe(false)
expect(isForbidden(new Error('x'))).toBe(false)
})
})

View file

@ -1,72 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createRouter, createMemoryHistory } from 'vue-router'
import {
getUmamiConfig,
initUmamiAnalytics,
trackUmamiPageview,
} from '@/utils/umami'
describe('umami', () => {
beforeEach(() => {
document.head.innerHTML = ''
delete window.umami
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('returns null when website id is unset', () => {
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '')
expect(getUmamiConfig()).toBeNull()
})
it('returns config when website id is set', () => {
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '11111111-2222-3333-4444-555555555555')
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', '')
expect(getUmamiConfig()).toEqual({
websiteId: '11111111-2222-3333-4444-555555555555',
scriptUrl: 'https://analytics.bilhej.se/script.js',
})
})
it('uses custom script url when provided', () => {
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'test-id')
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://example.test/script.js')
expect(getUmamiConfig()?.scriptUrl).toBe('https://example.test/script.js')
})
it('does not inject script when website id is unset', () => {
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '')
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div />' } }],
})
initUmamiAnalytics(router)
expect(document.querySelector('script[data-website-id]')).toBeNull()
})
it('injects script with auto-track disabled when configured', () => {
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'test-id')
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div />' } }],
})
initUmamiAnalytics(router)
const script = document.querySelector('script[data-website-id]')
expect(script?.getAttribute('data-website-id')).toBe('test-id')
expect(script?.getAttribute('data-auto-track')).toBe('false')
expect(script?.getAttribute('src')).toContain('script.js')
})
it('trackUmamiPageview forwards url to umami', () => {
const track = vi.fn()
window.umami = { track }
trackUmamiPageview('/orders')
expect(track).toHaveBeenCalledOnce()
const mapper = track.mock.calls[0][0] as (
props: Record<string, unknown>,
) => Record<string, unknown>
expect(mapper({ referrer: 'x' })).toEqual({ referrer: 'x', url: '/orders' })
})
})

View file

@ -8,11 +8,7 @@ export interface AdminOrder {
status: string status: string
trackingId: string | null trackingId: string | null
amountPaid: number | null amountPaid: number | null
shippedAt: string | null
adminNotes: string | null
createdAt: string createdAt: string
allowedStatuses: string[]
canRegisterShipment: boolean
} }
export function fetchAllOrders(): Promise<AdminOrder[]> { export function fetchAllOrders(): Promise<AdminOrder[]> {
@ -29,23 +25,12 @@ export function updateOrderStatus(
}) })
} }
export function registerShipment( export function updateTracking(
orderId: string, orderId: string,
trackingInput: string, trackingId: string | null,
notifyCustomer = true,
): Promise<AdminOrder> { ): Promise<AdminOrder> {
return request<AdminOrder>(`/admin/orders/${orderId}/register-shipment`, { return request<AdminOrder>(`/admin/orders/${orderId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ trackingInput, notifyCustomer }), body: JSON.stringify({ trackingId }),
})
}
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

@ -25,11 +25,6 @@ export interface MessageResponse {
message: string message: string
} }
/** Optional testToken is returned only when backend expose-token is enabled (E2E). */
export interface ChangeEmailResponse extends MessageResponse {
testToken?: string
}
/** Optional testToken is returned only when backend expose-token is enabled (E2E). */ /** Optional testToken is returned only when backend expose-token is enabled (E2E). */
export interface ForgotPasswordResponse extends MessageResponse { export interface ForgotPasswordResponse extends MessageResponse {
testToken?: string testToken?: string
@ -61,23 +56,3 @@ export function changePassword(
body: JSON.stringify({ currentPassword, newPassword }), body: JSON.stringify({ currentPassword, newPassword }),
}) })
} }
export function changeEmail(
newEmail: string,
password: string,
): Promise<ChangeEmailResponse> {
return request<ChangeEmailResponse>('/auth/change-email', {
method: 'POST',
body: JSON.stringify({ newEmail, password }),
})
}
export function confirmEmailChange(
token: string,
password: string,
): Promise<AuthResponse> {
return request<AuthResponse>('/auth/confirm-email-change', {
method: 'POST',
body: JSON.stringify({ token, password }),
})
}

View file

@ -1,6 +1,3 @@
import { useAuthStore } from '@/stores/authStore'
import router from '@/router'
const API_BASE = import.meta.env.VITE_API_URL || '/api' const API_BASE = import.meta.env.VITE_API_URL || '/api'
export class ApiError extends Error { export class ApiError extends Error {
@ -13,27 +10,10 @@ export class ApiError extends Error {
} }
} }
export function isSessionExpired(err: unknown): boolean {
return err instanceof ApiError && err.status === 401
}
export function isForbidden(err: unknown): boolean {
return err instanceof ApiError && err.status === 403
}
function getToken(): string | null { function getToken(): string | null {
return localStorage.getItem('auth_token') return localStorage.getItem('auth_token')
} }
function handleExpiredSession(): void {
const auth = useAuthStore()
if (auth.isAuthenticated) {
auth.logout()
const redirect = router.currentRoute.value.fullPath
router.push({ name: 'login', query: { redirect } })
}
}
export async function request<T>( export async function request<T>(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},
@ -54,9 +34,6 @@ export async function request<T>(
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && !url.startsWith('/auth/')) {
handleExpiredSession()
}
const body = await response.json().catch(() => ({})) const body = await response.json().catch(() => ({}))
throw new ApiError(response.status, body.message || 'Något gick fel') throw new ApiError(response.status, body.message || 'Något gick fel')
} }

Some files were not shown because too many files have changed in this diff Show more