Files
familienarchiv/frontend/e2e/geschichten.spec.ts
Marcel aae005d5e6
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m56s
CI / OCR Service Tests (pull_request) Successful in 48s
CI / Backend Unit Tests (pull_request) Failing after 3m13s
CI / Unit & Component Tests (push) Failing after 4m40s
CI / OCR Service Tests (push) Successful in 56s
CI / Backend Unit Tests (push) Failing after 3m20s
test(geschichten): decouple multi-person e2e from seed names
The multi-person filter e2e previously typed 'a' then 'b' into the
typeahead and trusted the dev seed to contain matching names.
If the seed ever changes, the test would silently degrade — both
calls might resolve to the same row, or the listbox might never
populate.

Refactor to use a single broadly-occurring probe vowel ('e') and
extract person ids straight from the listbox option DOM (the option
id encodes the person id as `${listboxId}-option-${personId}`).
For the second pick, iterate options and select the first whose
id differs from the first selection. The test now only depends on
the seed having ≥2 distinct persons whose name contains 'e' — a
much weaker, more durable assumption — and asserts on the URL
params with full equality instead of toHaveLength + first-element
spot checks.

Addresses Sara's iteration-3 concern #4 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 09:09:29 +02:00

152 lines
6.2 KiB
TypeScript

import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
/**
* Minimal Geschichten coverage. The deeper a11y / visual-regression suite is
* tracked separately; this file proves the core writer + reader journey works
* end-to-end against the real stack.
*
* Pre-requisite: V59 has granted BLOG_WRITE to the Administrators group, so
* the seeded admin user can author. The auth.setup project handles login.
*/
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
test.describe('Geschichten — writer + reader journey', () => {
test('admin can create a draft, publish it, and see it on the index', async ({ page }) => {
const title = `E2E story ${stamp()}`;
// Land on the index — empty state or pre-existing demo data is fine
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');
await expect(page.getByRole('heading', { name: 'Geschichten', level: 1 })).toBeVisible();
// Click "Neue Geschichte" — visible because admin has BLOG_WRITE
await page.getByRole('link', { name: 'Neue Geschichte' }).click();
await page.waitForURL('/geschichten/new');
// Fill in title — the body editor is Tiptap and harder to script reliably
await page.getByPlaceholder('Titel der Geschichte').fill(title);
// Save as draft and verify we land on the detail page
await page.getByRole('button', { name: 'Entwurf speichern' }).click();
await page.waitForURL(/\/geschichten\/[^/]+$/);
// Capture the new id from the URL
const detailUrl = page.url();
const id = detailUrl.split('/').pop();
expect(id).toBeTruthy();
// Publish from the edit page
await page.getByRole('link', { name: 'Bearbeiten' }).click();
await page.waitForURL(/\/edit$/);
await page.getByRole('button', { name: 'Veröffentlichen' }).click();
await page.waitForURL(detailUrl);
// Index now shows the published story
await page.goto('/geschichten');
await expect(page.getByRole('link', { name: title })).toBeVisible();
});
test('reader is taken to a story detail when clicking a card', async ({ page }) => {
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');
// Use the first story link in the list (demo data exists; if not, the
// previous test seeded one). The link wraps the whole card.
const firstStory = page.locator('a[href^="/geschichten/"]').filter({ hasText: /.+/ }).first();
await expect(firstStory).toBeVisible();
await firstStory.click();
await page.waitForURL(/\/geschichten\/[^/]+$/);
await expect(page.locator('article')).toBeVisible();
});
test('multi-person filter: chips, URL params, and AND removal work end-to-end', async ({
page
}) => {
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');
// We need two distinct persons to filter by, but we don't want to couple this
// test to specific seed names. Strategy: type a single broadly-occurring vowel
// ("e" is present in the vast majority of German names), open the listbox,
// and pick whichever option matches the predicate.
//
// option DOM ids encode the person id as `${listboxId}-option-${personId}`,
// so we can identify the *first different* option without knowing the seed.
const PROBE = 'e';
async function openPicker() {
await page.getByRole('button', { name: /Person wählen/ }).click();
const input = page.getByRole('combobox', { name: /Person wählen/ });
await input.fill(PROBE);
// Wait for the listbox to be populated.
await expect(page.getByRole('option').first()).toBeVisible();
}
async function pickFirstOption(): Promise<string> {
const opt = page.getByRole('option').first();
const optId = (await opt.getAttribute('id')) ?? '';
const personId = optId.split('-option-')[1] ?? '';
expect(personId).not.toEqual('');
await opt.click();
return personId;
}
async function pickFirstOptionDifferentFrom(excludeId: string): Promise<string> {
// Iterate through visible options and return the first whose person id != excludeId.
const optionCount = await page.getByRole('option').count();
for (let i = 0; i < optionCount; i++) {
const candidate = page.getByRole('option').nth(i);
const optId = (await candidate.getAttribute('id')) ?? '';
const personId = optId.split('-option-')[1] ?? '';
if (personId && personId !== excludeId) {
await candidate.click();
return personId;
}
}
throw new Error(
`Expected at least two distinct persons matching "${PROBE}" in the seed, found only one.`
);
}
await openPicker();
const firstId = await pickFirstOption();
await page.waitForURL(/personId=/);
const firstIds = new URL(page.url()).searchParams.getAll('personId');
expect(firstIds).toEqual([firstId]);
await openPicker();
const secondId = await pickFirstOptionDifferentFrom(firstId);
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 2);
const secondIds = new URL(page.url()).searchParams.getAll('personId');
expect(secondIds).toEqual([firstId, secondId]);
expect(secondId).not.toEqual(firstId);
// Two chips visible — find them by their remove-aria-label pattern
const chipButtons = page.getByRole('button', { name: /aus Filter entfernen/ });
await expect(chipButtons).toHaveCount(2);
// Remove the first chip — URL drops to one param, only the second id remains
await chipButtons.first().click();
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 1);
const finalIds = new URL(page.url()).searchParams.getAll('personId');
expect(finalIds).toEqual([secondId]);
});
test('AxeBuilder finds no critical violations on the index', async ({ page }) => {
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
// Filter to non-deferred severity. We don't gate the whole PR on a clean
// AxeBuilder run yet — Sara's review tracks the broader a11y backlog —
// but any "serious" or "critical" finding from this scan would block merge.
const blocking = results.violations.filter(
(v) => v.impact === 'serious' || v.impact === 'critical'
);
expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]);
});
});