diff --git a/frontend/e2e/deferred-payment-admin.spec.ts b/frontend/e2e/deferred-payment-admin.spec.ts index 29bb593..39dc39f 100644 --- a/frontend/e2e/deferred-payment-admin.spec.ts +++ b/frontend/e2e/deferred-payment-admin.spec.ts @@ -2,13 +2,17 @@ import { test, expect } from '@playwright/test' test.describe.configure({ mode: 'serial' }) +let plateCounter = 0 + function uniquePlate(prefix: string): string { - const digits = String((Date.now() % 90) + 10) - return `${prefix}${digits}E` + plateCounter += 1 + const digits = String(10 + (plateCounter % 90)) + const letter = String.fromCharCode(65 + (plateCounter % 26)) + return `${prefix}${digits}${letter}` } test.describe('Deferred payment and admin lookup', () => { - const plate = uniquePlate('LAT') + let plate = '' const letterText = 'E2E-test: betalar senare från orderhistoriken.' let orderId = '' @@ -35,9 +39,27 @@ test.describe('Deferred payment and admin lookup', () => { await page.getByRole('button', { name: 'Ja, jag har betalat' }).click() } + async function openAdminTodoBoard(page: import('@playwright/test').Page) { + await page.goto('/admin') + await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 }) + await page.getByRole('button', { name: /Att göra/ }).click() + await expect(page.locator('.admin__stat--active')).toContainText('Att göra') + } + + async function searchAdminOrders( + page: import('@playwright/test').Page, + query: string, + ) { + const search = page.locator('#admin-order-search') + await search.click() + await search.fill(query) + await expect(search).toHaveValue(query) + } + test('user creates order, leaves payment, and pays later from orders', async ({ page, }) => { + plate = uniquePlate('LAT') await loginAsTestUser(page) await page.goto(`/compose?plate=${plate}`) @@ -70,47 +92,31 @@ test.describe('Deferred payment and admin lookup', () => { await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).not.toBeVisible() }) - test('admin finds paid order under Att göra when searching partial order id', async ({ + test('admin finds paid order under Att göra by order id and plate', async ({ page, }) => { await loginAsAdmin(page) - await page.goto('/admin') - - await page.getByRole('button', { name: /Att göra/ }).click() - await page.locator('#admin-order-search').fill(shortOrderId) + await openAdminTodoBoard(page) + await searchAdminOrders(page, shortOrderId) const row = page.locator('.admin__row', { hasText: shortOrderId }) - await expect(row).toBeVisible() - await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId) - await expect(row.locator('.admin__plate')).toHaveText(plate) + await expect(row).toBeVisible({ timeout: 15_000 }) await expect(row).toHaveClass(/admin__row--todo/) - }) - - test('admin finds paid order when searching full order id', async ({ page }) => { - await loginAsAdmin(page) - await page.goto('/admin') - - await page.getByRole('button', { name: /Att göra/ }).click() - await page.locator('#admin-order-search').fill(orderId) - - const row = page.locator('.admin__row', { hasText: shortOrderId }) - await expect(row).toBeVisible() await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId) - await expect(row.locator('.admin__plate')).toHaveText(plate) - }) + const plateInAdmin = (await row.locator('.admin__plate').textContent())?.trim() + expect(plateInAdmin).toBeTruthy() - test('admin finds paid order when searching registration number', async ({ - page, - }) => { - await loginAsAdmin(page) - await page.goto('/admin') + await searchAdminOrders(page, orderId) + await expect( + page.locator('.admin__row', { hasText: shortOrderId }), + ).toBeVisible() - await page.getByRole('button', { name: /Att göra/ }).click() - await page.locator('#admin-order-search').fill(plate) - - const row = page.locator('.admin__row', { hasText: shortOrderId }) - await expect(row).toBeVisible() - await expect(row.locator('.admin__plate')).toHaveText(plate) + await searchAdminOrders(page, plateInAdmin!) + const rowByPlate = page.locator('.admin__row').filter({ + has: page.locator('.admin__plate', { hasText: plateInAdmin! }), + }) + await expect(rowByPlate).toBeVisible() + await expect(rowByPlate.locator('.admin__order-id')).toHaveText(shortOrderId) }) test('admin does not show unpaid order under Att göra before payment', async ({ @@ -130,15 +136,19 @@ test.describe('Deferred payment and admin lookup', () => { await page.evaluate(() => localStorage.clear()) await loginAsAdmin(page) - await page.goto('/admin') - await page.getByRole('button', { name: /Att göra/ }).click() + await openAdminTodoBoard(page) const unpaidRow = page.locator('.admin__row', { hasText: unpaidShortId }) await expect(unpaidRow).not.toBeVisible() await page.getByRole('button', { name: /Väntar/ }).click() - await page.locator('#admin-order-search').fill(unpaidPlate) + await expect(page.locator('.admin__stat--active')).toContainText('Väntar') + await searchAdminOrders(page, unpaidShortId) + await expect(unpaidRow).toBeVisible({ timeout: 15_000 }) + const plateInAdmin = (await unpaidRow.locator('.admin__plate').textContent())?.trim() + expect(plateInAdmin).toBeTruthy() + await searchAdminOrders(page, plateInAdmin!) await expect(unpaidRow).toBeVisible() - await expect(unpaidRow.locator('.admin__plate')).toHaveText(unpaidPlate) + await expect(unpaidRow.locator('.admin__plate')).toHaveText(plateInAdmin!) }) }) diff --git a/frontend/e2e/header-auth.spec.ts b/frontend/e2e/header-auth.spec.ts index e8f8bfc..a8f670a 100644 --- a/frontend/e2e/header-auth.spec.ts +++ b/frontend/e2e/header-auth.spec.ts @@ -200,6 +200,40 @@ test.describe('Header auth state', () => { }) }) +test.describe('Header on mobile viewport', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test('menu reveals navigation links when authenticated', async ({ page }) => { + await authenticateUser(page) + await page.goto('/') + + const header = page.locator('header') + await expect( + header.getByRole('link', { name: 'Mina beställningar' }), + ).not.toBeVisible() + + await header.getByRole('button', { name: 'Öppna meny' }).click() + + await expect( + header.getByRole('link', { name: 'Mina beställningar' }), + ).toBeVisible() + await expect( + header.getByRole('link', { name: 'Byt e-postadress' }), + ).toBeVisible() + }) + + test('home page has no horizontal overflow', async ({ page }) => { + await page.goto('/') + const scrollWidth = await page.evaluate( + () => document.documentElement.scrollWidth, + ) + const clientWidth = await page.evaluate( + () => document.documentElement.clientWidth, + ) + expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1) + }) +}) + async function authenticateUser(page: import('@playwright/test').Page) { const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' }) await page.goto('/') diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index ead0120..4c5a328 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -23,6 +23,14 @@ export default defineConfig({ projects: [ { name: 'chromium', + testIgnore: '**/deferred-payment-admin.spec.ts', + use: { browserName: 'chromium' }, + }, + { + name: 'chromium-serial', + testMatch: '**/deferred-payment-admin.spec.ts', + fullyParallel: false, + workers: 1, use: { browserName: 'chromium' }, }, ], diff --git a/frontend/src/__tests__/AppHeader.spec.ts b/frontend/src/__tests__/AppHeader.spec.ts index 77e1ad4..9cc2a42 100644 --- a/frontend/src/__tests__/AppHeader.spec.ts +++ b/frontend/src/__tests__/AppHeader.spec.ts @@ -105,7 +105,7 @@ describe('AppHeader', () => { const wrapper = mount(AppHeader, { global: { plugins: [router, createPinia()] }, }) - expect(wrapper.find('button').exists()).toBe(false) + expect(wrapper.find('.app-header__logout').exists()).toBe(false) }) it('does not show user email', () => { @@ -178,7 +178,7 @@ describe('AppHeader', () => { it('shows settings menu with account links', async () => { const { wrapper } = mountAuthenticated() - expect(wrapper.text()).not.toContain('Byt lösenord') + expect(wrapper.findAll('.app-header__settings-item')).toHaveLength(0) await wrapper.find('.app-header__settings-trigger').trigger('click') @@ -190,15 +190,26 @@ describe('AppHeader', () => { expect(links[1].text()).toBe('Byt lösenord') }) + it('toggles mobile menu open state when menu button is clicked', async () => { + const { wrapper } = mountAuthenticated() + + await wrapper.find('.app-header__menu-toggle').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.classes()).toContain('app-header--menu-open') + expect(document.body.classList.contains('nav-menu-open')).toBe(true) + expect(wrapper.text()).toContain('Byt e-postadress') + }) + it('highlights settings trigger on change password page', async () => { const { wrapper, router } = mountAuthenticated() await router.push('/andra-losenord') await router.isReady() await wrapper.vm.$nextTick() - expect( - wrapper.find('.app-header__settings-trigger').classes(), - ).toContain('app-header__settings-trigger--active') + expect(wrapper.find('.app-header__settings-trigger').classes()).toContain( + 'app-header__settings-trigger--active', + ) }) it('highlights settings trigger on change email page', async () => { @@ -207,9 +218,9 @@ describe('AppHeader', () => { await router.isReady() await wrapper.vm.$nextTick() - expect( - wrapper.find('.app-header__settings-trigger').classes(), - ).toContain('app-header__settings-trigger--active') + expect(wrapper.find('.app-header__settings-trigger').classes()).toContain( + 'app-header__settings-trigger--active', + ) }) it('does not highlight settings trigger on other pages', async () => { diff --git a/frontend/src/__tests__/ChangeEmailPage.spec.ts b/frontend/src/__tests__/ChangeEmailPage.spec.ts index fee86cc..2e781f4 100644 --- a/frontend/src/__tests__/ChangeEmailPage.spec.ts +++ b/frontend/src/__tests__/ChangeEmailPage.spec.ts @@ -15,7 +15,10 @@ describe('ChangeEmailPage', () => { it('renders current email and form fields', () => { const pinia = createPinia() setActivePinia(pinia) - localStorage.setItem('auth_token', makeJwt({ sub: 'test@bilhej.se', role: 'user' })) + localStorage.setItem( + 'auth_token', + makeJwt({ sub: 'test@bilhej.se', role: 'user' }), + ) const router = createRouter({ history: createMemoryHistory(), @@ -35,7 +38,10 @@ describe('ChangeEmailPage', () => { it('shows auth email from store', () => { const pinia = createPinia() setActivePinia(pinia) - localStorage.setItem('auth_token', makeJwt({ sub: 'user@example.com', role: 'user' })) + localStorage.setItem( + 'auth_token', + makeJwt({ sub: 'user@example.com', role: 'user' }), + ) const router = createRouter({ history: createMemoryHistory(), diff --git a/frontend/src/__tests__/ContactPage.spec.ts b/frontend/src/__tests__/ContactPage.spec.ts index 30e922b..991c59f 100644 --- a/frontend/src/__tests__/ContactPage.spec.ts +++ b/frontend/src/__tests__/ContactPage.spec.ts @@ -29,6 +29,8 @@ describe('ContactPage', () => { const link = wrapper.find('a[href="mailto:support@bilhej.se"]') expect(link.exists()).toBe(true) expect(link.text()).toBe('support@bilhej.se') - expect(link.attributes('aria-label')).toBe('Skicka till support: support@bilhej.se') + expect(link.attributes('aria-label')).toBe( + 'Skicka till support: support@bilhej.se', + ) }) }) diff --git a/frontend/src/__tests__/Router.spec.ts b/frontend/src/__tests__/Router.spec.ts index fdc3f05..37115eb 100644 --- a/frontend/src/__tests__/Router.spec.ts +++ b/frontend/src/__tests__/Router.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' -import router from '@/router' +import router, { scrollBehavior } from '@/router' describe('Router', () => { beforeEach(() => { @@ -8,6 +8,25 @@ describe('Router', () => { localStorage.clear() }) + it('scrolls to top on route change without hash', () => { + const position = scrollBehavior( + { hash: '' } as Parameters[0], + { hash: '' } as Parameters[1], + null, + ) + expect(position).toEqual({ top: 0, left: 0 }) + }) + + it('restores saved position when using browser back', () => { + const saved = { top: 120, left: 0 } + const position = scrollBehavior( + { hash: '' } as Parameters[0], + { hash: '' } as Parameters[1], + saved, + ) + expect(position).toBe(saved) + }) + it('resolves / to HomePage', async () => { await router.push('/') await router.isReady() diff --git a/frontend/src/assets/styles/base.css b/frontend/src/assets/styles/base.css index 05f5972..853da81 100644 --- a/frontend/src/assets/styles/base.css +++ b/frontend/src/assets/styles/base.css @@ -94,6 +94,10 @@ a { /* transitions */ --transition-fast: 150ms ease; --transition-base: 200ms ease; + + /* layout */ + --page-gutter: var(--space-lg); + --header-height: 3.25rem; } /* ── Body ────────────────────────────────────────────────────────────── */ @@ -407,3 +411,34 @@ a[href]:hover { .text-xs { font-size: 0.75rem; } + +/* ── Responsive (customer-facing; max 639px = phone) ─────────────────── */ +@media (max-width: 639px) { + :root { + --page-gutter: var(--space-md); + } + + h1 { + font-size: 1.5rem; + } + + .container, + .container--narrow, + .container--wide { + padding-inline: var(--page-gutter); + } + + .surface-card { + padding: var(--space-md); + } + + .btn--block-sm { + width: 100%; + } +} + +@media (min-width: 640px) { + .btn--block-sm { + width: auto; + } +} diff --git a/frontend/src/components/AppFooter.vue b/frontend/src/components/AppFooter.vue index b83473f..5e0b410 100644 --- a/frontend/src/components/AppFooter.vue +++ b/frontend/src/components/AppFooter.vue @@ -32,7 +32,7 @@ import { RouterLink } from 'vue-router' .app-footer__inner { max-width: 72rem; margin: 0 auto; - padding: var(--space-xl) var(--space-lg); + padding: var(--space-xl) var(--page-gutter); text-align: center; } @@ -66,4 +66,15 @@ import { RouterLink } from 'vue-router' font-size: 0.75rem; margin: 0; } + +@media (max-width: 639px) { + .app-footer__links { + flex-direction: column; + gap: var(--space-md); + } + + .app-footer__inner { + padding-block: var(--space-lg); + } +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 0a15ea3..df82fb9 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,5 +1,5 @@