diff --git a/e2e/abnahme.spec.ts b/e2e/abnahme.spec.ts new file mode 100644 index 0000000..26f28d3 --- /dev/null +++ b/e2e/abnahme.spec.ts @@ -0,0 +1,284 @@ +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) + } + }) +}) diff --git a/src/app/(app)/protokoll/page.tsx b/src/app/(app)/protokoll/page.tsx index a311f9f..67bec95 100644 --- a/src/app/(app)/protokoll/page.tsx +++ b/src/app/(app)/protokoll/page.tsx @@ -70,14 +70,27 @@ export default async function ProtokollSeite({ const proSeite = 50 const nummer = Math.max(1, Number.parseInt(seite, 10) || 1) + /* + * Zwei Arten von Eintraegen gehoeren hierher: + * + * 1. Alles, was diese Betreuung betrifft. + * 2. Anmeldungen, Abmeldungen und Kontoaenderungen des angemeldeten + * Benutzers. Die tragen keine betreuteId - sie haengen ja am Konto, + * nicht an der Akte. Wuerde man nur nach betreuteId filtern, fehlte + * ausgerechnet die Frage "wer hat wann auf die Akte zugegriffen". + */ + const filter = { + OR: [{ betreuteId: aktive.id }, { userId: benutzer.id, betreuteId: null }], + } + const [eintraege, gesamt] = await Promise.all([ db.auditLog.findMany({ - where: { betreuteId: aktive.id }, + where: filter, orderBy: { zeitpunkt: 'desc' }, skip: (nummer - 1) * proSeite, take: proSeite, }), - db.auditLog.count({ where: { betreuteId: aktive.id } }), + db.auditLog.count({ where: filter }), ]) const seiten = Math.max(1, Math.ceil(gesamt / proSeite)) @@ -86,7 +99,7 @@ export default async function ProtokollSeite({