diff --git a/frontend/e2e/.auth/user.json b/frontend/e2e/.auth/user.json index 1398d3b8..de4f774a 100644 --- a/frontend/e2e/.auth/user.json +++ b/frontend/e2e/.auth/user.json @@ -5,7 +5,7 @@ "value": "de", "domain": "localhost", "path": "/", - "expires": 1808565334.192108, + "expires": 1808896929.897686, "httpOnly": false, "secure": false, "sameSite": "Lax" @@ -15,7 +15,7 @@ "value": "Basic%20YWRtaW46YWRtaW4xMjM%3D", "domain": "localhost", "path": "/", - "expires": 1774091734.449243, + "expires": 1774423330.233039, "httpOnly": true, "secure": false, "sameSite": "Strict" diff --git a/frontend/e2e/documents.spec.ts b/frontend/e2e/documents.spec.ts index 54e56b1d..6c17518e 100644 --- a/frontend/e2e/documents.spec.ts +++ b/frontend/e2e/documents.spec.ts @@ -1,5 +1,9 @@ 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. @@ -150,29 +154,38 @@ const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); test.describe('PDF viewer', () => { let pdfDocHref: string; + let noFileDocHref: string; - test.beforeAll(async ({ browser }) => { - // Create a document and upload the PDF fixture so later tests have a - // real file attached. Runs once for the whole describe block. - const ctx = await browser.newContext(); - const p = await ctx.newPage(); + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; - await p.goto('/documents/new'); - await p.waitForSelector('[data-hydrated]'); - await p.getByLabel('Titel').fill('E2E PDF Viewer Test'); - await p.getByRole('button', { name: /Speichern/i }).click(); - await p.waitForURL(/\/documents\/[^/]+$/); + // 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(); - // Upload the PDF on the edit page - const href = p.url().replace(/\/$/, ''); - pdfDocHref = href; - await p.goto(`${href}/edit`); - await p.waitForSelector('[data-hydrated]'); - await p.locator('input[type="file"][name="file"]').setInputFiles(PDF_FIXTURE); - await p.getByRole('button', { name: /Speichern/i }).click(); - await p.waitForURL(/\/documents\/[^/]+$/); + 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}`; - await ctx.close(); + // 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 ({ @@ -201,20 +214,164 @@ test.describe('PDF viewer', () => { await page.screenshot({ path: 'test-results/e2e/pdf-viewer-nav.png' }); }); - test('non-PDF attachment renders as an img element, not canvas', async ({ page }) => { - // The seed document "Urlaubspostkarte Ostsee" has a .jpg original filename. - // Navigate to it and confirm an is used (no canvas, no iframe). - await page.goto('/'); - await page.waitForSelector('[data-hydrated]'); - await page.goto('/?q=Urlaubspostkarte'); - const link = page.getByRole('link', { name: /Urlaubspostkarte/i }).first(); - const href = await link.getAttribute('href'); - await page.goto(href!); + 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 is an image document + // 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 (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 sees a disabled 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]'); + // Wait for the PDF canvas — once rendered, the controls bar (with disabled button) is shown. + await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 30000 }); + + const disabledBtn = page.getByRole('button', { name: /annotieren/i }); + await expect(disabledBtn).toBeVisible({ timeout: 5000 }); + await expect(disabledBtn).toBeDisabled(); + + await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' }); + }); +});