From 668cd023be76c6118f08a3fdd1f95edb2feb5b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 15 May 2026 12:15:36 +0200 Subject: [PATCH] test: add admin dashboard Vitest and Playwright E2E tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vitest (14 tests) — AdminDashboard.spec.ts: - renders heading, subtitle, table columns, order data in rows - shows loading, empty, and error states - fetches GET /api/admin/orders on mount - expands row on click to reveal letter content (Brevtext label) - collapses row on second click - only one row expanded at a time (clicking row 2 closes row 1) - status dropdown change fires PATCH /api/admin/orders/{id}/status with correct URL, method, and JSON body - shows error message on failed status update Playwright E2E (8 tests) — admin-dashboard.spec.ts: - admin login (admin@bilhalsning.se / test1234) before each test - admin can navigate to /admin and see heading - non-admin user (test@bilhalsning.se) is redirected away from /admin - table renders Datum/E-post/Regnr/Status column headers - seeded order plates visible (ABC123, DEF456, GHI789) - click row expands letter content - click again collapses letter content - status dropdown change persists (selectOption delivered) - unauthenticated access redirects to login with ?redirect=/admin --- frontend/e2e/admin-dashboard.spec.ts | 84 +++++++ frontend/src/__tests__/AdminDashboard.spec.ts | 224 ++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 frontend/e2e/admin-dashboard.spec.ts create mode 100644 frontend/src/__tests__/AdminDashboard.spec.ts diff --git a/frontend/e2e/admin-dashboard.spec.ts b/frontend/e2e/admin-dashboard.spec.ts new file mode 100644 index 0000000..fcc973a --- /dev/null +++ b/frontend/e2e/admin-dashboard.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from '@playwright/test' + +test.describe('Admin dashboard', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill('admin@bilhalsning.se') + await page.getByLabel('Lösenord').fill('test1234') + await page.getByRole('button', { name: 'Logga in' }).click() + await page.waitForURL('/') + }) + + test('admin can navigate to admin page', async ({ page }) => { + await page.goto('/admin') + + await expect( + page.getByRole('heading', { name: 'Administration' }), + ).toBeVisible() + }) + + test('non-admin user is redirected away from admin', async ({ page }) => { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill('test@bilhalsning.se') + await page.getByLabel('Lösenord').fill('test1234') + await page.getByRole('button', { name: 'Logga in' }).click() + await page.waitForURL('/') + + await page.goto('/admin') + + await expect(page).toHaveURL('/') + }) + + test('shows orders table with columns', async ({ page }) => { + await page.goto('/admin') + + await expect(page.getByText('Datum')).toBeVisible() + await expect(page.getByText('E-post')).toBeVisible() + await expect(page.getByText('Regnr')).toBeVisible() + await expect(page.getByText('Status')).toBeVisible() + }) + + test('shows seeded order data', async ({ page }) => { + await page.goto('/admin') + + await expect(page.getByText('ABC123')).toBeVisible() + await expect(page.getByText('DEF456')).toBeVisible() + await expect(page.getByText('GHI789')).toBeVisible() + }) + + test('click row expands letter content', async ({ page }) => { + await page.goto('/admin') + + const rows = page.locator('.admin-dashboard__row') + await rows.first().click() + + await expect(page.getByText('Brevtext')).toBeVisible() + }) + + test('click expanded row collapses it', async ({ page }) => { + await page.goto('/admin') + + const rows = page.locator('.admin-dashboard__row') + await rows.first().click() + await expect(page.getByText('Brevtext')).toBeVisible() + + await rows.first().click() + await expect(page.getByText('Brevtext')).not.toBeVisible() + }) + + test('status dropdown changes update order status', async ({ page }) => { + await page.goto('/admin') + + const selects = page.locator('.admin-dashboard__status-select') + await selects.first().selectOption('delivered') + + const updatedSelect = selects.first() + await expect(updatedSelect).toHaveValue('delivered') + }) + + test('admin cannot access admin page without auth', async ({ page }) => { + await page.goto('/admin') + + await expect(page).toHaveURL(/\/logga-in\?redirect=\/admin/) + }) +}) diff --git a/frontend/src/__tests__/AdminDashboard.spec.ts b/frontend/src/__tests__/AdminDashboard.spec.ts new file mode 100644 index 0000000..a6d2428 --- /dev/null +++ b/frontend/src/__tests__/AdminDashboard.spec.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import { createPinia } from 'pinia' +import AdminPage from '@/pages/AdminPage.vue' + +function mockFetchResponse(status: number, body: unknown) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }) +} + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/admin', name: 'admin', component: AdminPage }, + { path: '/', name: 'home', component: { template: '
Home
' } }, + ], + }) +} + +function mountPage() { + const router = createTestRouter() + const pinia = createPinia() + router.push('/admin') + return { + router, + wrapper: mount(AdminPage, { + global: { plugins: [router, pinia] }, + }), + } +} + +const mockOrders = [ + { + id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + email: 'test@bilhalsning.se', + plate: 'ABC123', + letterText: 'Hej fin bil!', + status: 'sent', + trackingId: 'PN123456789', + amountPaid: 49.0, + createdAt: '2026-05-11T12:00:00Z', + }, + { + id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', + email: 'user@example.com', + plate: 'XYZ789', + letterText: 'Vill köpa din bil.', + status: 'pending_payment', + trackingId: null, + amountPaid: null, + createdAt: '2026-05-14T13:00:00Z', + }, +] + +describe('AdminDashboard', () => { + beforeEach(() => { + localStorage.clear() + globalThis.fetch = vi.fn() + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(200, mockOrders), + ) + }) + + it('renders heading and subtitle', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('Administration') + expect(wrapper.text()).toContain( + 'Hantera beställningar, mallar och användare', + ) + }) + + it('shows loading state initially', async () => { + globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {})) + const { wrapper } = mountPage() + expect(wrapper.text()).toContain('Laddar beställningar...') + }) + + it('fetches orders from API on mount', async () => { + mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/admin/orders', + expect.objectContaining({ headers: expect.any(Object) }), + ) + }) + + it('renders table with all columns', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('Datum') + expect(wrapper.text()).toContain('E-post') + expect(wrapper.text()).toContain('Regnr') + expect(wrapper.text()).toContain('Status') + }) + + it('renders order data in rows', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('test@bilhalsning.se') + expect(wrapper.text()).toContain('ABC123') + expect(wrapper.text()).toContain('user@example.com') + expect(wrapper.text()).toContain('XYZ789') + }) + + it('shows empty state when no orders', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, [])) + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('Inga beställningar ännu') + }) + + it('shows error state on API failure', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(500, { message: 'Internal server error' }), + ) + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('Kunde inte hämta beställningar') + }) + + it('expands row on click to show letter content', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + const rows = wrapper.findAll('.admin-dashboard__row') + expect(rows.length).toBe(2) + + await rows[0].trigger('click') + await new Promise((r) => setTimeout(r, 50)) + + expect(wrapper.text()).toContain('Hej fin bil!') + expect(wrapper.text()).toContain('Brevtext') + }) + + it('collapses row on second click', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + const rows = wrapper.findAll('.admin-dashboard__row') + await rows[0].trigger('click') + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('Hej fin bil!') + + await rows[0].trigger('click') + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).not.toContain('Hej fin bil!') + }) + + it('only expands one row at a time', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + const rows = wrapper.findAll('.admin-dashboard__row') + await rows[0].trigger('click') + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('Hej fin bil!') + + await rows[1].trigger('click') + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).not.toContain('Hej fin bil!') + expect(wrapper.text()).toContain('Vill köpa din bil.') + }) + + it('renders status dropdowns', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + const selects = wrapper.findAll('.admin-dashboard__status-select') + expect(selects.length).toBe(2) + }) + + it('fires status update API on dropdown change', async () => { + vi.mocked(globalThis.fetch) + .mockResolvedValueOnce(mockFetchResponse(200, mockOrders)) + .mockResolvedValueOnce( + mockFetchResponse(200, { ...mockOrders[0], status: 'paid' }), + ) + + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + const selects = wrapper.findAll('.admin-dashboard__status-select') + await selects[0].trigger('change') + await new Promise((r) => setTimeout(r, 50)) + + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/admin/orders/c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11/status', + expect.objectContaining({ + method: 'PATCH', + body: '{"status":"sent"}', + }), + ) + }) + + it('shows status error on failed update', async () => { + vi.mocked(globalThis.fetch) + .mockResolvedValueOnce(mockFetchResponse(200, mockOrders)) + .mockResolvedValueOnce( + mockFetchResponse(500, { message: 'Server error' }), + ) + + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + const selects = wrapper.findAll('.admin-dashboard__status-select') + await selects[0].trigger('change') + await new Promise((r) => setTimeout(r, 50)) + + expect(wrapper.text()).toContain('Kunde inte uppdatera status') + }) + + it('formats dates in Swedish locale', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + expect(wrapper.text()).toContain('2026') + }) +})