Compare commits
9 commits
afe70125f1
...
2ef093ba2b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ef093ba2b | ||
|
|
d16f048630 | ||
|
|
8ff71f8e32 | ||
|
|
33ffc8851d | ||
|
|
6381b6fd63 | ||
| 4e25badba6 | |||
|
|
d768b11add | ||
|
|
f849f8a05a | ||
|
|
573153b47a |
10 changed files with 882 additions and 9 deletions
|
|
@ -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...';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
185
frontend/src/__tests__/GuestCheckoutPage.spec.ts
Normal file
185
frontend/src/__tests__/GuestCheckoutPage.spec.ts
Normal 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.',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
191
frontend/src/__tests__/GuestOrderPage.spec.ts
Normal file
191
frontend/src/__tests__/GuestOrderPage.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
307
frontend/src/__tests__/GuestPaymentRedirect.spec.ts
Normal file
307
frontend/src/__tests__/GuestPaymentRedirect.spec.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
104
frontend/src/__tests__/guestOrders.spec.ts
Normal file
104
frontend/src/__tests__/guestOrders.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
52
frontend/src/__tests__/payment.spec.ts
Normal file
52
frontend/src/__tests__/payment.spec.ts
Normal 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/?',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue