test: add e2e tests

This commit is contained in:
Marcel
2026-03-17 13:33:33 +00:00
parent 973620a097
commit 7cb20dec50
20 changed files with 3272 additions and 1376 deletions

View File

View File

@@ -0,0 +1,23 @@
import { test as setup } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '.auth/user.json');
/**
* Logs in once and saves the session cookie so all E2E tests can reuse it.
* Configure credentials via environment variables:
* E2E_USERNAME (default: admin)
* E2E_PASSWORD (default: admin)
*/
setup('authenticate', async ({ page }) => {
const username = process.env.E2E_USERNAME ?? 'admin';
const password = process.env.E2E_PASSWORD ?? 'admin';
await page.goto('/login');
await page.getByLabel('Benutzername').fill(username);
await page.getByLabel('Passwort').fill(password);
await page.getByRole('button', { name: 'Anmelden' }).click();
await page.waitForURL('/');
await page.context().storageState({ path: authFile });
});

60
frontend/e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
/**
* These tests run WITHOUT the stored session so they can test the login flow itself.
* Playwright's storageState is only applied for the 'chromium' project, which depends
* on the 'setup' project. These tests use a fresh context via test.use({ storageState: undefined }).
*/
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Authentication', () => {
test('login page renders correctly', async ({ page }) => {
await page.goto('/login');
await expect(page.getByLabel('Benutzername')).toBeVisible();
await expect(page.getByLabel('Passwort')).toBeVisible();
await expect(page.getByRole('button', { name: 'Anmelden' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/login-page.png' });
});
test('redirects unauthenticated users to /login', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
await page.screenshot({ path: 'test-results/e2e/auth-redirect.png' });
});
test('protected routes redirect to /login without session', async ({ page }) => {
for (const url of ['/documents/new', '/persons', '/conversations']) {
await page.goto(url);
await expect(page).toHaveURL(/\/login/);
}
});
test('shows an error for wrong credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Benutzername').fill('nichtexistent');
await page.getByLabel('Passwort').fill('falschespasswort');
await page.getByRole('button', { name: 'Anmelden' }).click();
// Stays on login, shows error
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('.text-red-600')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/login-error.png' });
});
test('login with valid credentials redirects to home', async ({ page }) => {
await login(page);
await expect(page).toHaveURL('/');
await expect(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/login-success.png' });
});
test('logout clears the session and redirects to /login', async ({ page }) => {
await login(page);
await page.getByRole('button', { name: 'Abmelden' }).click();
await expect(page).toHaveURL(/\/login/);
// Confirm session is gone: navigating to / redirects back
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
await page.screenshot({ path: 'test-results/e2e/logout.png' });
});
});

View File

@@ -0,0 +1,110 @@
import { test, expect } from '@playwright/test';
/**
* Document management E2E tests.
* Assumes auth setup has run (storageState is applied by playwright.config.ts).
* Assumes the backend has at least one document in the database.
*/
test.describe('Document list', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('renders the search bar and document list', async ({ page }) => {
await expect(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toBeVisible();
await expect(page.getByRole('link', { name: /Neues Dokument/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/documents-home.png' });
});
test('navigation bar shows active state for Dokumente', async ({ page }) => {
const navLink = page.getByRole('navigation').getByRole('link', { name: 'Dokumente' });
await expect(navLink).toHaveClass(/border-brand-navy/);
});
test('text search filters the document list', async ({ page }) => {
const input = page.getByPlaceholder('Suche in Titel, Inhalt, Ort...');
await input.fill('zzz_unlikely_to_match_anything');
// Wait for debounced navigation
await page.waitForURL(/\?q=/);
await expect(page.getByText('Keine Dokumente gefunden')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/documents-search-no-results.png' });
});
test('clearing the search returns all documents', async ({ page }) => {
const input = page.getByPlaceholder('Suche in Titel, Inhalt, Ort...');
await input.fill('xyz_unlikely');
await page.waitForURL(/\?q=/);
// Click the reset link
await page.getByTitle('Filter zurücksetzen').click();
await page.waitForURL('/');
await expect(page).toHaveURL('/');
await page.screenshot({ path: 'test-results/e2e/documents-reset-search.png' });
});
test('advanced filters panel opens and closes', async ({ page }) => {
const btn = page.getByRole('button', { name: /Filter/i });
await btn.click();
await expect(page.getByLabel('Von')).toBeVisible();
await expect(page.getByLabel('Bis')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/documents-filters-open.png' });
await btn.click();
await expect(page.getByLabel('Von')).not.toBeVisible();
});
test('date range filter triggers a new search', async ({ page }) => {
await page.getByRole('button', { name: /Filter/i }).click();
await page.getByLabel('Von').fill('2000-01-01');
await page.waitForURL(/from=2000-01-01/);
await expect(page).toHaveURL(/from=2000-01-01/);
await page.screenshot({ path: 'test-results/e2e/documents-date-filter.png' });
});
});
test.describe('Document detail', () => {
test('clicking a document opens the detail page', async ({ page }) => {
await page.goto('/');
// Click the first document link in the list
const firstDoc = page.locator('ul li a').first();
const href = await firstDoc.getAttribute('href');
await firstDoc.click();
await expect(page).toHaveURL(href!);
await page.screenshot({ path: 'test-results/e2e/document-detail.png' });
});
});
test.describe('New document', () => {
test('renders the upload form', async ({ page }) => {
await page.goto('/documents/new');
await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible();
await expect(page.getByLabel('Titel')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-new.png' });
});
});
test.describe('Document edit', () => {
test('renders the edit form with pre-filled data', async ({ page }) => {
// Navigate to home, find first document, go to its edit page
await page.goto('/');
const firstDocLink = page.locator('ul li a').first();
const href = await firstDocLink.getAttribute('href');
await page.goto(`${href}/edit`);
await expect(page.getByRole('heading', { name: /Bearbeiten/i })).toBeVisible();
await expect(page.getByLabel('Titel')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-edit.png' });
});
test('shows a validation error for an invalid date format', async ({ page }) => {
await page.goto('/');
const firstDocLink = page.locator('ul li a').first();
const href = await firstDocLink.getAttribute('href');
await page.goto(`${href}/edit`);
const dateInput = page.getByLabel('Datum');
await dateInput.fill('invalid');
// Wait for the derived dateInvalid to trigger (needs user to type something that doesn't parse)
// Type a partial date to trigger dirty+invalid
await dateInput.fill('');
await dateInput.pressSequentially('abc');
await expect(page.getByText(/TT\.MM\.JJJJ/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
});
});

View File

@@ -0,0 +1,13 @@
import type { Page } from '@playwright/test';
export async function login(
page: Page,
username = process.env.E2E_USERNAME ?? 'admin',
password = process.env.E2E_PASSWORD ?? 'admin'
) {
await page.goto('/login');
await page.getByLabel('Benutzername').fill(username);
await page.getByLabel('Passwort').fill(password);
await page.getByRole('button', { name: 'Anmelden' }).click();
await page.waitForURL('/');
}

View File

@@ -0,0 +1,97 @@
import { test, expect } from '@playwright/test';
test.describe('Person list', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/persons');
});
test('renders the persons list page', async ({ page }) => {
await expect(page.getByRole('heading', { name: /Personen/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Neue Person/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/persons-list.png' });
});
test('search filters the persons list', async ({ page }) => {
const searchInput = page.getByPlaceholder(/Namen suchen/i);
await searchInput.fill('zzz_unlikely_match');
await page.waitForTimeout(600); // debounce
await expect(page.getByText(/Keine Personen gefunden/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/persons-search-empty.png' });
});
test('clicking a person opens the detail page', async ({ page }) => {
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
await expect(page).toHaveURL(/\/persons\/.+/);
await page.screenshot({ path: 'test-results/e2e/person-detail.png' });
});
});
test.describe('Person detail', () => {
test('shows the person name and their documents', async ({ page }) => {
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
// The detail page shows the person's name as a heading
await expect(page.getByRole('heading')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-detail-documents.png' });
});
test('can enter and cancel edit mode', async ({ page }) => {
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
// Click the edit button
const editBtn = page.getByRole('button', { name: /Bearbeiten/i });
if (await editBtn.isVisible()) {
await editBtn.click();
await expect(page.getByLabel('Vorname')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-edit-form.png' });
// Cancel
await page.getByRole('button', { name: /Abbrechen/i }).click();
await expect(page.getByLabel('Vorname')).not.toBeVisible();
}
});
});
test.describe('New person', () => {
test('renders the new person form', async ({ page }) => {
await page.goto('/persons/new');
await expect(page.getByLabel('Vorname')).toBeVisible();
await expect(page.getByLabel('Nachname')).toBeVisible();
await expect(page.getByRole('button', { name: /Erstellen/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-new.png' });
});
test('shows a validation error when submitting with empty fields', async ({ page }) => {
await page.goto('/persons/new');
// HTML required attribute prevents submission without filling required fields
await page.getByRole('button', { name: /Erstellen/i }).click();
// The form should not have navigated away
await expect(page).toHaveURL('/persons/new');
});
});
test.describe('Conversations', () => {
test('shows the empty state when no persons are selected', async ({ page }) => {
await page.goto('/conversations');
await expect(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/conversations-empty.png' });
});
test('nav link is active on the conversations page', async ({ page }) => {
await page.goto('/conversations');
const navLink = page.getByRole('link', { name: 'Konversationen' });
await expect(navLink).toHaveClass(/border-brand-navy/);
});
test('sort toggle changes the button label', async ({ page }) => {
await page.goto('/conversations');
const btn = page.getByRole('button', { name: /Sortierung/i });
await expect(btn).toContainText('Neueste zuerst');
await btn.click();
await expect(page).toHaveURL(/dir=ASC/);
await expect(btn).toContainText('Älteste zuerst');
await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' });
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -14,16 +14,19 @@
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
},
"dependencies": {
"openapi-fetch": "^0.13.5"
},
"devDependencies": {
"openapi-typescript": "^7.8.0",
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@inlang/paraglide-js": "^2.5.0",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
@@ -36,6 +39,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",
"globals": "^16.5.0",
"openapi-typescript": "^7.8.0",
"playwright": "^1.56.1",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",

View File

@@ -0,0 +1,45 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
export default defineConfig({
testDir: './e2e',
// Auto-starts the SvelteKit dev server before E2E tests.
// Reuses the existing server if already running (e.g. during active development).
// The backend + DB + MinIO must be started separately (see README or CI workflow).
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
timeout: 30_000
},
fullyParallel: false, // tests share auth state → run sequentially within a worker
retries: process.env.CI ? 2 : 0,
workers: 1,
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
screenshot: 'on', // always capture screenshots
video: 'retain-on-failure',
trace: 'retain-on-failure'
},
projects: [
// 1. Auth setup: logs in and saves the session cookie to disk
{
name: 'setup',
testMatch: /auth\.setup\.ts/
},
// 2. All E2E tests, re-using the stored session
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: path.join(__dirname, 'e2e/.auth/user.json')
},
dependencies: ['setup']
}
],
outputDir: 'test-results/e2e'
});

View File

@@ -0,0 +1,184 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonMultiSelect from './PersonMultiSelect.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' },
{ id: '3', firstName: 'Karl', lastName: 'König' }
];
function mockFetch(persons = PERSONS) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(persons)
})
);
}
function receiverInputs() {
return Array.from(
document.querySelectorAll<HTMLInputElement>('input[type="hidden"][name="receiverIds"]')
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('PersonMultiSelect rendering', () => {
it('renders the text input with placeholder when no persons selected', async () => {
render(PersonMultiSelect, { selectedPersons: [] });
await expect.element(page.getByPlaceholder('Namen tippen...')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-multiselect-empty.png' });
});
it('renders pre-selected persons as chips', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
]
});
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-multiselect-with-chips.png' });
});
it('renders hidden inputs for each selected person', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
]
});
await tick();
const inputs = receiverInputs();
expect(inputs).toHaveLength(2);
expect(inputs[0].value).toBe('1');
expect(inputs[1].value).toBe('2');
});
it('hides the placeholder when persons are selected', async () => {
render(PersonMultiSelect, {
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
});
await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument();
});
});
// ─── Selecting persons ────────────────────────────────────────────────────────
describe('PersonMultiSelect selecting persons', () => {
it('adds a person chip on result click', async () => {
mockFetch();
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(input).toHaveValue('');
await page.screenshot({
path: 'test-results/screenshots/person-multiselect-one-selected.png'
});
});
it('can select multiple persons sequentially', async () => {
mockFetch();
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Musterfrau, Anna').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
await page.screenshot({
path: 'test-results/screenshots/person-multiselect-two-selected.png'
});
});
it('filters already-selected persons from search results', async () => {
mockFetch();
render(PersonMultiSelect, {
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
});
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
});
it('selects a result with Enter key', async () => {
mockFetch([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Ma');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
});
});
// ─── Removing persons ─────────────────────────────────────────────────────────
describe('PersonMultiSelect removing persons', () => {
it('removes a chip when its × button is clicked', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
]
});
// Buttons have aria-label="Entfernen"
const removeButtons = page.getByRole('button', { name: 'Entfernen' });
await removeButtons.first().click();
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
});
it('removes the corresponding hidden input when a chip is removed', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
]
});
await page.getByRole('button', { name: 'Entfernen' }).first().click();
await tick();
const inputs = receiverInputs();
expect(inputs).toHaveLength(1);
expect(inputs[0].value).toBe('2');
});
});
// ─── Click outside ────────────────────────────────────────────────────────────
describe('PersonMultiSelect click outside', () => {
it('closes the dropdown when clicking outside', async () => {
mockFetch();
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
document.body.click();
await tick();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,196 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import PersonTypeahead from './PersonTypeahead.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
];
function mockFetchWithPersons(persons = PERSONS) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(persons)
})
);
}
function mockFetchFailure() {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
}
function hiddenInput(name: string) {
return document.querySelector<HTMLInputElement>(`input[type="hidden"][name="${name}"]`);
}
afterEach(() => {
vi.unstubAllGlobals();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('PersonTypeahead rendering', () => {
it('renders the label and text input', async () => {
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
await expect.element(page.getByText('Absender')).toBeInTheDocument();
await expect.element(page.getByPlaceholder('Namen tippen...')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-empty.png' });
});
it('pre-fills the visible input from initialName', async () => {
render(PersonTypeahead, {
name: 'senderId',
label: 'Absender',
initialName: 'Max Mustermann'
});
// The $effect that syncs initialName runs after mount — poll until the value appears
await expect.element(page.getByPlaceholder('Namen tippen...')).toHaveValue('Max Mustermann');
});
it('renders a hidden input with the correct name attribute', async () => {
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
await tick();
expect(hiddenInput('senderId')).toBeTruthy();
});
it('hidden input starts with the provided value', async () => {
render(PersonTypeahead, { name: 'senderId', label: 'Absender', value: '42' });
await tick();
expect(hiddenInput('senderId')?.value).toBe('42');
});
});
// ─── Search ───────────────────────────────────────────────────────────────────
describe('PersonTypeahead search', () => {
it('opens the dropdown with results after typing', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
});
it('shows loading indicator while fetching', async () => {
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await expect.element(page.getByText('Suche...')).toBeInTheDocument();
});
it('shows no dropdown when the search returns empty results', async () => {
mockFetchWithPersons([]);
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('XYZ');
await waitForDebounce();
await expect.element(page.getByText('Suche...')).not.toBeInTheDocument();
});
it('shows no results when fetch fails', async () => {
mockFetchFailure();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
});
});
// ─── Selection ────────────────────────────────────────────────────────────────
describe('PersonTypeahead selection', () => {
it('fills the visible input and closes dropdown on click', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await expect.element(input).toHaveValue('Max Mustermann');
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
});
it('sets the hidden input value to the selected person id', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await tick();
expect(hiddenInput('senderId')?.value).toBe('1');
});
it('calls onchange with the person id on selection', async () => {
mockFetchWithPersons();
const onchange = vi.fn();
render(PersonTypeahead, { name: 'senderId', label: 'Absender', onchange });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
expect(onchange).toHaveBeenCalledWith('1');
});
it('selects a result with Enter key', async () => {
mockFetchWithPersons([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await expect.element(input).toHaveValue('Max Mustermann');
});
});
// ─── Clearing ─────────────────────────────────────────────────────────────────
describe('PersonTypeahead clearing a selection', () => {
it('clears the hidden value when user edits the visible input after a selection', async () => {
mockFetchWithPersons();
const onchange = vi.fn();
render(PersonTypeahead, { name: 'senderId', label: 'Absender', onchange });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
expect(onchange).toHaveBeenCalledWith('1');
onchange.mockClear();
await input.fill('x');
await waitForDebounce();
expect(onchange).toHaveBeenCalledWith('');
await tick();
expect(hiddenInput('senderId')?.value).toBe('');
});
});
// ─── Click outside ────────────────────────────────────────────────────────────
describe('PersonTypeahead click outside', () => {
it('closes the dropdown when clicking outside the component', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
document.body.click();
await tick();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,213 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import TagInput from './TagInput.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));
function mockFetchWithTags(tagNames: string[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(tagNames.map((name) => ({ name })))
})
);
}
function mockFetchEmpty() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('TagInput rendering', () => {
it('shows creation placeholder when allowCreation=true and no tags', async () => {
render(TagInput, { tags: [], allowCreation: true });
await expect
.element(page.getByPlaceholder('Schlagworte hinzufügen...'))
.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/tag-input-empty.png' });
});
it('shows filter placeholder when allowCreation=false', async () => {
render(TagInput, { tags: [], allowCreation: false });
await expect
.element(page.getByPlaceholder('Nach Schlagworten filtern...'))
.toBeInTheDocument();
});
it('renders existing tags as chips', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
await expect.element(page.getByText('Familie')).toBeInTheDocument();
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/tag-input-with-chips.png' });
});
it('hides input placeholder once tags exist', async () => {
render(TagInput, { tags: ['Familie'], allowCreation: true });
const input = page.getByRole('textbox');
await expect.element(input).toHaveAttribute('placeholder', '');
});
it('shows the "Enter" hint when allowCreation=true', async () => {
render(TagInput, { tags: [], allowCreation: true });
await expect.element(page.getByText(/Enter drücken/i)).toBeInTheDocument();
});
it('hides the "Enter" hint when allowCreation=false', async () => {
render(TagInput, { tags: [], allowCreation: false });
await expect.element(page.getByText(/Enter drücken/i)).not.toBeInTheDocument();
});
});
// ─── Adding tags ──────────────────────────────────────────────────────────────
describe('TagInput adding tags', () => {
it('adds a tag on Enter and clears the input', async () => {
mockFetchEmpty();
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Urlaubsreise');
await userEvent.keyboard('{Enter}');
await expect.element(page.getByText('Urlaubsreise')).toBeInTheDocument();
await expect.element(input).toHaveValue('');
});
it('trims whitespace from the new tag', async () => {
mockFetchEmpty();
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill(' Leerzeichen ');
await userEvent.keyboard('{Enter}');
await expect.element(page.getByText('Leerzeichen')).toBeInTheDocument();
});
it('does not add a duplicate tag', async () => {
mockFetchEmpty();
render(TagInput, { tags: ['Familie'], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Familie');
await userEvent.keyboard('{Enter}');
await expect.element(input).toHaveValue('');
await expect.element(page.getByText('Familie')).toBeInTheDocument();
});
it('does not add an arbitrary tag when allowCreation=false', async () => {
mockFetchEmpty();
render(TagInput, { tags: [], allowCreation: false });
const input = page.getByRole('textbox');
await input.fill('UnbekannterTag');
await userEvent.keyboard('{Enter}');
await expect.element(page.getByText('UnbekannterTag')).not.toBeInTheDocument();
});
});
// ─── Removing tags ────────────────────────────────────────────────────────────
describe('TagInput removing tags', () => {
it('removes a chip when its × button is clicked', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
// The × buttons have aria-label="Schlagwort entfernen"
const removeButtons = page.getByRole('button', { name: 'Schlagwort entfernen' });
await removeButtons.first().click();
await expect.element(page.getByText('Familie')).not.toBeInTheDocument();
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/tag-input-after-remove.png' });
});
it('removes the last tag on Backspace when the input is empty', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
const input = page.getByRole('textbox');
await input.click();
await userEvent.keyboard('{Backspace}');
await expect.element(page.getByText('Krieg')).not.toBeInTheDocument();
await expect.element(page.getByText('Familie')).toBeInTheDocument();
});
it('does not remove a tag on Backspace when the input has text', async () => {
render(TagInput, { tags: ['Familie'], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('x');
await userEvent.keyboard('{Backspace}');
await expect.element(page.getByText('Familie')).toBeInTheDocument();
});
});
// ─── Autocomplete ─────────────────────────────────────────────────────────────
describe('TagInput autocomplete', () => {
it('shows suggestions after typing 2+ characters', async () => {
mockFetchWithTags(['Familie', 'Freunde']);
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Fa');
await waitForDebounce();
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestions.png' });
});
it('does not call fetch for fewer than 2 characters', async () => {
mockFetchEmpty();
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('F');
await waitForDebounce();
expect(fetch).not.toHaveBeenCalled();
});
it('filters already-selected tags out of suggestions', async () => {
mockFetchWithTags(['Familie', 'Freunde']);
render(TagInput, { tags: ['Familie'], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Fr');
await waitForDebounce();
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
});
it('selects a suggestion on click and adds it as a chip', async () => {
mockFetchWithTags(['Familie', 'Freunde']);
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Fa');
await waitForDebounce();
await page.getByRole('option', { name: 'Familie' }).click();
await expect.element(page.getByText('Familie')).toBeInTheDocument();
await expect.element(input).toHaveValue('');
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestion-selected.png' });
});
it('navigates suggestions with ArrowDown and selects with Enter', async () => {
mockFetchWithTags(['Aachen', 'Berlin', 'Celle']);
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('__');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}'); // index 0 → Aachen
await userEvent.keyboard('{ArrowDown}'); // index 1 → Berlin
await userEvent.keyboard('{Enter}');
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
});
it('hides the dropdown when clicking outside the component', async () => {
mockFetchWithTags(['Familie']);
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Fa');
await waitForDebounce();
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
document.body.click();
await tick();
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { germanToIso, isoToGerman } from './utils';
describe('isoToGerman', () => {
it('converts a standard ISO date', () => {
expect(isoToGerman('2024-03-15')).toBe('15.03.2024');
});
it('preserves leading zeros for day and month', () => {
expect(isoToGerman('2024-01-05')).toBe('05.01.2024');
});
it('handles December 31', () => {
expect(isoToGerman('1945-12-31')).toBe('31.12.1945');
});
it('returns empty string for empty input', () => {
expect(isoToGerman('')).toBe('');
});
it('returns empty string for plain text', () => {
expect(isoToGerman('not-a-date')).toBe('');
});
it('returns empty string for partial ISO string', () => {
expect(isoToGerman('2024-03')).toBe('');
});
it('returns empty string for ISO with time component', () => {
expect(isoToGerman('2024-03-15T12:00:00')).toBe('');
});
});
describe('germanToIso', () => {
it('converts a standard German date', () => {
expect(germanToIso('15.03.2024')).toBe('2024-03-15');
});
it('preserves leading zeros for day and month', () => {
expect(germanToIso('05.01.2024')).toBe('2024-01-05');
});
it('handles December 31', () => {
expect(germanToIso('31.12.1945')).toBe('1945-12-31');
});
it('returns empty string for empty input', () => {
expect(germanToIso('')).toBe('');
});
it('returns empty string for plain text', () => {
expect(germanToIso('not-a-date')).toBe('');
});
it('returns empty string for date without leading zeros', () => {
expect(germanToIso('5.3.2024')).toBe('');
});
it('returns empty string for ISO format input', () => {
expect(germanToIso('2024-03-15')).toBe('');
});
it('returns empty string for partial German date', () => {
expect(germanToIso('15.03')).toBe('');
});
});
describe('round-trip conversion', () => {
const dates = ['2024-03-15', '1945-01-01', '2000-12-31', '1899-07-04'];
for (const date of dates) {
it(`ISO → German → ISO is identity for ${date}`, () => {
expect(germanToIso(isoToGerman(date))).toBe(date);
});
}
it('German → ISO → German is identity', () => {
expect(isoToGerman(germanToIso('20.04.1889'))).toBe('20.04.1889');
});
});

20
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Converts an ISO date string (YYYY-MM-DD) to German display format (DD.MM.YYYY).
* Returns an empty string for invalid or empty input.
*/
export function isoToGerman(iso: string): string {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
const [y, m, d] = iso.split('-');
return `${d}.${m}.${y}`;
}
/**
* Converts a German date string (DD.MM.YYYY) to ISO format (YYYY-MM-DD).
* Returns an empty string for invalid or empty input.
*/
export function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
const [, d, m, y] = match;
return `${y}-${m}-${d}`;
}

View File

@@ -4,6 +4,7 @@
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import { untrack } from 'svelte';
import { isoToGerman, germanToIso } from '$lib/utils';
let { data, form } = $props();
@@ -12,19 +13,6 @@
let senderId = $state(doc.sender?.id ?? '');
let selectedReceivers = $state(doc.receivers ?? []);
function isoToGerman(iso: string): string {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
const [y, m, d] = iso.split('-');
return `${d}.${m}.${y}`;
}
function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
const [, d, m, y] = match;
return `${y}-${m}-${d}`;
}
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
let dateIso = $state(doc.documentDate ?? '');
let dateDirty = $state(false);

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import LoginPage from './+page.svelte';
const tick = () => new Promise((r) => setTimeout(r, 0));
describe('Login page rendering', () => {
it('renders the page title', async () => {
render(LoginPage, {});
await expect.element(page.getByText('Familienarchiv')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/login-default.png' });
});
it('renders the submit button', async () => {
render(LoginPage, {});
await expect.element(page.getByRole('button', { name: 'Anmelden' })).toBeInTheDocument();
});
it('renders the username input', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="username"]');
expect(input).not.toBeNull();
});
it('renders the password input', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
expect(input).not.toBeNull();
});
it('username field is required', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="username"]');
expect(input?.required).toBe(true);
});
it('password field is required', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
expect(input?.required).toBe(true);
});
it('password field has type="password"', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
expect(input?.type).toBe('password');
});
it('form submits to the login action', async () => {
render(LoginPage, {});
await tick();
const form = document.querySelector<HTMLFormElement>('form');
expect(form?.action).toMatch(/\?\/login$/);
});
});
describe('Login page error state', () => {
it('shows no error when form is undefined', async () => {
render(LoginPage, {});
await tick();
expect(document.querySelector('.text-red-600')).toBeNull();
});
it('shows no error when form has no error property', async () => {
render(LoginPage, { form: {} });
await tick();
expect(document.querySelector('.text-red-600')).toBeNull();
});
it('displays the error message from the form action', async () => {
render(LoginPage, { form: { error: 'Ungültige Anmeldedaten.' } });
await expect.element(page.getByText('Ungültige Anmeldedaten.')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/login-error.png' });
});
it('applies red styling to the error text', async () => {
render(LoginPage, { form: { error: 'Fehler!' } });
await tick();
expect(document.querySelector('.text-red-600')).not.toBeNull();
});
});

View File

@@ -1,13 +1,172 @@
import { page } from 'vitest/browser';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
const tick = () => new Promise((r) => setTimeout(r, 0));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
// Silence fetch calls from PersonTypeahead when advanced filters are open
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
);
// ─── Test data ────────────────────────────────────────────────────────────────
const emptyData = {
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
documents: [],
initialValues: { senderName: '', receiverName: '' },
error: null
};
const makeDoc = (overrides = {}) => ({
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
status: 'UPLOADED',
documentDate: '2024-03-15',
location: 'Berlin',
sender: { id: 'p1', firstName: 'Max', lastName: 'Mustermann' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Musterfrau' }],
tags: [{ name: 'Familie' }],
filePath: '/files/testbrief.pdf',
...overrides
});
const dataWithDocs = { ...emptyData, documents: [makeDoc()] };
// ─── Search bar ───────────────────────────────────────────────────────────────
describe('Home page search bar', () => {
it('renders the full-text search input', async () => {
render(Page, { data: emptyData });
await expect
.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...'))
.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/home-default.png' });
});
it('renders the filter toggle button', async () => {
render(Page, { data: emptyData });
// Use exact match to avoid collision with the empty-state "Alle Filter löschen" button
await expect
.element(page.getByRole('button', { name: 'Filter', exact: true }))
.toBeInTheDocument();
});
it('renders the reset link pointing to /', async () => {
render(Page, { data: emptyData });
const resetLink = page.getByTitle('Filter zurücksetzen');
await expect.element(resetLink).toBeInTheDocument();
await expect.element(resetLink).toHaveAttribute('href', '/');
});
it('pre-fills the search input from filters.q', async () => {
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
await expect.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toHaveValue('Urlaub');
});
});
// ─── Advanced filters ─────────────────────────────────────────────────────────
describe('Home page advanced filters', () => {
it('hides the advanced filters by default', async () => {
render(Page, { data: emptyData });
// Date inputs are inside the {#if showAdvanced} block → not in DOM
await tick();
expect(document.querySelector('input[id="from"]')).toBeNull();
expect(document.querySelector('input[id="to"]')).toBeNull();
});
it('toggles the advanced filter panel open on button click', async () => {
render(Page, { data: emptyData });
await page.getByRole('button', { name: 'Filter', exact: true }).click();
await tick();
expect(document.querySelector('input[id="from"]')).not.toBeNull();
expect(document.querySelector('input[id="to"]')).not.toBeNull();
await page.screenshot({ path: 'test-results/screenshots/home-filters-open.png' });
});
it('collapses the advanced filter panel on second click', async () => {
render(Page, { data: emptyData });
const btn = page.getByRole('button', { name: 'Filter', exact: true });
await btn.click();
// Wait for the input to appear before clicking again
await expect.element(page.getByText('Schlagworte')).toBeInTheDocument();
await btn.click();
// Wait for slide transition to finish
await expect.element(page.getByText('Schlagworte')).not.toBeInTheDocument();
});
it('renders the tag filter section when filters are open', async () => {
render(Page, { data: emptyData });
await page.getByRole('button', { name: 'Filter', exact: true }).click();
await expect.element(page.getByText('Schlagworte')).toBeInTheDocument();
});
});
// ─── Document list ────────────────────────────────────────────────────────────
describe('Home page document list', () => {
it('shows empty state when there are no documents', async () => {
render(Page, { data: emptyData });
await expect.element(page.getByText('Keine Dokumente gefunden')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/home-empty-state.png' });
});
it('renders a document with title, date, location, sender and receiver', async () => {
render(Page, { data: dataWithDocs });
await expect.element(page.getByText('Testbrief')).toBeInTheDocument();
await expect.element(page.getByText('15. März 2024')).toBeInTheDocument();
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/home-with-documents.png' });
});
it('renders a tag chip for each document tag', async () => {
render(Page, { data: dataWithDocs });
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
});
it('renders "Unbekannt" for sender when sender is null', async () => {
const data = { ...emptyData, documents: [makeDoc({ sender: null })] };
render(Page, { data });
await expect.element(page.getByText('Unbekannt')).toBeInTheDocument();
});
it('renders original filename when title is empty', async () => {
const data = { ...emptyData, documents: [makeDoc({ title: null })] };
render(Page, { data });
await expect.element(page.getByText('testbrief.pdf')).toBeInTheDocument();
});
it('links each document to its detail page', async () => {
render(Page, { data: dataWithDocs });
const link = page.getByRole('link', { name: /Testbrief/ });
await expect.element(link).toHaveAttribute('href', '/documents/1');
});
it('renders the "Neues Dokument" link', async () => {
render(Page, { data: emptyData });
const link = page.getByRole('link', { name: /Neues Dokument/i });
await expect.element(link).toBeInTheDocument();
await expect.element(link).toHaveAttribute('href', '/documents/new');
});
});
// ─── Error state ──────────────────────────────────────────────────────────────
describe('Home page error state', () => {
it('shows the error message when data.error is set', async () => {
const data = { ...emptyData, error: 'Daten konnten nicht geladen werden.' };
render(Page, { data });
await expect
.element(page.getByText('Daten konnten nicht geladen werden.'))
.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/home-error.png' });
});
});

View File

@@ -36,7 +36,9 @@ export default defineConfig({
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium', headless: true }]
instances: [{ browser: 'chromium', headless: true }],
screenshotDirectory: 'test-results/screenshots',
screenshotFailures: true
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**']

File diff suppressed because it is too large Load Diff