Compare commits
52 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a9d2fe688 | |||
|
|
d9aa2d60af | ||
|
|
00d1f48218 | ||
| 56cbc6fedf | |||
| f60af7237e | |||
| 9a63ff69e7 | |||
| d7739bcd58 | |||
|
|
4b35d8ff30 | ||
| c88fa142d3 | |||
| 81e3968e31 | |||
| 5335ba4f12 | |||
|
|
3d2db1471f | ||
|
|
da54a67d9d | ||
| aa2cb7c4a0 | |||
| 737bc3dc64 | |||
| fa7e48fe02 | |||
| c7eeaf6a6b | |||
| 0b2c58fa82 | |||
| aec7020621 | |||
| 623433ba4d | |||
| c578463b10 | |||
| afa552e18b | |||
| 2fa161f4fa | |||
| 5b5b44194d | |||
| 1c9269699e | |||
| 17fe67ae3f | |||
| 144791b7e6 | |||
| cf938501c5 | |||
| 4d3beeffb4 | |||
| 7a95c1423c | |||
| 71a3225a11 | |||
| b2aaeb5733 | |||
| 3532e4d486 | |||
| 3f20656f04 | |||
| a12e07ec1c | |||
| ec62ba7673 | |||
| 258f6f5a17 | |||
| bce2447238 | |||
| c0c32b718b | |||
| 255095e6bd | |||
| c0902d0494 | |||
| 081a1f90d3 | |||
| 162002dfb1 | |||
| 60cb07fc89 | |||
| 758ace1b92 | |||
| 139250b2ac | |||
| aa2b4b4a16 | |||
| 15d7b4ae4c | |||
| 41cfea09a6 | |||
| ca5ce12812 | |||
| 3d0b7fe799 | |||
| 082139d266 |
140 changed files with 9584 additions and 1192 deletions
|
|
@ -1,6 +1,66 @@
|
|||
# Exclude everything that isn't strictly needed to build or run the dev images.
|
||||
# The dev Dockerfiles COPY source subpaths (frontend/, backend/, gradlew,
|
||||
# settings.gradle, etc.), so without this the image would bloat with docs,
|
||||
# scripts, git history, etc.
|
||||
|
||||
# Build artifacts and caches (mounted as named volumes at runtime)
|
||||
.gradle
|
||||
.env
|
||||
.git
|
||||
frontend/node_modules
|
||||
backend/build
|
||||
frontend/dist
|
||||
frontend/coverage
|
||||
frontend/node_modules
|
||||
backend/.gradle
|
||||
|
||||
# Test outputs
|
||||
**/build/test-results
|
||||
**/build/reports
|
||||
**/coverage
|
||||
**/.pytest_cache
|
||||
frontend/playwright-report
|
||||
frontend/test-results
|
||||
|
||||
# Local config and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/application-local.yml
|
||||
|
||||
# VCS and editor state
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.github
|
||||
.forgejo
|
||||
.idea
|
||||
.vscode
|
||||
*.iml
|
||||
.DS_Store
|
||||
|
||||
# Documentation (not needed at runtime)
|
||||
README.md
|
||||
REQUIREMENTS.md
|
||||
AGENTS.md
|
||||
CODING_GUIDELINES.md
|
||||
docs/
|
||||
|
||||
# Ops scripts (not needed at runtime)
|
||||
scripts/
|
||||
|
||||
# Test source dirs that aren't built into runtime artifacts
|
||||
frontend/src/__tests__
|
||||
backend/src/test
|
||||
|
||||
# Docker metadata — Dockerfiles, .dockerignore, and compose files are not
|
||||
# needed inside the running image. Keep docker/*.conf and docker/entrypoint.sh
|
||||
# because frontend.prod.Dockerfile copies them into the production nginx image.
|
||||
docker/*.Dockerfile
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
logs/
|
||||
tmp/
|
||||
*.bak
|
||||
*.tmp
|
||||
13
.env.example
13
.env.example
|
|
@ -24,6 +24,12 @@ STRIPE_WEBHOOK_SECRET=whsec_...
|
|||
STRIPE_PRICE_ID=price_...
|
||||
|
||||
# ---------- Swish (Phase 0) ----------
|
||||
# The Swish number customers pay to. Two formats accepted:
|
||||
# - Swedish phone number: 0701234567 (normalised to 46… for the payment URL)
|
||||
# - Swish Business number: 1234567890 (starts with 123, used as-is)
|
||||
# A Swish Business number (123…) is recommended — get one from your bank
|
||||
# via a "Swish Företag" agreement. No Swish Commerce API certificate needed;
|
||||
# the frontend generates a pre-filled QR code + payment link automatically.
|
||||
SWISH_NUMBER=0701234567
|
||||
|
||||
# ---------- App URL (password reset links in email) ----------
|
||||
|
|
@ -43,3 +49,10 @@ APP_PUBLIC_BASE_URL=http://localhost:3000
|
|||
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
|
||||
ADMIN_EMAIL=admin@bilhej.se
|
||||
ADMIN_PASSWORD=change_me_to_a_strong_password
|
||||
|
||||
# ---------- Umami analytics (production frontend build only) ----------
|
||||
# Baked into the frontend image at build time. Leave unset for local dev / docker compose up.
|
||||
# Website ID from https://analytics.bilhej.se → Settings → Websites → BilHej
|
||||
# See docs/umami-analytics.md
|
||||
# VITE_UMAMI_WEBSITE_ID=
|
||||
# VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ jobs:
|
|||
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/jocke/bilhej.git
|
||||
git fetch --depth 1 origin ${GITHUB_SHA}
|
||||
git checkout FETCH_HEAD
|
||||
git fetch --depth 1 origin master
|
||||
|
||||
- name: Check Flyway migrations
|
||||
run: bash scripts/check-flyway-migrations.sh origin/master
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., v0.1.0)'
|
||||
required: true
|
||||
default: 'v0.1.0'
|
||||
description: 'Leave as "auto" to bump from latest git tag, or enter a specific version (e.g. v0.1.2)'
|
||||
required: false
|
||||
default: 'auto'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
|
|
@ -20,12 +21,36 @@ jobs:
|
|||
git fetch --depth 1 origin ${GITHUB_SHA}
|
||||
git checkout FETCH_HEAD
|
||||
|
||||
- name: Resolve version
|
||||
run: |
|
||||
INPUT_VERSION="${{ github.event.inputs.version }}"
|
||||
if [ -z "$INPUT_VERSION" ] || [ "$INPUT_VERSION" = "auto" ]; then
|
||||
git fetch --tags origin
|
||||
LATEST=$(git tag --list 'v*' --sort=-v:refname | head -1)
|
||||
if [ -z "$LATEST" ]; then LATEST="v0.0.0"; fi
|
||||
BASE="${LATEST#v}"
|
||||
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||
PATCH=$(echo "$BASE" | cut -d. -f3)
|
||||
PATCH=$(( ${PATCH:-0} + 1 ))
|
||||
VERSION="v${MAJOR:-0}.${MINOR:-0}.${PATCH}"
|
||||
echo "Latest tag: $LATEST → auto-bumped to $VERSION"
|
||||
else
|
||||
VERSION="$INPUT_VERSION"
|
||||
echo "Using manual version: $VERSION"
|
||||
fi
|
||||
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "ERROR: resolved version '$VERSION' is not valid semver (expected vX.Y.Z)"
|
||||
exit 1
|
||||
fi
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Tag version
|
||||
run: |
|
||||
git tag -d ${{ github.event.inputs.version }} 2>/dev/null || true
|
||||
git push origin --delete ${{ github.event.inputs.version }} 2>/dev/null || true
|
||||
git tag ${{ github.event.inputs.version }}
|
||||
git push origin ${{ github.event.inputs.version }}
|
||||
git tag -d ${{ env.VERSION }} 2>/dev/null || true
|
||||
git push origin --delete ${{ env.VERSION }} 2>/dev/null || true
|
||||
git tag ${{ env.VERSION }}
|
||||
git push origin ${{ env.VERSION }}
|
||||
|
||||
- name: Write production .env
|
||||
env:
|
||||
|
|
@ -45,6 +70,7 @@ jobs:
|
|||
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
|
||||
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
|
||||
MAIL_FROM: ${{ secrets.MAIL_FROM }}
|
||||
VITE_UMAMI_WEBSITE_ID: ${{ secrets.VITE_UMAMI_WEBSITE_ID }}
|
||||
run: |
|
||||
# Docker Compose treats $ as variable interpolation in .env files.
|
||||
# Escape literal dollar signs (e.g. in passwords) as $$.
|
||||
|
|
@ -66,6 +92,7 @@ jobs:
|
|||
printf 'MAIL_USERNAME=%s\n' "$(escape "$MAIL_USERNAME")"
|
||||
printf 'MAIL_PASSWORD=%s\n' "$(escape "$MAIL_PASSWORD")"
|
||||
printf 'MAIL_FROM=%s\n' "$(escape "${MAIL_FROM:-noreply@bilhej.se}")"
|
||||
printf 'VITE_UMAMI_WEBSITE_ID=%s\n' "$(escape "$VITE_UMAMI_WEBSITE_ID")"
|
||||
} > .env
|
||||
|
||||
- name: Build and start production stack
|
||||
|
|
@ -131,7 +158,7 @@ jobs:
|
|||
run: |
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════"
|
||||
echo " Deployed ${{ github.event.inputs.version }} to production"
|
||||
echo " Deployed ${{ env.VERSION }} to production"
|
||||
echo "═══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
docker compose -p bilhej-prod -f docker-compose.prod.yml ps
|
||||
|
|
|
|||
71
AGENTS.md
71
AGENTS.md
|
|
@ -74,6 +74,14 @@ stripe listen --forward-to localhost:8080/api/webhooks/stripe
|
|||
Flyway migrations run automatically on Spring Boot startup. Migration files
|
||||
live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descriptive_name.sql`.
|
||||
|
||||
**Before adding a migration:** run `./scripts/next-flyway-version.sh` and use that
|
||||
version. Never reuse a version number already on `master`. Never edit a migration
|
||||
after it has merged — add a new higher version instead. CI runs
|
||||
`scripts/check-flyway-migrations.sh` against `origin/master`.
|
||||
|
||||
If local dev Postgres fails with Flyway checksum / “migration not resolved locally”
|
||||
after switching branches, run `./gradlew reset` (wipes the Docker DB volume).
|
||||
|
||||
To reset: `docker compose down -v && docker compose up -d`.
|
||||
|
||||
Flyway schema migrations live in `db/migration/`; dev-only seeds (test users,
|
||||
|
|
@ -152,6 +160,26 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
|
|||
list concrete changes as bullet points. Never write single-line
|
||||
"feat: add X" messages.
|
||||
|
||||
**Before every commit (mandatory — agents must not skip):**
|
||||
|
||||
```bash
|
||||
# from repo root; needs Docker running
|
||||
export POSTGRES_DB=bilhej POSTGRES_USER=bilhej POSTGRES_PASSWORD=test_pw_ci_123
|
||||
export JWT_SECRET=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PRICE_ID=price_fake
|
||||
./gradlew check
|
||||
```
|
||||
|
||||
This runs frontend lint, frontend unit tests, backend tests, coverage
|
||||
thresholds, Flyway checks, and **all E2E tests in Docker**. **Do not commit or
|
||||
push if this fails.** Optional local guard: `./scripts/install-pre-commit-hook.sh`
|
||||
(runs the same `check` on every `git commit`).
|
||||
|
||||
**Note for agents:** The pre-commit hook runs the full `./gradlew check` which
|
||||
takes ~3.5 minutes. If your tool enforces a default timeout (e.g. 120 s on
|
||||
agent tool calls), increase it to ≥300 000 ms, or use `--no-verify` and run
|
||||
`./gradlew check` manually before committing.
|
||||
|
||||
### Frontend (Vue.js 3)
|
||||
- `<script setup>` with Composition API only. Never Options API.
|
||||
- File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables.
|
||||
|
|
@ -187,6 +215,9 @@ After the address is used to mail the letter, it must be deleted. The Order
|
|||
entity must NOT have an address field. The address lookup and mailing are
|
||||
external/human processes in Phase 0.
|
||||
|
||||
### E2E must use Docker (not host Playwright)
|
||||
See **Testing Approach → E2E (Playwright) — Docker only** above. Do not run `npx playwright install` or `npm run test:e2e` on the host when verifying E2E.
|
||||
|
||||
### Local email (Mailpit)
|
||||
`docker compose up` includes Mailpit (`ghcr.io/axllent/mailpit:v1.28`); password-reset mail appears at http://localhost:8025. E2E verifies SMTP via Mailpit API (`frontend/e2e/helpers/mailpit.ts`). Production uses Resend SMTP—see docs/production-email-checklist.md.
|
||||
|
||||
|
|
@ -228,12 +259,40 @@ the same PR — never merge code without corresponding tests.
|
|||
- Component tests with Vue Test Utils where needed.
|
||||
- E2E tests with Playwright in `frontend/e2e/`.
|
||||
|
||||
### E2E (Playwright)
|
||||
- `npm run test:e2e` — runs all Playwright tests (headless Chromium).
|
||||
- Requires `docker compose up` (backend + frontend running).
|
||||
- Config: `frontend/playwright.config.ts`.
|
||||
- Tests: `frontend/e2e/*.spec.ts`.
|
||||
- Docker CI: `npm run test:e2e:ci` — runs tests inside official Playwright Docker container. Starts postgres, backend, frontend, and Playwright via `docker-compose.ci.yml`. Use for consistent environment or CI pipelines.
|
||||
### E2E (Playwright) — **Docker only**
|
||||
|
||||
**Agents and humans: never run Playwright on the host.**
|
||||
|
||||
| Do **not** run | Why |
|
||||
|----------------|-----|
|
||||
| `npx playwright test` | Wrong environment; needs Docker stack |
|
||||
| `npm run test:e2e` | Same — host Playwright, not supported for agents |
|
||||
| `npx playwright install` | Do not install browsers on the host; the Playwright image already includes them |
|
||||
|
||||
**Always use Docker** (`docker-compose.e2e.yml`): isolated postgres (tmpfs), backend, frontend, Mailpit, and the official Playwright container on the `e2e` network (`PLAYWRIGHT_BASE_URL=http://frontend`).
|
||||
|
||||
**Full E2E suite** (same as Forgejo CI / `./gradlew check`):
|
||||
|
||||
```bash
|
||||
# from repo root — set env (or use .env; see .env.example)
|
||||
cd frontend && npm run test:e2e:ci
|
||||
# equivalent:
|
||||
./gradlew frontendE2E
|
||||
```
|
||||
|
||||
**Single spec or project** (stack must be reachable on the `e2e` network):
|
||||
|
||||
```bash
|
||||
# from repo root, after exporting the same vars as frontendE2E / .env
|
||||
docker compose -f docker-compose.e2e.yml up -d --build postgres mailpit backend frontend
|
||||
docker compose -f docker-compose.e2e.yml run --rm --build playwright \
|
||||
sh -c 'npx playwright test admin-fulfillment.spec.ts --project=chromium-serial --reporter=list'
|
||||
docker compose -f docker-compose.e2e.yml down
|
||||
```
|
||||
|
||||
- Config: `frontend/playwright.config.ts`
|
||||
- Tests: `frontend/e2e/*.spec.ts`
|
||||
- Serial specs (shared Mailpit / seeded DB): `admin-fulfillment`, `deferred-payment-admin`, `admin-dashboard`, `account-settings`, `password-reset` — project `chromium-serial` runs **after** parallel `chromium`, `workers: 1`
|
||||
|
||||
### CI (future)
|
||||
- `./gradlew check` and `npm run test && npm run lint` must pass before merge.
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
|
|||
| `MAIL_USERNAME` | `resend` (literal string) |
|
||||
| `MAIL_PASSWORD` | Resend API key (`re_...`; rotate if ever exposed) |
|
||||
| `MAIL_FROM` | `noreply@bilhej.se` (must be on verified domain) |
|
||||
| `VITE_UMAMI_WEBSITE_ID` | Umami website UUID for `bilhej.se` (see `docs/umami-analytics.md`) |
|
||||
|
||||
Passwords may contain `$` — the deploy workflow escapes these for Docker Compose.
|
||||
Production does **not** seed `test@bilhej.se` or demo orders. On first start, the
|
||||
|
|
@ -386,7 +387,9 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
|
|||
|
||||
1. Go to **Actions → Deploy to Production** in Forgejo.
|
||||
2. Click **Run workflow**.
|
||||
3. Enter a version tag (e.g., `v0.1.0`).
|
||||
3. Fill in both fields (Forgejo requires `type` on inputs — see `deploy.yml`):
|
||||
- **Use workflow from:** `master` (which commit to build). Do not confuse this with the deploy tag below.
|
||||
- **Version tag:** label created by the pipeline (e.g. `v0.1.2`). Change this each release; default `v0.1.0` is only a placeholder.
|
||||
4. Click **Run workflow**.
|
||||
|
||||
### Deploy failed (backend health check)
|
||||
|
|
|
|||
|
|
@ -446,7 +446,7 @@ Gross margin: 14 SEK
|
|||
| Is a license plate personal data? | Yes (it directly identifies a vehicle owner). |
|
||||
| Is an address personal data? | Yes. |
|
||||
| What if we only process address transiently? | Data minimization is a GDPR principle (Art. 5(1)(c)). Transient processing with immediate deletion is a strong compliance posture. |
|
||||
| Do we need to inform the recipient? | Yes, GDPR Art. 14 requires informing the data subject. The letter itself can serve this purpose — include a footer like: _"Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se"_ |
|
||||
| Do we need to inform the recipient? | Yes, GDPR Art. 14 requires informing the data subject. The letter itself can serve this purpose — include a footer like: _"Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: kontakt@bilhej.se"_ |
|
||||
|
||||
### 11.2 Transportstyrelsen Access
|
||||
|
||||
|
|
|
|||
|
|
@ -80,8 +80,15 @@ jacocoTestCoverageVerification {
|
|||
}
|
||||
}
|
||||
|
||||
tasks.register('flywayMigrationCheck', Exec) {
|
||||
group = 'verification'
|
||||
description = 'Ensure Flyway migrations are unique, immutable, and use new version numbers'
|
||||
workingDir = rootProject.projectDir
|
||||
commandLine 'bash', 'scripts/check-flyway-migrations.sh'
|
||||
}
|
||||
|
||||
tasks.named('check').configure {
|
||||
dependsOn jacocoTestCoverageVerification
|
||||
dependsOn jacocoTestCoverageVerification, flywayMigrationCheck
|
||||
}
|
||||
|
||||
tasks.register('hashPassword', JavaExec) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
package se.bilhalsning.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
|
|
@ -10,6 +14,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import se.bilhalsning.dto.ErrorResponse;
|
||||
import se.bilhalsning.security.JwtAuthenticationFilter;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
|
||||
|
|
@ -17,6 +22,13 @@ import se.bilhalsning.security.JwtService;
|
|||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
static final String UNAUTHENTICATED_MESSAGE =
|
||||
"Din session har löpt ut eller är ogiltig. Logga in igen.";
|
||||
static final String FORBIDDEN_MESSAGE =
|
||||
"Du har inte behörighet att utföra denna åtgärd.";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
|
|
@ -38,15 +50,29 @@ public class SecurityConfig {
|
|||
"/api/auth/register",
|
||||
"/api/auth/login",
|
||||
"/api/auth/forgot-password",
|
||||
"/api/auth/reset-password")
|
||||
"/api/auth/reset-password",
|
||||
"/api/auth/confirm-email-change")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/webhooks/**").permitAll()
|
||||
.requestMatchers("/api/payment/swish-info").permitAll()
|
||||
.requestMatchers("/api/vehicles/**").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(eh -> eh
|
||||
.authenticationEntryPoint((request, response, ex) ->
|
||||
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
|
||||
.accessDeniedHandler((request, response, ex) ->
|
||||
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private void writeError(HttpServletResponse response, HttpStatus status, String message)
|
||||
throws java.io.IOException {
|
||||
response.setStatus(status.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(message)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,13 @@ import org.springframework.web.bind.annotation.PathVariable;
|
|||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.bilhalsning.dto.AdminOrderMapper;
|
||||
import se.bilhalsning.dto.AdminOrderResponse;
|
||||
import se.bilhalsning.dto.RegisterShipmentRequest;
|
||||
import se.bilhalsning.dto.UpdateAdminNotesRequest;
|
||||
import se.bilhalsning.dto.UpdateStatusRequest;
|
||||
import se.bilhalsning.dto.UpdateTrackingRequest;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.service.AdminOrderWorkflowService;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
|
||||
import java.util.List;
|
||||
|
|
@ -24,11 +27,12 @@ import java.util.UUID;
|
|||
public class AdminController {
|
||||
|
||||
private final OrderService orderService;
|
||||
private final AdminOrderWorkflowService adminOrderWorkflowService;
|
||||
|
||||
@GetMapping("/orders")
|
||||
public ResponseEntity<List<AdminOrderResponse>> listAllOrders() {
|
||||
List<AdminOrderResponse> orders = orderService.getAllOrders().stream()
|
||||
.map(this::toAdminResponse)
|
||||
.map(AdminOrderMapper::toResponse)
|
||||
.toList();
|
||||
return ResponseEntity.ok(orders);
|
||||
}
|
||||
|
|
@ -37,29 +41,26 @@ public class AdminController {
|
|||
public ResponseEntity<AdminOrderResponse> updateStatus(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateStatusRequest request) {
|
||||
Order order = orderService.updateOrderStatus(id, request.status());
|
||||
return ResponseEntity.ok(toAdminResponse(order));
|
||||
Order order = adminOrderWorkflowService.updateOrderStatus(id, request.status());
|
||||
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
|
||||
}
|
||||
|
||||
@PatchMapping("/orders/{id}")
|
||||
public ResponseEntity<AdminOrderResponse> updateTracking(
|
||||
@PatchMapping("/orders/{id}/register-shipment")
|
||||
public ResponseEntity<AdminOrderResponse> registerShipment(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateTrackingRequest request) {
|
||||
Order order = orderService.updateTracking(id, request.trackingId());
|
||||
return ResponseEntity.ok(toAdminResponse(order));
|
||||
@Valid @RequestBody RegisterShipmentRequest request) {
|
||||
Order order = adminOrderWorkflowService.registerShipment(
|
||||
id,
|
||||
request.trackingInput(),
|
||||
request.notifyCustomerOrDefault());
|
||||
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
|
||||
}
|
||||
|
||||
private AdminOrderResponse toAdminResponse(Order order) {
|
||||
String email = order.getUser() != null ? order.getUser().getEmail() : "";
|
||||
return new AdminOrderResponse(
|
||||
order.getId(),
|
||||
email,
|
||||
order.getPlate(),
|
||||
order.getLetterText(),
|
||||
order.getStatus().getValue(),
|
||||
order.getTrackingId(),
|
||||
order.getAmountPaid(),
|
||||
order.getCreatedAt()
|
||||
);
|
||||
@PatchMapping("/orders/{id}/notes")
|
||||
public ResponseEntity<AdminOrderResponse> updateNotes(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody UpdateAdminNotesRequest request) {
|
||||
Order order = adminOrderWorkflowService.updateAdminNotes(id, request.adminNotes());
|
||||
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
|
@ -11,7 +12,10 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.bilhalsning.dto.AuthResponse;
|
||||
import se.bilhalsning.dto.ChangeEmailRequest;
|
||||
import se.bilhalsning.dto.ChangeEmailResponse;
|
||||
import se.bilhalsning.dto.ChangePasswordRequest;
|
||||
import se.bilhalsning.dto.ConfirmEmailChangeRequest;
|
||||
import se.bilhalsning.dto.ForgotPasswordRequest;
|
||||
import se.bilhalsning.dto.LoginRequest;
|
||||
import se.bilhalsning.dto.ForgotPasswordResponse;
|
||||
|
|
@ -20,6 +24,7 @@ import se.bilhalsning.dto.RegisterRequest;
|
|||
import se.bilhalsning.dto.ResetPasswordRequest;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
import se.bilhalsning.service.EmailChangeService;
|
||||
import se.bilhalsning.service.PasswordResetService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
|
|
@ -30,11 +35,15 @@ public class AuthController {
|
|||
|
||||
private final UserService userService;
|
||||
private final PasswordResetService passwordResetService;
|
||||
private final EmailChangeService emailChangeService;
|
||||
private final JwtService jwtService;
|
||||
|
||||
private static final String FORGOT_PASSWORD_MESSAGE =
|
||||
"Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet.";
|
||||
|
||||
private static final String CHANGE_EMAIL_MESSAGE =
|
||||
"Vi har skickat en bekräftelselänk till din nya e-postadress.";
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
userService.createUser(request.email(), request.password());
|
||||
|
|
@ -71,4 +80,21 @@ public class AuthController {
|
|||
principal.getUsername(), request.currentPassword(), request.newPassword());
|
||||
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats."));
|
||||
}
|
||||
|
||||
@PostMapping("/change-email")
|
||||
public ResponseEntity<ChangeEmailResponse> changeEmail(
|
||||
@Valid @RequestBody ChangeEmailRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
Optional<String> testToken = emailChangeService.requestChange(
|
||||
principal.getUsername(), request.password(), request.newEmail());
|
||||
return ResponseEntity.ok(ChangeEmailResponse.of(CHANGE_EMAIL_MESSAGE, testToken));
|
||||
}
|
||||
|
||||
@PostMapping("/confirm-email-change")
|
||||
public ResponseEntity<AuthResponse> confirmEmailChange(
|
||||
@Valid @RequestBody ConfirmEmailChangeRequest request) {
|
||||
User user = emailChangeService.confirmChange(request.token(), request.password());
|
||||
String token = jwtService.generateToken(user.getEmail(), user.getRole());
|
||||
return ResponseEntity.ok(new AuthResponse(token));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ import org.springframework.http.ResponseEntity;
|
|||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.bilhalsning.dto.CreateOrderRequest;
|
||||
import se.bilhalsning.dto.OrderResponse;
|
||||
import se.bilhalsning.dto.UpdateOrderRequest;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
|
|
@ -20,6 +23,7 @@ import se.bilhalsning.service.OrderService;
|
|||
import se.bilhalsning.service.UserService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/orders")
|
||||
|
|
@ -41,6 +45,21 @@ public class OrderController {
|
|||
return ResponseEntity.ok(orders);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<OrderResponse> get(@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
User user = userService.findByEmail(userDetails.getUsername())
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
Order order = orderService.getOrderById(id);
|
||||
|
||||
if (!order.getUserId().equals(user.getId())) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(toResponse(order));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<OrderResponse> create(
|
||||
@Valid @RequestBody CreateOrderRequest request,
|
||||
|
|
@ -57,6 +76,31 @@ public class OrderController {
|
|||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
public ResponseEntity<OrderResponse> update(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateOrderRequest request,
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
User user = userService.findByEmail(userDetails.getUsername())
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
Order order = orderService.updatePendingOrder(id, user.getId(), request.letterText());
|
||||
|
||||
return ResponseEntity.ok(toResponse(order));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/cancel")
|
||||
public ResponseEntity<OrderResponse> cancel(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
User user = userService.findByEmail(userDetails.getUsername())
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
Order order = orderService.cancelOrder(id, user.getId());
|
||||
|
||||
return ResponseEntity.ok(toResponse(order));
|
||||
}
|
||||
|
||||
private OrderResponse toResponse(Order order) {
|
||||
return new OrderResponse(
|
||||
order.getId(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import java.util.UUID;
|
|||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
|
@ -12,28 +14,38 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.bilhalsning.dto.OrderResponse;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/payment")
|
||||
public class PaymentController {
|
||||
|
||||
private final OrderService orderService;
|
||||
private final UserService userService;
|
||||
private final String swishNumber;
|
||||
private final int letterPrice;
|
||||
|
||||
public PaymentController(
|
||||
OrderService orderService,
|
||||
UserService userService,
|
||||
@Value("${app.payment.swish-number}") String swishNumber,
|
||||
@Value("${app.payment.letter-price}") int letterPrice) {
|
||||
this.orderService = orderService;
|
||||
this.userService = userService;
|
||||
this.swishNumber = swishNumber;
|
||||
this.letterPrice = letterPrice;
|
||||
}
|
||||
|
||||
@PostMapping("/{orderId}/pay")
|
||||
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) {
|
||||
Order order = orderService.confirmPayment(orderId);
|
||||
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId,
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
User user = userService.findByEmail(userDetails.getUsername())
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
Order order = orderService.confirmPayment(orderId, user.getId());
|
||||
return ResponseEntity.ok(toResponse(order));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.service.AdminOrderStatusRules;
|
||||
|
||||
public final class AdminOrderMapper {
|
||||
|
||||
private AdminOrderMapper() {}
|
||||
|
||||
public static AdminOrderResponse toResponse(Order order) {
|
||||
String email = order.getUser() != null ? order.getUser().getEmail() : "";
|
||||
return new AdminOrderResponse(
|
||||
order.getId(),
|
||||
email,
|
||||
order.getPlate(),
|
||||
order.getLetterText(),
|
||||
order.getStatus().getValue(),
|
||||
order.getTrackingId(),
|
||||
order.getAmountPaid(),
|
||||
order.getShippedAt(),
|
||||
order.getAdminNotes(),
|
||||
order.getCreatedAt(),
|
||||
AdminOrderStatusRules.allowedStatusValues(order),
|
||||
AdminOrderStatusRules.canRegisterShipment(order));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package se.bilhalsning.dto;
|
|||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record AdminOrderResponse(
|
||||
|
|
@ -12,5 +13,9 @@ public record AdminOrderResponse(
|
|||
String status,
|
||||
String trackingId,
|
||||
BigDecimal amountPaid,
|
||||
Instant createdAt
|
||||
Instant shippedAt,
|
||||
String adminNotes,
|
||||
Instant createdAt,
|
||||
List<String> allowedStatuses,
|
||||
boolean canRegisterShipment
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ChangeEmailRequest(
|
||||
@NotBlank @Email String newEmail, @NotBlank String password) {}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.util.Optional;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record ChangeEmailResponse(String message, String testToken) {
|
||||
|
||||
public static ChangeEmailResponse of(String message, Optional<String> testToken) {
|
||||
return new ChangeEmailResponse(message, testToken.orElse(null));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ConfirmEmailChangeRequest(@NotBlank String token, @NotBlank String password) {}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record RegisterShipmentRequest(
|
||||
@NotBlank(message = "Spårnings-ID krävs")
|
||||
String trackingInput,
|
||||
Boolean notifyCustomer
|
||||
) {
|
||||
public boolean notifyCustomerOrDefault() {
|
||||
return notifyCustomer == null || notifyCustomer;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record UpdateAdminNotesRequest(
|
||||
String adminNotes
|
||||
) {}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record UpdateOrderRequest(
|
||||
@NotBlank(message = "Brevtext krävs")
|
||||
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
|
||||
String letterText
|
||||
) {}
|
||||
|
|
@ -6,7 +6,7 @@ import jakarta.validation.constraints.Pattern;
|
|||
public record UpdateStatusRequest(
|
||||
@NotBlank(message = "Status krävs")
|
||||
@Pattern(
|
||||
regexp = "pending_payment|paid|processing|sent|delivered|failed",
|
||||
regexp = "pending_payment|paid|processing|sent|delivered|failed|cancelled",
|
||||
message = "Ogiltig status"
|
||||
)
|
||||
String status
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record UpdateTrackingRequest(
|
||||
String trackingId
|
||||
) {}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package se.bilhalsning.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "email_change_tokens")
|
||||
public class EmailChangeToken {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "new_email", nullable = false)
|
||||
private String newEmail;
|
||||
|
||||
@Column(name = "token_hash", nullable = false, length = 64)
|
||||
private String tokenHash;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(name = "used_at")
|
||||
private Instant usedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (this.id == null) {
|
||||
this.id = UUID.randomUUID();
|
||||
}
|
||||
if (this.createdAt == null) {
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getNewEmail() {
|
||||
return newEmail;
|
||||
}
|
||||
|
||||
public void setNewEmail(String newEmail) {
|
||||
this.newEmail = newEmail;
|
||||
}
|
||||
|
||||
public String getTokenHash() {
|
||||
return tokenHash;
|
||||
}
|
||||
|
||||
public void setTokenHash(String tokenHash) {
|
||||
this.tokenHash = tokenHash;
|
||||
}
|
||||
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(Instant expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public Instant getUsedAt() {
|
||||
return usedAt;
|
||||
}
|
||||
|
||||
public void setUsedAt(Instant usedAt) {
|
||||
this.usedAt = usedAt;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,12 @@ public class Order {
|
|||
@Column(name = "tracking_id", length = 100)
|
||||
private String trackingId;
|
||||
|
||||
@Column(name = "shipped_at")
|
||||
private Instant shippedAt;
|
||||
|
||||
@Column(name = "admin_notes", columnDefinition = "text")
|
||||
private String adminNotes;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
|
|
@ -130,6 +136,22 @@ public class Order {
|
|||
this.trackingId = trackingId;
|
||||
}
|
||||
|
||||
public Instant getShippedAt() {
|
||||
return shippedAt;
|
||||
}
|
||||
|
||||
public void setShippedAt(Instant shippedAt) {
|
||||
this.shippedAt = shippedAt;
|
||||
}
|
||||
|
||||
public String getAdminNotes() {
|
||||
return adminNotes;
|
||||
}
|
||||
|
||||
public void setAdminNotes(String adminNotes) {
|
||||
this.adminNotes = adminNotes;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ public enum OrderStatus {
|
|||
PROCESSING("processing"),
|
||||
SENT("sent"),
|
||||
DELIVERED("delivered"),
|
||||
FAILED("failed");
|
||||
FAILED("failed"),
|
||||
CANCELLED("cancelled");
|
||||
|
||||
private final String value;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class EmailChangeTokenInvalidException extends RuntimeException {
|
||||
|
||||
public EmailChangeTokenInvalidException() {
|
||||
super("Bekräftelselänken är ogiltig eller har gått ut.");
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,21 @@ public class GlobalExceptionHandler {
|
|||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(EmailChangeTokenInvalidException.class)
|
||||
public ResponseEntity<ErrorResponse> handleEmailChangeTokenInvalid(
|
||||
EmailChangeTokenInvalidException ex) {
|
||||
return ResponseEntity
|
||||
.badRequest()
|
||||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
return ResponseEntity
|
||||
.badRequest()
|
||||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(EmailAlreadyExistsException.class)
|
||||
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
|
||||
return ResponseEntity
|
||||
|
|
@ -36,6 +51,13 @@ public class GlobalExceptionHandler {
|
|||
.body(new ErrorResponse("E-postadressen är redan registrerad"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidOrderStateException.class)
|
||||
public ResponseEntity<ErrorResponse> handleInvalidOrderState(InvalidOrderStateException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CONFLICT)
|
||||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(OrderNotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
|
||||
return ResponseEntity
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class InvalidOrderStateException extends RuntimeException {
|
||||
public InvalidOrderStateException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package se.bilhalsning.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import se.bilhalsning.entity.EmailChangeToken;
|
||||
|
||||
public interface EmailChangeTokenRepository extends JpaRepository<EmailChangeToken, UUID> {
|
||||
|
||||
Optional<EmailChangeToken> findByTokenHashAndUsedAtIsNull(String tokenHash);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM EmailChangeToken t WHERE t.user.id = :userId AND t.usedAt IS NULL")
|
||||
void deleteUnusedByUserId(@Param("userId") UUID userId);
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import se.bilhalsning.entity.Order;
|
|||
import se.bilhalsning.entity.OrderStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
|
|
@ -17,4 +18,7 @@ public interface OrderRepository extends JpaRepository<Order, UUID> {
|
|||
|
||||
@EntityGraph(attributePaths = {"user"})
|
||||
List<Order> findAllByOrderByCreatedAtDesc();
|
||||
|
||||
@EntityGraph(attributePaths = {"user"})
|
||||
Optional<Order> findWithUserById(UUID id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ public class JwtService {
|
|||
this(secret, DEFAULT_EXPIRATION_MS);
|
||||
}
|
||||
|
||||
JwtService(String secret, long expirationMs) {
|
||||
public JwtService(String secret, long expirationMs) {
|
||||
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||
this.expirationMs = expirationMs;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||
|
||||
/**
|
||||
* Admin status transitions and UI affordances. Single source of truth for
|
||||
* {@link AdminOrderResponse#allowedStatuses()} and {@link AdminOrderResponse#canRegisterShipment()}.
|
||||
*/
|
||||
public final class AdminOrderStatusRules {
|
||||
|
||||
private AdminOrderStatusRules() {}
|
||||
|
||||
public static List<String> allowedStatusValues(Order order) {
|
||||
OrderStatus current = order.getStatus();
|
||||
LinkedHashSet<OrderStatus> options = new LinkedHashSet<>();
|
||||
options.add(current);
|
||||
for (OrderStatus target : allowedTargets(current, order)) {
|
||||
options.add(target);
|
||||
}
|
||||
List<String> values = new ArrayList<>();
|
||||
for (OrderStatus status : options) {
|
||||
values.add(status.getValue());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
public static boolean canRegisterShipment(Order order) {
|
||||
OrderStatus status = order.getStatus();
|
||||
if (status == OrderStatus.PROCESSING
|
||||
|| status == OrderStatus.SENT
|
||||
|| status == OrderStatus.DELIVERED) {
|
||||
return true;
|
||||
}
|
||||
return status == OrderStatus.FAILED && order.getAmountPaid() != null;
|
||||
}
|
||||
|
||||
public static void validateTransition(Order order, OrderStatus to) {
|
||||
OrderStatus from = order.getStatus();
|
||||
if (from == to) {
|
||||
return;
|
||||
}
|
||||
if (!allowedTargets(from, order).contains(to)) {
|
||||
throw new InvalidOrderStateException(
|
||||
"Status kan inte ändras från " + from.getValue() + " till " + to.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private static List<OrderStatus> allowedTargets(OrderStatus from, Order order) {
|
||||
return switch (from) {
|
||||
case PENDING_PAYMENT -> List.of(OrderStatus.FAILED);
|
||||
case PROCESSING -> List.of(OrderStatus.FAILED);
|
||||
case SENT -> List.of(OrderStatus.DELIVERED, OrderStatus.FAILED);
|
||||
case DELIVERED -> List.of(OrderStatus.FAILED);
|
||||
case FAILED -> allowedTargetsFromFailed(order);
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
private static List<OrderStatus> allowedTargetsFromFailed(Order order) {
|
||||
if (hasTrackingId(order)) {
|
||||
return List.of(
|
||||
OrderStatus.PROCESSING,
|
||||
OrderStatus.SENT,
|
||||
OrderStatus.DELIVERED);
|
||||
}
|
||||
if (order.getAmountPaid() == null) {
|
||||
return List.of(OrderStatus.PENDING_PAYMENT);
|
||||
}
|
||||
return List.of(OrderStatus.PROCESSING, OrderStatus.SENT);
|
||||
}
|
||||
|
||||
private static boolean hasTrackingId(Order order) {
|
||||
String trackingId = order.getTrackingId();
|
||||
return trackingId != null && !trackingId.isBlank();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||
import se.bilhalsning.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.repository.OrderRepository;
|
||||
import se.bilhalsning.util.PostNordTrackingNormalizer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminOrderWorkflowService {
|
||||
|
||||
private final OrderRepository orderRepository;
|
||||
private final OrderNotificationService orderNotificationService;
|
||||
|
||||
public Order updateOrderStatus(UUID orderId, String statusString) {
|
||||
Order order = requireOrder(orderId);
|
||||
OrderStatus newStatus = parseStatus(statusString);
|
||||
OrderStatus previousStatus = order.getStatus();
|
||||
AdminOrderStatusRules.validateTransition(order, newStatus);
|
||||
order.setStatus(newStatus);
|
||||
if (newStatus == OrderStatus.SENT
|
||||
&& previousStatus == OrderStatus.FAILED
|
||||
&& order.getShippedAt() == null) {
|
||||
order.setShippedAt(Instant.now());
|
||||
}
|
||||
Order saved = orderRepository.save(order);
|
||||
if (newStatus == OrderStatus.FAILED && previousStatus != OrderStatus.FAILED) {
|
||||
orderNotificationService.notifyOrderFailed(saved);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Order registerShipment(UUID orderId, String rawTrackingInput, boolean notifyCustomer) {
|
||||
String trackingId = PostNordTrackingNormalizer.normalize(rawTrackingInput);
|
||||
Order order = requireOrder(orderId);
|
||||
OrderStatus previousStatus = order.getStatus();
|
||||
|
||||
if (!AdminOrderStatusRules.canRegisterShipment(order)) {
|
||||
throw new InvalidOrderStateException(
|
||||
"Beställningen kan inte registreras som utskickad i detta tillstånd");
|
||||
}
|
||||
|
||||
if (previousStatus == OrderStatus.FAILED && order.getAmountPaid() == null) {
|
||||
throw new InvalidOrderStateException(
|
||||
"Obetalda misslyckade beställningar kan inte registreras som utskickade");
|
||||
}
|
||||
|
||||
boolean firstShipment = previousStatus == OrderStatus.PROCESSING
|
||||
|| previousStatus == OrderStatus.FAILED;
|
||||
order.setTrackingId(trackingId);
|
||||
if (firstShipment) {
|
||||
order.setStatus(OrderStatus.SENT);
|
||||
order.setShippedAt(Instant.now());
|
||||
}
|
||||
|
||||
Order saved = orderRepository.save(order);
|
||||
if (notifyCustomer && firstShipment) {
|
||||
orderNotificationService.notifyOrderSent(saved, trackingId);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Order updateAdminNotes(UUID orderId, String adminNotes) {
|
||||
Order order = requireOrder(orderId);
|
||||
order.setAdminNotes(adminNotes);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
private Order requireOrder(UUID orderId) {
|
||||
return orderRepository.findWithUserById(orderId)
|
||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||
}
|
||||
|
||||
private static OrderStatus parseStatus(String statusString) {
|
||||
try {
|
||||
return OrderStatus.valueOf(statusString.toUpperCase());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new IllegalArgumentException("Ogiltig status");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import se.bilhalsning.entity.EmailChangeToken;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.EmailChangeTokenInvalidException;
|
||||
import se.bilhalsning.repository.EmailChangeTokenRepository;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EmailChangeService {
|
||||
|
||||
private static final long TOKEN_TTL_HOURS = 24;
|
||||
|
||||
private final UserService userService;
|
||||
private final EmailChangeTokenRepository tokenRepository;
|
||||
private final EmailService emailService;
|
||||
private final PasswordResetService passwordResetService;
|
||||
|
||||
@Value("${app.public-base-url:http://localhost:3000}")
|
||||
private String publicBaseUrl;
|
||||
|
||||
@Value("${app.email-change.expose-token:false}")
|
||||
private boolean exposeToken;
|
||||
|
||||
@Transactional
|
||||
public Optional<String> requestChange(String currentEmail, String password, String newEmail) {
|
||||
User user = userService.authenticate(currentEmail, password);
|
||||
userService.validateEmailAvailableForChange(user, newEmail);
|
||||
|
||||
String normalizedEmail = newEmail.toLowerCase().trim();
|
||||
tokenRepository.deleteUnusedByUserId(user.getId());
|
||||
|
||||
String rawToken = passwordResetService.generateRawToken();
|
||||
EmailChangeToken entity = new EmailChangeToken();
|
||||
entity.setUser(user);
|
||||
entity.setNewEmail(normalizedEmail);
|
||||
entity.setTokenHash(PasswordResetService.hashToken(rawToken));
|
||||
entity.setExpiresAt(Instant.now().plusSeconds(TOKEN_TTL_HOURS * 3600));
|
||||
tokenRepository.save(entity);
|
||||
|
||||
String confirmUrl = publicBaseUrl.replaceAll("/$", "")
|
||||
+ "/bekrafta-epost?token="
|
||||
+ rawToken;
|
||||
emailService.sendEmailChangeConfirmation(normalizedEmail, confirmUrl);
|
||||
|
||||
return exposeToken ? Optional.of(rawToken) : Optional.empty();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public User confirmChange(String rawToken, String password) {
|
||||
EmailChangeToken token = tokenRepository
|
||||
.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken(rawToken))
|
||||
.filter(t -> t.getExpiresAt().isAfter(Instant.now()))
|
||||
.orElseThrow(EmailChangeTokenInvalidException::new);
|
||||
|
||||
User user = token.getUser();
|
||||
userService.authenticate(user.getEmail(), password);
|
||||
User updated = userService.applyEmailChange(user, token.getNewEmail());
|
||||
token.setUsedAt(Instant.now());
|
||||
tokenRepository.deleteUnusedByUserId(user.getId());
|
||||
tokenRepository.save(token);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
|
@ -58,4 +58,106 @@ public class EmailService {
|
|||
throw new IllegalStateException("Kunde inte skicka e-post just nu");
|
||||
}
|
||||
}
|
||||
|
||||
public void sendEmailChangeConfirmation(String toEmail, String confirmUrl) {
|
||||
String subject = "Bekräfta din nya e-postadress – BilHej";
|
||||
String body = """
|
||||
Hej,
|
||||
|
||||
Du har begärt att byta e-postadress för ditt BilHej-konto.
|
||||
|
||||
Öppna länken nedan och ange ditt lösenord för att bekräfta den nya adressen (giltig i 24 timmar):
|
||||
|
||||
%s
|
||||
|
||||
Om du inte begärde detta kan du ignorera det här meddelandet.
|
||||
|
||||
Vänliga hälsningar,
|
||||
BilHej
|
||||
""".formatted(confirmUrl);
|
||||
|
||||
if (mailHost == null || mailHost.isBlank() || mailSender == null) {
|
||||
log.info("SMTP not configured. Email change confirmation link for {}: {}", toEmail, confirmUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(mailFrom);
|
||||
message.setTo(toEmail);
|
||||
message.setSubject(subject);
|
||||
message.setText(body);
|
||||
try {
|
||||
mailSender.send(message);
|
||||
} catch (MailException ex) {
|
||||
log.error("Failed to send email change confirmation to {}", toEmail, ex);
|
||||
throw new IllegalStateException("Kunde inte skicka e-post just nu");
|
||||
}
|
||||
}
|
||||
|
||||
public void sendOrderProcessingEmail(String toEmail, String plate, String ordersUrl) {
|
||||
String subject = "Din beställning hanteras – BilHej";
|
||||
String body = """
|
||||
Hej,
|
||||
|
||||
Tack för din betalning! Vi har tagit emot din beställning för fordonet %s och börjar hantera brevet.
|
||||
|
||||
Du kan följa status 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.repository.UserRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OrderNotificationService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final EmailService emailService;
|
||||
|
||||
@Value("${app.public-base-url:http://localhost:3000}")
|
||||
private String publicBaseUrl;
|
||||
|
||||
public void notifyOrderProcessing(Order order) {
|
||||
String email = resolveCustomerEmail(order);
|
||||
if (email.isBlank()) {
|
||||
return;
|
||||
}
|
||||
emailService.sendOrderProcessingEmail(
|
||||
email,
|
||||
order.getPlate(),
|
||||
ordersPageUrl());
|
||||
}
|
||||
|
||||
public void notifyOrderSent(Order order, String trackingId) {
|
||||
String email = resolveCustomerEmail(order);
|
||||
if (email.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String trackingUrl = "https://www.postnord.se/verktyg/spara/?id=" + trackingId;
|
||||
emailService.sendOrderSentEmail(email, order.getPlate(), trackingId, trackingUrl);
|
||||
}
|
||||
|
||||
public void notifyOrderFailed(Order order) {
|
||||
String email = resolveCustomerEmail(order);
|
||||
if (email.isBlank()) {
|
||||
return;
|
||||
}
|
||||
emailService.sendOrderFailedEmail(email, order.getPlate(), ordersPageUrl());
|
||||
}
|
||||
|
||||
private String resolveCustomerEmail(Order order) {
|
||||
if (order.getUser() != null && order.getUser().getEmail() != null) {
|
||||
return order.getUser().getEmail();
|
||||
}
|
||||
UUID userId = order.getUserId();
|
||||
if (userId == null) {
|
||||
return "";
|
||||
}
|
||||
return userRepository.findById(userId)
|
||||
.map(User::getEmail)
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
private String ordersPageUrl() {
|
||||
String base = publicBaseUrl.endsWith("/")
|
||||
? publicBaseUrl.substring(0, publicBaseUrl.length() - 1)
|
||||
: publicBaseUrl;
|
||||
return base + "/mina-bestallningar";
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
|
|||
import org.springframework.stereotype.Service;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||
import se.bilhalsning.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.repository.OrderRepository;
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ import java.util.UUID;
|
|||
public class OrderService {
|
||||
|
||||
private final OrderRepository orderRepository;
|
||||
private final OrderNotificationService orderNotificationService;
|
||||
|
||||
public Order createOrder(UUID userId, String plate, String letterText) {
|
||||
Order order = new Order();
|
||||
|
|
@ -38,28 +40,39 @@ public class OrderService {
|
|||
return orderRepository.findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
|
||||
public Order updateOrderStatus(UUID orderId, String statusString) {
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||
|
||||
OrderStatus newStatus = OrderStatus.valueOf(statusString.toUpperCase());
|
||||
order.setStatus(newStatus);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
public Order updateTracking(UUID orderId, String trackingId) {
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||
|
||||
order.setTrackingId(trackingId);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
public Order confirmPayment(UUID orderId) {
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||
|
||||
public Order confirmPayment(UUID orderId, UUID userId) {
|
||||
Order order = requirePendingOwnedBy(orderId, userId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
Order saved = orderRepository.save(order);
|
||||
orderNotificationService.notifyOrderProcessing(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Order cancelOrder(UUID orderId, UUID userId) {
|
||||
Order order = requirePendingOwnedBy(orderId, userId);
|
||||
order.setStatus(OrderStatus.CANCELLED);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
public Order updatePendingOrder(UUID orderId, UUID userId, String letterText) {
|
||||
Order order = requirePendingOwnedBy(orderId, userId);
|
||||
order.setLetterText(letterText);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
private Order requirePendingOwnedBy(UUID orderId, UUID userId) {
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||
|
||||
if (!order.getUserId().equals(userId)) {
|
||||
throw new OrderNotFoundException(orderId);
|
||||
}
|
||||
|
||||
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
|
||||
throw new InvalidOrderStateException(
|
||||
"Beställningen kan inte ändras i detta tillstånd");
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,4 +53,26 @@ public class UserService {
|
|||
}
|
||||
updatePassword(user, newPassword);
|
||||
}
|
||||
|
||||
public void validateEmailAvailableForChange(User user, String newEmail) {
|
||||
String normalizedEmail = newEmail.toLowerCase().trim();
|
||||
if (normalizedEmail.equals(user.getEmail())) {
|
||||
throw new IllegalArgumentException("Ny e-postadress måste skilja sig från nuvarande");
|
||||
}
|
||||
if (userRepository.existsByEmail(normalizedEmail)) {
|
||||
throw new EmailAlreadyExistsException(normalizedEmail);
|
||||
}
|
||||
}
|
||||
|
||||
public User applyEmailChange(User user, String newEmail) {
|
||||
String normalizedEmail = newEmail.toLowerCase().trim();
|
||||
if (normalizedEmail.equals(user.getEmail())) {
|
||||
return user;
|
||||
}
|
||||
if (userRepository.existsByEmail(normalizedEmail)) {
|
||||
throw new EmailAlreadyExistsException(normalizedEmail);
|
||||
}
|
||||
user.setEmail(normalizedEmail);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
package se.bilhalsning.util;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class PostNordTrackingNormalizer {
|
||||
|
||||
private PostNordTrackingNormalizer() {
|
||||
}
|
||||
|
||||
public static String normalize(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalArgumentException("Spårnings-ID krävs");
|
||||
}
|
||||
|
||||
String trimmed = raw.trim();
|
||||
if (trimmed.toLowerCase().contains("postnord")) {
|
||||
String fromUrl = extractIdFromPostNordUrl(trimmed);
|
||||
if (fromUrl != null && !fromUrl.isBlank()) {
|
||||
trimmed = fromUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed.replaceAll("\\s+", "");
|
||||
}
|
||||
|
||||
private static String extractIdFromPostNordUrl(String url) {
|
||||
try {
|
||||
URI uri = URI.create(url);
|
||||
String query = uri.getQuery();
|
||||
if (query == null) {
|
||||
return null;
|
||||
}
|
||||
for (String param : query.split("&")) {
|
||||
if (param.startsWith("id=")) {
|
||||
return URLDecoder.decode(param.substring(3), StandardCharsets.UTF_8).trim();
|
||||
}
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -33,3 +33,5 @@ app:
|
|||
# E2E only: never enable in production (see application-prod.yml).
|
||||
password-reset:
|
||||
expose-token: true
|
||||
email-change:
|
||||
expose-token: true
|
||||
|
|
|
|||
|
|
@ -17,3 +17,5 @@ app:
|
|||
password: ${ADMIN_PASSWORD}
|
||||
password-reset:
|
||||
expose-token: false
|
||||
email-change:
|
||||
expose-token: false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
-- Dev/CI: order in "processing" for admin fulfillment testing
|
||||
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
|
||||
VALUES (
|
||||
'c4eebc99-9c0b-4ef8-bb6d-6bb9bd380a14',
|
||||
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||
'JKL012',
|
||||
'Hej! Bara en påminnelse om serviceboken.',
|
||||
'processing',
|
||||
49.00,
|
||||
NULL,
|
||||
TIMESTAMP '2026-05-16 09:00:00',
|
||||
TIMESTAMP '2026-05-16 09:00:00'
|
||||
);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE email_change_tokens (
|
||||
id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
new_email VARCHAR(255) NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
used_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_email_change_tokens PRIMARY KEY (id),
|
||||
CONSTRAINT fk_email_change_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_email_change_tokens_user_id ON email_change_tokens (user_id);
|
||||
CREATE INDEX idx_email_change_tokens_token_hash ON email_change_tokens (token_hash);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
ALTER TABLE orders ADD COLUMN admin_notes TEXT;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
ALTER TABLE orders DROP CONSTRAINT ck_orders_status;
|
||||
|
||||
ALTER TABLE orders
|
||||
ADD CONSTRAINT ck_orders_status CHECK (
|
||||
status IN (
|
||||
'pending_payment',
|
||||
'paid',
|
||||
'processing',
|
||||
'sent',
|
||||
'delivered',
|
||||
'failed',
|
||||
'cancelled'
|
||||
)
|
||||
);
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package se.bilhalsning;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class FlywayMigrationFilesTest {
|
||||
|
||||
private static final Pattern VERSION_PATTERN = Pattern.compile("^V(\\d+)__.+\\.sql$");
|
||||
private static final Path MIGRATION_DIR = Path.of("src/main/resources/db/migration");
|
||||
|
||||
@Test
|
||||
void shouldUseUniqueVersionNumbersAndValidNames() throws IOException {
|
||||
assertTrue(
|
||||
Files.isDirectory(MIGRATION_DIR),
|
||||
() -> "Expected migration directory at " + MIGRATION_DIR.toAbsolutePath());
|
||||
|
||||
Set<Integer> versions = new HashSet<>();
|
||||
|
||||
try (Stream<Path> files = Files.list(MIGRATION_DIR)) {
|
||||
for (Path file : files.filter(path -> path.getFileName().toString().endsWith(".sql")).toList()) {
|
||||
String name = file.getFileName().toString();
|
||||
Matcher matcher = VERSION_PATTERN.matcher(name);
|
||||
assertTrue(matcher.matches(), () -> "Invalid migration filename: " + name);
|
||||
|
||||
int version = Integer.parseInt(matcher.group(1));
|
||||
assertFalse(
|
||||
versions.contains(version),
|
||||
() -> "Duplicate Flyway version V" + version + " in " + MIGRATION_DIR);
|
||||
versions.add(version);
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse(versions.isEmpty(), "Expected at least one schema migration");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
|
|
@ -23,7 +23,9 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||
import se.bilhalsning.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.service.AdminOrderWorkflowService;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
|
||||
@SpringBootTest
|
||||
|
|
@ -36,17 +38,22 @@ class AdminControllerTest {
|
|||
@MockitoBean
|
||||
private OrderService orderService;
|
||||
|
||||
@MockitoBean
|
||||
private AdminOrderWorkflowService adminOrderWorkflowService;
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/orders"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se", roles = "USER")
|
||||
void shouldReturn403ForNonAdminUser() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/orders"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -61,151 +68,96 @@ class AdminControllerTest {
|
|||
.andExpect(jsonPath("$[0].id").value(order.getId().toString()))
|
||||
.andExpect(jsonPath("$[0].email").value("test@bilhej.se"))
|
||||
.andExpect(jsonPath("$[0].plate").value("ABC123"))
|
||||
.andExpect(jsonPath("$[0].letterText").value("Test letter"))
|
||||
.andExpect(jsonPath("$[0].status").value("sent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldReturnEmptyArrayWhenNoOrders() throws Exception {
|
||||
when(orderService.getAllOrders()).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/admin/orders"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenPatchingStatusWithoutAuth() throws Exception {
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"paid\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se", roles = "USER")
|
||||
void shouldReturn403WhenPatchingStatusAsNonAdmin() throws Exception {
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"paid\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(jsonPath("$[0].status").value("sent"))
|
||||
.andExpect(jsonPath("$[0].allowedStatuses").isArray())
|
||||
.andExpect(jsonPath("$[0].canRegisterShipment").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldUpdateOrderStatusSuccessfully() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PAID);
|
||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.FAILED);
|
||||
|
||||
when(orderService.updateOrderStatus(eq(orderId), eq("paid"))).thenReturn(order);
|
||||
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("failed")))
|
||||
.thenReturn(order);
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"paid\"}"))
|
||||
.content("{\"status\":\"failed\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(orderId.toString()))
|
||||
.andExpect(jsonPath("$.status").value("paid"));
|
||||
.andExpect(jsonPath("$.status").value("failed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldReturn400WhenStatusIsInvalid() throws Exception {
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"invalid_status\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldReturn400WhenStatusIsBlank() throws Exception {
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldReturn404WhenOrderNotFound() throws Exception {
|
||||
void shouldReturn409WhenStatusTransitionInvalid() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
when(orderService.updateOrderStatus(eq(orderId), eq("paid")))
|
||||
.thenThrow(new OrderNotFoundException(orderId));
|
||||
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("delivered")))
|
||||
.thenThrow(new InvalidOrderStateException("Ogiltig övergång"));
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"paid\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenPatchingTrackingWithoutAuth() throws Exception {
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingId\":\"PN123456789\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se", roles = "USER")
|
||||
void shouldReturn403WhenPatchingTrackingAsNonAdmin() throws Exception {
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingId\":\"PN123456789\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
.content("{\"status\":\"delivered\"}"))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldUpdateTrackingSuccessfully() throws Exception {
|
||||
void shouldRegisterShipmentSuccessfully() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
|
||||
order.setTrackingId("PN123456789");
|
||||
order.setShippedAt(Instant.parse("2026-05-13T12:00:00Z"));
|
||||
|
||||
when(orderService.updateTracking(eq(orderId), eq("PN123456789"))).thenReturn(order);
|
||||
when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123456789"), eq(true)))
|
||||
.thenReturn(order);
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}", orderId)
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingId\":\"PN123456789\"}"))
|
||||
.content("{\"trackingInput\":\"PN123456789\",\"notifyCustomer\":true}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(orderId.toString()))
|
||||
.andExpect(jsonPath("$.trackingId").value("PN123456789"));
|
||||
.andExpect(jsonPath("$.trackingId").value("PN123456789"))
|
||||
.andExpect(jsonPath("$.status").value("sent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldClearTrackingWhenNull() throws Exception {
|
||||
void shouldReturn400WhenTrackingInputBlank() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
|
||||
order.setTrackingId(null);
|
||||
|
||||
when(orderService.updateTracking(eq(orderId), eq(null))).thenReturn(order);
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}", orderId)
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingId\":null}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.trackingId").doesNotExist());
|
||||
.content("{\"trackingInput\":\"\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldReturn404WhenOrderNotFoundForTracking() throws Exception {
|
||||
void shouldUpdateAdminNotes() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
when(orderService.updateTracking(eq(orderId), eq("PN123456789")))
|
||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PROCESSING);
|
||||
order.setAdminNotes("Kontaktat TS");
|
||||
|
||||
when(adminOrderWorkflowService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order);
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/notes", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"adminNotes\":\"Kontaktat TS\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.adminNotes").value("Kontaktat TS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldReturn404WhenOrderNotFoundForRegisterShipment() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123"), anyBoolean()))
|
||||
.thenThrow(new OrderNotFoundException(orderId));
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}", orderId)
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingId\":\"PN123456789\"}"))
|
||||
.content("{\"trackingInput\":\"PN123\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
|
|
@ -219,7 +171,6 @@ class AdminControllerTest {
|
|||
order.setPlate(plate);
|
||||
order.setLetterText("Test letter");
|
||||
order.setStatus(status);
|
||||
order.setTrackingId(null);
|
||||
order.setAmountPaid(new BigDecimal("49.00"));
|
||||
|
||||
return order;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import se.bilhalsning.exception.EmailAlreadyExistsException;
|
|||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
import java.util.Optional;
|
||||
import se.bilhalsning.service.EmailChangeService;
|
||||
import se.bilhalsning.service.PasswordResetService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
|
|
@ -40,6 +41,9 @@ class AuthControllerTest {
|
|||
@MockitoBean
|
||||
private PasswordResetService passwordResetService;
|
||||
|
||||
@MockitoBean
|
||||
private EmailChangeService emailChangeService;
|
||||
|
||||
@MockitoBean
|
||||
private JwtService jwtService;
|
||||
|
||||
|
|
@ -221,6 +225,46 @@ class AuthControllerTest {
|
|||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@example.com")
|
||||
void shouldReturn200WhenChangeEmailRequestSucceeds() throws Exception {
|
||||
when(emailChangeService.requestChange("user@example.com", "password123", "new@example.com"))
|
||||
.thenReturn(Optional.of("test-token"));
|
||||
|
||||
mockMvc.perform(post("/api/auth/change-email")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message")
|
||||
.value("Vi har skickat en bekräftelselänk till din nya e-postadress."))
|
||||
.andExpect(jsonPath("$.testToken").value("test-token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn200AndNewTokenWhenConfirmEmailChangeSucceeds() throws Exception {
|
||||
User user = new User();
|
||||
user.setEmail("new@example.com");
|
||||
user.setRole("user");
|
||||
when(emailChangeService.confirmChange("confirm-token", "password123")).thenReturn(user);
|
||||
when(jwtService.generateToken("new@example.com", "user")).thenReturn("new-jwt-token");
|
||||
|
||||
mockMvc.perform(post("/api/auth/confirm-email-change")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"token\":\"confirm-token\",\"password\":\"password123\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.token").value("new-jwt-token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectChangeEmailWithoutAuth() throws Exception {
|
||||
mockMvc.perform(post("/api/auth/change-email")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
|
@ -16,16 +18,22 @@ import org.springframework.boot.test.context.SpringBootTest;
|
|||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import se.bilhalsning.dto.OrderResponse;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
|
||||
class OrderControllerTest {
|
||||
|
||||
private static final String TEST_SECRET =
|
||||
"this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
|
|
@ -36,9 +44,10 @@ class OrderControllerTest {
|
|||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -98,11 +107,31 @@ class OrderControllerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenPostingWithoutAuth() throws Exception {
|
||||
void shouldReturn401WhenPostingWithoutAuth() throws Exception {
|
||||
mockMvc.perform(post("/api/orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401WithSwedishMessageWhenTokenExpired() throws Exception {
|
||||
JwtService expiredJwtService = new JwtService(TEST_SECRET, -1000L);
|
||||
String expiredToken = expiredJwtService.generateToken("test@bilhej.se");
|
||||
|
||||
mockMvc.perform(get("/api/orders")
|
||||
.header("Authorization", "Bearer " + expiredToken))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401WithMessageWhenNoAuthHeader() throws Exception {
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message")
|
||||
.value(org.hamcrest.Matchers.containsString("session")));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -163,4 +192,115 @@ class OrderControllerTest {
|
|||
.content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldGetSingleOrderForOwner() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhej.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setPlate("ABC123");
|
||||
order.setLetterText("Test letter");
|
||||
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
|
||||
|
||||
when(orderService.getOrderById(orderId)).thenReturn(order);
|
||||
|
||||
mockMvc.perform(get("/api/orders/" + orderId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(orderId.toString()))
|
||||
.andExpect(jsonPath("$.plate").value("ABC123"))
|
||||
.andExpect(jsonPath("$.status").value("pending_payment"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldReturn404WhenGettingOtherUsersOrder() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhej.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(UUID.randomUUID());
|
||||
|
||||
when(orderService.getOrderById(orderId)).thenReturn(order);
|
||||
|
||||
mockMvc.perform(get("/api/orders/" + orderId))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldPatchOrderSuccessfully() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhej.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setPlate("ABC123");
|
||||
order.setLetterText("Updated text");
|
||||
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
|
||||
|
||||
when(orderService.updatePendingOrder(any(), any(), any())).thenReturn(order);
|
||||
|
||||
mockMvc.perform(patch("/api/orders/" + orderId)
|
||||
.contentType("application/json")
|
||||
.content("{\"letterText\":\"Updated text\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.letterText").value("Updated text"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldRejectPatchWithEmptyLetterText() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
|
||||
mockMvc.perform(patch("/api/orders/" + orderId)
|
||||
.contentType("application/json")
|
||||
.content("{\"letterText\":\"\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldCancelOrderSuccessfully() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhej.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setPlate("ABC123");
|
||||
order.setLetterText("Test letter");
|
||||
order.setStatus(se.bilhalsning.entity.OrderStatus.CANCELLED);
|
||||
|
||||
when(orderService.cancelOrder(orderId, userId)).thenReturn(order);
|
||||
|
||||
mockMvc.perform(post("/api/orders/" + orderId + "/cancel"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("cancelled"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
|
|
@ -7,6 +8,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
|
@ -18,8 +20,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
|
|
@ -31,23 +35,34 @@ class PaymentControllerTest {
|
|||
@MockitoBean
|
||||
private OrderService orderService;
|
||||
|
||||
@MockitoBean
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/payment/{orderId}/pay",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldConfirmPaymentSuccessfully() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhej.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setPlate("ABC123");
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
|
||||
when(orderService.confirmPayment(eq(orderId))).thenReturn(order);
|
||||
when(orderService.confirmPayment(eq(orderId), eq(userId))).thenReturn(order);
|
||||
|
||||
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
|
|
@ -60,7 +75,14 @@ class PaymentControllerTest {
|
|||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldReturn404WhenOrderNotFound() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
when(orderService.confirmPayment(eq(orderId)))
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhej.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
when(orderService.confirmPayment(eq(orderId), eq(userId)))
|
||||
.thenThrow(new OrderNotFoundException(orderId));
|
||||
|
||||
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class AccountSettingsIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private EmailChangeService emailChangeService;
|
||||
|
||||
@Test
|
||||
void shouldChangePasswordAndChangeBack() {
|
||||
String email = "pw-settings-" + UUID.randomUUID() + "@bilhej.se";
|
||||
String originalPassword = "original1234";
|
||||
String changedPassword = "changed12345";
|
||||
|
||||
userService.createUser(email, originalPassword);
|
||||
|
||||
userService.changePassword(email, originalPassword, changedPassword);
|
||||
assertDoesNotThrow(() -> userService.authenticate(email, changedPassword));
|
||||
assertThrows(
|
||||
InvalidCredentialsException.class,
|
||||
() -> userService.authenticate(email, originalPassword));
|
||||
|
||||
userService.changePassword(email, changedPassword, originalPassword);
|
||||
assertDoesNotThrow(() -> userService.authenticate(email, originalPassword));
|
||||
assertThrows(
|
||||
InvalidCredentialsException.class,
|
||||
() -> userService.authenticate(email, changedPassword));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldChangeEmailAfterConfirmationAndChangeBack() {
|
||||
String suffix = UUID.randomUUID().toString();
|
||||
String originalEmail = "email-settings-" + suffix + "@bilhej.se";
|
||||
String tempEmail = "email-settings-" + suffix + "-new@bilhej.se";
|
||||
String password = "password1234";
|
||||
|
||||
userService.createUser(originalEmail, password);
|
||||
|
||||
var firstToken = emailChangeService
|
||||
.requestChange(originalEmail, password, tempEmail)
|
||||
.orElseThrow();
|
||||
emailChangeService.confirmChange(firstToken, password);
|
||||
|
||||
assertEquals(tempEmail, userService.findByEmail(tempEmail).orElseThrow().getEmail());
|
||||
assertThrows(
|
||||
InvalidCredentialsException.class,
|
||||
() -> userService.authenticate(originalEmail, password));
|
||||
|
||||
var secondToken = emailChangeService
|
||||
.requestChange(tempEmail, password, originalEmail)
|
||||
.orElseThrow();
|
||||
emailChangeService.confirmChange(secondToken, password);
|
||||
|
||||
assertEquals(
|
||||
originalEmail, userService.findByEmail(originalEmail).orElseThrow().getEmail());
|
||||
assertThrows(
|
||||
InvalidCredentialsException.class, () -> userService.authenticate(tempEmail, password));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectEmailChangeConfirmWhenPasswordWrong() {
|
||||
String suffix = UUID.randomUUID().toString();
|
||||
String originalEmail = "email-wrongpw-" + suffix + "@bilhej.se";
|
||||
String tempEmail = "email-wrongpw-" + suffix + "-new@bilhej.se";
|
||||
String password = "password1234";
|
||||
|
||||
userService.createUser(originalEmail, password);
|
||||
|
||||
var token = emailChangeService
|
||||
.requestChange(originalEmail, password, tempEmail)
|
||||
.orElseThrow();
|
||||
|
||||
assertThrows(
|
||||
InvalidCredentialsException.class,
|
||||
() -> emailChangeService.confirmChange(token, "wrongpassword"));
|
||||
|
||||
assertEquals(
|
||||
originalEmail, userService.findByEmail(originalEmail).orElseThrow().getEmail());
|
||||
assertThrows(
|
||||
InvalidCredentialsException.class,
|
||||
() -> userService.authenticate(tempEmail, password));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AdminOrderStatusRulesTest {
|
||||
|
||||
@Test
|
||||
void shouldIncludeCurrentAndTargetsForSentOrder() {
|
||||
Order order = orderWithStatus(OrderStatus.SENT);
|
||||
|
||||
assertEquals(
|
||||
java.util.List.of("sent", "delivered", "failed"),
|
||||
AdminOrderStatusRules.allowedStatusValues(order));
|
||||
assertTrue(AdminOrderStatusRules.canRegisterShipment(order));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowOnlyFailedFromPendingPayment() {
|
||||
Order order = orderWithStatus(OrderStatus.PENDING_PAYMENT);
|
||||
|
||||
assertEquals(
|
||||
java.util.List.of("pending_payment", "failed"),
|
||||
AdminOrderStatusRules.allowedStatusValues(order));
|
||||
assertFalse(AdminOrderStatusRules.canRegisterShipment(order));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExposeFailedRecoveryOptionsWhenTrackingExists() {
|
||||
Order order = orderWithStatus(OrderStatus.FAILED);
|
||||
order.setTrackingId("PN123");
|
||||
order.setAmountPaid(new BigDecimal("49.00"));
|
||||
|
||||
assertTrue(AdminOrderStatusRules.allowedStatusValues(order).contains("sent"));
|
||||
assertTrue(AdminOrderStatusRules.canRegisterShipment(order));
|
||||
}
|
||||
|
||||
private static Order orderWithStatus(OrderStatus status) {
|
||||
Order order = new Order();
|
||||
order.setStatus(status);
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||
import se.bilhalsning.repository.OrderRepository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AdminOrderWorkflowServiceTest {
|
||||
|
||||
@Mock
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@Mock
|
||||
private OrderNotificationService orderNotificationService;
|
||||
|
||||
@InjectMocks
|
||||
private AdminOrderWorkflowService adminOrderWorkflowService;
|
||||
|
||||
@Test
|
||||
void shouldRegisterShipmentFromProcessingAndSetSent() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.registerShipment(orderId, "PN123456789", true);
|
||||
|
||||
assertEquals(OrderStatus.SENT, result.getStatus());
|
||||
assertEquals("PN123456789", result.getTrackingId());
|
||||
assertNotNull(result.getShippedAt());
|
||||
verify(orderNotificationService).notifyOrderSent(result, "PN123456789");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRegisterShipmentWhenPendingPayment() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(InvalidOrderStateException.class,
|
||||
() -> adminOrderWorkflowService.registerShipment(orderId, "PN123", true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidAdminStatusTransition() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(InvalidOrderStateException.class,
|
||||
() -> adminOrderWorkflowService.updateOrderStatus(orderId, "delivered"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMarkPendingPaymentAsFailed() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "failed");
|
||||
|
||||
assertEquals(OrderStatus.FAILED, result.getStatus());
|
||||
verify(orderNotificationService).notifyOrderFailed(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRevertFailedToPendingPaymentWhenUnpaid() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.FAILED);
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "pending_payment");
|
||||
|
||||
assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRevertFailedToProcessingWhenPaidWithoutTracking() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.FAILED);
|
||||
order.setAmountPaid(new BigDecimal("49.00"));
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "processing");
|
||||
|
||||
assertEquals(OrderStatus.PROCESSING, result.getStatus());
|
||||
verify(orderNotificationService, never()).notifyOrderFailed(any(Order.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRevertFailedToSentWhenPaidWithoutTracking() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.FAILED);
|
||||
order.setAmountPaid(new BigDecimal("49.00"));
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "sent");
|
||||
|
||||
assertEquals(OrderStatus.SENT, result.getStatus());
|
||||
assertNotNull(result.getShippedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRegisterShipmentFromFailedWhenPaid() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.FAILED);
|
||||
order.setAmountPaid(new BigDecimal("49.00"));
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.registerShipment(orderId, "PN888", true);
|
||||
|
||||
assertEquals(OrderStatus.SENT, result.getStatus());
|
||||
assertEquals("PN888", result.getTrackingId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRevertFailedToSentWhenTrackingExists() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.FAILED);
|
||||
order.setTrackingId("PN123");
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "sent");
|
||||
|
||||
assertEquals(OrderStatus.SENT, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotifyCustomerOnFailedStatusFromProcessing() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
adminOrderWorkflowService.updateOrderStatus(orderId, "failed");
|
||||
|
||||
verify(orderNotificationService).notifyOrderFailed(any(Order.class));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import se.bilhalsning.entity.EmailChangeToken;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.EmailChangeTokenInvalidException;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
import se.bilhalsning.repository.EmailChangeTokenRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class EmailChangeServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserService userService;
|
||||
|
||||
@Mock
|
||||
private EmailChangeTokenRepository tokenRepository;
|
||||
|
||||
@Mock
|
||||
private EmailService emailService;
|
||||
|
||||
@Mock
|
||||
private PasswordResetService passwordResetService;
|
||||
|
||||
@InjectMocks
|
||||
private EmailChangeService emailChangeService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(emailChangeService, "publicBaseUrl", "http://localhost:3000");
|
||||
ReflectionTestUtils.setField(emailChangeService, "exposeToken", true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSendConfirmationEmailWhenRequestIsValid() {
|
||||
User user = new User();
|
||||
user.setEmail("old@example.com");
|
||||
user.setRole("user");
|
||||
|
||||
when(userService.authenticate("old@example.com", "password123")).thenReturn(user);
|
||||
when(passwordResetService.generateRawToken()).thenReturn("raw-token");
|
||||
|
||||
Optional<String> testToken =
|
||||
emailChangeService.requestChange("old@example.com", "password123", "new@example.com");
|
||||
|
||||
assertEquals(Optional.of("raw-token"), testToken);
|
||||
verify(userService).validateEmailAvailableForChange(user, "new@example.com");
|
||||
verify(tokenRepository).deleteUnusedByUserId(user.getId());
|
||||
verify(tokenRepository).save(any(EmailChangeToken.class));
|
||||
verify(emailService)
|
||||
.sendEmailChangeConfirmation(
|
||||
eq("new@example.com"),
|
||||
eq("http://localhost:3000/bekrafta-epost?token=raw-token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRequestWhenPasswordWrong() {
|
||||
when(userService.authenticate("old@example.com", "wrong"))
|
||||
.thenThrow(new InvalidCredentialsException());
|
||||
|
||||
assertThrows(
|
||||
InvalidCredentialsException.class,
|
||||
() -> emailChangeService.requestChange("old@example.com", "wrong", "new@example.com"));
|
||||
|
||||
verify(tokenRepository, never()).save(any(EmailChangeToken.class));
|
||||
verify(emailService, never()).sendEmailChangeConfirmation(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConfirmEmailChangeWhenTokenIsValid() {
|
||||
User user = new User();
|
||||
user.setEmail("old@example.com");
|
||||
user.setRole("user");
|
||||
|
||||
EmailChangeToken token = new EmailChangeToken();
|
||||
token.setUser(user);
|
||||
token.setNewEmail("new@example.com");
|
||||
token.setTokenHash(PasswordResetService.hashToken("raw-token"));
|
||||
token.setExpiresAt(java.time.Instant.now().plusSeconds(3600));
|
||||
|
||||
when(tokenRepository.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken("raw-token")))
|
||||
.thenReturn(Optional.of(token));
|
||||
when(userService.authenticate("old@example.com", "password123")).thenReturn(user);
|
||||
when(userService.applyEmailChange(user, "new@example.com")).thenReturn(user);
|
||||
|
||||
User result = emailChangeService.confirmChange("raw-token", "password123");
|
||||
|
||||
assertEquals(user, result);
|
||||
verify(userService).authenticate("old@example.com", "password123");
|
||||
verify(tokenRepository).deleteUnusedByUserId(user.getId());
|
||||
verify(tokenRepository).save(token);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectConfirmWhenPasswordWrong() {
|
||||
User user = new User();
|
||||
user.setEmail("old@example.com");
|
||||
|
||||
EmailChangeToken token = new EmailChangeToken();
|
||||
token.setUser(user);
|
||||
token.setNewEmail("new@example.com");
|
||||
token.setTokenHash(PasswordResetService.hashToken("raw-token"));
|
||||
token.setExpiresAt(java.time.Instant.now().plusSeconds(3600));
|
||||
|
||||
when(tokenRepository.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken("raw-token")))
|
||||
.thenReturn(Optional.of(token));
|
||||
when(userService.authenticate("old@example.com", "wrong"))
|
||||
.thenThrow(new InvalidCredentialsException());
|
||||
|
||||
assertThrows(
|
||||
InvalidCredentialsException.class,
|
||||
() -> emailChangeService.confirmChange("raw-token", "wrong"));
|
||||
|
||||
verify(userService, never()).applyEmailChange(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectConfirmWhenTokenInvalid() {
|
||||
when(tokenRepository.findByTokenHashAndUsedAtIsNull(any())).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(
|
||||
EmailChangeTokenInvalidException.class,
|
||||
() -> emailChangeService.confirmChange("bad-token", "password123"));
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import org.mockito.Mock;
|
|||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||
import se.bilhalsning.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.repository.OrderRepository;
|
||||
|
||||
|
|
@ -26,6 +27,9 @@ class OrderServiceTest {
|
|||
@Mock
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@Mock
|
||||
private OrderNotificationService orderNotificationService;
|
||||
|
||||
@InjectMocks
|
||||
private OrderService orderService;
|
||||
|
||||
|
|
@ -127,4 +131,126 @@ class OrderServiceTest {
|
|||
assertThrows(OrderNotFoundException.class,
|
||||
() -> orderService.getOrderById(orderId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCancelOrderWhenPendingPayment() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.cancelOrder(orderId, userId);
|
||||
|
||||
assertEquals(OrderStatus.CANCELLED, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenCancellingNonPendingOrder() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(InvalidOrderStateException.class,
|
||||
() -> orderService.cancelOrder(orderId, userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenCancellingOtherUsersOrder() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID otherUserId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(ownerId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(OrderNotFoundException.class,
|
||||
() -> orderService.cancelOrder(orderId, otherUserId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUpdatePendingOrderLetterText() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
order.setLetterText("Old text");
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.updatePendingOrder(orderId, userId, "New text");
|
||||
|
||||
assertEquals("New text", result.getLetterText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenUpdatingNonPendingOrder() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(InvalidOrderStateException.class,
|
||||
() -> orderService.updatePendingOrder(orderId, userId, "New text"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConfirmPaymentForPendingOrder() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.confirmPayment(orderId, userId);
|
||||
|
||||
assertEquals(OrderStatus.PROCESSING, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenConfirmingPaymentForNonPendingOrder() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setStatus(OrderStatus.CANCELLED);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(InvalidOrderStateException.class,
|
||||
() -> orderService.confirmPayment(orderId, userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenConfirmingPaymentForOtherUsersOrder() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID otherUserId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(ownerId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(OrderNotFoundException.class,
|
||||
() -> orderService.confirmPayment(orderId, otherUserId));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,4 +203,35 @@ class UserServiceTest {
|
|||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldApplyEmailChangeWhenNewEmailAvailable() {
|
||||
User user = new User();
|
||||
user.setEmail("old@example.com");
|
||||
user.setPasswordHash("hash");
|
||||
user.setRole("user");
|
||||
|
||||
when(userRepository.existsByEmail("new@example.com")).thenReturn(false);
|
||||
when(userRepository.save(user)).thenReturn(user);
|
||||
|
||||
User result = userService.applyEmailChange(user, "new@example.com");
|
||||
|
||||
assertEquals("new@example.com", result.getEmail());
|
||||
verify(userRepository).save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectApplyEmailChangeWhenNewEmailTaken() {
|
||||
User user = new User();
|
||||
user.setEmail("old@example.com");
|
||||
user.setPasswordHash("hash");
|
||||
|
||||
when(userRepository.existsByEmail("taken@example.com")).thenReturn(true);
|
||||
|
||||
assertThrows(
|
||||
EmailAlreadyExistsException.class,
|
||||
() -> userService.applyEmailChange(user, "taken@example.com"));
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
package se.bilhalsning.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class PostNordTrackingNormalizerTest {
|
||||
|
||||
@Test
|
||||
void shouldTrimAndRemoveWhitespaceFromPlainId() {
|
||||
assertEquals("PN123456789", PostNordTrackingNormalizer.normalize(" PN 123 456 789 "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExtractIdFromPostNordUrl() {
|
||||
String url = "https://www.postnord.se/verktyg/spara/?id=PN987654321&utm=foo";
|
||||
assertEquals("PN987654321", PostNordTrackingNormalizer.normalize(url));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenInputIsBlank() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> PostNordTrackingNormalizer.normalize(" "));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
app:
|
||||
password-reset:
|
||||
expose-token: true
|
||||
email-change:
|
||||
expose-token: true
|
||||
|
|
|
|||
106
docker-compose.dev-bindless.yml
Normal file
106
docker-compose.dev-bindless.yml
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Bindless dev stack — standalone variant of docker-compose.yml.
|
||||
#
|
||||
# Why this exists as a standalone file (not an override):
|
||||
# Docker Compose merges `volumes:` by list concatenation, not by entry
|
||||
# replacement, so an override can't drop the bind mounts from the base file —
|
||||
# only append to them. A standalone file lets us redefine services with only
|
||||
# the volumes we want.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.dev-bindless.yml up -d --build
|
||||
#
|
||||
# Use this when the Docker daemon can't bind-mount the host repo correctly:
|
||||
# - Docker-in-Docker setups (e.g. this Hermes sandbox)
|
||||
# - rootless Docker with restricted mount paths
|
||||
# - Some CI runners
|
||||
#
|
||||
# For normal local dev, use docker-compose.yml — it bind-mounts the repo for
|
||||
# Vite HMR and gradle bootRun hot reload.
|
||||
#
|
||||
# Trade-off vs. the bind-mounted dev compose:
|
||||
# - The image is "frozen" at build time. Editing source on the host does not
|
||||
# affect the running container. Edit + rebuild + restart, or run
|
||||
# `docker compose up -d --build` after changes.
|
||||
# - All source lives inside the image (docker/backend.Dockerfile and
|
||||
# docker/frontend.Dockerfile COPY it in at build time).
|
||||
#
|
||||
# What you still get:
|
||||
# - Gradle caches in named volumes (.gradle, backend/build, gradle-cache)
|
||||
# so dependency downloads persist between `up` cycles.
|
||||
# - Postgres data persists across `down` (via the pgdata volume).
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: bilhej-postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
mailpit:
|
||||
image: ghcr.io/axllent/mailpit:v1.28
|
||||
container_name: bilhej-mailpit
|
||||
ports:
|
||||
- "1025:1025"
|
||||
- "8025:8025"
|
||||
|
||||
backend:
|
||||
image: bilhej-backend-dev
|
||||
build:
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
context: .
|
||||
container_name: bilhej-backend
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: docker
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SWISH_NUMBER: ${SWISH_NUMBER}
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
||||
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost:3000}
|
||||
MAIL_HOST: mailpit
|
||||
MAIL_PORT: "1025"
|
||||
MAIL_USERNAME: ""
|
||||
MAIL_PASSWORD: ""
|
||||
MAIL_FROM: ${MAIL_FROM:-noreply@bilhej.se}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
mailpit:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- backend-gradle-project:/app/.gradle
|
||||
- backend-build:/app/backend/build
|
||||
- gradle-cache:/root/.gradle
|
||||
|
||||
frontend:
|
||||
image: bilhej-frontend-dev
|
||||
build:
|
||||
dockerfile: docker/frontend.Dockerfile
|
||||
context: .
|
||||
container_name: bilhej-frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
gradle-cache:
|
||||
backend-gradle-project:
|
||||
backend-build:
|
||||
|
|
@ -89,8 +89,8 @@ services:
|
|||
sleep 1;
|
||||
done;
|
||||
echo 'Waiting for backend...';
|
||||
for i in \$(seq 1 60); do
|
||||
curl -s http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
|
||||
for i in \$(seq 1 120); do
|
||||
curl -sf http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
|
||||
sleep 1;
|
||||
done;
|
||||
echo 'Waiting for frontend...';
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ services:
|
|||
build:
|
||||
dockerfile: docker/frontend.prod.Dockerfile
|
||||
context: .
|
||||
args:
|
||||
VITE_UMAMI_WEBSITE_ID: ${VITE_UMAMI_WEBSITE_ID:-}
|
||||
VITE_UMAMI_SCRIPT_URL: ${VITE_UMAMI_SCRIPT_URL:-https://analytics.bilhej.se/script.js}
|
||||
container_name: bilhej-frontend-prod
|
||||
ports:
|
||||
- "3001:80"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,15 @@
|
|||
FROM eclipse-temurin:21-jdk
|
||||
WORKDIR /app
|
||||
|
||||
# Copy build configuration and wrapper first so this layer caches well.
|
||||
COPY gradlew settings.gradle build.gradle ./
|
||||
COPY gradle/ gradle/
|
||||
RUN chmod +x gradlew
|
||||
|
||||
# Copy backend module. The dev compose overlays this with a host bind mount
|
||||
# for live source changes; if the bind mount is absent (DinD, CI, k8s) the
|
||||
# image is still self-contained and `gradlew :backend:bootRun` will work.
|
||||
COPY backend/ backend/
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["./gradlew", ":backend:bootRun", "--no-daemon"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first so this layer caches independently of source changes.
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the frontend. The dev compose overlays individual paths
|
||||
# (./frontend/src, ./frontend/public, ./frontend/index.html) with host bind
|
||||
# mounts for live reload; if those bind mounts are absent (DinD, CI, k8s)
|
||||
# the image is still self-contained and `npm run dev` will serve from the
|
||||
# COPY'd files.
|
||||
COPY frontend/ .
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
ARG VITE_UMAMI_WEBSITE_ID=
|
||||
ARG VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
|
||||
ENV VITE_UMAMI_WEBSITE_ID=$VITE_UMAMI_WEBSITE_ID
|
||||
ENV VITE_UMAMI_SCRIPT_URL=$VITE_UMAMI_SCRIPT_URL
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ .
|
||||
|
|
|
|||
|
|
@ -54,3 +54,30 @@ Fallback: reset links still log when `MAIL_HOST` is empty.
|
|||
|
||||
Keep using Mailpit (`docker compose up`, http://localhost:8025). Do not point local Docker at
|
||||
Resend unless you intend to send real mail.
|
||||
|
||||
## 5. Inbound email on bilhej.se
|
||||
|
||||
Inbound mail uses **Resend Receiving** on the root domain `bilhej.se`. No mailbox is created in
|
||||
Strato; the MX record routes all `@bilhej.se` addresses to Resend. You do not create each address
|
||||
separately in Resend.
|
||||
|
||||
**Setup (done once):**
|
||||
|
||||
1. Resend → **Domains** → `bilhej.se` → enable **Receiving**
|
||||
2. Strato → **DNS** → add the receiving MX record (e.g. `inbound-smtp.eu-west-1.amazonaws.com`)
|
||||
3. Wait until Resend shows receiving as **Verified**
|
||||
4. Send test mail to `support@bilhej.se` and `kontakt@bilhej.se`; confirm both appear under **Emails → Receiving**
|
||||
|
||||
**Reading mail:** open the [Resend Receiving inbox](https://resend.com/emails/receiving). There is
|
||||
no automatic forward to Gmail unless you add a webhook handler later.
|
||||
|
||||
| Address | Purpose | Where mail goes |
|
||||
|---------|---------|-----------------|
|
||||
| `support@bilhej.se` | Orders, Swish, status, technical issues | Resend dashboard |
|
||||
| `kontakt@bilhej.se` | General contact, printed letter footer | Resend dashboard |
|
||||
| `klagomal@bilhej.se` | Complaints (shown on `/kontakt`) | Resend dashboard |
|
||||
| `noreply@bilhej.se` | Outbound only (password reset) | Not an inbox |
|
||||
|
||||
**Optional later (same Resend inbox, no extra DNS):** `abuse@bilhej.se` if you want a published
|
||||
address for misuse reports; `privacy@bilhej.se` if integritetspolicy asks for a dedicated
|
||||
data-protection contact.
|
||||
|
|
|
|||
81
docs/umami-analytics.md
Normal file
81
docs/umami-analytics.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Umami analytics (BilHej app)
|
||||
|
||||
Privacy-friendly page analytics via self-hosted [Umami](https://umami.is/docs) on **`https://analytics.bilhej.se`**. Server install is live on the VPS; this doc is for **BilHej code and deploy config** only.
|
||||
|
||||
## Values (production)
|
||||
|
||||
| Item | Value |
|
||||
|------|--------|
|
||||
| Collector | `https://analytics.bilhej.se` |
|
||||
| Tracker script | `https://analytics.bilhej.se/script.js` |
|
||||
| Dashboard | `https://analytics.bilhej.se` (admin login) |
|
||||
| Website in Umami | Name **BilHej**, domain **`bilhej.se`** |
|
||||
| Website ID | `ce59614c-9f2a-4f99-8ba3-c5217f88c3f7` |
|
||||
|
||||
The Website ID is public in the browser (tracking snippet). Set it via **`VITE_UMAMI_WEBSITE_ID`** in production frontend build env — do not hardcode in source.
|
||||
|
||||
**Note:** Umami 3.1 on this server uses the default **`/script.js`** path. `TRACKER_SCRIPT_NAME` / custom `bilhej-stats.js` is not applied in this version.
|
||||
|
||||
### Example snippet (for reference)
|
||||
|
||||
```html
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.bilhej.se/script.js"
|
||||
data-website-id="ce59614c-9f2a-4f99-8ba3-c5217f88c3f7"
|
||||
></script>
|
||||
```
|
||||
|
||||
## Frontend env
|
||||
|
||||
Production builds read this from the **Forgejo Actions secret** `VITE_UMAMI_WEBSITE_ID`. The deploy workflow writes it into `.env` on the server, then `docker compose -f docker-compose.prod.yml build` bakes it into the frontend image.
|
||||
|
||||
**Forgejo → Repository → Settings → Actions → Secrets:**
|
||||
|
||||
| Secret | Value |
|
||||
|--------|--------|
|
||||
| `VITE_UMAMI_WEBSITE_ID` | `ce59614c-9f2a-4f99-8ba3-c5217f88c3f7` |
|
||||
|
||||
Not a high-risk secret (the same ID appears in the browser), but keeping it in Forgejo matches other deploy config.
|
||||
|
||||
Manual deploy on the server (without Forgejo) works the same way: put the line in the project `.env` before `docker compose ... up --build`.
|
||||
|
||||
Optional override (default matches production):
|
||||
|
||||
```bash
|
||||
# VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
|
||||
```
|
||||
|
||||
Leave `VITE_UMAMI_WEBSITE_ID` unset in local dev unless you intentionally send traffic to production Umami.
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
- [x] Load `script.js` with `data-website-id` from `VITE_UMAMI_WEBSITE_ID` (only when set).
|
||||
- [x] Send **SPA pageviews** on Vue Router `afterEach` (`data-auto-track="false"`).
|
||||
- [x] Update **integritetspolicy** — analytics, country-level stats, no IP stored in BilHej DB.
|
||||
- [x] Admin link **Webbstatistik** → Umami dashboard (prod builds only).
|
||||
|
||||
Umami derives **country** from the visitor IP at ingest and does not show IP lists in the UI. BilHej must not store visitor IPs for analytics.
|
||||
|
||||
## Verify after deploy
|
||||
|
||||
1. Browse `https://bilhej.se` (several routes).
|
||||
2. Umami → **BilHej** → **Realtime** / **Countries**.
|
||||
|
||||
## Server layout (reference)
|
||||
|
||||
| Item | Actual on VPS |
|
||||
|------|----------------|
|
||||
| Compose project | `~/umami` (`/home/jocke/umami`) |
|
||||
| Public access | nginx → `http://umami:3000` on Docker network `web` (host port 3000 used by open-webui) |
|
||||
| Database | `umami-db` on internal network `umami-internal` only |
|
||||
|
||||
```bash
|
||||
cd ~/umami
|
||||
docker compose ps
|
||||
docker compose logs -f umami
|
||||
docker compose pull && docker compose up -d # updates — read release notes first
|
||||
docker exec umami-db pg_dump -U umami umami > ~/umami-backup-$(date +%F).sql
|
||||
```
|
||||
|
||||
Country stats require nginx to pass **`X-Forwarded-For`** (already configured for this vhost).
|
||||
175
frontend/e2e/account-settings.spec.ts
Normal file
175
frontend/e2e/account-settings.spec.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { test, expect, type Page, type APIRequestContext } from '@playwright/test'
|
||||
import { clearMailpit, waitForEmailChangeToken } from './helpers/mailpit'
|
||||
|
||||
test.describe('Account settings', () => {
|
||||
test('can change password and change back', async ({ page, request }) => {
|
||||
const email = `pw-change-${Date.now()}@bilhej.se`
|
||||
const originalPassword = 'original1234'
|
||||
const changedPassword = 'changed12345'
|
||||
|
||||
await registerUser(request, email, originalPassword)
|
||||
await loginViaUi(page, email, originalPassword)
|
||||
|
||||
await changePasswordViaUi(page, originalPassword, changedPassword)
|
||||
await expect(page.getByText('Lösenordet har uppdaterats.')).toBeVisible()
|
||||
|
||||
await logoutViaHeader(page)
|
||||
await expectLoginFails(page, email, originalPassword)
|
||||
|
||||
await loginViaUi(page, email, changedPassword)
|
||||
|
||||
await changePasswordViaUi(page, changedPassword, originalPassword)
|
||||
await expect(page.getByText('Lösenordet har uppdaterats.')).toBeVisible()
|
||||
|
||||
await logoutViaHeader(page)
|
||||
await expectLoginFails(page, email, changedPassword)
|
||||
await loginViaUi(page, email, originalPassword)
|
||||
})
|
||||
|
||||
test('can change email after confirming link sent to new address', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const suffix = Date.now()
|
||||
const originalEmail = `email-change-${suffix}@bilhej.se`
|
||||
const tempEmail = `email-change-${suffix}-new@bilhej.se`
|
||||
const password = 'password1234'
|
||||
|
||||
await clearMailpit(request)
|
||||
await registerUser(request, originalEmail, password)
|
||||
await loginViaUi(page, originalEmail, password)
|
||||
|
||||
await page.goto('/andra-epost')
|
||||
await changeEmailViaUi(page, tempEmail, password)
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Vi har skickat en bekräftelselänk till din nya e-postadress.',
|
||||
),
|
||||
).toBeVisible()
|
||||
|
||||
const token = await waitForEmailChangeToken(request, tempEmail, {
|
||||
publicBaseUrl: 'http://frontend',
|
||||
})
|
||||
await confirmEmailChangeViaUi(page, token, password)
|
||||
await expect(
|
||||
page.getByText('Din e-postadress har uppdaterats.'),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('header')).toContainText(tempEmail)
|
||||
|
||||
await clearMailpit(request)
|
||||
await page.goto('/andra-epost')
|
||||
await changeEmailViaUi(page, originalEmail, password)
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Vi har skickat en bekräftelselänk till din nya e-postadress.',
|
||||
),
|
||||
).toBeVisible()
|
||||
|
||||
const restoreToken = await waitForEmailChangeToken(request, originalEmail, {
|
||||
publicBaseUrl: 'http://frontend',
|
||||
})
|
||||
await confirmEmailChangeViaUi(page, restoreToken, password)
|
||||
await expect(
|
||||
page.getByText('Din e-postadress har uppdaterats.'),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('header')).toContainText(originalEmail)
|
||||
})
|
||||
|
||||
test('does not change email when confirm password is wrong', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const suffix = Date.now()
|
||||
const originalEmail = `email-wrongpw-e2e-${suffix}@bilhej.se`
|
||||
const tempEmail = `email-wrongpw-e2e-${suffix}-new@bilhej.se`
|
||||
const password = 'password1234'
|
||||
|
||||
await clearMailpit(request)
|
||||
await registerUser(request, originalEmail, password)
|
||||
await loginViaUi(page, originalEmail, password)
|
||||
|
||||
await page.goto('/andra-epost')
|
||||
await changeEmailViaUi(page, tempEmail, password)
|
||||
|
||||
const token = await waitForEmailChangeToken(request, tempEmail, {
|
||||
publicBaseUrl: 'http://frontend',
|
||||
})
|
||||
|
||||
await page.goto(`/bekrafta-epost?token=${token}`)
|
||||
await page.locator('#password').fill('wrongpassword')
|
||||
await page.getByRole('button', { name: 'Bekräfta ny e-postadress' }).click()
|
||||
|
||||
await expect(page.getByText('Lösenordet är felaktigt')).toBeVisible()
|
||||
await expect(page.locator('header')).toContainText(originalEmail)
|
||||
|
||||
const login = await request.post('/api/auth/login', {
|
||||
data: { email: originalEmail, password },
|
||||
})
|
||||
expect(login.ok()).toBeTruthy()
|
||||
|
||||
const loginWithNewEmail = await request.post('/api/auth/login', {
|
||||
data: { email: tempEmail, password },
|
||||
})
|
||||
expect(loginWithNewEmail.ok()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
async function registerUser(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
const response = await request.post('/api/auth/register', {
|
||||
data: { email, password },
|
||||
})
|
||||
expect(response.ok()).toBeTruthy()
|
||||
}
|
||||
|
||||
async function loginViaUi(page: Page, email: string, password: string) {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill(email)
|
||||
await page.getByLabel('Lösenord').fill(password)
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await expect(page).toHaveURL('/')
|
||||
}
|
||||
|
||||
async function expectLoginFails(page: Page, email: string, password: string) {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill(email)
|
||||
await page.getByLabel('Lösenord').fill(password)
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await expect(page.getByText('Felaktig e-post eller lösenord')).toBeVisible()
|
||||
}
|
||||
|
||||
async function logoutViaHeader(page: Page) {
|
||||
await page.locator('header').getByRole('button', { name: 'Logga ut' }).click()
|
||||
await expect(page).toHaveURL('/')
|
||||
}
|
||||
|
||||
async function changePasswordViaUi(
|
||||
page: Page,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
) {
|
||||
await page.goto('/andra-losenord')
|
||||
await page.locator('#current-password').fill(currentPassword)
|
||||
await page.locator('#password').fill(newPassword)
|
||||
await page.locator('#confirm-password').fill(newPassword)
|
||||
await page.getByRole('button', { name: 'Spara nytt lösenord' }).click()
|
||||
}
|
||||
|
||||
async function changeEmailViaUi(page: Page, newEmail: string, password: string) {
|
||||
await page.locator('#new-email').fill(newEmail)
|
||||
await page.locator('#password').fill(password)
|
||||
await page.getByRole('button', { name: 'Spara ny e-postadress' }).click()
|
||||
}
|
||||
|
||||
async function confirmEmailChangeViaUi(
|
||||
page: Page,
|
||||
token: string,
|
||||
password: string,
|
||||
) {
|
||||
await page.goto(`/bekrafta-epost?token=${token}`)
|
||||
await page.locator('#password').fill(password)
|
||||
await page.getByRole('button', { name: 'Bekräfta ny e-postadress' }).click()
|
||||
}
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { loginAsAdmin } from './helpers/admin'
|
||||
|
||||
const SEEDED_ORDER_ID = 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'
|
||||
const SEEDED_ORDER_SHORT_ID = SEEDED_ORDER_ID.slice(0, 8)
|
||||
const PROCESSING_PLATE = 'JKL012'
|
||||
|
||||
function rowByPlate(page: import('@playwright/test').Page, plate: string) {
|
||||
return page.locator('.admin__row').filter({
|
||||
has: page.locator('.admin__plate', { hasText: plate }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Admin dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('admin@bilhalsning.se')
|
||||
await page.getByLabel('Lösenord').fill('test1234')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await page.waitForURL('/')
|
||||
await loginAsAdmin(page)
|
||||
})
|
||||
|
||||
test('admin can navigate to admin page', async ({ page }) => {
|
||||
|
|
@ -37,9 +41,7 @@ test.describe('Admin dashboard', () => {
|
|||
await page.goto('/admin')
|
||||
|
||||
await expect(page.getByRole('columnheader', { name: 'Datum' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: 'Beställnings-ID' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: 'ID' })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: 'E-post' })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: 'Regnr' })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
|
||||
|
|
@ -69,34 +71,37 @@ test.describe('Admin dashboard', () => {
|
|||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('click expand button shows tracking section', async ({ page }) => {
|
||||
test('click row shows tracking section', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
||||
|
||||
const expandBtns = page.locator('.admin__expand-btn')
|
||||
await expandBtns.first().click()
|
||||
await rowByPlate(page, PROCESSING_PLATE).click()
|
||||
|
||||
await expect(page.getByText('Spårnings-ID').first()).toBeVisible()
|
||||
await expect(page.getByText('Registrera utskick').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('click expand button again collapses it', async ({ page }) => {
|
||||
test('click row again collapses it', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
||||
|
||||
const expandBtns = page.locator('.admin__expand-btn')
|
||||
await expandBtns.first().click()
|
||||
const row = rowByPlate(page, PROCESSING_PLATE)
|
||||
await row.click()
|
||||
await expect(page.locator('.admin__tracking-input').first()).toBeVisible()
|
||||
|
||||
await expandBtns.first().click()
|
||||
await row.click()
|
||||
await expect(page.locator('.admin__tracking-input').first()).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('status dropdown changes update order status', async ({ page }) => {
|
||||
test('status dropdown shows current status for sent orders', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/admin')
|
||||
await page.locator('#admin-order-search').fill(SEEDED_ORDER_SHORT_ID)
|
||||
|
||||
const selects = page.locator('.admin__status-select')
|
||||
await selects.first().selectOption('delivered')
|
||||
|
||||
const updatedSelect = selects.first()
|
||||
await expect(updatedSelect).toHaveValue('delivered')
|
||||
const row = page.locator('.admin__row', { hasText: SEEDED_ORDER_SHORT_ID })
|
||||
const select = row.locator('.admin__status-select')
|
||||
await expect(select).toBeVisible()
|
||||
await expect(select).toHaveValue('sent')
|
||||
})
|
||||
|
||||
test('admin cannot access admin page without auth', async ({ page }) => {
|
||||
|
|
@ -108,20 +113,21 @@ test.describe('Admin dashboard', () => {
|
|||
|
||||
test('expanded row shows tracking input and save button', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
||||
|
||||
const expandBtns = page.locator('.admin__expand-btn')
|
||||
await expandBtns.first().click()
|
||||
await rowByPlate(page, PROCESSING_PLATE).click()
|
||||
|
||||
await expect(page.getByText('Spårnings-ID').first()).toBeVisible()
|
||||
await expect(page.getByText('Registrera utskick').first()).toBeVisible()
|
||||
await expect(page.locator('.admin__tracking-input')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Spara' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Registrera utskick' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows PostNord link when trackingId exists', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
|
||||
const expandBtns = page.locator('.admin__expand-btn')
|
||||
await expandBtns.last().click()
|
||||
await page.locator('.admin__row').last().click()
|
||||
|
||||
const trackingLink = page.locator('.admin__tracking-link')
|
||||
await expect(trackingLink).toBeVisible()
|
||||
|
|
@ -132,8 +138,7 @@ test.describe('Admin dashboard', () => {
|
|||
await page.goto('/admin')
|
||||
|
||||
const defRow = page.locator('.admin__row', { hasText: 'DEF456' }).first()
|
||||
const expandBtn = defRow.locator('.admin__expand-btn')
|
||||
await expandBtn.click()
|
||||
await defRow.click()
|
||||
|
||||
const trackingLink = page.locator('.admin__tracking-link')
|
||||
await expect(trackingLink).not.toBeVisible()
|
||||
|
|
|
|||
87
frontend/e2e/admin-fulfillment.spec.ts
Normal file
87
frontend/e2e/admin-fulfillment.spec.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { loginAsAdmin, openAdminDashboard } from './helpers/admin'
|
||||
|
||||
/**
|
||||
* Admin order status and shipment flows (serial — mutates seeded orders).
|
||||
* Requires docker e2e stack with dev seeds (DEF456, JKL012, ABC123).
|
||||
*/
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
const PENDING_ORDER_SHORT_ID = 'c2eebc99'
|
||||
const PROCESSING_PLATE = 'JKL012'
|
||||
const SENT_ORDER_SHORT_ID = 'c1eebc99'
|
||||
|
||||
function orderRowByPlate(page: import('@playwright/test').Page, plate: string) {
|
||||
return page.locator('.admin__row').filter({
|
||||
has: page.locator('.admin__plate', { hasText: plate }),
|
||||
})
|
||||
}
|
||||
|
||||
function orderRowByShortId(
|
||||
page: import('@playwright/test').Page,
|
||||
shortId: string,
|
||||
) {
|
||||
return page.locator('.admin__row', { hasText: shortId })
|
||||
}
|
||||
|
||||
test.describe('Admin fulfillment flows', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page)
|
||||
await openAdminDashboard(page)
|
||||
})
|
||||
|
||||
test('can mark unpaid order as failed', async ({ page }) => {
|
||||
await page.locator('#admin-order-search').fill(PENDING_ORDER_SHORT_ID)
|
||||
const row = orderRowByShortId(page, PENDING_ORDER_SHORT_ID)
|
||||
const select = row.locator('.admin__status-select')
|
||||
await select.selectOption('failed')
|
||||
await expect(select).toHaveValue('failed')
|
||||
await expect(page.getByRole('alert')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('can revert unpaid failed order to pending payment', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator('#admin-order-search').fill(PENDING_ORDER_SHORT_ID)
|
||||
const row = orderRowByShortId(page, PENDING_ORDER_SHORT_ID)
|
||||
const select = row.locator('.admin__status-select')
|
||||
await expect(select).toHaveValue('failed')
|
||||
await select.selectOption('pending_payment')
|
||||
await expect(select).toHaveValue('pending_payment')
|
||||
})
|
||||
|
||||
test('can register shipment for processing order', async ({ page }) => {
|
||||
const row = orderRowByPlate(page, PROCESSING_PLATE)
|
||||
await row.click()
|
||||
await page
|
||||
.locator('.admin__tracking-input')
|
||||
.fill('PN-E2E-FULFILLMENT-001')
|
||||
await page.getByRole('button', { name: 'Registrera utskick' }).click()
|
||||
await expect(row.locator('.admin__status-select')).toHaveValue('sent')
|
||||
await expect(page.locator('.admin__tracking-link')).toBeVisible()
|
||||
})
|
||||
|
||||
test('can mark sent order as delivered', async ({ page }) => {
|
||||
await page.locator('#admin-order-search').fill(SENT_ORDER_SHORT_ID)
|
||||
const row = orderRowByShortId(page, SENT_ORDER_SHORT_ID)
|
||||
const select = row.locator('.admin__status-select')
|
||||
if ((await select.inputValue()) !== 'delivered') {
|
||||
await select.selectOption('delivered')
|
||||
}
|
||||
await expect(select).toHaveValue('delivered')
|
||||
})
|
||||
|
||||
test('can mark delivered order as failed then back to sent when tracking exists', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator('#admin-order-search').fill(SENT_ORDER_SHORT_ID)
|
||||
const row = orderRowByShortId(page, SENT_ORDER_SHORT_ID)
|
||||
const select = row.locator('.admin__status-select')
|
||||
|
||||
await select.selectOption('failed')
|
||||
await expect(select).toHaveValue('failed')
|
||||
|
||||
await select.selectOption('sent')
|
||||
await expect(select).toHaveValue('sent')
|
||||
})
|
||||
})
|
||||
|
|
@ -25,6 +25,22 @@ test.describe('Auth guards', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('redirects unauthenticated user from /andra-losenord to /logga-in', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/andra-losenord')
|
||||
await expect(page).toHaveURL(/\/logga-in\?redirect=\/andra-losenord/)
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('redirects unauthenticated user from /andra-epost to /logga-in', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/andra-epost')
|
||||
await expect(page).toHaveURL(/\/logga-in\?redirect=\/andra-epost/)
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('redirects authenticated user from /logga-in to home', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
|
@ -54,6 +70,13 @@ test.describe('Auth guards', () => {
|
|||
})
|
||||
|
||||
test('allows admin user to access /admin', async ({ page }) => {
|
||||
await page.route('**/api/admin/orders', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '[]',
|
||||
}),
|
||||
)
|
||||
const jwt = makeJwt({ role: 'admin' })
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { loginAsAdmin, openAdminDashboard } from './helpers/admin'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let plateCounter = 0
|
||||
|
||||
function uniquePlate(prefix: string): string {
|
||||
const digits = String((Date.now() % 90) + 10)
|
||||
return `${prefix}${digits}E`
|
||||
plateCounter += 1
|
||||
const digits = String(10 + (plateCounter % 90))
|
||||
const letter = String.fromCharCode(65 + (plateCounter % 26))
|
||||
return `${prefix}${digits}${letter}`
|
||||
}
|
||||
|
||||
test.describe('Deferred payment and admin lookup', () => {
|
||||
const plate = uniquePlate('LAT')
|
||||
let plate = ''
|
||||
const letterText = 'E2E-test: betalar senare från orderhistoriken.'
|
||||
|
||||
let orderId = ''
|
||||
|
|
@ -35,9 +40,26 @@ test.describe('Deferred payment and admin lookup', () => {
|
|||
await page.getByRole('button', { name: 'Ja, jag har betalat' }).click()
|
||||
}
|
||||
|
||||
async function openAdminTodoBoard(page: import('@playwright/test').Page) {
|
||||
await openAdminDashboard(page)
|
||||
await page.getByRole('button', { name: /Att göra/ }).click()
|
||||
await expect(page.locator('.admin__stat--active')).toContainText('Att göra')
|
||||
}
|
||||
|
||||
async function searchAdminOrders(
|
||||
page: import('@playwright/test').Page,
|
||||
query: string,
|
||||
) {
|
||||
const search = page.locator('#admin-order-search')
|
||||
await search.click()
|
||||
await search.fill(query)
|
||||
await expect(search).toHaveValue(query)
|
||||
}
|
||||
|
||||
test('user creates order, leaves payment, and pays later from orders', async ({
|
||||
page,
|
||||
}) => {
|
||||
plate = uniquePlate('LAT')
|
||||
await loginAsTestUser(page)
|
||||
|
||||
await page.goto(`/compose?plate=${plate}`)
|
||||
|
|
@ -59,58 +81,42 @@ test.describe('Deferred payment and admin lookup', () => {
|
|||
const orderCard = page.locator('.orders__card', { hasText: orderId })
|
||||
await expect(orderCard.getByText(plate)).toBeVisible()
|
||||
await expect(orderCard.locator('.badge')).toHaveText('Väntar på betalning')
|
||||
await expect(orderCard.getByRole('link', { name: 'Betala nu' })).toBeVisible()
|
||||
await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).toBeVisible()
|
||||
|
||||
await orderCard.getByRole('link', { name: 'Betala nu' }).click()
|
||||
await orderCard.getByRole('link', { name: 'Betala 49 kr' }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/betalning/${orderId}`))
|
||||
await completeSwishPayment(page)
|
||||
|
||||
await expect(page).toHaveURL('/orders')
|
||||
await expect(orderCard.locator('.badge')).toHaveText('Hanteras')
|
||||
await expect(orderCard.getByRole('link', { name: 'Betala nu' })).not.toBeVisible()
|
||||
await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('admin finds paid order under Att göra when searching partial order id', async ({
|
||||
test('admin finds paid order under Att göra by order id and plate', async ({
|
||||
page,
|
||||
}) => {
|
||||
await loginAsAdmin(page)
|
||||
await page.goto('/admin')
|
||||
|
||||
await page.getByRole('button', { name: /Att göra/ }).click()
|
||||
await page.locator('#admin-order-search').fill(shortOrderId)
|
||||
await openAdminTodoBoard(page)
|
||||
|
||||
await searchAdminOrders(page, shortOrderId)
|
||||
const row = page.locator('.admin__row', { hasText: shortOrderId })
|
||||
await expect(row).toBeVisible()
|
||||
await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId)
|
||||
await expect(row.locator('.admin__plate')).toHaveText(plate)
|
||||
await expect(row).toBeVisible({ timeout: 15_000 })
|
||||
await expect(row).toHaveClass(/admin__row--todo/)
|
||||
})
|
||||
|
||||
test('admin finds paid order when searching full order id', async ({ page }) => {
|
||||
await loginAsAdmin(page)
|
||||
await page.goto('/admin')
|
||||
|
||||
await page.getByRole('button', { name: /Att göra/ }).click()
|
||||
await page.locator('#admin-order-search').fill(orderId)
|
||||
|
||||
const row = page.locator('.admin__row', { hasText: shortOrderId })
|
||||
await expect(row).toBeVisible()
|
||||
await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId)
|
||||
await expect(row.locator('.admin__plate')).toHaveText(plate)
|
||||
})
|
||||
const plateInAdmin = (await row.locator('.admin__plate').textContent())?.trim()
|
||||
expect(plateInAdmin).toBeTruthy()
|
||||
|
||||
test('admin finds paid order when searching registration number', async ({
|
||||
page,
|
||||
}) => {
|
||||
await loginAsAdmin(page)
|
||||
await page.goto('/admin')
|
||||
await searchAdminOrders(page, orderId)
|
||||
await expect(
|
||||
page.locator('.admin__row', { hasText: shortOrderId }),
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Att göra/ }).click()
|
||||
await page.locator('#admin-order-search').fill(plate)
|
||||
|
||||
const row = page.locator('.admin__row', { hasText: shortOrderId })
|
||||
await expect(row).toBeVisible()
|
||||
await expect(row.locator('.admin__plate')).toHaveText(plate)
|
||||
await searchAdminOrders(page, plateInAdmin!)
|
||||
const rowByPlate = page.locator('.admin__row').filter({
|
||||
has: page.locator('.admin__plate', { hasText: plateInAdmin! }),
|
||||
})
|
||||
await expect(rowByPlate).toBeVisible()
|
||||
await expect(rowByPlate.locator('.admin__order-id')).toHaveText(shortOrderId)
|
||||
})
|
||||
|
||||
test('admin does not show unpaid order under Att göra before payment', async ({
|
||||
|
|
@ -130,15 +136,19 @@ test.describe('Deferred payment and admin lookup', () => {
|
|||
|
||||
await page.evaluate(() => localStorage.clear())
|
||||
await loginAsAdmin(page)
|
||||
await page.goto('/admin')
|
||||
await page.getByRole('button', { name: /Att göra/ }).click()
|
||||
await openAdminTodoBoard(page)
|
||||
|
||||
const unpaidRow = page.locator('.admin__row', { hasText: unpaidShortId })
|
||||
await expect(unpaidRow).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Väntar/ }).click()
|
||||
await page.locator('#admin-order-search').fill(unpaidPlate)
|
||||
await expect(page.locator('.admin__stat--active')).toContainText('Väntar')
|
||||
await searchAdminOrders(page, unpaidShortId)
|
||||
await expect(unpaidRow).toBeVisible({ timeout: 15_000 })
|
||||
const plateInAdmin = (await unpaidRow.locator('.admin__plate').textContent())?.trim()
|
||||
expect(plateInAdmin).toBeTruthy()
|
||||
await searchAdminOrders(page, plateInAdmin!)
|
||||
await expect(unpaidRow).toBeVisible()
|
||||
await expect(unpaidRow.locator('.admin__plate')).toHaveText(unpaidPlate)
|
||||
await expect(unpaidRow.locator('.admin__plate')).toHaveText(plateInAdmin!)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
55
frontend/e2e/expired-token.spec.ts
Normal file
55
frontend/e2e/expired-token.spec.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Expired token logout', () => {
|
||||
test('router guard redirects expired token to login and logs out', async ({
|
||||
page,
|
||||
}) => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: past })
|
||||
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
|
||||
await page.goto('/orders')
|
||||
|
||||
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Logga ut' }),
|
||||
).not.toBeVisible()
|
||||
|
||||
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
|
||||
expect(stored).toBeNull()
|
||||
})
|
||||
|
||||
test('API 401 logs out and redirects when guard accepts token but backend rejects it', async ({
|
||||
page,
|
||||
}) => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: future })
|
||||
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
|
||||
await page.goto('/orders')
|
||||
await page.waitForURL(/\/logga-in\?redirect=\/orders/)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(header.getByRole('button', { name: 'Logga ut' })).not.toBeVisible()
|
||||
|
||||
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
|
||||
expect(stored).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
const signature = 'test-sig'
|
||||
return `${header}.${body}.${signature}`
|
||||
}
|
||||
|
|
@ -100,6 +100,13 @@ test.describe('Header auth state', () => {
|
|||
})
|
||||
|
||||
test('logout redirects to home page', async ({ page }) => {
|
||||
await page.route('**/api/orders', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '[]',
|
||||
}),
|
||||
)
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
||||
await page.goto('/orders')
|
||||
await page.evaluate(
|
||||
|
|
@ -143,8 +150,104 @@ test.describe('Header auth state', () => {
|
|||
header.getByRole('link', { name: 'Admin' }),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('shows settings button when authenticated', async ({ page }) => {
|
||||
await authenticateUser(page)
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Inställningar' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('settings menu links to change email and password pages', async ({
|
||||
page,
|
||||
}) => {
|
||||
await authenticateUser(page)
|
||||
|
||||
const header = page.locator('header')
|
||||
const settingsButton = header.getByRole('button', { name: 'Inställningar' })
|
||||
await settingsButton.click()
|
||||
|
||||
const menu = header.getByRole('menu')
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Byt e-postadress' }),
|
||||
).toHaveAttribute('href', '/andra-epost')
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Byt lösenord' }),
|
||||
).toHaveAttribute('href', '/andra-losenord')
|
||||
})
|
||||
|
||||
test('highlights settings button on change password page', async ({
|
||||
page,
|
||||
}) => {
|
||||
await authenticateUser(page)
|
||||
await page.goto('/andra-losenord')
|
||||
|
||||
const settingsButton = page
|
||||
.locator('header')
|
||||
.getByRole('button', { name: 'Inställningar' })
|
||||
await expect(settingsButton).toHaveClass(/app-header__settings-trigger--active/)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Byt lösenord' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('highlights settings button on change email page', async ({ page }) => {
|
||||
await authenticateUser(page)
|
||||
await page.goto('/andra-epost')
|
||||
|
||||
const settingsButton = page
|
||||
.locator('header')
|
||||
.getByRole('button', { name: 'Inställningar' })
|
||||
await expect(settingsButton).toHaveClass(/app-header__settings-trigger--active/)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Byt e-postadress' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Header on mobile viewport', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
test('menu reveals navigation links when authenticated', async ({ page }) => {
|
||||
await authenticateUser(page)
|
||||
await page.goto('/')
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Mina beställningar' }),
|
||||
).not.toBeVisible()
|
||||
|
||||
await header.getByRole('button', { name: 'Öppna meny' }).click()
|
||||
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Mina beställningar' }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Byt e-postadress' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('home page has no horizontal overflow', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const scrollWidth = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth,
|
||||
)
|
||||
const clientWidth = await page.evaluate(
|
||||
() => document.documentElement.clientWidth,
|
||||
)
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1)
|
||||
})
|
||||
})
|
||||
|
||||
async function authenticateUser(page: import('@playwright/test').Page) {
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
await page.goto('/')
|
||||
}
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
|
|
|
|||
15
frontend/e2e/helpers/admin.ts
Normal file
15
frontend/e2e/helpers/admin.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
export async function loginAsAdmin(page: Page) {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('admin@bilhalsning.se')
|
||||
await page.getByLabel('Lösenord').fill('test1234')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await page.waitForURL('/')
|
||||
}
|
||||
|
||||
export async function openAdminDashboard(page: Page) {
|
||||
await page.goto('/admin')
|
||||
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
||||
}
|
||||
|
|
@ -100,6 +100,65 @@ function extractResetToken(body: string, publicBaseUrl?: string): string | null
|
|||
return null
|
||||
}
|
||||
|
||||
export async function waitForEmailChangeToken(
|
||||
request: APIRequestContext,
|
||||
recipientEmail: string,
|
||||
options: { timeoutMs?: number; publicBaseUrl?: string } = {},
|
||||
): Promise<string> {
|
||||
const timeoutMs = options.timeoutMs ?? 20_000
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const normalizedRecipient = recipientEmail.toLowerCase().trim()
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const listResponse = await request.get(`${mailpitApiBase}/api/v1/messages`)
|
||||
if (!listResponse.ok()) {
|
||||
await sleep(500)
|
||||
continue
|
||||
}
|
||||
|
||||
const list = (await listResponse.json()) as MailpitMessagesResponse
|
||||
for (const summary of list.messages ?? []) {
|
||||
const matchesRecipient = summary.To?.some(
|
||||
(to) => to.Address.toLowerCase() === normalizedRecipient,
|
||||
)
|
||||
if (!matchesRecipient) continue
|
||||
|
||||
const detailResponse = await request.get(
|
||||
`${mailpitApiBase}/api/v1/message/${summary.ID}`,
|
||||
)
|
||||
if (!detailResponse.ok()) continue
|
||||
|
||||
const detail = (await detailResponse.json()) as MailpitMessageDetail
|
||||
const body = detail.Text ?? detail.HTML ?? ''
|
||||
const token = extractEmailChangeToken(body, options.publicBaseUrl)
|
||||
if (token) return token
|
||||
}
|
||||
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No email change confirmation for ${recipientEmail} in Mailpit within ${timeoutMs}ms`,
|
||||
)
|
||||
}
|
||||
|
||||
function extractEmailChangeToken(body: string, publicBaseUrl?: string): string | null {
|
||||
const pathPattern = /\/bekrafta-epost\?token=([A-Za-z0-9_-]+)/
|
||||
const pathMatch = body.match(pathPattern)
|
||||
if (pathMatch) return pathMatch[1]
|
||||
|
||||
if (publicBaseUrl) {
|
||||
const escaped = publicBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const fullPattern = new RegExp(
|
||||
`${escaped}/bekrafta-epost\\?token=([A-Za-z0-9_-]+)`,
|
||||
)
|
||||
const fullMatch = body.match(fullPattern)
|
||||
if (fullMatch) return fullMatch[1]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ test.describe('Order history', () => {
|
|||
|
||||
await page.goto('/orders')
|
||||
|
||||
await expect(page.getByText('Skickat')).toBeVisible()
|
||||
await expect(page.getByText('Skickat').first()).toBeVisible()
|
||||
await expect(page.getByText('Väntar på betalning').first()).toBeVisible()
|
||||
await expect(page.getByText('Levererat').first()).toBeVisible()
|
||||
})
|
||||
|
|
@ -66,8 +66,8 @@ test.describe('Order history', () => {
|
|||
await page.goto('/orders')
|
||||
|
||||
const unpaidCard = page.locator('.orders__card', { hasText: 'DEF456' })
|
||||
await expect(unpaidCard.getByRole('link', { name: 'Betala nu' })).toBeVisible()
|
||||
await unpaidCard.getByRole('link', { name: 'Betala nu' }).click()
|
||||
await expect(unpaidCard.getByRole('link', { name: 'Betala 49 kr' })).toBeVisible()
|
||||
await unpaidCard.getByRole('link', { name: 'Betala 49 kr' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/betalning\/c2eebc99/)
|
||||
await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible()
|
||||
|
|
@ -91,4 +91,38 @@ test.describe('Order history', () => {
|
|||
const trackingLink2 = page.getByRole('link', { name: 'PN987654321' })
|
||||
await expect(trackingLink2).toBeVisible()
|
||||
})
|
||||
|
||||
test('can cancel pending order', async ({ page }) => {
|
||||
const plate = 'CAN999'
|
||||
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
||||
await page.getByLabel('Lösenord').fill('test1234')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await page.waitForURL('/')
|
||||
|
||||
await page.goto(`/compose?plate=${plate}`)
|
||||
await page.getByLabel('Ditt meddelande').fill('E2E-test: ska kunna avbrytas.')
|
||||
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
|
||||
await expect(page).toHaveURL(/\/betalning\//)
|
||||
|
||||
await page.goto('/orders')
|
||||
|
||||
const pendingCard = page.locator('.orders__card', { hasText: plate })
|
||||
await expect(pendingCard.getByText('Väntar på betalning')).toBeVisible()
|
||||
await expect(
|
||||
pendingCard.getByRole('link', { name: 'Betala 49 kr' }),
|
||||
).toBeVisible()
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept())
|
||||
await pendingCard.getByRole('button', { name: 'Avbryt beställning' }).click()
|
||||
|
||||
await expect(pendingCard.getByText('Avbruten')).toBeVisible()
|
||||
await expect(
|
||||
pendingCard.getByRole('link', { name: 'Betala 49 kr' }),
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
pendingCard.getByRole('button', { name: 'Avbryt beställning' }),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -49,6 +49,31 @@ test.describe('Payment redirect', () => {
|
|||
|
||||
await page.waitForURL(/\/betalning\//)
|
||||
await expect(page.getByText('Swisha till')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Jag har betalat' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows QR code for desktop scanning', async ({ page }) => {
|
||||
await page.goto('/compose?plate=QRA222')
|
||||
await page.getByLabel('Ditt meddelande').fill('Fin bil!')
|
||||
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
|
||||
|
||||
await page.waitForURL(/\/betalning\//)
|
||||
await expect(page.getByRole('img', { name: 'Swish QR-kod' })).toBeVisible()
|
||||
await expect(page.getByText('Skanna QR-koden')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows Swish payment link with pre-filled data', async ({ page }) => {
|
||||
await page.goto('/compose?plate=MNO345')
|
||||
await page.getByLabel('Ditt meddelande').fill('Hej där!')
|
||||
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
|
||||
|
||||
await page.waitForURL(/\/betalning\//)
|
||||
const swishLink = page.getByRole('link', { name: 'Betala med Swish' })
|
||||
await expect(swishLink).toBeVisible()
|
||||
const href = await swishLink.getAttribute('href')
|
||||
expect(href).toContain('app.swish.nu')
|
||||
expect(href).toContain('amt=49.00')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
356
frontend/package-lock.json
generated
356
frontend/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
|
|
@ -16,6 +17,7 @@
|
|||
"@playwright/test": "^1.60.0",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
|
|
@ -791,9 +793,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -811,9 +810,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -831,9 +827,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -851,9 +844,6 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -871,9 +861,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -891,9 +878,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1054,6 +1038,16 @@
|
|||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
|
||||
|
|
@ -1962,6 +1956,15 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
|
|
@ -1987,11 +1990,91 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
|
|
@ -2004,7 +2087,6 @@
|
|||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
|
|
@ -2136,6 +2218,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
|
|
@ -2160,6 +2251,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
|
|
@ -2718,6 +2815,15 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
|
|
@ -2863,7 +2969,6 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -3285,9 +3390,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3309,9 +3411,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3333,9 +3432,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3357,9 +3453,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3743,6 +3836,15 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
|
|
@ -3787,7 +3889,6 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -3936,6 +4037,15 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.13",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||
|
|
@ -4034,6 +4144,23 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
|
|
@ -4084,6 +4211,15 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
|
|
@ -4094,6 +4230,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
|
|
@ -4208,6 +4350,12 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
|
@ -5094,6 +5242,12 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
|
@ -5236,6 +5390,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
|
|
@ -5251,6 +5411,134 @@
|
|||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
"@playwright/test": "^1.60.0",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,27 @@ export default defineConfig({
|
|||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
testIgnore: [
|
||||
'**/deferred-payment-admin.spec.ts',
|
||||
'**/admin-fulfillment.spec.ts',
|
||||
'**/admin-dashboard.spec.ts',
|
||||
'**/account-settings.spec.ts',
|
||||
'**/password-reset.spec.ts',
|
||||
],
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'chromium-serial',
|
||||
dependencies: ['chromium'],
|
||||
testMatch: [
|
||||
'**/admin-fulfillment.spec.ts',
|
||||
'**/deferred-payment-admin.spec.ts',
|
||||
'**/admin-dashboard.spec.ts',
|
||||
'**/account-settings.spec.ts',
|
||||
'**/password-reset.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,10 +1,44 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import AboutPage from '@/pages/AboutPage.vue'
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/om-oss', name: 'about', component: AboutPage },
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('AboutPage', () => {
|
||||
it('renders heading', () => {
|
||||
const wrapper = mount(AboutPage)
|
||||
it('renders heading and lead', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AboutPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Om Bilhej')
|
||||
expect(wrapper.text()).toContain('Bilhej gör det enkelt')
|
||||
})
|
||||
|
||||
it('renders how-it-works steps', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AboutPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Skriv brevet här')
|
||||
expect(wrapper.text()).toContain('Vi postar åt dig')
|
||||
})
|
||||
|
||||
it('links to home page', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AboutPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
const cta = wrapper.find('a.about__cta-btn')
|
||||
expect(cta.exists()).toBe(true)
|
||||
expect(cta.attributes('href')).toBe('/')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ const mockOrders = [
|
|||
status: 'sent',
|
||||
trackingId: 'PN123456789',
|
||||
amountPaid: 49.0,
|
||||
shippedAt: '2026-05-13T12:00:00Z',
|
||||
adminNotes: null,
|
||||
createdAt: '2026-05-11T12:00:00Z',
|
||||
allowedStatuses: ['sent', 'delivered', 'failed'],
|
||||
canRegisterShipment: true,
|
||||
},
|
||||
{
|
||||
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
|
|
@ -53,7 +57,11 @@ const mockOrders = [
|
|||
status: 'processing',
|
||||
trackingId: null,
|
||||
amountPaid: null,
|
||||
shippedAt: null,
|
||||
adminNotes: null,
|
||||
createdAt: '2026-05-14T13:00:00Z',
|
||||
allowedStatuses: ['processing', 'failed'],
|
||||
canRegisterShipment: true,
|
||||
},
|
||||
{
|
||||
id: 'c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13',
|
||||
|
|
@ -63,16 +71,24 @@ const mockOrders = [
|
|||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
amountPaid: null,
|
||||
shippedAt: null,
|
||||
adminNotes: null,
|
||||
createdAt: '2026-05-15T14:00:00Z',
|
||||
allowedStatuses: ['pending_payment', 'failed'],
|
||||
canRegisterShipment: false,
|
||||
},
|
||||
]
|
||||
|
||||
function freshMockOrders() {
|
||||
return mockOrders.map((order) => ({ ...order }))
|
||||
}
|
||||
|
||||
describe('AdminDashboard', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, mockOrders),
|
||||
mockFetchResponse(200, freshMockOrders()),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -101,10 +117,10 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Datum')
|
||||
expect(wrapper.text()).toContain('Beställnings-ID')
|
||||
expect(wrapper.text()).toContain('ID')
|
||||
expect(wrapper.text()).toContain('E-post')
|
||||
expect(wrapper.text()).toContain('Regnr')
|
||||
expect(wrapper.text()).toContain('Meddelande')
|
||||
expect(wrapper.text()).toContain('Brev')
|
||||
expect(wrapper.text()).toContain('Status')
|
||||
})
|
||||
|
||||
|
|
@ -163,7 +179,7 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
await expandBtns[0].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.find('.admin__expanded-row').exists()).toBe(true)
|
||||
|
|
@ -177,7 +193,7 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
await expandBtns[0].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.findAll('.admin__expanded-row')).toHaveLength(1)
|
||||
|
|
@ -198,15 +214,16 @@ describe('AdminDashboard', () => {
|
|||
|
||||
it('fires status update API on dropdown change', async () => {
|
||||
vi.mocked(globalThis.fetch)
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders()))
|
||||
.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, { ...mockOrders[0], status: 'paid' }),
|
||||
mockFetchResponse(200, { ...mockOrders[0], status: 'delivered' }),
|
||||
)
|
||||
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const selects = wrapper.findAll('.admin__status-select')
|
||||
await selects[0].setValue('delivered')
|
||||
await selects[0].trigger('change')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
|
|
@ -214,14 +231,14 @@ describe('AdminDashboard', () => {
|
|||
'/api/admin/orders/c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11/status',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: '{"status":"sent"}',
|
||||
body: '{"status":"delivered"}',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows status error on failed update', async () => {
|
||||
vi.mocked(globalThis.fetch)
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders()))
|
||||
.mockResolvedValueOnce(
|
||||
mockFetchResponse(500, { message: 'Server error' }),
|
||||
)
|
||||
|
|
@ -230,10 +247,11 @@ describe('AdminDashboard', () => {
|
|||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const selects = wrapper.findAll('.admin__status-select')
|
||||
await selects[0].setValue('delivered')
|
||||
await selects[0].trigger('change')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(wrapper.text()).toContain('Kunde inte uppdatera status')
|
||||
expect(wrapper.text()).toContain('Server error')
|
||||
})
|
||||
|
||||
it('formats dates in Swedish locale', async () => {
|
||||
|
|
@ -247,7 +265,7 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
await expandBtns[0].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
|
|
@ -260,7 +278,7 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
await expandBtns[0].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
|
|
@ -274,7 +292,7 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
await expandBtns[1].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
|
|
@ -282,32 +300,47 @@ describe('AdminDashboard', () => {
|
|||
expect(link.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('fires PATCH on tracking save button click', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
mockFetchResponse(200, mockOrders),
|
||||
)
|
||||
it('fires register-shipment API on register button click', async () => {
|
||||
vi.mocked(globalThis.fetch)
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders()))
|
||||
.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, {
|
||||
...mockOrders[1],
|
||||
status: 'sent',
|
||||
trackingId: 'PN999',
|
||||
}),
|
||||
)
|
||||
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
await expandBtns[1].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await wrapper.find('.admin__tracking-input').setValue('PN999')
|
||||
const registerBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text() === 'Registrera utskick')
|
||||
expect(registerBtn).toBeDefined()
|
||||
await registerBtn!.trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
'/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12/register-shipment',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
trackingInput: 'PN999',
|
||||
notifyCustomer: true,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows tracking error on failed save', async () => {
|
||||
vi.mocked(globalThis.fetch)
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders()))
|
||||
.mockResolvedValueOnce(
|
||||
mockFetchResponse(500, { message: 'Server error' }),
|
||||
)
|
||||
|
|
@ -315,14 +348,18 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
await expandBtns[1].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await wrapper.find('.admin__tracking-input').setValue('PN999')
|
||||
const registerBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text() === 'Registrera utskick')
|
||||
await registerBtn!.trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID')
|
||||
expect(wrapper.text()).toContain('Kunde inte registrera utskick')
|
||||
})
|
||||
|
||||
it('shows Att göra stat for processing orders', async () => {
|
||||
|
|
@ -392,7 +429,10 @@ describe('AdminDashboard', () => {
|
|||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const rows = wrapper.findAll('.admin__row')
|
||||
const processingRow = rows.find((row) => row.text().includes('XYZ789'))
|
||||
const processingRow = rows.find(
|
||||
(row) =>
|
||||
row.text().includes('XYZ789') && row.classes().includes('admin__row'),
|
||||
)
|
||||
expect(processingRow?.classes()).toContain('admin__row--todo')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ function createTestRouter() {
|
|||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/om',
|
||||
path: '/om-oss',
|
||||
name: 'about',
|
||||
component: { template: '<div>About</div>' },
|
||||
},
|
||||
{
|
||||
path: '/om',
|
||||
redirect: '/om-oss',
|
||||
},
|
||||
{
|
||||
path: '/kontakt',
|
||||
name: 'contact',
|
||||
|
|
@ -40,7 +44,7 @@ describe('AppFooter', () => {
|
|||
const links = wrapper.findAll('a')
|
||||
|
||||
expect(links[0].text()).toBe('Om oss')
|
||||
expect(links[0].attributes('href')).toBe('/om')
|
||||
expect(links[0].attributes('href')).toBe('/om-oss')
|
||||
|
||||
expect(links[1].text()).toBe('Kontakt')
|
||||
expect(links[1].attributes('href')).toBe('/kontakt')
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ function createTestRouter() {
|
|||
name: 'change-password',
|
||||
component: { template: '<div>Change password</div>' },
|
||||
},
|
||||
{
|
||||
path: '/andra-epost',
|
||||
name: 'change-email',
|
||||
component: { template: '<div>Change email</div>' },
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
|
|
@ -100,7 +105,7 @@ describe('AppHeader', () => {
|
|||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
expect(wrapper.find('.app-header__logout').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show user email', () => {
|
||||
|
|
@ -142,7 +147,7 @@ describe('AppHeader', () => {
|
|||
|
||||
it('shows logout button', () => {
|
||||
const { wrapper } = mountAuthenticated()
|
||||
const logoutButton = wrapper.find('button')
|
||||
const logoutButton = wrapper.find('.app-header__logout')
|
||||
expect(logoutButton.exists()).toBe(true)
|
||||
expect(logoutButton.text()).toBe('Logga ut')
|
||||
})
|
||||
|
|
@ -171,14 +176,62 @@ describe('AppHeader', () => {
|
|||
expect(ordersLink?.text()).toBe('Mina beställningar')
|
||||
})
|
||||
|
||||
it('shows change password link', () => {
|
||||
it('shows settings menu with account links', async () => {
|
||||
const { wrapper } = mountAuthenticated()
|
||||
const links = wrapper.findAll('a')
|
||||
const changeLink = links.find(
|
||||
(a) => a.attributes('href') === '/andra-losenord',
|
||||
expect(wrapper.findAll('.app-header__settings-item')).toHaveLength(0)
|
||||
|
||||
await wrapper.find('.app-header__settings-trigger').trigger('click')
|
||||
|
||||
const links = wrapper.findAll('.app-header__settings-item')
|
||||
expect(links).toHaveLength(2)
|
||||
expect(links[0].attributes('href')).toBe('/andra-epost')
|
||||
expect(links[0].text()).toBe('Byt e-postadress')
|
||||
expect(links[1].attributes('href')).toBe('/andra-losenord')
|
||||
expect(links[1].text()).toBe('Byt lösenord')
|
||||
})
|
||||
|
||||
it('toggles mobile menu open state when menu button is clicked', async () => {
|
||||
const { wrapper } = mountAuthenticated()
|
||||
|
||||
await wrapper.find('.app-header__menu-toggle').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.classes()).toContain('app-header--menu-open')
|
||||
expect(document.body.classList.contains('nav-menu-open')).toBe(true)
|
||||
expect(wrapper.text()).toContain('Byt e-postadress')
|
||||
})
|
||||
|
||||
it('highlights settings trigger on change password page', async () => {
|
||||
const { wrapper, router } = mountAuthenticated()
|
||||
await router.push('/andra-losenord')
|
||||
await router.isReady()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain(
|
||||
'app-header__settings-trigger--active',
|
||||
)
|
||||
expect(changeLink).toBeTruthy()
|
||||
expect(changeLink?.text()).toBe('Byt lösenord')
|
||||
})
|
||||
|
||||
it('highlights settings trigger on change email page', async () => {
|
||||
const { wrapper, router } = mountAuthenticated()
|
||||
await router.push('/andra-epost')
|
||||
await router.isReady()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain(
|
||||
'app-header__settings-trigger--active',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not highlight settings trigger on other pages', async () => {
|
||||
const { wrapper, router } = mountAuthenticated()
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.find('.app-header__settings-trigger').classes(),
|
||||
).not.toContain('app-header__settings-trigger--active')
|
||||
})
|
||||
|
||||
it('does not show admin link for regular user', () => {
|
||||
|
|
@ -210,7 +263,7 @@ describe('AppHeader', () => {
|
|||
resolve()
|
||||
})
|
||||
})
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.find('.app-header__logout').trigger('click')
|
||||
await navigationDone
|
||||
|
||||
expect(auth.isAuthenticated).toBe(false)
|
||||
|
|
|
|||
57
frontend/src/__tests__/ChangeEmailPage.spec.ts
Normal file
57
frontend/src/__tests__/ChangeEmailPage.spec.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import ChangeEmailPage from '@/pages/ChangeEmailPage.vue'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
return `${header}.${body}.test-sig`
|
||||
}
|
||||
|
||||
describe('ChangeEmailPage', () => {
|
||||
it('renders current email and form fields', () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
localStorage.setItem(
|
||||
'auth_token',
|
||||
makeJwt({ sub: 'test@bilhej.se', role: 'user' }),
|
||||
)
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/andra-epost', component: ChangeEmailPage }],
|
||||
})
|
||||
|
||||
const wrapper = mount(ChangeEmailPage, {
|
||||
global: { plugins: [router, pinia] },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Byt e-postadress')
|
||||
expect(wrapper.text()).toContain('test@bilhej.se')
|
||||
expect(wrapper.find('#new-email').exists()).toBe(true)
|
||||
expect(wrapper.find('#password').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows auth email from store', () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
localStorage.setItem(
|
||||
'auth_token',
|
||||
makeJwt({ sub: 'user@example.com', role: 'user' }),
|
||||
)
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/andra-epost', component: ChangeEmailPage }],
|
||||
})
|
||||
|
||||
mount(ChangeEmailPage, {
|
||||
global: { plugins: [router, pinia] },
|
||||
})
|
||||
|
||||
expect(useAuthStore().email).toBe('user@example.com')
|
||||
})
|
||||
})
|
||||
50
frontend/src/__tests__/ConfirmEmailChangePage.spec.ts
Normal file
50
frontend/src/__tests__/ConfirmEmailChangePage.spec.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import ConfirmEmailChangePage from '@/pages/ConfirmEmailChangePage.vue'
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/bekrafta-epost',
|
||||
name: 'confirm-email-change',
|
||||
component: ConfirmEmailChangePage,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountPage(initialPath: string) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const router = createTestRouter()
|
||||
await router.push(initialPath)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ConfirmEmailChangePage, {
|
||||
global: { plugins: [router, pinia] },
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
describe('ConfirmEmailChangePage', () => {
|
||||
it('shows password form when token is present', async () => {
|
||||
const { wrapper } = await mountPage('/bekrafta-epost?token=test-token')
|
||||
|
||||
expect(wrapper.text()).toContain('Bekräfta e-postadress')
|
||||
expect(wrapper.text()).toContain('Ange ditt lösenord')
|
||||
expect(wrapper.find('#password').exists()).toBe(true)
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe(
|
||||
'Bekräfta ny e-postadress',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error when token is missing', async () => {
|
||||
const { wrapper } = await mountPage('/bekrafta-epost')
|
||||
|
||||
expect(wrapper.text()).toContain('Bekräftelselänken saknar en giltig kod.')
|
||||
})
|
||||
})
|
||||
|
|
@ -3,8 +3,34 @@ import { mount } from '@vue/test-utils'
|
|||
import ContactPage from '@/pages/ContactPage.vue'
|
||||
|
||||
describe('ContactPage', () => {
|
||||
it('renders heading', () => {
|
||||
it('renders heading and lead', () => {
|
||||
const wrapper = mount(ContactPage)
|
||||
expect(wrapper.text()).toContain('Kontakta oss')
|
||||
expect(wrapper.text()).toContain('klagomål')
|
||||
})
|
||||
|
||||
it('renders support email', () => {
|
||||
const wrapper = mount(ContactPage)
|
||||
expect(wrapper.text()).toContain('support@bilhej.se')
|
||||
})
|
||||
|
||||
it('renders general contact email', () => {
|
||||
const wrapper = mount(ContactPage)
|
||||
expect(wrapper.text()).toContain('kontakt@bilhej.se')
|
||||
})
|
||||
|
||||
it('renders complaints email', () => {
|
||||
const wrapper = mount(ContactPage)
|
||||
expect(wrapper.text()).toContain('klagomal@bilhej.se')
|
||||
})
|
||||
|
||||
it('links support to mailto', () => {
|
||||
const wrapper = mount(ContactPage)
|
||||
const link = wrapper.find('a[href="mailto:support@bilhej.se"]')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.text()).toBe('support@bilhej.se')
|
||||
expect(link.attributes('aria-label')).toBe(
|
||||
'Skicka till support: support@bilhej.se',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
149
frontend/src/__tests__/EditOrderPage.spec.ts
Normal file
149
frontend/src/__tests__/EditOrderPage.spec.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import EditOrderPage from '@/pages/EditOrderPage.vue'
|
||||
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||
|
||||
vi.mock('@/api/orders', () => ({
|
||||
fetchOrder: vi.fn(),
|
||||
updateOrder: vi.fn(),
|
||||
}))
|
||||
|
||||
import { fetchOrder, updateOrder } from '@/api/orders'
|
||||
|
||||
const mockFetchOrder = vi.mocked(fetchOrder)
|
||||
const mockUpdateOrder = vi.mocked(updateOrder)
|
||||
|
||||
const pendingOrder = {
|
||||
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
plate: 'DEF456',
|
||||
letterText: 'Vill köpa din bil.',
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
amountPaid: null,
|
||||
createdAt: '2026-05-14T13:00:00Z',
|
||||
}
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/orders',
|
||||
name: 'orders',
|
||||
component: { template: '<div>Orders</div>' },
|
||||
},
|
||||
{
|
||||
path: '/bestallning/:orderId/redigera',
|
||||
name: 'edit-order',
|
||||
component: EditOrderPage,
|
||||
},
|
||||
{
|
||||
path: '/betalning/:orderId',
|
||||
name: 'payment',
|
||||
component: PaymentRedirect,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountPage(orderId = pendingOrder.id) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
const router = createTestRouter()
|
||||
await router.push({ name: 'edit-order', params: { orderId } })
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EditOrderPage, {
|
||||
global: {
|
||||
plugins: [router, pinia],
|
||||
},
|
||||
})
|
||||
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
describe('EditOrderPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchOrder.mockResolvedValue(pendingOrder)
|
||||
mockUpdateOrder.mockResolvedValue(pendingOrder)
|
||||
})
|
||||
|
||||
it('shows loading state while fetching', async () => {
|
||||
mockFetchOrder.mockImplementation(() => new Promise(() => {}))
|
||||
const { wrapper } = await mountPage()
|
||||
expect(wrapper.text()).toContain('Laddar beställning...')
|
||||
})
|
||||
|
||||
it('loads order and pre-fills textarea', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetchOrder).toHaveBeenCalledWith(pendingOrder.id)
|
||||
})
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.element.value).toBe('Vill köpa din bil.')
|
||||
expect(wrapper.text()).toContain('DEF456')
|
||||
expect(wrapper.text()).toContain('Redigera brev')
|
||||
})
|
||||
|
||||
it('shows error when order is not pending_payment', async () => {
|
||||
mockFetchOrder.mockResolvedValue({
|
||||
...pendingOrder,
|
||||
status: 'sent',
|
||||
})
|
||||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain(
|
||||
'Den här beställningen kan inte redigeras',
|
||||
)
|
||||
})
|
||||
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('Tillbaka till beställningar')
|
||||
})
|
||||
|
||||
it('submit calls updateOrder and navigates to payment', async () => {
|
||||
const { wrapper, router } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||
})
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Uppdaterat meddelande')
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
await button.trigger('submit')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockUpdateOrder).toHaveBeenCalledWith(
|
||||
pendingOrder.id,
|
||||
'Uppdaterat meddelande',
|
||||
)
|
||||
expect(router.currentRoute.value.name).toBe('payment')
|
||||
expect(router.currentRoute.value.params.orderId).toBe(pendingOrder.id)
|
||||
expect(router.currentRoute.value.query.plate).toBe('DEF456')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error message on update failure', async () => {
|
||||
mockUpdateOrder.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||
})
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Uppdaterat meddelande')
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
await button.trigger('submit')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Kunde inte spara ändringarna')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -4,6 +4,26 @@ import { createRouter, createMemoryHistory } from 'vue-router'
|
|||
import { createPinia } from 'pinia'
|
||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||
|
||||
const sessionMocks = vi.hoisted(() => {
|
||||
const mockLogout = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
return {
|
||||
mockLogout,
|
||||
mockPush,
|
||||
mockAuth: { isAuthenticated: true, logout: mockLogout },
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => sessionMocks.mockAuth,
|
||||
}))
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
currentRoute: { value: { fullPath: '/orders' } },
|
||||
push: sessionMocks.mockPush,
|
||||
},
|
||||
}))
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
|
|
@ -22,6 +42,11 @@ function createTestRouter() {
|
|||
name: 'payment',
|
||||
component: { template: '<div>Payment</div>' },
|
||||
},
|
||||
{
|
||||
path: '/bestallning/:orderId/redigera',
|
||||
name: 'edit-order',
|
||||
component: { template: '<div>Edit</div>' },
|
||||
},
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
],
|
||||
})
|
||||
|
|
@ -58,13 +83,39 @@ const mockOrders = [
|
|||
},
|
||||
]
|
||||
|
||||
function mockOrdersFetch(orders: unknown) {
|
||||
vi.mocked(globalThis.fetch).mockImplementation((url, init) => {
|
||||
const urlStr = String(url)
|
||||
const method = init?.method ?? 'GET'
|
||||
|
||||
if (urlStr.includes('/payment/swish-info')) {
|
||||
return mockFetchResponse(200, { number: '1234567890', amount: 49 })
|
||||
}
|
||||
|
||||
if (urlStr.includes('/cancel') && method === 'POST') {
|
||||
return mockFetchResponse(200, {
|
||||
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
plate: 'DEF456',
|
||||
letterText: 'Vill köpa din bil.',
|
||||
status: 'cancelled',
|
||||
trackingId: null,
|
||||
createdAt: '2026-05-14T13:00:00Z',
|
||||
})
|
||||
}
|
||||
|
||||
if (urlStr.includes('/orders')) {
|
||||
return mockFetchResponse(200, orders)
|
||||
}
|
||||
|
||||
return mockFetchResponse(404, { message: 'Not found' })
|
||||
})
|
||||
}
|
||||
|
||||
describe('OrdersPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, mockOrders),
|
||||
)
|
||||
mockOrdersFetch(mockOrders)
|
||||
})
|
||||
|
||||
it('renders heading and subtitle', async () => {
|
||||
|
|
@ -82,6 +133,13 @@ describe('OrdersPage', () => {
|
|||
expect(wrapper.text()).toContain('Laddar beställningar...')
|
||||
})
|
||||
|
||||
it('shows section headings when pending and completed orders exist', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Obetalda beställningar')
|
||||
expect(wrapper.text()).toContain('Tidigare beställningar')
|
||||
})
|
||||
|
||||
it('fetches orders from API on mount', async () => {
|
||||
mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
|
@ -110,7 +168,9 @@ describe('OrdersPage', () => {
|
|||
await new Promise((r) => setTimeout(r, 50))
|
||||
const link = wrapper.find('a[href*="postnord"]')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.classes()).toContain('orders__tracking-btn')
|
||||
expect(link.text()).toContain('PN123456789')
|
||||
expect(link.text()).toContain('Spåra brev')
|
||||
expect(link.attributes('target')).toBe('_blank')
|
||||
})
|
||||
|
||||
|
|
@ -125,9 +185,7 @@ describe('OrdersPage', () => {
|
|||
createdAt: '2026-05-14T13:00:00Z',
|
||||
},
|
||||
]
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, ordersWithoutTracking),
|
||||
)
|
||||
mockOrdersFetch(ordersWithoutTracking)
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const link = wrapper.find('a[href*="postnord"]')
|
||||
|
|
@ -137,9 +195,8 @@ describe('OrdersPage', () => {
|
|||
it('renders order id and message', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Beställnings-ID')
|
||||
expect(wrapper.text()).toContain('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11')
|
||||
expect(wrapper.text()).toContain('Meddelande')
|
||||
expect(wrapper.text()).toContain('Beställnings-ID')
|
||||
expect(wrapper.text()).toContain('Hej fin bil!')
|
||||
expect(wrapper.text()).toContain('Vill köpa din bil.')
|
||||
})
|
||||
|
|
@ -148,19 +205,24 @@ describe('OrdersPage', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('2026')
|
||||
expect(wrapper.text()).toContain('Skapad')
|
||||
})
|
||||
|
||||
it('shows empty state when no orders', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, []))
|
||||
mockOrdersFetch([])
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Inga beställningar ännu')
|
||||
})
|
||||
|
||||
it('shows error state on API failure', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(500, { message: 'Internal server error' }),
|
||||
)
|
||||
vi.mocked(globalThis.fetch).mockImplementation((url) => {
|
||||
const urlStr = String(url)
|
||||
if (urlStr.includes('/payment/swish-info')) {
|
||||
return mockFetchResponse(200, { number: '1234567890', amount: 49 })
|
||||
}
|
||||
return mockFetchResponse(500, { message: 'Internal server error' })
|
||||
})
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Kunde inte hämta beställningar')
|
||||
|
|
@ -170,19 +232,35 @@ describe('OrdersPage', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const badges = wrapper.findAll('.badge')
|
||||
expect(badges[0].classes()).toContain('badge--success')
|
||||
expect(badges[1].classes()).toContain('badge--muted')
|
||||
expect(badges[0].classes()).toContain('badge--warning')
|
||||
expect(badges[1].classes()).toContain('badge--success')
|
||||
})
|
||||
|
||||
it('shows order id on pending payment orders', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const pendingCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('DEF456'))
|
||||
expect(pendingCard?.text()).toContain('Beställnings-ID')
|
||||
expect(pendingCard?.text()).toContain(
|
||||
'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows pay button only for pending payment orders', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const payLinks = wrapper.findAll('.orders__pay-btn')
|
||||
expect(payLinks).toHaveLength(1)
|
||||
expect(payLinks[0].text()).toBe('Betala nu')
|
||||
const pendingCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('DEF456'))
|
||||
const payLink = pendingCard?.find('a.orders__pay-btn')
|
||||
expect(payLink?.exists()).toBe(true)
|
||||
expect(payLink?.text()).toBe('Betala 49 kr')
|
||||
|
||||
const href = payLinks[0].attributes('href')
|
||||
const href = payLink?.attributes('href')
|
||||
expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12')
|
||||
expect(href).toContain('plate=DEF456')
|
||||
})
|
||||
|
|
@ -194,7 +272,71 @@ describe('OrdersPage', () => {
|
|||
const sentCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('ABC123'))
|
||||
expect(sentCard?.find('.orders__pay-btn').exists()).toBe(false)
|
||||
expect(sentCard?.find('a.orders__pay-btn').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows edit link for pending payment orders', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const pendingCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('DEF456'))
|
||||
const editLink = pendingCard?.find('a.orders__edit-btn')
|
||||
expect(editLink?.exists()).toBe(true)
|
||||
expect(editLink?.text()).toBe('Redigera brev')
|
||||
|
||||
const href = editLink?.attributes('href')
|
||||
expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12')
|
||||
expect(href).toContain('redigera')
|
||||
})
|
||||
|
||||
it('shows cancel button for pending payment orders', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const pendingCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('DEF456'))
|
||||
const cancelBtn = pendingCard?.find('.orders__cancel-btn')
|
||||
expect(cancelBtn?.exists()).toBe(true)
|
||||
expect(cancelBtn?.text()).toBe('Avbryt beställning')
|
||||
})
|
||||
|
||||
it('calls cancel API and updates status to Avbruten', async () => {
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => true),
|
||||
)
|
||||
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const pendingCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('DEF456'))
|
||||
await pendingCard?.find('.orders__cancel-btn').trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12/cancel',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
expect(wrapper.text()).toContain('Avbruten')
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not show edit or cancel actions for non-pending orders', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const sentCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('ABC123'))
|
||||
expect(sentCard?.find('.orders__cancel-btn').exists()).toBe(false)
|
||||
expect(sentCard?.text()).not.toContain('Redigera brev')
|
||||
expect(sentCard?.text()).not.toContain('Avbryt beställning')
|
||||
})
|
||||
|
||||
it('renders processing status correctly', async () => {
|
||||
|
|
@ -208,13 +350,80 @@ describe('OrdersPage', () => {
|
|||
createdAt: '2026-05-15T10:00:00Z',
|
||||
},
|
||||
]
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, ordersWithProcessing),
|
||||
)
|
||||
mockOrdersFetch(ordersWithProcessing)
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Hanteras')
|
||||
const badge = wrapper.find('.badge')
|
||||
expect(badge.classes()).toContain('badge--primary')
|
||||
})
|
||||
|
||||
it('shows expand toggle for long messages and reveals full text', async () => {
|
||||
const longText =
|
||||
'Hej! Jag ville nämna en situation i trafiken där vi båda kanske blev lite frustrerade. Det är lätt att det blir så när man kör bil i rusningstid och tempot blir högt.'
|
||||
const ordersWithLongMessage = [
|
||||
{
|
||||
id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||
plate: 'ABC123',
|
||||
letterText: longText,
|
||||
status: 'processing',
|
||||
trackingId: null,
|
||||
createdAt: '2026-05-11T12:00:00Z',
|
||||
},
|
||||
]
|
||||
mockOrdersFetch(ordersWithLongMessage)
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const card = wrapper.find('.orders__card')
|
||||
const preview = card.find('.orders__preview')
|
||||
const toggle = card.find('.orders__preview-toggle')
|
||||
|
||||
expect(toggle.exists()).toBe(true)
|
||||
expect(toggle.text()).toBe('Visa mer')
|
||||
expect(preview.classes()).not.toContain('orders__preview--expanded')
|
||||
|
||||
await toggle.trigger('click')
|
||||
|
||||
expect(preview.classes()).toContain('orders__preview--expanded')
|
||||
expect(toggle.text()).toBe('Visa mindre')
|
||||
expect(card.text()).toContain(longText)
|
||||
})
|
||||
|
||||
it('does not show expand toggle for short messages', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OrdersPage — expired session (401)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
sessionMocks.mockLogout.mockClear()
|
||||
sessionMocks.mockPush.mockClear()
|
||||
sessionMocks.mockAuth.isAuthenticated = true
|
||||
})
|
||||
|
||||
it('does not show generic error and triggers global logout/redirect on 401', async () => {
|
||||
vi.mocked(globalThis.fetch).mockImplementation((url) => {
|
||||
const urlStr = String(url)
|
||||
if (urlStr.includes('/payment/swish-info')) {
|
||||
return mockFetchResponse(200, { number: '123', amount: 49 })
|
||||
}
|
||||
return mockFetchResponse(401, { message: 'Din session har löpt ut.' })
|
||||
})
|
||||
localStorage.setItem('auth_token', 'expired-token')
|
||||
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(wrapper.text()).not.toContain('Kunde inte hämta beställningar')
|
||||
expect(sessionMocks.mockLogout).toHaveBeenCalledTimes(1)
|
||||
expect(sessionMocks.mockPush).toHaveBeenCalledWith({
|
||||
name: 'login',
|
||||
query: { redirect: '/orders' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,14 +5,26 @@ import { createRouter, createMemoryHistory } from 'vue-router'
|
|||
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,mock-qr'),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/payment', () => ({
|
||||
payOrder: vi.fn(),
|
||||
fetchSwishInfo: vi.fn(),
|
||||
buildSwishPaymentUrl: vi.fn(
|
||||
(number: string, amount: number, message: string) =>
|
||||
`https://app.swish.nu/1/p/sw/?sw=${number}&amt=${amount.toFixed(2)}&msg=${message}`,
|
||||
),
|
||||
}))
|
||||
|
||||
import { payOrder, fetchSwishInfo } from '@/api/payment'
|
||||
import QRCode from 'qrcode'
|
||||
const mockPayOrder = vi.mocked(payOrder)
|
||||
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
|
||||
const mockToDataURL = vi.mocked(QRCode.toDataURL)
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
|
|
@ -59,6 +71,7 @@ describe('PaymentRedirect', () => {
|
|||
number: '0701234567',
|
||||
amount: 49,
|
||||
})
|
||||
mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
|
||||
})
|
||||
|
||||
it('renders heading and amount', async () => {
|
||||
|
|
@ -81,7 +94,7 @@ describe('PaymentRedirect', () => {
|
|||
expect(wrapper.text()).toContain('Beställnings-ID')
|
||||
expect(wrapper.text()).toContain(orderId)
|
||||
expect(wrapper.text()).toContain(
|
||||
'Ange beställnings-ID ovan som meddelande i Swish-appen',
|
||||
'fylls i automatiskt via QR-kod eller länk',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -93,13 +106,30 @@ describe('PaymentRedirect', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('renders QR code after fetching swish info', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
|
||||
})
|
||||
expect(mockToDataURL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders a Swish payment link', async () => {
|
||||
const { wrapper } = await mountPage('test-order', 'ABC123')
|
||||
await vi.waitFor(() => {
|
||||
const link = wrapper.find('.payment__swish-link')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.attributes('href')).toContain('app.swish.nu')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation dialog after clicking pay button', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
|
||||
expect(wrapper.text()).toContain('0701234567')
|
||||
|
|
@ -110,15 +140,15 @@ describe('PaymentRedirect', () => {
|
|||
it('can cancel confirmation dialog', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Avbryt')
|
||||
})
|
||||
|
||||
await wrapper.find('.btn--ghost').trigger('click')
|
||||
await wrapper.find('.payment__confirm-cancel').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Swisha till')
|
||||
expect(wrapper.text()).not.toContain('Avbryt')
|
||||
|
|
@ -137,16 +167,15 @@ describe('PaymentRedirect', () => {
|
|||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
||||
})
|
||||
|
||||
const confirmButtons = wrapper.findAll('.btn--primary')
|
||||
await confirmButtons[confirmButtons.length - 1].trigger('click')
|
||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||
|
||||
expect(mockPayOrder).toHaveBeenCalledWith('order-1')
|
||||
})
|
||||
|
|
@ -156,16 +185,15 @@ describe('PaymentRedirect', () => {
|
|||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
||||
})
|
||||
|
||||
const confirmButtons = wrapper.findAll('.btn--primary')
|
||||
await confirmButtons[confirmButtons.length - 1].trigger('click')
|
||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen')
|
||||
|
|
@ -184,16 +212,15 @@ describe('PaymentRedirect', () => {
|
|||
|
||||
const { wrapper, router } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
||||
})
|
||||
|
||||
const confirmButtons = wrapper.findAll('.btn--primary')
|
||||
await confirmButtons[confirmButtons.length - 1].trigger('click')
|
||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(router.currentRoute.value.name).toBe('orders')
|
||||
|
|
|
|||
64
frontend/src/__tests__/PrivacyPolicyPage.spec.ts
Normal file
64
frontend/src/__tests__/PrivacyPolicyPage.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import PrivacyPolicyPage from '@/pages/PrivacyPolicyPage.vue'
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/integritetspolicy',
|
||||
name: 'privacy',
|
||||
component: PrivacyPolicyPage,
|
||||
},
|
||||
{
|
||||
path: '/kontakt',
|
||||
name: 'contact',
|
||||
component: { template: '<div>Kontakt</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('PrivacyPolicyPage', () => {
|
||||
it('renders title and lead', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(PrivacyPolicyPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Integritetspolicy')
|
||||
expect(wrapper.text()).toContain('personuppgifter')
|
||||
})
|
||||
|
||||
it('describes sender and recipient data handling', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(PrivacyPolicyPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Mottagarens postadress')
|
||||
expect(wrapper.text()).toContain('sparas inte efter utskick')
|
||||
expect(wrapper.text()).toContain('varken vi eller obehöriga')
|
||||
})
|
||||
|
||||
it('describes web analytics', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(PrivacyPolicyPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Webbstatistik')
|
||||
expect(wrapper.text()).toContain('analytics.bilhej.se')
|
||||
expect(wrapper.text()).toContain('IP-adresser')
|
||||
})
|
||||
|
||||
it('links to contact email and contact page', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(PrivacyPolicyPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.find('a[href="mailto:kontakt@bilhej.se"]').exists()).toBe(
|
||||
true,
|
||||
)
|
||||
expect(wrapper.find('a.policy__link').attributes('href')).toBe('/kontakt')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import router from '@/router'
|
||||
import router, { scrollBehavior } from '@/router'
|
||||
|
||||
describe('Router', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -8,6 +8,25 @@ describe('Router', () => {
|
|||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('scrolls to top on route change without hash', () => {
|
||||
const position = scrollBehavior(
|
||||
{ hash: '' } as Parameters<typeof scrollBehavior>[0],
|
||||
{ hash: '' } as Parameters<typeof scrollBehavior>[1],
|
||||
null,
|
||||
)
|
||||
expect(position).toEqual({ top: 0, left: 0 })
|
||||
})
|
||||
|
||||
it('restores saved position when using browser back', () => {
|
||||
const saved = { top: 120, left: 0 }
|
||||
const position = scrollBehavior(
|
||||
{ hash: '' } as Parameters<typeof scrollBehavior>[0],
|
||||
{ hash: '' } as Parameters<typeof scrollBehavior>[1],
|
||||
saved,
|
||||
)
|
||||
expect(position).toBe(saved)
|
||||
})
|
||||
|
||||
it('resolves / to HomePage', async () => {
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
|
@ -32,6 +51,18 @@ describe('Router', () => {
|
|||
expect(router.currentRoute.value.name).toBe('forgot-password')
|
||||
})
|
||||
|
||||
it('resolves /integritetspolicy to PrivacyPolicyPage', async () => {
|
||||
await router.push('/integritetspolicy')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('privacy')
|
||||
})
|
||||
|
||||
it('resolves /villkor to TermsOfServicePage', async () => {
|
||||
await router.push('/villkor')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('terms')
|
||||
})
|
||||
|
||||
it('resolves /aterstall-losenord to ResetPasswordPage', async () => {
|
||||
await router.push('/aterstall-losenord?token=abc')
|
||||
await router.isReady()
|
||||
|
|
@ -52,6 +83,13 @@ describe('Router', () => {
|
|||
expect(router.currentRoute.value.name).toBe('change-password')
|
||||
})
|
||||
|
||||
it('resolves /andra-epost to ChangeEmailPage when authenticated', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
await router.push('/andra-epost')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('change-email')
|
||||
})
|
||||
|
||||
it('resolves /admin to AdminPage for admin user', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
|
||||
await router.push('/admin')
|
||||
|
|
@ -93,6 +131,19 @@ describe('Router guards', () => {
|
|||
expect(router.currentRoute.value.query.redirect).toBe('/andra-losenord')
|
||||
})
|
||||
|
||||
it('redirects unauthenticated user from /andra-epost to /logga-in', async () => {
|
||||
await router.push('/andra-epost')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/andra-epost')
|
||||
})
|
||||
|
||||
it('resolves /bekrafta-epost to ConfirmEmailChangePage', async () => {
|
||||
await router.push('/bekrafta-epost?token=abc')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('confirm-email-change')
|
||||
})
|
||||
|
||||
it('redirects unauthenticated user from /admin to /logga-in', async () => {
|
||||
await router.push('/admin')
|
||||
await router.isReady()
|
||||
|
|
@ -163,6 +214,64 @@ describe('Router guards', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Router guards — expired tokens', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('redirects expired-token user from /orders to /logga-in with redirect query', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/orders')
|
||||
})
|
||||
|
||||
it('clears the expired token from localStorage on redirect', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows access with a token whose exp is in the future', async () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
|
||||
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('orders')
|
||||
})
|
||||
|
||||
it('lets expired-token user open /logga-in instead of bouncing to home', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/logga-in')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('lets expired-token user open /registrera instead of bouncing to home', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/registrera')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('register')
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
|
|
|
|||
58
frontend/src/__tests__/TermsOfServicePage.spec.ts
Normal file
58
frontend/src/__tests__/TermsOfServicePage.spec.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import TermsOfServicePage from '@/pages/TermsOfServicePage.vue'
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/villkor',
|
||||
name: 'terms',
|
||||
component: TermsOfServicePage,
|
||||
},
|
||||
{
|
||||
path: '/integritetspolicy',
|
||||
name: 'privacy',
|
||||
component: { template: '<div>Integritet</div>' },
|
||||
},
|
||||
{
|
||||
path: '/kontakt',
|
||||
name: 'contact',
|
||||
component: { template: '<div>Kontakt</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('TermsOfServicePage', () => {
|
||||
it('renders title and lead', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(TermsOfServicePage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Användarvillkor')
|
||||
expect(wrapper.text()).toContain('49 kr')
|
||||
})
|
||||
|
||||
it('describes payment and order rules', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(TermsOfServicePage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Swish')
|
||||
expect(wrapper.text()).toContain('Obetalda beställningar kan redigeras')
|
||||
})
|
||||
|
||||
it('links to privacy policy and support email', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(TermsOfServicePage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.find('a[href="/integritetspolicy"]').exists()).toBe(true)
|
||||
expect(wrapper.find('a[href="mailto:support@bilhej.se"]').exists()).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -212,6 +212,52 @@ describe('authStore', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('authStore.isTokenExpired', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns true when there is no token', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.isTokenExpired()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for a token with an expired exp claim', () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a token with a future exp claim', () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a token without an exp claim', () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true after logout clears the token', async () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
|
||||
const store = useAuthStore()
|
||||
expect(store.isTokenExpired()).toBe(false)
|
||||
|
||||
store.logout()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
|
|
|
|||
125
frontend/src/__tests__/client.spec.ts
Normal file
125
frontend/src/__tests__/client.spec.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const mockLogout = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
return {
|
||||
mockLogout,
|
||||
mockPush,
|
||||
mockAuth: { isAuthenticated: true, logout: mockLogout },
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => mocks.mockAuth,
|
||||
}))
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
currentRoute: { value: { fullPath: '/orders' } },
|
||||
push: mocks.mockPush,
|
||||
},
|
||||
}))
|
||||
|
||||
import { request, ApiError, isSessionExpired, isForbidden } from '@/api/client'
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('api client', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
mocks.mockLogout.mockClear()
|
||||
mocks.mockPush.mockClear()
|
||||
mocks.mockAuth.isAuthenticated = true
|
||||
})
|
||||
|
||||
it('logs out and redirects to login on 401 from a protected endpoint', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
|
||||
)
|
||||
localStorage.setItem('auth_token', 'expired-token')
|
||||
|
||||
await expect(request('/orders')).rejects.toThrow('Din session har löpt ut.')
|
||||
|
||||
expect(mocks.mockLogout).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.mockPush).toHaveBeenCalledWith({
|
||||
name: 'login',
|
||||
query: { redirect: '/orders' },
|
||||
})
|
||||
})
|
||||
|
||||
it('still throws ApiError with 401 status after handling expired session', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
|
||||
)
|
||||
|
||||
try {
|
||||
await request('/orders')
|
||||
throw new Error('should have thrown')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect((err as ApiError).status).toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not log out on 401 from an auth endpoint (wrong credentials)', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
|
||||
)
|
||||
|
||||
await expect(request('/auth/login', { method: 'POST' })).rejects.toThrow(
|
||||
'Felaktig e-post eller lösenord',
|
||||
)
|
||||
|
||||
expect(mocks.mockLogout).not.toHaveBeenCalled()
|
||||
expect(mocks.mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not log out on 403 (forbidden is not session expiry)', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(403, { message: 'Du har inte behörighet' }),
|
||||
)
|
||||
localStorage.setItem('auth_token', 'valid-token')
|
||||
|
||||
await expect(request('/admin/orders')).rejects.toThrow(
|
||||
'Du har inte behörighet',
|
||||
)
|
||||
|
||||
expect(mocks.mockLogout).not.toHaveBeenCalled()
|
||||
expect(mocks.mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not redirect when there is no token on 401', async () => {
|
||||
mocks.mockAuth.isAuthenticated = false
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
|
||||
)
|
||||
|
||||
await expect(request('/orders')).rejects.toThrow()
|
||||
|
||||
expect(mocks.mockLogout).not.toHaveBeenCalled()
|
||||
expect(mocks.mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isSessionExpired returns true only for a 401 ApiError', () => {
|
||||
expect(isSessionExpired(new ApiError(401, 'x'))).toBe(true)
|
||||
expect(isSessionExpired(new ApiError(403, 'x'))).toBe(false)
|
||||
expect(isSessionExpired(new ApiError(500, 'x'))).toBe(false)
|
||||
expect(isSessionExpired(new Error('x'))).toBe(false)
|
||||
expect(isSessionExpired(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('isForbidden returns true only for a 403 ApiError', () => {
|
||||
expect(isForbidden(new ApiError(403, 'x'))).toBe(true)
|
||||
expect(isForbidden(new ApiError(401, 'x'))).toBe(false)
|
||||
expect(isForbidden(new Error('x'))).toBe(false)
|
||||
})
|
||||
})
|
||||
72
frontend/src/__tests__/umami.spec.ts
Normal file
72
frontend/src/__tests__/umami.spec.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import {
|
||||
getUmamiConfig,
|
||||
initUmamiAnalytics,
|
||||
trackUmamiPageview,
|
||||
} from '@/utils/umami'
|
||||
|
||||
describe('umami', () => {
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = ''
|
||||
delete window.umami
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('returns null when website id is unset', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '')
|
||||
expect(getUmamiConfig()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns config when website id is set', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '11111111-2222-3333-4444-555555555555')
|
||||
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', '')
|
||||
expect(getUmamiConfig()).toEqual({
|
||||
websiteId: '11111111-2222-3333-4444-555555555555',
|
||||
scriptUrl: 'https://analytics.bilhej.se/script.js',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses custom script url when provided', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'test-id')
|
||||
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://example.test/script.js')
|
||||
expect(getUmamiConfig()?.scriptUrl).toBe('https://example.test/script.js')
|
||||
})
|
||||
|
||||
it('does not inject script when website id is unset', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '')
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
})
|
||||
initUmamiAnalytics(router)
|
||||
expect(document.querySelector('script[data-website-id]')).toBeNull()
|
||||
})
|
||||
|
||||
it('injects script with auto-track disabled when configured', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'test-id')
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
})
|
||||
initUmamiAnalytics(router)
|
||||
const script = document.querySelector('script[data-website-id]')
|
||||
expect(script?.getAttribute('data-website-id')).toBe('test-id')
|
||||
expect(script?.getAttribute('data-auto-track')).toBe('false')
|
||||
expect(script?.getAttribute('src')).toContain('script.js')
|
||||
})
|
||||
|
||||
it('trackUmamiPageview forwards url to umami', () => {
|
||||
const track = vi.fn()
|
||||
window.umami = { track }
|
||||
trackUmamiPageview('/orders')
|
||||
expect(track).toHaveBeenCalledOnce()
|
||||
const mapper = track.mock.calls[0][0] as (
|
||||
props: Record<string, unknown>,
|
||||
) => Record<string, unknown>
|
||||
expect(mapper({ referrer: 'x' })).toEqual({ referrer: 'x', url: '/orders' })
|
||||
})
|
||||
})
|
||||
|
|
@ -8,7 +8,11 @@ export interface AdminOrder {
|
|||
status: string
|
||||
trackingId: string | null
|
||||
amountPaid: number | null
|
||||
shippedAt: string | null
|
||||
adminNotes: string | null
|
||||
createdAt: string
|
||||
allowedStatuses: string[]
|
||||
canRegisterShipment: boolean
|
||||
}
|
||||
|
||||
export function fetchAllOrders(): Promise<AdminOrder[]> {
|
||||
|
|
@ -25,12 +29,23 @@ export function updateOrderStatus(
|
|||
})
|
||||
}
|
||||
|
||||
export function updateTracking(
|
||||
export function registerShipment(
|
||||
orderId: string,
|
||||
trackingId: string | null,
|
||||
trackingInput: string,
|
||||
notifyCustomer = true,
|
||||
): Promise<AdminOrder> {
|
||||
return request<AdminOrder>(`/admin/orders/${orderId}`, {
|
||||
return request<AdminOrder>(`/admin/orders/${orderId}/register-shipment`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ trackingId }),
|
||||
body: JSON.stringify({ trackingInput, notifyCustomer }),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAdminNotes(
|
||||
orderId: string,
|
||||
adminNotes: string | null,
|
||||
): Promise<AdminOrder> {
|
||||
return request<AdminOrder>(`/admin/orders/${orderId}/notes`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ adminNotes }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ export interface MessageResponse {
|
|||
message: string
|
||||
}
|
||||
|
||||
/** Optional testToken is returned only when backend expose-token is enabled (E2E). */
|
||||
export interface ChangeEmailResponse extends MessageResponse {
|
||||
testToken?: string
|
||||
}
|
||||
|
||||
/** Optional testToken is returned only when backend expose-token is enabled (E2E). */
|
||||
export interface ForgotPasswordResponse extends MessageResponse {
|
||||
testToken?: string
|
||||
|
|
@ -56,3 +61,23 @@ export function changePassword(
|
|||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
})
|
||||
}
|
||||
|
||||
export function changeEmail(
|
||||
newEmail: string,
|
||||
password: string,
|
||||
): Promise<ChangeEmailResponse> {
|
||||
return request<ChangeEmailResponse>('/auth/change-email', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ newEmail, password }),
|
||||
})
|
||||
}
|
||||
|
||||
export function confirmEmailChange(
|
||||
token: string,
|
||||
password: string,
|
||||
): Promise<AuthResponse> {
|
||||
return request<AuthResponse>('/auth/confirm-email-change', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { useAuthStore } from '@/stores/authStore'
|
||||
import router from '@/router'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
|
@ -10,10 +13,27 @@ export class ApiError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export function isSessionExpired(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.status === 401
|
||||
}
|
||||
|
||||
export function isForbidden(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.status === 403
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('auth_token')
|
||||
}
|
||||
|
||||
function handleExpiredSession(): void {
|
||||
const auth = useAuthStore()
|
||||
if (auth.isAuthenticated) {
|
||||
auth.logout()
|
||||
const redirect = router.currentRoute.value.fullPath
|
||||
router.push({ name: 'login', query: { redirect } })
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
|
|
@ -34,6 +54,9 @@ export async function request<T>(
|
|||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && !url.startsWith('/auth/')) {
|
||||
handleExpiredSession()
|
||||
}
|
||||
const body = await response.json().catch(() => ({}))
|
||||
throw new ApiError(response.status, body.message || 'Något gick fel')
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue