Compare commits
4 commits
2ef093ba2b
...
afe70125f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe70125f1 | ||
|
|
be069aa92c | ||
|
|
a0cefb2646 | ||
|
|
08fcbba580 |
10 changed files with 9 additions and 882 deletions
|
|
@ -90,7 +90,7 @@ services:
|
||||||
done;
|
done;
|
||||||
echo 'Waiting for backend...';
|
echo 'Waiting for backend...';
|
||||||
for i in \$(seq 1 120); do
|
for i in \$(seq 1 120); do
|
||||||
curl -s -o /dev/null http://backend:8080/api/vehicles/ZZZ999 && break;
|
curl -sf http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
|
||||||
sleep 1;
|
sleep 1;
|
||||||
done;
|
done;
|
||||||
echo 'Waiting for frontend...';
|
echo 'Waiting for frontend...';
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,7 @@ const isCI = !!process.env.PLAYWRIGHT_BASE_URL
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
// CI flakes: the E2E stack runs 4 parallel Playwright workers against a
|
retries: 0,
|
||||||
// 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: {
|
use: {
|
||||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||||
headless: true,
|
headless: true,
|
||||||
|
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
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.',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
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,16 +112,6 @@ describe('PaymentRedirect', () => {
|
||||||
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
|
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
expect(mockToDataURL).toHaveBeenCalledTimes(1)
|
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 () => {
|
it('renders a Swish payment link', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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,12 +48,9 @@ export function buildSwishPaymentUrl(
|
||||||
* - 123… (Swish Business number) → unchanged
|
* - 123… (Swish Business number) → unchanged
|
||||||
* - 46… (already international) → unchanged
|
* - 46… (already international) → unchanged
|
||||||
* - 0… (Swedish national format) → 46 + rest without leading 0
|
* - 0… (Swedish national format) → 46 + rest without leading 0
|
||||||
* - +46… (international with plus) → 46… (the plus is stripped first)
|
|
||||||
*/
|
*/
|
||||||
function normalizeSwishNumber(number: string): string {
|
function normalizeSwishNumber(number: string): string {
|
||||||
// Strip whitespace and a leading "+": a number stored as "+46 70 …" would
|
const trimmed = number.replace(/\s/g, '')
|
||||||
// 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('123')) return trimmed
|
||||||
if (trimmed.startsWith('46')) return trimmed
|
if (trimmed.startsWith('46')) return trimmed
|
||||||
if (trimmed.startsWith('0')) return '46' + trimmed.slice(1)
|
if (trimmed.startsWith('0')) return '46' + trimmed.slice(1)
|
||||||
|
|
|
||||||
|
|
@ -27,30 +27,16 @@ onMounted(async () => {
|
||||||
const info = await fetchSwishInfo()
|
const info = await fetchSwishInfo()
|
||||||
swishNumber.value = info.number
|
swishNumber.value = info.number
|
||||||
swishAmount.value = info.amount
|
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) {
|
if (swishPaymentUrl.value) {
|
||||||
qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, {
|
qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, {
|
||||||
// Swish requires a reliably scannable black-on-white QR. The previous
|
width: 224,
|
||||||
// settings (margin 2, #111827, 224px) produced a 2-module quiet zone
|
margin: 2,
|
||||||
// — half the QR spec minimum — which the Swish app's scanner fails to
|
color: { dark: '#111827', light: '#ffffff' },
|
||||||
// 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 {
|
} catch {
|
||||||
// ignored: payment link + manual fallback remain usable
|
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -253,8 +239,8 @@ async function confirmPayment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
.payment__qr-img {
|
.payment__qr-img {
|
||||||
width: 288px;
|
width: 224px;
|
||||||
height: 288px;
|
height: 224px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin: 0 auto var(--space-sm);
|
margin: 0 auto var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue