test: add e2e tests
This commit is contained in:
0
frontend/e2e/.auth/.gitkeep
Normal file
0
frontend/e2e/.auth/.gitkeep
Normal file
23
frontend/e2e/auth.setup.ts
Normal file
23
frontend/e2e/auth.setup.ts
Normal 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
60
frontend/e2e/auth.spec.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
110
frontend/e2e/documents.spec.ts
Normal file
110
frontend/e2e/documents.spec.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
13
frontend/e2e/helpers/auth.ts
Normal file
13
frontend/e2e/helpers/auth.ts
Normal 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('/');
|
||||
}
|
||||
97
frontend/e2e/persons.spec.ts
Normal file
97
frontend/e2e/persons.spec.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
1883
frontend/package-lock.json
generated
1883
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
45
frontend/playwright.config.ts
Normal file
45
frontend/playwright.config.ts
Normal 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'
|
||||
});
|
||||
184
frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts
Normal file
184
frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
196
frontend/src/lib/components/PersonTypeahead.svelte.spec.ts
Normal file
196
frontend/src/lib/components/PersonTypeahead.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
213
frontend/src/lib/components/TagInput.svelte.spec.ts
Normal file
213
frontend/src/lib/components/TagInput.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
80
frontend/src/lib/utils.spec.ts
Normal file
80
frontend/src/lib/utils.spec.ts
Normal 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
20
frontend/src/lib/utils.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
87
frontend/src/routes/login/page.svelte.spec.ts
Normal file
87
frontend/src/routes/login/page.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/**']
|
||||
|
||||
1255
frontend/yarn.lock
1255
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user