diff --git a/e2e/aufgaben.spec.ts b/e2e/aufgaben.spec.ts new file mode 100644 index 0000000..d818985 --- /dev/null +++ b/e2e/aufgaben.spec.ts @@ -0,0 +1,154 @@ +import { PrismaPg } from '@prisma/adapter-pg' +import { expect, test, type Page } from '@playwright/test' +import { PrismaClient } from '../src/generated/prisma/client.js' + +/** + * Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken. + * + * Alle Testdaten tragen ein gemeinsames Präfix und werden am Ende entfernt, + * 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 }) }) + +test.describe('Aufgaben', () => { + test.skip( + !EMAIL || !PASSWORT || !verbindung, + 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', + ) + + test.afterAll(async () => { + await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } }) + await db.projekt.deleteMany({ where: { name: { startsWith: PRAEFIX } } }) + await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'aufgabe.' } } }) + 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) + + // --- Erfassen: ein Feld genügt -------------------------------------- + await page.goto('/inbox') + await page.getByLabel('Neue Aufgabe').fill(titel) + await page.getByRole('button', { name: 'Erfassen' }).click() + + const zeile = page.locator('li').filter({ hasText: titel }) + await expect(zeile).toBeVisible() + // Ohne Bereich liegt sie in der Inbox – genau das ist der Unterschied. + await expect(zeile.getByText('Inbox')).toBeVisible() + + // --- Einsortieren ---------------------------------------------------- + const bereich = await db.bereich.findFirst({ + where: { aktiv: true }, + orderBy: { reihenfolge: 'asc' }, + select: { id: true, name: true }, + }) + expect(bereich).not.toBeNull() + + await zeile.getByLabel('Einsortieren nach').selectOption(bereich!.id) + await expect(page.getByText(`liegt jetzt in ${bereich!.name}`)).toBeVisible() + + // Aus der Inbox ist sie damit verschwunden. + await page.goto('/inbox') + await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0) + + // --- Abhaken --------------------------------------------------------- + await page.goto('/aufgaben') + const inListe = page.locator('li').filter({ hasText: titel }) + await expect(inListe).toBeVisible() + await inListe.getByRole('button', { name: 'Als erledigt abhaken' }).click() + + // Erledigtes fällt aus der Standardansicht heraus. + await page.goto('/aufgaben') + await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0) + + // Mit Filter "Alle" ist sie wieder da. + await page.goto('/aufgaben?status=ERLEDIGT') + await expect(page.locator('li').filter({ hasText: titel })).toBeVisible() + + const gespeichert = await db.aufgabe.findFirst({ where: { titel } }) + expect(gespeichert?.status).toBe('ERLEDIGT') + expect(gespeichert?.erledigtAm).not.toBeNull() + }) + + test('macht aus einer Idee eine Aufgabe', async ({ page }) => { + const titel = `${PRAEFIX} Blog aufsetzen` + await anmelden(page) + + await page.goto('/ideen') + await page.getByLabel('Neue Idee').fill(titel) + await page.getByRole('button', { name: 'Erfassen' }).click() + + const zeile = page.locator('li').filter({ hasText: titel }) + await expect(zeile).toBeVisible() + + await zeile.getByRole('button', { name: 'Zur Aufgabe machen' }).click() + await expect(page.getByText('ist jetzt eine Aufgabe')).toBeVisible() + + const danach = await db.aufgabe.findFirst({ where: { titel } }) + expect(danach?.typ).toBe('AUFGABE') + + // Aus der Ideenliste ist sie damit verschwunden. + await page.goto('/ideen') + await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0) + }) + + test('filtert nach Bereich und über die Adresszeile', async ({ page }) => { + await anmelden(page) + const bereich = await db.bereich.findFirst({ + where: { aktiv: true }, + select: { id: true, name: true }, + }) + + const drin = `${PRAEFIX} Gehört dazu` + const draussen = `${PRAEFIX} Gehört nicht dazu` + await db.aufgabe.createMany({ + data: [ + { titel: drin, bereichId: bereich!.id }, + { titel: draussen, bereichId: null }, + ], + }) + + await page.goto(`/aufgaben?bereich=${bereich!.id}`) + await expect(page.locator('li').filter({ hasText: drin })).toBeVisible() + await expect(page.locator('li').filter({ hasText: draussen })).toHaveCount(0) + + // Nur Inbox zeigt genau die andere. + await page.goto('/aufgaben?bereich=inbox') + await expect(page.locator('li').filter({ hasText: draussen })).toBeVisible() + await expect(page.locator('li').filter({ hasText: drin })).toHaveCount(0) + }) + + test('zeigt den Fortschritt eines Projekts', async ({ page }) => { + const projektName = `${PRAEFIX} Website neu` + const projekt = await db.projekt.create({ data: { name: projektName } }) + await db.aufgabe.createMany({ + data: [ + { titel: `${PRAEFIX} Entwurf`, projektId: projekt.id, status: 'ERLEDIGT' }, + { titel: `${PRAEFIX} Umsetzung`, projektId: projekt.id, status: 'OFFEN' }, + // Verworfenes zählt bewusst gar nicht mit. + { titel: `${PRAEFIX} Verworfen`, projektId: projekt.id, status: 'VERWORFEN' }, + ], + }) + + await anmelden(page) + await page.goto(`/projekte/${projekt.id}`) + + await expect(page.getByRole('heading', { name: projektName })).toBeVisible() + await expect(page.getByText('1 von 2 erledigt (50 %)')).toBeVisible() + }) +}) diff --git a/scripts/integrationstest.ts b/scripts/integrationstest.ts index 41f217c..5dc9217 100644 --- a/scripts/integrationstest.ts +++ b/scripts/integrationstest.ts @@ -44,8 +44,11 @@ const kennung = `zzz-test-${Date.now()}` const testEmail = `${kennung}@example.invalid` async function aufraeumen(): Promise { - // Reihenfolge egal, die Kaskaden erledigen den Rest; der Audit-Eintrag - // hängt bewusst an keinem Benutzer mehr. + // Erst die Aufgaben, dann Projekt und Bereich – sonst blieben Verweise + // stehen, die den Bereich am Löschen hindern. + await db.aufgabe.deleteMany({ where: { titel: { startsWith: kennung } } }) + await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: kennung } } }) + await db.projekt.deleteMany({ where: { name: { startsWith: kennung } } }) await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } }) await db.benutzer.deleteMany({ where: { email: testEmail } }) await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } }) @@ -174,6 +177,120 @@ async function main(): Promise { 'Verknüpfte Konten verschwinden mit dem Benutzer', (await db.konto.count({ where: { providerAccountId: kennung } })) === 0, ) + + // --- Aufgaben: Standardwerte und Inbox ----------------------------------- + const aufgabe = await db.aufgabe.create({ + data: { titel: `${kennung} Angebot schreiben` }, + }) + pruefe('Aufgabe anlegen', aufgabe.id.length > 0) + pruefe( + 'Ohne Angabe ist eine Aufgabe offen, normal und von Hand erfasst', + aufgabe.status === 'OFFEN' && + aufgabe.typ === 'AUFGABE' && + aufgabe.prioritaet === 3 && + aufgabe.quelle === 'MANUELL', + ) + pruefe('Ohne Bereich liegt sie in der Inbox', aufgabe.bereichId === null) + + // --- Eindeutigkeit der Quelle ------------------------------------------- + // Von Hand erfasste Aufgaben haben quelleRef = null. PostgreSQL sieht + // NULL-Werte als verschieden an, deshalb behindern sie sich nicht. + const zweiteOhneRef = await db.aufgabe.create({ + data: { titel: `${kennung} Noch eine von Hand` }, + }) + pruefe( + 'Mehrere Aufgaben ohne Quellverweis sind erlaubt', + zweiteOhneRef.id !== aufgabe.id, + ) + + await db.aufgabe.create({ + data: { + titel: `${kennung} Ticket 4711`, + quelle: 'ZAMMAD', + quelleRef: `${kennung}-4711`, + quelleUrl: 'https://zammad.example.invalid/4711', + }, + }) + await scheitert('Dieselbe Quelle mit derselben Kennung nur einmal', () => + db.aufgabe.create({ + data: { + titel: `${kennung} Ticket 4711 doppelt`, + quelle: 'ZAMMAD', + quelleRef: `${kennung}-4711`, + }, + }), + ) + + // Dieselbe Kennung bei anderer Quelle ist dagegen in Ordnung. + const andereQuelle = await db.aufgabe.create({ + data: { + titel: `${kennung} Telegram 4711`, + quelle: 'TELEGRAM', + quelleRef: `${kennung}-4711`, + }, + }) + pruefe('Dieselbe Kennung bei anderer Quelle ist erlaubt', andereQuelle.id.length > 0) + + // So werden Integrationen es tun: upsert statt anlegen. + const gewandelt = await db.aufgabe.upsert({ + where: { + quelle_quelleRef: { quelle: 'ZAMMAD', quelleRef: `${kennung}-4711` }, + }, + update: { titel: `${kennung} Ticket 4711 aktualisiert` }, + create: { + titel: `${kennung} Ticket 4711 neu`, + quelle: 'ZAMMAD', + quelleRef: `${kennung}-4711`, + }, + }) + pruefe( + 'Ein Upsert über [quelle, quelleRef] aktualisiert statt zu doppeln', + gewandelt.titel.endsWith('aktualisiert') && + (await db.aufgabe.count({ + where: { quelle: 'ZAMMAD', quelleRef: `${kennung}-4711` }, + })) === 1, + ) + + // --- Projekt: Zuordnung überlebt das Löschen nicht, die Aufgabe schon ---- + const projekt = await db.projekt.create({ data: { name: `${kennung} Website` } }) + await db.aufgabe.update({ + where: { id: aufgabe.id }, + data: { projektId: projekt.id }, + }) + + await scheitert('Projektname ist eindeutig', () => + db.projekt.create({ data: { name: projekt.name } }), + ) + + await db.projekt.delete({ where: { id: projekt.id } }) + const nachProjektLoeschung = await db.aufgabe.findUnique({ + where: { id: aufgabe.id }, + select: { id: true, projektId: true }, + }) + pruefe('Die Aufgabe überlebt das Löschen ihres Projekts', nachProjektLoeschung !== null) + pruefe( + 'Nur die Zuordnung zum Projekt wird gelöst', + nachProjektLoeschung?.projektId === null, + ) + + // --- Bereich: dasselbe Verhalten ---------------------------------------- + const bereich2 = await db.bereich.create({ + data: { name: `${kennung}-zweiter`, farbe: '#16a34a' }, + }) + await db.aufgabe.update({ + where: { id: aufgabe.id }, + data: { bereichId: bereich2.id }, + }) + await db.bereich.delete({ where: { id: bereich2.id } }) + + const nachBereichLoeschung = await db.aufgabe.findUnique({ + where: { id: aufgabe.id }, + select: { bereichId: true }, + }) + pruefe( + 'Beim Löschen eines Bereichs fällt die Aufgabe zurück in die Inbox', + nachBereichLoeschung?.bereichId === null, + ) } let abbruch: unknown = null