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