diff --git a/frontend/src/__tests__/GuestCheckoutPage.spec.ts b/frontend/src/__tests__/GuestCheckoutPage.spec.ts new file mode 100644 index 0000000..0bce7d2 --- /dev/null +++ b/frontend/src/__tests__/GuestCheckoutPage.spec.ts @@ -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: '
Home
' } }, + { + path: '/gast-kassa', + name: 'guest-checkout', + component: GuestCheckoutPage, + }, + { + path: '/gast-betalning/:orderId', + name: 'guest-payment', + component: { template: '
Payment
' }, + }, + { + path: '/logga-in', + name: 'login', + component: { template: '
Login
' }, + }, + ], + }) +} + +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.', + ) + }) + }) +}) diff --git a/frontend/src/__tests__/GuestOrderPage.spec.ts b/frontend/src/__tests__/GuestOrderPage.spec.ts new file mode 100644 index 0000000..d5f62b6 --- /dev/null +++ b/frontend/src/__tests__/GuestOrderPage.spec.ts @@ -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: '
Home
' } }, + { + path: '/gast-order/:token', + name: 'guest-order', + component: GuestOrderPage, + }, + { + path: '/gast-betalning/:orderId', + name: 'guest-payment', + component: { template: '
Payment
' }, + }, + ], + }) +} + +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) + }) +}) diff --git a/frontend/src/__tests__/GuestPaymentRedirect.spec.ts b/frontend/src/__tests__/GuestPaymentRedirect.spec.ts new file mode 100644 index 0000000..7e31b4b --- /dev/null +++ b/frontend/src/__tests__/GuestPaymentRedirect.spec.ts @@ -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: '
Home
' } }, + { + path: '/gast-betalning/:orderId', + name: 'guest-payment', + component: GuestPaymentRedirect, + }, + { + path: '/gast-order/:token', + name: 'guest-order', + component: { template: '
Order
' }, + }, + ], + }) +} + +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', + ) + }) +}) diff --git a/frontend/src/__tests__/guestOrders.spec.ts b/frontend/src/__tests__/guestOrders.spec.ts new file mode 100644 index 0000000..05215ed --- /dev/null +++ b/frontend/src/__tests__/guestOrders.spec.ts @@ -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) + }) + }) +})