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'