import { expect, test } from '@playwright/test' import { anmelden, beispielBild, beispielPdf } from './hilfen' /** * Die Abnahmekriterien aus dem Auftrag, als Test. * * Diese Datei prueft nicht, ob einzelne Funktionen richtig rechnen - das * tun die Vitest-Tests. Sie prueft, ob die Zusagen halten, die ich mir * selbst gegeben habe, und zwar an der laufenden Anwendung. * * Grundlage ist der Demofall aus dem Seed (SEED_DEMO=true). */ test.beforeEach(async ({ page }) => { await anmelden(page) }) test.describe('Abnahme', () => { test('leitet die gesetzlichen Fristen aus dem Bestellungsdatum ab', async ({ page }) => { await page.goto('/fristen') // Bestellung war am 14.02.2026 - daraus muessen mindestens // Vermoegensverzeichnis, Anfangsbericht, Jahresbericht und die // Aufwandspauschale entstanden sein. const inhalt = page.locator('main') await expect(inhalt).toContainText(/Vermögensverzeichnis/i) await expect(inhalt).toContainText(/Jahresbericht/i) await expect(inhalt).toContainText(/Aufwandspauschale/i) // Und sie tragen ihre Rechtsgrundlage - sonst weiss man nicht, warum // man das tut. await expect(inhalt).toContainText(/§ 18\d\d BGB/) }) test('warnt bei der Aufwandspauschale ausdruecklich vor dem Verfall', async ({ page }) => { await page.goto('/fristen') const pauschale = page .locator('main') .locator('li, article, div') .filter({ hasText: /Aufwandspauschale/i }) .first() await expect(pauschale).toBeVisible() // Eine Ausschlussfrist muss als solche erkennbar sein. Wer sie // verstreichen laesst, verliert den Anspruch ersatzlos. await expect(pauschale).toContainText(/Ausschlussfrist|verfäll|ersatzlos/i) }) test('nimmt ein fotografiertes Schreiben auf und ordnet es einem Vorgang zu', async ({ page, }) => { await page.goto('/dokumente/neu') // Erst wenn React die Seite uebernommen hat, haengt am Dateifeld ein // onChange. Setzt man die Datei vorher, kommt sie im Formular nie an. await page.waitForLoadState('networkidle') await expect(page.getByRole('button', { name: /In die Akte übernehmen/i })).toBeVisible() await page.setInputFiles('input[name="dateien"]', { name: 'bescheid-bezirk.pdf', mimeType: 'application/pdf', buffer: beispielPdf('Bescheid Bezirk Oberfranken'), }) // Der Absendeknopf bleibt gesperrt, solange keine Datei angenommen // wurde - die Pruefung an den Magic Bytes laeuft im Browser vor dem // Hochladen. Erst warten, dann weiter. const uebernehmen = page.getByRole('button', { name: /In die Akte übernehmen/i }) await expect(uebernehmen).toBeEnabled({ timeout: 30_000 }) await page.fill('input[name="titel"]', 'Bescheid Bezirk (Abnahmetest)') // Zuordnung zu einem Vorgang - der zweite Klick des versprochenen // "in zwei Klicks zugeordnet". const vorgangsauswahl = page.locator('select[name="vorgangId"]') await expect(vorgangsauswahl).toBeVisible() const werte = await vorgangsauswahl.locator('option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).value).filter(Boolean), ) expect(werte.length, 'Es muss mindestens einen Vorgang zur Auswahl geben').toBeGreaterThan(0) await vorgangsauswahl.selectOption(werte[0]!) await uebernehmen.click() // Danach steht das Dokument in der Ablage. await page.waitForURL(/\/dokumente/, { timeout: 60_000 }) await page.goto('/dokumente') await expect(page.locator('main')).toContainText('Bescheid Bezirk (Abnahmetest)') }) test('weist eine Datei ab, die sich nur als PDF ausgibt', async ({ page }) => { await page.goto('/dokumente/neu') await page.waitForLoadState('networkidle') await expect(page.getByRole('button', { name: /In die Akte übernehmen/i })).toBeVisible() // Das ist der Angriff, gegen den die Pruefung an den Magic Bytes // gebaut ist: HTML mit PDF-Endung und PDF-MIME-Typ. Wuerde es // angenommen und spaeter ausgeliefert, liefe fremdes Skript im // Kontext der Anwendung. await page.setInputFiles('input[name="dateien"]', { name: 'harmlos.pdf', mimeType: 'application/pdf', buffer: Buffer.from('', 'utf8'), }) // Wird die Datei schon im Browser abgelehnt, bleibt der Knopf gesperrt - // das ist bereits der gewuenschte Ausgang. Kommt sie durch, muss // spaetestens der Server sie abweisen. const uebernehmen = page.getByRole('button', { name: /In die Akte übernehmen/i }) const angenommen = await uebernehmen .isEnabled({ timeout: 10_000 }) .catch(() => false) if (angenommen) { await page.fill('input[name="titel"]', 'Untergeschobene Datei') await uebernehmen.click() await page.waitForTimeout(4000) } await page.goto('/dokumente') await expect(page.locator('main')).not.toContainText('Untergeschobene Datei') }) test('laesst einen genehmigungspflichtigen Vorgang nicht ohne Beschluss vollziehen', async ({ page, }) => { await page.goto('/vorgaenge') // Der Demofall enthaelt "Haus Bindlach: Verwertung oder Darlehen // klaeren" - genehmigungspflichtig nach § 1850 BGB, ohne hinterlegten // Beschluss. await page.getByRole('link', { name: /Haus Bindlach/i }).first().click() await page.waitForLoadState('networkidle') const inhalt = page.locator('main') await expect(inhalt).toContainText(/§ 1850|§ 1858|Genehmigung/i) // Die Stufe "Vollzogen" darf gar nicht erst waehlbar sein, solange // kein Beschlussdokument hinterlegt ist. const auswahlfelder = page.locator('main select') const anzahl = await auswahlfelder.count() for (let i = 0; i < anzahl; i++) { const optionen = await auswahlfelder .nth(i) .locator('option') .allInnerTexts() .catch(() => [] as string[]) const bietetVollzug = optionen.some((o) => /vollzogen|vollzug/i.test(o)) expect( bietetVollzug, 'Ohne Beschlussdokument darf "Vollzogen" nicht angeboten werden (§ 1858 BGB)', ).toBe(false) } }) test('erzeugt das Vermoegensverzeichnis als PDF, ohne dass Zahlen abgetippt werden', async ({ page, }) => { await page.goto('/finanzen/berichte') const [download] = await Promise.all([ page.waitForEvent('download', { timeout: 90_000 }), page.getByRole('button', { name: /Vermögensverzeichnis erzeugen/i }).click(), ]) const pfad = await download.path() expect(pfad, 'Es muss eine Datei herauskommen').toBeTruthy() const { readFile } = await import('node:fs/promises') const inhalt = await readFile(pfad!) // Echtes PDF, nicht eine Fehlerseite mit PDF-Namen. expect(inhalt.subarray(0, 5).toString('latin1')).toBe('%PDF-') expect(inhalt.length).toBeGreaterThan(2000) expect(download.suggestedFilename()).toMatch(/\.pdf$/i) }) test('zeigt jederzeit, was der Bezirk noch von mir will', async ({ page }) => { await page.goto('/sozialhilfe') // Die Uebersicht nennt den Stand als "x von y Unterlagen geliefert". await expect(page.locator('main')).toContainText(/\d+\s+von\s+\d+/) // Die einzelnen offenen Punkte stehen in der Akte selbst. await page.getByRole('link', { name: /Akte öffnen/i }).first().click() await page.waitForURL(/\/sozialhilfe\/.+/, { timeout: 60_000 }) await page.waitForLoadState('networkidle') const akte = page.locator('main') await expect(akte).toContainText(/Rückkaufswert|Übertragungen|Unterhaltspflichtigen/i) // Und je Zeile ein Status, damit man sieht, was noch fehlt. await expect(akte).toContainText(/offen|übersandt|Beschaffung/i) }) test('verlangt vor dem Vollexport eine frische Anmeldung', async ({ page }) => { await page.goto('/berichte/export') // Der Export nimmt alle Gesundheits- und Finanzdaten mit. Eine offene // Sitzung an einem unbeaufsichtigten Rechner darf dafuer nicht // genuegen. await expect(page.getByRole('button', { name: /Export freischalten/i })).toBeVisible() await page.getByRole('button', { name: /Export freischalten/i }).click() // Grosszuegig warten: beim ersten Aufruf muss Next.js die // Bestaetigungsseite erst uebersetzen. await page.waitForURL(/\/bestaetigen/, { timeout: 90_000 }) await expect(page.locator('main')).toContainText(/Passwort/i) await expect(page.locator('input[name="code"]')).toBeVisible() }) test('protokolliert jede Aenderung unveraenderlich', async ({ page }) => { await page.goto('/protokoll') const inhalt = page.locator('main') await expect(inhalt).toContainText(/unveränderlich/i) // Die Anmeldung von gerade eben muss darin stehen. await expect(inhalt).toContainText(/angemeldet/i) }) test('haelt beim Demofall die verschluesselten Felder lesbar', async ({ page }) => { // Verschluesselung nutzt nichts, wenn sie den Zugriff kaputt macht - // und ein Backup ist erst dann geglueckt, wenn hier Klartext steht. await page.goto('/stammdaten') await expect(page.locator('main')).toContainText(/COPD GOLD III/) await expect(page.locator('main')).toContainText(/Verschlüsselt gespeichert/i) }) }) test.describe('Am Handy bedienbar', () => { test('zeigt die Navigation als Schublade und scrollt die Seite nicht seitlich', async ({ page, isMobile, }) => { test.skip(!isMobile, 'Nur am Handy sinnvoll') await page.goto('/heute') // Die Seitenleiste ist eingeklappt, es gibt einen Menuknopf. const menue = page.getByRole('button', { name: 'Menü öffnen' }) await expect(menue).toBeVisible() await menue.click() await expect(page.getByRole('navigation', { name: 'Hauptnavigation' })).toBeVisible() // Escape schliesst die Schublade - das ist zugleich der Nachweis, dass // sie sich ohne Maus bedienen laesst. await page.keyboard.press('Escape') await expect(page.getByRole('navigation', { name: 'Hauptnavigation' })).toBeHidden() // Kein waagerechtes Scrollen der ganzen Seite - Tabellen scrollen im // eigenen Rahmen. for (const pfad of ['/heute', '/fristen', '/finanzen', '/sozialhilfe/heimkosten']) { await page.goto(pfad) await page.waitForLoadState('networkidle') const ueberstand = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth, ) // Ein Pixel Toleranz fuer Rundung. expect(ueberstand, `${pfad} scrollt seitlich`).toBeLessThanOrEqual(1) } }) test('macht Bedienelemente gross genug zum Treffen', async ({ page, isMobile }) => { test.skip(!isMobile, 'Nur am Handy sinnvoll') await page.goto('/kontakte/protokoll/neu') await page.waitForLoadState('networkidle') const knoepfe = page.locator('main button:visible') const anzahl = Math.min(await knoepfe.count(), 12) for (let i = 0; i < anzahl; i++) { const rahmen = await knoepfe.nth(i).boundingBox() if (!rahmen || rahmen.height === 0) continue const text = (await knoepfe.nth(i).innerText()).slice(0, 30) // 44 Pixel ist die uebliche Mindestgroesse fuer Touchziele. expect(rahmen.height, `Knopf "${text}" ist nur ${rahmen.height}px hoch`).toBeGreaterThanOrEqual(40) } }) })