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'