/** * Integrationstest gegen eine echte Datenbank. * * Prüft, was Vitest nicht abdeckt: Eindeutigkeit, Fremdschlüssel, Kaskaden * und dass das Audit-Protokoll den Benutzer überlebt. * * Bewusst ohne umschließende Transaktion: Der Test prüft absichtlich Fälle, * die scheitern müssen, und ein gescheiterter Befehl bricht in PostgreSQL die * ganze Transaktion ab. Stattdessen tragen alle Testdaten dieselbe Kennung * und werden am Ende wieder entfernt — auch wenn der Test unterwegs abbricht. * * Aufruf: npm run test:integration */ import 'dotenv/config' import { PrismaPg } from '@prisma/adapter-pg' import { PrismaClient } from '../src/generated/prisma/client.js' const verbindung = process.env.DATABASE_URL if (!verbindung) { console.error('DATABASE_URL ist nicht gesetzt.') process.exit(1) } const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) }) type Pruefung = { name: string; ok: boolean; hinweis?: string } const ergebnisse: Pruefung[] = [] function pruefe(name: string, bedingung: boolean, hinweis?: string): void { ergebnisse.push(hinweis ? { name, ok: bedingung, hinweis } : { name, ok: bedingung }) } /** Erwartet, dass die Aktion scheitert. */ async function scheitert(name: string, tun: () => Promise): Promise { try { await tun() pruefe(name, false, 'Die Aktion war erlaubt, obwohl sie scheitern sollte.') } catch { pruefe(name, true) } } const kennung = `zzz-test-${Date.now()}` const testEmail = `${kennung}@example.invalid` async function aufraeumen(): Promise { // 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 } }) // Kalenderkonten hängen am Benutzer und gehen mit ihm; falls der Test // vorher abbricht, hier trotzdem noch einmal aufräumen. await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: kennung } } }) await db.benutzer.deleteMany({ where: { email: testEmail } }) await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } }) await db.webauthnChallenge.deleteMany({ where: { kennung } }) } async function main(): Promise { // --- Benutzer und Eindeutigkeit der Adresse ------------------------------ const benutzer = await db.benutzer.create({ data: { email: testEmail, name: 'Testkonto' }, }) pruefe('Benutzer anlegen', benutzer.id.length > 0) pruefe('Rolle ist im Zweifel BENUTZER', benutzer.rolle === 'BENUTZER') pruefe('Konto ist im Zweifel aktiv', benutzer.aktiv === true) await scheitert('E-Mail-Adresse ist eindeutig', () => db.benutzer.create({ data: { email: testEmail } }), ) // --- Bereich und Eindeutigkeit des Namens -------------------------------- const bereich = await db.bereich.create({ data: { name: `${kennung}-bereich`, farbe: '#2563eb', kapazitaetMo: 120 }, }) pruefe('Bereich anlegen', bereich.name.endsWith('-bereich')) pruefe( 'Arbeitsfenster hat brauchbare Standardwerte', bereich.arbeitsfensterVon === 480 && bereich.arbeitsfensterBis === 1020, ) await scheitert('Bereichsname ist eindeutig', () => db.bereich.create({ data: { name: bereich.name, farbe: '#000000' } }), ) // --- Passkey, Eindeutigkeit und Fremdschlüssel --------------------------- const passkey = await db.passkey.create({ data: { benutzerId: benutzer.id, credentialId: `${kennung}-cred`, publicKey: Buffer.from([1, 2, 3]), bezeichnung: 'Testgerät', }, }) pruefe('Passkey anlegen', passkey.counter === 0n) pruefe( 'Zähler ist ein BigInt und beginnt bei null', typeof passkey.counter === 'bigint', ) await scheitert('Credential-ID ist eindeutig', () => db.passkey.create({ data: { benutzerId: benutzer.id, credentialId: passkey.credentialId, publicKey: Buffer.from([4]), bezeichnung: 'Zweitgerät', }, }), ) await scheitert('Passkey ohne gültigen Benutzer ist nicht möglich', () => db.passkey.create({ data: { benutzerId: 'gibt-es-nicht', credentialId: `${kennung}-waise`, publicKey: Buffer.from([9]), bezeichnung: 'Waise', }, }), ) // --- Öffentlicher Schlüssel kommt unverändert zurück --------------------- const gelesen = await db.passkey.findUnique({ where: { id: passkey.id } }) pruefe( 'Der öffentliche Schlüssel kommt Byte für Byte zurück', Buffer.from(gelesen?.publicKey ?? []).equals(Buffer.from([1, 2, 3])), ) // --- Konto und Eindeutigkeit je Anbieter --------------------------------- await db.konto.create({ data: { benutzerId: benutzer.id, type: 'oidc', provider: 'oidc', providerAccountId: kennung, }, }) await scheitert('Ein Anbieterkonto ist nur einmal verknüpfbar', () => db.konto.create({ data: { benutzerId: benutzer.id, type: 'oidc', provider: 'oidc', providerAccountId: kennung, }, }), ) // --- Kalenderkonto und Termine ------------------------------------------ const kalenderkonto = await db.kalenderkonto.create({ data: { benutzerId: benutzer.id, anbieter: 'GRAPH', anzeigename: `${kennung} Firma Outlook`, extKontoId: `${kennung}-konto`, }, }) pruefe( 'Kalenderkonto zeigt im Zweifel auf den Standardkalender', kalenderkonto.kalenderId === 'standard', ) pruefe( 'Ein frisches Konto hat noch keinen Abgleichverweis', kalenderkonto.deltaVerweis === null, ) await scheitert('Dasselbe Konto und derselbe Kalender nur einmal', () => db.kalenderkonto.create({ data: { benutzerId: benutzer.id, anbieter: 'GRAPH', anzeigename: `${kennung} Doppelt`, extKontoId: `${kennung}-konto`, }, }), ) // Dasselbe Konto mit einem anderen Kalender ist dagegen in Ordnung. const zweiterKalender = await db.kalenderkonto.create({ data: { benutzerId: benutzer.id, anbieter: 'GRAPH', anzeigename: `${kennung} Zweiter Kalender`, extKontoId: `${kennung}-konto`, kalenderId: 'AAMkAD-zweiter', }, }) pruefe( 'Dasselbe Konto mit anderem Kalender ist erlaubt', zweiterKalender.id !== kalenderkonto.id, ) const termin = await db.termin.create({ data: { kalenderkontoId: kalenderkonto.id, extId: `${kennung}-termin`, betreff: 'Wochenbesprechung', beginn: new Date('2026-03-17T08:00:00Z'), ende: new Date('2026-03-17T09:00:00Z'), }, }) pruefe('Termin anlegen', termin.zeigtAls === 'GEBUCHT' && termin.ganztaegig === false) await scheitert('Derselbe Termin je Konto nur einmal', () => db.termin.create({ data: { kalenderkontoId: kalenderkonto.id, extId: `${kennung}-termin`, beginn: new Date('2026-03-17T08:00:00Z'), ende: new Date('2026-03-17T09:00:00Z'), }, }), ) // So arbeitet der Abgleich: upsert statt anlegen. const gewandelterTermin = await db.termin.upsert({ where: { kalenderkontoId_extId: { kalenderkontoId: kalenderkonto.id, extId: `${kennung}-termin`, }, }, update: { betreff: 'Wochenbesprechung, verschoben', zeigtAls: 'MIT_VORBEHALT' }, create: { kalenderkontoId: kalenderkonto.id, extId: `${kennung}-termin`, beginn: new Date('2026-03-17T08:00:00Z'), ende: new Date('2026-03-17T09:00:00Z'), }, }) pruefe( 'Ein Upsert aktualisiert den Termin, statt ihn zu doppeln', gewandelterTermin.id === termin.id && gewandelterTermin.zeigtAls === 'MIT_VORBEHALT' && (await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 1, ) // --- Standard-Kalenderkonto eines Bereichs ------------------------------- await db.bereich.update({ where: { id: bereich.id }, data: { standardKalenderkontoId: kalenderkonto.id }, }) await db.kalenderkonto.delete({ where: { id: zweiterKalender.id } }) // --- Audit überlebt den Benutzer ---------------------------------------- const audit = await db.auditEintrag.create({ data: { benutzerId: benutzer.id, benutzerEmail: benutzer.email, aktion: 'test.durchgefuehrt', objektTyp: 'Bereich', objektId: bereich.id, details: { name: bereich.name }, instanz: 'daheim', }, }) pruefe('Audit-Eintrag anlegen', audit.id.length > 0) await db.benutzer.delete({ where: { id: benutzer.id } }) const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } }) pruefe('Audit-Eintrag überlebt das Löschen des Benutzers', nachher !== null) pruefe( 'Die Kennung wird gelöst, die Adresse bleibt als Nachweis stehen', nachher?.benutzerId === null && nachher?.benutzerEmail === testEmail, ) pruefe( 'Passkeys verschwinden mit dem Benutzer', (await db.passkey.count({ where: { credentialId: passkey.credentialId } })) === 0, ) pruefe( 'Verknüpfte Konten verschwinden mit dem Benutzer', (await db.konto.count({ where: { providerAccountId: kennung } })) === 0, ) pruefe( 'Kalenderkonten verschwinden mit dem Benutzer', (await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0, ) pruefe( 'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine', (await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0, ) pruefe( 'Der Bereich überlebt das Löschen seines Standard-Kalenderkontos', (await db.bereich.findUnique({ where: { id: bereich.id } })) ?.standardKalenderkontoId === null, ) // --- 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 main() .catch((fehler: unknown) => { abbruch = fehler }) .finally(async () => { await aufraeumen() const uebrig = (await db.benutzer.count({ where: { email: testEmail } })) + (await db.bereich.count({ where: { name: { startsWith: kennung } } })) pruefe('Aufräumen hat nichts übrig gelassen', uebrig === 0) await db.$disconnect() if (abbruch) { console.error('Der Integrationstest ist abgebrochen:', abbruch) process.exit(1) } const gefallen = ergebnisse.filter((e) => !e.ok) for (const e of ergebnisse) { console.log(`${e.ok ? '✓' : '✗'} ${e.name}${e.hinweis ? ` – ${e.hinweis}` : ''}`) } console.log( `\n${ergebnisse.length - gefallen.length} von ${ergebnisse.length} Prüfungen bestanden.`, ) process.exit(gefallen.length === 0 ? 0 : 1) })