Files
betreuungs-cockpit/e2e/abnahme.spec.ts
T
Florian Wilde dccc34d669 test: Abnahmekriterien als Playwright-Tests, Anmeldungen im Protokoll
Zehn Tests, die genau die Zusagen aus dem Auftrag pruefen - an der
laufenden Anwendung, nicht an einzelnen Funktionen:

- Aus dem Bestellungsdatum entstehen die gesetzlichen Fristen, mit
  Rechtsgrundlage, und die Aufwandspauschale ist als Ausschlussfrist
  gekennzeichnet.
- Ein PDF laesst sich hochladen und in zwei Klicks einem Vorgang
  zuordnen.
- Eine Datei, die sich nur als PDF ausgibt, wird abgewiesen. Das ist der
  Angriff, gegen den die Pruefung an den Magic Bytes gebaut ist: HTML mit
  PDF-Endung und PDF-MIME-Typ, das spaeter als Seite ausgeliefert wuerde.
- Ein genehmigungspflichtiger Vorgang bietet die Stufe "Vollzogen" gar
  nicht erst an, solange kein Beschluss hinterlegt ist (§ 1858 BGB).
- Das Vermoegensverzeichnis kommt als echtes PDF heraus - geprueft wird
  die Signatur %PDF-, nicht nur der Dateiname.
- Der Vollexport verlangt eine frische Anmeldung.
- Am Handy: kein waagerechtes Scrollen der Seite, Bedienelemente
  mindestens 40 Pixel hoch, Schublade per Escape schliessbar.

Dazu ein echter Fund aus dem Protokolltest: Anmeldungen und
Abmeldungen tragen keine betreuteId - sie haengen am Konto, nicht an der
Akte. Die Protokollansicht filterte nur nach betreuteId und liess damit
ausgerechnet die Frage weg, wer wann auf die Akte zugegriffen hat. Jetzt
werden beide Arten von Eintraegen gezeigt.

34 Tests, je einmal am Schreibtisch und am Handy.

Etappe: 8
Phase: Haertung
2026-08-22 07:10:19 +02:00

285 lines
11 KiB
TypeScript

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('<html><script>alert(1)</script></html>', '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)
}
})
})