From 6e1f2791cdd0e9e186b781acfb9d95ca0512dabb Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 11 Sep 2026 10:28:32 +0200 Subject: [PATCH] test: Benachrichtigungen end-to-end und in der Datenbank absichern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Einstellung haelt ueber das Neuladen, ohne eingeschalteten Weg geht nichts hinaus, und ein unsinniger Vorlauf wird auch dann abgewiesen, wenn er an der Oberflaeche vorbei hereinkommt. Der Anmeldehelfer liegt jetzt einmal in e2e/hilfe.ts und fasst nach: ein Klick, der vor der Hydratation ankommt, verpufft folgenlos — das hat die Testlaeufe unter Last wiederholt scheitern lassen. Der Integrationstest prueft die drei neuen Tabellen samt Kaskade. Co-Authored-By: Claude Opus 5 --- e2e/aufgaben.spec.ts | 18 ++----- e2e/benachrichtigungen.spec.ts | 91 ++++++++++++++++++++++++++++++++++ e2e/hilfe.ts | 35 +++++++++++++ e2e/rollen.spec.ts | 17 ++----- e2e/woche.spec.ts | 18 ++----- scripts/integrationstest.ts | 67 +++++++++++++++++++++++++ 6 files changed, 206 insertions(+), 40 deletions(-) create mode 100644 e2e/benachrichtigungen.spec.ts create mode 100644 e2e/hilfe.ts diff --git a/e2e/aufgaben.spec.ts b/e2e/aufgaben.spec.ts index 89fe0af..e8c5175 100644 --- a/e2e/aufgaben.spec.ts +++ b/e2e/aufgaben.spec.ts @@ -1,6 +1,7 @@ import { PrismaPg } from '@prisma/adapter-pg' -import { expect, test, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import { PrismaClient } from '../src/generated/prisma/client.js' +import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe' /** * Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken. @@ -9,16 +10,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js' * damit die Entwicklungsdatenbank hinterher aussieht wie vorher. */ -const EMAIL = process.env.ADMIN_EMAIL ?? '' -const PASSWORT = process.env.ADMIN_PASSWORT ?? '' const PRAEFIX = `E2E-${Date.now()}` -const verbindung = process.env.DATABASE_URL ?? '' -const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) }) +const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) }) test.describe('Aufgaben', () => { test.skip( - !EMAIL || !PASSWORT || !verbindung, + !EMAIL || !PASSWORT || !VERBINDUNG, 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', ) @@ -29,14 +27,6 @@ test.describe('Aufgaben', () => { await db.$disconnect() }) - async function anmelden(seite: Page): Promise { - await seite.goto('/anmelden') - await seite.getByLabel('E-Mail-Adresse').fill(EMAIL) - await seite.getByLabel('Passwort').fill(PASSWORT) - await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click() - await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() - } - test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => { const titel = `${PRAEFIX} Angebot schreiben` await anmelden(page) diff --git a/e2e/benachrichtigungen.spec.ts b/e2e/benachrichtigungen.spec.ts new file mode 100644 index 0000000..e598872 --- /dev/null +++ b/e2e/benachrichtigungen.spec.ts @@ -0,0 +1,91 @@ +import { PrismaPg } from '@prisma/adapter-pg' +import { expect, test } from '@playwright/test' +import { PrismaClient } from '../src/generated/prisma/client.js' +import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe' + +/** + * Was in M5 wirklich zählt: die Einstellung hält, und ohne eingeschalteten + * Weg geht nichts hinaus. + * + * Der Test rührt nur an der Einstellung des Testkontos und stellt sie am + * Ende wieder her. + */ + +const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) }) + +test.describe('Benachrichtigungen', () => { + test.skip( + !EMAIL || !PASSWORT || !VERBINDUNG, + 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', + ) + + test.afterAll(async () => { + const benutzer = await db.benutzer.findUnique({ + where: { email: EMAIL }, + select: { id: true }, + }) + if (benutzer) { + await db.benachrichtigungseinstellung.deleteMany({ + where: { benutzerId: benutzer.id }, + }) + await db.meldungsnachweis.deleteMany({ where: { benutzerId: benutzer.id } }) + } + await db.auditEintrag.deleteMany({ + where: { aktion: { startsWith: 'benachrichtigung.' } }, + }) + await db.$disconnect() + }) + + test('speichert die Einstellung und hält sie', async ({ page }) => { + await anmelden(page) + await page.goto('/einstellungen') + + const karte = page.locator('[data-bild="benachrichtigungen"]') + await expect(karte).toBeVisible() + + // --- Ab Werk ist nichts eingeschaltet --------------------------------- + await expect(karte.locator('input[name="perPush"]')).not.toBeChecked() + await expect(karte.locator('input[name="perMail"]')).not.toBeChecked() + + // --- Ohne Weg keine Probe --------------------------------------------- + await karte.getByRole('button', { name: 'Probe schicken' }).click() + await expect(page.getByText('Erst einen Weg einschalten')).toBeVisible() + + // --- Ändern und speichern --------------------------------------------- + await karte.locator('input[name="perMail"]').check() + await karte.locator('input[value="zeitblock"]').check() + await karte.getByLabel('Tagesüberblick um').fill('06:30') + await karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)').fill('20') + await karte.getByLabel('Am Wochenende gar nichts melden').check() + await karte.getByRole('button', { name: 'Speichern' }).click() + await expect(page.getByText('Einstellungen gespeichert.')).toBeVisible() + + // --- Nach dem Neuladen steht alles noch da ---------------------------- + await page.reload() + await expect(karte.locator('input[name="perMail"]')).toBeChecked() + await expect(karte.locator('input[value="zeitblock"]')).toBeChecked() + await expect(karte.getByLabel('Tagesüberblick um')).toHaveValue('06:30') + await expect(karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)')).toHaveValue( + '20', + ) + await expect(karte.getByLabel('Am Wochenende gar nichts melden')).toBeChecked() + }) + + test('weist eine unsinnige Vorlaufzeit zurück', async ({ page }) => { + await anmelden(page) + await page.goto('/einstellungen') + + const karte = page.locator('[data-bild="benachrichtigungen"]') + const vorlauf = karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)') + // Das Zahlenfeld begrenzt auf 120; der Server darf sich darauf nicht + // verlassen, deshalb kommt der Wert an der Oberfläche vorbei. + await vorlauf.evaluate((feld: HTMLInputElement) => { + feld.max = '600' + feld.value = '300' + }) + await karte.getByRole('button', { name: 'Speichern' }).click() + + await expect(page.getByText('Bitte die markierten Felder prüfen.')).toBeVisible() + await expect(karte.getByText('Mehr als zwei Stunden Vorlauf')).toBeVisible() + }) +}) diff --git a/e2e/hilfe.ts b/e2e/hilfe.ts new file mode 100644 index 0000000..d5b601b --- /dev/null +++ b/e2e/hilfe.ts @@ -0,0 +1,35 @@ +import { expect, type Page } from '@playwright/test' + +/** Gemeinsames für alle E2E-Tests. */ + +export const EMAIL = process.env.ADMIN_EMAIL ?? '' +export const PASSWORT = process.env.ADMIN_PASSWORT ?? '' +export const VERBINDUNG = process.env.DATABASE_URL ?? '' + +/** + * Meldet das Testkonto mit Passwort an. + * + * Der Knopf gehört zu einem Client-Formular. Ein Klick, der vor der + * Hydratation ankommt, verpufft folgenlos — die Seite bleibt einfach stehen. + * Deshalb wird so lange nachgefasst, bis die Startseite da ist; hat der erste + * Klick schon gewirkt, wird nicht noch einmal geklickt. + */ +export async function anmelden( + seite: Page, + email: string = EMAIL, + passwort: string = PASSWORT, +): Promise { + await seite.goto('/anmelden') + await seite.getByLabel('E-Mail-Adresse').fill(email) + await seite.getByLabel('Passwort').fill(passwort) + + const knopf = seite.getByRole('button', { name: 'Mit Passwort anmelden' }) + const begruessung = seite.getByRole('heading', { name: /Guten Tag/ }) + + await expect(async () => { + if (new URL(seite.url()).pathname === '/anmelden') { + await knopf.click({ timeout: 5_000 }) + } + await expect(begruessung).toBeVisible({ timeout: 5_000 }) + }).toPass({ timeout: 45_000 }) +} diff --git a/e2e/rollen.spec.ts b/e2e/rollen.spec.ts index db89306..d9b9eff 100644 --- a/e2e/rollen.spec.ts +++ b/e2e/rollen.spec.ts @@ -1,7 +1,8 @@ import { PrismaPg } from '@prisma/adapter-pg' -import { expect, test, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import bcrypt from 'bcryptjs' import { PrismaClient } from '../src/generated/prisma/client.js' +import { anmelden as anmeldenMit } from './hilfe' /** * Prüft, dass die Rollentrennung serverseitig hält. @@ -38,23 +39,15 @@ test.describe('Rollentrennung', () => { await db.$disconnect() }) - async function anmelden(seite: Page): Promise { - await seite.goto('/anmelden') - await seite.getByLabel('E-Mail-Adresse').fill(EMAIL) - await seite.getByLabel('Passwort').fill(PASSWORT) - await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click() - await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() - } - test('zeigt einem Benutzer den Menüpunkt Protokoll gar nicht', async ({ page }) => { - await anmelden(page) + await anmeldenMit(page, EMAIL, PASSWORT) await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0) }) test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({ page, }) => { - await anmelden(page) + await anmeldenMit(page, EMAIL, PASSWORT) await page.goto('/verwaltung/protokoll') await expect(page).toHaveURL(/\/keine-berechtigung/) @@ -64,7 +57,7 @@ test.describe('Rollentrennung', () => { }) test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => { - await anmelden(page) + await anmeldenMit(page, EMAIL, PASSWORT) await page.goto('/bereiche') await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible() diff --git a/e2e/woche.spec.ts b/e2e/woche.spec.ts index 94d7394..beb54da 100644 --- a/e2e/woche.spec.ts +++ b/e2e/woche.spec.ts @@ -1,6 +1,7 @@ import { PrismaPg } from '@prisma/adapter-pg' -import { expect, test, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import { PrismaClient } from '../src/generated/prisma/client.js' +import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe' /** * Wochenansicht und Zeitblöcke. @@ -11,16 +12,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js' * Kalender nicht erreichbar ist. */ -const EMAIL = process.env.ADMIN_EMAIL ?? '' -const PASSWORT = process.env.ADMIN_PASSWORT ?? '' const PRAEFIX = `E2E-W-${Date.now()}` -const verbindung = process.env.DATABASE_URL ?? '' -const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) }) +const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) }) test.describe('Woche', () => { test.skip( - !EMAIL || !PASSWORT || !verbindung, + !EMAIL || !PASSWORT || !VERBINDUNG, 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', ) @@ -34,14 +32,6 @@ test.describe('Woche', () => { await db.$disconnect() }) - async function anmelden(seite: Page): Promise { - await seite.goto('/anmelden') - await seite.getByLabel('E-Mail-Adresse').fill(EMAIL) - await seite.getByLabel('Passwort').fill(PASSWORT) - await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click() - await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() - } - test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => { await db.kalenderkonto.deleteMany({}) await anmelden(page) diff --git a/scripts/integrationstest.ts b/scripts/integrationstest.ts index 84f6055..58b4cd6 100644 --- a/scripts/integrationstest.ts +++ b/scripts/integrationstest.ts @@ -56,6 +56,8 @@ async function aufraeumen(): Promise { await db.benutzer.deleteMany({ where: { email: testEmail } }) await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } }) await db.webauthnChallenge.deleteMany({ where: { kennung } }) + await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } }) + await db.meldungsnachweis.deleteMany({ where: { schluessel: { contains: kennung } } }) } async function main(): Promise { @@ -259,6 +261,61 @@ async function main(): Promise { }) pruefe('Audit-Eintrag anlegen', audit.id.length > 0) + // --- Benachrichtigungen -------------------------------------------------- + await db.benachrichtigungseinstellung.create({ + data: { benutzerId: benutzer.id }, + }) + await scheitert('Je Benutzer nur eine Benachrichtigungseinstellung', () => + db.benachrichtigungseinstellung.create({ data: { benutzerId: benutzer.id } }), + ) + + const voreinstellung = await db.benachrichtigungseinstellung.findUniqueOrThrow({ + where: { benutzerId: benutzer.id }, + }) + pruefe( + 'Ab Werk ist kein Weg eingeschaltet', + voreinstellung.perPush === false && voreinstellung.perMail === false, + ) + + await db.pushGeraet.create({ + data: { + benutzerId: benutzer.id, + endpunkt: `https://push.invalid/${kennung}`, + p256dh: 'x', + auth: 'y', + }, + }) + await scheitert('Derselbe Push-Endpunkt geht nur einmal', () => + db.pushGeraet.create({ + data: { + benutzerId: benutzer.id, + endpunkt: `https://push.invalid/${kennung}`, + p256dh: 'x', + auth: 'y', + }, + }), + ) + + await db.meldungsnachweis.create({ + data: { benutzerId: benutzer.id, schluessel: `faelligkeit:${kennung}:2026-03-17` }, + }) + await scheitert('Derselbe Meldungsschluessel geht nur einmal', () => + db.meldungsnachweis.create({ + data: { benutzerId: benutzer.id, schluessel: `faelligkeit:${kennung}:2026-03-17` }, + }), + ) + pruefe( + 'Derselbe Schlüssel am nächsten Tag ist erlaubt', + ( + await db.meldungsnachweis.create({ + data: { + benutzerId: benutzer.id, + schluessel: `faelligkeit:${kennung}:2026-03-18`, + }, + }) + ).id.length > 0, + ) + await db.benutzer.delete({ where: { id: benutzer.id } }) const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } }) @@ -280,6 +337,16 @@ async function main(): Promise { 'Kalenderkonten verschwinden mit dem Benutzer', (await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0, ) + pruefe( + 'Push-Geräte verschwinden mit dem Benutzer', + (await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0, + ) + pruefe( + 'Meldungsnachweise verschwinden mit dem Benutzer', + (await db.meldungsnachweis.count({ + where: { schluessel: { contains: kennung } }, + })) === 0, + ) pruefe( 'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine', (await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0,