From 491dc99c55daffed3739d1d3760a3bd69df57fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Wed, 13 May 2026 19:17:29 +0200 Subject: [PATCH] feat: add login page with Playwright E2E tests Add the frontend login page (LoginPage.vue) with email and password fields, Swedish UI strings, and integration with the backend login endpoint. Also sets up Playwright as the E2E testing framework with browser tests for both login and registration flows. Frontend login implementation: - Add LoginPage.vue with form validation, error handling, and link to registration page - Add login() API function in auth.ts - Add loginUser() method to authStore that stores JWT token - Add /logga-in route to Vue Router - Add 'Logga in' nav link to AppHeader alongside existing 'Registrera' - Add 10 unit tests for LoginPage component - Add 4 unit tests for loginUser auth store method - Add 1 route resolution test and 1 AppHeader link test Playwright E2E setup and tests: - Install @playwright/test and configure playwright.config.ts - Add npm scripts: test:e2e (local) and test:e2e:ci (Docker CI) - Exclude e2e/ directory from Vitest to prevent test runner conflicts - Add .gitignore entries for test-results/ and playwright-report/ - Add 5 E2E tests for login (navigation, invalid credentials, success redirect, navigation to register, input types) - Add 6 E2E tests for register (navigation, success redirect, validation errors for invalid email/short password/mismatched passwords, navigation to login) --- frontend/.gitignore | 4 + frontend/e2e/login.spec.ts | 48 ++++++ frontend/e2e/register.spec.ts | 56 +++++++ frontend/package-lock.json | 64 ++++++++ frontend/package.json | 5 +- frontend/playwright.config.ts | 29 ++++ frontend/src/__tests__/AppHeader.spec.ts | 21 +++ frontend/src/__tests__/LoginPage.spec.ts | 148 ++++++++++++++++++ frontend/src/__tests__/Router.spec.ts | 6 + frontend/src/__tests__/authStore.spec.ts | 60 ++++++++ frontend/src/api/auth.ts | 7 + frontend/src/components/AppHeader.vue | 1 + frontend/src/pages/LoginPage.vue | 185 +++++++++++++++++++++++ frontend/src/router/index.ts | 6 + frontend/src/stores/authStore.ts | 9 +- frontend/vite.config.ts | 1 + 16 files changed, 647 insertions(+), 3 deletions(-) create mode 100644 frontend/e2e/login.spec.ts create mode 100644 frontend/e2e/register.spec.ts create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/src/__tests__/LoginPage.spec.ts create mode 100644 frontend/src/pages/LoginPage.vue diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..b709050 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,7 @@ dist-ssr *.njsproj *.sln *.sw? + +# Playwright +test-results/ +playwright-report/ diff --git a/frontend/e2e/login.spec.ts b/frontend/e2e/login.spec.ts new file mode 100644 index 0000000..2d5630f --- /dev/null +++ b/frontend/e2e/login.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test' + +test.describe('Login page', () => { + test('can navigate to login page', async ({ page }) => { + await page.goto('/logga-in') + await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible() + }) + + test('shows error for invalid credentials', async ({ page }) => { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill('user@example.com') + await page.getByLabel('Lösenord').fill('wrongpassword') + await page.getByRole('button', { name: 'Logga in' }).click() + + await expect(page.getByText('Felaktig e-post eller lösenord')).toBeVisible() + }) + + test('redirects to home after successful login', async ({ page }) => { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill('test@example.com') + await page.getByLabel('Lösenord').fill('password123') + await page.getByRole('button', { name: 'Logga in' }).click() + + await expect(page).toHaveURL('/') + }) + + test('can navigate from login to register', async ({ page }) => { + await page.goto('/logga-in') + await page.getByRole('link', { name: 'Skapa konto' }).click() + + await expect(page).toHaveURL('/registrera') + await expect( + page.getByRole('heading', { name: 'Skapa konto' }), + ).toBeVisible() + }) + + test('login form has correct input types', async ({ page }) => { + await page.goto('/logga-in') + await expect(page.getByLabel('E-postadress')).toHaveAttribute( + 'type', + 'email', + ) + await expect(page.getByLabel('Lösenord')).toHaveAttribute( + 'type', + 'password', + ) + }) +}) diff --git a/frontend/e2e/register.spec.ts b/frontend/e2e/register.spec.ts new file mode 100644 index 0000000..3523f87 --- /dev/null +++ b/frontend/e2e/register.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test' + +test.describe('Register page', () => { + test('can navigate to register page', async ({ page }) => { + await page.goto('/registrera') + await expect( + page.getByRole('heading', { name: 'Skapa konto' }), + ).toBeVisible() + }) + + test('registers a new user and redirects to home', async ({ page }) => { + const uniqueEmail = `playwright-${Date.now()}@test.com` + + await page.goto('/registrera') + await page.getByLabel('E-postadress').fill(uniqueEmail) + await page.getByLabel('Lösenord').first().fill('password123') + await page.getByLabel('Bekräfta lösenord').fill('password123') + await page.getByRole('button', { name: 'Skapa konto' }).click() + + await expect(page).toHaveURL('/') + }) + + test('shows validation error for invalid email', async ({ page }) => { + await page.goto('/registrera') + await page.getByLabel('E-postadress').fill('not-an-email') + + await expect(page.getByText('Ange en giltig e-postadress')).toBeVisible() + }) + + test('shows validation error for short password', async ({ page }) => { + await page.goto('/registrera') + await page.getByLabel('Lösenord').first().fill('short') + + await expect( + page.getByText('Lösenordet måste vara minst 8 tecken'), + ).toBeVisible() + }) + + test('shows validation error for mismatched passwords', async ({ page }) => { + await page.goto('/registrera') + await page.getByLabel('Lösenord').first().fill('password123') + await page.getByLabel('Bekräfta lösenord').fill('different123') + + await expect(page.getByText('Lösenorden matchar inte')).toBeVisible() + }) + + test('can navigate from register to login', async ({ page }) => { + await page.goto('/registrera') + await page + .getByRole('main') + .getByRole('link', { name: 'Logga in' }) + .click() + + await expect(page).toHaveURL('/logga-in') + }) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2ed943d..67ca36c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "vue-router": "^5.0.6" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@rushstack/eslint-patch": "^1.16.1", "@types/node": "^24.12.2", "@vitejs/plugin-vue": "^6.0.6", @@ -670,6 +671,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", @@ -3717,6 +3734,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5aa05f0..667066d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,9 @@ "lint": "eslint src/ --fix", "format": "prettier --write src/", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ci": "docker compose -f ../docker-compose.ci.yml up --build --abort-on-container-exit --exit-code-from playwright" }, "dependencies": { "pinia": "^3.0.4", @@ -18,6 +20,7 @@ "vue-router": "^5.0.6" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@rushstack/eslint-patch": "^1.16.1", "@types/node": "^24.12.2", "@vitejs/plugin-vue": "^6.0.6", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..ead0120 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from '@playwright/test' + +const isCI = !!process.env.PLAYWRIGHT_BASE_URL + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + retries: 0, + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000', + headless: true, + }, + ...(isCI + ? {} + : { + webServer: { + command: 'npm run dev', + port: 3000, + reuseExistingServer: true, + timeout: 30_000, + }, + }), + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], +}) diff --git a/frontend/src/__tests__/AppHeader.spec.ts b/frontend/src/__tests__/AppHeader.spec.ts index 6a933e6..be5616a 100644 --- a/frontend/src/__tests__/AppHeader.spec.ts +++ b/frontend/src/__tests__/AppHeader.spec.ts @@ -8,6 +8,16 @@ function createTestRouter() { history: createMemoryHistory(), routes: [ { path: '/', name: 'home', component: { template: '
Home
' } }, + { + path: '/logga-in', + name: 'login', + component: { template: '
Login
' }, + }, + { + path: '/registrera', + name: 'register', + component: { template: '
Register
' }, + }, ], }) } @@ -43,4 +53,15 @@ describe('AppHeader', () => { expect(registerLink).toBeTruthy() expect(registerLink?.text()).toBe('Registrera') }) + + it('has a link to login', () => { + const router = createTestRouter() + const wrapper = mount(AppHeader, { + global: { plugins: [router] }, + }) + const links = wrapper.findAll('a') + const loginLink = links.find((a) => a.attributes('href') === '/logga-in') + expect(loginLink).toBeTruthy() + expect(loginLink?.text()).toBe('Logga in') + }) }) diff --git a/frontend/src/__tests__/LoginPage.spec.ts b/frontend/src/__tests__/LoginPage.spec.ts new file mode 100644 index 0000000..a6df103 --- /dev/null +++ b/frontend/src/__tests__/LoginPage.spec.ts @@ -0,0 +1,148 @@ +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 LoginPage from '@/pages/LoginPage.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: '/logga-in', name: 'login', component: LoginPage }, + { path: '/', name: 'home', component: { template: '
Home
' } }, + { + path: '/registrera', + name: 'register', + component: { template: '
Register
' }, + }, + ], + }) +} + +function mountPage() { + const router = createTestRouter() + const pinia = createPinia() + router.push('/logga-in') + return { + router, + wrapper: mount(LoginPage, { + global: { plugins: [router, pinia] }, + }), + } +} + +describe('LoginPage', () => { + beforeEach(() => { + localStorage.clear() + globalThis.fetch = vi.fn() + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(200, { token: 'test-token' }), + ) + }) + + it('renders heading and subtitle', async () => { + const { wrapper } = mountPage() + expect(wrapper.text()).toContain('Logga in') + expect(wrapper.text()).toContain('Ange din e-postadress och ditt lösenord') + }) + + it('renders email and password fields', async () => { + const { wrapper } = mountPage() + expect(wrapper.find('#email').exists()).toBe(true) + expect(wrapper.find('#password').exists()).toBe(true) + }) + + it('does not render confirm password field', async () => { + const { wrapper } = mountPage() + expect(wrapper.find('#confirm-password').exists()).toBe(false) + }) + + it('disables submit when fields are empty', async () => { + const { wrapper } = mountPage() + const button = wrapper.find('button[type="submit"]') + expect(button.attributes('disabled')).toBeDefined() + }) + + it('enables submit when both fields have values', async () => { + const { wrapper } = mountPage() + await wrapper.find('#email').setValue('test@example.com') + await wrapper.find('#password').setValue('password123') + const button = wrapper.find('button[type="submit"]') + expect(button.attributes('disabled')).toBeUndefined() + }) + + it('shows loading text while submitting', async () => { + globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {})) + const { wrapper } = mountPage() + await wrapper.find('#email').setValue('test@example.com') + await wrapper.find('#password').setValue('password123') + await wrapper.find('form').trigger('submit.prevent') + expect(wrapper.text()).toContain('Loggar in...') + }) + + it('calls login API and redirects to home on success', async () => { + const { wrapper, router } = mountPage() + + await wrapper.find('#email').setValue('test@example.com') + await wrapper.find('#password').setValue('password123') + await wrapper.find('form').trigger('submit.prevent') + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/auth/login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + email: 'test@example.com', + password: 'password123', + }), + }), + ) + expect(router.currentRoute.value.name).toBe('home') + }) + + it('shows generic error on login failure', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }), + ) + const { wrapper } = mountPage() + + await wrapper.find('#email').setValue('test@example.com') + await wrapper.find('#password').setValue('wrongpassword') + await wrapper.find('form').trigger('submit.prevent') + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(wrapper.text()).toContain('Felaktig e-post eller lösenord') + }) + + it('does not leak specific error messages', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(500, { message: 'Internal server error' }), + ) + const { wrapper } = mountPage() + + await wrapper.find('#email').setValue('test@example.com') + await wrapper.find('#password').setValue('password123') + await wrapper.find('form').trigger('submit.prevent') + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(wrapper.text()).toContain('Felaktig e-post eller lösenord') + expect(wrapper.text()).not.toContain('Internal server error') + }) + + it('renders register link', async () => { + const { wrapper } = mountPage() + expect(wrapper.text()).toContain('Har du inget konto?') + }) +}) diff --git a/frontend/src/__tests__/Router.spec.ts b/frontend/src/__tests__/Router.spec.ts index cf9ae3b..ab9456e 100644 --- a/frontend/src/__tests__/Router.spec.ts +++ b/frontend/src/__tests__/Router.spec.ts @@ -14,6 +14,12 @@ describe('Router', () => { expect(router.currentRoute.value.name).toBe('register') }) + it('resolves /logga-in to LoginPage', async () => { + await router.push('/logga-in') + await router.isReady() + expect(router.currentRoute.value.name).toBe('login') + }) + it('does not crash on unknown route', async () => { await router.push('/nonexistent') await router.isReady() diff --git a/frontend/src/__tests__/authStore.spec.ts b/frontend/src/__tests__/authStore.spec.ts index 7d099a4..6105ef2 100644 --- a/frontend/src/__tests__/authStore.spec.ts +++ b/frontend/src/__tests__/authStore.spec.ts @@ -69,4 +69,64 @@ describe('authStore', () => { expect(store.isAuthenticated).toBe(false) expect(localStorage.getItem('auth_token')).toBeNull() }) + + it('sets token on loginUser success', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(200, { token: 'login-token' }), + ) + const store = useAuthStore() + + await store.loginUser('user@example.com', 'password123') + + expect(store.token).toBe('login-token') + expect(store.isAuthenticated).toBe(true) + expect(localStorage.getItem('auth_token')).toBe('login-token') + }) + + it('rejects when login API fails', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }), + ) + const store = useAuthStore() + + await expect( + store.loginUser('user@example.com', 'wrongpassword'), + ).rejects.toThrow('Felaktig e-post eller lösenord') + + expect(store.isAuthenticated).toBe(false) + expect(store.token).toBeNull() + }) + + it('calls login endpoint with correct credentials', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(200, { token: 'login-token' }), + ) + const store = useAuthStore() + + await store.loginUser('user@example.com', 'password123') + + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/auth/login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + email: 'user@example.com', + password: 'password123', + }), + }), + ) + }) + + it('does not call register endpoint on login', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(200, { token: 'login-token' }), + ) + const store = useAuthStore() + + await store.loginUser('user@example.com', 'password123') + + const calls = vi.mocked(globalThis.fetch).mock.calls + const registerCall = calls.find((call) => call[0] === '/api/auth/register') + expect(registerCall).toBeUndefined() + }) }) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 82d0a71..6060a34 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -13,3 +13,10 @@ export function register( body: JSON.stringify({ email, password }), }) } + +export function login(email: string, password: string): Promise { + return request('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }) +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 06a438d..ee10553 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -7,6 +7,7 @@ import { RouterLink } from 'vue-router'