Files
planer/scripts/integrationstest.ts
Florian WildeandClaude Opus 5 7c8fe10f35 test: Erfassungsweg und Eindeutigkeit der Quellen absichern
E2E über den Weg, der in M2 zählt: schnell erfassen, einsortieren,
abhaken — und nachsehen, dass Erledigtes aus der Standardansicht fällt,
aber mit Filter wieder auftaucht.

Der Integrationstest deckt jetzt auch ab, was die Integrationen ab M6
brauchen: dieselbe Quelle mit derselben Kennung nur einmal, dieselbe
Kennung bei anderer Quelle erlaubt, mehrere Aufgaben ohne Quellverweis
nebeneinander, und ein Upsert aktualisiert statt zu doppeln.

Dazu der Nachweis, dass Aufgaben das Löschen ihres Projekts und ihres
Bereichs überleben und dabei nur die Zuordnung verlieren.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:17:45 +02:00

326 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<unknown>): Promise<void> {
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<void> {
// 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 } } })
await db.webauthnChallenge.deleteMany({ where: { kennung } })
}
async function main(): Promise<void> {
// --- 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,
},
}),
)
// --- 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,
)
// --- 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)
})