Gemessen statt vermutet: die Datenschicht war nie das Problem. resolvePermissions unter 1 ms, loadDashboard 11 ms, searchCases 5 ms, die Abteilungsabfragen 3–4 ms. Die Sekunden kamen aus zwei Ecken. **Fehlende Ladegrenzen.** Den meisten Routen fehlte loading.tsx - unter anderem der Übersicht, der Suche und dem gesamten Adminbereich. Ohne diese Datei hat der Router keine Grenze, wartet stumm auf die fertige Serverantwort, und bis dahin steht die alte Seite: man klickt, und scheinbar passiert nichts. Neunzehn Skelette nachgetragen, jede Route hat jetzt eine. Dazu quittiert der geklickte Link den Klick selbst, mit einem ruhig pulsierenden Punkt statt eines Kreisels. Regel 5 verbietet den Blockade-Spinner, der Inhalt ersetzt; eine Quittung im Link ersetzt nichts, und die eigentliche Antwort auf das Warten bleibt das Skelett. **Webpack.** Der Entwicklungsserver übersetzte jede Route beim ersten Aufruf neu: 8 s für die Übersicht, 2,8 s für die Fallliste. Mit Turbopack sind es 0,43 s beim ersten Aufruf und 0,1 s danach. Nur pnpm dev, der Produktionsbau bleibt unverändert. Gegengeprüft, dass die Anmeldeseite mit CSS und ohne Fehler im Log rendert. Nebenbei die eine Abfrage, die schlecht skalierte: die Übersicht zählte ungelesene Nachrichten mit einer Abfrage je Ort - bei hundert Akten hundert Hin- und Rückwege. Jetzt ein einziges groupBy mit der Lesemarke im ODER-Zweig des jeweiligen Ortes. Ein Test prüft, was dabei leicht verrutscht: die Marke gilt je Ort, und eigene Nachrichten zählen nie mit.
197 lines
6.8 KiB
TypeScript
197 lines
6.8 KiB
TypeScript
// @vitest-environment node
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import { db } from '@/lib/db'
|
|
import { loadDashboard } from '@/lib/dashboard/load'
|
|
import { listWatched, toggleWatch } from '@/lib/cases/watch'
|
|
import { AccessError } from '@/lib/errors'
|
|
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
|
|
|
|
/**
|
|
* Die Übersicht ist der Ort, an dem eine unachtsame Abfrage am ehesten eine
|
|
* Akte verrät: fünf Listen auf einer Seite, jede mit eigenem Filter. Geprüft
|
|
* wird deshalb jede einzeln gegen eine zu hoch eingestufte Akte.
|
|
*/
|
|
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
|
const suite = url ? describe : describe.skip
|
|
|
|
const marke = `dash-${Date.now()}`
|
|
|
|
function perms(
|
|
userId: string,
|
|
clearanceLevel: number,
|
|
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
|
|
): EffectivePermissions {
|
|
return {
|
|
userId,
|
|
clearanceLevel,
|
|
permissions: new Set<Permission>(options.permissions ?? []),
|
|
isAdmin: options.isAdmin ?? false,
|
|
hasMembership: true,
|
|
}
|
|
}
|
|
|
|
suite('Übersicht und Beobachtungsliste', () => {
|
|
const nutzer = { stufe1: `${marke}-stufe1`, stufe4: `${marke}-stufe4` }
|
|
let offenNummer = ''
|
|
let geheimId = ''
|
|
let geheimNummer = ''
|
|
|
|
beforeAll(async () => {
|
|
const vorlage = await db.documentTemplate.create({
|
|
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
|
|
})
|
|
for (const id of Object.values(nutzer)) {
|
|
await db.user.create({ data: { id, username: id, displayName: id } })
|
|
}
|
|
|
|
const gemeinsam = {
|
|
templateId: vorlage.id,
|
|
templateKey: vorlage.key,
|
|
createdById: nutzer.stufe4,
|
|
}
|
|
|
|
const offen = await db.case.create({
|
|
data: {
|
|
...gemeinsam,
|
|
caseNumber: `Dash-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
title: 'Offener Vorgang',
|
|
classification: 1,
|
|
status: 'OPEN',
|
|
dueAt: new Date(Date.now() + 2 * 86_400_000),
|
|
},
|
|
})
|
|
const geheim = await db.case.create({
|
|
data: {
|
|
...gemeinsam,
|
|
caseNumber: `Dghm-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
title: 'Streng vertraulich',
|
|
classification: 4,
|
|
status: 'OPEN',
|
|
// Auch eine Frist: sie darf in keiner Liste auftauchen.
|
|
dueAt: new Date(Date.now() + 86_400_000),
|
|
},
|
|
})
|
|
offenNummer = offen.caseNumber
|
|
geheimId = geheim.id
|
|
geheimNummer = geheim.caseNumber
|
|
|
|
// Der Stufe-1-Nutzer ist an beiden Akten beteiligt — an der geheimen aber
|
|
// nicht über sein Konto, sondern gar nicht. Er darf sie nicht sehen.
|
|
await db.caseParticipant.create({
|
|
data: { caseId: offen.id, userId: nutzer.stufe1, role: 'INVESTIGATOR' },
|
|
})
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await db.$disconnect()
|
|
})
|
|
|
|
it('hält eine zu hoch eingestufte Akte aus jeder Liste heraus', async () => {
|
|
const daten = await loadDashboard(perms(nutzer.stufe1, 1))
|
|
const alle = [
|
|
...daten.meine,
|
|
...daten.fristen,
|
|
...daten.zuletzt,
|
|
...daten.beobachtet,
|
|
].map((akte) => akte.id)
|
|
|
|
expect(alle).not.toContain(geheimId)
|
|
// Und die eigene ist da, sonst prüft der Test nichts.
|
|
expect(daten.meine.map((a) => a.caseNumber)).toContain(offenNummer)
|
|
expect(daten.fristen.map((a) => a.caseNumber)).toContain(offenNummer)
|
|
})
|
|
|
|
it('zeigt der Stufe 4 die Frist der geheimen Akte', async () => {
|
|
const daten = await loadDashboard(perms(nutzer.stufe4, 4))
|
|
expect(daten.fristen.map((a) => a.id)).toContain(geheimId)
|
|
})
|
|
|
|
it('lässt nur beobachten, was man sehen darf', async () => {
|
|
// Beobachten ist kein Weg an der Stufe vorbei.
|
|
await expect(toggleWatch(perms(nutzer.stufe1, 1), geheimNummer)).rejects.toThrow(AccessError)
|
|
|
|
const an = await toggleWatch(perms(nutzer.stufe1, 1), offenNummer)
|
|
expect(an.watching).toBe(true)
|
|
expect((await listWatched(perms(nutzer.stufe1, 1))).map((a) => a.caseNumber)).toContain(
|
|
offenNummer,
|
|
)
|
|
|
|
const aus = await toggleWatch(perms(nutzer.stufe1, 1), offenNummer)
|
|
expect(aus.watching).toBe(false)
|
|
})
|
|
|
|
it('nimmt eine beobachtete Akte aus der Liste, sobald sie zu hoch steigt', async () => {
|
|
const p = perms(nutzer.stufe4, 4)
|
|
await toggleWatch(p, geheimNummer)
|
|
expect((await listWatched(p)).map((a) => a.id)).toContain(geheimId)
|
|
|
|
// Derselbe Beobachtungseintrag, aber jetzt aus Sicht einer niedrigeren
|
|
// Stufe: die Liste läuft durch dieselbe Sichtbarkeitsprüfung.
|
|
const abgestiegen = perms(nutzer.stufe4, 1)
|
|
expect((await listWatched(abgestiegen)).map((a) => a.id)).not.toContain(geheimId)
|
|
})
|
|
|
|
it('zählt Ungelesenes je Ort und lässt Eigenes aus', async () => {
|
|
// Die Zählung lief einmal als eine Abfrage je Ort und ist jetzt eine
|
|
// einzige über alle. Geprüft wird deshalb, was dabei leicht verrutscht:
|
|
// die Lesemarke gilt **je Ort**, und eigene Nachrichten zählen nie mit.
|
|
const p = perms(nutzer.stufe1, 1)
|
|
const vorlage = await db.documentTemplate.findFirstOrThrow({
|
|
where: { key: `${marke}-vorlage` },
|
|
})
|
|
const zweite = await db.case.create({
|
|
data: {
|
|
templateId: vorlage.id,
|
|
templateKey: vorlage.key,
|
|
createdById: nutzer.stufe4,
|
|
caseNumber: `Ung-2026-${Date.now().toString(36)}`,
|
|
title: 'Zweiter Vorgang',
|
|
classification: 1,
|
|
status: 'OPEN',
|
|
},
|
|
})
|
|
await db.caseParticipant.create({
|
|
data: { caseId: zweite.id, userId: nutzer.stufe1, role: 'INVESTIGATOR' },
|
|
})
|
|
|
|
const erste = await db.case.findFirstOrThrow({ where: { caseNumber: offenNummer } })
|
|
const frueher = new Date(Date.now() - 60_000)
|
|
|
|
await db.chatMessage.createMany({
|
|
data: [
|
|
// Vor der Lesemarke der ersten Akte: zählt nicht.
|
|
{
|
|
id: `${marke}-alt`,
|
|
caseId: erste.id,
|
|
authorId: nutzer.stufe4,
|
|
body: 'alt',
|
|
createdAt: frueher,
|
|
},
|
|
// Danach: zählt.
|
|
{ id: `${marke}-neu`, caseId: erste.id, authorId: nutzer.stufe4, body: 'neu' },
|
|
// Eigene Nachricht: zählt nie.
|
|
{ id: `${marke}-eigen`, caseId: erste.id, authorId: nutzer.stufe1, body: 'eigen' },
|
|
// Zweite Akte ohne Lesemarke: alles zählt.
|
|
{ id: `${marke}-z1`, caseId: zweite.id, authorId: nutzer.stufe4, body: 'a' },
|
|
{ id: `${marke}-z2`, caseId: zweite.id, authorId: nutzer.stufe4, body: 'b' },
|
|
],
|
|
})
|
|
|
|
await db.readMarker.create({
|
|
data: {
|
|
userId: nutzer.stufe1,
|
|
scopeType: 'case',
|
|
scopeId: erste.id,
|
|
lastReadAt: new Date(Date.now() - 30_000),
|
|
},
|
|
})
|
|
|
|
const daten = await loadDashboard(p)
|
|
const zuErster = daten.ungelesen.find((u) => u.scopeId === erste.id)
|
|
const zuZweiter = daten.ungelesen.find((u) => u.scopeId === zweite.id)
|
|
|
|
expect(zuErster?.count).toBe(1)
|
|
expect(zuZweiter?.count).toBe(2)
|
|
})
|
|
})
|