Compare commits
No commits in common. "d7739bcd58885f0247486d9671c5ca233bdbd6fd" and "f2c1a9e2d61751bbf113ab9f2ecda2fcab1e03fc" have entirely different histories.
d7739bcd58
...
f2c1a9e2d6
256 changed files with 95 additions and 28022 deletions
|
|
@ -1,66 +0,0 @@
|
||||||
# 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
52
.env.example
|
|
@ -1,52 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
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
6
.gitignore
vendored
|
|
@ -10,7 +10,6 @@ target/
|
||||||
*.jar
|
*.jar
|
||||||
*.war
|
*.war
|
||||||
!.mvn/wrapper/maven-wrapper.jar
|
!.mvn/wrapper/maven-wrapper.jar
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|
@ -38,11 +37,6 @@ Thumbs.db
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
certs/
|
|
||||||
|
|
||||||
# Gradle
|
|
||||||
.gradle/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Java
|
# Java
|
||||||
*.hprof
|
*.hprof
|
||||||
|
|
|
||||||
158
AGENTS.md
158
AGENTS.md
|
|
@ -15,7 +15,7 @@ the recipient's name or address.
|
||||||
integration yet. Owner address is obtained manually by a human and entered into
|
integration yet. Owner address is obtained manually by a human and entered into
|
||||||
the admin panel.
|
the admin panel.
|
||||||
|
|
||||||
Tech stack: Vue.js 3 (Vite, Pinia) frontend + Java 21 Spring Boot 4 backend +
|
Tech stack: Vue.js 3 (Vite, Pinia) frontend + Java 21 Spring Boot 3 backend +
|
||||||
PostgreSQL 16. Deployed via Docker Compose.
|
PostgreSQL 16. Deployed via Docker Compose.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -24,24 +24,11 @@ PostgreSQL 16. Deployed via Docker Compose.
|
||||||
|
|
||||||
Always run these after making changes to verify nothing is broken.
|
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)
|
### Quick start (everything)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # first time only, then fill in keys
|
cp .env.example .env # first time only, then fill in keys
|
||||||
docker compose up -d # starts postgres, backend, frontend
|
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)
|
### Frontend (Vue.js 3 + Vite)
|
||||||
|
|
@ -53,14 +40,15 @@ npm run dev # dev server on :3000 with HMR
|
||||||
npm run build # production build
|
npm run build # production build
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
npm run test # vitest
|
npm run test # vitest
|
||||||
npm run test:coverage # vitest with coverage (HTML at frontend/coverage/)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend (Spring Boot 4 + Java 21)
|
### Backend (Spring Boot 3 + Java 21)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :backend:bootRun # dev server on :8080
|
cd backend
|
||||||
./gradlew :backend:test # JUnit 5 + Mockito (backend only)
|
./mvnw spring-boot:run # dev server on :8080
|
||||||
|
./mvnw test # JUnit 5 + Mockito
|
||||||
|
./mvnw verify # full verification including integration tests
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stripe webhooks (local testing)
|
### Stripe webhooks (local testing)
|
||||||
|
|
@ -74,20 +62,8 @@ stripe listen --forward-to localhost:8080/api/webhooks/stripe
|
||||||
Flyway migrations run automatically on Spring Boot startup. Migration files
|
Flyway migrations run automatically on Spring Boot startup. Migration files
|
||||||
live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descriptive_name.sql`.
|
live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descriptive_name.sql`.
|
||||||
|
|
||||||
**Before adding a migration:** run `./scripts/next-flyway-version.sh` and use that
|
|
||||||
version. Never reuse a version number already on `master`. Never edit a migration
|
|
||||||
after it has merged — add a new higher version instead. CI runs
|
|
||||||
`scripts/check-flyway-migrations.sh` against `origin/master`.
|
|
||||||
|
|
||||||
If local dev Postgres fails with Flyway checksum / “migration not resolved locally”
|
|
||||||
after switching branches, run `./gradlew reset` (wipes the Docker DB volume).
|
|
||||||
|
|
||||||
To reset: `docker compose down -v && docker compose up -d`.
|
To reset: `docker compose down -v && docker compose up -d`.
|
||||||
|
|
||||||
Flyway schema migrations live in `db/migration/`; dev-only seeds (test users,
|
|
||||||
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
|
## Project Structure
|
||||||
|
|
@ -98,14 +74,13 @@ bilhej/
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── pages/ # Route-level page components
|
│ │ ├── pages/ # Route-level page components
|
||||||
│ │ ├── components/ # Reusable UI components
|
│ │ ├── components/ # Reusable UI components
|
||||||
│ │ ├── composables/ # useXxx.ts shared logic
|
│ │ ├── composables/ # useXxx.js shared logic
|
||||||
│ │ ├── stores/ # Pinia stores
|
│ │ ├── stores/ # Pinia stores
|
||||||
│ │ ├── api/ # API client modules
|
│ │ ├── api/ # API client modules
|
||||||
│ │ ├── router/ # Vue Router config
|
│ │ ├── router/ # Vue Router config
|
||||||
│ │ └── assets/ # Static files, CSS
|
│ │ └── assets/ # Static files, CSS
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── backend/ # Spring Boot 4 (Java 21) — Gradle subproject
|
├── backend/ # Spring Boot 3 (Java 21)
|
||||||
│ ├── build.gradle # Spring Boot plugin, Java deps, test config
|
|
||||||
│ ├── src/main/java/se/bilhalsning/
|
│ ├── src/main/java/se/bilhalsning/
|
||||||
│ │ ├── config/ # @Configuration classes
|
│ │ ├── config/ # @Configuration classes
|
||||||
│ │ ├── controller/ # REST controllers
|
│ │ ├── controller/ # REST controllers
|
||||||
|
|
@ -117,23 +92,11 @@ bilhej/
|
||||||
│ │ ├── exception/ # Custom exceptions + @ControllerAdvice
|
│ │ ├── exception/ # Custom exceptions + @ControllerAdvice
|
||||||
│ │ └── mapper/ # Entity ↔ DTO mapping
|
│ │ └── mapper/ # Entity ↔ DTO mapping
|
||||||
│ └── src/main/resources/
|
│ └── src/main/resources/
|
||||||
│ ├── application.yml # default (H2, IDE dev)
|
│ ├── application.yml
|
||||||
│ ├── application-docker.yml # docker profile (PostgreSQL)
|
|
||||||
│ └── db/migration/ # Flyway migrations
|
│ └── db/migration/ # Flyway migrations
|
||||||
├── docker/ # Dockerfiles
|
├── docker/ # Dockerfiles
|
||||||
│ ├── backend.Dockerfile # dev: JDK 21 + gradle :backend:bootRun
|
├── docker-compose.yml
|
||||||
│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle build → JRE Alpine, non-root)
|
├── docker-compose.prod.yml
|
||||||
│ ├── 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
|
├── .env.example
|
||||||
├── AGENTS.md # This file
|
├── AGENTS.md # This file
|
||||||
├── README.md
|
├── README.md
|
||||||
|
|
@ -156,29 +119,6 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
|
||||||
- Create `feature/*`, `fix/*`, or `chore/*` branches from `develop`.
|
- Create `feature/*`, `fix/*`, or `chore/*` branches from `develop`.
|
||||||
- Never commit directly to `master` or `develop`.
|
- Never commit directly to `master` or `develop`.
|
||||||
- Merge strategy: fast-forward or merge — either is fine.
|
- 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)
|
### Frontend (Vue.js 3)
|
||||||
- `<script setup>` with Composition API only. Never Options API.
|
- `<script setup>` with Composition API only. Never Options API.
|
||||||
|
|
@ -186,12 +126,12 @@ agent tool calls), increase it to ≥300 000 ms, or use `--no-verify` and run
|
||||||
- API calls live in `api/` modules, never in components.
|
- API calls live in `api/` modules, never in components.
|
||||||
- Component styles are scoped.
|
- Component styles are scoped.
|
||||||
|
|
||||||
### Backend (Spring Boot 4)
|
### Backend (Spring Boot 3)
|
||||||
- Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`.
|
- Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`.
|
||||||
- DTOs: prefer Java records. No bare entities in responses.
|
- DTOs: prefer Java records. No bare entities in responses.
|
||||||
- Controllers stay thin. All logic in services.
|
- Controllers stay thin. All logic in services.
|
||||||
- Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`).
|
- Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`).
|
||||||
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
|
- No Lombok beyond `@RequiredArgsConstructor`.
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
- Table names: snake_case, plural. PKs: UUID, generated in code.
|
- Table names: snake_case, plural. PKs: UUID, generated in code.
|
||||||
|
|
@ -215,15 +155,6 @@ After the address is used to mail the letter, it must be deleted. The Order
|
||||||
entity must NOT have an address field. The address lookup and mailing are
|
entity must NOT have an address field. The address lookup and mailing are
|
||||||
external/human processes in Phase 0.
|
external/human processes in Phase 0.
|
||||||
|
|
||||||
### E2E must use Docker (not host Playwright)
|
|
||||||
See **Testing Approach → E2E (Playwright) — Docker only** above. Do not run `npx playwright install` or `npm run test:e2e` on the host when verifying E2E.
|
|
||||||
|
|
||||||
### Local email (Mailpit)
|
|
||||||
`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
|
### Stripe webhook signature verification
|
||||||
Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`.
|
Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`.
|
||||||
Webhook endpoint is public (no auth). Without signature verification an
|
Webhook endpoint is public (no auth). Without signature verification an
|
||||||
|
|
@ -243,10 +174,6 @@ public vehicle info) must be excluded from the Spring Security filter chain.
|
||||||
|
|
||||||
## Testing Approach
|
## 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
|
### Backend
|
||||||
- JUnit 5 + Mockito for service layer tests.
|
- JUnit 5 + Mockito for service layer tests.
|
||||||
- `@WebMvcTest` for controller tests.
|
- `@WebMvcTest` for controller tests.
|
||||||
|
|
@ -257,65 +184,10 @@ the same PR — never merge code without corresponding tests.
|
||||||
### Frontend
|
### Frontend
|
||||||
- Vitest for composables and utility functions.
|
- Vitest for composables and utility functions.
|
||||||
- Component tests with Vue Test Utils where needed.
|
- Component tests with Vue Test Utils where needed.
|
||||||
- E2E tests with Playwright in `frontend/e2e/`.
|
- E2E tests deferred to Phase 1.
|
||||||
|
|
||||||
### 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)
|
### CI (future)
|
||||||
- `./gradlew check` and `npm run test && npm run lint` must pass before merge.
|
- `./mvnw verify` 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`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,6 @@ 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.
|
- **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.
|
- **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.
|
- **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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -94,9 +85,9 @@ Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `style`
|
||||||
|--------------|-----------------------------|----------------------------------|
|
|--------------|-----------------------------|----------------------------------|
|
||||||
| Page | PascalCase, in `pages/` | `HomePage.vue`, `OrderHistoryPage.vue` |
|
| Page | PascalCase, in `pages/` | `HomePage.vue`, `OrderHistoryPage.vue` |
|
||||||
| Component | PascalCase, in `components/`| `PlateInput.vue`, `LetterPreview.vue` |
|
| Component | PascalCase, in `components/`| `PlateInput.vue`, `LetterPreview.vue` |
|
||||||
| Composable | camelCase, `use` prefix | `useAuth.ts`, `usePayment.ts` |
|
| Composable | camelCase, `use` prefix | `useAuth.js`, `usePayment.js` |
|
||||||
| Store | camelCase, in `stores/` | `authStore.ts`, `orderStore.ts` |
|
| Store | camelCase, in `stores/` | `authStore.js`, `orderStore.js` |
|
||||||
| API module | camelCase, in `api/` | `orders.ts`, `templates.ts` |
|
| API module | camelCase, in `api/` | `orders.js`, `templates.js` |
|
||||||
|
|
||||||
### Component Structure
|
### Component Structure
|
||||||
|
|
||||||
|
|
@ -149,12 +140,12 @@ async function handleSubmit() {
|
||||||
- `v-model` bindings use `defineModel()` or explicit `modelValue` + `update:modelValue`.
|
- `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.
|
- 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).
|
- Prefer Pinia stores over prop drilling for shared state (auth, current order).
|
||||||
- API calls live in `api/*.ts` modules, not in components.
|
- API calls live in `api/*.js` modules, not in components.
|
||||||
- Use `fetch` or `axios` via a single configured instance (base URL, auth header interceptor).
|
- Use `fetch` or `axios` via a single configured instance (base URL, auth header interceptor).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Backend — Spring Boot 4
|
## 4. Backend — Spring Boot 3
|
||||||
|
|
||||||
### Package Structure
|
### Package Structure
|
||||||
|
|
||||||
|
|
@ -218,7 +209,7 @@ public class OrderController {
|
||||||
- All responses: `ResponseEntity<T>`. Never return bare entities.
|
- All responses: `ResponseEntity<T>`. Never return bare entities.
|
||||||
- Entity fields use `snake_case` column naming explicitly (`@Column(name = "created_at")`).
|
- 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/`.
|
- Database migrations: Flyway. All schema changes go through SQL migration files in `db/migration/`.
|
||||||
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
|
- No Lombok beyond `@RequiredArgsConstructor`. Prefer explicit getters/setters or records.
|
||||||
|
|
||||||
### API Path Conventions
|
### API Path Conventions
|
||||||
|
|
||||||
|
|
@ -250,7 +241,7 @@ GET /api/vehicles/{plate} Get public vehicle info
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// api/client.ts — centralized fetch wrapper
|
// api/client.js — centralized fetch wrapper
|
||||||
async function request(url, options) {
|
async function request(url, options) {
|
||||||
const response = await fetch(`${BASE_URL}${url}`, {
|
const response = await fetch(`${BASE_URL}${url}`, {
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -298,37 +289,10 @@ public class GlobalExceptionHandler {
|
||||||
|
|
||||||
## 7. Testing
|
## 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`.
|
- Backend: JUnit 5 + Mockito. Service layer tests as unit tests. Controller tests with `@WebMvcTest`.
|
||||||
- Frontend: Vitest for composables and utility functions. Component tests with Vue Test Utils.
|
- Frontend: Vitest for composables and utility functions. Cypress or Playwright for E2E (Phase 1).
|
||||||
- E2E: Playwright (`npm run test:e2e`). Tests in `frontend/e2e/`. Requires `docker compose up`.
|
|
||||||
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
|
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
|
||||||
- Aim for test coverage on business logic, not on getters/setters/boilerplate.
|
- 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
404
README.md
|
|
@ -11,7 +11,7 @@ The user enters a registration number, composes a letter (from a template or fre
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-------------|-----------------------------------------|
|
|-------------|-----------------------------------------|
|
||||||
| Frontend | Vue.js 3 (Composition API), Vite, Pinia |
|
| Frontend | Vue.js 3 (Composition API), Vite, Pinia |
|
||||||
| Backend | Java 21, Spring Boot 4, Gradle |
|
| Backend | Java 21, Spring Boot 3 |
|
||||||
| Database | PostgreSQL 16 |
|
| Database | PostgreSQL 16 |
|
||||||
| Auth | Spring Security + JWT |
|
| Auth | Spring Security + JWT |
|
||||||
| Payments | Stripe (cards + Swish) |
|
| Payments | Stripe (cards + Swish) |
|
||||||
|
|
@ -34,53 +34,13 @@ The user enters a registration number, composes a letter (from a template or fre
|
||||||
git clone <repo-url> bilhej
|
git clone <repo-url> bilhej
|
||||||
cd bilhej
|
cd bilhej
|
||||||
cp .env.example .env # fill in your keys
|
cp .env.example .env # fill in your keys
|
||||||
docker compose up -d # or: ./gradlew up
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The app will be available at:
|
The app will be available at:
|
||||||
- Frontend: `http://localhost:3000`
|
- Frontend: `http://localhost:3000`
|
||||||
- Backend API: `http://localhost:8080`
|
- Backend API: `http://localhost:8080`
|
||||||
- PostgreSQL: `localhost:5432`
|
- 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`) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -97,147 +57,6 @@ Copy `.env.example` to `.env` and fill in:
|
||||||
| `STRIPE_SECRET_KEY` | Stripe secret key |
|
| `STRIPE_SECRET_KEY` | Stripe secret key |
|
||||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
|
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
|
||||||
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
|
| `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'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -256,11 +75,11 @@ bilhej/
|
||||||
│ │ ├── api/ # API client and endpoints
|
│ │ ├── api/ # API client and endpoints
|
||||||
│ │ ├── assets/ # Static assets, CSS
|
│ │ ├── assets/ # Static assets, CSS
|
||||||
│ │ ├── App.vue
|
│ │ ├── App.vue
|
||||||
│ │ └── main.ts
|
│ │ └── main.js
|
||||||
│ ├── index.html
|
│ ├── index.html
|
||||||
│ ├── vite.config.ts
|
│ ├── vite.config.js
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── backend/ # Spring Boot 4
|
├── backend/ # Spring Boot 3
|
||||||
│ ├── src/main/java/se/bilhalsning/
|
│ ├── src/main/java/se/bilhalsning/
|
||||||
│ │ ├── BilHejApplication.java
|
│ │ ├── BilHejApplication.java
|
||||||
│ │ ├── config/ # Security, CORS, Stripe config
|
│ │ ├── config/ # Security, CORS, Stripe config
|
||||||
|
|
@ -271,222 +90,36 @@ bilhej/
|
||||||
│ │ ├── service/ # Business logic
|
│ │ ├── service/ # Business logic
|
||||||
│ │ └── security/ # JWT filter, user details
|
│ │ └── security/ # JWT filter, user details
|
||||||
│ └── src/main/resources/
|
│ └── src/main/resources/
|
||||||
│ ├── application.yml # default profile (H2)
|
│ ├── application.yml
|
||||||
│ ├── application-docker.yml # docker profile (PostgreSQL)
|
|
||||||
│ └── db/migration/ # Flyway migrations
|
│ └── db/migration/ # Flyway migrations
|
||||||
├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR)
|
├── docker-compose.yml
|
||||||
├── docker-compose.prod.yml # prod: multi-stage builds, no source mounts, restart: unless-stopped
|
|
||||||
├── docker/
|
├── docker/
|
||||||
│ ├── backend.Dockerfile # dev: JDK + gradle :backend:bootRun
|
│ ├── backend.Dockerfile
|
||||||
│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle → JRE Alpine, non-root)
|
│ └── frontend.Dockerfile
|
||||||
│ ├── 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
|
├── .env.example
|
||||||
├── README.md
|
├── README.md
|
||||||
├── REQUIREMENTS.md
|
├── REQUIREMENTS.md
|
||||||
└── CODING_GUIDELINES.md
|
├── CODING_GUIDELINES.md
|
||||||
```
|
└── ARCHITECTURE.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
|
## 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)
|
### Frontend (dev server with HMR)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install # first time only
|
npm install
|
||||||
npm run dev # :3000 with HMR
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend (IDE or CLI)
|
### Backend (IDE or CLI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :backend:bootRun # :8080, profile: default (H2)
|
cd backend
|
||||||
|
./mvnw spring-boot:run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stripe Webhooks (local testing)
|
### Stripe Webhooks (local testing)
|
||||||
|
|
@ -495,15 +128,10 @@ npm run dev # :3000 with HMR
|
||||||
stripe listen --forward-to localhost:8080/api/webhooks/stripe
|
stripe listen --forward-to localhost:8080/api/webhooks/stripe
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database reset
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew reset # wipes DB volume and restarts containers
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Related Documents
|
## Related Documents
|
||||||
|
|
||||||
- [REQUIREMENTS.md](./REQUIREMENTS.md) — Full product requirements and business model
|
- [REQUIREMENTS.md](./REQUIREMENTS.md) — Full product requirements and business model
|
||||||
- [CODING_GUIDELINES.md](./CODING_GUIDELINES.md) — Code conventions and standards
|
- [CODING_GUIDELINES.md](./CODING_GUIDELINES.md) — Code conventions and standards
|
||||||
|
- [ARCHITECTURE.md](./ARCHITECTURE.md) — Detailed architecture and data flow
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ the user assumes full responsibility for content.
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| Frontend | Vue.js 3 (Composition API), Vite, Pinia state management, Vue Router |
|
| Frontend | Vue.js 3 (Composition API), Vite, Pinia state management, Vue Router |
|
||||||
| Backend API | Java 21, Spring Boot 4, Spring Security (JWT), Spring Data JPA |
|
| Backend API | Java 21, Spring Boot 3, Spring Security (JWT), Spring Data JPA |
|
||||||
| Database | PostgreSQL 16 |
|
| Database | PostgreSQL 16 |
|
||||||
| Deployment | Docker, Docker Compose |
|
| Deployment | Docker, Docker Compose |
|
||||||
| Hosting (Phase 0) | Home server via dynamic DNS or static IP, Let's Encrypt SSL |
|
| 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
|
│ REST API calls
|
||||||
┌──────────────────▼───────────────────────┐
|
┌──────────────────▼───────────────────────┐
|
||||||
│ Spring Boot 4 (Java 21) │
|
│ Spring Boot 3 (Java 21) │
|
||||||
│ Port: 8080 │
|
│ Port: 8080 │
|
||||||
│ ┌────────────┐ ┌────────────────────┐ │
|
│ ┌────────────┐ ┌────────────────────┐ │
|
||||||
│ │ Spring │ │ Service Layer │ │
|
│ │ 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 a license plate personal data? | Yes (it directly identifies a vehicle owner). |
|
||||||
| Is an address personal data? | Yes. |
|
| Is an address personal data? | Yes. |
|
||||||
| What if we only process address transiently? | Data minimization is a GDPR principle (Art. 5(1)(c)). Transient processing with immediate deletion is a strong compliance posture. |
|
| What if we only process address transiently? | Data minimization is a GDPR principle (Art. 5(1)(c)). Transient processing with immediate deletion is a strong compliance posture. |
|
||||||
| Do we need to inform the recipient? | Yes, GDPR Art. 14 requires informing the data subject. The letter itself can serve this purpose — include a footer like: _"Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: kontakt@bilhej.se"_ |
|
| Do we need to inform the recipient? | Yes, GDPR Art. 14 requires informing the data subject. The letter itself can serve this purpose — include a footer like: _"Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se"_ |
|
||||||
|
|
||||||
### 11.2 Transportstyrelsen Access
|
### 11.2 Transportstyrelsen Access
|
||||||
|
|
||||||
|
|
@ -556,7 +556,7 @@ For Phase 0 with manual processing, staying unregistered is workable. If revenue
|
||||||
|
|
||||||
```
|
```
|
||||||
Frontend: Vue.js 3, Vite, Pinia, Vue Router
|
Frontend: Vue.js 3, Vite, Pinia, Vue Router
|
||||||
Backend: Java 21, Spring Boot 4, Spring Security (JWT), JPA/Hibernate
|
Backend: Java 21, Spring Boot 3, Spring Security (JWT), JPA/Hibernate
|
||||||
Database: PostgreSQL 16
|
Database: PostgreSQL 16
|
||||||
Deploy: Docker, Docker Compose, Nginx reverse proxy
|
Deploy: Docker, Docker Compose, Nginx reverse proxy
|
||||||
Hosting: Home server (Phase 0) → Swedish VPS (Phase 1)
|
Hosting: Home server (Phase 0) → Swedish VPS (Phase 1)
|
||||||
|
|
|
||||||
3
backend/.gitattributes
vendored
3
backend/.gitattributes
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
/gradlew text eol=lf
|
|
||||||
*.bat text eol=crlf
|
|
||||||
*.jar binary
|
|
||||||
37
backend/.gitignore
vendored
37
backend/.gitignore
vendored
|
|
@ -1,37 +0,0 @@
|
||||||
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/
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
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
93
backend/gradlew.bat
vendored
|
|
@ -1,93 +0,0 @@
|
||||||
@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
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
import se.bilhalsning.entity.Order;
|
|
||||||
import se.bilhalsning.service.AdminOrderStatusRules;
|
|
||||||
|
|
||||||
public final class AdminOrderMapper {
|
|
||||||
|
|
||||||
private AdminOrderMapper() {}
|
|
||||||
|
|
||||||
public static AdminOrderResponse toResponse(Order order) {
|
|
||||||
String email = order.getUser() != null ? order.getUser().getEmail() : "";
|
|
||||||
return new AdminOrderResponse(
|
|
||||||
order.getId(),
|
|
||||||
email,
|
|
||||||
order.getPlate(),
|
|
||||||
order.getLetterText(),
|
|
||||||
order.getStatus().getValue(),
|
|
||||||
order.getTrackingId(),
|
|
||||||
order.getAmountPaid(),
|
|
||||||
order.getShippedAt(),
|
|
||||||
order.getAdminNotes(),
|
|
||||||
order.getCreatedAt(),
|
|
||||||
AdminOrderStatusRules.allowedStatusValues(order),
|
|
||||||
AdminOrderStatusRules.canRegisterShipment(order));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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
|
|
||||||
) {}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
public record AuthResponse(String token) {}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.Email;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
public record ChangeEmailRequest(
|
|
||||||
@NotBlank @Email String newEmail, @NotBlank String password) {}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public record ChangeEmailResponse(String message, String testToken) {
|
|
||||||
|
|
||||||
public static ChangeEmailResponse of(String message, Optional<String> testToken) {
|
|
||||||
return new ChangeEmailResponse(message, testToken.orElse(null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
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) {}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
public record ConfirmEmailChangeRequest(@NotBlank String token, @NotBlank String password) {}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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
|
|
||||||
) {}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
public record ErrorResponse(String message) {}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.Email;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
public record ForgotPasswordRequest(@NotBlank @Email String email) {}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public record ForgotPasswordResponse(String message, String testToken) {
|
|
||||||
|
|
||||||
public static ForgotPasswordResponse of(String message, Optional<String> testToken) {
|
|
||||||
return new ForgotPasswordResponse(message, testToken.orElse(null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.Email;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
public record LoginRequest(
|
|
||||||
@NotBlank @Email String email,
|
|
||||||
@NotBlank String password
|
|
||||||
) {}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
public record MessageResponse(String message) {}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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
|
|
||||||
) {}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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
|
|
||||||
) {}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
public record RegisterShipmentRequest(
|
|
||||||
@NotBlank(message = "Spårnings-ID krävs")
|
|
||||||
String trackingInput,
|
|
||||||
Boolean notifyCustomer
|
|
||||||
) {
|
|
||||||
public boolean notifyCustomerOrDefault() {
|
|
||||||
return notifyCustomer == null || notifyCustomer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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
|
|
||||||
) {}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
public record UpdateAdminNotesRequest(
|
|
||||||
String adminNotes
|
|
||||||
) {}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
|
|
||||||
public record UpdateOrderRequest(
|
|
||||||
@NotBlank(message = "Brevtext krävs")
|
|
||||||
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
|
|
||||||
String letterText
|
|
||||||
) {}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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
|
|
||||||
) {}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
public record VehicleInfoResponse(
|
|
||||||
String make,
|
|
||||||
String model,
|
|
||||||
int year,
|
|
||||||
String color,
|
|
||||||
String fuel
|
|
||||||
) {}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
package se.bilhalsning.entity;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.FetchType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.PrePersist;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "email_change_tokens")
|
|
||||||
public class EmailChangeToken {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
|
|
||||||
private UUID id;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
|
||||||
@JoinColumn(name = "user_id", nullable = false)
|
|
||||||
private User user;
|
|
||||||
|
|
||||||
@Column(name = "new_email", nullable = false)
|
|
||||||
private String newEmail;
|
|
||||||
|
|
||||||
@Column(name = "token_hash", nullable = false, length = 64)
|
|
||||||
private String tokenHash;
|
|
||||||
|
|
||||||
@Column(name = "expires_at", nullable = false)
|
|
||||||
private Instant expiresAt;
|
|
||||||
|
|
||||||
@Column(name = "used_at")
|
|
||||||
private Instant usedAt;
|
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
|
||||||
private Instant createdAt;
|
|
||||||
|
|
||||||
@PrePersist
|
|
||||||
void onCreate() {
|
|
||||||
if (this.id == null) {
|
|
||||||
this.id = UUID.randomUUID();
|
|
||||||
}
|
|
||||||
if (this.createdAt == null) {
|
|
||||||
this.createdAt = Instant.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public UUID getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setId(UUID id) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public User getUser() {
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUser(User user) {
|
|
||||||
this.user = user;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getNewEmail() {
|
|
||||||
return newEmail;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setNewEmail(String newEmail) {
|
|
||||||
this.newEmail = newEmail;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getTokenHash() {
|
|
||||||
return tokenHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setTokenHash(String tokenHash) {
|
|
||||||
this.tokenHash = tokenHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getExpiresAt() {
|
|
||||||
return expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setExpiresAt(Instant expiresAt) {
|
|
||||||
this.expiresAt = expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getUsedAt() {
|
|
||||||
return usedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUsedAt(Instant usedAt) {
|
|
||||||
this.usedAt = usedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getCreatedAt() {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCreatedAt(Instant createdAt) {
|
|
||||||
this.createdAt = createdAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
package se.bilhalsning.entity;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.FetchType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.PrePersist;
|
|
||||||
import jakarta.persistence.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
package se.bilhalsning.entity;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.FetchType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.PrePersist;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
package se.bilhalsning.exception;
|
|
||||||
|
|
||||||
public class EmailAlreadyExistsException extends RuntimeException {
|
|
||||||
|
|
||||||
public EmailAlreadyExistsException(String email) {
|
|
||||||
super("Email already registered: " + email);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
package se.bilhalsning.exception;
|
|
||||||
|
|
||||||
public class EmailChangeTokenInvalidException extends RuntimeException {
|
|
||||||
|
|
||||||
public EmailChangeTokenInvalidException() {
|
|
||||||
super("Bekräftelselänken är ogiltig eller har gått ut.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package se.bilhalsning.exception;
|
|
||||||
|
|
||||||
public class InvalidCredentialsException extends RuntimeException {
|
|
||||||
public InvalidCredentialsException() {
|
|
||||||
super("Felaktig e-post eller lösenord");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package se.bilhalsning.exception;
|
|
||||||
|
|
||||||
public class InvalidOrderStateException extends RuntimeException {
|
|
||||||
public InvalidOrderStateException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
package se.bilhalsning.exception;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public class OrderNotFoundException extends RuntimeException {
|
|
||||||
public OrderNotFoundException(UUID id) {
|
|
||||||
super("Order not found: " + id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
package se.bilhalsning.exception;
|
|
||||||
|
|
||||||
public class PasswordResetTokenInvalidException extends RuntimeException {
|
|
||||||
|
|
||||||
public PasswordResetTokenInvalidException() {
|
|
||||||
super("Återställningslänken är ogiltig eller har gått ut");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package se.bilhalsning.exception;
|
|
||||||
|
|
||||||
public class VehicleLookupException extends RuntimeException {
|
|
||||||
public VehicleLookupException(String message, Throwable cause) {
|
|
||||||
super(message, cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package se.bilhalsning.exception;
|
|
||||||
|
|
||||||
public class VehicleNotFoundException extends RuntimeException {
|
|
||||||
public VehicleNotFoundException(String plate) {
|
|
||||||
super("Vehicle not found: " + plate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
package se.bilhalsning.repository;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
import se.bilhalsning.entity.EmailChangeToken;
|
|
||||||
|
|
||||||
public interface EmailChangeTokenRepository extends JpaRepository<EmailChangeToken, UUID> {
|
|
||||||
|
|
||||||
Optional<EmailChangeToken> findByTokenHashAndUsedAtIsNull(String tokenHash);
|
|
||||||
|
|
||||||
@Modifying
|
|
||||||
@Query("DELETE FROM EmailChangeToken t WHERE t.user.id = :userId AND t.usedAt IS NULL")
|
|
||||||
void deleteUnusedByUserId(@Param("userId") UUID userId);
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
package se.bilhalsning.repository;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
import se.bilhalsning.entity.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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
package se.bilhalsning.service;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import se.bilhalsning.entity.Order;
|
|
||||||
import se.bilhalsning.entity.OrderStatus;
|
|
||||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin status transitions and UI affordances. Single source of truth for
|
|
||||||
* {@link AdminOrderResponse#allowedStatuses()} and {@link AdminOrderResponse#canRegisterShipment()}.
|
|
||||||
*/
|
|
||||||
public final class AdminOrderStatusRules {
|
|
||||||
|
|
||||||
private AdminOrderStatusRules() {}
|
|
||||||
|
|
||||||
public static List<String> allowedStatusValues(Order order) {
|
|
||||||
OrderStatus current = order.getStatus();
|
|
||||||
LinkedHashSet<OrderStatus> options = new LinkedHashSet<>();
|
|
||||||
options.add(current);
|
|
||||||
for (OrderStatus target : allowedTargets(current, order)) {
|
|
||||||
options.add(target);
|
|
||||||
}
|
|
||||||
List<String> values = new ArrayList<>();
|
|
||||||
for (OrderStatus status : options) {
|
|
||||||
values.add(status.getValue());
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean canRegisterShipment(Order order) {
|
|
||||||
OrderStatus status = order.getStatus();
|
|
||||||
if (status == OrderStatus.PROCESSING
|
|
||||||
|| status == OrderStatus.SENT
|
|
||||||
|| status == OrderStatus.DELIVERED) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return status == OrderStatus.FAILED && order.getAmountPaid() != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void validateTransition(Order order, OrderStatus to) {
|
|
||||||
OrderStatus from = order.getStatus();
|
|
||||||
if (from == to) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!allowedTargets(from, order).contains(to)) {
|
|
||||||
throw new InvalidOrderStateException(
|
|
||||||
"Status kan inte ändras från " + from.getValue() + " till " + to.getValue());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<OrderStatus> allowedTargets(OrderStatus from, Order order) {
|
|
||||||
return switch (from) {
|
|
||||||
case PENDING_PAYMENT -> List.of(OrderStatus.FAILED);
|
|
||||||
case PROCESSING -> List.of(OrderStatus.FAILED);
|
|
||||||
case SENT -> List.of(OrderStatus.DELIVERED, OrderStatus.FAILED);
|
|
||||||
case DELIVERED -> List.of(OrderStatus.FAILED);
|
|
||||||
case FAILED -> allowedTargetsFromFailed(order);
|
|
||||||
default -> List.of();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<OrderStatus> allowedTargetsFromFailed(Order order) {
|
|
||||||
if (hasTrackingId(order)) {
|
|
||||||
return List.of(
|
|
||||||
OrderStatus.PROCESSING,
|
|
||||||
OrderStatus.SENT,
|
|
||||||
OrderStatus.DELIVERED);
|
|
||||||
}
|
|
||||||
if (order.getAmountPaid() == null) {
|
|
||||||
return List.of(OrderStatus.PENDING_PAYMENT);
|
|
||||||
}
|
|
||||||
return List.of(OrderStatus.PROCESSING, OrderStatus.SENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean hasTrackingId(Order order) {
|
|
||||||
String trackingId = order.getTrackingId();
|
|
||||||
return trackingId != null && !trackingId.isBlank();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
package se.bilhalsning.service;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import se.bilhalsning.entity.Order;
|
|
||||||
import se.bilhalsning.entity.OrderStatus;
|
|
||||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
|
||||||
import se.bilhalsning.exception.OrderNotFoundException;
|
|
||||||
import se.bilhalsning.repository.OrderRepository;
|
|
||||||
import se.bilhalsning.util.PostNordTrackingNormalizer;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AdminOrderWorkflowService {
|
|
||||||
|
|
||||||
private final OrderRepository orderRepository;
|
|
||||||
private final OrderNotificationService orderNotificationService;
|
|
||||||
|
|
||||||
public Order updateOrderStatus(UUID orderId, String statusString) {
|
|
||||||
Order order = requireOrder(orderId);
|
|
||||||
OrderStatus newStatus = parseStatus(statusString);
|
|
||||||
OrderStatus previousStatus = order.getStatus();
|
|
||||||
AdminOrderStatusRules.validateTransition(order, newStatus);
|
|
||||||
order.setStatus(newStatus);
|
|
||||||
if (newStatus == OrderStatus.SENT
|
|
||||||
&& previousStatus == OrderStatus.FAILED
|
|
||||||
&& order.getShippedAt() == null) {
|
|
||||||
order.setShippedAt(Instant.now());
|
|
||||||
}
|
|
||||||
Order saved = orderRepository.save(order);
|
|
||||||
if (newStatus == OrderStatus.FAILED && previousStatus != OrderStatus.FAILED) {
|
|
||||||
orderNotificationService.notifyOrderFailed(saved);
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Order registerShipment(UUID orderId, String rawTrackingInput, boolean notifyCustomer) {
|
|
||||||
String trackingId = PostNordTrackingNormalizer.normalize(rawTrackingInput);
|
|
||||||
Order order = requireOrder(orderId);
|
|
||||||
OrderStatus previousStatus = order.getStatus();
|
|
||||||
|
|
||||||
if (!AdminOrderStatusRules.canRegisterShipment(order)) {
|
|
||||||
throw new InvalidOrderStateException(
|
|
||||||
"Beställningen kan inte registreras som utskickad i detta tillstånd");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousStatus == OrderStatus.FAILED && order.getAmountPaid() == null) {
|
|
||||||
throw new InvalidOrderStateException(
|
|
||||||
"Obetalda misslyckade beställningar kan inte registreras som utskickade");
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean firstShipment = previousStatus == OrderStatus.PROCESSING
|
|
||||||
|| previousStatus == OrderStatus.FAILED;
|
|
||||||
order.setTrackingId(trackingId);
|
|
||||||
if (firstShipment) {
|
|
||||||
order.setStatus(OrderStatus.SENT);
|
|
||||||
order.setShippedAt(Instant.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
Order saved = orderRepository.save(order);
|
|
||||||
if (notifyCustomer && firstShipment) {
|
|
||||||
orderNotificationService.notifyOrderSent(saved, trackingId);
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Order updateAdminNotes(UUID orderId, String adminNotes) {
|
|
||||||
Order order = requireOrder(orderId);
|
|
||||||
order.setAdminNotes(adminNotes);
|
|
||||||
return orderRepository.save(order);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Order requireOrder(UUID orderId) {
|
|
||||||
return orderRepository.findWithUserById(orderId)
|
|
||||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static OrderStatus parseStatus(String statusString) {
|
|
||||||
try {
|
|
||||||
return OrderStatus.valueOf(statusString.toUpperCase());
|
|
||||||
} catch (IllegalArgumentException ex) {
|
|
||||||
throw new IllegalArgumentException("Ogiltig status");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
package se.bilhalsning.service;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.Optional;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import se.bilhalsning.entity.EmailChangeToken;
|
|
||||||
import se.bilhalsning.entity.User;
|
|
||||||
import se.bilhalsning.exception.EmailChangeTokenInvalidException;
|
|
||||||
import se.bilhalsning.repository.EmailChangeTokenRepository;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class EmailChangeService {
|
|
||||||
|
|
||||||
private static final long TOKEN_TTL_HOURS = 24;
|
|
||||||
|
|
||||||
private final UserService userService;
|
|
||||||
private final EmailChangeTokenRepository tokenRepository;
|
|
||||||
private final EmailService emailService;
|
|
||||||
private final PasswordResetService passwordResetService;
|
|
||||||
|
|
||||||
@Value("${app.public-base-url:http://localhost:3000}")
|
|
||||||
private String publicBaseUrl;
|
|
||||||
|
|
||||||
@Value("${app.email-change.expose-token:false}")
|
|
||||||
private boolean exposeToken;
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Optional<String> requestChange(String currentEmail, String password, String newEmail) {
|
|
||||||
User user = userService.authenticate(currentEmail, password);
|
|
||||||
userService.validateEmailAvailableForChange(user, newEmail);
|
|
||||||
|
|
||||||
String normalizedEmail = newEmail.toLowerCase().trim();
|
|
||||||
tokenRepository.deleteUnusedByUserId(user.getId());
|
|
||||||
|
|
||||||
String rawToken = passwordResetService.generateRawToken();
|
|
||||||
EmailChangeToken entity = new EmailChangeToken();
|
|
||||||
entity.setUser(user);
|
|
||||||
entity.setNewEmail(normalizedEmail);
|
|
||||||
entity.setTokenHash(PasswordResetService.hashToken(rawToken));
|
|
||||||
entity.setExpiresAt(Instant.now().plusSeconds(TOKEN_TTL_HOURS * 3600));
|
|
||||||
tokenRepository.save(entity);
|
|
||||||
|
|
||||||
String confirmUrl = publicBaseUrl.replaceAll("/$", "")
|
|
||||||
+ "/bekrafta-epost?token="
|
|
||||||
+ rawToken;
|
|
||||||
emailService.sendEmailChangeConfirmation(normalizedEmail, confirmUrl);
|
|
||||||
|
|
||||||
return exposeToken ? Optional.of(rawToken) : Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public User confirmChange(String rawToken, String password) {
|
|
||||||
EmailChangeToken token = tokenRepository
|
|
||||||
.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken(rawToken))
|
|
||||||
.filter(t -> t.getExpiresAt().isAfter(Instant.now()))
|
|
||||||
.orElseThrow(EmailChangeTokenInvalidException::new);
|
|
||||||
|
|
||||||
User user = token.getUser();
|
|
||||||
userService.authenticate(user.getEmail(), password);
|
|
||||||
User updated = userService.applyEmailChange(user, token.getNewEmail());
|
|
||||||
token.setUsedAt(Instant.now());
|
|
||||||
tokenRepository.deleteUnusedByUserId(user.getId());
|
|
||||||
tokenRepository.save(token);
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
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 på dina beställningar här:
|
|
||||||
%s
|
|
||||||
|
|
||||||
Vänliga hälsningar,
|
|
||||||
BilHej
|
|
||||||
""".formatted(plate, ordersUrl);
|
|
||||||
sendPlainText(toEmail, subject, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void sendOrderSentEmail(String toEmail, String plate, String trackingId, String trackingUrl) {
|
|
||||||
String subject = "Ditt brev är skickat – BilHej";
|
|
||||||
String body = """
|
|
||||||
Hej,
|
|
||||||
|
|
||||||
Ditt brev till fordonet %s har skickats med PostNord.
|
|
||||||
|
|
||||||
Spårnings-ID: %s
|
|
||||||
Spåra brevet: %s
|
|
||||||
|
|
||||||
Vänliga hälsningar,
|
|
||||||
BilHej
|
|
||||||
""".formatted(plate, trackingId, trackingUrl);
|
|
||||||
sendPlainText(toEmail, subject, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void sendOrderFailedEmail(String toEmail, String plate, String ordersUrl) {
|
|
||||||
String subject = "Din beställning kunde inte slutföras – BilHej";
|
|
||||||
String body = """
|
|
||||||
Hej,
|
|
||||||
|
|
||||||
Tyvärr kunde vi inte slutföra din beställning för fordonet %s. Vi återkommer om återbetalning behövs.
|
|
||||||
|
|
||||||
Se dina beställningar här:
|
|
||||||
%s
|
|
||||||
|
|
||||||
Vänliga hälsningar,
|
|
||||||
BilHej
|
|
||||||
""".formatted(plate, ordersUrl);
|
|
||||||
sendPlainText(toEmail, subject, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sendPlainText(String toEmail, String subject, String body) {
|
|
||||||
if (mailHost == null || mailHost.isBlank() || mailSender == null) {
|
|
||||||
log.info("SMTP not configured. Email to {} — subject: {}", toEmail, subject);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SimpleMailMessage message = new SimpleMailMessage();
|
|
||||||
message.setFrom(mailFrom);
|
|
||||||
message.setTo(toEmail);
|
|
||||||
message.setSubject(subject);
|
|
||||||
message.setText(body);
|
|
||||||
try {
|
|
||||||
mailSender.send(message);
|
|
||||||
} catch (MailException ex) {
|
|
||||||
log.error("Failed to send email to {}", toEmail, ex);
|
|
||||||
throw new IllegalStateException("Kunde inte skicka e-post just nu");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
package se.bilhalsning.service;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import se.bilhalsning.entity.Order;
|
|
||||||
import se.bilhalsning.entity.User;
|
|
||||||
import se.bilhalsning.repository.UserRepository;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class OrderNotificationService {
|
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
|
||||||
private final EmailService emailService;
|
|
||||||
|
|
||||||
@Value("${app.public-base-url:http://localhost:3000}")
|
|
||||||
private String publicBaseUrl;
|
|
||||||
|
|
||||||
public void notifyOrderProcessing(Order order) {
|
|
||||||
String email = resolveCustomerEmail(order);
|
|
||||||
if (email.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
emailService.sendOrderProcessingEmail(
|
|
||||||
email,
|
|
||||||
order.getPlate(),
|
|
||||||
ordersPageUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void notifyOrderSent(Order order, String trackingId) {
|
|
||||||
String email = resolveCustomerEmail(order);
|
|
||||||
if (email.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String trackingUrl = "https://www.postnord.se/verktyg/spara/?id=" + trackingId;
|
|
||||||
emailService.sendOrderSentEmail(email, order.getPlate(), trackingId, trackingUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void notifyOrderFailed(Order order) {
|
|
||||||
String email = resolveCustomerEmail(order);
|
|
||||||
if (email.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
emailService.sendOrderFailedEmail(email, order.getPlate(), ordersPageUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveCustomerEmail(Order order) {
|
|
||||||
if (order.getUser() != null && order.getUser().getEmail() != null) {
|
|
||||||
return order.getUser().getEmail();
|
|
||||||
}
|
|
||||||
UUID userId = order.getUserId();
|
|
||||||
if (userId == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return userRepository.findById(userId)
|
|
||||||
.map(User::getEmail)
|
|
||||||
.orElse("");
|
|
||||||
}
|
|
||||||
|
|
||||||
private String ordersPageUrl() {
|
|
||||||
String base = publicBaseUrl.endsWith("/")
|
|
||||||
? publicBaseUrl.substring(0, publicBaseUrl.length() - 1)
|
|
||||||
: publicBaseUrl;
|
|
||||||
return base + "/mina-bestallningar";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
package se.bilhalsning.service;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import se.bilhalsning.entity.Order;
|
|
||||||
import se.bilhalsning.entity.OrderStatus;
|
|
||||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
|
||||||
import se.bilhalsning.exception.OrderNotFoundException;
|
|
||||||
import se.bilhalsning.repository.OrderRepository;
|
|
||||||
|
|
||||||
import 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
package se.bilhalsning.util;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URLDecoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
public final class PostNordTrackingNormalizer {
|
|
||||||
|
|
||||||
private PostNordTrackingNormalizer() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String normalize(String raw) {
|
|
||||||
if (raw == null || raw.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("Spårnings-ID krävs");
|
|
||||||
}
|
|
||||||
|
|
||||||
String trimmed = raw.trim();
|
|
||||||
if (trimmed.toLowerCase().contains("postnord")) {
|
|
||||||
String fromUrl = extractIdFromPostNordUrl(trimmed);
|
|
||||||
if (fromUrl != null && !fromUrl.isBlank()) {
|
|
||||||
trimmed = fromUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed.replaceAll("\\s+", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String extractIdFromPostNordUrl(String url) {
|
|
||||||
try {
|
|
||||||
URI uri = URI.create(url);
|
|
||||||
String query = uri.getQuery();
|
|
||||||
if (query == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
for (String param : query.split("&")) {
|
|
||||||
if (param.startsWith("id=")) {
|
|
||||||
return URLDecoder.decode(param.substring(3), StandardCharsets.UTF_8).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IllegalArgumentException ignored) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
-- 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'
|
|
||||||
);
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
-- 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'
|
|
||||||
);
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- 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');
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
-- Dev/CI: order in "processing" for admin fulfillment testing
|
|
||||||
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
|
|
||||||
VALUES (
|
|
||||||
'c4eebc99-9c0b-4ef8-bb6d-6bb9bd380a14',
|
|
||||||
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
|
||||||
'JKL012',
|
|
||||||
'Hej! Bara en påminnelse om serviceboken.',
|
|
||||||
'processing',
|
|
||||||
49.00,
|
|
||||||
NULL,
|
|
||||||
TIMESTAMP '2026-05-16 09:00:00',
|
|
||||||
TIMESTAMP '2026-05-16 09:00:00'
|
|
||||||
);
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
CREATE TABLE email_change_tokens (
|
|
||||||
id UUID NOT NULL,
|
|
||||||
user_id UUID NOT NULL,
|
|
||||||
new_email VARCHAR(255) NOT NULL,
|
|
||||||
token_hash VARCHAR(64) NOT NULL,
|
|
||||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
||||||
used_at TIMESTAMP WITH TIME ZONE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT pk_email_change_tokens PRIMARY KEY (id),
|
|
||||||
CONSTRAINT fk_email_change_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_email_change_tokens_user_id ON email_change_tokens (user_id);
|
|
||||||
CREATE INDEX idx_email_change_tokens_token_hash ON email_change_tokens (token_hash);
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMP WITH TIME ZONE;
|
|
||||||
|
|
||||||
ALTER TABLE orders ADD COLUMN admin_notes TEXT;
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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'))
|
|
||||||
);
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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);
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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);
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
ALTER TABLE orders DROP CONSTRAINT ck_orders_status;
|
|
||||||
|
|
||||||
ALTER TABLE orders
|
|
||||||
ADD CONSTRAINT ck_orders_status CHECK (
|
|
||||||
status IN (
|
|
||||||
'pending_payment',
|
|
||||||
'paid',
|
|
||||||
'processing',
|
|
||||||
'sent',
|
|
||||||
'delivered',
|
|
||||||
'failed',
|
|
||||||
'cancelled'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
package se.bilhalsning;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
|
|
||||||
@SpringBootTest
|
|
||||||
class BilHejApplicationTests {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void contextLoads() {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
package se.bilhalsning;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
class FlywayMigrationFilesTest {
|
|
||||||
|
|
||||||
private static final Pattern VERSION_PATTERN = Pattern.compile("^V(\\d+)__.+\\.sql$");
|
|
||||||
private static final Path MIGRATION_DIR = Path.of("src/main/resources/db/migration");
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldUseUniqueVersionNumbersAndValidNames() throws IOException {
|
|
||||||
assertTrue(
|
|
||||||
Files.isDirectory(MIGRATION_DIR),
|
|
||||||
() -> "Expected migration directory at " + MIGRATION_DIR.toAbsolutePath());
|
|
||||||
|
|
||||||
Set<Integer> versions = new HashSet<>();
|
|
||||||
|
|
||||||
try (Stream<Path> files = Files.list(MIGRATION_DIR)) {
|
|
||||||
for (Path file : files.filter(path -> path.getFileName().toString().endsWith(".sql")).toList()) {
|
|
||||||
String name = file.getFileName().toString();
|
|
||||||
Matcher matcher = VERSION_PATTERN.matcher(name);
|
|
||||||
assertTrue(matcher.matches(), () -> "Invalid migration filename: " + name);
|
|
||||||
|
|
||||||
int version = Integer.parseInt(matcher.group(1));
|
|
||||||
assertFalse(
|
|
||||||
versions.contains(version),
|
|
||||||
() -> "Duplicate Flyway version V" + version + " in " + MIGRATION_DIR);
|
|
||||||
versions.add(version);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assertFalse(versions.isEmpty(), "Expected at least one schema migration");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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[] {})));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
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
Loading…
Reference in a new issue