feat(guest): guest checkout without login (Swish + QR) #17

Merged
jocke merged 5 commits from feature/guest-checkout into master 2026-06-22 11:16:16 +00:00
4 changed files with 787 additions and 0 deletions
Showing only changes of commit 2ef093ba2b - Show all commits

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

@ -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)
})
})
})