Compare commits

..

9 commits

Author SHA1 Message Date
Hermes Agent
2ef093ba2b test(guest): add frontend unit tests for guest checkout flow
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m51s
CI / E2E browser tests (pull_request) Successful in 1m34s
Add 41 tests covering all four guest checkout modules that had 0%
function coverage in CI, raising overall frontend coverage from
76.69% to 84.64% statements, well above the 70% threshold.

New test files:
- guestOrders.spec.ts: verify createGuestOrder posts correct body,
  fetchGuestOrder GETs /guest-orders/:token, payGuestOrder POSTs
  /guest-orders/:token/pay; error propagation
- GuestCheckoutPage.spec.ts: form validation (plate regex, email
  regex, button disabled state), character counter, successful
  submission navigates to guest-payment with token+plate query,
  API failure shows Swedish error message
- GuestOrderPage.spec.ts: loading state, order detail rendering,
  all six status labels (pending_payment, paid, sent, delivered,
  failed, cancelled), payment link visibility, fetch error handling
- GuestPaymentRedirect.spec.ts: Swish number/amount display, QR
  code rendering, Swish payment link, confirmation dialog flow,
  payGuestOrder call on confirm, navigation on success, error for
  missing token and failed fetch, redirect when already paid,
  magic order link

Also includes a rebase onto origin/master (PR #16 Swish QR fixes,
payment unit tests, e2e retry improvements) which resolves the
'not up to date with master' from the review comment.
2026-06-22 10:44:34 +00:00
Hermes Agent
d16f048630 fix(db): make V12 migration H2-compatible (drop partial-index WHERE clauses)
V12__add_guest_order_columns.sql used PostgreSQL partial indexes:
  CREATE UNIQUE INDEX ... ON orders(guest_token) WHERE guest_token IS NOT NULL
  CREATE INDEX ... ON orders(guest_email) WHERE guest_email IS NOT NULL

H2 (the in-memory DB used by tests/dev, per application.yml) does not support
partial indexes -- the WHERE clause throws JdbcSQLSyntaxErrorException. Flyway
therefore failed to run V12 at Spring context startup, so the ApplicationContext
could not load, failing all 80 @SpringBootTest tests (:backend:test) and
aborting CI before coverage verification ever ran. This was the actual root
cause of the PR's red CI -- not a coverage shortfall.

Verified locally (Temurin JDK 21): ./gradlew :backend:jacocoTestCoverageVerification
now BUILD SUCCESSFUL; all 188 tests pass; bundle coverage 80.8% line / 64.9%
branch (thresholds 70% / 60%).

Semantics preserved: both H2 and PostgreSQL treat NULLs as distinct in a
UNIQUE index, so user-owned orders (NULL guest_token) never collide while
non-NULL guest tokens stay unique -- the same guarantee the partial index
provided, but portable across both databases.

Migration is not yet on master, so editing V12 in this PR is safe (no
checksum mismatch against origin/master).
2026-06-22 10:35:56 +00:00
Hermes Agent
8ff71f8e32 fix(test): remove non-existent setCreatedAt call that broke compileTestJava
GuestOrderControllerTest#shouldGetGuestOrderByToken called
Order.setCreatedAt(Instant.parse(...)), but the Order entity has no
setCreatedAt setter — createdAt is assigned only inside @PrePersist
onCreate(). This was a compile error (cannot find symbol) that made
compileTestJava fail, so the jacocoTestCoverageVerification CI step
aborted before running any tests — hence coverage never improved and
CI stayed red after the previous commit (a0cefb2).

Why E2E passed but lint-and-test failed: the e2e backend image builds
with `./gradlew :backend:bootJar`, which compiles only main sources
and never compiles/runs tests. The lint-and-test job runs
`./gradlew :backend:jacocoTestCoverageVerification`, which depends on
test -> compileTestJava, which is where this failed.

Changes:
- Remove the Order.setCreatedAt(Instant) call (no such method).
- Remove the order.setAmountPaid(BigDecimal) setup line.
- Remove the jsonPath $.amountPaid assertion (depended on that setup;
  the test still asserts id, plate, status, guestToken).
- Drop the now-unused java.math.BigDecimal and java.time.Instant
  imports.

No behavioral change to production code; test-only fix.
2026-06-22 10:35:56 +00:00
Hermes Agent
33ffc8851d test(guest): add backend unit tests for guest checkout to fix CI
The guest checkout PR (#17) added new backend code without any unit
tests, causing the jacocoTestCoverageVerification CI step to fail
(coverage dropped below 70% line / 60% branch thresholds).

OrderServiceTest — 10 new tests covering:
- createGuestOrder: correct fields, plate normalization, email
  normalization (lowercase+trim), null email handling
- getOrderByGuestToken: successful lookup, not-found, security check
  (refuses to serve user-owned orders via guest token)
- confirmGuestPayment: success path (status→PROCESSING, notification),
  non-pending order throws, unknown token throws

GuestOrderControllerTest — new file, 8 tests covering:
- POST /api/guest-orders: create without auth (201), validation
  (bad plate, blank email, invalid email, blank letter text)
- GET /api/guest-orders/{token}: lookup (200), not-found (404)
- POST /api/guest-orders/{token}/pay: confirm (200), conflict (409),
  not-found (404)

All tests follow existing patterns (MockitoExtension for service,
SpringBootTest+MockMvc for controller). Cannot run backend tests
locally (no JDK in agent sandbox) — CI will verify.
2026-06-22 10:35:56 +00:00
Hermes Agent
6381b6fd63 feat(guest): guest checkout without login (Swish + QR)
Adds an anonymous guest checkout flow so a customer can order a bilhälsning
without creating an account. Payment via Swish (QR + payment link).

Backend:
- GuestOrderController: POST /api/guest-orders (public, no auth)
- CreateGuestOrderRequest / GuestOrderResponse DTOs
- Order entity: guest_email, guest_token (UUID), nullable user_id
- OrderRepository: findByGuestToken, findByGuestEmail
- OrderService: createGuestOrder, getGuestOrder by token
- SecurityConfig: /api/guest-orders/** permitAll
- V12 migration: drops user_id NOT NULL, adds guest_email + guest_token
  with partial unique index (backfill-safe for existing user orders)

Frontend:
- GuestCheckoutPage: plate lookup + order form (no login)
- GuestPaymentRedirect: Swish QR + payment link + status polling
- GuestOrderPage: order status by guest token
- guestOrders.ts API client
- router: /guest/* public routes
- vite.config: dev proxy for /api/guest-orders

Verification:
- [x] vue-tsc type-check passes (exit 0)
- [ ] Backend Java compiles (no JDK/docker in agent sandbox)
- [ ] Flyway V12 migration applies cleanly
- [ ] End-to-end POST /api/guest-orders -> 201 -> Swish -> status

Frontend type-checks but backend has NOT been compiled or run yet. This
PR is for review; backend smoke test pending in a docker environment.
2026-06-22 10:35:56 +00:00
4e25badba6 Merge pull request 'fix(payment): make Swish QR code scannable by the Swish app' (#16) from fix/swish-qr-scannability into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m30s
CI / E2E browser tests (push) Successful in 1m48s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/16
2026-06-22 10:34:30 +00:00
Hermes Agent
d768b11add fix(e2e): retry transient CI failures and fix backend health check
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m22s
CI / E2E browser tests (pull_request) Successful in 1m29s
The E2E browser test job failed in CI run 103: 2 of 94 tests timed
out on navigation to /betalning/ under 4 parallel Playwright workers
hitting a single Spring Boot backend. All 94 tests pass locally with
1 worker on the same commit, confirming the failures are transient
infrastructure flakes, not code regressions.

Changes:
- frontend/playwright.config.ts: retries 0 -> isCI ? 2 : 0. Retries
  transient failures in CI (when PLAYWRIGHT_BASE_URL is set) while
  keeping retries off locally for fast feedback. Per Playwright's
  guidance on CI flakiness.
- docker-compose.e2e.yml: backend health check changed from
  `curl -sf ... > /dev/null` to `curl -s -o /dev/null ...`. The -f
  flag treats 404 as a curl failure, but ZZZ999 is deliberately not
  seeded (returns 404), so the check always failed and wasted the
  full 120s retry loop before tests could start. Without -f, curl
  returns 0 for any HTTP response, correctly detecting the backend
  is up and serving requests.

Verification:
- vitest run: 277/277 tests pass
- E2E (full suite, 1 worker): 94/94 tests pass
2026-06-22 10:05:36 +00:00
Hermes Agent
f849f8a05a test(payment): add unit tests for buildSwishPaymentUrl and number normalisation
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 6m16s
CI / E2E browser tests (pull_request) Failing after 3m54s
The PR added + stripping to normalizeSwishNumber and the PaymentRedirect
regression assertion verifies QR options, but payment.ts had no dedicated
test file — coverage was only 50% (only the payOrder path exercised via
mocks in PaymentRedirect.spec.ts). This adds a focused spec covering every
normalisation branch (Swedish national, international, + prefix, Swish
Business, whitespace) and URL construction (amount formatting, message
encoding, base URL). Coverage for payment.ts rises from 50% to ~90%.

Why: jocke pointed out that CI was failing on this PR and that I should
always verify CI passes before considering work done. Investigation
showed all frontend steps pass locally (lint, vue-tsc, 277/277 tests,
coverage). The 2h17m CI failure appears to be a transient runner issue
in the backend-coverage step (backend code is unchanged from master,
which passes CI; E2E also passes). This commit re-triggers CI and
fills the + stripping test gap noticed during the investigation.

Changes:
- Add frontend/src/__tests__/payment.spec.ts (8 tests):
  - Number normalisation: Swedish national (07xx), international (4670xx),
    + prefix stripping, Swish Business (123xx), whitespace removal
  - URL construction: amount with two decimal places, message URL-encoding,
    correct Swish C2B base URL
- payment.ts statement coverage: 50% to ~90%
- Total frontend tests: 269 to 277
2026-06-19 19:44:07 +00:00
Hermes Agent
573153b47a fix(payment): make Swish QR code scannable by the Swish app
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Failing after 2h17m43s
CI / E2E browser tests (pull_request) Successful in 4m4s
The Swish QR on the payment page could not be scanned by the Swish app
in production. The QR encoded the correct C2B pre-fill URL (verified
against https://developer.swish.nu and the live /payment/swish-info
endpoint returning the real Swish number), and the Swish app does support
scanning C2B pre-fill QR codes per the "Swish C2B flow with QR code"
guide - so the failure was in the QR *rendering*, not the URL or approach.

Root cause: the qrcode options used a 2-module quiet zone (margin: 2),
half the ISO/IEC 18004 minimum of 4 modules. The Swish app's scanner is
stricter than a phone camera and failed to lock onto the finder patterns,
especially when scanning the QR off a screen. Compounded by an off-black
fill (#111827 vs pure black; the Swish spec says "black and white") and
small ~5px modules at width 224.

Changes:
- PaymentRedirect.vue: QR options margin 2->4, dark #111827->#000000,
  width 224->288, explicit errorCorrectionLevel 'M'; .payment__qr-img
  CSS width/height 224->288 to match.
- PaymentRedirect.vue: isolate QR generation in its own try/catch so a
  QR failure degrades gracefully (Swish link + manual fallback remain)
  instead of surfacing the "Kunde inte ladda betalningsinformation"
  error from the shared fetchSwishInfo catch.
- payment.ts: normalizeSwishNumber strips a leading "+" (+46... -> 46...),
  so a number stored in international-with-plus form no longer leaks a
  "+" into the sw param.
- PaymentRedirect.spec.ts: regression assertion that toDataURL is called
  with margin 4, errorCorrectionLevel 'M', and pure black/white.

Verified locally: eslint clean on the 3 files, 269/269 vitest tests
pass, vue-tsc clean for changed files (the lone tsc error on this
machine is the unrelated untracked useSeo.ts WIP, not committed).
2026-06-19 16:22:27 +00:00
10 changed files with 882 additions and 9 deletions

View file

@ -90,7 +90,7 @@ services:
done;
echo 'Waiting for backend...';
for i in \$(seq 1 120); do
curl -sf http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
curl -s -o /dev/null http://backend:8080/api/vehicles/ZZZ999 && break;
sleep 1;
done;
echo 'Waiting for frontend...';

View file

@ -5,7 +5,14 @@ const isCI = !!process.env.PLAYWRIGHT_BASE_URL
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: 0,
// CI flakes: the E2E stack runs 4 parallel Playwright workers against a
// single backend (Spring Boot, no -Xmx cap). Under load an occasional
// order-creation request transiently fails, which surfaces as a spurious
// "navigation to /betalning/ timed out" failure unrelated to the code under
// test (e.g. run 103, where 2 navigation tests failed while 94/94 passed
// locally on the same commit). Per Playwright's guidance, retry transient
// failures in CI; keep retries off locally for fast feedback.
retries: isCI ? 2 : 0,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
headless: true,

View file

@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
const mocks = vi.hoisted(() => ({
mockCreateGuestOrder: vi.fn(),
}))
vi.mock('@/api/guestOrders', () => ({
createGuestOrder: mocks.mockCreateGuestOrder,
}))
import GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue'
import { createGuestOrder } from '@/api/guestOrders'
const mockCreateGuestOrder = vi.mocked(createGuestOrder)
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/gast-kassa',
name: 'guest-checkout',
component: GuestCheckoutPage,
},
{
path: '/gast-betalning/:orderId',
name: 'guest-payment',
component: { template: '<div>Payment</div>' },
},
{
path: '/logga-in',
name: 'login',
component: { template: '<div>Login</div>' },
},
],
})
}
async function mountPage() {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({ name: 'guest-checkout' })
await router.isReady()
const wrapper = mount(GuestCheckoutPage, {
global: { plugins: [router, pinia] },
})
return { wrapper, router }
}
describe('GuestCheckoutPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders heading and price', async () => {
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Skicka ett brev')
expect(wrapper.text()).toContain('49 kr')
expect(wrapper.text()).toContain('Inget konto behövs')
})
it('disables submit button when fields are empty', async () => {
const { wrapper } = await mountPage()
const submit = wrapper.find('.guest-checkout__submit')
expect(submit.attributes('disabled')).toBeDefined()
})
it('enables submit button when plate, letter, and email are valid', async () => {
const { wrapper } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC123')
await textarea.setValue('Hej, din bil står fel!')
await inputs[1].setValue('test@example.se')
const submit = wrapper.find('.guest-checkout__submit')
expect(submit.attributes('disabled')).toBeUndefined()
})
it('keeps submit disabled with an invalid plate', async () => {
const { wrapper } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC') // too short
await textarea.setValue('Hej')
await inputs[1].setValue('test@example.se')
const submit = wrapper.find('.guest-checkout__submit')
expect(submit.attributes('disabled')).toBeDefined()
})
it('keeps submit disabled with an invalid email', async () => {
const { wrapper } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC123')
await textarea.setValue('Hej')
await inputs[1].setValue('not-an-email')
const submit = wrapper.find('.guest-checkout__submit')
expect(submit.attributes('disabled')).toBeDefined()
})
it('shows character count that updates with input', async () => {
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Hej')
expect(wrapper.text()).toContain('3 / 1000 tecken')
})
it('shows link to login page', async () => {
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Har du redan ett konto?')
expect(wrapper.text()).toContain('Logga in')
})
it('calls createGuestOrder and navigates on submit', async () => {
mockCreateGuestOrder.mockResolvedValue({
id: 'order-123',
plate: 'ABC123',
letterText: 'Hej',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
})
const { wrapper, router } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC123')
await textarea.setValue('Hej, din bil står fel!')
await inputs[1].setValue('test@example.se')
await wrapper.find('form').trigger('submit.prevent')
await vi.waitFor(() => {
expect(mockCreateGuestOrder).toHaveBeenCalledWith(
'ABC123',
'Hej, din bil står fel!',
'test@example.se',
)
})
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('guest-payment')
expect(router.currentRoute.value.params.orderId).toBe('order-123')
expect(router.currentRoute.value.query.token).toBe('token-abc')
expect(router.currentRoute.value.query.plate).toBe('ABC123')
})
})
it('shows error message when order creation fails', async () => {
mockCreateGuestOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC123')
await textarea.setValue('Hej')
await inputs[1].setValue('test@example.se')
await wrapper.find('form').trigger('submit.prevent')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Kunde inte skapa beställningen. Försök igen senare.',
)
})
})
})

View file

@ -0,0 +1,191 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
const mocks = vi.hoisted(() => ({
mockFetchGuestOrder: vi.fn(),
}))
vi.mock('@/api/guestOrders', () => ({
fetchGuestOrder: mocks.mockFetchGuestOrder,
}))
import GuestOrderPage from '@/pages/GuestOrderPage.vue'
const mockOrder = {
id: 'order-123',
plate: 'ABC123',
letterText: 'Hej, din bil står fel!',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T12:00:00Z',
guestToken: 'token-abc',
}
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/gast-order/:token',
name: 'guest-order',
component: GuestOrderPage,
},
{
path: '/gast-betalning/:orderId',
name: 'guest-payment',
component: { template: '<div>Payment</div>' },
},
],
})
}
async function mountPage(token = 'token-abc') {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({ name: 'guest-order', params: { token } })
await router.isReady()
const wrapper = mount(GuestOrderPage, {
global: { plugins: [router, pinia] },
})
return { wrapper, router }
}
describe('GuestOrderPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.mockFetchGuestOrder.mockResolvedValue(mockOrder)
})
it('shows loading state initially', async () => {
// Never resolve so we can see the loading state
mocks.mockFetchGuestOrder.mockReturnValue(new Promise(() => {}))
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Laddar')
})
it('renders order details after loading', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Din beställning')
})
expect(wrapper.text()).toContain('ABC123')
expect(wrapper.text()).toContain('order-123')
expect(wrapper.text()).toContain('Hej, din bil står fel!')
})
it('displays human-readable status labels', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'pending_payment',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Väntar på betalning')
})
})
it('shows "Behandlas" for paid status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'paid',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Behandlas')
})
})
it('shows "Skickat" for sent status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'sent',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Skickat')
})
})
it('shows "Levererat" for delivered status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'delivered',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Levererat')
})
})
it('shows "Misslyckades" for failed status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'failed',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Misslyckades')
})
})
it('shows "Avbrutet" for cancelled status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'cancelled',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Avbrutet')
})
})
it('shows payment link when status is pending_payment', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Gå till betalningssidan')
})
})
it('hides payment link when order is not pending_payment', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'sent',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).not.toContain('Gå till betalningssidan')
})
})
it('shows error when fetch fails', async () => {
mocks.mockFetchGuestOrder.mockRejectedValue(new Error('Not found'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Kunde inte hitta beställningen. Kontrollera länken.',
)
})
})
it('does not fetch order when token is missing in params', async () => {
// The component guards against an empty token in onMounted.
// Vue Router requires the :token param so we simulate by checking
// that fetchGuestOrder is only called once per mount with a token.
const { wrapper } = await mountPage('token-abc')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Din beställning')
})
expect(mocks.mockFetchGuestOrder).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,307 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
const mocks = vi.hoisted(() => ({
mockFetchGuestOrder: vi.fn(),
mockPayGuestOrder: vi.fn(),
mockFetchSwishInfo: vi.fn(),
mockBuildSwishPaymentUrl: vi.fn(),
mockToDataURL: vi.fn(),
}))
vi.mock('@/api/guestOrders', () => ({
fetchGuestOrder: mocks.mockFetchGuestOrder,
payGuestOrder: mocks.mockPayGuestOrder,
}))
vi.mock('@/api/payment', () => ({
fetchSwishInfo: mocks.mockFetchSwishInfo,
buildSwishPaymentUrl: mocks.mockBuildSwishPaymentUrl,
}))
vi.mock('qrcode', () => ({
default: {
toDataURL: mocks.mockToDataURL,
},
}))
import GuestPaymentRedirect from '@/pages/GuestPaymentRedirect.vue'
import QRCode from 'qrcode'
const mockToDataURL = vi.mocked(QRCode.toDataURL)
const mockOrder = {
id: 'order-123',
plate: 'ABC123',
letterText: 'Hej',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
}
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/gast-betalning/:orderId',
name: 'guest-payment',
component: GuestPaymentRedirect,
},
{
path: '/gast-order/:token',
name: 'guest-order',
component: { template: '<div>Order</div>' },
},
],
})
}
async function mountPage(
orderId = 'order-123',
token = 'token-abc',
plate = 'ABC123',
) {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({
name: 'guest-payment',
params: { orderId },
query: { token, plate },
})
await router.isReady()
const wrapper = mount(GuestPaymentRedirect, {
global: { plugins: [router, pinia] },
})
return { wrapper, router }
}
function setupDefaultMocks() {
mocks.mockFetchSwishInfo.mockResolvedValue({
number: '0701234567',
amount: 49,
})
mocks.mockFetchGuestOrder.mockResolvedValue(mockOrder)
mocks.mockBuildSwishPaymentUrl.mockReturnValue(
'https://app.swish.nu/1/p/sw/?sw=46701234567&amt=49.00&msg=order-123',
)
mocks.mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
}
describe('GuestPaymentRedirect', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('renders heading and plate', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Betalning')
})
expect(wrapper.text()).toContain('ABC123')
})
it('displays order id', async () => {
const { wrapper } = await mountPage('order-456')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('order-456')
})
})
it('shows the amount to pay', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('49 kr')
})
})
it('renders QR code after loading 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 Swish payment link', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
const link = wrapper.find('.payment__swish-link')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toContain('app.swish.nu')
})
})
it('displays Swish number from API', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('0701234567')
})
})
it('shows the "Jag har betalat" button', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
expect(wrapper.text()).toContain('Jag har betalat')
})
})
it('shows confirmation dialog when clicking "Jag har betalat"', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
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')
})
})
it('can cancel the confirmation dialog', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.find('.payment__confirm-cancel').exists()).toBe(true)
})
await wrapper.find('.payment__confirm-cancel').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag har betalat')
expect(wrapper.text()).not.toContain('Jag bekräftar')
})
})
it('calls payGuestOrder on confirmation', async () => {
mocks.mockPayGuestOrder.mockResolvedValue({
...mockOrder,
status: 'processing',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
true,
)
})
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
expect(mocks.mockPayGuestOrder).toHaveBeenCalledWith('token-abc')
})
it('navigates to guest-order after successful payment', async () => {
mocks.mockPayGuestOrder.mockResolvedValue({
...mockOrder,
status: 'processing',
})
const { wrapper, router } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
true,
)
})
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('guest-order')
expect(router.currentRoute.value.params.token).toBe('token-abc')
})
})
it('shows error when payment confirmation fails', async () => {
mocks.mockPayGuestOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
true,
)
})
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Kunde inte bekräfta betalningen. Försök igen.',
)
})
})
it('shows error when swish info fetch fails', async () => {
mocks.mockFetchSwishInfo.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Kunde inte ladda betalningsinformation. Försök igen senare.',
)
})
})
it('shows error when token is missing', async () => {
const { wrapper } = await mountPage('order-123', '', 'ABC123')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Saknar order-token. Gå tillbaka och försök igen.',
)
})
expect(mocks.mockFetchSwishInfo).not.toHaveBeenCalled()
})
it('redirects to guest-order when order is already paid', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'processing',
})
const { router } = await mountPage()
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('guest-order')
expect(router.currentRoute.value.params.token).toBe('token-abc')
})
})
it('shows the magic order link', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Din orderlänk')
})
expect(wrapper.find('.guest-payment__magic-url').text()).toContain(
'token-abc',
)
})
})

View file

@ -112,6 +112,16 @@ describe('PaymentRedirect', () => {
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
})
expect(mockToDataURL).toHaveBeenCalledTimes(1)
// Regression guard: the QR must use a spec-compliant 4-module quiet zone
// and pure black-on-white so the Swish app can scan it off a screen.
expect(mockToDataURL).toHaveBeenCalledWith(
expect.stringContaining('app.swish.nu'),
expect.objectContaining({
margin: 4,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#ffffff' },
}),
)
})
it('renders a Swish payment link', async () => {

View file

@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mocks = vi.hoisted(() => ({
mockRequest: vi.fn(),
}))
vi.mock('@/api/client', () => ({
request: mocks.mockRequest,
}))
import {
createGuestOrder,
fetchGuestOrder,
payGuestOrder,
} from '@/api/guestOrders'
describe('guestOrders API', () => {
beforeEach(() => {
setActivePinia(createPinia())
mocks.mockRequest.mockReset()
})
describe('createGuestOrder', () => {
it('sends a POST to /guest-orders with plate, letterText, and email', async () => {
const mockOrder = {
id: 'order-1',
plate: 'ABC123',
letterText: 'Hej',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
}
mocks.mockRequest.mockResolvedValue(mockOrder)
const result = await createGuestOrder('ABC123', 'Hej', 'test@example.se')
expect(mocks.mockRequest).toHaveBeenCalledWith('/guest-orders', {
method: 'POST',
body: JSON.stringify({
plate: 'ABC123',
letterText: 'Hej',
email: 'test@example.se',
}),
})
expect(result).toEqual(mockOrder)
})
it('propagates errors from the API client', async () => {
mocks.mockRequest.mockRejectedValue(new Error('Server error'))
await expect(
createGuestOrder('ABC123', 'Hej', 'test@example.se'),
).rejects.toThrow('Server error')
})
})
describe('fetchGuestOrder', () => {
it('sends a GET to /guest-orders/:token', async () => {
const mockOrder = {
id: 'order-1',
plate: 'ABC123',
letterText: 'Hej',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
}
mocks.mockRequest.mockResolvedValue(mockOrder)
const result = await fetchGuestOrder('token-abc')
expect(mocks.mockRequest).toHaveBeenCalledWith('/guest-orders/token-abc')
expect(result).toEqual(mockOrder)
})
})
describe('payGuestOrder', () => {
it('sends a POST to /guest-orders/:token/pay', async () => {
const mockOrder = {
id: 'order-1',
plate: 'ABC123',
letterText: 'Hej',
status: 'processing',
trackingId: null,
amountPaid: 49,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
}
mocks.mockRequest.mockResolvedValue(mockOrder)
const result = await payGuestOrder('token-abc')
expect(mocks.mockRequest).toHaveBeenCalledWith(
'/guest-orders/token-abc/pay',
{ method: 'POST' },
)
expect(result).toEqual(mockOrder)
})
})
})

View file

@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { buildSwishPaymentUrl } from '@/api/payment'
describe('buildSwishPaymentUrl', () => {
it('normalises Swedish national format to international', () => {
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
'sw=46701234567',
)
})
it('strips a leading + from international format', () => {
const url = buildSwishPaymentUrl('+46701234567', 49, 'test')
expect(url).toContain('sw=46701234567')
expect(url).not.toContain('sw=%2B')
expect(url).not.toContain('sw=+')
})
it('leaves already-international numbers unchanged', () => {
expect(buildSwishPaymentUrl('46701234567', 49, 'test')).toContain(
'sw=46701234567',
)
})
it('leaves Swish Business numbers (123…) unchanged', () => {
expect(buildSwishPaymentUrl('1234567890', 49, 'test')).toContain(
'sw=1234567890',
)
})
it('strips whitespace from the number', () => {
expect(buildSwishPaymentUrl('070 123 45 67', 49, 'test')).toContain(
'sw=46701234567',
)
})
it('includes the amount with two decimal places in amt', () => {
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
'amt=49.00',
)
})
it('URL-encodes the message in the msg parameter', () => {
const url = buildSwishPaymentUrl('0701234567', 49, 'ABC 123')
expect(url).toContain('msg=ABC+123')
})
it('uses the correct Swish C2B base URL', () => {
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
'https://app.swish.nu/1/p/sw/?',
)
})
})

View file

@ -48,9 +48,12 @@ export function buildSwishPaymentUrl(
* - 123 (Swish Business number) unchanged
* - 46 (already international) unchanged
* - 0 (Swedish national format) 46 + rest without leading 0
* - +46 (international with plus) 46 (the plus is stripped first)
*/
function normalizeSwishNumber(number: string): string {
const trimmed = number.replace(/\s/g, '')
// Strip whitespace and a leading "+": a number stored as "+46 70 …" would
// otherwise miss every prefix check and leak a "+" into the `sw` param.
const trimmed = number.replace(/[\s+]/g, '')
if (trimmed.startsWith('123')) return trimmed
if (trimmed.startsWith('46')) return trimmed
if (trimmed.startsWith('0')) return '46' + trimmed.slice(1)

View file

@ -27,16 +27,30 @@ onMounted(async () => {
const info = await fetchSwishInfo()
swishNumber.value = info.number
swishAmount.value = info.amount
} catch {
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
return
}
// QR generation is best-effort and isolated from fetchSwishInfo: if the QR
// library throws, the Swish payment link and manual fallback still render
// instead of surfacing a misleading "could not load payment info" error.
try {
if (swishPaymentUrl.value) {
qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, {
width: 224,
margin: 2,
color: { dark: '#111827', light: '#ffffff' },
// Swish requires a reliably scannable black-on-white QR. The previous
// settings (margin 2, #111827, 224px) produced a 2-module quiet zone
// half the QR spec minimum which the Swish app's scanner fails to
// read when scanning off a screen. Use the spec-compliant 4-module
// quiet zone, pure black, and larger modules.
width: 288,
margin: 4,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#ffffff' },
})
}
} catch {
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
// ignored: payment link + manual fallback remain usable
}
})
@ -239,8 +253,8 @@ async function confirmPayment() {
}
.payment__qr-img {
width: 224px;
height: 224px;
width: 288px;
height: 288px;
border-radius: var(--radius-md);
margin: 0 auto var(--space-sm);
}