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 }) => { // 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('/persons?q=zzz_unlikely_match'); 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 the top-level heading await expect(page.getByRole('heading', { level: 1 })).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('birth and death year fields appear in edit mode and save correctly', async ({ page }) => { await page.goto('/persons'); const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first(); await firstPerson.click(); await page.waitForSelector('[data-hydrated]'); const editBtn = page.getByRole('button', { name: /Bearbeiten/i }); await editBtn.click(); await expect(page.getByLabel(/Geburtsjahr/i)).toBeVisible(); await expect(page.getByLabel(/Todesjahr/i)).toBeVisible(); await page.getByLabel(/Geburtsjahr/i).fill('1890'); await page.getByLabel(/Todesjahr/i).fill('1965'); await page.getByRole('button', { name: /Speichern/i }).click(); // After saving, the years should be shown in view mode await expect(page.getByText('* 1890')).toBeVisible(); await expect(page.getByText('† 1965')).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-birth-death-years.png' }); }); }); 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('Person creation', () => { test('user fills in first and last name and lands on the new person detail page', async ({ page }) => { await page.goto('/persons/new'); await page.getByLabel('Vorname').fill('E2E'); await page.getByLabel('Nachname').fill('Testperson'); await page.getByRole('button', { name: /Erstellen/i }).click(); await expect(page).toHaveURL(/\/persons\/[^/]+$/); await expect(page.getByRole('heading', { name: 'E2E Testperson' })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-create.png' }); }); }); test.describe('Person detail — sort toggle', () => { test('each section has its own sort toggle that works independently', async ({ page }) => { await page.goto('/persons'); const firstPerson = page.locator('a[href^="/persons/"]').first(); await firstPerson.click(); await page.waitForSelector('[data-hydrated]'); // Find sort buttons — there may be 0, 1 or 2 depending on whether sections have >1 doc const sortBtns = page.getByRole('button', { name: /Neueste zuerst|Älteste zuerst/i }); const btnCount = await sortBtns.count(); if (btnCount >= 1) { const firstBtn = sortBtns.first(); await expect(firstBtn).toContainText('Neueste zuerst'); await firstBtn.click(); await expect(firstBtn).toContainText('Älteste zuerst'); await firstBtn.click(); await expect(firstBtn).toContainText('Neueste zuerst'); } if (btnCount >= 2) { // Second sort button toggles independently const secondBtn = sortBtns.nth(1); await expect(secondBtn).toContainText('Neueste zuerst'); await secondBtn.click(); await expect(secondBtn).toContainText('Älteste zuerst'); // First button should be unaffected await expect(sortBtns.first()).toContainText('Neueste zuerst'); } await page.screenshot({ path: 'test-results/e2e/person-sort-toggle.png' }); }); }); test.describe('Person detail — sent and received documents', () => { test('shows both sent and received document sections', async ({ page }) => { await page.goto('/persons'); const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first(); await firstPerson.click(); await page.waitForSelector('[data-hydrated]'); await expect(page.getByRole('heading', { name: /Gesendete Dokumente/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /Empfangene Dokumente/i })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-sent-received.png' }); }); test('shows year range next to document count when documents have dates', async ({ page }) => { // Navigate to the first person who has documents with dates await page.goto('/persons'); const personLinks = page.locator('a[href^="/persons/"]:not([href="/persons/new"])'); const count = await personLinks.count(); for (let i = 0; i < count; i++) { await page.goto('/persons'); await personLinks.nth(i).click(); await page.waitForSelector('[data-hydrated]'); // Check if either section heading has a year range (4 digits) const sentHeading = page.getByRole('heading', { name: /Gesendete Dokumente/i }).locator('..'); const hasYearRange = await sentHeading.locator('span').filter({ hasText: /\d{4}/ }).count(); if (hasYearRange > 0) { await expect( sentHeading.locator('span').filter({ hasText: /\d{4}/ }).first() ).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-year-range.png' }); return; } } // If no person has dated documents, the test is a no-op (year range is optional) }); }); // ── J5: Add a relationship on the person edit page ──────────────────────── // // Creates two persons via API, then opens the first person's edit page and // uses the AddRelationshipForm to link them. Asserts the chip appears. test.describe('Person relationship — add via edit page (J5)', () => { let personAId: string; let personBName: string; test.beforeAll(async ({ request }) => { const stamp = Date.now().toString(36); const aRes = await request.post('/api/persons', { data: { firstName: 'E2E-Rel-A', lastName: stamp } }); if (!aRes.ok()) throw new Error(`Create person A failed: ${aRes.status()}`); const a = await aRes.json(); personAId = a.id; const bRes = await request.post('/api/persons', { data: { firstName: 'E2E-Rel-B', lastName: stamp } }); if (!bRes.ok()) throw new Error(`Create person B failed: ${bRes.status()}`); const b = await bRes.json(); personBName = b.displayName ?? `E2E-Rel-B ${stamp}`; }); test('user adds a SPOUSE_OF relationship and sees the chip on the edit page', async ({ page }) => { await page.goto(`/persons/${personAId}/edit`); await page.waitForSelector('[data-hydrated]'); // Open the AddRelationshipForm by clicking the "+ Beziehung hinzufügen" button. await page.getByRole('button', { name: '+ Beziehung hinzufügen' }).click(); // Select SPOUSE_OF from the type dropdown. await page.selectOption('select[name="relationType"]', 'SPOUSE_OF'); // Type person B's name in the PersonTypeahead. const personInput = page.getByRole('combobox', { name: /Person/i }); await expect(personInput).toBeVisible({ timeout: 5_000 }); await personInput.fill('E2E-Rel-B'); const suggestion = page.getByRole('option').first(); await expect(suggestion).toBeVisible({ timeout: 5_000 }); await suggestion.click(); // Submit the relationship form. await page.getByRole('button', { name: 'Hinzufügen' }).click(); // The relationship chip should appear inside the Beziehungen section. const relCard = page .locator('div') .filter({ has: page.locator('h2', { hasText: 'Beziehungen' }) }) .first(); await expect(relCard.locator('a[href^="/persons/"]', { hasText: personBName })).toBeVisible({ timeout: 8_000 }); await page.screenshot({ path: 'test-results/e2e/person-relationship-added.png' }); }); });