Die letzte offene Zeile aus TASKS.md. Sie blieb offen, weil eine Spur über
jeden Aktenaufruf schnell etwas anderes wird als eine Bequemlichkeit.
Vier Festlegungen halten sie auf der richtigen Seite:
- Kein Verlauf. Der Primärschlüssel (userId, targetType, targetId) lässt je
Nutzer und Datensatz genau eine Zeile zu, die überschrieben wird. Die
Tabelle kann nicht sagen, wie oft oder in welcher Reihenfolge gearbeitet
wurde. Zwanzig Einträge, älteres fällt hinten heraus.
- Nur die eigene. Keine Ansicht, kein Adminbereich, keine Funktion mit
fremder Kennung. Jede Abfrage nimmt p.userId.
- Jederzeit löschbar, ohne Rückfrage und ohne Grabstein.
- Vorabrufe zählen nicht. Ohne die Prüfung auf next-router-prefetch stünde
dort, worüber jemand mit der Maus gefahren ist.
Gelesen wird über dieselben Sichtbarkeitsfragmente wie überall: eine
vorgemerkte Kennung ist keine Berechtigung. Steigt eine Akte über die
Freigabestufe, verschwindet sie ohne Lücke und ohne Platzhalter; Personalakten
brauchen zusätzlich personnel.read. Das Zugriffsprotokoll bleibt unberührt und
bleibt die einzige Auskunft darüber, wer wann was gelesen hat.
Sie steht auf der Übersicht und im leeren Feld der Kommandopalette. Der zweite
Ort ist der eigentliche.
Nebenbei: NEXT_DIST_DIR, damit ein Build neben einem laufenden
Entwicklungsserver nicht dasselbe .next-Verzeichnis abräumt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
// @vitest-environment node
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import { db } from '@/lib/db'
|
|
import { clearRecent, listRecent, noteOpened, RECENT_MAX } from '@/lib/recent/service'
|
|
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
|
|
|
|
/**
|
|
* Die Sprungliste ist eine Bequemlichkeit — und genau deshalb der Ort, an dem
|
|
* eine Abkürzung besonders verlockend wäre: die Kennungen liegen ja schon da,
|
|
* warum sie nochmal prüfen? Weil eine vorgemerkte Kennung keine Berechtigung
|
|
* ist. Diese Datei prüft vor allem, dass die Liste nichts durchlässt.
|
|
*/
|
|
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
|
const suite = url ? describe : describe.skip
|
|
|
|
const marke = `recent-${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('Zuletzt geöffnet', () => {
|
|
const nutzer = { a: `${marke}-a`, b: `${marke}-b` }
|
|
let offenId = ''
|
|
let offenNummer = ''
|
|
let geheimId = ''
|
|
let personId = ''
|
|
|
|
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.a,
|
|
}
|
|
const offen = await db.case.create({
|
|
data: {
|
|
...gemeinsam,
|
|
caseNumber: `Rez-2026-${String(Date.now()).slice(-4)}`,
|
|
title: 'Offener Vorgang',
|
|
classification: 1,
|
|
status: 'OPEN',
|
|
},
|
|
})
|
|
const geheim = await db.case.create({
|
|
data: {
|
|
...gemeinsam,
|
|
caseNumber: `Rgh-2026-${String(Date.now()).slice(-4)}`,
|
|
title: 'Streng vertraulich',
|
|
classification: 4,
|
|
status: 'OPEN',
|
|
},
|
|
})
|
|
const person = await db.personnelFile.create({
|
|
data: { displayName: `${marke}-Person`, classification: 1, createdById: nutzer.a },
|
|
})
|
|
|
|
offenId = offen.id
|
|
offenNummer = offen.caseNumber
|
|
geheimId = geheim.id
|
|
personId = person.id
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await db.$disconnect()
|
|
})
|
|
|
|
it('merkt sich einen Sprung und zeigt ihn wieder', async () => {
|
|
await noteOpened(nutzer.a, 'CASE', offenId)
|
|
const liste = await listRecent(perms(nutzer.a, 1))
|
|
|
|
expect(liste.map((e) => e.targetId)).toContain(offenId)
|
|
expect(liste[0]?.href).toBe(`/faelle/${offenNummer}`)
|
|
expect(liste[0]?.label).toBe(offenNummer)
|
|
})
|
|
|
|
it('führt keinen Verlauf: zweimal öffnen bleibt eine Zeile', async () => {
|
|
await noteOpened(nutzer.a, 'CASE', offenId)
|
|
await noteOpened(nutzer.a, 'CASE', offenId)
|
|
|
|
const zeilen = await db.recentView.count({
|
|
where: { userId: nutzer.a, targetType: 'CASE', targetId: offenId },
|
|
})
|
|
expect(zeilen).toBe(1)
|
|
})
|
|
|
|
it('zeigt eine zu hoch eingestufte Akte nicht, auch wenn sie vorgemerkt ist', async () => {
|
|
// Der Fall, der zählt: die Kennung liegt in der Liste, die Freigabe fehlt.
|
|
// Etwa weil die Akte nach dem Öffnen hochgestuft wurde.
|
|
await noteOpened(nutzer.a, 'CASE', geheimId)
|
|
|
|
const hoch = await listRecent(perms(nutzer.a, 4))
|
|
expect(hoch.map((e) => e.targetId)).toContain(geheimId)
|
|
|
|
const niedrig = await listRecent(perms(nutzer.a, 1))
|
|
expect(niedrig.map((e) => e.targetId)).not.toContain(geheimId)
|
|
// Und keine Lücke, kein Platzhalter: die Zeile fehlt einfach.
|
|
expect(niedrig.every((e) => e.label.length > 0)).toBe(true)
|
|
})
|
|
|
|
it('zeigt Personalakten nur, wer sie überhaupt aufrufen darf', async () => {
|
|
await noteOpened(nutzer.a, 'PERSONNEL', personId)
|
|
|
|
const ohneRecht = await listRecent(perms(nutzer.a, 4))
|
|
expect(ohneRecht.map((e) => e.targetId)).not.toContain(personId)
|
|
|
|
const mitRecht = await listRecent(perms(nutzer.a, 4, { permissions: ['personnel.read'] }))
|
|
expect(mitRecht.map((e) => e.targetId)).toContain(personId)
|
|
})
|
|
|
|
it('zeigt niemandem die Liste eines anderen', async () => {
|
|
await noteOpened(nutzer.b, 'CASE', offenId)
|
|
|
|
// Nutzer a hat die Akte auch geöffnet — aber b bekommt seine eigene Zeile,
|
|
// und ein Administrator bekommt seine eigene, nicht die von b.
|
|
const admin = await listRecent(perms(`${marke}-admin`, 5, { isAdmin: true }))
|
|
expect(admin).toHaveLength(0)
|
|
})
|
|
|
|
it('behält nur die jüngsten Einträge', async () => {
|
|
const vorlage = await db.documentTemplate.findFirstOrThrow({
|
|
where: { key: `${marke}-vorlage` },
|
|
})
|
|
const viele = nutzer.b
|
|
for (let i = 0; i < RECENT_MAX + 3; i++) {
|
|
const akte = await db.case.create({
|
|
data: {
|
|
templateId: vorlage.id,
|
|
templateKey: vorlage.key,
|
|
createdById: viele,
|
|
caseNumber: `Rvi-${Date.now()}-${i}`,
|
|
title: `Vorgang ${i}`,
|
|
classification: 1,
|
|
status: 'OPEN',
|
|
},
|
|
})
|
|
await noteOpened(viele, 'CASE', akte.id)
|
|
}
|
|
|
|
const zeilen = await db.recentView.count({ where: { userId: viele } })
|
|
expect(zeilen).toBeLessThanOrEqual(RECENT_MAX)
|
|
// Die zuerst geöffnete Akte ist hinten herausgefallen.
|
|
const liste = await listRecent(perms(viele, 1), RECENT_MAX)
|
|
expect(liste.map((e) => e.targetId)).not.toContain(offenId)
|
|
})
|
|
|
|
it('lässt sich leeren', async () => {
|
|
await noteOpened(nutzer.a, 'CASE', offenId)
|
|
expect(await clearRecent(nutzer.a)).toBeGreaterThan(0)
|
|
expect(await listRecent(perms(nutzer.a, 5, { isAdmin: true }))).toHaveLength(0)
|
|
})
|
|
})
|