test: Benachrichtigungen end-to-end und in der Datenbank absichern

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 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-11 10:28:32 +02:00
co-authored by Claude Opus 5
parent f03ea73dcd
commit 6e1f2791cd
6 changed files with 206 additions and 40 deletions
+4 -14
View File
@@ -1,6 +1,7 @@
import { PrismaPg } from '@prisma/adapter-pg' 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 { 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. * 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. * 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 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.describe('Aufgaben', () => {
test.skip( test.skip(
!EMAIL || !PASSWORT || !verbindung, !EMAIL || !PASSWORT || !VERBINDUNG,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
) )
@@ -29,14 +27,6 @@ test.describe('Aufgaben', () => {
await db.$disconnect() await db.$disconnect()
}) })
async function anmelden(seite: Page): Promise<void> {
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 }) => { test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => {
const titel = `${PRAEFIX} Angebot schreiben` const titel = `${PRAEFIX} Angebot schreiben`
await anmelden(page) await anmelden(page)
+91
View File
@@ -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()
})
})
+35
View File
@@ -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<void> {
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 })
}
+5 -12
View File
@@ -1,7 +1,8 @@
import { PrismaPg } from '@prisma/adapter-pg' 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 bcrypt from 'bcryptjs'
import { PrismaClient } from '../src/generated/prisma/client.js' import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden as anmeldenMit } from './hilfe'
/** /**
* Prüft, dass die Rollentrennung serverseitig hält. * Prüft, dass die Rollentrennung serverseitig hält.
@@ -38,23 +39,15 @@ test.describe('Rollentrennung', () => {
await db.$disconnect() await db.$disconnect()
}) })
async function anmelden(seite: Page): Promise<void> {
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 }) => { 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) await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0)
}) })
test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({ test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({
page, page,
}) => { }) => {
await anmelden(page) await anmeldenMit(page, EMAIL, PASSWORT)
await page.goto('/verwaltung/protokoll') await page.goto('/verwaltung/protokoll')
await expect(page).toHaveURL(/\/keine-berechtigung/) await expect(page).toHaveURL(/\/keine-berechtigung/)
@@ -64,7 +57,7 @@ test.describe('Rollentrennung', () => {
}) })
test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => { test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => {
await anmelden(page) await anmeldenMit(page, EMAIL, PASSWORT)
await page.goto('/bereiche') await page.goto('/bereiche')
await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible()
+4 -14
View File
@@ -1,6 +1,7 @@
import { PrismaPg } from '@prisma/adapter-pg' 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 { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
/** /**
* Wochenansicht und Zeitblöcke. * Wochenansicht und Zeitblöcke.
@@ -11,16 +12,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js'
* Kalender nicht erreichbar ist. * Kalender nicht erreichbar ist.
*/ */
const EMAIL = process.env.ADMIN_EMAIL ?? ''
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
const PRAEFIX = `E2E-W-${Date.now()}` 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.describe('Woche', () => {
test.skip( test.skip(
!EMAIL || !PASSWORT || !verbindung, !EMAIL || !PASSWORT || !VERBINDUNG,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
) )
@@ -34,14 +32,6 @@ test.describe('Woche', () => {
await db.$disconnect() await db.$disconnect()
}) })
async function anmelden(seite: Page): Promise<void> {
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 }) => { test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => {
await db.kalenderkonto.deleteMany({}) await db.kalenderkonto.deleteMany({})
await anmelden(page) await anmelden(page)
+67
View File
@@ -56,6 +56,8 @@ async function aufraeumen(): Promise<void> {
await db.benutzer.deleteMany({ where: { email: testEmail } }) await db.benutzer.deleteMany({ where: { email: testEmail } })
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } }) await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
await db.webauthnChallenge.deleteMany({ where: { 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<void> { async function main(): Promise<void> {
@@ -259,6 +261,61 @@ async function main(): Promise<void> {
}) })
pruefe('Audit-Eintrag anlegen', audit.id.length > 0) 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 } }) await db.benutzer.delete({ where: { id: benutzer.id } })
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } }) const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
@@ -280,6 +337,16 @@ async function main(): Promise<void> {
'Kalenderkonten verschwinden mit dem Benutzer', 'Kalenderkonten verschwinden mit dem Benutzer',
(await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0, (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( pruefe(
'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine', 'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine',
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0, (await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0,