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.
- Backend coverage runs from repo root where gradlew lives
- Frontend coverage runs from frontend/ with working-directory
- No cd tricks that break relative paths
- 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/
- 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
- 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
- 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
- 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
- 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
- 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)
- 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
- 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
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'
- 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 })
- 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
- 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
- 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
- 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
- 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
- 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
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)
- 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)
- 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
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
- 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
- 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)
- 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()
- 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
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
- 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)
- 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.)
- 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
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
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
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)
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
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