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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user