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,