Compare commits

...

52 commits

Author SHA1 Message Date
1a9d2fe688 Merge pull request 'feat(payment): Swish QR code and pre-filled payment link' (#15) from feature/swish-qr-payment into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m20s
CI / E2E browser tests (push) Successful in 3m31s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/15
2026-06-19 15:38:18 +00:00
Hermes Agent
d9aa2d60af fix(e2e): use unique plate in QR code test to avoid admin row collision
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m11s
CI / E2E browser tests (pull_request) Successful in 3m29s
The new 'shows QR code for desktop scanning' E2E test used plate JKL012,
which is the same plate seeded as a processing order in the dev migrations
(V7__seed_processing_order.sql) and used by the admin dashboard/fulfillment
tests as PROCESSING_PLATE.

Because the E2E chromium (parallel) tests run before the chromium-serial
tests, the QR test created a second order with plate JKL012. When the serial
admin tests then searched for rows matching JKL012, Playwright's strict
mode found 2 matching rows and threw a strict mode violation.

This caused 4 test failures + 2 skipped tests:
- admin-dashboard: click row shows tracking section
- admin-dashboard: click row again collapses it
- admin-dashboard: expanded row shows tracking input and save button
- admin-fulfillment: can register shipment for processing order
- admin-fulfillment: can mark sent order as delivered (skipped)
- admin-fulfillment: can mark delivered order as failed then back to sent (skipped)

Changed the plate to QRA222 — not used in any seed data or other E2E test.
2026-06-19 14:04:26 +00:00
Hermes Agent
00d1f48218 feat(payment): Swish QR code and pre-filled payment link
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m29s
CI / E2E browser tests (pull_request) Failing after 3m49s
Replace manual "type the number and order ID" flow with:
- Client-side QR code (qrcode npm package) for desktop users
- Pre-filled Swish payment URL (app.swish.nu) for mobile users
- Manual number fallback + "Jag har betalat" confirmation

The Swish C2B URL scheme pre-fills amount and message (order ID)
without requiring any Swish Commerce API certificate or bank agreement.

Supports both personal phone numbers (070...) and Swish Företag
business numbers (123...) via number normalization in buildSwishPaymentUrl().

Set SWISH_NUMBER in .env to a Företags number once set up.
2026-06-19 12:06:29 +00:00
56cbc6fedf Merge pull request 'develop' (#14) from develop into master
Some checks failed
CI / E2E browser tests (push) Has been cancelled
CI / Lint, type check, unit tests, coverage (push) Has been cancelled
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/14
2026-06-17 13:45:03 +00:00
f60af7237e Merge pull request 'Autofill deploy version from latest git tag' (#13) from feature/auto-version-from-tag into develop
Some checks failed
CI / E2E browser tests (push) Has been cancelled
CI / Lint, type check, unit tests, coverage (push) Has been cancelled
CI / E2E browser tests (pull_request) Has been cancelled
CI / Lint, type check, unit tests, coverage (pull_request) Has been cancelled
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/13
2026-06-17 13:44:28 +00:00
9a63ff69e7 Autofill deploy version from latest git tag instead of hardcoded v0.1.0
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m6s
CI / E2E browser tests (pull_request) Successful in 3m20s
The deploy.yml workflow_dispatch input always defaulted to 'v0.1.0',
requiring manual edit every time. Now the version defaults to 'auto',
which fetches all tags, finds the latest v* tag via semver sort, bumps
the patch component, and uses that as the deploy tag.

Changes:
- deploy.yml input: default changed to 'auto', required → false,
  description updated to explain both auto and manual modes
- Added 'Resolve version' step: fetches tags, bumps latest semver
  tag by patch, validates output format, exports to $VERSION
- 'Tag version' step: substituted ${{ github.event.inputs.version }}
  → ${{ env.VERSION }} to use the resolved/computed version
- 'Print deploy status' step: same substitution
- Semver validation guard rejects malformed tags (auto and manual)
2026-06-17 15:00:27 +02:00
d7739bcd58 Merge pull request 'fix(admin): restore broken table styling after focused-modules refactor' (#12) from fix/admin-table-styling into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m6s
CI / E2E browser tests (push) Successful in 3m21s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/12
2026-06-17 12:39:39 +00:00
Hermes Agent
4b35d8ff30 fix(admin): restore broken table styling after focused-modules refactor
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 6m8s
CI / E2E browser tests (pull_request) Successful in 5m53s
The c7eeaf6 refactor split the monolithic AdminPage into AdminStatsBar,
AdminOrdersTable, and AdminOrderDetailPanel. All CSS rules for the admin
sub-components were left in AdminPage.vue's `<style scoped>` block. Vue 3
scoped styles only apply to elements in the parent's own template — they
do not penetrate multi-root child components. Every element rendered by
the extracted sub-components lost its styling (stats grid, table layout,
column widths, status badges, expand icons, row highlighting, expanded
detail panels, etc.), making the admin page appear visually broken.

Changes:
- frontend/src/pages/AdminPage.vue: remove `scoped` attribute from the
  `<style>` block. All selectors are BEM-namespaced under `.admin__*`
  so making them global is safe and is the minimal fix.

Visual verification: N/A (sandbox cannot reach production). See git diff
for the one-character change.
2026-06-17 11:29:24 +00:00
c88fa142d3 Merge pull request 'Log out users automatically when their JWT expires.' (#11) from feature/expired-token-logout into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m38s
CI / E2E browser tests (push) Successful in 3m27s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/11
2026-06-17 10:44:18 +00:00
81e3968e31 Log out users automatically when their JWT expires.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m11s
CI / E2E browser tests (pull_request) Successful in 3m57s
Previously an expired token left the frontend in a stuck state: the
router guard only checked token presence (never the exp claim), so the
user could still navigate to protected pages, and every API call then
failed with a generic Swedish "Kunde inte hämta…" message while the
header kept showing the logged-in UI. There was no global response
interceptor, and the backend returned an ambiguous 403 (no body) for
unauthenticated requests because no AuthenticationEntryPoint was
configured, making 403 mean both "no/invalid token" and "forbidden".

Backend:
- Add an AuthenticationEntryPoint in SecurityConfig that returns 401
  with a Swedish {"message": ...} ErrorResponse body for
  unauthenticated/expired-token requests, and an AccessDeniedHandler
  returning 403 with the same body shape for genuine authorization
  failures. This makes 401 = not authenticated/expired and
  403 = authenticated but forbidden, the standard REST convention.
- Make JwtService(String, long) constructor public so integration
  tests can mint expired tokens (was package-private).
- Update the 6 no-auth controller tests from 403 to 401
  (OrderControllerTest, AdminControllerTest, PaymentControllerTest,
  AuthControllerTest change-password/change-email) and assert the
  message body exists; keep shouldReturn403ForNonAdminUser as 403.
- Add OrderControllerTest.shouldReturn401WithSwedishMessageWhenTokenExpired
  (expired JWT via TTL -1000ms) and shouldReturn401WithMessageWhenNoAuthHeader.

Frontend:
- Add isTokenExpired() to utils/jwt.ts using the previously-unused exp
  claim, and expose it on the auth store.
- Add a global 401 interceptor in api/client.ts: on a 401 from any
  non-/auth/ endpoint, call auth.logout() and redirect to
  /logga-in?redirect=<currentPath>. Skip /auth/ so wrong-password 401s
  on login/change-password stay handled locally. Add isSessionExpired
  and isForbidden helpers for per-page catch blocks.
- Harden the router guard to reject tokens whose exp is in the past
  (logout + redirect to login with ?redirect=), and let expired-token
  users open /logga-in and /registrera instead of bouncing to home.
- Refactor the generic-error catch blocks on OrdersPage, EditOrderPage,
  ComposePage, PaymentRedirect, useAdminOrders, and useAdminOrderActions
  to skip the generic Swedish message on 401 (handled globally) while
  preserving wrong-password 401 handling on change-pw/email pages.

Tests:
- New frontend/src/__tests__/client.spec.ts covering 401 -> logout +
  redirect, 401 from /auth/ -> no logout, 403 -> no logout, no-token
  401 -> no redirect, and isSessionExpired/isForbidden helpers.
- Add authStore.spec.ts cases for isTokenExpired (no token, past exp,
  future exp, missing exp, after logout).
- Add Router.spec.ts cases for expired-token redirects, token clearing,
  future-exp access, and guest pages not bouncing expired users.
- Add OrdersPage.spec.ts case asserting 401 triggers no generic error
  and the global logout/redirect.
- New E2E expired-token.spec.ts (Docker) covering both the router-guard
  expired-token redirect and the API-401 redirect, with logged-out
  header and cleared localStorage assertions.
- Mock the API in two pre-existing fake-JWT E2E tests
  (auth-guards admin access, header-auth logout redirect) that broke
  because the backend now correctly 401s their unsigned test-sig tokens.

Verified with ./gradlew check (frontend lint + 267 unit tests, backend
tests + coverage, Flyway, 92 E2E tests in Docker) and ./gradlew coverage;
all coverage thresholds maintained (jwt.ts at 100%).
2026-06-17 12:43:31 +02:00
5335ba4f12 Merge pull request 'chore: make dev Dockerfiles self-contained, add bindless dev override' (#10) from chore/dockerfile-self-contained into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m33s
CI / E2E browser tests (push) Successful in 3m22s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/10
2026-06-17 10:34:03 +00:00
Hermes Agent
3d2db1471f fixup: keep docker/*.conf and docker/entrypoint.sh in build context
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m42s
CI / E2E browser tests (pull_request) Successful in 3m27s
Review feedback on PR #10: excluding the whole docker/ directory broke
frontend.prod.Dockerfile, which copies docker/nginx.conf and
docker/entrypoint.sh into the production nginx image.

- Replace docker/ with docker/*.Dockerfile so only the Dockerfiles are
  removed from the build context.
- Restore docker-compose*.yml exclusion.
- Correct the header comment to reflect that dev Dockerfiles COPY source
  subpaths, not the entire repo root.

Verified: docker compose -f docker-compose.prod.yml build frontend
succeeds and both COPY docker/... steps complete.
2026-06-17 10:29:47 +00:00
Hermes Agent
da54a67d9d chore: make dev Dockerfiles self-contained, add bindless dev override
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m26s
CI / E2E browser tests (pull_request) Successful in 4m14s
Why
---
The dev compose (docker-compose.yml) assumes the Docker daemon can bind-mount
the host repo (and several subpaths) at runtime, providing live source for
`gradle :backend:bootRun` and Vite HMR. That works on a normal Linux/macOS
host but breaks in:

  - Docker-in-Docker setups (e.g. the Hermes sandbox used for agent work)
  - rootless Docker with restricted mount paths
  - some CI runners

The failure mode is the daemon's mount namespace only sees compose-created
named-volume subdirs at the bind source, not the real repo files. The
backend then fails with `stat ./gradlew: no such file or directory` and
the frontend fails with `mount src=.../index.html, dst=.../index.html
... not a directory`. The image itself is empty of source — there are no
`COPY` lines in the dev Dockerfiles.

Approach
--------
Make the dev images self-sufficient by COPYing the source at build time.
The compose bind mount is kept (it's still the right thing for normal
local dev with HMR), but it's no longer load-bearing. The image works
standalone in any environment.

Add a separate `docker-compose.dev-bindless.yml` for environments where
host bind mounts can't be used (DinD, CI, restricted Docker). It uses
the same images (COPY'd source) but redefines the services with no
host bind mounts — only the named cache volumes remain, so gradle and
Vite caches persist between `up` cycles.

Compose merge semantics caveat: `volumes:` lists merge by concatenation,
not by entry replacement, so the bindless workflow can't be expressed as
a compose override on top of docker-compose.yml. A standalone file is
required.

Changes
-------
* docker/backend.Dockerfile
  - Add `COPY gradlew settings.gradle build.gradle ./`
  - Add `COPY gradle/ gradle/`
  - Add `RUN chmod +x gradlew`
  - Add `COPY backend/ backend/`
  - Add `EXPOSE 8080`
  - Keep ENTRYPOINT unchanged.
  - New image is runnable with `docker run bilhej-backend-dev` (no bind
    mount needed) and works under `docker compose up -d` on any host.

* docker/frontend.Dockerfile
  - Add comments documenting the two-stage COPY pattern (deps first for
    layer cache, then full source).
  - Keep the existing structure — it already COPYs the source, just
    wasn't being relied on. Now bind-mount failures (e.g. index.html
    type mismatch in DinD) don't kill the container; the COPY'd file
    is already in place.
  - Add `EXPOSE 3000` (was missing).

* .dockerignore
  - Expand to exclude everything that isn't strictly needed at build or
    run time: docs, scripts, git, editor config, build outputs, test
    results, logs, env files, docker-related metadata, etc.
  - Cuts the build context from ~MBs to ~800 KB (verified).
  - Image contents are now: gradlew + wrapper, build.gradle, settings,
    gradle/, backend/ (for backend image); package.json, package-lock,
    src/, public/, index.html, node_modules (for frontend image).

* docker-compose.dev-bindless.yml (new)
  - Standalone variant of docker-compose.yml with all host bind mounts
    removed. Same service definitions, same image tags, same env vars,
    same named cache volumes (pgdata, gradle-cache, backend-gradle-
    project, backend-build). Only differences: no `.:/app`, no
    `./frontend/src:/app/src`, no `./frontend/public:/app/public`, no
    `./frontend/index.html:/app/index.html`.
  - Usage: `docker compose -f docker-compose.dev-bindless.yml up -d`
    (no `--build` needed if images already exist; include `--build`
    on first run or after pulling changes).
  - Trade-off vs the default dev compose: image is "frozen" at build
    time, so editing source on the host doesn't trigger HMR. Edit +
    `docker compose up -d --build` (or just rebuild the relevant
    service) to pick up changes. Named cache volumes still keep
    gradle/npm caches warm across rebuilds.

* e2e compose (docker-compose.e2e.yml, docker/*.e2e.Dockerfile) —
  unchanged. They were already self-contained and continue to work as
  before. Verified by running the full 90/90 Playwright suite in 54s.

Compatibility with existing dev workflow
----------------------------------------
On a normal host where bind mounts work (the common case):

  - `docker compose up -d` (the existing command) keeps working
    unchanged. The bind mount on `.:/app` overlays the COPY'd source
    at runtime, so HMR and `gradle :backend:bootRun` hot-reload work
    exactly like before.
  - Image size grows (~50 MB backend, ~50 MB frontend on top of base
    image; ~200 MB including node_modules). Acceptable for dev.
  - First-time `docker compose build` is slightly slower because it
    has to COPY the source. Subsequent builds cache well: the COPY
    layer invalidates only when source files change.

Verified
--------
  - Hermes DinD sandbox: bindless dev stack (`docker-compose.dev-
    bindless.yml`) brings up postgres + mailpit + backend + frontend
    with no bind mounts. Spring Boot starts in ~6s, Vite dev server
    in ~700ms. Backend serves real API responses
    (`GET /api/vehicles/ABC123 -> 404 Inget fordon hittades`).
  - Hermes DinD sandbox: e2e stack runs all 90 Playwright tests in
    ~54s, identical to pre-patch behavior.
  - Docker image self-sufficiency: `docker run --rm bilhej-backend-dev`
    and `docker run --rm bilhej-frontend-dev` both work without any
    bind mounts.

Refs: project AGENTS.md (Docker section, gradle check pre-commit).
2026-06-17 09:44:18 +00:00
aa2cb7c4a0 Merge pull request 'Add production-only Umami analytics for bilhej.se.' (#9) from feature/umami-analytics into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m17s
CI / E2E browser tests (push) Successful in 3m28s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/9
2026-06-01 10:10:19 +00:00
737bc3dc64 Add production-only Umami analytics for bilhej.se.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m8s
CI / E2E browser tests (pull_request) Successful in 3m29s
Enable pageview tracking when VITE_UMAMI_WEBSITE_ID is set at frontend
build time (Forgejo secret + deploy workflow), with SPA route updates
and no script in local dev. Document setup in docs/umami-analytics.md,
extend integritetspolicy, and add admin Webbstatistik link in prod builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 12:02:14 +02:00
fa7e48fe02 Merge pull request 'Refactor admin fulfillment into focused modules.' (#8) from refactor/admin-fulfillment into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m5s
CI / E2E browser tests (push) Successful in 3m21s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/8
2026-05-28 12:48:44 +00:00
c7eeaf6a6b Refactor admin fulfillment into focused modules.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m9s
CI / E2E browser tests (pull_request) Successful in 4m1s
Extract AdminOrderWorkflowService and status rules API; split AdminPage
into composables and components; share order status constants; update tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 14:34:03 +02:00
0b2c58fa82 Merge pull request 'Add admin order fulfillment tracking.' (#7) from feature/admin-fulfillment-tracking into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m10s
CI / E2E browser tests (push) Successful in 3m24s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/7
2026-05-28 12:16:59 +00:00
aec7020621 Stabilize CI E2E: serial admin specs and no shared DB races.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m6s
CI / E2E browser tests (pull_request) Successful in 3m27s
Run admin-dashboard with other DB/Mailpit specs after parallel tests.
Stop admin-dashboard from mutating the sent seed order before fulfillment.
Wait longer for backend readiness in the E2E stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 08:47:16 +02:00
623433ba4d Format AdminPage.vue with Prettier.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m16s
CI / E2E browser tests (pull_request) Failing after 1m17s
No behavior change; satisfies frontend lint formatting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 18:53:33 +02:00
c578463b10 Add mandatory pre-commit full check for agents and devs.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m6s
CI / E2E browser tests (pull_request) Failing after 1m8s
Document that ./gradlew check must pass before every commit. Add scripts
to run the same verification as CI and optionally install a git hook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 13:43:35 +02:00
afa552e18b Run Mailpit E2E specs serially to stop flakes.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m7s
CI / E2E browser tests (pull_request) Failing after 1m17s
account-settings and password-reset called clearMailpit in parallel with
other tests, wiping emails before waitForEmailChangeToken could read them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 13:24:26 +02:00
2fa161f4fa Fix frontend tests after admin status error UX.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m4s
CI / E2E browser tests (pull_request) Failing after 1m9s
Align AdminDashboard unit test with API error messages shown in UI.
Stabilize account-settings E2E by relying on waitForEmailChangeToken only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 13:00:28 +02:00
5b5b44194d Fix V7 dev seed for H2 compatibility.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Failing after 2m15s
CI / E2E browser tests (pull_request) Failing after 1m34s
Remove PostgreSQL-only ON CONFLICT so Flyway succeeds in tests and local H2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 12:40:22 +02:00
1c9269699e Add admin order fulfillment tracking.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Failing after 1m50s
CI / E2E browser tests (pull_request) Failing after 1m38s
Register PostNord shipments, admin notes, and guarded status transitions
with customer emails. Expandable admin UI, V11 migration, serial E2E suite,
and AGENTS.md Docker-only E2E guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 12:21:17 +02:00
17fe67ae3f Merge pull request 'Make customer-facing UI usable on smartphones.' (#6) from feature/mobile-responsive into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m11s
CI / E2E browser tests (push) Successful in 56s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/6
2026-05-26 11:48:49 +00:00
144791b7e6 Fix deferred-payment E2E failures under parallel CI workers.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m8s
CI / E2E browser tests (pull_request) Successful in 1m2s
Remove the brittle filter-empty assertion from admin search helpers.
Run deferred-payment-admin in an isolated Playwright project with one
worker so serial tests keep shared order state. Stabilize unpaid-order
lookup by searching order id first, then plate text from the admin row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 13:45:16 +02:00
cf938501c5 Fix flaky admin plate search in deferred-payment E2E.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m9s
CI / E2E browser tests (pull_request) Failing after 1m2s
Merge admin lookup checks into one serial test, create the plate when
the order is created, and search using the plate shown in the admin row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 13:26:22 +02:00
4d3beeffb4 Stabilize deferred-payment admin E2E search assertions.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m22s
CI / E2E browser tests (pull_request) Failing after 1m6s
CI intermittently failed when searching admin orders by registration
number because the table was queried before data and filters settled.
Wait for the admin list to load, clear the search field between queries,
and use a longer timeout when expecting the matching row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 13:16:11 +02:00
7a95c1423c Make customer-facing UI usable on smartphones.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m22s
CI / E2E browser tests (pull_request) Failing after 1m3s
Mobile traffic was breaking on narrow viewports because the header nav
overflowed and several pages used desktop-only spacing. This adds a
shared phone breakpoint, a hamburger menu, and scroll-to-top on route
changes so footer and menu navigation always land at the top of the page.

- Add --page-gutter and max-width 639px rules in base.css
- AppHeader: hamburger panel on small screens; flat account links on mobile
- AppFooter: stack footer links vertically on phones
- Home, compose, edit order, orders, auth, and legal pages: tighter gutters
  and responsive layout (orders card actions stack; home grids single-column)
- Router scrollBehavior: scroll to top on navigation; restore on browser back
- Tests: AppHeader menu toggle, Router scrollBehavior, mobile Playwright checks

Admin page is intentionally unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 13:03:35 +02:00
71a3225a11 Merge pull request 'Add account settings dropdown and verified email change flow.' (#5) from feature/account-settings-dropdown into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m13s
CI / E2E browser tests (push) Successful in 53s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/5
2026-05-22 12:35:46 +00:00
b2aaeb5733 Merge origin/master into feature/account-settings-dropdown.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m24s
CI / E2E browser tests (pull_request) Successful in 1m31s
Resolve router conflict: keep /bekrafta-epost confirm route alongside
master's /om-oss about page and /om redirect.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 14:34:38 +02:00
3532e4d486 Add account settings dropdown and verified email change flow.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m9s
CI / E2E browser tests (pull_request) Successful in 1m55s
Replace the header "Byt lösenord" link with an Inställningar menu for
changing email or password. Email changes are two-step: request with
password, confirmation link to the new address, then password again on
confirm so a wrong inbox cannot take over the account.

- Backend: EmailChangeService, V10 email_change_tokens, confirm API
- Frontend: ChangeEmailPage, ConfirmEmailChangePage, header dropdown
- E2E: account-settings round-trips, Mailpit verification, wrong-password guard
- Flyway: V9 restore for dev DBs, CI migration checks, V10 for email tokens

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 14:33:06 +02:00
3f20656f04 Merge pull request 'feature/cancel-edit-pending-orders' (#4) from feature/cancel-edit-pending-orders into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m21s
CI / E2E browser tests (push) Successful in 56s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/4
2026-05-22 11:54:15 +00:00
a12e07ec1c Register routes for integritetspolicy and villkor legal pages.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m4s
CI / E2E browser tests (pull_request) Successful in 57s
- Add /integritetspolicy and /villkor to Vue Router
- Add Router tests confirming both public legal routes resolve

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 13:51:20 +02:00
ec62ba7673 Add användarvillkor page for Bilhej service terms.
- Create TermsOfServicePage covering Swish payment, letter content rules, and liability
- Describe edit/cancel before payment and refund expectations after posting
- Link to integritetspolicy and support contact in footer CTA
- Add TermsOfServicePage unit tests

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 13:51:20 +02:00
258f6f5a17 Add integritetspolicy page with Phase 0 privacy copy.
- Create PrivacyPolicyPage with sections on data, rights, and letter recipients
- Use plain Swedish without technical jargon or operational detail
- Link to kontakt page and mailto for privacy questions
- Add PrivacyPolicyPage unit tests

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 13:51:20 +02:00
bce2447238 Rework contact page emails and simplify mailto actions.
- Add support@bilhej.se for orders and technical issues
- Move complaints to klagomal@bilhej.se instead of personal Gmail
- Show one mailto chip per card instead of duplicate link and button
- Update ContactPage tests and production email checklist for all @bilhej.se addresses

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 13:51:20 +02:00
c0c32b718b Merge about page prose into hero and drop redundant section heading.
- Move the three explanatory paragraphs into the hero card under the lead
- Remove the separate "Vad vi gör" section that repeated the same framing
- Add a light divider between lead and body text for readability

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 13:51:11 +02:00
255095e6bd Document kontakt@bilhej.se receiving and fix stale contact address in requirements.
- Add production checklist section for Resend inbound on bilhej.se
- Note that mail is read in the Resend dashboard unless a webhook is added later
- Update GDPR letter footer example in REQUIREMENTS.md to kontakt@bilhej.se

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:59:49 +02:00
c0902d0494 Merge pull request 'feature/cancel-edit-pending-orders' (#3) from feature/cancel-edit-pending-orders into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m3s
CI / E2E browser tests (push) Successful in 54s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/3
2026-05-22 10:48:33 +00:00
081a1f90d3 Add expand/collapse for long letter previews on orders page.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 1m59s
CI / E2E browser tests (pull_request) Successful in 57s
- Truncate previews over 120 characters with a Visa mer toggle
- Allow per-order expand state on pending and completed cards
- Add styles for expanded preview and toggle button
- Cover long and short message behavior in OrdersPage tests

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:47:46 +02:00
162002dfb1 Fix printed letter footer contact address to kontakt@bilhej.se.
- Replace obsolete hej@bilhalsning.se in ComposePage GDPR footer
- Apply the same correction on EditOrderPage for edited orders

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:47:46 +02:00
60cb07fc89 Redesign contact page with separate support and complaint channels.
- Replace single placeholder mailto with two contact cards
- Show kontakt@bilhej.se for general questions and service issues
- Show jcamorling@gmail.com for complaints with clearer labeling
- Add tips section pointing users to Mina beställningar first
- Extend ContactPage tests for both email addresses

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:47:46 +02:00
758ace1b92 Redesign about page and move route to /om-oss.
- Replace placeholder about card with hero, prose, steps, and CTA
- Add primary route /om-oss with redirect from legacy /om
- Update footer tagline and Om oss link to match new URL
- Extend AboutPage and AppFooter tests for new content and routing

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:47:44 +02:00
139250b2ac Redesign homepage with clearer marketing sections and honest copy.
- Add structured use-case cards mapped to real letter templates
- Replace generic hero with bullets on traceability, anonymity, and timing
- Add three-step how-it-works flow with manual posting disclaimer
- Reframe trust section around convenience rather than hidden addresses
- Refresh layout with gradient heroes, icons, and consistent section styling

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:47:42 +02:00
aa2b4b4a16 Merge pull request 'Fix order cancellation by allowing cancelled in the database status constraint.' (#2) from feature/cancel-edit-pending-orders into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m13s
CI / E2E browser tests (push) Successful in 53s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/2
2026-05-22 09:54:07 +00:00
15d7b4ae4c Fix order cancellation by allowing cancelled in the database status constraint.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m26s
CI / E2E browser tests (pull_request) Successful in 1m18s
The cancel API returned 500 because ck_orders_status did not include cancelled.
Adds Flyway V9 and an E2E test for cancelling a pending order from /orders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 11:53:25 +02:00
41cfea09a6 Merge pull request 'feature/cancel-edit-pending-orders' (#1) from feature/cancel-edit-pending-orders into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m17s
CI / E2E browser tests (push) Successful in 51s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/1
2026-05-22 09:45:20 +00:00
ca5ce12812 Polish orders page UI for pending and completed cards.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m0s
CI / E2E browser tests (pull_request) Successful in 1m27s
Redesigns the order list so unpaid and paid orders share a consistent
card layout, with clearer payment context and labeled metadata users
need before paying via Swish.

- Split list into Obetalda/Tidigare sections with pending orders first
- Pending cards: preview box, labeled Beställnings-ID, price row, Betala 49 kr
- Completed cards: same header/preview layout, prominent Spåra brev button
- Replace em-dash pay label and update unit/E2E selectors

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 11:38:26 +02:00
3d0b7fe799 Allow users to edit or cancel unpaid orders before payment.
Adds backend endpoints and frontend edit page so pending orders can be updated or soft-cancelled without admin intervention.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 11:21:47 +02:00
082139d266 Fix Forgejo deploy form: add type string to version input.
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m58s
CI / E2E browser tests (push) Successful in 1m22s
Forgejo workflow_dispatch requires an explicit input type; without it the
UI showed invalidinputtype. Clarify README: workflow ref vs version tag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:38:14 +02:00
140 changed files with 9584 additions and 1192 deletions

View file

@ -1,6 +1,66 @@
# 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
.env
.git
frontend/node_modules
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.example
**/application-local.yml
# VCS and editor state
.git
.gitignore
.gitattributes
.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__
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,6 +24,12 @@ STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_...
# ---------- 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
# ---------- App URL (password reset links in email) ----------
@ -43,3 +49,10 @@ APP_PUBLIC_BASE_URL=http://localhost:3000
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
ADMIN_EMAIL=admin@bilhej.se
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,6 +17,10 @@ jobs:
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/jocke/bilhej.git
git fetch --depth 1 origin ${GITHUB_SHA}
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
with:

View file

@ -4,9 +4,10 @@ on:
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v0.1.0)'
required: true
default: 'v0.1.0'
description: 'Leave as "auto" to bump from latest git tag, or enter a specific version (e.g. v0.1.2)'
required: false
default: 'auto'
type: string
jobs:
deploy:
@ -20,12 +21,36 @@ jobs:
git fetch --depth 1 origin ${GITHUB_SHA}
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
run: |
git tag -d ${{ github.event.inputs.version }} 2>/dev/null || true
git push origin --delete ${{ github.event.inputs.version }} 2>/dev/null || true
git tag ${{ github.event.inputs.version }}
git push origin ${{ github.event.inputs.version }}
git tag -d ${{ env.VERSION }} 2>/dev/null || true
git push origin --delete ${{ env.VERSION }} 2>/dev/null || true
git tag ${{ env.VERSION }}
git push origin ${{ env.VERSION }}
- name: Write production .env
env:
@ -45,6 +70,7 @@ jobs:
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
VITE_UMAMI_WEBSITE_ID: ${{ secrets.VITE_UMAMI_WEBSITE_ID }}
run: |
# Docker Compose treats $ as variable interpolation in .env files.
# Escape literal dollar signs (e.g. in passwords) as $$.
@ -66,6 +92,7 @@ jobs:
printf 'MAIL_USERNAME=%s\n' "$(escape "$MAIL_USERNAME")"
printf 'MAIL_PASSWORD=%s\n' "$(escape "$MAIL_PASSWORD")"
printf 'MAIL_FROM=%s\n' "$(escape "${MAIL_FROM:-noreply@bilhej.se}")"
printf 'VITE_UMAMI_WEBSITE_ID=%s\n' "$(escape "$VITE_UMAMI_WEBSITE_ID")"
} > .env
- name: Build and start production stack
@ -131,7 +158,7 @@ jobs:
run: |
echo ""
echo "═══════════════════════════════════════════════════"
echo " Deployed ${{ github.event.inputs.version }} to production"
echo " Deployed ${{ env.VERSION }} to production"
echo "═══════════════════════════════════════════════════"
echo ""
docker compose -p bilhej-prod -f docker-compose.prod.yml ps

View file

@ -74,6 +74,14 @@ stripe listen --forward-to localhost:8080/api/webhooks/stripe
Flyway migrations run automatically on Spring Boot startup. Migration files
live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descriptive_name.sql`.
**Before adding a migration:** run `./scripts/next-flyway-version.sh` and use that
version. Never reuse a version number already on `master`. Never edit a migration
after it has merged — add a new higher version instead. CI runs
`scripts/check-flyway-migrations.sh` against `origin/master`.
If local dev Postgres fails with Flyway checksum / “migration not resolved locally”
after switching branches, run `./gradlew reset` (wipes the Docker DB volume).
To reset: `docker compose down -v && docker compose up -d`.
Flyway schema migrations live in `db/migration/`; dev-only seeds (test users,
@ -152,6 +160,26 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
list concrete changes as bullet points. Never write single-line
"feat: add X" messages.
**Before every commit (mandatory — agents must not skip):**
```bash
# from repo root; needs Docker running
export POSTGRES_DB=bilhej POSTGRES_USER=bilhej POSTGRES_PASSWORD=test_pw_ci_123
export JWT_SECRET=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PRICE_ID=price_fake
./gradlew check
```
This runs frontend lint, frontend unit tests, 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)
- `<script setup>` with Composition API only. Never Options API.
- File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables.
@ -187,6 +215,9 @@ After the address is used to mail the letter, it must be deleted. The Order
entity must NOT have an address field. The address lookup and mailing are
external/human processes in Phase 0.
### E2E must use Docker (not host Playwright)
See **Testing Approach → E2E (Playwright) — Docker only** above. Do not run `npx playwright install` or `npm run test:e2e` on the host when verifying E2E.
### Local email (Mailpit)
`docker compose up` includes Mailpit (`ghcr.io/axllent/mailpit:v1.28`); password-reset mail appears at http://localhost:8025. E2E verifies SMTP via Mailpit API (`frontend/e2e/helpers/mailpit.ts`). Production uses Resend SMTP—see docs/production-email-checklist.md.
@ -228,12 +259,40 @@ the same PR — never merge code without corresponding tests.
- Component tests with Vue Test Utils where needed.
- E2E tests with Playwright in `frontend/e2e/`.
### E2E (Playwright)
- `npm run test:e2e` — runs all Playwright tests (headless Chromium).
- Requires `docker compose up` (backend + frontend running).
- Config: `frontend/playwright.config.ts`.
- Tests: `frontend/e2e/*.spec.ts`.
- Docker CI: `npm run test:e2e:ci` — runs tests inside official Playwright Docker container. Starts postgres, backend, frontend, and Playwright via `docker-compose.ci.yml`. Use for consistent environment or CI pipelines.
### E2E (Playwright) — **Docker only**
**Agents and humans: never run Playwright on the host.**
| Do **not** run | Why |
|----------------|-----|
| `npx playwright test` | Wrong environment; needs Docker stack |
| `npm run test:e2e` | Same — host Playwright, not supported for agents |
| `npx playwright install` | Do not install browsers on the host; the Playwright image already includes them |
**Always use Docker** (`docker-compose.e2e.yml`): isolated postgres (tmpfs), backend, frontend, Mailpit, and the official Playwright container on the `e2e` network (`PLAYWRIGHT_BASE_URL=http://frontend`).
**Full E2E suite** (same as Forgejo CI / `./gradlew check`):
```bash
# from repo root — set env (or use .env; see .env.example)
cd frontend && npm run test:e2e:ci
# equivalent:
./gradlew frontendE2E
```
**Single spec or project** (stack must be reachable on the `e2e` network):
```bash
# from repo root, after exporting the same vars as frontendE2E / .env
docker compose -f docker-compose.e2e.yml up -d --build postgres mailpit backend frontend
docker compose -f docker-compose.e2e.yml run --rm --build playwright \
sh -c 'npx playwright test admin-fulfillment.spec.ts --project=chromium-serial --reporter=list'
docker compose -f docker-compose.e2e.yml down
```
- Config: `frontend/playwright.config.ts`
- Tests: `frontend/e2e/*.spec.ts`
- Serial specs (shared Mailpit / seeded DB): `admin-fulfillment`, `deferred-payment-admin`, `admin-dashboard`, `account-settings`, `password-reset` — project `chromium-serial` runs **after** parallel `chromium`, `workers: 1`
### CI (future)
- `./gradlew check` and `npm run test && npm run lint` must pass before merge.

View file

@ -342,6 +342,7 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
| `MAIL_USERNAME` | `resend` (literal string) |
| `MAIL_PASSWORD` | Resend API key (`re_...`; rotate if ever exposed) |
| `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.
Production does **not** seed `test@bilhej.se` or demo orders. On first start, the
@ -386,7 +387,9 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
1. Go to **Actions → Deploy to Production** in Forgejo.
2. Click **Run workflow**.
3. Enter a version tag (e.g., `v0.1.0`).
3. Fill in both fields (Forgejo requires `type` on inputs — see `deploy.yml`):
- **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**.
### 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 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. |
| 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"_ |
| 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"_ |
### 11.2 Transportstyrelsen Access

View file

@ -80,8 +80,15 @@ 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 {
dependsOn jacocoTestCoverageVerification
dependsOn jacocoTestCoverageVerification, flywayMigrationCheck
}
tasks.register('hashPassword', JavaExec) {

View file

@ -1,8 +1,12 @@
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.context.annotation.Bean;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
@ -10,6 +14,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import se.bilhalsning.dto.ErrorResponse;
import se.bilhalsning.security.JwtAuthenticationFilter;
import se.bilhalsning.security.JwtService;
@ -17,6 +22,13 @@ import se.bilhalsning.security.JwtService;
@EnableWebSecurity
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
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
@ -38,15 +50,29 @@ public class SecurityConfig {
"/api/auth/register",
"/api/auth/login",
"/api/auth/forgot-password",
"/api/auth/reset-password")
"/api/auth/reset-password",
"/api/auth/confirm-email-change")
.permitAll()
.requestMatchers("/api/webhooks/**").permitAll()
.requestMatchers("/api/payment/swish-info").permitAll()
.requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.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);
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,10 +9,13 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.AdminOrderMapper;
import se.bilhalsning.dto.AdminOrderResponse;
import se.bilhalsning.dto.RegisterShipmentRequest;
import se.bilhalsning.dto.UpdateAdminNotesRequest;
import se.bilhalsning.dto.UpdateStatusRequest;
import se.bilhalsning.dto.UpdateTrackingRequest;
import se.bilhalsning.entity.Order;
import se.bilhalsning.service.AdminOrderWorkflowService;
import se.bilhalsning.service.OrderService;
import java.util.List;
@ -24,11 +27,12 @@ import java.util.UUID;
public class AdminController {
private final OrderService orderService;
private final AdminOrderWorkflowService adminOrderWorkflowService;
@GetMapping("/orders")
public ResponseEntity<List<AdminOrderResponse>> listAllOrders() {
List<AdminOrderResponse> orders = orderService.getAllOrders().stream()
.map(this::toAdminResponse)
.map(AdminOrderMapper::toResponse)
.toList();
return ResponseEntity.ok(orders);
}
@ -37,29 +41,26 @@ public class AdminController {
public ResponseEntity<AdminOrderResponse> updateStatus(
@PathVariable UUID id,
@Valid @RequestBody UpdateStatusRequest request) {
Order order = orderService.updateOrderStatus(id, request.status());
return ResponseEntity.ok(toAdminResponse(order));
Order order = adminOrderWorkflowService.updateOrderStatus(id, request.status());
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
}
@PatchMapping("/orders/{id}")
public ResponseEntity<AdminOrderResponse> updateTracking(
@PatchMapping("/orders/{id}/register-shipment")
public ResponseEntity<AdminOrderResponse> registerShipment(
@PathVariable UUID id,
@Valid @RequestBody UpdateTrackingRequest request) {
Order order = orderService.updateTracking(id, request.trackingId());
return ResponseEntity.ok(toAdminResponse(order));
@Valid @RequestBody RegisterShipmentRequest request) {
Order order = adminOrderWorkflowService.registerShipment(
id,
request.trackingInput(),
request.notifyCustomerOrDefault());
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
}
private AdminOrderResponse toAdminResponse(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.getCreatedAt()
);
@PatchMapping("/orders/{id}/notes")
public ResponseEntity<AdminOrderResponse> updateNotes(
@PathVariable UUID id,
@RequestBody UpdateAdminNotesRequest request) {
Order order = adminOrderWorkflowService.updateAdminNotes(id, request.adminNotes());
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
}
}

View file

@ -1,6 +1,7 @@
package se.bilhalsning.controller;
import jakarta.validation.Valid;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@ -11,7 +12,10 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.AuthResponse;
import se.bilhalsning.dto.ChangeEmailRequest;
import se.bilhalsning.dto.ChangeEmailResponse;
import se.bilhalsning.dto.ChangePasswordRequest;
import se.bilhalsning.dto.ConfirmEmailChangeRequest;
import se.bilhalsning.dto.ForgotPasswordRequest;
import se.bilhalsning.dto.LoginRequest;
import se.bilhalsning.dto.ForgotPasswordResponse;
@ -20,6 +24,7 @@ import se.bilhalsning.dto.RegisterRequest;
import se.bilhalsning.dto.ResetPasswordRequest;
import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.EmailChangeService;
import se.bilhalsning.service.PasswordResetService;
import se.bilhalsning.service.UserService;
@ -30,11 +35,15 @@ public class AuthController {
private final UserService userService;
private final PasswordResetService passwordResetService;
private final EmailChangeService emailChangeService;
private final JwtService jwtService;
private static final String FORGOT_PASSWORD_MESSAGE =
"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")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
userService.createUser(request.email(), request.password());
@ -71,4 +80,21 @@ public class AuthController {
principal.getUsername(), request.currentPassword(), request.newPassword());
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,12 +7,15 @@ 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.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.CreateOrderRequest;
import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.dto.UpdateOrderRequest;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.InvalidCredentialsException;
@ -20,6 +23,7 @@ import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/orders")
@ -41,6 +45,21 @@ public class OrderController {
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
public ResponseEntity<OrderResponse> create(
@Valid @RequestBody CreateOrderRequest request,
@ -57,6 +76,31 @@ public class OrderController {
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) {
return new OrderResponse(
order.getId(),

View file

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

View file

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

View file

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

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

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

View file

@ -0,0 +1,13 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
public record RegisterShipmentRequest(
@NotBlank(message = "Spårnings-ID krävs")
String trackingInput,
Boolean notifyCustomer
) {
public boolean notifyCustomerOrDefault() {
return notifyCustomer == null || notifyCustomer;
}
}

View file

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

View file

@ -0,0 +1,10 @@
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(
@NotBlank(message = "Status krävs")
@Pattern(
regexp = "pending_payment|paid|processing|sent|delivered|failed",
regexp = "pending_payment|paid|processing|sent|delivered|failed|cancelled",
message = "Ogiltig status"
)
String status

View file

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

View file

@ -0,0 +1,106 @@
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,6 +43,12 @@ public class Order {
@Column(name = "tracking_id", length = 100)
private String trackingId;
@Column(name = "shipped_at")
private Instant shippedAt;
@Column(name = "admin_notes", columnDefinition = "text")
private String adminNotes;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@ -130,6 +136,22 @@ public class Order {
this.trackingId = trackingId;
}
public Instant getShippedAt() {
return shippedAt;
}
public void setShippedAt(Instant shippedAt) {
this.shippedAt = shippedAt;
}
public String getAdminNotes() {
return adminNotes;
}
public void setAdminNotes(String adminNotes) {
this.adminNotes = adminNotes;
}
public Instant getCreatedAt() {
return createdAt;
}

View file

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

View file

@ -0,0 +1,8 @@
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,6 +29,21 @@ public class GlobalExceptionHandler {
.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)
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
return ResponseEntity
@ -36,6 +51,13 @@ public class GlobalExceptionHandler {
.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)
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
return ResponseEntity

View file

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

View file

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

View file

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

View file

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

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

@ -0,0 +1,70 @@
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,4 +58,106 @@ public class EmailService {
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

@ -0,0 +1,69 @@
package se.bilhalsning.service;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.User;
import se.bilhalsning.repository.UserRepository;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class OrderNotificationService {
private final UserRepository userRepository;
private final EmailService emailService;
@Value("${app.public-base-url:http://localhost:3000}")
private String publicBaseUrl;
public void notifyOrderProcessing(Order order) {
String email = resolveCustomerEmail(order);
if (email.isBlank()) {
return;
}
emailService.sendOrderProcessingEmail(
email,
order.getPlate(),
ordersPageUrl());
}
public void notifyOrderSent(Order order, String trackingId) {
String email = resolveCustomerEmail(order);
if (email.isBlank()) {
return;
}
String trackingUrl = "https://www.postnord.se/verktyg/spara/?id=" + trackingId;
emailService.sendOrderSentEmail(email, order.getPlate(), trackingId, trackingUrl);
}
public void notifyOrderFailed(Order order) {
String email = resolveCustomerEmail(order);
if (email.isBlank()) {
return;
}
emailService.sendOrderFailedEmail(email, order.getPlate(), ordersPageUrl());
}
private String resolveCustomerEmail(Order order) {
if (order.getUser() != null && order.getUser().getEmail() != null) {
return order.getUser().getEmail();
}
UUID userId = order.getUserId();
if (userId == null) {
return "";
}
return userRepository.findById(userId)
.map(User::getEmail)
.orElse("");
}
private String ordersPageUrl() {
String base = publicBaseUrl.endsWith("/")
? publicBaseUrl.substring(0, publicBaseUrl.length() - 1)
: publicBaseUrl;
return base + "/mina-bestallningar";
}
}

View file

@ -4,6 +4,7 @@ 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;
@ -15,6 +16,7 @@ import java.util.UUID;
public class OrderService {
private final OrderRepository orderRepository;
private final OrderNotificationService orderNotificationService;
public Order createOrder(UUID userId, String plate, String letterText) {
Order order = new Order();
@ -38,28 +40,39 @@ public class OrderService {
return orderRepository.findAllByOrderByCreatedAtDesc();
}
public Order updateOrderStatus(UUID orderId, String statusString) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
OrderStatus newStatus = OrderStatus.valueOf(statusString.toUpperCase());
order.setStatus(newStatus);
return orderRepository.save(order);
}
public Order updateTracking(UUID orderId, String trackingId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.setTrackingId(trackingId);
return orderRepository.save(order);
}
public Order confirmPayment(UUID orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
public Order confirmPayment(UUID orderId, UUID userId) {
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)
.orElseThrow(() -> new OrderNotFoundException(orderId));
if (!order.getUserId().equals(userId)) {
throw new OrderNotFoundException(orderId);
}
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
throw new InvalidOrderStateException(
"Beställningen kan inte ändras i detta tillstånd");
}
return order;
}
}

View file

@ -53,4 +53,26 @@ public class UserService {
}
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

@ -0,0 +1,45 @@
package se.bilhalsning.util;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
public final class PostNordTrackingNormalizer {
private PostNordTrackingNormalizer() {
}
public static String normalize(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("Spårnings-ID krävs");
}
String trimmed = raw.trim();
if (trimmed.toLowerCase().contains("postnord")) {
String fromUrl = extractIdFromPostNordUrl(trimmed);
if (fromUrl != null && !fromUrl.isBlank()) {
trimmed = fromUrl;
}
}
return trimmed.replaceAll("\\s+", "");
}
private static String extractIdFromPostNordUrl(String url) {
try {
URI uri = URI.create(url);
String query = uri.getQuery();
if (query == null) {
return null;
}
for (String param : query.split("&")) {
if (param.startsWith("id=")) {
return URLDecoder.decode(param.substring(3), StandardCharsets.UTF_8).trim();
}
}
} catch (IllegalArgumentException ignored) {
return null;
}
return null;
}
}

View file

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

View file

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

View file

@ -0,0 +1,13 @@
-- Dev/CI: order in "processing" for admin fulfillment testing
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
VALUES (
'c4eebc99-9c0b-4ef8-bb6d-6bb9bd380a14',
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'JKL012',
'Hej! Bara en påminnelse om serviceboken.',
'processing',
49.00,
NULL,
TIMESTAMP '2026-05-16 09:00:00',
TIMESTAMP '2026-05-16 09:00:00'
);

View file

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

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

View file

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

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

View file

@ -22,6 +22,7 @@ import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.security.JwtService;
import java.util.Optional;
import se.bilhalsning.service.EmailChangeService;
import se.bilhalsning.service.PasswordResetService;
import se.bilhalsning.service.UserService;
@ -40,6 +41,9 @@ class AuthControllerTest {
@MockitoBean
private PasswordResetService passwordResetService;
@MockitoBean
private EmailChangeService emailChangeService;
@MockitoBean
private JwtService jwtService;
@ -221,6 +225,46 @@ class AuthControllerTest {
.contentType(MediaType.APPLICATION_JSON)
.content(
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.andExpect(status().isForbidden());
.andExpect(status().isUnauthorized())
.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,7 +1,9 @@
package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.any;
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.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -16,16 +18,22 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
class OrderControllerTest {
private static final String TEST_SECRET =
"this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
@Autowired
private MockMvc mockMvc;
@ -36,9 +44,10 @@ class OrderControllerTest {
private UserService userService;
@Test
void shouldReturn403WhenNotAuthenticated() throws Exception {
void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isForbidden());
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
@ -98,11 +107,31 @@ class OrderControllerTest {
}
@Test
void shouldReturn403WhenPostingWithoutAuth() throws Exception {
void shouldReturn401WhenPostingWithoutAuth() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
.andExpect(status().isForbidden());
.andExpect(status().isUnauthorized())
.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
@ -163,4 +192,115 @@ class OrderControllerTest {
.content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}"))
.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,5 +1,6 @@
package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ -7,6 +8,7 @@ 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.status;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -18,8 +20,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
@SpringBootTest
@AutoConfigureMockMvc
@ -31,23 +35,34 @@ class PaymentControllerTest {
@MockitoBean
private OrderService orderService;
@MockitoBean
private UserService userService;
@Test
void shouldReturn403WhenNotAuthenticated() throws Exception {
void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(post("/api/payment/{orderId}/pay",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(status().isForbidden());
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldConfirmPaymentSuccessfully() throws Exception {
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.setId(orderId);
order.setPlate("ABC123");
order.setStatus(OrderStatus.PROCESSING);
when(orderService.confirmPayment(eq(orderId))).thenReturn(order);
when(orderService.confirmPayment(eq(orderId), eq(userId))).thenReturn(order);
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON))
@ -60,7 +75,14 @@ class PaymentControllerTest {
@WithMockUser(username = "test@bilhej.se")
void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.confirmPayment(eq(orderId)))
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));
when(orderService.confirmPayment(eq(orderId), eq(userId)))
.thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)

View file

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

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

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

@ -0,0 +1,140 @@
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,6 +9,7 @@ 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.exception.OrderNotFoundException;
import se.bilhalsning.repository.OrderRepository;
@ -26,6 +27,9 @@ class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private OrderNotificationService orderNotificationService;
@InjectMocks
private OrderService orderService;
@ -127,4 +131,126 @@ class OrderServiceTest {
assertThrows(OrderNotFoundException.class,
() -> 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,4 +203,35 @@ class UserServiceTest {
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

@ -0,0 +1,26 @@
package se.bilhalsning.util;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class PostNordTrackingNormalizerTest {
@Test
void shouldTrimAndRemoveWhitespaceFromPlainId() {
assertEquals("PN123456789", PostNordTrackingNormalizer.normalize(" PN 123 456 789 "));
}
@Test
void shouldExtractIdFromPostNordUrl() {
String url = "https://www.postnord.se/verktyg/spara/?id=PN987654321&utm=foo";
assertEquals("PN987654321", PostNordTrackingNormalizer.normalize(url));
}
@Test
void shouldThrowWhenInputIsBlank() {
assertThrows(IllegalArgumentException.class,
() -> PostNordTrackingNormalizer.normalize(" "));
}
}

View file

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

View file

@ -0,0 +1,106 @@
# 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;
done;
echo 'Waiting for backend...';
for i in \$(seq 1 60); do
curl -s http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
for i in \$(seq 1 120); do
curl -sf http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
sleep 1;
done;
echo 'Waiting for frontend...';

View file

@ -49,6 +49,9 @@ services:
build:
dockerfile: docker/frontend.prod.Dockerfile
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
ports:
- "3001:80"

View file

@ -1,3 +1,15 @@
FROM eclipse-temurin:21-jdk
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"]

View file

@ -1,7 +1,16 @@
FROM node:24-alpine
WORKDIR /app
# Install dependencies first so this layer caches independently of source changes.
COPY frontend/package.json frontend/package-lock.json ./
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/ .
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View file

@ -1,5 +1,9 @@
FROM node:24-alpine AS builder
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 ./
RUN npm ci
COPY frontend/ .

View file

@ -54,3 +54,30 @@ 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
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.

81
docs/umami-analytics.md Normal file
View file

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

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

View file

@ -0,0 +1,87 @@
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,6 +25,22 @@ test.describe('Auth guards', () => {
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 ({
page,
}) => {
@ -54,6 +70,13 @@ test.describe('Auth guards', () => {
})
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' })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)

View file

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

View file

@ -0,0 +1,55 @@
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,6 +100,13 @@ test.describe('Header auth state', () => {
})
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' })
await page.goto('/orders')
await page.evaluate(
@ -143,8 +150,104 @@ test.describe('Header auth state', () => {
header.getByRole('link', { name: 'Admin' }),
).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 {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))

View file

@ -0,0 +1,15 @@
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,6 +100,65 @@ function extractResetToken(body: string, publicBaseUrl?: string): string | 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> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View file

@ -49,7 +49,7 @@ test.describe('Order history', () => {
await page.goto('/orders')
await expect(page.getByText('Skickat')).toBeVisible()
await expect(page.getByText('Skickat').first()).toBeVisible()
await expect(page.getByText('Väntar på betalning').first()).toBeVisible()
await expect(page.getByText('Levererat').first()).toBeVisible()
})
@ -66,8 +66,8 @@ test.describe('Order history', () => {
await page.goto('/orders')
const unpaidCard = page.locator('.orders__card', { hasText: 'DEF456' })
await expect(unpaidCard.getByRole('link', { name: 'Betala nu' })).toBeVisible()
await unpaidCard.getByRole('link', { name: 'Betala nu' }).click()
await expect(unpaidCard.getByRole('link', { name: 'Betala 49 kr' })).toBeVisible()
await unpaidCard.getByRole('link', { name: 'Betala 49 kr' }).click()
await expect(page).toHaveURL(/\/betalning\/c2eebc99/)
await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible()
@ -91,4 +91,38 @@ test.describe('Order history', () => {
const trackingLink2 = page.getByRole('link', { name: 'PN987654321' })
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,6 +49,31 @@ test.describe('Payment redirect', () => {
await page.waitForURL(/\/betalning\//)
await expect(page.getByText('Swisha till')).toBeVisible()
await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible()
await expect(
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,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32",
"vue-router": "^5.0.6"
},
@ -16,6 +17,7 @@
"@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "^10.2.0",
@ -791,9 +793,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -811,9 +810,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -831,9 +827,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -851,9 +844,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -871,9 +861,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -891,9 +878,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1054,6 +1038,16 @@
"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": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
@ -1962,6 +1956,15 @@
"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": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@ -1987,11 +1990,91 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@ -2004,7 +2087,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
@ -2136,6 +2218,15 @@
}
}
},
"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": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@ -2160,6 +2251,12 @@
"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": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -2718,6 +2815,15 @@
"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": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@ -2863,7 +2969,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -3285,9 +3390,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3309,9 +3411,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3333,9 +3432,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3357,9 +3453,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3743,6 +3836,15 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -3787,7 +3889,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -3936,6 +4037,15 @@
"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": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
@ -4034,6 +4144,23 @@
"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": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@ -4084,6 +4211,15 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -4094,6 +4230,12 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@ -4208,6 +4350,12 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -5094,6 +5242,12 @@
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -5236,6 +5390,12 @@
"dev": true,
"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": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
@ -5251,6 +5411,134 @@
"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": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

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

View file

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

View file

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

View file

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

View file

@ -30,6 +30,11 @@ function createTestRouter() {
name: 'change-password',
component: { template: '<div>Change password</div>' },
},
{
path: '/andra-epost',
name: 'change-email',
component: { template: '<div>Change email</div>' },
},
{
path: '/admin',
name: 'admin',
@ -100,7 +105,7 @@ describe('AppHeader', () => {
const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] },
})
expect(wrapper.find('button').exists()).toBe(false)
expect(wrapper.find('.app-header__logout').exists()).toBe(false)
})
it('does not show user email', () => {
@ -142,7 +147,7 @@ describe('AppHeader', () => {
it('shows logout button', () => {
const { wrapper } = mountAuthenticated()
const logoutButton = wrapper.find('button')
const logoutButton = wrapper.find('.app-header__logout')
expect(logoutButton.exists()).toBe(true)
expect(logoutButton.text()).toBe('Logga ut')
})
@ -171,14 +176,62 @@ describe('AppHeader', () => {
expect(ordersLink?.text()).toBe('Mina beställningar')
})
it('shows change password link', () => {
it('shows settings menu with account links', async () => {
const { wrapper } = mountAuthenticated()
const links = wrapper.findAll('a')
const changeLink = links.find(
(a) => a.attributes('href') === '/andra-losenord',
expect(wrapper.findAll('.app-header__settings-item')).toHaveLength(0)
await wrapper.find('.app-header__settings-trigger').trigger('click')
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', () => {
@ -210,7 +263,7 @@ describe('AppHeader', () => {
resolve()
})
})
await wrapper.find('button').trigger('click')
await wrapper.find('.app-header__logout').trigger('click')
await navigationDone
expect(auth.isAuthenticated).toBe(false)

View file

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

@ -0,0 +1,50 @@
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,8 +3,34 @@ import { mount } from '@vue/test-utils'
import ContactPage from '@/pages/ContactPage.vue'
describe('ContactPage', () => {
it('renders heading', () => {
it('renders heading and lead', () => {
const wrapper = mount(ContactPage)
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

@ -0,0 +1,149 @@
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,6 +4,26 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia'
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) {
return Promise.resolve({
ok: status >= 200 && status < 300,
@ -22,6 +42,11 @@ function createTestRouter() {
name: 'payment',
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>' } },
],
})
@ -58,13 +83,39 @@ 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', () => {
beforeEach(() => {
localStorage.clear()
globalThis.fetch = vi.fn()
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, mockOrders),
)
mockOrdersFetch(mockOrders)
})
it('renders heading and subtitle', async () => {
@ -82,6 +133,13 @@ describe('OrdersPage', () => {
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 () => {
mountPage()
await new Promise((r) => setTimeout(r, 50))
@ -110,7 +168,9 @@ describe('OrdersPage', () => {
await new Promise((r) => setTimeout(r, 50))
const link = wrapper.find('a[href*="postnord"]')
expect(link.exists()).toBe(true)
expect(link.classes()).toContain('orders__tracking-btn')
expect(link.text()).toContain('PN123456789')
expect(link.text()).toContain('Spåra brev')
expect(link.attributes('target')).toBe('_blank')
})
@ -125,9 +185,7 @@ describe('OrdersPage', () => {
createdAt: '2026-05-14T13:00:00Z',
},
]
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, ordersWithoutTracking),
)
mockOrdersFetch(ordersWithoutTracking)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const link = wrapper.find('a[href*="postnord"]')
@ -137,9 +195,8 @@ describe('OrdersPage', () => {
it('renders order id and message', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
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('Beställnings-ID')
expect(wrapper.text()).toContain('Hej fin bil!')
expect(wrapper.text()).toContain('Vill köpa din bil.')
})
@ -148,19 +205,24 @@ describe('OrdersPage', () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('2026')
expect(wrapper.text()).toContain('Skapad')
})
it('shows empty state when no orders', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, []))
mockOrdersFetch([])
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Inga beställningar ännu')
})
it('shows error state on API failure', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(500, { message: 'Internal server error' }),
)
vi.mocked(globalThis.fetch).mockImplementation((url) => {
const urlStr = String(url)
if (urlStr.includes('/payment/swish-info')) {
return mockFetchResponse(200, { number: '1234567890', amount: 49 })
}
return mockFetchResponse(500, { message: 'Internal server error' })
})
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Kunde inte hämta beställningar')
@ -170,19 +232,35 @@ describe('OrdersPage', () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const badges = wrapper.findAll('.badge')
expect(badges[0].classes()).toContain('badge--success')
expect(badges[1].classes()).toContain('badge--muted')
expect(badges[0].classes()).toContain('badge--warning')
expect(badges[1].classes()).toContain('badge--success')
})
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 () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const payLinks = wrapper.findAll('.orders__pay-btn')
expect(payLinks).toHaveLength(1)
expect(payLinks[0].text()).toBe('Betala nu')
const pendingCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('DEF456'))
const payLink = pendingCard?.find('a.orders__pay-btn')
expect(payLink?.exists()).toBe(true)
expect(payLink?.text()).toBe('Betala 49 kr')
const href = payLinks[0].attributes('href')
const href = payLink?.attributes('href')
expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12')
expect(href).toContain('plate=DEF456')
})
@ -194,7 +272,71 @@ describe('OrdersPage', () => {
const sentCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('ABC123'))
expect(sentCard?.find('.orders__pay-btn').exists()).toBe(false)
expect(sentCard?.find('a.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 () => {
@ -208,13 +350,80 @@ describe('OrdersPage', () => {
createdAt: '2026-05-15T10:00:00Z',
},
]
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, ordersWithProcessing),
)
mockOrdersFetch(ordersWithProcessing)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hanteras')
const badge = wrapper.find('.badge')
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,14 +5,26 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import PaymentRedirect from '@/pages/PaymentRedirect.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', () => ({
payOrder: 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 QRCode from 'qrcode'
const mockPayOrder = vi.mocked(payOrder)
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
const mockToDataURL = vi.mocked(QRCode.toDataURL)
function createTestRouter() {
return createRouter({
@ -59,6 +71,7 @@ describe('PaymentRedirect', () => {
number: '0701234567',
amount: 49,
})
mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
})
it('renders heading and amount', async () => {
@ -81,7 +94,7 @@ describe('PaymentRedirect', () => {
expect(wrapper.text()).toContain('Beställnings-ID')
expect(wrapper.text()).toContain(orderId)
expect(wrapper.text()).toContain(
'Ange beställnings-ID ovan som meddelande i Swish-appen',
'fylls i automatiskt via QR-kod eller länk',
)
})
})
@ -93,13 +106,30 @@ 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 () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click')
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
expect(wrapper.text()).toContain('0701234567')
@ -110,15 +140,15 @@ describe('PaymentRedirect', () => {
it('can cancel confirmation dialog', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click')
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Avbryt')
})
await wrapper.find('.btn--ghost').trigger('click')
await wrapper.find('.payment__confirm-cancel').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Swisha till')
expect(wrapper.text()).not.toContain('Avbryt')
@ -137,16 +167,15 @@ describe('PaymentRedirect', () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click')
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat')
})
const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
expect(mockPayOrder).toHaveBeenCalledWith('order-1')
})
@ -156,16 +185,15 @@ describe('PaymentRedirect', () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click')
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat')
})
const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen')
@ -184,16 +212,15 @@ describe('PaymentRedirect', () => {
const { wrapper, router } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click')
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat')
})
const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders')

View file

@ -0,0 +1,64 @@
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 { setActivePinia, createPinia } from 'pinia'
import router from '@/router'
import router, { scrollBehavior } from '@/router'
describe('Router', () => {
beforeEach(() => {
@ -8,6 +8,25 @@ describe('Router', () => {
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 () => {
await router.push('/')
await router.isReady()
@ -32,6 +51,18 @@ describe('Router', () => {
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 () => {
await router.push('/aterstall-losenord?token=abc')
await router.isReady()
@ -52,6 +83,13 @@ describe('Router', () => {
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 () => {
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
await router.push('/admin')
@ -93,6 +131,19 @@ describe('Router guards', () => {
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 () => {
await router.push('/admin')
await router.isReady()
@ -163,6 +214,64 @@ 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 {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))

View file

@ -0,0 +1,58 @@
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,6 +212,52 @@ 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 {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))

View file

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

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

View file

@ -25,6 +25,11 @@ export interface MessageResponse {
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). */
export interface ForgotPasswordResponse extends MessageResponse {
testToken?: string
@ -56,3 +61,23 @@ export function changePassword(
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,3 +1,6 @@
import { useAuthStore } from '@/stores/authStore'
import router from '@/router'
const API_BASE = import.meta.env.VITE_API_URL || '/api'
export class ApiError extends Error {
@ -10,10 +13,27 @@ 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 {
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>(
url: string,
options: RequestInit = {},
@ -34,6 +54,9 @@ export async function request<T>(
})
if (!response.ok) {
if (response.status === 401 && !url.startsWith('/auth/')) {
handleExpiredSession()
}
const body = await response.json().catch(() => ({}))
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