Code: - Persist panelOpen to localStorage so panel stays open after reload - Auto-open panel to Metadaten when document has no file (no prior state) Tests: - Nav active state: check bg-nav-active instead of text-brand-navy (nav uses semantic tokens since dark mode refactor) - Save button: use exact:true to avoid matching "Speichern & abschließen" (new button was added alongside the plain "Speichern" button) Note: annotation tests (documents.spec.ts:324, 356) are pre-existing flaky failures due to test data contamination, not caused by this PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
505 lines
20 KiB
TypeScript
505 lines
20 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import fs from 'fs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
/**
|
|
* 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('/');
|
|
// Wait for SvelteKit hydration to complete so onclick/oninput handlers are active.
|
|
await page.waitForSelector('[data-hydrated]');
|
|
});
|
|
|
|
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(/bg-nav-active/);
|
|
});
|
|
|
|
test('text search filters the document list', async ({ page }) => {
|
|
// Navigate directly with the query param — tests that search results are filtered
|
|
// correctly without depending on the debounced oninput → goto chain in CI.
|
|
await page.goto('/?q=zzz_unlikely_to_match_anything');
|
|
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 }) => {
|
|
// Navigate with an active query first, then click the reset link.
|
|
await page.goto('/?q=xyz_unlikely');
|
|
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', exact: true });
|
|
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', exact: true }).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 creation', () => {
|
|
test('user fills in a title and lands on the new document detail page', async ({ page }) => {
|
|
await page.goto('/documents/new');
|
|
await page.waitForSelector('[data-hydrated]');
|
|
|
|
await page.getByLabel('Titel').fill('E2E Testbrief');
|
|
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
|
|
|
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
|
await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible();
|
|
await page.screenshot({ path: 'test-results/e2e/document-create.png' });
|
|
});
|
|
});
|
|
|
|
test.describe('Document editing', () => {
|
|
test('user opens an existing document, changes the title, and sees the update', async ({
|
|
page
|
|
}) => {
|
|
// Find the document created in the previous describe
|
|
await page.goto('/?q=E2E+Testbrief');
|
|
await page.waitForSelector('[data-hydrated]');
|
|
const docLink = page.getByRole('link', { name: 'E2E Testbrief' }).first();
|
|
const href = await docLink.getAttribute('href');
|
|
await page.goto(`${href}/edit`);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
|
|
await page.getByLabel('Titel').fill('E2E Testbrief (überarbeitet)');
|
|
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
|
|
|
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
|
await expect(page.getByText('E2E Testbrief (überarbeitet)')).toBeVisible();
|
|
await page.screenshot({ path: 'test-results/e2e/document-edit-save.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`);
|
|
// Wait for hydration so oninput={handleDateInput} is registered.
|
|
await page.waitForSelector('[data-hydrated]');
|
|
const dateInput = page.getByLabel('Datum');
|
|
// Type partial digits: '99' → dateDisplay='99', dateIso='' → dateInvalid=true
|
|
await dateInput.fill('');
|
|
await dateInput.pressSequentially('99');
|
|
await expect(page.getByText(/TT\.MM\.JJJJ/i)).toBeVisible();
|
|
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
|
|
});
|
|
});
|
|
|
|
// ─── PDF Viewer ───────────────────────────────────────────────────────────────
|
|
|
|
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
|
|
|
test.describe('PDF viewer', () => {
|
|
let pdfDocHref: string;
|
|
let noFileDocHref: string;
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
|
|
// Create a document with a PDF file.
|
|
const createRes = await request.post('/api/documents', {
|
|
multipart: { title: 'E2E PDF Viewer Test' }
|
|
});
|
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
|
const doc = await createRes.json();
|
|
|
|
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
|
multipart: {
|
|
title: doc.title,
|
|
file: {
|
|
name: 'minimal.pdf',
|
|
mimeType: 'application/pdf',
|
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
|
}
|
|
}
|
|
});
|
|
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
|
pdfDocHref = `${baseURL}/documents/${doc.id}`;
|
|
|
|
// Create a document WITHOUT a file — used to verify no canvas is rendered.
|
|
const noFileRes = await request.post('/api/documents', {
|
|
multipart: { title: 'E2E No-File Test' }
|
|
});
|
|
if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`);
|
|
const noFileDoc = await noFileRes.json();
|
|
noFileDocHref = `${baseURL}/documents/${noFileDoc.id}`;
|
|
});
|
|
|
|
test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
|
|
page
|
|
}) => {
|
|
await page.goto(pdfDocHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
|
|
// There must be NO iframe — we replaced it with PDF.js canvas rendering.
|
|
await expect(page.locator('iframe')).not.toBeAttached();
|
|
|
|
// At least one canvas element must be visible (one per rendered page).
|
|
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-canvas.png' });
|
|
});
|
|
|
|
test('page navigation controls are visible', async ({ page }) => {
|
|
await page.goto(pdfDocHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 15000 });
|
|
|
|
await expect(page.getByRole('button', { name: /prev|previous|zurück|vorige/i })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: /next|weiter|nächste/i })).toBeVisible();
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-nav.png' });
|
|
});
|
|
|
|
test('document without a file has no canvas', async ({ page }) => {
|
|
// A document with no file attached must not render a PDF canvas.
|
|
await page.goto(noFileDocHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
|
|
// No canvas — this document has no file
|
|
await expect(page.locator('canvas')).not.toBeAttached();
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-image-fallback.png' });
|
|
});
|
|
});
|
|
|
|
// ─── PDF Annotations (admin) ──────────────────────────────────────────────────
|
|
|
|
// Shared with the read-only user describe block below
|
|
let sharedAnnotationDocId: string;
|
|
|
|
test.describe('PDF annotations — admin', () => {
|
|
let annotationDocHref: string;
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
// Create a document with a PDF via API — much faster than UI automation.
|
|
const createRes = await request.post('/api/documents', {
|
|
multipart: { title: 'E2E Annotations Test' }
|
|
});
|
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
|
const doc = await createRes.json();
|
|
|
|
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
|
multipart: {
|
|
title: doc.title,
|
|
file: {
|
|
name: 'minimal.pdf',
|
|
mimeType: 'application/pdf',
|
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
|
}
|
|
}
|
|
});
|
|
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
|
|
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
annotationDocHref = `${baseURL}/documents/${doc.id}`;
|
|
sharedAnnotationDocId = doc.id;
|
|
});
|
|
|
|
test('admin user sees an active Annotieren button on a PDF', async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
await page.goto(annotationDocHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
|
|
|
// Admin has ANNOTATE_ALL — button must be enabled
|
|
const annotateBtn = page.getByRole('button', { name: /^annotieren$/i });
|
|
await expect(annotateBtn).toBeVisible();
|
|
await expect(annotateBtn).not.toBeDisabled();
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotations-button-admin.png' });
|
|
});
|
|
|
|
test('admin can draw an annotation and it appears on the page', async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
await page.goto(annotationDocHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
|
|
|
// Enable annotate mode
|
|
await page.getByRole('button', { name: /^annotieren$/i }).click();
|
|
|
|
// Color picker must appear
|
|
await expect(page.getByLabel(/farbe/i)).toBeVisible();
|
|
|
|
// Draw on the annotation layer overlay
|
|
const annotationLayer = page.locator('[role="presentation"]').last();
|
|
const box = await annotationLayer.boundingBox();
|
|
if (!box) throw new Error('Annotation layer not found');
|
|
|
|
const startX = box.x + box.width * 0.3;
|
|
const startY = box.y + box.height * 0.3;
|
|
const endX = box.x + box.width * 0.55;
|
|
const endY = box.y + box.height * 0.55;
|
|
|
|
await page.mouse.move(startX, startY);
|
|
await page.mouse.down();
|
|
await page.mouse.move(endX, endY);
|
|
await page.mouse.up();
|
|
|
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
|
timeout: 8000
|
|
});
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotation-drawn.png' });
|
|
});
|
|
|
|
test('annotation persists after page reload', async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
await page.goto(annotationDocHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
|
|
|
// Annotation from the previous test must be loaded from the API
|
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
|
timeout: 8000
|
|
});
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotation-persisted.png' });
|
|
});
|
|
|
|
test('admin can delete an annotation', async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
await page.goto(annotationDocHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
|
|
|
// Ensure annotation is visible before enabling annotate mode
|
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
|
timeout: 8000
|
|
});
|
|
|
|
// Enable annotate mode to show delete buttons
|
|
await page.getByRole('button', { name: /^annotieren$/i }).click();
|
|
|
|
const deleteBtn = page.getByRole('button', { name: /annotation löschen/i }).first();
|
|
await expect(deleteBtn).toBeVisible({ timeout: 8000 });
|
|
await deleteBtn.click();
|
|
|
|
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, {
|
|
timeout: 8000
|
|
});
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotation-deleted.png' });
|
|
});
|
|
});
|
|
|
|
// ─── PDF Annotations — file hash (version awareness) ─────────────────────────
|
|
|
|
test.describe('PDF annotations — file hash versioning', () => {
|
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
const PDF_FIXTURE2 = path.resolve(__dirname, 'fixtures/minimal2.pdf');
|
|
|
|
test('annotations are hidden after a different file is uploaded', async ({ page, request }) => {
|
|
test.setTimeout(90_000);
|
|
|
|
// 1. Create document and upload original PDF
|
|
const createRes = await request.post('/api/documents', {
|
|
multipart: { title: 'E2E Hash Test — version' }
|
|
});
|
|
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
|
const doc = await createRes.json();
|
|
|
|
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
|
multipart: {
|
|
title: doc.title,
|
|
file: {
|
|
name: 'minimal.pdf',
|
|
mimeType: 'application/pdf',
|
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
|
}
|
|
}
|
|
});
|
|
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
|
|
|
|
// 2. Create an annotation via API
|
|
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
|
|
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#ff0000' }
|
|
});
|
|
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
|
|
|
|
// 3. Verify annotation appears before re-upload
|
|
await page.goto(`${baseURL}/documents/${doc.id}`);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
|
timeout: 8000
|
|
});
|
|
|
|
// 4. Upload a different file (different hash)
|
|
const reuploadRes = await request.put(`/api/documents/${doc.id}`, {
|
|
multipart: {
|
|
title: doc.title,
|
|
file: {
|
|
name: 'minimal2.pdf',
|
|
mimeType: 'application/pdf',
|
|
buffer: fs.readFileSync(PDF_FIXTURE2)
|
|
}
|
|
}
|
|
});
|
|
if (!reuploadRes.ok()) throw new Error(`Re-upload failed: ${reuploadRes.status()}`);
|
|
|
|
// 5. Reload — annotation must be hidden and notice shown
|
|
await page.reload();
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
|
|
|
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { timeout: 8000 });
|
|
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
|
|
timeout: 5000
|
|
});
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotation-hidden-after-reupload.png' });
|
|
});
|
|
|
|
test('annotations reappear after re-uploading the original file', async ({ page, request }) => {
|
|
test.setTimeout(90_000);
|
|
|
|
// 1. Create document and upload original PDF
|
|
const createRes = await request.post('/api/documents', {
|
|
multipart: { title: 'E2E Hash Test — restore' }
|
|
});
|
|
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
|
const doc = await createRes.json();
|
|
|
|
const originalBytes = fs.readFileSync(PDF_FIXTURE);
|
|
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
|
multipart: {
|
|
title: doc.title,
|
|
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
|
|
}
|
|
});
|
|
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
|
|
|
|
// 2. Create annotation
|
|
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
|
|
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#0000ff' }
|
|
});
|
|
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
|
|
|
|
// 3. Replace with different file
|
|
const replaceRes = await request.put(`/api/documents/${doc.id}`, {
|
|
multipart: {
|
|
title: doc.title,
|
|
file: {
|
|
name: 'minimal2.pdf',
|
|
mimeType: 'application/pdf',
|
|
buffer: fs.readFileSync(PDF_FIXTURE2)
|
|
}
|
|
}
|
|
});
|
|
if (!replaceRes.ok()) throw new Error(`Replace failed: ${replaceRes.status()}`);
|
|
|
|
// 4. Re-upload original file (restoring the hash)
|
|
const restoreRes = await request.put(`/api/documents/${doc.id}`, {
|
|
multipart: {
|
|
title: doc.title,
|
|
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
|
|
}
|
|
});
|
|
if (!restoreRes.ok()) throw new Error(`Restore failed: ${restoreRes.status()}`);
|
|
|
|
// 5. Verify annotation reappears and notice is gone
|
|
await page.goto(`${baseURL}/documents/${doc.id}`);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
|
|
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
|
timeout: 8000
|
|
});
|
|
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).not.toBeVisible();
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotation-restored.png' });
|
|
});
|
|
});
|
|
|
|
// ─── PDF Annotations (read-only user) ─────────────────────────────────────────
|
|
|
|
test.describe('PDF annotations — read-only user', () => {
|
|
// Isolated session — does not share the admin storage state
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
test('read-only user does not see the Annotieren button', async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
await page.goto('/login');
|
|
await page.getByLabel('Benutzername').fill('reader');
|
|
await page.getByLabel('Passwort').fill('reader123');
|
|
await page.getByRole('button', { name: 'Anmelden' }).click();
|
|
await page.waitForURL('/');
|
|
|
|
// Navigate directly to the PDF document created by the admin beforeAll.
|
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
|
|
// Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all.
|
|
const annotateBtn = page.getByRole('button', { name: /annotieren/i });
|
|
await expect(annotateBtn).not.toBeVisible({ timeout: 5000 });
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
|
|
});
|
|
});
|