import type { Page } from '@playwright/test' import { PrismaClient } from '@prisma/client' import { TOTP, Secret } from 'otpauth' import { readFile } from 'node:fs/promises' /** * Gemeinsame Helfer fuer die Playwright-Tests. * * Die Anmeldung braucht einen echten TOTP-Code. Deshalb setzt der Seed im * Entwicklungsmodus ein bekanntes Geheimnis (SEED_DEV_TOTP_SECRET), aus * dem sich hier der aktuelle Code errechnen laesst. In der Produktion * ignoriert der Seed diese Variable. */ let gemerkt: Record | null = null export async function umgebung(): Promise> { if (gemerkt) return gemerkt const roh = await readFile('.env', 'utf8') const werte: Record = {} for (const zeile of roh.split(/\r?\n/)) { const treffer = zeile.match(/^([A-Z0-9_]+)=(.*)$/) if (treffer) werte[treffer[1]!] = treffer[2]!.replace(/^"|"$/g, '') } gemerkt = werte return werte } export function totpCode(secret: string, kennung: string): string { return new TOTP({ issuer: 'Betreuungs-Cockpit', label: kennung, algorithm: 'SHA1', digits: 6, period: 30, secret: Secret.fromBase32(secret), }).generate() } /** * Loescht die Fehlversuche des Testzugangs. * * Noetig, weil ein Test die Login-Sperre absichtlich ausloest. Ohne diesen * Reset waeren alle folgenden Tests fuer eine Viertelstunde ausgesperrt - * und man saehe nicht mehr, ob sie wirklich fehlschlagen oder nur warten. */ export async function fehlversucheLoeschen(): Promise { const db = new PrismaClient() try { await db.loginVersuch.deleteMany({ where: { erfolg: false } }) } finally { await db.$disconnect() } } /** Meldet sich an und wartet, bis die Anwendung erreicht ist. */ export async function anmelden(seite: Page): Promise { const env = await umgebung() // Vorherige Tests koennen die Sperre ausgeloest haben. await fehlversucheLoeschen() await seite.goto('/anmelden') await seite.fill('input[name="email"]', env.SEED_ADMIN_EMAIL!) await seite.fill('input[name="passwort"]', env.SEED_ADMIN_PASSWORT!) await seite.fill('input[name="code"]', totpCode(env.SEED_DEV_TOTP_SECRET!, env.SEED_ADMIN_EMAIL!)) await Promise.all([ seite.waitForURL((url) => !url.pathname.startsWith('/anmelden'), { timeout: 45_000 }), seite.click('button[type="submit"]'), ]) } /** * Ein winziges, gueltiges PNG als Buffer - dient als hochgeladenes * "Foto eines Behoerdenschreibens". Bewusst inline erzeugt, damit im * Repository keine Binaerdatei liegt. */ export function beispielBild(): Buffer { return Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ) } /** * Eine minimale, gueltige PDF-Datei. Ebenfalls inline erzeugt. * Enthaelt den uebergebenen Text als sichtbaren Seiteninhalt. */ export function beispielPdf(text = 'Bescheid'): Buffer { const inhalt = `BT /F1 24 Tf 72 700 Td (${text}) Tj ET` const objekte = [ '<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] ' + '/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', `<< /Length ${inhalt.length} >>\nstream\n${inhalt}\nendstream`, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', ] let pdf = '%PDF-1.4\n' const versaetze: number[] = [] objekte.forEach((objekt, i) => { versaetze.push(pdf.length) pdf += `${i + 1} 0 obj\n${objekt}\nendobj\n` }) const xref = pdf.length pdf += `xref\n0 ${objekte.length + 1}\n0000000000 65535 f \n` for (const versatz of versaetze) { pdf += `${String(versatz).padStart(10, '0')} 00000 n \n` } pdf += `trailer\n<< /Size ${objekte.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n` return Buffer.from(pdf, 'latin1') } /** * Doppelt gerenderte Listen — was beim Suchen schiefgeht. * * Die Dokumentenliste (und moeglicherweise kuenftig weitere) rendert * denselben Inhalt ZWEIMAL: als Karten fuers Handy, als Tabelle ab * Tablet. Eine der beiden Fassungen ist per Breakpoint versteckt, steht * aber im DOM. * * Die Folge im Test: * * await expect(page.getByText('Bescheid').first()).toBeVisible() * // -> "element is not visible", obwohl die Seite richtig aussieht * * `.first()` nimmt, was zuerst im Baum steht - und das ist die Karte, * also am Schreibtisch die unsichtbare Fassung. Es liegt NICHT an der * Anwendung. * * Nachgemessen an /dokumente mit acht Dokumenten: Ein Titel liefert 16 * Treffer, davon 8 sichtbar - und `.first()` ist unsichtbar. * * Drei Wege, die tragen: * * 1. Ueber den Seitentext pruefen, wenn es nur um "steht das da?" geht: * * const inhalt = await page.locator('body').innerText() * expect(inhalt).toContain('Bescheid') * * innerText liefert nur sichtbaren Text - die versteckte Fassung * faellt von selbst weg. (Fuer Inhalt in einem zugeklappten *
braucht es dagegen textContent.) * * 2. Auf die sichtbare Fassung einschraenken, wenn geklickt wird: * * page.getByRole('link', { name: 'Bescheid' }).locator('visible=true') * * 3. Die Zusammenfassungszeile pruefen ("12 Dokumente"), wenn es um * Anzahlen geht - die steht nur einmal. * * Was nicht traegt: eine feste Anzahl erwarten. `getByText(...)` findet * jeden Eintrag zweimal, und wer das mit toHaveCount(1) prueft, schreibt * einen Test, der bei jeder Layoutaenderung umfaellt. */