WP-01: domain contract and failing tests for auth and user management
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Domain entities/ports, service stubs, 18 application unit tests with in-memory
fakes, API integration tests for /api/auth and /api/users, Vitest specs for the
auth store, login page and user form, Playwright auth/user-management flow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 21:31:59 +02:00
parent 780c84098b
commit 83aa500f5d
21 changed files with 1427 additions and 29 deletions

View File

@ -0,0 +1,42 @@
import { mount } from '@vue/test-utils'
import UserForm from './UserForm.vue'
describe('UserForm', () => {
it('emits the new user payload in create mode', async () => {
const w = mount(UserForm, { props: { mode: 'create' } })
await w.find('input[name=email]').setValue('u@x.de')
await w.find('input[name=display_name]').setValue('User')
await w.find('input[name=password]').setValue('user-password-123')
await w.find('select[name=role]').setValue('admin')
await w.find('form').trigger('submit')
expect(w.emitted('submit')![0][0]).toEqual({
email: 'u@x.de',
display_name: 'User',
password: 'user-password-123',
role: 'admin',
})
})
it('rejects short passwords client-side', async () => {
const w = mount(UserForm, { props: { mode: 'create' } })
await w.find('input[name=email]').setValue('u@x.de')
await w.find('input[name=display_name]').setValue('User')
await w.find('input[name=password]').setValue('short')
await w.find('form').trigger('submit')
expect(w.emitted('submit')).toBeUndefined()
expect(w.text()).toContain('at least 12 characters')
})
it('hides email and password in edit mode and prefills values', () => {
const w = mount(UserForm, {
props: {
mode: 'edit',
user: { id: '1', email: 'u@x.de', display_name: 'User', role: 'user', is_active: false },
},
})
expect(w.find('input[name=email]').exists()).toBe(false)
expect(w.find('input[name=password]').exists()).toBe(false)
expect((w.find('input[name=display_name]').element as HTMLInputElement).value).toBe('User')
expect((w.find('input[name=is_active]').element as HTMLInputElement).checked).toBe(false)
})
})

View File

@ -1,24 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import StatusBadge from '../components/StatusBadge.vue'
const status = ref<'ok' | 'error' | 'unknown'>('unknown')
onMounted(async () => {
try {
const res = await fetch('/healthz')
status.value = res.ok ? 'ok' : 'error'
} catch {
status.value = 'error'
}
})
</script>
<template>
<main class="mx-auto max-w-2xl p-8">
<h1 class="text-2xl font-semibold">SoftVisor Monitoring</h1>
<p class="mt-4 text-gray-600">
Backend:
<span data-testid="backend-status"><StatusBadge :status="status" :label="status" /></span>
</p>
</main>
</template>

View File

@ -0,0 +1,38 @@
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import LoginPage from './LoginPage.vue'
import { useAuthStore } from '../stores/auth'
const push = vi.fn()
vi.mock('vue-router', () => ({ useRouter: () => ({ push }), useRoute: () => ({ query: {} }) }))
beforeEach(() => {
setActivePinia(createPinia())
push.mockReset()
})
describe('LoginPage', () => {
it('submits credentials and navigates to the dashboard', async () => {
const store = useAuthStore()
store.login = vi.fn().mockResolvedValue(undefined)
const w = mount(LoginPage)
await w.find('input[type=email]').setValue('a@x.de')
await w.find('input[type=password]').setValue('secret-password')
await w.find('form').trigger('submit')
await flushPromises()
expect(store.login).toHaveBeenCalledWith('a@x.de', 'secret-password')
expect(push).toHaveBeenCalledWith('/')
})
it('shows an error message on invalid credentials', async () => {
const store = useAuthStore()
store.login = vi.fn().mockRejectedValue({ code: 'invalid_credentials', message: 'x' })
const w = mount(LoginPage)
await w.find('input[type=email]').setValue('a@x.de')
await w.find('input[type=password]').setValue('wrong')
await w.find('form').trigger('submit')
await flushPromises()
expect(w.text()).toContain('Invalid email or password')
expect(push).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,74 @@
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from './auth'
import { api } from '../api/client'
const admin = { id: '1', email: 'a@x.de', display_name: 'A', role: 'admin', is_active: true }
function mockFetch(responses: Array<{ status: number; body?: unknown }>) {
const calls: Array<{ url: string; init: RequestInit }> = []
let i = 0
globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} })
const r = responses[Math.min(i++, responses.length - 1)]
return new Response(r.body === undefined ? null : JSON.stringify(r.body), {
status: r.status,
headers: { 'content-type': 'application/json' },
})
}) as unknown as typeof fetch
return calls
}
beforeEach(() => setActivePinia(createPinia()))
describe('auth store', () => {
it('login stores access token and user', async () => {
mockFetch([{ status: 200, body: { access_token: 'tok', user: admin } }])
const store = useAuthStore()
await store.login('a@x.de', 'pw')
expect(store.accessToken).toBe('tok')
expect(store.user?.email).toBe('a@x.de')
expect(store.isAdmin).toBe(true)
})
it('login failure throws with the API error code', async () => {
mockFetch([{ status: 401, body: { error: 'invalid_credentials', message: 'nope' } }])
const store = useAuthStore()
await expect(store.login('a@x.de', 'pw')).rejects.toMatchObject({ code: 'invalid_credentials' })
expect(store.accessToken).toBeNull()
})
it('api client retries once with a refreshed token on 401', async () => {
const calls = mockFetch([
{ status: 401, body: { error: 'unauthorized' } },
{ status: 200, body: { access_token: 'fresh', user: admin } },
{ status: 200, body: [admin] },
])
const store = useAuthStore()
store.accessToken = 'stale'
const users = await api.get<unknown[]>('/api/users')
expect(users).toHaveLength(1)
expect(store.accessToken).toBe('fresh')
expect(calls[1].url).toBe('/api/auth/refresh')
expect((calls[2].init.headers as Record<string, string>)['Authorization']).toBe('Bearer fresh')
})
it('logs out when the refresh fails', async () => {
mockFetch([{ status: 401 }, { status: 401, body: { error: 'invalid_token' } }])
const store = useAuthStore()
store.accessToken = 'stale'
store.user = admin
await expect(api.get('/api/users')).rejects.toMatchObject({ status: 401 })
expect(store.accessToken).toBeNull()
expect(store.user).toBeNull()
})
it('logout calls the API and clears state', async () => {
const calls = mockFetch([{ status: 204 }])
const store = useAuthStore()
store.accessToken = 'tok'
store.user = admin
await store.logout()
expect(calls[0].url).toBe('/api/auth/logout')
expect(store.user).toBeNull()
})
})