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)
This commit is contained in:
Joakim Mörling 2026-05-13 19:17:29 +02:00
parent 3d4a6daee9
commit 491dc99c55
16 changed files with 647 additions and 3 deletions

4
frontend/.gitignore vendored
View file

@ -22,3 +22,7 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Playwright
test-results/
playwright-report/

View file

@ -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',
)
})
})

View file

@ -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')
})
})

View file

@ -13,6 +13,7 @@
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
@ -670,6 +671,22 @@
"url": "https://opencollective.com/pkgr" "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", "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" "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": { "node_modules/postcss": {
"version": "8.5.13", "version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",

View file

@ -10,7 +10,9 @@
"lint": "eslint src/ --fix", "lint": "eslint src/ --fix",
"format": "prettier --write src/", "format": "prettier --write src/",
"test": "vitest run", "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": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
@ -18,6 +20,7 @@
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",

View file

@ -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' },
},
],
})

View file

@ -8,6 +8,16 @@ function createTestRouter() {
history: createMemoryHistory(), history: createMemoryHistory(),
routes: [ routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } }, { path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/logga-in',
name: 'login',
component: { template: '<div>Login</div>' },
},
{
path: '/registrera',
name: 'register',
component: { template: '<div>Register</div>' },
},
], ],
}) })
} }
@ -43,4 +53,15 @@ describe('AppHeader', () => {
expect(registerLink).toBeTruthy() expect(registerLink).toBeTruthy()
expect(registerLink?.text()).toBe('Registrera') 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')
})
}) })

View file

@ -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: '<div>Home</div>' } },
{
path: '/registrera',
name: 'register',
component: { template: '<div>Register</div>' },
},
],
})
}
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?')
})
})

View file

@ -14,6 +14,12 @@ describe('Router', () => {
expect(router.currentRoute.value.name).toBe('register') 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 () => { it('does not crash on unknown route', async () => {
await router.push('/nonexistent') await router.push('/nonexistent')
await router.isReady() await router.isReady()

View file

@ -69,4 +69,64 @@ describe('authStore', () => {
expect(store.isAuthenticated).toBe(false) expect(store.isAuthenticated).toBe(false)
expect(localStorage.getItem('auth_token')).toBeNull() 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()
})
}) })

View file

@ -13,3 +13,10 @@ export function register(
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}) })
} }
export function login(email: string, password: string): Promise<AuthResponse> {
return request<AuthResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
}

View file

@ -7,6 +7,7 @@ import { RouterLink } from 'vue-router'
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink> <RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
<nav class="app-header__nav"> <nav class="app-header__nav">
<RouterLink to="/" class="app-header__link">Hem</RouterLink> <RouterLink to="/" class="app-header__link">Hem</RouterLink>
<RouterLink to="/logga-in" class="app-header__link">Logga in</RouterLink>
<RouterLink to="/registrera" class="app-header__link" <RouterLink to="/registrera" class="app-header__link"
>Registrera</RouterLink >Registrera</RouterLink
> >

View file

@ -0,0 +1,185 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = useRouter()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const isValid = computed(
() => email.value.length > 0 && password.value.length > 0,
)
async function handleSubmit() {
if (!isValid.value || submitting.value) return
submitting.value = true
errorMessage.value = ''
try {
await authStore.loginUser(email.value, password.value)
await router.push('/')
} catch {
errorMessage.value = 'Felaktig e-post eller lösenord'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="login">
<h1 class="login__title">Logga in</h1>
<p class="login__subtitle">
Ange din e-postadress och ditt lösenord för att logga in.
</p>
<form class="login__form" @submit.prevent="handleSubmit">
<div class="login__field">
<label for="email" class="login__label">E-postadress</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
class="login__input"
placeholder="namn@exempel.se"
/>
</div>
<div class="login__field">
<label for="password" class="login__label">Lösenord</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="login__input"
placeholder="Ditt lösenord"
/>
</div>
<p v-if="errorMessage" class="login__api-error">{{ errorMessage }}</p>
<button
type="submit"
class="login__submit"
:disabled="!isValid || submitting"
>
{{ submitting ? 'Loggar in...' : 'Logga in' }}
</button>
</form>
<p class="login__register-link">
Har du inget konto?
<RouterLink to="/registrera">Skapa konto</RouterLink>
</p>
</div>
</template>
<style scoped>
.login {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
.login__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
color: #1a202c;
}
.login__subtitle {
margin: 0 0 1.5rem 0;
color: #718096;
font-size: 0.875rem;
}
.login__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.login__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.login__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
}
.login__input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #cbd5e0;
border-radius: 0.5rem;
outline: none;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.login__input:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
}
.login__api-error {
margin: 0;
padding: 0.75rem 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem;
}
.login__submit {
width: 100%;
padding: 0.875rem 1.5rem;
background: #4299e1;
color: #fff;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.login__submit:hover:not(:disabled) {
background: #3182ce;
}
.login__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login__register-link {
margin-top: 1.5rem;
text-align: center;
font-size: 0.875rem;
color: #718096;
}
.login__register-link a {
color: #4299e1;
text-decoration: none;
}
.login__register-link a:hover {
text-decoration: underline;
}
</style>

View file

@ -4,6 +4,7 @@ import ComposePage from '@/pages/ComposePage.vue'
import AboutPage from '@/pages/AboutPage.vue' import AboutPage from '@/pages/AboutPage.vue'
import ContactPage from '@/pages/ContactPage.vue' import ContactPage from '@/pages/ContactPage.vue'
import RegisterPage from '@/pages/RegisterPage.vue' import RegisterPage from '@/pages/RegisterPage.vue'
import LoginPage from '@/pages/LoginPage.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -23,6 +24,11 @@ const router = createRouter({
name: 'register', name: 'register',
component: RegisterPage, component: RegisterPage,
}, },
{
path: '/logga-in',
name: 'login',
component: LoginPage,
},
{ {
path: '/om', path: '/om',
name: 'about', name: 'about',

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { register } from '@/api/auth' import { register, login } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('auth_token')) const token = ref<string | null>(localStorage.getItem('auth_token'))
@ -22,9 +22,14 @@ export const useAuthStore = defineStore('auth', () => {
setToken(response.token) setToken(response.token)
} }
async function loginUser(email: string, password: string): Promise<void> {
const response = await login(email, password)
setToken(response.token)
}
function logout() { function logout() {
clearToken() clearToken()
} }
return { token, isAuthenticated, registerUser, logout } return { token, isAuthenticated, registerUser, loginUser, logout }
}) })

View file

@ -19,5 +19,6 @@ export default defineConfig({
test: { test: {
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['src/__tests__/setup.ts'], setupFiles: ['src/__tests__/setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
}, },
}) })