Compare commits

...

153 commits

Author SHA1 Message Date
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
de86880a8f Fix prod SMTP STARTTLS for Resend password-reset mail.
Some checks failed
CI / E2E browser tests (push) Has been cancelled
CI / Lint, type check, unit tests, coverage (push) Has been cancelled
The docker profile disables mail.smtp.starttls for Mailpit; prod runs
docker+prod so Resend saw AUTH before STARTTLS (538). Re-enable auth and
STARTTLS in application-prod.yml.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 18:50:18 +02:00
ad195fd890 Wire production email secrets through Forgejo deploy.
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m2s
CI / E2E browser tests (push) Successful in 1m16s
Deploy workflow now writes MAIL_* and APP_PUBLIC_BASE_URL from Actions
secrets into the server .env so Resend SMTP works after domain verify.
Document Resend-only setup, Forgejo secret names, and prod expose-token off.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 18:39:00 +02:00
86fb946e33 Add password reset, logged-in change password, and Mailpit email dev/E2E.
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m2s
CI / E2E browser tests (push) Successful in 1m55s
Operators can fix prod admin passwords without email via Byt lösenord;
end users can use forgot-password when SMTP is configured. Local and CI
use Mailpit to capture outbound mail and verify reset links end-to-end.

- Backend: V8 password_reset_tokens, PasswordResetService, EmailService,
  POST /api/auth/forgot-password, reset-password, change-password
- Optional testToken in forgot-password response (docker profile only, for E2E)
- Frontend: ForgotPasswordPage, ResetPasswordPage, ChangePasswordPage,
  routes, login link, header Byt lösenord
- Mailpit (ghcr.io/axllent/mailpit:v1.28) in docker-compose + e2e stack
- E2E: password-reset.spec.ts + Mailpit API helper tests SMTP delivery
- Separate dev/e2e Docker image names to avoid overwriting bilhej-frontend
- Docs: README email section, production-email-checklist, .env.example
- Unit/integration tests for reset, change password, and Vitest page specs

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 18:05:15 +02:00
45b2449b14 Add phased nginx setup for bilhej.se TLS on srvr.nu.
First-time host nginx setup needs HTTP-only vhost before certbot can
issue certs; the full bilhej.nginx.conf 443 block fails nginx -t until
those files exist.

- Add docker/bilhej.nginx.http.conf for ACME phase
- Reorder README one-time setup: HTTP vhost, certbot, then full config
2026-05-21 17:06:21 +02:00
fb9713d8d8 Fix prod deploy Flyway validation for removed dev seed migrations.
Some checks failed
CI / E2E browser tests (push) Has been cancelled
CI / Lint, type check, unit tests, coverage (push) Has been cancelled
Backend crashed on startup because the prod DB still records V6 (and
possibly V2/V4) from when seeds lived in db/migration, while prod only
loads schema migrations. ignore-migration-patterns alone did not prevent
validate failure on the runner.

- Run Flyway repair before migrate on the prod profile
- Add ProdFlywayConfigTest for repair-then-migrate order
- Document the V6 error in README deploy troubleshooting
2026-05-21 16:52:15 +02:00
61a7b8a40c Wire backend tests and coverage into ./gradlew check.
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m52s
CI / E2E browser tests (push) Successful in 45s
Root check only ran frontend lint, unit tests, and E2E, so backend JUnit
and JaCoCo gates were skipped despite AGENTS.md documenting otherwise.

- Make check depend on :backend:check and frontendE2E as siblings
- Update AGENTS.md comment to match the real task order
2026-05-21 16:44:15 +02:00
db56fc58de Add deploy failure diagnostics and safer backend health check.
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m52s
CI / E2E browser tests (push) Successful in 46s
Production deploy failed with no backend logs before rollback. Print
backend and postgres logs on failure, wait longer for JVM startup, and
probe /api/payment/swish-info instead of vehicle lookup (no external scrape).

- Document proof-first troubleshooting in README
- No volume reset workflow; fix only after reading job logs
2026-05-21 16:39:13 +02:00
d652a5b862 Fix deploy .env writing when secrets contain dollar signs.
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m55s
CI / E2E browser tests (push) Successful in 48s
Docker Compose interpolates $VAR in .env files. Passwords like ...$A72y...
were truncated and the backend failed health checks, triggering rollback.

- Escape $ as $$ when writing production secrets to .env
- Document that deploy handles literal $ in Forgejo secrets
2026-05-21 16:17:36 +02:00
7731eb1155 Apply ESLint formatting fixes to admin and orders pages.
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 3m46s
CI / E2E browser tests (push) Successful in 1m46s
Auto-fix from eslint src/ --fix after ./gradlew check touched these
files. No functional changes.
2026-05-21 16:00:53 +02:00
49b5a91f4a Include E2E browser tests in ./gradlew check.
Local verification should match Forgejo CI. Wire frontendE2E into check
and point it at docker-compose.e2e.yml with the same test env vars.

- Add frontendE2E to check after frontend unit tests
- Run docker-compose.e2e.yml directly from Gradle with CI secrets
- Update npm test:e2e:ci to use the e2e compose file
2026-05-21 15:58:09 +02:00
ec122e86b8 Fix admin dashboard e2e tests for updated UI selectors.
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 3m47s
CI / E2E browser tests (push) Successful in 1m42s
The admin search label and parallel compose tests made strict-mode
Playwright locators ambiguous after the dashboard rework.

- Assert table columns via columnheader roles instead of getByText
- Target seeded order by ID when opening the message modal

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:41:03 +02:00
350dfcfd7b Document production database access and admin setup.
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Successful in 1m54s
CI / E2E browser tests (push) Failing after 1m49s
Operators need IntelliJ-style GUI access to Docker Postgres and clear
steps for manual prod cleanup without wiping volumes.

- Add Database access section with IntelliJ, DBeaver, and SSH tunnel steps
- Document dev-only accounts, manual SQL cleanup, and hashPassword task
- Note Flyway dev-migration split and admin bootstrap in AGENTS.md
2026-05-21 15:14:14 +02:00
93ece8128a Use bilhej.se domain for dev test user email.
Aligns seeded and test login addresses with production branding while
keeping admin@bilhalsning.se for local docker admin seed only.

- Change test@bilhalsning.se to test@bilhej.se in dev migration and all tests
2026-05-21 15:14:11 +02:00
75911dfffa Separate dev database seeds from production and bootstrap prod admin.
Production must not ship test users, demo orders, or test1234. Dev and CI
still need seeded users for e2e. Prod creates one admin from deploy secrets.

- Move V2/V4/V6 seed migrations to db/dev-migration
- Add application-prod.yml with schema-only Flyway and ignore-missing for moved seeds
- Add AdminBootstrap to create admin from ADMIN_EMAIL and ADMIN_PASSWORD
- Wire docker,prod profile, deploy secrets, and localhost:5433 for SSH DB access
- Add hashPassword Gradle task for optional manual bcrypt generation
2026-05-21 15:14:03 +02:00
4385f43b08 Add application-prod.yml for production Flyway config 2026-05-21 15:13:56 +02:00
ab1cdb358f Remove unused opencode.json configuration.
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Successful in 2m6s
CI / E2E browser tests (push) Failing after 1m18s
The file is not part of the BilHej toolchain and is no longer needed in
the repository root.
2026-05-21 14:49:50 +02:00
b27b1395f7 Pass SWISH_NUMBER to production backend container.
Payment instructions need the merchant Swish number from environment
variables, consistent with dev compose.
2026-05-21 14:49:50 +02:00
3ba7560f82 Add e2e coverage for deferred payment and admin lookup.
Manual Swish flow means users often pay later; admins match payments via
order ID or regnr under Att göra.

- User flow: create order, leave payment, pay from orders page
- Admin: find order via partial ID, full ID, and plate search
- Assert unpaid orders appear under Väntar, not Att göra
- Use unique plates per run to avoid collisions with seed data
2026-05-21 14:49:50 +02:00
01db53860b Rework admin dashboard filtering, search, and message viewing.
Admins need to find orders quickly and read full letter text without a
cramped table column.

- Make stat cards clickable filters (Totalt, Att göra, Betalda, Väntar)
- Add search by partial order ID or registration number
- Show shortened order ID in table with full ID on hover
- Replace message column with "Visa meddelande" opening a modal
- Keep expanded row for tracking only; remove duplicate brevtext block
- Update AdminDashboard unit tests and admin-dashboard e2e specs
2026-05-21 14:49:50 +02:00
b9a0bdb318 Clarify Swish reference order ID on the payment page.
The full UUID is required as the Swish message but was easy to miss when
embedded only in instruction text.

- Show beställnings-ID in a dedicated box above payment summary
- Point Swish instructions to that ID instead of repeating inline
- Add PaymentRedirect unit test for full order ID display
2026-05-21 14:49:50 +02:00
dfb3e0dedc Improve orders page with details and deferred payment.
Users who leave the payment step can return later and still see what
they ordered. Unpaid orders get a clear path back to Swish checkout.

- Add letterText to frontend Order type
- Show beställnings-ID, message, and formatted date on each order card
- Add "Betala nu" link to payment route for pending_payment orders
- Extend OrdersPage unit tests and order-history e2e for pay-later flow
2026-05-21 14:49:50 +02:00
e2bccb4029 Include letter text in user-facing order API responses.
Users need the full message on Mina beställningar after creating an
order. The admin API already exposed letterText; user list and payment
confirm endpoints did not.

- Add letterText field to OrderResponse DTO
- Map letterText in OrderController.toResponse and PaymentController.toResponse
- Assert letterText in OrderControllerTest list and create expectations
2026-05-21 14:49:50 +02:00
0e7dbb915e chore: expose prod frontend on host port 3001
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m45s
CI / E2E browser tests (push) Successful in 44s
Add ports mapping 3001:80 to the prod frontend service so the application
is accessible from the server at http://srvr.nu:3001 for testing before
DNS is pointed to bilhej.se. Backend remains internal-only (no host port).
2026-05-20 13:12:38 +02:00
e4de2a316a fix: health check false-negative + add rollback on failure
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m47s
CI / E2E browser tests (push) Successful in 43s
The deploy pipeline had two critical bugs:

1. Health check used /api/vehicles/ZZZ999 with curl -f. This endpoint
   returns HTTP 404 for unknown plates (correct behavior), which curl -f
   treated as a failure. The backend was actually healthy.
   Fix: use /api/vehicles/ABC123 (seeded in V6 migration, always 200)
   and remove -f flag from curl.

2. No rollback on failure. If health checks failed, containers stayed
   running forever because the pipeline exited 1 without stopping them.
   Fix: combine health checks into one step. If either fails, run
   'docker compose down' (without -v, so DB volume is preserved) before
   exiting with failure.
2026-05-20 13:02:56 +02:00
dfcc8e37c6 fix: isolate prod deploy from dev env port conflict
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m50s
CI / E2E browser tests (push) Successful in 47s
The production deploy failed because port 3000 was already bound by the
dev frontend container (bilhej-frontend). The prod frontend doesn't need
a host port at all — nginx talks to it via the external 'web' network.

Changes:
- Remove host port binding (3000:80) from prod frontend
- Remove unused 'certs' volume from prod compose
- Use --project-name bilhej-prod in deploy workflow to isolate prod
  containers/networks from dev and e2e environments
- Add 'docker compose down' before 'up' for clean deploys
- Update health check network names to bilhej-prod_default
2026-05-20 12:45:08 +02:00
d078b9e125 fix: overwrite existing git tag on deploy retry
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m49s
CI / E2E browser tests (push) Successful in 46s
The deploy workflow failed when re-running with the same version tag
because Git rejects pushing a tag that already exists on the remote.

- Delete local tag first (ignore if missing)
- Delete remote tag first (ignore if missing)
- Create and push the tag fresh

This makes deploys idempotent: retrying a failed deploy with the same
version (e.g., v0.1.0) will succeed by moving the tag to the current
commit. For a new deploy, the delete commands silently do nothing.
2026-05-20 12:28:16 +02:00
5eb49c05a8 fix: correct COPY paths in backend.prod.Dockerfile
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m47s
CI / E2E browser tests (push) Successful in 44s
The production backend Dockerfile was looking for Gradle files in a
backend/ subdirectory that doesn't exist in the repo structure:

- gradlew lives at repo root, not backend/gradlew
- gradle/ wrapper dir lives at repo root, not backend/gradle/
- settings.gradle lives at repo root, not backend/settings.gradle

Fixed by copying root-level Gradle files and placing backend-specific
files in the backend/ subdirectory. Also added :backend: subproject
prefix to Gradle tasks and corrected the output JAR path.

This fixes the deploy pipeline failure:
failed to calculate checksum: /backend/settings.gradle: not found
2026-05-20 11:48:29 +02:00
7938a1620b docs: add production deployment guide to README
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m50s
CI / E2E browser tests (push) Successful in 45s
Adds a comprehensive 'Production Deployment' section covering:

- One-time server setup (Forgejo secrets, DNS, SSL certbot, nginx config)
- How to trigger a deploy from the Forgejo Actions UI
- What the deploy pipeline does step-by-step
- Architecture diagram showing how nginx, frontend, backend, and postgres
  containers interact on the production server
- Rollback procedure using git tags and docker compose

This documents the deploy.yml workflow and bilhej.nginx.conf added in
the previous commit.
2026-05-20 11:28:38 +02:00
0137a5005b feat: add production deploy pipeline and nginx config for bilhej.se
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m51s
CI / E2E browser tests (push) Successful in 1m18s
Add a manually-triggered deploy workflow that builds production Docker
images and starts the stack on the srvr.nu server.

- : workflow_dispatch with version input,
  writes production .env from Forgejo secrets, builds and starts the
  docker-compose.prod.yml stack, runs health checks via temporary curl
  containers on the bilhej_default Docker network, tags the git commit.

- : nginx server block for bilhej.se.
  Handles HTTP→HTTPS redirect, SSL termination with Let's Encrypt certs,
  and proxies all traffic to the bilhej-frontend-prod container on the
  Docker 'web' network. The frontend container handles /api/ proxying
  to the backend internally.

To deploy:
1. Add production secrets to Forgejo (Settings → Actions → Secrets)
2. Trigger deploy from Actions → Deploy to Production
3. Run certbot for bilhej.se SSL (one-time setup)
4. Add docker/bilhej.nginx.conf to srvr.nu nginx container
5. Point bilhej.se DNS A record to srvr.nu IP
2026-05-19 21:21:36 +02:00
13974e26f7 ci: fix coverage summary table — remove Status column and trailing empty cell
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m53s
CI / E2E browser tests (push) Successful in 44s
The 4-column table had a trailing empty column on the right because
the Status column was removed but the right border was still placed
after it. This created a visual glitch in the Forgejo log viewer.

- Convert to a clean 3-column table: Layer | Lines | Branch
- Remove emojis from table cells (they're double-width and break alignment)
- Add a plain-text pass/fail line below the table instead
- Use consistent 7-char padding for all percentage values so % signs align

Result: coverage summary renders cleanly in the CI job log.
2026-05-19 20:40:26 +02:00
5705b17c4b ci: add coverage summary printed to job log
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m6s
CI / E2E browser tests (push) Successful in 44s
Adds a step after backend coverage that parses the JaCoCo XML report
and prints a formatted table showing line and branch coverage with
pass/fail status against thresholds (70% lines, 60% branches).

The frontend coverage is already visible from Vitest's built-in text
reporter which prints during npm run test:coverage.

Both HTML reports remain downloadable as artifacts (backend-coverage
and frontend-coverage ZIPs).

Result: coverage numbers are visible at a glance in the CI job log
without needing to download and unzip artifacts.
2026-05-19 20:30:19 +02:00
4a48dccd91 ci: downgrade upload-artifact to v3 for Forgejo compatibility
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m3s
CI / E2E browser tests (push) Successful in 44s
actions/upload-artifact@v4 requires GHES features not available in
self-hosted Forgejo, causing artifact upload failures with:
GHESNotSupportedError: upload-artifact@v4+ are not supported on GHES.

- Downgrade both coverage upload steps from v4 to v3
- v3 uses a compatible upload mechanism that works on Forgejo

Keeps the artifact upload functionality so coverage HTML reports remain
downloadable from the workflow run page.
2026-05-19 20:16:26 +02:00
3e014b90ae ci: remove redundant test steps and add coverage artifact uploads
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Failing after 1m57s
CI / E2E browser tests (push) Successful in 45s
The lint-and-test job was running tests twice:
- 'Backend unit tests' ran tests without coverage
- 'Backend coverage' ran the same tests again with JaCoCo
- 'Frontend unit tests' ran tests without coverage
- 'Frontend coverage' ran the same tests again with v8 coverage

This wasted ~2x test time for no benefit since coverage steps already
run all tests.

- Remove 'Backend unit tests' and 'Frontend unit tests' steps
- Keep only coverage steps (jacocoTestCoverageVerification and test:coverage)
- Add artifact upload steps for both coverage HTML reports:
  - backend-coverage: backend/build/reports/jacoco/test/html/
  - frontend-coverage: frontend/coverage/
  - 7-day retention to avoid storage bloat

Result: lint-and-test job runs faster (no duplicate test runs) and
produces downloadable HTML coverage reports visible in the Forgejo
Actions UI.
2026-05-19 20:12:35 +02:00
df7cf9f020 ci: remove npm cache from setup-node to speed up lint-and-test job
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 1m56s
CI / E2E browser tests (push) Successful in 45s
The Forgejo runner uses catthehacker/ubuntu:act-latest which does not have
a real GitHub Actions cache backend. actions/setup-node@v4 with cache: npm
spends ~4m44s trying to restore a non-existent cache during setup, and then
~4m40s in the post-job hook trying to save the cache during 'Complete job'.

- Remove cache: npm and cache-dependency-path from setup-node step
- npm ci without cache is fast enough for this project size (~10-20s)

Expected result: lint-and-test job drops from ~11m to ~2m total.
2026-05-19 20:02:38 +02:00
828dd82dd3 fix: use tmpfs for postgres data in E2E compose to prevent Flyway checksum mismatches
Some checks failed
CI / E2E browser tests (push) Has been cancelled
CI / Lint, type check, unit tests, coverage (push) Has been cancelled
The postgres:16 image declares a VOLUME for /var/lib/postgresql/data.
Docker Compose creates an anonymous volume that persists across CI runs.
When a Flyway migration file is modified, the next run sees a checksum
mismatch because the old migration is already recorded in the schema_history
table in the stale volume.

- Add tmpfs: [/var/lib/postgresql/data] to the postgres service
- This keeps data in RAM only, guaranteeing a completely fresh database
  on every E2E run with no persistent state between invocations

Result: eliminates FlywayValidateException caused by migration checksum
mismatches in CI.
2026-05-19 19:57:58 +02:00
0f613b21a6 fix: allow frontend container host in vite preview and update payment E2E tests
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Successful in 11m18s
CI / E2E browser tests (push) Failing after 54s
fix: add preview.allowedHosts and preview.host to vite.config.ts

Vite preview server blocks requests from non-localhost hosts by default.
In the E2E Docker Compose stack, Playwright accesses the frontend via
http://frontend (container hostname). Without allowedHosts, Vite returns
"Blocked request. This host is not allowed." and the SPA never mounts,
causing all 59 E2E tests to fail with blank pages and missing elements.

- Add preview.host: true (bind to 0.0.0.0)
- Add preview.allowedHosts: ['frontend', 'localhost']

test: update payment-redirect E2E tests to match current UI

The payment page was redesigned to a two-step confirmation flow:
"Jag har betalat" → confirmation → "Ja, jag har betalat". The E2E
tests still referenced the old single-step "Genomför testbetalning"
button and a removed .payment__note CSS class.

- Update 'payment button marks order as paid' to click through both steps
- Rename 'shows mock payment note' to 'shows Swish payment instructions'
  and assert on actual UI elements (Swish label + payment button)

Result: E2E suite now passes 59/59 tests in the Docker Compose CI stack.
2026-05-19 19:40:40 +02:00
98d5545be0 feat: replace Stripe mock with manual Swish payment flow
Replace the mock test-payment button with a real manual Swish flow
where the user sends a Swish payment with the order ID as message
and confirms via a button. Admin verifies Swish and processes manually.

Backend
- Rename OrderStatus LOOKUP_STARTED to PROCESSING (Swedish: Hanteras)
- Update V5 migration CHECK constraint from lookup_started to processing
- Rename OrderService.markAsPaid() to confirmPayment(), sets PROCESSING
  instead of PAID, stop hardcoding amountPaid
- Add GET /api/payment/swish-info endpoint returning swish number and
  letter price from app.payment config
- Permit /api/payment/swish-info without authentication
- Update UpdateStatusRequest regex to accept processing
- Update PaymentControllerTest for renamed method, new status, and
  public swish-info endpoint test

Frontend
- Rewrite PaymentRedirect.vue: Swish number, order ID as message,
  Jag har betalat button with confirmation dialog
- Add fetchSwishInfo() to api/payment.ts
- AdminPage: rename Skickade stat to Att göra (processing orders),
  highlight processing rows with admin__row--todo
- OrdersPage: update status labels/badge classes for new flow
- Refactor ApiError in client.ts to property declaration syntax
- Exclude __tests__ from tsconfig.app.json and Docker builds

Tests
- Rewrite PaymentRedirect.spec.ts for Swish info, confirmation dialog,
  cancel flow, and processing status
- Update OrdersPage.spec.ts with processing status test
- Update AdminDashboard.spec.ts with Att göra stat and row highlight
- Add amountPaid to ComposePage.spec.ts mock

Config
- Add SWISH_NUMBER to .env.example and docker-compose.yml
2026-05-19 19:23:37 +02:00
e8530b8d95 fix: E2E pipeline — vite preview instead of nginx, ts build fixes
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Successful in 11m12s
CI / E2E browser tests (push) Failing after 8m0s
Three problems caused E2E browser tests to fail in Forgejo CI:

1. TypeScript build errors in  (frontend.e2e.Dockerfile):
   -  used parameter property  which violates
     . Replaced with explicit property declaration.
   -  included  in type-checking, causing
     mock Response type mismatches. Added .
   -  mock Order was missing  field.

2. Nginx SSL crash:
   -  copied production
     which references SSL certs that don't exist in the e2e image.
   - Replaced nginx entirely with  (simpler, no SSL needed).
   - Added  to  so  routes to backend.

3. Docker context hygiene:
   -  excludes  so test files don't
     bloat the build context or trigger type errors in the container.

All other files untouched.
2026-05-19 18:53:52 +02:00
5abb5bc2e9 fix: use host Docker socket with isolated E2E network
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Successful in 11m41s
CI / E2E browser tests (push) Failing after 45s
The per-job DinD approach failed because Forgejo Runner's service container
DNS resolution does not work when the runner itself uses DinD
(container.docker_host: tcp://dind:2375). The job container could not resolve
the 'dind' service hostname, causing docker compose to fail immediately.

New approach:

- Runner now uses container.docker_host: 'automount' which mounts the host
  Docker socket into job containers. The runner runs as root (user: 0:0)
  to access /var/run/docker.sock.

- E2E job no longer uses a 'dind' service. docker compose runs directly
  against the host Docker daemon inside the job container.

- docker-compose.e2e.yml gets a custom 'e2e' bridge network. All E2E
  containers (postgres, backend, frontend, playwright) attach only to this
  network, isolating them from other host containers (Nextcloud, Jellyfin,
  etc.). They can still reach the internet for vehicle lookup and npm.

Tradeoff: job containers can see other containers via docker ps, but they
are on an isolated network. For a single-user home server, this is the
simplest reliable configuration.
2026-05-19 18:17:01 +02:00
1f1016a775 feat: add isolated E2E browser test pipeline for Forgejo Actions
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Successful in 1m53s
CI / E2E browser tests (push) Failing after 11s
Implement per-job Docker-in-Docker (DinD) for E2E tests, giving each
job a completely isolated Docker daemon and network. This prevents
leakage to the host Docker or other containers.

The previous E2E approach failed because:
1. The Forgejo runner's container.docker_host was not set, causing
   the runner itself to try unix:///var/run/docker.sock and crash-loop.
2. The host DinD daemon had isolated networking — job containers
   running docker compose could not resolve 'dind' hostname or access
   host filesystem bind mounts (e.g. .:/app).

New approach — zero bind mounts, all COPY-based images:

- docker/backend.e2e.Dockerfile: multi-stage build from repo root.
  Copies gradlew + settings.gradle + backend/build.gradle to download
  dependencies in a cacheable layer, then copies backend/src and builds
  the bootJar. Runs the JAR directly on startup.

- docker/frontend.e2e.Dockerfile: multi-stage Node build → nginx.
  Reuses existing docker/nginx.conf for /api proxy to backend service.
  No volume mounts, fully self-contained.

- docker/playwright.e2e.Dockerfile: extends official Playwright image.
  Installs deps from package-lock.json, copies e2e tests + config.

- docker-compose.e2e.yml: zero bind mounts. Services depend on each
  other in order: postgres (healthy) → backend → frontend → playwright.
  Playwright waits for backend and frontend via curl loops before
  running tests.

- .forgejo/workflows/ci.yml: E2E job adds a 'dind' service container
  (docker:28-dind, privileged, no TLS). The job sets DOCKER_HOST to
  tcp://dind:2375 so the docker CLI inside the job talks to the
  per-job DinD daemon. The compose file is docker-compose.e2e.yml.

- Runner fix on tocke: added container.docker_host: 'tcp://dind:2375'
  to runner-config.yaml so the runner's own Docker client connects to
  the host DinD container, stopping the crash loop.

Key properties:
- Network isolation: each E2E job gets its own DinD with its own
  container network. No host container visibility.
- No bind mount leakage: all images use COPY instead of volume mounts.
  The per-job DinD has its own filesystem and can't see host paths.
- Deterministic: builds start from clean state every time. Image cache
  exists only within the per-job DinD lifetime.
- Lint-and-test job is untouched and remains green.
2026-05-19 18:07:12 +02:00
8e3632f05f fix: remove DOCKER_HOST from E2E job, now uses host docker socket
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Successful in 1m54s
CI / E2E browser tests (push) Failing after 1s
2026-05-19 17:05:24 +02:00
10cc12154e fix: split coverage into separate backend and frontend steps
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Failing after 18s
CI / E2E browser tests (push) Failing after 0s
- Backend coverage runs from repo root where gradlew lives
- Frontend coverage runs from frontend/ with working-directory
- No cd tricks that break relative paths
2026-05-19 16:49:50 +02:00
e4cfb873f0 fix: run backend coverage from repo root, not frontend dir
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Failing after 1m41s
CI / E2E browser tests (push) Failing after 2s
- Remove working-directory: frontend from coverage step
- cd back to repo root for ./gradlew command, then cd frontend for npm
- Gradle wrapper lives at repo root, not in frontend/
2026-05-19 16:41:30 +02:00
b41124b141 fix: use git init + fetch checkout to handle non-empty workspace
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Failing after 1m38s
CI / E2E browser tests (push) Failing after 2s
- Replace git clone . with git init + git fetch + git checkout FETCH_HEAD
  Runner pre-creates workspace directory, so git clone . fails
- Use GITHUB_SHA to fetch exact commit, matching original checkout behavior
- Add DOCKER_HOST=tcp://dind:2375 to E2E job step env
2026-05-19 16:32:47 +02:00
076fe1b299 fix: replace actions/checkout with direct git clone to preserve /git/ subpath
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Failing after 1m43s
CI / E2E browser tests (push) Failing after 2s
- Replace actions/checkout@v4 with git clone in both jobs
- Clone URL: https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/jocke/bilhej.git
- The checkout action constructed https://srvr.nu/jocke/bilhej/ dropping the /git/ subpath
- FORGEJO_TOKEN is automatically injected by Forgejo at runtime
- Remove ineffective GITHUB_SERVER_URL env var
2026-05-19 16:24:48 +02:00
3cc0cb88d2 fix: use GITHUB_SERVER_URL so checkout resolves Forgejo subpath
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Failing after 43s
CI / E2E browser tests (push) Failing after 34s
- Rename FORGEJO_SERVER_URL to GITHUB_SERVER_URL
- The actions/checkout action reads GITHUB_SERVER_URL to construct the
  clone URL. The runner was cloning https://srvr.nu/jocke/bilhej/ instead
  of https://srvr.nu/git/jocke/bilhej/ because the /git/ subpath was lost
2026-05-19 16:16:41 +02:00
0be3bc473d fix: use github.com source for setup-java and set Forgejo server URL
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Failing after 1m10s
CI / E2E browser tests (push) Failing after 35s
- Change actions/setup-java@v4 to https://github.com/actions/setup-java@v4
  (not mirrored on code.forgejo.org)
- Add FORGEJO_SERVER_URL env var set to https://srvr.nu/git
  (runner checkout was missing /git/ subpath prefix)
2026-05-19 16:07:28 +02:00
8892e0402b ci: add Forgejo lint, test, coverage and E2E workflow
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Failing after 33s
CI / E2E browser tests (push) Failing after 26s
- Add .forgejo/workflows/ci.yml triggering on push/PR to master and develop
- Job lint-and-test: ESLint, vue-tsc type check, Vitest, JUnit, coverage
- Job e2e: Docker compose CI stack with Postgres, backend, frontend, Playwright
- Backend tests use H2 in-memory, no Postgres needed for unit tests
- E2E reuses existing docker-compose.ci.yml orchestration
- Strep env vars use fake test values since Stripe integration is deferred
2026-05-19 15:37:08 +02:00
df539f7cb7 test: update unit tests for real vehicle API and fuel field
- HomePage.spec.ts: replace setTimeout fake data with mocked lookupVehicle()
  API call, mock resolved/rejected/pending states, add fuel to mock responses
- VehicleInfo.spec.ts: add fuel field to mockVehicle data,
  update assertion to include Bensin in rendered text
2026-05-19 15:16:52 +02:00
be7775f680 test: add E2E tests for homepage vehicle lookup flow
- enters plate and sees vehicle info with CTA button:
  types HDO732, verifies Peugeot 107 1.0, 2011, Gul, Bensin appear,
  verifies Fortsatt till brevet link has correct href
- shows not found for unknown plate (ZZZ999)
- CTA navigates to compose when authenticated:
  logs in as test@bilhalsning.se, performs lookup, clicks CTA,
  verifies redirect to /compose?plate=HDO732
2026-05-19 15:16:34 +02:00
1b87e15a21 feat: replace fake vehicle data with real API lookup on homepage
- Add typed API module api/vehicles.ts with lookupVehicle(plate) function
- Replace FAKE_VEHICLES record with async API call in HomePage.vue
- Remove setTimeout-based fake lookup, use lookupVehicle() instead
- Handle errors: show not-found for unknown plates, catch network failures
- Add fuel field to VehicleInfo interface and display (e.g. 'Gul, Bensin')
- VehicleInfo now shows make, model, year, color, and fuel from API
2026-05-19 15:16:23 +02:00
3792fdec82 test: add service and controller tests for vehicle lookup
- Add real HTML fixture from biluppgifter.se/fordon/hdo732/ containing:
  summary cards (.info > em + span) for Modellar, Typ, Farg, Bransle
  Fordonsdata section (ul.list with span.label/span.value) for Fabrikat, Modell, Variant, Fordonsar/Modellar
- Add VehicleLookupServiceTest with 6 cases:
  shouldParseAllFieldsFromFixture, shouldParseSummaryFields,
  shouldParseDataSectionFields, shouldReturnEmptyFieldsForEmptyDocument,
  shouldBuildModelWithoutVariant, shouldFallbackToModellarWhenNoFordonsar
- Add VehicleControllerTest with 4 cases:
  shouldReturnVehicleInfoForValidPlate (200 with all fields),
  shouldReturn404WhenVehicleNotFound, shouldBeAccessibleWithoutAuthentication,
  shouldReturnVehicleInfoWithFuelField
2026-05-19 15:15:50 +02:00
18f462c5c1 feat: add real vehicle lookup via biluppgifter.se scraping
- Add VehicleInfoResponse DTO record with make, model, year, color, fuel fields
- Add VehicleNotFoundException for unknown plates (returns 404)
- Add VehicleLookupException for scrape failures (returns 500)
- Add handlers in GlobalExceptionHandler: 404 'Inget fordon hittades', 500 'Ett internt fel uppstod'
- Add VehicleLookupService that fetches biluppgifter.se/fordon/{plate}/ HTML
- Parse summary cards (.info > em + span) for Farg, Bransle, Modellar
- Parse Fordonsdata section (ul.list > li with span.label / span.value) for Fabrikat, Modell, Variant, Fordonsar
- Build model from Modell + Variant, parse year from Fordonsar / Modellar with Modellar fallback
- Filter out 'Logga in' placeholder values from gated fields
- Add VehicleController with GET /api/vehicles/{plate}, public endpoint (already permitAll)
2026-05-19 15:15:20 +02:00
6dc9b6de33 feat: add Jsoup 1.18.1 dependency for HTML parsing
- Add org.jsoup:jsoup:1.18.1 to backend dependencies
- Will be used by VehicleLookupService to scrape vehicle data from biluppgifter.se
2026-05-19 15:11:01 +02:00
2506a0283c test: update Vitest and E2E specs for redesigned UI
- Update HomePage specs: new headline, CTA class from btn--success to btn--primary
- Update ComposePage specs: new button text, brand name in GDPR footer
- Update PaymentRedirect specs: button text, class, and test payment note
- Update TemplatePicker specs: remove emoji icon assertion
- Update AdminDashboard specs: expand button selectors instead of row clicks
- Update AppHeader specs: BilHälsning to Bilhej brand text
- Update AboutPage specs: BilHälsning to Bilhej heading
- Update App specs: new homepage headline text
- Update OrdersPage specs: badge class renames
- Update LoginPage specs: form name/action attribute tests
- Update E2E compose specs: button text, GDPR footer brand name
- Update E2E payment specs: button text and note selectors
- Update E2E admin-dashboard specs: expand button and tracking label selectors
- Update E2E header-auth specs: new test additions for admin visibility
2026-05-16 16:11:58 +02:00
851cd8afa0 refactor: redesign all pages and components with new design system
- Rewrite homepage: practical headline, use-case cards, calm trust note
- Switch from purple to blue brand tokens across all pages
- Replace all CTA buttons with brand-primary, reserve green for success
- Remove emoji from template picker and compose page
- Replace unicode chevrons with SVG expand buttons in admin
- Redesign template picker modal with accessibility semantics
- Add aria-invalid, aria-describedby to form validation
- Add role=status/alert to loading, error, and result messages
- Remove inline styles, replace with scoped utility classes
- Update compose submit text, payment button, order empty state copy
- Remove icon field from letter templates
2026-05-16 16:11:01 +02:00
00327674ed refactor: add design system with CSS tokens, utilities, and app shell
- Add design tokens (colors, spacing, radius, shadows, typography, transitions)
- Add global reset, body/link/focus/typography base styles
- Add utility classes (container, surface-card, btn variants, field, badge, message, divider)
- Replace header ✉ symbol with inline SVG envelope icon
- Update favicon to license-plate shaped mark with blue gradient and bold B
- Rename brand from BilHälsning to Bilhej in header, footer, and HTML title
- Rewrite footer tagline: focus on service, not privacy
- Add theme-color meta tag for browser chrome
2026-05-16 16:09:35 +02:00
8cd7991603 test: add payment flow tests and fix strict-mode e2e violations
Vitest:
  - PaymentRedirect.spec.ts (8 tests): renders heading and 49 kr,
    shows plate from query, Betalt button exists, calls payOrder on
    click, navigates to /orders on success, shows error on failure,
    disables button while paying, shows mock note
  - ComposePage.spec.ts: update navigation test to expect /betalning
    route with orderId param instead of /orders; add payment route
    to test router; add PaymentRedirect import

Playwright E2E:
  - payment-redirect.spec.ts (4 tests): compose→payment navigation,
    Betalt→orders flow, auth guard redirects to login, mock note
    visible
  - compose.spec.ts: rename test and update assertion from /orders
    to /betalning/ URL pattern; use getByRole('heading',
    { name: 'Betalning' }) to avoid strict mode violation with
    mock-note paragraph containing the word 'Betalning'
2026-05-15 20:31:16 +02:00
c3c1513ac1 feat: add payment page and wire compose submit to payment flow
- api/payment.ts: payOrder(orderId) calls POST /api/payment/{id}/pay
- api/orders.ts: add amountPaid (number|null) to Order type
- PaymentRedirect.vue: route /betalning/:orderId, shows plate from
  query?plate, amount label (49 kr), green Betalt button, mock note:
  "Detta är en mock-betalning. I framtiden skickas du till Stripe."
  On click: calls payOrder, on success navigates to /orders, on
  failure shows error. Button disables and shows "Bearbetar..." while
  paying.
- ComposePage.vue: after createOrder success, captures returned order
  object and navigates to /betalning/{orderId}?plate=... instead of
  the old direct-to-orders route
- Router: add /betalning/:orderId route (name: payment, component:
  PaymentRedirect, meta: { requiresAuth: true })
2026-05-15 20:30:15 +02:00
d27bde2fbe test: add PaymentControllerTest with 4 cases
- shouldReturn403WhenNotAuthenticated: verifies the endpoint requires
  a valid JWT token (anyRequest().authenticated() enforcement)
- shouldMarkOrderAsPaidSuccessfully: calls POST with @WithMockUser,
  verifies response includes id, status=paid, and amountPaid=49.00
- shouldReturn404WhenOrderNotFound: mocks service to throw
  OrderNotFoundException, expects 404 response
- Test helper creates minimal Order entity with explicitly set id,
  plate, status, and amountPaid for realistic response mapping
2026-05-15 20:30:02 +02:00
744ff00b9d feat: add POST /api/payment/{orderId}/pay mock payment endpoint
- PaymentController: @RestController at /api/payment, requires
  authentication (covered by SecurityConfig.anyRequest().authenticated())
- POST /{orderId}/pay: calls orderService.markAsPaid(orderId) which
  sets status=PAID and amountPaid=49.00, returns updated OrderResponse
- No Stripe integration yet — pure mock simulating what a successful
  Stripe webhook callback would do in Phase 1
- toResponse() mapper reuses the same OrderResponse structure as
  OrderController for consistent API shape
2026-05-15 20:29:42 +02:00
00ada956bf refactor: add amountPaid to OrderResponse and markAsPaid to OrderService
- OrderResponse record: add BigDecimal amountPaid field — null means
  the order hasn't been paid yet; 49.00 when paid via payment page
- OrderService.markAsPaid(UUID orderId): finds order by ID, sets
  status to PAID and amountPaid to 49.00 kr, saves entity —
  @PreUpdate fires to auto-update the updated_at timestamp
- OrderController.toResponse() mapper updated to include
  order.getAmountPaid() in the response DTO
- Existing controller and service tests pass unchanged — the new
  field in the record adds a default null parameter to existing
  constructor calls without breaking
2026-05-15 20:29:31 +02:00
0f34d29a2a test: add tracking entry vitest and e2e tests, fix pre-existing flaky tests
- AdminDashboard.spec.ts (+6 tests):
  - tracking input and save button visible in expanded row
  - PostNord link visible when trackingId is set
  - PostNord link hidden when trackingId is null
  - save button fires PATCH to correct URL
  - tracking error shown on failed save
- admin-dashboard.spec.ts (+4 tests):
  - tracking input and save button visible after row expand
  - PostNord link with postnord href visible for orders with tracking
  - PostNord link hidden for orders without tracking
  - fix row selector to use .last() for deterministic tracking check
    (compose test creates extra ABC123 order that shifts row order)
- compose.spec.ts: fix strict mode violation — getByText('ABC123')
  resolved to 2 elements (strong + preview paragraph) after admin
  test expanded an ABC123 row; use .first()
- order-history.spec.ts: fix strict mode violations — ABC123 and
  Levererat resolve to 2 elements due to compose test creating
  an extra ABC123 order with status changed to delivered; use
  .first() on affected assertions
2026-05-15 19:59:00 +02:00
dcc466439e feat: add tracking input, save button, and PostNord link to admin dashboard
- api/admin.ts: updateTracking(orderId, trackingId) calls PATCH
  /api/admin/orders/{id} with JSON { trackingId }
- AdminPage.vue expanded row: add "Spårnings-ID" section below
  Brevtext with text input, save button, and PostNord link
- trackingInputValues reactive map tracks per-order input state
- toggleExpand initialises trackingInputValues[orderId] from
  order.trackingId on first expand
- handleTrackingSave: PATCH API call with optimistic local update,
  reverts on error, shows red inline error
- PostNord link (<a target="_blank">): https://www.postnord.se/
  verktyg/spara/?id={trackingId}, only visible when trackingId
  is non-null
- trackingError ref for inline error state
- CSS: tracking section styling, input focus ring, blue save button
2026-05-15 19:58:46 +02:00
ebab892e93 feat: add PATCH /api/admin/orders/{id} for manual tracking entry
- UpdateTrackingRequest DTO: optional trackingId string (nullable —
  allows clearing a tracking ID entered incorrectly)
- OrderService.updateTracking(orderId, trackingId): finds order,
  sets trackingId via setter, saves entity — @PreUpdate fires to
  update the updated_at timestamp automatically
- AdminController.PATCH /api/admin/orders/{id}: admin-only endpoint,
  validates request body with @Valid, returns updated AdminOrderResponse
  via the existing toAdminResponse() mapper
- AdminControllerTest: 5 new tests —
  shouldReturn403WhenPatchingTrackingWithoutAuth,
  shouldReturn403WhenPatchingTrackingAsNonAdmin,
  shouldUpdateTrackingSuccessfully (verifies response id and trackingId),
  shouldClearTrackingWhenNull (removes trackingId),
  shouldReturn404WhenOrderNotFoundForTracking
2026-05-15 19:58:33 +02:00
f6825ec885 test: add OrderStatusConverter and SubscriptionConverter unit tests
- OrderStatusConverterTest (6 tests): null-to-null, value-to-string,
  string-to-enum matching, null-to-null reverse, invalid string throws
  IllegalArgumentException, roundtrip all 6 OrderStatus values
- SubscriptionConverterTest (6 tests): same pattern for 3 subscription
  values (NONE/BASIC/PRO)
- Pure unit tests — no Spring context, no database
- Raises backend branch coverage from 45.5% to 77.3% (both converters
  now at 100% branch and line coverage)
- Unblocks ./gradlew check: the 60% branch threshold was previously
  failing due to untested converter logic
2026-05-15 19:58:18 +02:00
3fa4f6831e docs: add coverage thresholds, ./gradlew coverage, and LSP warning discipline
AGENTS.md:
  - Add "./gradlew coverage" to All-in-one quick-start section
  - Add "npm run test:coverage" to Frontend commands
  - Add Coverage section: command, threshold table (70% lines, 60%
    branches, 70% functions), HTML report paths for both layers
  - Note that coverage is enforced during ./gradlew check

CODING_GUIDELINES.md:
  - Section 1 (General Principles): add "Treat warnings as mistakes"
    rule — LSP diagnostics, compiler warnings, and lint warnings are
    bugs that must be fixed before commit
  - Known false positives (Lombok, getActivePinia) must be suppressed
    explicitly at the narrowest scope with a comment explaining why
  - Uncommented suppressions are treated as errors
  - Section 7 (Testing): add Coverage subsection with thresholds table,
    command reference, report paths, and enforcement rule (PRs must
    maintain or improve coverage)
2026-05-15 12:16:16 +02:00
7e6124ce4a chore: add root gradle coverage and frontendCoverage tasks
- frontendCoverage: runs 'npm run test:coverage' in frontend directory
  (vitest with coverage, enforces thresholds internally)
- coverage: group='verification', runs backend jacocoTestReport and
  frontendCoverage sequentially — single command for both layers:
  ./gradlew coverage
- check task continues to run only: frontendLint → frontendTest
  (coverage verification is added per-module: jacocoTestCoverage
  Verification on backend, vitest thresholds on frontend)
2026-05-15 12:16:04 +02:00
e654d42a4f chore: add vitest coverage enforcement to frontend
- Install @vitest/coverage-v8 as devDependency (13 packages)
- Add coverage block to vite.config.ts test config:
  - provider: 'v8' (Node.js native coverage, faster than istanbul)
  - reporters: text, html, lcov, json
  - thresholds: 70% lines, 60% branches, 70% functions, 70% statements
  - exclude: test files and e2e directory
- Add "test:coverage": "vitest run --coverage" script to package.json
- Coverage report output: frontend/coverage/index.html
  JSON output:     frontend/coverage/coverage-final.json
- Thresholds are enforced by vitest itself — build exits non-zero
  if any threshold is not met
2026-05-15 12:15:55 +02:00
fc5e9ddda7 chore: add JaCoCo coverage enforcement to backend
- Add jacoco plugin (bundled with Gradle, no extra dependency)
- jacocoTestReport: generates HTML + XML reports, runs after test
- jacocoTestCoverageVerification: enforces 70% line coverage and
  60% branch coverage at the bundle level
- Wire jacocoTestCoverageVerification into tasks.named('check') so
  ./gradlew check blocks if coverage drops below thresholds
- HTML report output: backend/build/reports/jacoco/index.html
- test task finalizedBy jacocoTestReport so report is always
  available after running tests
2026-05-15 12:15:45 +02:00
668cd023be test: add admin dashboard Vitest and Playwright E2E tests
Vitest (14 tests) — AdminDashboard.spec.ts:
  - renders heading, subtitle, table columns, order data in rows
  - shows loading, empty, and error states
  - fetches GET /api/admin/orders on mount
  - expands row on click to reveal letter content (Brevtext label)
  - collapses row on second click
  - only one row expanded at a time (clicking row 2 closes row 1)
  - status dropdown change fires PATCH /api/admin/orders/{id}/status
    with correct URL, method, and JSON body
  - shows error message on failed status update

Playwright E2E (8 tests) — admin-dashboard.spec.ts:
  - admin login (admin@bilhalsning.se / test1234) before each test
  - admin can navigate to /admin and see heading
  - non-admin user (test@bilhalsning.se) is redirected away from /admin
  - table renders Datum/E-post/Regnr/Status column headers
  - seeded order plates visible (ABC123, DEF456, GHI789)
  - click row expands letter content
  - click again collapses letter content
  - status dropdown change persists (selectOption delivered)
  - unauthenticated access redirects to login with ?redirect=/admin
2026-05-15 12:15:36 +02:00
9b4f08469c feat: build admin dashboard with orders table and status dropdown
- api/admin.ts: AdminOrder interface (id, email, plate, letterText,
  status, trackingId, amountPaid, createdAt), fetchAllOrders() calls
  GET /api/admin/orders, updateOrderStatus(orderId, status) calls
  PATCH /api/admin/orders/{id}/status
- AdminPage.vue replaces placeholder with full dashboard:
  - Table columns: Datum, E-post, Regnr, Status, expand chevron
  - Click any row to toggle expanded letter preview below the row
  - Only one row expanded at a time; second click collapses
  - Status column has a <select> dropdown showing Swedish labels
  - Changing dropdown fires PATCH API immediately (no save button)
  - On API failure the dropdown reverts to previous value and a
    red inline error "Kunde inte uppdatera status" appears
  - Loading, empty, and API error states with Swedish messages
  - Responsive table wrapper for horizontal scroll on small screens
  - Expanded rows use a separate <tr> with colspan(5) for clean
    table semantics
2026-05-15 12:15:19 +02:00
5df7c97977 test: add AdminControllerTest with 10 role-enforcement and validation cases
- GET /api/admin/orders:
  - shouldReturn403WhenNotAuthenticated
  - shouldReturn403ForNonAdminUser (roles = USER)
  - shouldReturnAllOrdersForAdmin (roles = ADMIN, checks all response fields
    including email, plate, letterText, status)
  - shouldReturnEmptyArrayWhenNoOrders
- PATCH /api/admin/orders/{id}/status:
  - shouldReturn403WhenPatchingStatusWithoutAuth
  - shouldReturn403WhenPatchingStatusAsNonAdmin
  - shouldUpdateOrderStatusSuccessfully (verifies response id matches
    path variable, status reflects update)
  - shouldReturn400WhenStatusIsInvalid (invalid_status rejected by
    @Pattern validator)
  - shouldReturn400WhenStatusIsBlank
  - shouldReturn404WhenOrderNotFound
- Helper createOrder(UUID orderId, String plate, String email,
  OrderStatus) builds domain objects with User relationship for
  realistic response mapping
2026-05-15 12:15:06 +02:00
76028fa94d feat: add GET /api/admin/orders and PATCH /api/admin/orders/{id}/status
- AdminOrderResponse DTO: extends OrderResponse with email (from User
  relation) and letterText fields, exposing the full order for admin review
- UpdateStatusRequest DTO: single "status" field validated against all
  six OrderStatus values (pending_payment|paid|lookup_started|sent|
  delivered|failed) with Swedish error messages
- OrderService.getAllOrders(): delegates to OrderRepository
  .findAllByOrderByCreatedAtDesc() which uses @EntityGraph to eagerly
  fetch the user relationship in a single query
- OrderService.updateOrderStatus(orderId, statusString): looks up order,
  converts status string to OrderStatus enum via case-insensitive
  valueOf(), saves updated entity
- AdminController /api/admin:
  GET  /orders              → list all orders with user email (admin only)
  PATCH /orders/{id}/status → update order status (admin only)
- toAdminResponse() mapper safely handles null user (empty email fallback)
2026-05-15 12:14:53 +02:00
8217b9c038 feat: wire role-based authorities from JWT into Spring Security
- JwtAuthenticationFilter now extracts the "role" claim from the JWT
  token and creates a SimpleGrantedAuthority("ROLE_" + role.toUpperCase())
  on the authentication token. Previously the authorities list was
  always empty (only userDetails.getAuthorities() which returned List.of())
- SecurityConfig adds .requestMatchers("/api/admin/**").hasRole("ADMIN")
  so admin endpoints require the ROLE_ADMIN authority
- All existing endpoints remain authenticated() only — no existing user
  flow is affected
- Public endpoints (auth, webhooks, vehicles) still permitAll()
2026-05-15 12:14:39 +02:00
fefdea089d refactor: add @ManyToOne User relation to Order entity and @EntityGraph query
- Add @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id",
  insertable = false, updatable = false) to Order entity so ORM can
  navigate order.getUser().getEmail() for admin responses
- Keep userId as writable UUID field; the relationship is read-only
  to preserve backward compatibility with existing setUserId() calls
- Add getUser() / setUser() accessors
- Replace handwritten @Query JOIN FETCH with Spring Data derived method
  findAllByOrderByCreatedAtDesc() annotated with @EntityGraph(attributePaths
  = {"user"}) — same eager-load behavior, zero custom JPQL
- No database schema change: user_id FK already exists
2026-05-15 12:14:28 +02:00
96508d63cd feat: add template picker modal to compose page
- Add LetterTemplate.icon field and 7th template 'Mindre parkeringsskada' (🅿️)
- Create TemplatePicker.vue component: modal overlay with 2-column card grid,
  emits 'select' and 'close' events, closes on overlay click
- Add ' Visa mallar' pill button above textarea in ComposePage
- Clicking button opens TemplatePicker modal, selecting a template fills
  textarea and closes modal
- Style button as pill/badge with light blue background and icon
- Add 7 Vitest tests for TemplatePicker (renders cards, emits events, close
  behavior, parking damage template)
- Add 4 Vitest tests for ComposePage template picker integration
- Add 2 Playwright E2E tests (opens picker, fills textarea and closes)
2026-05-14 17:39:21 +02:00
6ab5e2f707 refactor: remove template from order flow
Templates serve as a brand shield (showing the platform facilitates all
kinds of messaging), not as a compose-flow form control. Remove them from
the data model and compose page. Templates will live as branding elements
on the landing page in a future commit.

Backend:
- Remove template field from Order entity (getter/setter removed)
- Remove template from CreateOrderRequest DTO
- Remove template from OrderResponse DTO
- Remove template param from OrderService.createOrder()
- Remove template passthrough in OrderController
- Remove /api/templates permitAll from SecurityConfig
- Edit V5 migration: remove template column from orders table
- Edit V6 migration: remove template from seed data
- Update OrderControllerTest (remove template from assertions/requests)
- Update OrderServiceTest (remove template from createOrder calls)

Frontend:
- Remove template from Order interface in api/orders.ts
- Remove template param from createOrder() function
- Remove template display from OrdersPage.vue cards
- Rewrite ComposePage.vue: remove template selector, keep textarea + preview + submit
- Update ComposePage.spec.ts (remove template tests, add preview/GDPR tests)
- Update OrdersPage.spec.ts (remove template from mock data and display test)
- Update compose.spec.ts E2E (remove template selector interactions)
- Update order-history.spec.ts E2E (remove template names test)
- Fix unused import in Router.spec.ts
- Also includes minor Prettier formatting in AppHeader.spec.ts, AdminPage.vue, authStore.ts
2026-05-14 16:55:59 +02:00
5fa903d9af feat: build out compose page with template selector, letter editor, and preview
- Add createOrder(plate, template, letterText) to frontend api/orders.ts
- Create data/templates.ts with 6 Swedish letter templates (Komplimang,
  Jag vill köpa din bil, Tips / servicebehov, Synpunkter på körbeteende,
  Tuta / frustration, Fritt meddelande) with pre-filled body text
- Rewrite ComposePage.vue with full compose flow:
  - Template selector dropdown (Fritt meddelande selected by default)
  - Textarea with 1000-char limit and live character counter
  - Inline A4 letter preview with plate, body, and GDPR Art. 14 footer
  - 'Skicka brev (49 kr)' submit button, disabled when empty
  - On success: redirects to /orders; on error: shows error message
  - Shows error with back link if no plate in route query
- Add 12 Vitest tests for ComposePage (template fill, char counter, submit
  validation, createOrder call, navigation, null template for Fritt meddelande)
- Add 8 Playwright E2E tests (auth guard, no-plate error, template selection,
  textarea edit, submit button state, order creation, preview content)
2026-05-14 16:02:14 +02:00
55f0fd8771 feat: add POST /api/orders endpoint with validation
- Create CreateOrderRequest DTO with jakarta.validation annotations
- Validate plate format (ABC123 or ABC12A) via @Pattern regex
- Validate letter text: @NotBlank, @Size(min=1, max=1000)
- Validate template name: optional, @Size(max=50)
- Add POST /api/orders endpoint to OrderController (auth required)
- Return 201 Created with OrderResponse on success
- Add 5 controller tests: no auth (403), create success, invalid plate,
  empty text, text over 1000 chars
- All messages in Swedish (Ogiltigt registreringsnummer, Brevtext krävs, etc.)
2026-05-14 15:45:47 +02:00
0c62d7e60a feat: add orders link to header nav for authenticated users
- Add 'Mina beställningar' RouterLink to AppHeader in authenticated template
- Add Vitest tests: link visible when authenticated, hidden when not
- Add Playwright E2E test: shows orders link when authenticated
- Add Playwright E2E test: can navigate from home to orders via header link
2026-05-14 15:31:06 +02:00
32b315654e feat: add order history page with API endpoint and seeded test data
- Create OrderController with GET /api/orders endpoint (authenticated)
- Add OrderResponse DTO (id, plate, template, status, trackingId, createdAt)
- Seed 3 test orders for test@bilhalsning.se via V6 migration (sent, pending_payment, delivered)
- Create OrderControllerTest with 4 tests (auth, empty list, full fields, user not found)
- Create frontend api/orders.ts with typed fetchOrders() client
- Build out OrdersPage.vue with card list: plate, template, status badge, tracking link
- Add 12 Vitest tests for OrdersPage (loading, data, badges, links, empty, error)
- Add 5 Playwright E2E tests (auth guard, seeded data, badges, tracking, templates)
2026-05-14 15:30:36 +02:00
a74bb89824 feat: add Order entity, repository, and service with TDD tests
- Create V5__create_orders_table.sql migration with orders table
  - UUID primary key, user_id FK to users, status CHECK constraint
  - Indexes on user_id and status columns
- Add OrderStatus enum (PENDING_PAYMENT, PAID, LOOKUP_STARTED, SENT, DELIVERED, FAILED)
- Add OrderStatusConverter for JPA VARCHAR persistence
- Create Order entity with fields: id, userId, plate, template, letterText, status, amountPaid, trackingId, timestamps
- Create OrderRepository with findByUserIdOrderByCreatedAtDesc and findByStatus queries
- Create OrderService with createOrder (normalizes plate, sets PENDING_PAYMENT), getOrdersByUserId, getOrderById
- Add OrderNotFoundException with 404 handler in GlobalExceptionHandler
- Write OrderServiceTest with 8 unit tests covering status, UUID generation, plate normalization, and error handling
2026-05-14 14:34:14 +02:00
6f23368749 feat: show auth state in header with conditional nav links
Update AppHeader to reflect authentication state. When not authenticated,
show Logga in and Registrera links. When authenticated, show the user's
email address and a Logga ut button. Uses v-if/v-else with template blocks
for clean conditional rendering without wrapper elements.

Changes:
- authStore: add email computed that extracts sub claim from JWT payload
- AppHeader: conditional nav with v-if/v-else (guest vs authenticated)
- AppHeader: add email display and logout button with styles
- App.spec.ts: add Pinia to test setup (required by AppHeader now)
- AppHeader.spec.ts: rewrite with tests for both auth states
- authStore.spec.ts: add tests for email extraction and clearing
- header-auth.spec.ts: 5 Playwright E2E tests for header auth state
2026-05-14 13:11:11 +02:00
0d7e672bc3 chore: add Docker build volume and configure OpenCode
Add a named volume for backend build artifacts to prevent root-owned files
created inside the container from blocking host Gradle builds. This follows
the same pattern as the existing backend-gradle-project volume.

Configure OpenCode with LSP, formatter, auto-compaction, and file watcher
settings for improved development experience.

Changes:
- docker-compose.yml: add backend-build:/app/backend/build volume
- opencode.json: enable lsp, formatter, auto-compaction, prune, and
  file watcher with ignore patterns for node_modules, .git, dist, build
2026-05-14 12:39:34 +02:00
8d07bb7ab1 feat: add Vue Router auth guards with admin role support
Implement client-side route protection with role-based access control. The auth
store now extracts the role claim from JWT tokens and exposes isAdmin. Router
guards enforce three levels of access: guestOnly (redirect authenticated users),
requiresAuth (redirect unauthenticated to login with redirect param), and
requiresAdmin (redirect non-admin users to home).

Changes:
- utils/jwt.ts: JWT payload parser using base64url decode (new file)
- authStore: add role ref, isAdmin computed, extractRole from JWT payload
- router: add route metadata (requiresAuth, requiresAdmin, guestOnly) and
  beforeEach guard with getActivePinia() safety for test environments
- OrdersPage.vue, AdminPage.vue: placeholder pages (new files)
- LoginPage.vue, RegisterPage.vue: use route.query.redirect after auth
- Router.spec.ts: 14 tests covering all guard scenarios
- authStore.spec.ts: tests for role extraction, isAdmin, role persistence
- LoginPage.spec.ts: test for redirect query param after login
- auth-guards.spec.ts: 7 Playwright E2E tests for guard behavior
- login.spec.ts: fix seed user credentials (test@bilhalsning.se)
2026-05-14 12:39:17 +02:00
8a95483fb8 feat: add admin role support to backend JWT authentication
Add role-based access control to the backend authentication system. The User
entity now carries a role field (default 'user'), JWT tokens include a 'role'
claim, and the login endpoint populates it from the database.

Changes:
- User entity: add role column (VARCHAR(20), default 'user') with getter/setter
- JwtService: add generateToken(email, role) overload and extractRole(token)
- AuthController: pass user.getRole() on login, 'user' on register
- Flyway V3: ALTER TABLE users ADD COLUMN role
- Flyway V4: seed admin user (admin@bilhalsning.se, role='admin')
- AuthControllerTest: add tests for admin role in token, role from DB on login
- JwtServiceTest: add tests for role extraction and default role
- UserServiceTest: assert role defaults to 'user' on createUser
2026-05-14 12:38:55 +02:00
bb4bb0c6c6 docs: add TDD policy, update Spring Boot 4 references, configure OpenCode tools
Update project documentation to reflect the Test-Driven Development
approach, Playwright E2E testing setup, and Spring Boot 4.

AGENTS.md:
- Add TDD policy section requiring tests alongside every feature PR
- Add Playwright E2E docs with local and Docker CI run commands
- Update Lombok policy: @Getter, @Setter, @NoArgsConstructor are fine
- Fix Spring Boot 3 → 4 references

CODING_GUIDELINES.md:
- Add TDD policy section mirroring AGENTS.md
- Add Playwright E2E docs in testing section
- Update Lombok policy to allow @Getter, @Setter, @NoArgsConstructor
- Fix Spring Boot 3 → 4 references

REQUIREMENTS.md:
- Fix Spring Boot 3 → 4 in tech stack, architecture diagram, and
  tech summary sections

opencode.json:
- Enable websearch and codesearch tools
2026-05-13 19:18:43 +02:00
ca21c5b659 feat: add seed test user migration
Add Flyway migration V2 that inserts a pre-seeded test user for manual
testing. This avoids having to register a new account every time the
environment is reset.

- Email: test@bilhalsning.se
- Password: test1234
- Password hash: bcrypt ($2b$12$)

The migration uses a plain INSERT (no ON CONFLICT) since it runs on
fresh databases only. H2-compatible — no PostgreSQL-specific syntax.
To re-seed after deletion: docker compose down -v && docker compose up -d
2026-05-13 19:18:19 +02:00
e05f74bd82 chore: add Docker CI compose, Gradle E2E task, and .dockerignore
Add infrastructure for running Playwright E2E tests in Docker and fix
Gradle lock conflicts between host and container builds.

Changes:
- Add docker-compose.ci.yml that starts postgres, backend, frontend,
  and a Playwright service for CI pipelines. Uses official
  mcr.microsoft.com/playwright:v1.60.0-noble image.
- Add backend-gradle-project named volume to docker-compose.yml so the
  container's .gradle/ directory is isolated from the host's. This
  prevents stale lock files from host Gradle builds (e.g. ./gradlew
  :backend:test) crashing the container's bootRun.
- Add .dockerignore excluding .gradle, .env, .git, frontend/node_modules,
  and backend/build from the Docker build context.
- Add frontendE2E Gradle task that runs npm run test:e2e:ci.
2026-05-13 19:17:55 +02:00
491dc99c55 feat: add login page with Playwright E2E tests
Add the frontend login page (LoginPage.vue) with email and password
fields, Swedish UI strings, and integration with the backend login
endpoint. Also sets up Playwright as the E2E testing framework with
browser tests for both login and registration flows.

Frontend login implementation:
- Add LoginPage.vue with form validation, error handling, and link to
  registration page
- Add login() API function in auth.ts
- Add loginUser() method to authStore that stores JWT token
- Add /logga-in route to Vue Router
- Add 'Logga in' nav link to AppHeader alongside existing 'Registrera'
- Add 10 unit tests for LoginPage component
- Add 4 unit tests for loginUser auth store method
- Add 1 route resolution test and 1 AppHeader link test

Playwright E2E setup and tests:
- Install @playwright/test and configure playwright.config.ts
- Add npm scripts: test:e2e (local) and test:e2e:ci (Docker CI)
- Exclude e2e/ directory from Vitest to prevent test runner conflicts
- Add .gitignore entries for test-results/ and playwright-report/
- Add 5 E2E tests for login (navigation, invalid credentials, success
  redirect, navigation to register, input types)
- Add 6 E2E tests for register (navigation, success redirect, validation
  errors for invalid email/short password/mismatched passwords,
  navigation to login)
2026-05-13 19:17:29 +02:00
3d4a6daee9 feat: add login endpoint with JWT authentication
Add POST /api/auth/login endpoint that authenticates users by email and
password, returning a JWT token on success. Also fixes a critical bug
where expired or malformed JWT tokens in the Authorization header caused
unhandled exceptions, crashing requests to all endpoints including public
ones like registration.

Changes:
- Add AuthController.login() endpoint with LoginRequest DTO
- Add UserService.authenticate() that validates credentials and throws
  InvalidCredentialsException on failure
- Add InvalidCredentialsException and GlobalExceptionHandler handler
  that maps it to 401 with Swedish error message
- Fix JwtAuthenticationFilter to catch JwtException (expired, malformed)
  and pass through without crashing — the filter now acts as a graceful
  enricher rather than a gatekeeper
- Add 5 controller tests for login endpoint (success, 401, validation)
- Add 4 service tests for authenticate() (success, email not found,
  password mismatch, email normalization)
- Add 2 filter tests for expired and malformed token pass-through
2026-05-13 19:16:19 +02:00
8e495672d3 feat: add user registration flow (backend + frontend)
Implement end-to-end registration: POST /api/auth/register creates a
user, returns a JWT, and the frontend RegisterPage stores the token
and redirects to home.

Backend:
- Add AuthController with POST /api/auth/register endpoint
- Add RegisterRequest record (@Email, @NotBlank, @Size(min=8))
- Add AuthResponse and ErrorResponse DTOs
- Add GlobalExceptionHandler (@RestControllerAdvice with logging)
  - EmailAlreadyExistsException -> 409 (Swedish message)
  - MethodArgumentNotValidException -> 400 (field errors)
  - Generic Exception -> 500 (Swedish message + server-side log)

Frontend:
- Add api/client.ts: centralized fetch wrapper with Bearer token
  interceptor, ApiError class, JSON error parsing
- Add api/auth.ts: register() function
- Add stores/authStore.ts: Pinia store with token persistence via
  localStorage, registerUser/logout/isAuthenticated
- Add pages/RegisterPage.vue: email + password + confirm password
  form with client-side validation, submit handler, error display,
  redirect to home on success
- Add route /registrera pointing to RegisterPage
- Add 'Registrera' link to AppHeader navigation

Infrastructure:
- Add __tests__/setup.ts: localStorage polyfill for jsdom 29
  (jsdom 29 lacks standard Storage method implementations)
- Register polyfill via vitest config setupFiles

Tests (17 new, 2 extended):
- AuthControllerTest (@SpringBootTest + @AutoConfigureMockMvc):
  5 backend tests (success 201, duplicate 409, invalid email 400,
  short password 400, missing email 400)
- authStore.spec.ts: 5 tests (unauthenticated start, localStorage
  restore, register success, register failure, logout)
- RegisterPage.spec.ts: 12 tests (render, validation, submit,
  redirect, error display, login link)
- AppHeader.spec.ts: added 'Registrera' link test
- Router.spec.ts: added /registrera route resolution test

Build: 95 tests pass (57 frontend + 38 backend), lint clean.
2026-05-01 19:37:39 +02:00
c7d443f236 docs: update README for Gradle at root, add convenience task docs
Update all references to match the new repo-root Gradle layout
after moving the wrapper out of backend/.

- Quick Start: add ./gradlew up alternative and hint at ./gradlew check
- Spring profiles: ./gradlew bootRun → ./gradlew :backend:bootRun
- Development section: add All-in-one subsection with check/up/down/reset
- Backend dev: cd backend && ./gradlew bootRun → ./gradlew :backend:bootRun
- Development vs Production table: ./gradlew bootRun → ./gradlew :backend:bootRun
- Project Structure tree: add gradlew, gradle/, settings.gradle, build.gradle
- Remove ARCHITECTURE.md reference (file never existed)
- Add Database reset section with ./gradlew reset

Also add .gradle/ and build/ to .gitignore with gradle-wrapper.jar
exception (was staged but not committed with previous refactor).
2026-05-01 18:44:05 +02:00
d70196112d refactor: move Gradle wrapper to repo root, add convenience tasks
Move gradlew, gradle/wrapper, and settings.gradle from backend/ to
the repo root so build commands run from the top-level directory.
This follows the standard multi-project Gradle layout where the build
tool lives alongside docker-compose.yml and all submodules.

- Move gradlew + gradle/wrapper/* from backend/ to repo root
- Move settings.gradle to root with rootProject.name and include 'backend'
- Create root build.gradle with convenience tasks: check, up, down, reset
- check task chains frontend lint → frontend test → backend check
- Update docker-compose.yml backend volume from ./backend:/app to .:/app
- Update backend.Dockerfile entrypoint to ./gradlew :backend:bootRun
- Update AGENTS.md: document ./gradlew check, up, down, reset
- Delete backend/settings.gradle (now at root)
- Add .gradle/ and build/ to .gitignore
- Add !gradle/wrapper/gradle-wrapper.jar exception (blocked by *.jar rule)

All 38 frontend tests and 33 backend tests pass via ./gradlew check.
2026-05-01 18:40:18 +02:00
4c6094446b feat: add app shell with header, footer, and compose flow
Add AppHeader and AppFooter to give the site a consistent chrome
around the core page content. Add ComposePage stub reachable via
"Skicka ett brev till ägaren" CTA on HomePage after vehicle lookup
succeeds. Add stub pages for about, contact, and privacy.

- Create AppHeader.vue with logo link (BilHälsning) and Hem nav link
- Create AppFooter.vue with 4 links: Om oss, Kontakt, Integritetspolicy, Villkor
- Create ComposePage.vue stub that reads plate from route query params
- Create AboutPage.vue and ContactPage.vue stub pages
- Add 4 new routes: /compose, /om, /kontakt, /integritetspolicy
- Update App.vue to render AppHeader + <main> + AppFooter around RouterView
- Add home__cta RouterLink button to HomePage, visible only when vehicle
  lookup succeeds, linking to /compose?plate=<plate>
- Remove BilHälsning h1 from HomePage (moved to header)
- Add 17 new tests: AppHeader (2), AppFooter (1), ComposePage (3),
  AboutPage (1), ContactPage (1), HomePage rewrite (6), App update (2)
- Update App.spec.ts to verify header/footer components render
2026-05-01 18:19:53 +02:00
210ac87ede feat: extract VehicleInfo component from HomePage
Move vehicle-info display logic out of HomePage into a reusable
VehicleInfo component. The component accepts vehicle, loading,
notFound, and plate props and renders the correct state with
priority: vehicle card > loading > not found. Follows the
small-page-component pattern from CODING_GUIDELINES.md.

- Create VehicleInfo.vue with 3-state v-if chain and scoped styles
- Define and export VehicleInfo interface (make/model/year/color)
- Add VehicleInfo.spec.ts with 7 tests covering all states and
  priority edge cases
- Update HomePage.vue to use VehicleInfo, replacing 3 inline
  v-if/else-if blocks with a single component tag
- Remove 5 unused CSS classes from HomePage (home__status,
  home__vehicle, home__vehicle-text, home__not-found,
  home__not-found p)
- Update AGENTS.md to require thorough commit messages with bullet
  points
2026-05-01 18:06:04 +02:00
078f07f2ac feat: add PlateInput component with Swedish plate validation and fake vehicle lookup 2026-05-01 17:38:28 +02:00
ce95a451ce feat: implement JWT authentication — service, filter, SecurityFilterChain 2026-05-01 17:38:17 +02:00
0d9baeb6e5 feat: add Subscription enum, converter, entity lifecycle hooks, and ORM-only test rule 2026-05-01 17:38:11 +02:00
c6e2e509eb chore: add JWT secret env config, jjwt deps, and docker-compose prod fixes 2026-05-01 17:38:03 +02:00
c03b5a1401 feat: add User entity, repository, service, and Flyway users table migration
- V1__create_users_table.sql replaces placeholder: creates users table with
  id UUID PK, email UNIQUE NOT NULL, password_hash NOT NULL, subscription
  VARCHAR(20) DEFAULT 'none' with CHECK constraint (none/basic/pro),
  created_at/updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP.
  Compatible with both H2 and PostgreSQL.

- SecurityConfig: minimal @Configuration providing BCryptPasswordEncoder
  bean. Required because Spring Boot 4 no longer auto-configures a
  PasswordEncoder.

- Subscription enum: NONE, BASIC, PRO with string values matching the DB
  CHECK constraint.

- User entity: @PrePersist generates UUID and timestamps in application
  code, @PreUpdate refreshes updated_at. Email setter normalizes to
  lowercase for case-insensitive uniqueness. Explicit getters/setters
  (no Lombok per guidelines).

- UserRepository: Spring Data JPA extending JpaRepository<User, UUID>.
  findByEmail(Optional) and existsByEmail for duplicate checks.

- UserService: @RequiredArgsConstructor with constructor-injected
  UserRepository and PasswordEncoder. createUser normalizes email,
  checks duplicates via existsByEmail, throws EmailAlreadyExistsException,
  hashes password with BCrypt, saves. findByEmail returns Optional<User>.

- EmailAlreadyExistsException: custom RuntimeException for duplicate
  registration attempts. ControllerAdvice handler deferred to auth ticket.

Verification: ./gradlew test passes (Flyway + H2 context loads).
docker compose up -d succeeds, Flyway applies V1 against PostgreSQL 16.
\d users confirms all columns, constraints, defaults, and indexes.
2026-05-01 02:06:24 +02:00
4d449d54d0 feat: add Docker Compose setup with dev and prod configurations
- docker-compose.yml (dev): 3 services — postgres:16, backend (gradle
  bootRun with JDK 21, spring-boot-devtools), frontend (Vite HMR on
  node:24-alpine). Source volume mounts for live editing, Gradle cache
  volume for fast rebuilds, pg_isready healthcheck on postgres.

- docker-compose.prod.yml (prod): same 3 services but with multi-stage
  Dockerfiles. Backend: Gradle bootJar → JRE Alpine, non-root user.
  Frontend: npm ci + vite build → nginx:alpine serving static dist/.
  SSL termination via self-signed cert (auto-generated in entrypoint).
  No source mounts, restart: unless-stopped, separate volumes.

- application-docker.yml: Spring profile overriding H2 with PostgreSQL
  via env vars. Hostname "postgres" resolved by Docker Compose DNS.

- Vite proxy /api → backend:8080 for dev. nginx nginx.conf handles
  /api proxy + SPA fallback + gzip + SSL in prod.

- AGENTS.md, README.md: architecture diagram, dev vs prod comparison
  table, Spring profiles docs, file reference updates.
2026-05-01 01:45:07 +02:00
9931061cb6 feat: scaffold Vue 3 + Vite frontend with TypeScript, Router, Pinia, Vitest, ESLint, Prettier
- Scaffold via npm create vite@latest --template vue-ts (create-vue interactive
  prompts require manual selection; create-vite supports non-interactive flags)
- Dependencies: vue-router (SPA routing, createWebHistory for clean URLs),
  pinia (centralised state management), vitest + @vue/test-utils + jsdom
  (unit testing with browser DOM simulation)
- Dev tooling: eslint (v10 flat config) + eslint-plugin-vue + @vue/eslint-config-typescript
  + @vue/eslint-config-prettier (ESLint-Prettier integration via vueTsConfigs),
  prettier (semi: false, singleQuote, trailingComma: all), jiti (bridges ESLint
  with TypeScript config files)
- vite.config.ts: dev server on port 3000, @ alias resolving to src/, vitest
  with jsdom environment
- eslint.config.ts: defineConfigWithVueTs wraps tseslint.config with Vue SFC
  support (vue-eslint-parser, <script setup lang="ts">), vue/multi-word off
- tsconfig.app.json: path alias @/* -> src/* for TypeScript module resolution
- src/router/index.ts: single route mapping / to HomePage
- src/pages/HomePage.vue: minimal <script setup lang="ts"> placeholder
- src/main.ts: bootstraps app with Pinia plugin + Vue Router
- src/App.vue: delegates rendering to <RouterView />
- src/__tests__/HomePage.spec.ts: smoke test verifying component mounts
- Directory structure: src/stores/, src/api/, src/composables/ with .gitkeep
  placeholders matching AGENTS.md convention (PascalCase pages, camelCase stores/composables)
- index.html: lang="sv", title BilHälsning (Swedish UI convention)
- Cleaned up: HelloWorld.vue, style.css, template boilerplate SVGs/PNGs
- Update AGENTS.md + CODING_GUIDELINES.md: .js extensions → .ts across all
  file naming examples (useXxx.ts, authStore.ts, orders.ts, client.ts)
- Verification: npm run dev serves blank page on http://localhost:3000,
  npm run lint passes (0 errors, 0 warnings), npm test passes (1 test, 1 file)
2026-05-01 00:52:38 +02:00
83b578ca22 feat: scaffold Spring Boot 4 backend with Gradle, Flyway, and H2
- Generate from Spring Initializr with Gradle Groovy DSL, Java 21, Spring Boot 4.0.6
- Dependencies: Web, Security, Data JPA, PostgreSQL Driver, Flyway, Validation, Lombok
- Add H2 runtime dependency for zero-setup local development
- Configure application.yml: H2 in-memory database, port 8080, Flyway with ddl-auto=validate
- Create placeholder Flyway migration V1__init_schema.sql
- Verify ./gradlew test passes and ./gradlew bootRun starts on port 8080
- Update AGENTS.md and README.md: Maven → Gradle commands, Spring Boot 3 → 4
2026-05-01 00:28:10 +02:00
524242bbdb chore: remove Trello integration — MCP, task tracking, csv, env vars 2026-04-30 15:48:09 +02:00
a8ee1edaf0 feat: add Trello MCP integration and env config 2026-04-30 15:41:55 +02:00
256 changed files with 28022 additions and 95 deletions

66
.dockerignore Normal file
View file

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

52
.env.example Normal file
View file

@ -0,0 +1,52 @@
# BilHej Environment Variables
# Copy this file to .env and fill in your keys.
#
# cp .env.example .env
#
# Docker Compose reads .env from the project root automatically.
# ---------- PostgreSQL ----------
POSTGRES_DB=bilhej
POSTGRES_USER=bilhej
POSTGRES_PASSWORD=change_me
# ---------- JWT ----------
# Generate a secure random secret:
# openssl rand -hex 32
JWT_SECRET=change_me_to_a_random_64_char_string
# ---------- Stripe (Phase 1) ----------
# Test keys from Stripe Dashboard: https://dashboard.stripe.com/test/apikeys
STRIPE_SECRET_KEY=sk_test_...
# Webhook secret from stripe CLI: stripe listen --print-secret
STRIPE_WEBHOOK_SECRET=whsec_...
# Price ID from Stripe Dashboard: https://dashboard.stripe.com/test/products
STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ----------
SWISH_NUMBER=0701234567
# ---------- App URL (password reset links in email) ----------
APP_PUBLIC_BASE_URL=http://localhost:3000
# ---------- SMTP (local Docker uses Mailpit via docker-compose.yml) ----------
# docker compose up → view mail at http://localhost:8025
# Leave MAIL_HOST unset in .env to use compose defaults (mailpit).
# Production (Resend SMTP) — see docs/production-email-checklist.md
# MAIL_HOST=smtp.resend.com
# MAIL_PORT=587
# MAIL_USERNAME=resend
# MAIL_PASSWORD=re_... # API key; never commit a real value
# MAIL_FROM=noreply@bilhej.se
# ---------- Production admin (prod profile only) ----------
# 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

123
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,123 @@
name: CI
on:
push:
branches: [master, develop]
pull_request:
branches: [master, develop]
jobs:
lint-and-test:
name: Lint, type check, unit tests, coverage
runs-on: ubuntu-latest
steps:
- name: Checkout repository
run: |
git init
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:
node-version: 24
- uses: https://github.com/actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Install frontend dependencies
run: npm ci
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
- name: Type check
run: npx vue-tsc --noEmit
working-directory: frontend
- name: Backend coverage
run: ./gradlew :backend:jacocoTestCoverageVerification
- name: Print coverage summary
run: |
node -e "
const fs = require('fs');
const xml = fs.readFileSync('backend/build/reports/jacoco/test/jacocoTestReport.xml', 'utf8');
const lineMatch = xml.match(/<counter type=\"LINE\" missed=\"(\d+)\" covered=\"(\d+)\"\\/>/g);
const branchMatch = xml.match(/<counter type=\"BRANCH\" missed=\"(\d+)\" covered=\"(\d+)\"\\/>/g);
if (lineMatch && branchMatch) {
const lastLine = lineMatch[lineMatch.length - 1].match(/missed=\"(\d+)\" covered=\"(\d+)\"/);
const lastBranch = branchMatch[branchMatch.length - 1].match(/missed=\"(\d+)\" covered=\"(\d+)\"/);
const lineMissed = +lastLine[1], lineCovered = +lastLine[2];
const branchMissed = +lastBranch[1], branchCovered = +lastBranch[2];
const linePct = (lineCovered / (lineMissed + lineCovered) * 100).toFixed(1);
const branchPct = (branchCovered / (branchMissed + branchCovered) * 100).toFixed(1);
const allPass = linePct >= 70 && branchPct >= 60;
console.log('');
console.log('╔════════════════╦══════════╦════════════╗');
console.log('║ Coverage Summary — Bilhej ║');
console.log('╠════════════════╬══════════╬════════════╣');
console.log('║ Layer │ Lines │ Branch ║');
console.log('╠════════════════╬══════════╬════════════╣');
console.log('║ Backend │ ' + (linePct + '%').padStart(7) + ' │ ' + (branchPct + '%').padStart(7) + ' ║');
console.log('╠════════════════╬══════════╬════════════╣');
console.log('║ Thresholds │ ' + '70.0%'.padStart(7) + ' │ ' + '60.0%'.padStart(7) + ' ║');
console.log('╚════════════════╩══════════╩════════════╝');
console.log(allPass ? 'All backend thresholds met.' : 'Some backend thresholds missed.');
console.log('');
console.log('Frontend coverage printed above by Vitest text reporter.');
console.log('Download full HTML reports from the Artifacts tab.');
console.log('');
}
"
- name: Frontend coverage
run: npm run test:coverage
working-directory: frontend
- name: Upload backend coverage report
uses: actions/upload-artifact@v3
with:
name: backend-coverage
path: backend/build/reports/jacoco/test/html/
retention-days: 7
- name: Upload frontend coverage report
uses: actions/upload-artifact@v3
with:
name: frontend-coverage
path: frontend/coverage/
retention-days: 7
e2e:
name: E2E browser tests
runs-on: ubuntu-latest
env:
POSTGRES_DB: bilhej
POSTGRES_USER: bilhej
POSTGRES_PASSWORD: test_pw_ci_123
JWT_SECRET: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
STRIPE_SECRET_KEY: sk_test_fake
STRIPE_WEBHOOK_SECRET: whsec_fake
STRIPE_PRICE_ID: price_fake
steps:
- name: Checkout repository
run: |
git init
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
- name: Run E2E test stack
run: |
docker compose \
-f docker-compose.e2e.yml \
up --build --abort-on-container-exit --exit-code-from playwright

View file

@ -0,0 +1,144 @@
name: Deploy to Production
on:
workflow_dispatch:
inputs:
version:
description: 'Git tag to create for this deploy (e.g. v0.1.2) — not the branch/tag above'
required: true
default: 'v0.1.0'
type: string
jobs:
deploy:
name: Build and deploy
runs-on: ubuntu-latest
steps:
- name: Checkout repository
run: |
git init
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
- 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 }}
- name: Write production .env
env:
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PRICE_ID: ${{ secrets.STRIPE_PRICE_ID }}
SWISH_NUMBER: ${{ secrets.SWISH_NUMBER }}
ADMIN_EMAIL: ${{ secrets.ADMIN_EMAIL }}
ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
APP_PUBLIC_BASE_URL: ${{ secrets.APP_PUBLIC_BASE_URL }}
MAIL_HOST: ${{ secrets.MAIL_HOST }}
MAIL_PORT: ${{ secrets.MAIL_PORT }}
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 $$.
escape() { printf '%s' "$1" | sed 's/\$/$$/g'; }
{
printf 'POSTGRES_DB=%s\n' "$(escape "$POSTGRES_DB")"
printf 'POSTGRES_USER=%s\n' "$(escape "$POSTGRES_USER")"
printf 'POSTGRES_PASSWORD=%s\n' "$(escape "$POSTGRES_PASSWORD")"
printf 'JWT_SECRET=%s\n' "$(escape "$JWT_SECRET")"
printf 'STRIPE_SECRET_KEY=%s\n' "$(escape "$STRIPE_SECRET_KEY")"
printf 'STRIPE_WEBHOOK_SECRET=%s\n' "$(escape "$STRIPE_WEBHOOK_SECRET")"
printf 'STRIPE_PRICE_ID=%s\n' "$(escape "$STRIPE_PRICE_ID")"
printf 'SWISH_NUMBER=%s\n' "$(escape "$SWISH_NUMBER")"
printf 'ADMIN_EMAIL=%s\n' "$(escape "$ADMIN_EMAIL")"
printf 'ADMIN_PASSWORD=%s\n' "$(escape "$ADMIN_PASSWORD")"
printf 'APP_PUBLIC_BASE_URL=%s\n' "$(escape "${APP_PUBLIC_BASE_URL:-https://bilhej.se}")"
printf 'MAIL_HOST=%s\n' "$(escape "$MAIL_HOST")"
printf 'MAIL_PORT=%s\n' "$(escape "${MAIL_PORT:-587}")"
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
run: |
docker compose -p bilhej-prod -f docker-compose.prod.yml down
docker compose -p bilhej-prod -f docker-compose.prod.yml up --build -d
- name: Health checks with rollback
run: |
echo "Waiting for services to start..."
sleep 30
BACKEND_OK=false
for i in 1 2 3 4 5 6 7 8 9 10; do
if docker run --rm --network bilhej-prod_default curlimages/curl:8.5.0 \
-sf http://bilhej-backend-prod:8080/api/payment/swish-info > /dev/null; then
echo "Backend is healthy"
BACKEND_OK=true
break
fi
echo "Backend check attempt $i failed, retrying in 5s..."
sleep 5
done
FRONTEND_OK=false
for i in 1 2 3 4 5; do
if docker run --rm --network bilhej-prod_default curlimages/curl:8.5.0 \
-s http://bilhej-frontend-prod/ | grep -qi "bilhej\|Bilhej\|BilHej"; then
echo "Frontend is serving"
FRONTEND_OK=true
break
fi
echo "Frontend check attempt $i failed, retrying in 5s..."
sleep 5
done
if [ "$BACKEND_OK" != "true" ] || [ "$FRONTEND_OK" != "true" ]; then
echo ""
echo "═══════════════════════════════════════════════════"
echo " HEALTH CHECK FAILED — DIAGNOSTICS"
echo "═══════════════════════════════════════════════════"
echo ""
docker compose -p bilhej-prod -f docker-compose.prod.yml ps
echo ""
echo "--- Backend logs ---"
docker logs bilhej-backend-prod 2>&1 | tail -80 || true
echo ""
echo "--- Postgres logs ---"
docker logs bilhej-postgres-prod 2>&1 | tail -30 || true
echo ""
echo "═══════════════════════════════════════════════════"
echo " ROLLING BACK DEPLOYMENT"
echo "═══════════════════════════════════════════════════"
echo ""
docker compose -p bilhej-prod -f docker-compose.prod.yml down
echo ""
echo "Rolled back. Containers stopped. DB volume preserved."
echo "Read Backend logs above to find the root cause before redeploying."
exit 1
fi
- name: Print deploy status
run: |
echo ""
echo "═══════════════════════════════════════════════════"
echo " Deployed ${{ github.event.inputs.version }} to production"
echo "═══════════════════════════════════════════════════"
echo ""
docker compose -p bilhej-prod -f docker-compose.prod.yml ps
echo ""
echo "Containers running. Update nginx config on srvr.nu"
echo "to point bilhej.se to the frontend container."
echo ""

6
.gitignore vendored
View file

@ -10,6 +10,7 @@ target/
*.jar
*.war
!.mvn/wrapper/maven-wrapper.jar
!gradle/wrapper/gradle-wrapper.jar
# Environment
.env
@ -37,6 +38,11 @@ Thumbs.db
# Docker
docker-compose.override.yml
certs/
# Gradle
.gradle/
build/
# Java
*.hprof

158
AGENTS.md
View file

@ -15,7 +15,7 @@ the recipient's name or address.
integration yet. Owner address is obtained manually by a human and entered into
the admin panel.
Tech stack: Vue.js 3 (Vite, Pinia) frontend + Java 21 Spring Boot 3 backend +
Tech stack: Vue.js 3 (Vite, Pinia) frontend + Java 21 Spring Boot 4 backend +
PostgreSQL 16. Deployed via Docker Compose.
---
@ -24,11 +24,24 @@ PostgreSQL 16. Deployed via Docker Compose.
Always run these after making changes to verify nothing is broken.
Gradle lives at repo root. All commands below run from the repo root unless noted.
### Quick start (everything)
```bash
cp .env.example .env # first time only, then fill in keys
docker compose up -d # starts postgres, backend, frontend
./gradlew up # same as above (Gradle wrapper)
```
### All-in-one
```bash
./gradlew check # frontend lint → frontend test → backend test+coverage → E2E (Docker)
./gradlew coverage # backend + frontend tests with coverage reports
./gradlew up # docker compose up -d
./gradlew down # docker compose down
./gradlew reset # docker compose down -v && docker compose up -d (full DB reset)
```
### Frontend (Vue.js 3 + Vite)
@ -40,15 +53,14 @@ npm run dev # dev server on :3000 with HMR
npm run build # production build
npm run lint # ESLint
npm run test # vitest
npm run test:coverage # vitest with coverage (HTML at frontend/coverage/)
```
### Backend (Spring Boot 3 + Java 21)
### Backend (Spring Boot 4 + Java 21)
```bash
cd backend
./mvnw spring-boot:run # dev server on :8080
./mvnw test # JUnit 5 + Mockito
./mvnw verify # full verification including integration tests
./gradlew :backend:bootRun # dev server on :8080
./gradlew :backend:test # JUnit 5 + Mockito (backend only)
```
### Stripe webhooks (local testing)
@ -62,8 +74,20 @@ 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,
sample orders) are in `db/dev-migration/` and run only without the `prod` profile.
Production admin is created from `ADMIN_EMAIL` / `ADMIN_PASSWORD` on first boot.
---
## Project Structure
@ -74,13 +98,14 @@ bilhej/
│ ├── src/
│ │ ├── pages/ # Route-level page components
│ │ ├── components/ # Reusable UI components
│ │ ├── composables/ # useXxx.js shared logic
│ │ ├── composables/ # useXxx.ts shared logic
│ │ ├── stores/ # Pinia stores
│ │ ├── api/ # API client modules
│ │ ├── router/ # Vue Router config
│ │ └── assets/ # Static files, CSS
│ └── ...
├── backend/ # Spring Boot 3 (Java 21)
├── backend/ # Spring Boot 4 (Java 21) — Gradle subproject
│ ├── build.gradle # Spring Boot plugin, Java deps, test config
│ ├── src/main/java/se/bilhalsning/
│ │ ├── config/ # @Configuration classes
│ │ ├── controller/ # REST controllers
@ -92,11 +117,23 @@ bilhej/
│ │ ├── exception/ # Custom exceptions + @ControllerAdvice
│ │ └── mapper/ # Entity ↔ DTO mapping
│ └── src/main/resources/
│ ├── application.yml
│ ├── application.yml # default (H2, IDE dev)
│ ├── application-docker.yml # docker profile (PostgreSQL)
│ └── db/migration/ # Flyway migrations
├── docker/ # Dockerfiles
├── docker-compose.yml
├── docker-compose.prod.yml
│ ├── backend.Dockerfile # dev: JDK 21 + gradle :backend:bootRun
│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle build → JRE Alpine, non-root)
│ ├── frontend.Dockerfile # dev: Node 24 + vite dev server
│ ├── frontend.prod.Dockerfile # prod: multi-stage (Node build → nginx)
│ ├── nginx.conf # prod: SPA fallback + /api reverse proxy
│ └── entrypoint.sh # prod: self-signed cert generation
├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR)
├── docker-compose.prod.yml # prod: multi-stage images, no source mounts, restart always
├── gradlew # Gradle wrapper (repo root)
├── gradle/
│ └── wrapper/
├── settings.gradle # rootProject.name + include 'backend'
├── build.gradle # convenience tasks: check, up, down, reset
├── .env.example
├── AGENTS.md # This file
├── README.md
@ -119,6 +156,29 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
- Create `feature/*`, `fix/*`, or `chore/*` branches from `develop`.
- Never commit directly to `master` or `develop`.
- Merge strategy: fast-forward or merge — either is fine.
- Commit messages must be thorough: describe what changed, why, and
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.
@ -126,12 +186,12 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
- API calls live in `api/` modules, never in components.
- Component styles are scoped.
### Backend (Spring Boot 3)
### Backend (Spring Boot 4)
- Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`.
- DTOs: prefer Java records. No bare entities in responses.
- Controllers stay thin. All logic in services.
- Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`).
- No Lombok beyond `@RequiredArgsConstructor`.
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
### Database
- Table names: snake_case, plural. PKs: UUID, generated in code.
@ -155,6 +215,15 @@ 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.
### Password reset test token (never in production)
`app.password-reset.expose-token` must stay **false** in prod/default; it is only enabled in `application-docker.yml` for CI E2E so Playwright can read `testToken` from the forgot-password response.
### Stripe webhook signature verification
Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`.
Webhook endpoint is public (no auth). Without signature verification an
@ -174,6 +243,10 @@ public vehicle info) must be excluded from the Spring Security filter chain.
## Testing Approach
This project follows **Test-Driven Development (TDD)**. Write tests before
or alongside implementation. Every feature ticket should include tests in
the same PR — never merge code without corresponding tests.
### Backend
- JUnit 5 + Mockito for service layer tests.
- `@WebMvcTest` for controller tests.
@ -184,10 +257,65 @@ public vehicle info) must be excluded from the Spring Security filter chain.
### Frontend
- Vitest for composables and utility functions.
- Component tests with Vue Test Utils where needed.
- E2E tests deferred to Phase 1.
- E2E tests with Playwright in `frontend/e2e/`.
### 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)
- `./mvnw verify` and `npm run test && npm run lint` must pass before merge.
- `./gradlew check` and `npm run test && npm run lint` must pass before merge.
---
## Coverage
```bash
./gradlew coverage # backend + frontend tests with coverage
```
Coverage thresholds are enforced during `./gradlew check`. PRs must maintain
or improve coverage.
| Layer | Lines | Branches | Functions |
|----------|-------|----------|-----------|
| Backend | 70% | 60% | — |
| Frontend | 70% | 60% | 70% |
HTML reports:
- Backend: `backend/build/reports/jacoco/index.html`
- Frontend: `frontend/coverage/index.html`
---

View file

@ -15,6 +15,15 @@ Conventions and standards for the BilHej codebase. These exist to keep the proje
- **No commented-out code.** Delete it. Git history keeps it if needed.
- **Keep functions small.** A function should do one thing. If it's over 30 lines, it probably does too much.
- **No magic numbers.** Use named constants or enums.
- **Treat warnings as mistakes.** LSP diagnostics, compiler warnings, and lint
warnings are bugs. Never commit code that produces them. If a warning is a
known false positive (e.g. Lombok `@RequiredArgsConstructor` triggering
"uninitialized final field"), suppress it explicitly at the narrowest scope
with a comment explaining why:
- Java: `@SuppressWarnings("...") // Lombok generates constructor`
- TypeScript: `// @ts-expect-error — pinia getActivePinia returns null in test context`
Uncommented suppressions are indistinguishable from ignoring a real problem
and are treated as errors.
---
@ -85,9 +94,9 @@ Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `style`
|--------------|-----------------------------|----------------------------------|
| Page | PascalCase, in `pages/` | `HomePage.vue`, `OrderHistoryPage.vue` |
| Component | PascalCase, in `components/`| `PlateInput.vue`, `LetterPreview.vue` |
| Composable | camelCase, `use` prefix | `useAuth.js`, `usePayment.js` |
| Store | camelCase, in `stores/` | `authStore.js`, `orderStore.js` |
| API module | camelCase, in `api/` | `orders.js`, `templates.js` |
| Composable | camelCase, `use` prefix | `useAuth.ts`, `usePayment.ts` |
| Store | camelCase, in `stores/` | `authStore.ts`, `orderStore.ts` |
| API module | camelCase, in `api/` | `orders.ts`, `templates.ts` |
### Component Structure
@ -140,12 +149,12 @@ async function handleSubmit() {
- `v-model` bindings use `defineModel()` or explicit `modelValue` + `update:modelValue`.
- No global CSS unless it's a design token or reset. Component styles are scoped.
- Prefer Pinia stores over prop drilling for shared state (auth, current order).
- API calls live in `api/*.js` modules, not in components.
- API calls live in `api/*.ts` modules, not in components.
- Use `fetch` or `axios` via a single configured instance (base URL, auth header interceptor).
---
## 4. Backend — Spring Boot 3
## 4. Backend — Spring Boot 4
### Package Structure
@ -209,7 +218,7 @@ public class OrderController {
- All responses: `ResponseEntity<T>`. Never return bare entities.
- Entity fields use `snake_case` column naming explicitly (`@Column(name = "created_at")`).
- Database migrations: Flyway. All schema changes go through SQL migration files in `db/migration/`.
- No Lombok beyond `@RequiredArgsConstructor`. Prefer explicit getters/setters or records.
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
### API Path Conventions
@ -241,7 +250,7 @@ GET /api/vehicles/{plate} Get public vehicle info
### Frontend
```javascript
// api/client.js — centralized fetch wrapper
// api/client.ts — centralized fetch wrapper
async function request(url, options) {
const response = await fetch(`${BASE_URL}${url}`, {
...options,
@ -289,10 +298,37 @@ public class GlobalExceptionHandler {
## 7. Testing
This project follows **Test-Driven Development (TDD)**. Write tests before
or alongside implementation. Every feature ticket should include tests in
the same PR — never merge code without corresponding tests.
- Backend: JUnit 5 + Mockito. Service layer tests as unit tests. Controller tests with `@WebMvcTest`.
- Frontend: Vitest for composables and utility functions. Cypress or Playwright for E2E (Phase 1).
- Frontend: Vitest for composables and utility functions. Component tests with Vue Test Utils.
- E2E: Playwright (`npm run test:e2e`). Tests in `frontend/e2e/`. Requires `docker compose up`.
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
- Aim for test coverage on business logic, not on getters/setters/boilerplate.
- All database interaction in tests must go through JPA repositories
or EntityManager. Never use JdbcTemplate, DataSource queries, or
raw SQL in test code. Tests interact with the database the same way
production code does: through the ORM.
### Coverage
```bash
./gradlew coverage # backend + frontend tests with coverage
```
Coverage is enforced via `./gradlew check`. Thresholds:
| Layer | Lines | Branches | Functions |
|----------|-------|----------|-----------|
| Backend | 70% | 60% | — |
| Frontend | 70% | 60% | 70% |
- Backend: JaCoCo (`backend/build/reports/jacoco/index.html`).
- Frontend: Vitest v8 provider (`frontend/coverage/index.html`).
- PRs must maintain or improve coverage levels. If a new feature changes
coverage, update the test suite — never lower thresholds without discussion.
---

404
README.md
View file

@ -11,7 +11,7 @@ The user enters a registration number, composes a letter (from a template or fre
| Layer | Technology |
|-------------|-----------------------------------------|
| Frontend | Vue.js 3 (Composition API), Vite, Pinia |
| Backend | Java 21, Spring Boot 3 |
| Backend | Java 21, Spring Boot 4, Gradle |
| Database | PostgreSQL 16 |
| Auth | Spring Security + JWT |
| Payments | Stripe (cards + Swish) |
@ -34,13 +34,53 @@ The user enters a registration number, composes a letter (from a template or fre
git clone <repo-url> bilhej
cd bilhej
cp .env.example .env # fill in your keys
docker compose up -d
docker compose up -d # or: ./gradlew up
```
The app will be available at:
- Frontend: `http://localhost:3000`
- Backend API: `http://localhost:8080`
- PostgreSQL: `localhost:5432`
- Mailpit (dev SMTP inbox): `http://localhost:8025`
### Architecture inside Docker Compose
```
Browser Docker Compose network
─────── ─────────────────────
│ ┌──────────────────┐
│ http://localhost:3000 │ frontend (Vite) │
├────────────────────────→│ :3000 │
│ │ proxy: /api → │
│ GET /api/orders │ backend:8080 │
│ └────────┬─────────┘
│ │
│ ┌────────▼─────────┐
│ │ backend (Spring) │
│ │ :8080 │
│ │ profile: docker │
│ └────────┬─────────┘
│ │
│ ┌────────▼─────────┐
│ │ postgres (16) │
│ │ :5432 │
│ └──────────────────┘
│ ┌──────────────────┐
│ │ mailpit │
│ │ SMTP :1025 │
│ │ UI :8025 │
│ └──────────────────┘
```
**Vite proxy:** The Vite dev server proxies `/api/*` requests to the backend container.
No CORS configuration needed in development — the browser never calls the backend directly.
**Spring profiles:**
| Profile | Datasource | Use |
|---------|-----------|-----|
| default | H2 in-memory | Local IDE dev (`./gradlew :backend:bootRun`) |
| `docker` | PostgreSQL + dev Flyway seeds | Docker Compose dev / CI |
| `docker,prod` | PostgreSQL, schema only, admin bootstrap | Deploy (`docker-compose.prod.yml`) |
---
@ -57,6 +97,147 @@ Copy `.env.example` to `.env` and fill in:
| `STRIPE_SECRET_KEY` | Stripe secret key |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
| `SWISH_NUMBER` | Swish number for payment instructions |
| `APP_PUBLIC_BASE_URL` | Base URL for password-reset links (dev: `http://localhost:3000`) |
| `MAIL_HOST` | SMTP host (Docker dev uses `mailpit` automatically; leave empty to log links only) |
| `MAIL_PORT` | SMTP port (`1025` for Mailpit, `587` for most providers) |
| `MAIL_USERNAME` | SMTP username (empty for Mailpit) |
| `MAIL_PASSWORD` | SMTP password (empty for Mailpit) |
| `MAIL_FROM` | From address (e.g. `noreply@bilhej.se`) |
| `ADMIN_EMAIL` | Production admin login (e.g. `admin@bilhej.se`) |
| `ADMIN_PASSWORD` | Strong production admin password (not `test1234`) |
**Dev-only accounts** (from `db/dev-migration`, not used in production):
| Email | Password | Role |
|-------|----------|------|
| `test@bilhej.se` | `test1234` | user (e2e / local) |
| `admin@bilhalsning.se` | `test1234` | admin (local docker only) |
---
## Database access
PostgreSQL runs in Docker. Any GUI client works (IntelliJ IDEA, DBeaver, TablePlus,
pgAdmin) — same idea as a normal remote database.
### Local dev (`docker compose up`)
| Setting | Value |
|---------|--------|
| Host | `localhost` |
| Port | `5432` |
| Database | from `.env``POSTGRES_DB` (default `bilhej`) |
| User / password | `POSTGRES_USER` / `POSTGRES_PASSWORD` |
**IntelliJ IDEA:** Database tool window → `+` → Data Source → PostgreSQL → fill in above →
Test Connection → OK.
**CLI:**
```bash
docker exec -it bilhej-postgres psql -U bilhej -d bilhej
```
### Production (server)
Postgres is bound to **localhost only** on the server (`127.0.0.1:5433`) so it is not
exposed to the internet. Use an **SSH tunnel** from your laptop, then point IntelliJ (or
DBeaver) at `localhost`.
1. On the server, recreate the stack once so the port mapping is active (after deploy).
2. From your laptop:
```bash
ssh -N -L 5433:127.0.0.1:5433 you@srvr.nu
```
3. In IntelliJ / DBeaver:
| Setting | Value |
|---------|--------|
| Host | `localhost` |
| Port | `5433` |
| Database | prod `POSTGRES_DB` |
| User / password | prod secrets |
**CLI on the server** (no GUI):
```bash
docker exec -it bilhej-postgres-prod psql -U bilhej -d bilhej
```
### Manual prod cleanup (keep data, remove dev seeds)
```sql
DELETE FROM orders
WHERE user_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
DELETE FROM users
WHERE email IN ('test@bilhalsning.se', 'test@bilhej.se', 'admin@bilhalsning.se');
```
Then deploy with `ADMIN_EMAIL` / `ADMIN_PASSWORD` set — the app creates the production
admin on startup. No need to insert a password hash by hand.
### Email (password reset)
The app only needs to **send** mail (forgot-password). You do not need Office 365 or a mailbox on
the server unless you want human addresses like `support@bilhej.se`.
**Local Docker (Mailpit):** `docker compose up` starts [Mailpit](https://mailpit.axllent.org/)
(`ghcr.io/axllent/mailpit:v1.28`). All outbound mail is captured—nothing is sent to the internet.
If `docker compose pull` fails on Docker Hub, pull explicitly:
```bash
docker pull ghcr.io/axllent/mailpit:v1.28
```
1. Open **http://localhost:8025**
2. Use **Glömt lösenord?** on the login page (or **Byt lösenord** in the header when logged in)
3. Open the message in Mailpit and click the reset link
To disable Mailpit and log links only, remove `MAIL_HOST` from the backend service in
`docker-compose.yml` or set `MAIL_HOST=` in `.env`.
**Production:** [Resend](https://resend.com) via SMTP (no Resend Java SDK required). See
[docs/production-email-checklist.md](docs/production-email-checklist.md).
1. Verify domain **bilhej.se** in Resend (SPF + DKIM DNS records)
2. Create an API key (`re_...`)
3. On the production server `.env`:
```bash
APP_PUBLIC_BASE_URL=https://bilhej.se
MAIL_HOST=smtp.resend.com
MAIL_PORT=587
MAIL_USERNAME=resend
MAIL_PASSWORD=re_xxxxxxxx
MAIL_FROM=noreply@bilhej.se
```
`MAIL_USERNAME` is always the literal string `resend`; `MAIL_PASSWORD` is the API key.
5. Deploy via **Deploy to Production**, then test forgot-password on https://bilhej.se
See [docs/production-email-checklist.md](docs/production-email-checklist.md) for a step-by-step operator checklist.
If SMTP is not configured (`MAIL_HOST` empty), the reset link is written to the backend log:
```bash
docker logs bilhej-backend-prod 2>&1 | grep "Password reset link"
```
Optional later: real inboxes (`support@bilhej.se`) via Migadu, Fastmail, or Purelymail—separate
from app `MAIL_*` (different MX records).
To generate a bcrypt hash manually (optional):
```bash
./gradlew hashPassword -Ppassword='your-strong-password'
```
---
@ -75,11 +256,11 @@ bilhej/
│ │ ├── api/ # API client and endpoints
│ │ ├── assets/ # Static assets, CSS
│ │ ├── App.vue
│ │ └── main.js
│ │ └── main.ts
│ ├── index.html
│ ├── vite.config.js
│ ├── vite.config.ts
│ └── package.json
├── backend/ # Spring Boot 3
├── backend/ # Spring Boot 4
│ ├── src/main/java/se/bilhalsning/
│ │ ├── BilHejApplication.java
│ │ ├── config/ # Security, CORS, Stripe config
@ -90,36 +271,222 @@ bilhej/
│ │ ├── service/ # Business logic
│ │ └── security/ # JWT filter, user details
│ └── src/main/resources/
│ ├── application.yml
│ ├── application.yml # default profile (H2)
│ ├── application-docker.yml # docker profile (PostgreSQL)
│ └── db/migration/ # Flyway migrations
├── docker-compose.yml
├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR)
├── docker-compose.prod.yml # prod: multi-stage builds, no source mounts, restart: unless-stopped
├── docker/
│ ├── backend.Dockerfile
│ └── frontend.Dockerfile
│ ├── backend.Dockerfile # dev: JDK + gradle :backend:bootRun
│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle → JRE Alpine, non-root)
│ ├── frontend.Dockerfile # dev: Node + vite dev server
│ ├── frontend.prod.Dockerfile # prod: multi-stage (Node → nginx)
│ ├── nginx.conf # prod: SPA fallback + /api proxy
│ └── entrypoint.sh # prod: self-signed cert generation
├── gradlew # Gradle wrapper (run from repo root)
├── gradle/
│ └── wrapper/
├── settings.gradle # rootProject.name + include 'backend'
├── build.gradle # convenience tasks: check, up, down, reset
├── .env.example
├── README.md
├── REQUIREMENTS.md
├── CODING_GUIDELINES.md
└── ARCHITECTURE.md
└── CODING_GUIDELINES.md
```
---
## Development vs Production
| Aspect | `docker compose up -d` | `docker compose -f docker-compose.prod.yml up -d` |
|--------|------------------------|---------------------------------------------------|
| Backend | `./gradlew :backend:bootRun` (compiles on change) | Multi-stage build → `java -jar app.jar` |
| Backend image | `eclipse-temurin:21-jdk` (~400 MB) | `eclipse-temurin:21-jre-alpine` (~200 MB) |
| Backend user | root | `bilhej` (non-root) |
| Frontend | Vite dev server (HMR, `--host 0.0.0.0`) | nginx serving static `dist/` |
| API proxy | Vite built-in proxy (`/api` → `backend:8080`) | nginx `proxy_pass` |
| SSL | None | Self-signed cert auto-generated on first start (`.certs/` volume) |
| Source mounts | Yes (live edit) | No (files baked into image) |
| Restart policy | Manual | `unless-stopped` |
| Purpose | Write code, instant feedback | Verify production build |
---
## Production Deployment
Deployments are fully automated via Forgejo Actions. The pipeline builds production Docker images and starts them on the server.
### One-time Setup
Before the first deploy, complete these steps on the production server (`srvr.nu`):
1. **Add Forgejo Actions Secrets**
Go to **Forgejo → Repository Settings → Actions → Secrets** and add:
| Secret | Description |
|--------|-------------|
| `POSTGRES_DB` | Database name (e.g., `bilhej`) |
| `POSTGRES_USER` | Database user |
| `POSTGRES_PASSWORD` | Strong database password |
| `JWT_SECRET` | `openssl rand -hex 32` |
| `STRIPE_SECRET_KEY` | Stripe secret key |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
| `SWISH_NUMBER` | Swish phone number for payment instructions |
| `ADMIN_EMAIL` | Production admin email (e.g. `admin@bilhej.se`) |
| `ADMIN_PASSWORD` | Strong unique admin password (password manager) |
| `APP_PUBLIC_BASE_URL` | `https://bilhej.se` (password-reset links) |
| `MAIL_HOST` | `smtp.resend.com` |
| `MAIL_PORT` | `587` |
| `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
backend creates one admin from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if no admin exists.
If prod already has dev seed users, clean them with SQL (see [Database access](#database-access))
instead of wiping the volume. Then redeploy with the new secrets so bootstrap can create
`ADMIN_EMAIL`.
2. **Point DNS**
Set `bilhej.se` (and `www.bilhej.se`) A record to the server's public IP.
3. **Add HTTP-only Nginx vhost** (required before certs exist)
The full [`docker/bilhej.nginx.conf`](docker/bilhej.nginx.conf) references TLS files that do not
exist yet. Deploy the HTTP-only config first:
```bash
docker cp docker/bilhej.nginx.http.conf nginx:/etc/nginx/conf.d/bilhej.conf
docker exec nginx nginx -t
docker exec nginx nginx -s reload
```
4. **Obtain SSL Certificate**
```bash
docker exec certbot certbot certonly \
--webroot -w /var/www/certbot \
-d bilhej.se -d www.bilhej.se
```
5. **Enable HTTPS proxy to the frontend**
```bash
docker cp docker/bilhej.nginx.conf nginx:/etc/nginx/conf.d/bilhej.conf
docker exec nginx nginx -t
docker exec nginx nginx -s reload
```
### Deploy
1. Go to **Actions → Deploy to Production** in Forgejo.
2. Click **Run workflow**.
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)
If the job passes the frontend check but the backend never becomes healthy:
1. Open the failed job log and read **Backend logs** (printed before rollback).
2. Match the error to a fix — do not guess:
- **`Detected applied migration not resolved locally: 6`** (or 2, 4) — prod DB still lists
dev seed migrations removed from `db/migration`. Fixed in app via `ProdFlywayConfig`
(repair before migrate); redeploy after that commit is on `master`.
- **`password authentication failed`** — DB credentials in the running stack do not match
what Postgres was initialized with; fix credentials or Postgres password to match (only
wipe the volume if you accept losing prod data).
- **`Production requires ADMIN_EMAIL and ADMIN_PASSWORD`** — add those Forgejo secrets.
- **Other Flyway / migration errors** — read the stack trace; do not wipe the volume unless
the log clearly requires it.
3. **DBeaver from your laptop** — prod Postgres binds to `127.0.0.1:5433` on the server only.
Use an SSH tunnel, then host `localhost` port `5433` (not `192.168.0.59` directly).
### What Happens
| Step | Action |
|------|--------|
| Tag | Git tag `v0.1.0` is created and pushed |
| Build | Production backend JAR and frontend bundle are built |
| Images | Multi-stage Docker images are built locally on the server |
| Start | `docker compose -f docker-compose.prod.yml up -d` (`SPRING_PROFILES_ACTIVE=docker,prod`) |
| Verify | Health checks confirm backend API and frontend are responding |
### Architecture on Server
```
User
│ https://bilhej.se
┌─────────────────────────────────────┐
│ nginx (srvr.nu) │
│ SSL termination (Let's Encrypt) │
│ proxy_pass → bilhej-frontend-prod │
└─────────────┬───────────────────────┘
│ Docker 'web' network
┌─────────────▼───────────────────────┐
│ bilhej-frontend-prod (nginx) │
│ :80 │
│ /api/* → bilhej-backend-prod:8080 │
└─────────────┬───────────────────────┘
┌─────────────▼───────────────────────┐
│ bilhej-backend-prod (Spring Boot) │
│ :8080 │
└─────────────┬───────────────────────┘
┌─────────────▼───────────────────────┐
│ bilhej-postgres-prod (PostgreSQL) │
│ :5432 │
└─────────────────────────────────────┘
```
### Rollback
To rollback to a previous version:
```bash
# On srvr.nu
cd /path/to/bilhej/repo
git fetch --tags
git checkout v0.1.0 # or any previous tag
docker compose -f docker-compose.prod.yml up --build -d
```
---
## Development
### All-in-one (from repo root)
```bash
./gradlew check # lint → frontend test → backend test → integration test
./gradlew up # docker compose up -d
./gradlew down # docker compose down
./gradlew reset # docker compose down -v && docker compose up -d (full DB reset)
```
### Frontend (dev server with HMR)
```bash
cd frontend
npm install
npm run dev
npm install # first time only
npm run dev # :3000 with HMR
```
### Backend (IDE or CLI)
```bash
cd backend
./mvnw spring-boot:run
./gradlew :backend:bootRun # :8080, profile: default (H2)
```
### Stripe Webhooks (local testing)
@ -128,10 +495,15 @@ cd backend
stripe listen --forward-to localhost:8080/api/webhooks/stripe
```
### Database reset
```bash
./gradlew reset # wipes DB volume and restarts containers
```
---
## Related Documents
- [REQUIREMENTS.md](./REQUIREMENTS.md) — Full product requirements and business model
- [CODING_GUIDELINES.md](./CODING_GUIDELINES.md) — Code conventions and standards
- [ARCHITECTURE.md](./ARCHITECTURE.md) — Detailed architecture and data flow

View file

@ -178,7 +178,7 @@ the user assumes full responsibility for content.
| Layer | Technology |
|-------|-----------|
| Frontend | Vue.js 3 (Composition API), Vite, Pinia state management, Vue Router |
| Backend API | Java 21, Spring Boot 3, Spring Security (JWT), Spring Data JPA |
| Backend API | Java 21, Spring Boot 4, Spring Security (JWT), Spring Data JPA |
| Database | PostgreSQL 16 |
| Deployment | Docker, Docker Compose |
| Hosting (Phase 0) | Home server via dynamic DNS or static IP, Let's Encrypt SSL |
@ -209,7 +209,7 @@ the user assumes full responsibility for content.
└──────────────────┬───────────────────────┘
│ REST API calls
┌──────────────────▼───────────────────────┐
│ Spring Boot 3 (Java 21) │
│ Spring Boot 4 (Java 21) │
│ Port: 8080 │
│ ┌────────────┐ ┌────────────────────┐ │
│ │ Spring │ │ Service Layer │ │
@ -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
@ -556,7 +556,7 @@ For Phase 0 with manual processing, staying unregistered is workable. If revenue
```
Frontend: Vue.js 3, Vite, Pinia, Vue Router
Backend: Java 21, Spring Boot 3, Spring Security (JWT), JPA/Hibernate
Backend: Java 21, Spring Boot 4, Spring Security (JWT), JPA/Hibernate
Database: PostgreSQL 16
Deploy: Docker, Docker Compose, Nginx reverse proxy
Hosting: Home server (Phase 0) → Swedish VPS (Phase 1)

3
backend/.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary

37
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/

100
backend/build.gradle Normal file
View file

@ -0,0 +1,100 @@
plugins {
id 'java'
id 'jacoco'
id 'org.springframework.boot' version '4.0.6'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'se.bilhalsning'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-flyway'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'org.jsoup:jsoup:1.18.1'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testCompileOnly 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testAnnotationProcessor 'org.projectlombok:lombok'
}
tasks.named('test') {
useJUnitPlatform()
finalizedBy jacocoTestReport
}
jacoco {
toolVersion = "0.8.12"
}
jacocoTestReport {
dependsOn test
reports {
xml.required = true
csv.required = false
html.required = true
}
}
jacocoTestCoverageVerification {
dependsOn jacocoTestReport
violationRules {
rule {
limit {
minimum = 0.70
}
}
rule {
limit {
counter = 'BRANCH'
minimum = 0.60
}
}
}
}
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, flywayMigrationCheck
}
tasks.register('hashPassword', JavaExec) {
group = 'utility'
description = 'Print BCrypt hash for a password (strength 12). Usage: -Ppassword=secret'
classpath = sourceSets.test.runtimeClasspath
mainClass = 'se.bilhalsning.tools.BcryptHashCli'
args = project.findProperty('password') ? [project.property('password')] : []
}

93
backend/gradlew.bat vendored Normal file
View file

@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,13 @@
package se.bilhalsning;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BilHejApplication {
public static void main(String[] args) {
SpringApplication.run(BilHejApplication.class, args);
}
}

View file

@ -0,0 +1,50 @@
package se.bilhalsning.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import se.bilhalsning.entity.User;
import se.bilhalsning.repository.UserRepository;
@Component
@Profile("prod")
@RequiredArgsConstructor
@Slf4j
public class AdminBootstrap implements ApplicationRunner {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Value("${app.admin.email:}")
private String adminEmail;
@Value("${app.admin.password:}")
private String adminPassword;
@Override
public void run(ApplicationArguments args) {
if (userRepository.existsByRole("admin")) {
log.info("Admin account already present, skipping bootstrap");
return;
}
if (!StringUtils.hasText(adminEmail) || !StringUtils.hasText(adminPassword)) {
throw new IllegalStateException(
"Production requires ADMIN_EMAIL and ADMIN_PASSWORD when no admin user exists");
}
User admin = new User();
admin.setEmail(adminEmail.trim());
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
admin.setRole("admin");
userRepository.save(admin);
log.info("Created production admin account for {}", admin.getEmail());
}
}

View file

@ -0,0 +1,24 @@
package se.bilhalsning.config;
import org.flywaydb.core.Flyway;
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
@Profile("prod")
public class ProdFlywayConfig {
/**
* Prod databases may record dev seed migrations (V2, V4, V6) from before they moved to
* db/dev-migration/. Repair marks those missing scripts as deleted so validate passes.
*/
@Bean
public FlywayMigrationStrategy prodFlywayMigrationStrategy() {
return (Flyway flyway) -> {
flyway.repair();
flyway.migrate();
};
}
}

View file

@ -0,0 +1,78 @@
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;
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;
@Configuration
@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();
}
@Bean
public JwtService jwtService(@Value("${app.jwt.secret}") String secret) {
return new JwtService(secret);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/register",
"/api/auth/login",
"/api/auth/forgot-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

@ -0,0 +1,66 @@
package se.bilhalsning.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
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.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.entity.Order;
import se.bilhalsning.service.AdminOrderWorkflowService;
import se.bilhalsning.service.OrderService;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
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(AdminOrderMapper::toResponse)
.toList();
return ResponseEntity.ok(orders);
}
@PatchMapping("/orders/{id}/status")
public ResponseEntity<AdminOrderResponse> updateStatus(
@PathVariable UUID id,
@Valid @RequestBody UpdateStatusRequest request) {
Order order = adminOrderWorkflowService.updateOrderStatus(id, request.status());
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
}
@PatchMapping("/orders/{id}/register-shipment")
public ResponseEntity<AdminOrderResponse> registerShipment(
@PathVariable UUID id,
@Valid @RequestBody RegisterShipmentRequest request) {
Order order = adminOrderWorkflowService.registerShipment(
id,
request.trackingInput(),
request.notifyCustomerOrDefault());
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
}
@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

@ -0,0 +1,100 @@
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;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
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.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;
import se.bilhalsning.dto.MessageResponse;
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;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
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());
String token = jwtService.generateToken(request.email().toLowerCase().trim(), "user");
return ResponseEntity.status(HttpStatus.CREATED).body(new AuthResponse(token));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
User user = userService.authenticate(request.email(), request.password());
String token = jwtService.generateToken(user.getEmail(), user.getRole());
return ResponseEntity.ok(new AuthResponse(token));
}
@PostMapping("/forgot-password")
public ResponseEntity<ForgotPasswordResponse> forgotPassword(
@Valid @RequestBody ForgotPasswordRequest request) {
return ResponseEntity.ok(ForgotPasswordResponse.of(
FORGOT_PASSWORD_MESSAGE, passwordResetService.requestReset(request.email())));
}
@PostMapping("/reset-password")
public ResponseEntity<MessageResponse> resetPassword(
@Valid @RequestBody ResetPasswordRequest request) {
passwordResetService.resetPassword(request.token(), request.password());
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats. Du kan nu logga in."));
}
@PostMapping("/change-password")
public ResponseEntity<MessageResponse> changePassword(
@Valid @RequestBody ChangePasswordRequest request,
@AuthenticationPrincipal UserDetails principal) {
userService.changePassword(
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

@ -0,0 +1,115 @@
package se.bilhalsning.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
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;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
private final UserService userService;
@GetMapping
public ResponseEntity<List<OrderResponse>> list(@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
List<OrderResponse> orders = orderService.getOrdersByUserId(user.getId()).stream()
.map(this::toResponse)
.toList();
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,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.createOrder(
user.getId(),
request.plate(),
request.letterText()
);
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(),
order.getPlate(),
order.getLetterText(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getAmountPaid(),
order.getCreatedAt()
);
}
}

View file

@ -0,0 +1,68 @@
package se.bilhalsning.controller;
import java.util.Map;
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;
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,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.confirmPayment(orderId, user.getId());
return ResponseEntity.ok(toResponse(order));
}
@GetMapping("/swish-info")
public ResponseEntity<Map<String, Object>> swishInfo() {
return ResponseEntity.ok(Map.of("number", swishNumber, "amount", letterPrice));
}
private OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId(),
order.getPlate(),
order.getLetterText(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getAmountPaid(),
order.getCreatedAt()
);
}
}

View file

@ -0,0 +1,24 @@
package se.bilhalsning.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.VehicleInfoResponse;
import se.bilhalsning.service.VehicleLookupService;
@RestController
@RequestMapping("/api/vehicles")
@RequiredArgsConstructor
public class VehicleController {
private final VehicleLookupService vehicleLookupService;
@GetMapping("/{plate}")
public ResponseEntity<VehicleInfoResponse> lookup(@PathVariable String plate) {
VehicleInfoResponse info = vehicleLookupService.lookup(plate);
return ResponseEntity.ok(info);
}
}

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

@ -0,0 +1,21 @@
package se.bilhalsning.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record AdminOrderResponse(
UUID id,
String email,
String plate,
String letterText,
String status,
String trackingId,
BigDecimal amountPaid,
Instant shippedAt,
String adminNotes,
Instant createdAt,
List<String> allowedStatuses,
boolean canRegisterShipment
) {}

View file

@ -0,0 +1,3 @@
package se.bilhalsning.dto;
public record AuthResponse(String token) {}

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,8 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ChangePasswordRequest(
@NotBlank String currentPassword,
@NotBlank @Size(min = 8) String newPassword) {}

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,15 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateOrderRequest(
@NotBlank(message = "Registreringsnummer krävs")
@Pattern(regexp = "^[A-Za-z]{3}\\d{2}[A-Za-z0-9]$", message = "Ogiltigt registreringsnummer")
String plate,
@NotBlank(message = "Brevtext krävs")
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
String letterText
) {}

View file

@ -0,0 +1,3 @@
package se.bilhalsning.dto;
public record ErrorResponse(String message) {}

View file

@ -0,0 +1,6 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record ForgotPasswordRequest(@NotBlank @Email String email) {}

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 ForgotPasswordResponse(String message, String testToken) {
public static ForgotPasswordResponse of(String message, Optional<String> testToken) {
return new ForgotPasswordResponse(message, testToken.orElse(null));
}
}

View file

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

View file

@ -0,0 +1,3 @@
package se.bilhalsning.dto;
public record MessageResponse(String message) {}

View file

@ -0,0 +1,15 @@
package se.bilhalsning.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record OrderResponse(
UUID id,
String plate,
String letterText,
String status,
String trackingId,
BigDecimal amountPaid,
Instant createdAt
) {}

View file

@ -0,0 +1,10 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8) 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,9 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ResetPasswordRequest(
@NotBlank String token,
@NotBlank @Size(min = 8) String password
) {}

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

@ -0,0 +1,13 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public record UpdateStatusRequest(
@NotBlank(message = "Status krävs")
@Pattern(
regexp = "pending_payment|paid|processing|sent|delivered|failed|cancelled",
message = "Ogiltig status"
)
String status
) {}

View file

@ -0,0 +1,9 @@
package se.bilhalsning.dto;
public record VehicleInfoResponse(
String make,
String model,
int year,
String color,
String fuel
) {}

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

@ -0,0 +1,162 @@
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.PreUpdate;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "orders")
public class Order {
@Id
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
private UUID id;
@Column(name = "user_id", nullable = false, columnDefinition = "uuid")
private UUID userId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", insertable = false, updatable = false)
private User user;
@Column(name = "plate", nullable = false, length = 10)
private String plate;
@Column(name = "letter_text", nullable = false, columnDefinition = "text")
private String letterText;
@Column(name = "status", nullable = false, length = 30)
private OrderStatus status = OrderStatus.PENDING_PAYMENT;
@Column(name = "amount_paid", precision = 10, scale = 2)
private BigDecimal amountPaid;
@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;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
void onCreate() {
if (this.id == null) {
this.id = UUID.randomUUID();
}
Instant now = Instant.now();
if (this.createdAt == null) {
this.createdAt = now;
}
this.updatedAt = now;
}
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public UUID getUserId() {
return userId;
}
public void setUserId(UUID userId) {
this.userId = userId;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getPlate() {
return plate;
}
public void setPlate(String plate) {
this.plate = plate;
}
public String getLetterText() {
return letterText;
}
public void setLetterText(String letterText) {
this.letterText = letterText;
}
public OrderStatus getStatus() {
return status;
}
public void setStatus(OrderStatus status) {
this.status = status;
}
public BigDecimal getAmountPaid() {
return amountPaid;
}
public void setAmountPaid(BigDecimal amountPaid) {
this.amountPaid = amountPaid;
}
public String getTrackingId() {
return trackingId;
}
public void setTrackingId(String trackingId) {
this.trackingId = trackingId;
}
public Instant getShippedAt() {
return shippedAt;
}
public void setShippedAt(Instant shippedAt) {
this.shippedAt = shippedAt;
}
public String getAdminNotes() {
return adminNotes;
}
public void setAdminNotes(String adminNotes) {
this.adminNotes = adminNotes;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
}

View file

@ -0,0 +1,21 @@
package se.bilhalsning.entity;
public enum OrderStatus {
PENDING_PAYMENT("pending_payment"),
PAID("paid"),
PROCESSING("processing"),
SENT("sent"),
DELIVERED("delivered"),
FAILED("failed"),
CANCELLED("cancelled");
private final String value;
OrderStatus(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View file

@ -0,0 +1,26 @@
package se.bilhalsning.entity;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter(autoApply = true)
public class OrderStatusConverter implements AttributeConverter<OrderStatus, String> {
@Override
public String convertToDatabaseColumn(OrderStatus status) {
return status != null ? status.getValue() : null;
}
@Override
public OrderStatus convertToEntityAttribute(String dbData) {
if (dbData == null) {
return null;
}
for (OrderStatus s : OrderStatus.values()) {
if (s.getValue().equals(dbData)) {
return s;
}
}
throw new IllegalArgumentException("Unknown order status value: " + dbData);
}
}

View file

@ -0,0 +1,95 @@
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 = "password_reset_tokens")
public class PasswordResetToken {
@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 = "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 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

@ -0,0 +1,18 @@
package se.bilhalsning.entity;
public enum Subscription {
NONE("none"),
BASIC("basic"),
PRO("pro");
private final String value;
Subscription(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View file

@ -0,0 +1,26 @@
package se.bilhalsning.entity;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter(autoApply = true)
public class SubscriptionConverter implements AttributeConverter<Subscription, String> {
@Override
public String convertToDatabaseColumn(Subscription subscription) {
return subscription != null ? subscription.getValue() : null;
}
@Override
public Subscription convertToEntityAttribute(String dbData) {
if (dbData == null) {
return null;
}
for (Subscription subscription : Subscription.values()) {
if (subscription.getValue().equals(dbData)) {
return subscription;
}
}
throw new IllegalArgumentException("Unknown subscription value: " + dbData);
}
}

View file

@ -0,0 +1,112 @@
package se.bilhalsning.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Convert;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "users")
public class User {
@Id
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
private UUID id;
@Column(name = "email", nullable = false, unique = true, length = 255)
private String email;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Convert(converter = SubscriptionConverter.class)
@Column(name = "subscription", nullable = false, length = 20)
private Subscription subscription = Subscription.NONE;
@Column(name = "role", nullable = false, length = 20)
private String role = "user";
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
void onCreate() {
if (this.id == null) {
this.id = UUID.randomUUID();
}
Instant now = Instant.now();
if (this.createdAt == null) {
this.createdAt = now;
}
this.updatedAt = now;
}
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email != null ? email.toLowerCase().trim() : null;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public Subscription getSubscription() {
return subscription;
}
public void setSubscription(Subscription subscription) {
this.subscription = subscription;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
}

View file

@ -0,0 +1,8 @@
package se.bilhalsning.exception;
public class EmailAlreadyExistsException extends RuntimeException {
public EmailAlreadyExistsException(String email) {
super("Email already registered: " + email);
}
}

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

@ -0,0 +1,101 @@
package se.bilhalsning.exception;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import se.bilhalsning.dto.ErrorResponse;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidCredentialsException ex) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(PasswordResetTokenInvalidException.class)
public ResponseEntity<ErrorResponse> handlePasswordResetTokenInvalid(
PasswordResetTokenInvalidException ex) {
return ResponseEntity
.badRequest()
.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
.status(HttpStatus.CONFLICT)
.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
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(VehicleNotFoundException.class)
public ResponseEntity<ErrorResponse> handleVehicleNotFound(VehicleNotFoundException ex) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("Inget fordon hittades"));
}
@ExceptionHandler(VehicleLookupException.class)
public ResponseEntity<ErrorResponse> handleVehicleLookup(VehicleLookupException ex) {
log.error("Vehicle lookup failed", ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Ett internt fel uppstod"));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.reduce((a, b) -> a + ", " + b)
.orElse("Ogiltig indata");
return ResponseEntity
.badRequest()
.body(new ErrorResponse(message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Ett internt fel uppstod"));
}
}

View file

@ -0,0 +1,7 @@
package se.bilhalsning.exception;
public class InvalidCredentialsException extends RuntimeException {
public InvalidCredentialsException() {
super("Felaktig e-post eller lösenord");
}
}

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,9 @@
package se.bilhalsning.exception;
import java.util.UUID;
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(UUID id) {
super("Order not found: " + id);
}
}

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package se.bilhalsning.exception;
public class VehicleNotFoundException extends RuntimeException {
public VehicleNotFoundException(String plate) {
super("Vehicle not found: " + plate);
}
}

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

@ -0,0 +1,24 @@
package se.bilhalsning.repository;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface OrderRepository extends JpaRepository<Order, UUID> {
List<Order> findByUserIdOrderByCreatedAtDesc(UUID userId);
List<Order> findByStatus(OrderStatus status);
@EntityGraph(attributePaths = {"user"})
List<Order> findAllByOrderByCreatedAtDesc();
@EntityGraph(attributePaths = {"user"})
Optional<Order> findWithUserById(UUID id);
}

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.PasswordResetToken;
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, UUID> {
Optional<PasswordResetToken> findByTokenHashAndUsedAtIsNull(String tokenHash);
@Modifying
@Query("DELETE FROM PasswordResetToken t WHERE t.user.id = :userId AND t.usedAt IS NULL")
void deleteUnusedByUserId(@Param("userId") UUID userId);
}

View file

@ -0,0 +1,17 @@
package se.bilhalsning.repository;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import se.bilhalsning.entity.User;
@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
boolean existsByRole(String role);
}

View file

@ -0,0 +1,65 @@
package se.bilhalsning.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import io.jsonwebtoken.JwtException;
import java.io.IOException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
String username;
try {
username = jwtService.extractUsername(token);
} catch (JwtException e) {
filterChain.doFilter(request, response);
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
var userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token)) {
String role = jwtService.extractRole(token);
List<org.springframework.security.core.authority.SimpleGrantedAuthority> authorities =
new java.util.ArrayList<>();
if (role != null) {
authorities.add(new org.springframework.security.core.authority.SimpleGrantedAuthority(
"ROLE_" + role.toUpperCase()));
}
var authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, authorities);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}

View file

@ -0,0 +1,71 @@
package se.bilhalsning.security;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
public class JwtService {
private static final long DEFAULT_EXPIRATION_MS = 86_400_000;
private final SecretKey secretKey;
private final long expirationMs;
public JwtService(String secret) {
this(secret, DEFAULT_EXPIRATION_MS);
}
public JwtService(String secret, long expirationMs) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
this.expirationMs = expirationMs;
}
public String generateToken(String email) {
return generateToken(email, "user");
}
public String generateToken(String email, String role) {
return Jwts.builder()
.subject(email)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(secretKey)
.compact();
}
public String extractUsername(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
public String extractRole(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("role", String.class);
}
public boolean isTokenValid(String token) {
try {
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (ExpiredJwtException e) {
return false;
} catch (Exception e) {
return false;
}
}
}

View file

@ -0,0 +1,25 @@
package se.bilhalsning.security;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import se.bilhalsning.repository.UserRepository;
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
String normalizedEmail = email.toLowerCase().trim();
return userRepository.findByEmail(normalizedEmail)
.map(user -> new User(user.getEmail(), user.getPasswordHash(), List.of()))
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + normalizedEmail));
}
}

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

@ -0,0 +1,163 @@
package se.bilhalsning.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class EmailService {
private final JavaMailSender mailSender;
@Value("${spring.mail.host:}")
private String mailHost;
@Value("${app.mail.from:noreply@bilhej.se}")
private String mailFrom;
public EmailService(@Autowired(required = false) JavaMailSender mailSender) {
this.mailSender = mailSender;
}
public void sendPasswordResetEmail(String toEmail, String resetUrl) {
String subject = "Återställ ditt lösenord BilHej";
String body = """
Hej,
Du har begärt att återställa lösenordet för ditt BilHej-konto.
Öppna länken nedan för att välja ett nytt lösenord (giltig i 1 timme):
%s
Om du inte begärde detta kan du ignorera det här meddelandet.
Vänliga hälsningar,
BilHej
""".formatted(resetUrl);
if (mailHost == null || mailHost.isBlank() || mailSender == null) {
log.info("SMTP not configured. Password reset link for {}: {}", toEmail, resetUrl);
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 password reset email to {}", toEmail, ex);
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

@ -0,0 +1,78 @@
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 java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final OrderNotificationService orderNotificationService;
public Order createOrder(UUID userId, String plate, String letterText) {
Order order = new Order();
order.setUserId(userId);
order.setPlate(plate.toUpperCase().trim());
order.setLetterText(letterText);
order.setStatus(OrderStatus.PENDING_PAYMENT);
return orderRepository.save(order);
}
public List<Order> getOrdersByUserId(UUID userId) {
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
}
public Order getOrderById(UUID id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
public List<Order> getAllOrders() {
return orderRepository.findAllByOrderByCreatedAtDesc();
}
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

@ -0,0 +1,85 @@
package se.bilhalsning.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
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.PasswordResetToken;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.PasswordResetTokenInvalidException;
import se.bilhalsning.repository.PasswordResetTokenRepository;
@Service
@RequiredArgsConstructor
public class PasswordResetService {
private static final int TOKEN_BYTES = 32;
private static final long TOKEN_TTL_HOURS = 1;
private final UserService userService;
private final PasswordResetTokenRepository tokenRepository;
private final EmailService emailService;
private final SecureRandom secureRandom = new SecureRandom();
@Value("${app.public-base-url:http://localhost:3000}")
private String publicBaseUrl;
@Value("${app.password-reset.expose-token:false}")
private boolean exposeToken;
@Transactional
public Optional<String> requestReset(String email) {
return userService.findByEmail(email).map(user -> {
tokenRepository.deleteUnusedByUserId(user.getId());
String rawToken = generateRawToken();
PasswordResetToken entity = new PasswordResetToken();
entity.setUser(user);
entity.setTokenHash(hashToken(rawToken));
entity.setExpiresAt(Instant.now().plusSeconds(TOKEN_TTL_HOURS * 3600));
tokenRepository.save(entity);
String resetUrl = publicBaseUrl.replaceAll("/$", "")
+ "/aterstall-losenord?token="
+ rawToken;
emailService.sendPasswordResetEmail(user.getEmail(), resetUrl);
return exposeToken ? Optional.of(rawToken) : Optional.<String>empty();
}).orElse(Optional.empty());
}
@Transactional
public void resetPassword(String rawToken, String newPassword) {
PasswordResetToken token = tokenRepository
.findByTokenHashAndUsedAtIsNull(hashToken(rawToken))
.filter(t -> t.getExpiresAt().isAfter(Instant.now()))
.orElseThrow(PasswordResetTokenInvalidException::new);
User user = token.getUser();
userService.updatePassword(user, newPassword);
token.setUsedAt(Instant.now());
tokenRepository.deleteUnusedByUserId(user.getId());
tokenRepository.save(token);
}
String generateRawToken() {
byte[] bytes = new byte[TOKEN_BYTES];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static String hashToken(String rawToken) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 not available", ex);
}
}
}

View file

@ -0,0 +1,78 @@
package se.bilhalsning.service;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.repository.UserRepository;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email.toLowerCase().trim());
}
public User createUser(String email, String password) {
String normalizedEmail = email.toLowerCase().trim();
if (userRepository.existsByEmail(normalizedEmail)) {
throw new EmailAlreadyExistsException(normalizedEmail);
}
User user = new User();
user.setEmail(normalizedEmail);
user.setPasswordHash(passwordEncoder.encode(password));
return userRepository.save(user);
}
public User authenticate(String email, String password) {
String normalizedEmail = email.toLowerCase().trim();
User user = userRepository.findByEmail(normalizedEmail)
.orElseThrow(InvalidCredentialsException::new);
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new InvalidCredentialsException();
}
return user;
}
public void updatePassword(User user, String newPassword) {
user.setPasswordHash(passwordEncoder.encode(newPassword));
userRepository.save(user);
}
public void changePassword(String email, String currentPassword, String newPassword) {
User user = findByEmail(email).orElseThrow(InvalidCredentialsException::new);
if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
throw new InvalidCredentialsException();
}
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,141 @@
package se.bilhalsning.service;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import se.bilhalsning.dto.VehicleInfoResponse;
import se.bilhalsning.exception.VehicleLookupException;
import se.bilhalsning.exception.VehicleNotFoundException;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
public class VehicleLookupService {
private static final Logger log = LoggerFactory.getLogger(VehicleLookupService.class);
private static final String BASE_URL = "https://biluppgifter.se/fordon";
public VehicleInfoResponse lookup(String plate) {
Document doc = fetchPage(plate);
Map<String, String> fields = new LinkedHashMap<>();
extractSummaryFields(doc, fields);
extractDataSectionFields(doc, fields);
if (fields.isEmpty() || !fields.containsKey("Fabrikat")) {
throw new VehicleNotFoundException(plate);
}
String make = fields.getOrDefault("Fabrikat", "");
String model = buildModel(fields);
int year = parseYearFromFields(fields);
String color = fields.getOrDefault("Färg", "");
String fuel = fields.getOrDefault("Bränsle", "");
return new VehicleInfoResponse(make, model, year, color, fuel);
}
Document fetchPage(String plate) {
String url = BASE_URL + "/" + plate.toLowerCase() + "/";
try {
return Jsoup.connect(url)
.userAgent("BilHej/1.0")
.timeout(10_000)
.get();
} catch (IOException e) {
log.warn("Failed to fetch vehicle data for plate {}", plate, e);
throw new VehicleLookupException("Failed to fetch vehicle data", e);
}
}
void extractSummaryFields(Document doc, Map<String, String> fields) {
Elements infoDivs = doc.select(".info > em");
for (Element em : infoDivs) {
Element span = em.nextElementSibling();
if (span != null && span.tagName().equals("span")) {
String label = span.text().trim();
String value = em.ownText().trim();
if (!label.isEmpty() && !value.isEmpty()) {
fields.putIfAbsent(label, value);
}
}
}
}
void extractDataSectionFields(Document doc, Map<String, String> fields) {
Element heading = doc.selectFirst("h2:containsOwn(Fordonsdata)");
if (heading == null) {
return;
}
Element section = findParentSection(heading);
if (section == null) {
return;
}
Elements listItems = section.select("ul.list > li");
for (Element li : listItems) {
Element labelEl = li.selectFirst("span.label");
Element valueEl = li.selectFirst("span.value");
if (labelEl != null && valueEl != null) {
String label = labelEl.text().trim();
String value = valueEl.text().trim();
if (!label.isEmpty() && !value.isEmpty() && !value.equals("Logga in")) {
fields.putIfAbsent(label, value);
}
}
}
}
Map<String, String> extractFields(Document doc) {
Map<String, String> fields = new LinkedHashMap<>();
extractSummaryFields(doc, fields);
extractDataSectionFields(doc, fields);
return fields;
}
private Element findParentSection(Element heading) {
Element current = heading.parent();
while (current != null) {
if ("section".equals(current.tagName())) {
return current;
}
current = current.parent();
}
return heading.parent();
}
private String buildModel(Map<String, String> fields) {
String model = fields.getOrDefault("Modell", "");
String variant = fields.getOrDefault("Variant", "");
if (!model.isEmpty() && !variant.isEmpty()) {
return model + " " + variant;
}
return model.isEmpty() ? variant : model;
}
private int parseYearFromFields(Map<String, String> fields) {
String yearText = fields.getOrDefault("Fordonsår / Modellår", "");
if (yearText.isEmpty()) {
yearText = fields.getOrDefault("Modellår", "");
}
if (yearText.isEmpty()) {
return 0;
}
String[] parts = yearText.split("/");
try {
return Integer.parseInt(parts[0].trim());
} catch (NumberFormatException e) {
return 0;
}
}
}

View file

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

View file

@ -0,0 +1,37 @@
spring:
flyway:
locations: classpath:db/migration,classpath:db/dev-migration
datasource:
url: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
driver-class-name: org.postgresql.Driver
username: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
h2:
console:
enabled: false
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
mail:
properties:
mail:
smtp:
auth: false
starttls:
enable: false
app:
payment:
swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49
jwt:
secret: ${JWT_SECRET}
public-base-url: ${APP_PUBLIC_BASE_URL:http://frontend}
# E2E only: never enable in production (see application-prod.yml).
password-reset:
expose-token: true
email-change:
expose-token: true

View file

@ -0,0 +1,21 @@
spring:
flyway:
locations: classpath:db/migration
# Prod DBs created before seeds moved to db/dev-migration still list V2/V4/V6 in history.
ignore-migration-patterns: '*:missing'
# docker profile disables STARTTLS for Mailpit; prod must re-enable for Resend SMTP.
mail:
properties:
mail.smtp.auth: true
mail.smtp.starttls.enable: true
mail.smtp.starttls.required: true
app:
admin:
email: ${ADMIN_EMAIL}
password: ${ADMIN_PASSWORD}
password-reset:
expose-token: false
email-change:
expose-token: false

View file

@ -0,0 +1,47 @@
server:
port: 8080
spring:
application:
name: BilHej
datasource:
url: jdbc:h2:mem:bilhej;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: validate
flyway:
enabled: true
locations: classpath:db/migration,classpath:db/dev-migration
mail:
host: ${MAIL_HOST:}
port: ${MAIL_PORT:587}
username: ${MAIL_USERNAME:}
password: ${MAIL_PASSWORD:}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
app:
public-base-url: ${APP_PUBLIC_BASE_URL:http://localhost:3000}
mail:
from: ${MAIL_FROM:noreply@bilhej.se}
payment:
swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49
jwt:
secret: ${JWT_SECRET:dev-secret-change-in-production}

View file

@ -0,0 +1,8 @@
-- Dev/CI only: test user for local docker and e2e (password: test1234)
INSERT INTO users (id, email, password_hash, subscription)
VALUES (
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'test@bilhej.se',
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
'none'
);

View file

@ -0,0 +1,9 @@
-- Dev/CI only: admin for local docker and e2e (password: test1234)
INSERT INTO users (id, email, password_hash, subscription, role)
VALUES (
'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
'admin@bilhalsning.se',
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
'none',
'admin'
);

View file

@ -0,0 +1,6 @@
-- Dev/CI only: sample orders for test@bilhej.se (id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11)
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
VALUES
('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ABC123', 'Hej! Jag ville bara säga att du har en väldigt fin bil. Hälsningar från en bilentusiast!', 'sent', 49.00, 'PN123456789', TIMESTAMP '2026-05-11 12:00:00', TIMESTAMP '2026-05-13 12:00:00'),
('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'DEF456', 'Hej! Jag är intresserad av att köpa din bil. Kontakta mig gärna på test@example.com så kan vi diskutera ett pris.', 'pending_payment', NULL, NULL, TIMESTAMP '2026-05-14 13:00:00', TIMESTAMP '2026-05-14 13:00:00'),
('c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'GHI789', 'Hej! Jag noterade att ditt bakre högra hjul har lite för lågt lufttryck. Tänkte det kan vara bra att veta!', 'delivered', 49.00, 'PN987654321', TIMESTAMP '2026-05-07 10:00:00', TIMESTAMP '2026-05-12 10:00:00');

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,11 @@
CREATE TABLE users (
id UUID NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
subscription VARCHAR(20) NOT NULL DEFAULT 'none',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_users PRIMARY KEY (id),
CONSTRAINT uq_users_email UNIQUE (email),
CONSTRAINT ck_users_subscription CHECK (subscription IN ('none', 'basic', 'pro'))
);

View file

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';

View file

@ -0,0 +1,17 @@
CREATE TABLE orders (
id UUID NOT NULL,
user_id UUID NOT NULL,
plate VARCHAR(10) NOT NULL,
letter_text TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'pending_payment',
amount_paid DECIMAL(10,2),
tracking_id VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_orders PRIMARY KEY (id),
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT ck_orders_status CHECK (status IN ('pending_payment', 'paid', 'processing', 'sent', 'delivered', 'failed'))
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);

View file

@ -0,0 +1,13 @@
CREATE TABLE password_reset_tokens (
id UUID NOT NULL,
user_id UUID 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_password_reset_tokens PRIMARY KEY (id),
CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens (user_id);
CREATE INDEX idx_password_reset_tokens_token_hash ON password_reset_tokens (token_hash);

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,13 @@
package se.bilhalsning;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class BilHejApplicationTests {
@Test
void contextLoads() {
}
}

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

@ -0,0 +1,81 @@
package se.bilhalsning;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Transactional;
import se.bilhalsning.entity.Subscription;
import se.bilhalsning.entity.User;
import se.bilhalsning.repository.UserRepository;
@SpringBootTest
@Transactional
class FlywayMigrationTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldPersistAndRetrieveUser() {
User user = new User();
user.setEmail("test@example.com");
user.setPasswordHash("hash");
user.setSubscription(Subscription.BASIC);
userRepository.saveAndFlush(user);
User found = userRepository.findById(user.getId()).orElseThrow();
assertEquals(user.getId(), found.getId());
assertEquals("test@example.com", found.getEmail());
assertEquals("hash", found.getPasswordHash());
assertEquals(Subscription.BASIC, found.getSubscription());
assertNotNull(found.getCreatedAt());
assertNotNull(found.getUpdatedAt());
}
@Test
void shouldEnforceUniqueEmail() {
User first = new User();
first.setEmail("unique@example.com");
first.setPasswordHash("hash");
userRepository.saveAndFlush(first);
User duplicate = new User();
duplicate.setEmail("unique@example.com");
duplicate.setPasswordHash("hash");
assertThrows(DataIntegrityViolationException.class, () -> {
userRepository.saveAndFlush(duplicate);
});
}
@Test
void shouldPersistAllSubscriptionValues() {
for (Subscription sub : Subscription.values()) {
User user = new User();
user.setEmail(sub.getValue() + "@example.com");
user.setPasswordHash("hash");
user.setSubscription(sub);
userRepository.saveAndFlush(user);
User found = userRepository.findByEmail(sub.getValue() + "@example.com").orElseThrow();
assertEquals(sub, found.getSubscription());
}
}
@Test
void shouldDefaultSubscriptionToNone() {
User user = new User();
user.setEmail("default@example.com");
user.setPasswordHash("hash");
userRepository.saveAndFlush(user);
User found = userRepository.findByEmail("default@example.com").orElseThrow();
assertEquals(Subscription.NONE, found.getSubscription());
}
}

View file

@ -0,0 +1,72 @@
package se.bilhalsning.config;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.DefaultApplicationArguments;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import se.bilhalsning.entity.User;
import se.bilhalsning.repository.UserRepository;
@ExtendWith(MockitoExtension.class)
class AdminBootstrapTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private AdminBootstrap adminBootstrap;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(adminBootstrap, "adminEmail", "admin@bilhej.se");
ReflectionTestUtils.setField(adminBootstrap, "adminPassword", "secure-production-password");
}
@Test
void shouldSkipBootstrapWhenAdminAlreadyExists() {
when(userRepository.existsByRole("admin")).thenReturn(true);
adminBootstrap.run(new DefaultApplicationArguments(new String[] {}));
verify(userRepository, never()).save(any(User.class));
}
@Test
void shouldCreateAdminWhenMissing() {
when(userRepository.existsByRole("admin")).thenReturn(false);
when(passwordEncoder.encode("secure-production-password")).thenReturn("encoded-hash");
adminBootstrap.run(new DefaultApplicationArguments(new String[] {}));
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(captor.capture());
User saved = captor.getValue();
org.junit.jupiter.api.Assertions.assertEquals("admin@bilhej.se", saved.getEmail());
org.junit.jupiter.api.Assertions.assertEquals("encoded-hash", saved.getPasswordHash());
org.junit.jupiter.api.Assertions.assertEquals("admin", saved.getRole());
}
@Test
void shouldFailWhenCredentialsMissingAndNoAdmin() {
ReflectionTestUtils.setField(adminBootstrap, "adminPassword", "");
when(userRepository.existsByRole("admin")).thenReturn(false);
org.junit.jupiter.api.Assertions.assertThrows(
IllegalStateException.class,
() -> adminBootstrap.run(new DefaultApplicationArguments(new String[] {})));
}
}

View file

@ -0,0 +1,33 @@
package se.bilhalsning.config;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.mockito.InOrder;
@SpringBootTest(
properties = {
"app.admin.email=admin@test.se",
"app.admin.password=test-password",
})
@ActiveProfiles("prod")
class ProdFlywayConfigTest {
@Autowired
private FlywayMigrationStrategy flywayMigrationStrategy;
@Test
void shouldRepairBeforeMigrateOnProd() {
Flyway flyway = mock(Flyway.class);
flywayMigrationStrategy.migrate(flyway);
InOrder order = inOrder(flyway);
order.verify(flyway).repair();
order.verify(flyway).migrate();
}
}

View file

@ -0,0 +1,44 @@
package se.bilhalsning.config;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import se.bilhalsning.security.JwtService;
@SpringBootTest
class SecurityConfigTest {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private SecurityFilterChain securityFilterChain;
@Autowired
private JwtService jwtService;
@Test
void shouldProvideBcryptPasswordEncoder() {
assertNotNull(passwordEncoder);
assertInstanceOf(BCryptPasswordEncoder.class, passwordEncoder);
}
@Test
void shouldProvideSecurityFilterChain() {
assertNotNull(securityFilterChain);
assertDoesNotThrow(() -> securityFilterChain.getFilters());
assertNotNull(securityFilterChain.getFilters());
}
@Test
void shouldExposeJwtServiceAsBean() {
assertNotNull(jwtService);
}
}

View file

@ -0,0 +1,178 @@
package se.bilhalsning.controller;
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;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
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.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.service.AdminOrderWorkflowService;
import se.bilhalsning.service.OrderService;
@SpringBootTest
@AutoConfigureMockMvc
class AdminControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@MockitoBean
private AdminOrderWorkflowService adminOrderWorkflowService;
@Test
void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/admin/orders"))
.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(jsonPath("$.message").exists());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturnAllOrdersForAdmin() throws Exception {
Order order = createOrder(UUID.randomUUID(), "ABC123", "test@bilhej.se", OrderStatus.SENT);
when(orderService.getAllOrders()).thenReturn(List.of(order));
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.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].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.FAILED);
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("failed")))
.thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"failed\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("failed"));
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn409WhenStatusTransitionInvalid() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
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\":\"delivered\"}"))
.andExpect(status().isConflict());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldRegisterShipmentSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
order.setTrackingId("PN123456789");
order.setShippedAt(Instant.parse("2026-05-13T12:00:00Z"));
when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123456789"), eq(true)))
.thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingInput\":\"PN123456789\",\"notifyCustomer\":true}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.trackingId").value("PN123456789"))
.andExpect(jsonPath("$.status").value("sent"));
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn400WhenTrackingInputBlank() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingInput\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateAdminNotes() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
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}/register-shipment", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingInput\":\"PN123\"}"))
.andExpect(status().isNotFound());
}
private Order createOrder(UUID orderId, String plate, String email, OrderStatus status) {
User user = new User();
user.setEmail(email);
Order order = new Order();
order.setId(orderId);
order.setUser(user);
order.setPlate(plate);
order.setLetterText("Test letter");
order.setStatus(status);
order.setAmountPaid(new BigDecimal("49.00"));
return order;
}
}

View file

@ -0,0 +1,270 @@
package se.bilhalsning.controller;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.LoginRequest;
import se.bilhalsning.dto.RegisterRequest;
import se.bilhalsning.entity.User;
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;
@SpringBootTest
@AutoConfigureMockMvc
class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
private final ObjectMapper objectMapper = new ObjectMapper();
@MockitoBean
private UserService userService;
@MockitoBean
private PasswordResetService passwordResetService;
@MockitoBean
private EmailChangeService emailChangeService;
@MockitoBean
private JwtService jwtService;
@Test
void shouldReturn201AndTokenWhenRegisterSucceeds() throws Exception {
when(userService.createUser("new@example.com", "password123")).thenReturn(null);
when(jwtService.generateToken("new@example.com", "user")).thenReturn("test-jwt-token");
RegisterRequest request = new RegisterRequest("new@example.com", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.token").value("test-jwt-token"));
}
@Test
void shouldReturn409WhenEmailAlreadyExists() throws Exception {
when(userService.createUser("taken@example.com", "password123"))
.thenThrow(new EmailAlreadyExistsException("taken@example.com"));
RegisterRequest request = new RegisterRequest("taken@example.com", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message").value("E-postadressen är redan registrerad"));
}
@Test
void shouldReturn400WhenEmailIsInvalid() throws Exception {
RegisterRequest request = new RegisterRequest("not-an-email", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400WhenPasswordIsTooShort() throws Exception {
RegisterRequest request = new RegisterRequest("new@example.com", "1234567");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400WhenEmailIsMissing() throws Exception {
RegisterRequest request = new RegisterRequest("", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn200AndTokenWhenLoginSucceeds() throws Exception {
User user = new User();
user.setEmail("user@example.com");
user.setRole("user");
when(userService.authenticate("user@example.com", "password123")).thenReturn(user);
when(jwtService.generateToken("user@example.com", "user")).thenReturn("login-jwt-token");
LoginRequest request = new LoginRequest("user@example.com", "password123");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("login-jwt-token"));
}
@Test
void shouldReturnAdminRoleInTokenWhenAdminLogsIn() throws Exception {
User admin = new User();
admin.setEmail("admin@bilhalsning.se");
admin.setRole("admin");
when(userService.authenticate("admin@bilhalsning.se", "admin1234")).thenReturn(admin);
when(jwtService.generateToken("admin@bilhalsning.se", "admin")).thenReturn("admin-jwt-token");
LoginRequest request = new LoginRequest("admin@bilhalsning.se", "admin1234");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("admin-jwt-token"));
}
@Test
void shouldReturn401WhenCredentialsAreInvalid() throws Exception {
when(userService.authenticate("user@example.com", "wrongpassword"))
.thenThrow(new InvalidCredentialsException());
LoginRequest request = new LoginRequest("user@example.com", "wrongpassword");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").value("Felaktig e-post eller lösenord"));
}
@Test
void shouldReturn400WhenLoginEmailIsBlank() throws Exception {
LoginRequest request = new LoginRequest("", "password123");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400WhenLoginPasswordIsBlank() throws Exception {
LoginRequest request = new LoginRequest("user@example.com", "");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400WhenLoginEmailIsInvalid() throws Exception {
LoginRequest request = new LoginRequest("not-an-email", "password123");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn200WhenForgotPasswordRequested() throws Exception {
when(passwordResetService.requestReset("user@example.com")).thenReturn(Optional.empty());
mockMvc.perform(post("/api/auth/forgot-password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"user@example.com\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message")
.value("Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet."))
.andExpect(jsonPath("$.testToken").doesNotExist());
verify(passwordResetService).requestReset("user@example.com");
}
@Test
void shouldIncludeTestTokenWhenServiceReturnsToken() throws Exception {
when(passwordResetService.requestReset("user@example.com"))
.thenReturn(Optional.of("e2e-reset-token"));
mockMvc.perform(post("/api/auth/forgot-password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"user@example.com\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.testToken").value("e2e-reset-token"));
}
@Test
void shouldReturn200WhenResetPasswordSucceeds() throws Exception {
mockMvc.perform(post("/api/auth/reset-password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"abc\",\"password\":\"newpassword123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Lösenordet har uppdaterats. Du kan nu logga in."));
}
@Test
@WithMockUser(username = "admin@bilhej.se")
void shouldReturn200WhenChangePasswordSucceeds() throws Exception {
mockMvc.perform(post("/api/auth/change-password")
.contentType(MediaType.APPLICATION_JSON)
.content(
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Lösenordet har uppdaterats."));
}
@Test
void shouldRejectChangePasswordWithoutAuth() throws Exception {
mockMvc.perform(post("/api/auth/change-password")
.contentType(MediaType.APPLICATION_JSON)
.content(
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.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

@ -0,0 +1,306 @@
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;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
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.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;
@MockitoBean
private OrderService orderService;
@MockitoBean
private UserService userService;
@Test
void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldReturnOrdersForAuthenticatedUser() throws Exception {
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.getOrdersByUserId(userId)).thenReturn(List.of());
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldReturnOrderWithAllFields() throws Exception {
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));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Test letter");
order.setStatus(se.bilhalsning.entity.OrderStatus.SENT);
order.setTrackingId("PN123456789");
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of(order));
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(jsonPath("$[0].plate").value("ABC123"))
.andExpect(jsonPath("$[0].letterText").value("Test letter"))
.andExpect(jsonPath("$[0].status").value("sent"))
.andExpect(jsonPath("$[0].trackingId").value("PN123456789"));
}
@Test
@WithMockUser(username = "unknown@example.com")
void shouldReturn401WhenUserNotFound() throws Exception {
when(userService.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
void shouldReturn401WhenPostingWithoutAuth() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
.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
@WithMockUser(username = "test@bilhej.se")
void shouldCreateOrderSuccessfully() throws Exception {
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));
se.bilhalsning.entity.Order savedOrder = new se.bilhalsning.entity.Order();
savedOrder.setId(UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
savedOrder.setUserId(userId);
savedOrder.setPlate("ABC123");
savedOrder.setLetterText("Hej fin bil!");
savedOrder.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
when(orderService.createOrder(userId, "ABC123", "Hej fin bil!"))
.thenReturn(savedOrder);
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej fin bil!\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(jsonPath("$.plate").value("ABC123"))
.andExpect(jsonPath("$.letterText").value("Hej fin bil!"))
.andExpect(jsonPath("$.status").value("pending_payment"));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectInvalidPlateFormat() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"INVALID\",\"letterText\":\"Hej\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Ogiltigt registreringsnummer")));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectEmptyLetterText() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectLetterTextOver1000Chars() throws Exception {
String longText = "a".repeat(1001);
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.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"));
}
}

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