Phase 9 abgeschlossen: Übersicht, Beobachtungsliste, helles Thema, Messung
Die Übersicht beantwortet vier Fragen, die man morgens hat: woran arbeite ich,
was ist fällig, wo hat jemand geschrieben, was hat sich getan. Jede Liste
läuft durch visibleCaseWhere - das ist der Ort, an dem eine unachtsame
Abfrage am ehesten eine Akte verrät. Ein Test prüft jede einzeln gegen eine zu
hoch eingestufte Akte. Der bisherige Inhalt ("Dein Zugang") steht jetzt unter
/zugang.
Beobachten ist eine Merkliste, kein Recht: nur was sichtbar ist, lässt sich
merken, und steigt eine Akte später über die eigene Stufe, verschwindet sie
aus der Liste, ohne dass jemand aufräumt.
Das helle Thema tauscht nur die semantische Ebene; die Grundpalette bleibt.
Die Klassifizierungsfarben sind für hellen Grund eine Spur dunkler, im Farbton
aber dieselben - eine Stufe muss in beiden Themen wiedererkennbar sein. Drei
Zustände, "wie das System" ist der Auslieferungszustand, und ein Skript im
Kopf setzt die Wahl, bevor das erste Mal gemalt wird.
Die Messung misst nicht den Nutzer. web-vitals schickt Zahlen und das
Routenmuster, keine Kennung und keinen konkreten Pfad: wer wann welche Akte
geöffnet hat, steht im Zugriffsprotokoll, und eine zweite Spur daneben wäre
eine Überwachungsfunktion ohne Auftrag. Ausgewertet wird Median und 75.
Perzentil, nicht der Mittelwert.
Mobil: die breiten Tabellen scrollen in sich, statt die Seite breiter zu
machen. Ein e2e-Test hält fest, dass keine Seite waagerecht überläuft.
scripts/restore-test.sh spielt eine Sicherung in eine Wegwerf-Datenbank und
prüft ausdrücklich, dass Regeln und Trigger mitkommen: eine Wiederherstellung
ohne entry_no_delete wäre eine Datenbank ohne die Zusicherung, um derentwillen
das System gebaut wird. Einmal durchgespielt, alles angekommen.
186 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 54 e2e.
Grösste Route 116,2 von 120 kB.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const css = readFileSync(join(process.cwd(), 'app/globals.css'), 'utf8')
|
||||
|
||||
/**
|
||||
* Das helle Thema ist verpflichtend, siehe docs/10-design-system.md. Diese
|
||||
* Tests halten die zwei Eigenschaften fest, die es leicht verliert: dass es
|
||||
* wirklich jede semantische Farbe tauscht, und dass eine Freigabestufe in
|
||||
* beiden Themen dieselbe Farbe behält — sonst lernt man sie zweimal.
|
||||
*/
|
||||
describe('Helles Thema', () => {
|
||||
const semantisch = [
|
||||
'--color-surface',
|
||||
'--color-panel',
|
||||
'--color-raised',
|
||||
'--color-line',
|
||||
'--color-text',
|
||||
'--color-text-dim',
|
||||
'--color-accent',
|
||||
]
|
||||
|
||||
function block(selektor: string): string {
|
||||
const index = css.indexOf(selektor)
|
||||
expect(index, `${selektor} fehlt`).toBeGreaterThan(-1)
|
||||
return css.slice(index, css.indexOf('}', index))
|
||||
}
|
||||
|
||||
it('tauscht jede semantische Farbe', () => {
|
||||
const hell = block("[data-theme='light'] {")
|
||||
for (const token of semantisch) {
|
||||
expect(hell, `${token} fehlt im hellen Thema`).toContain(token)
|
||||
}
|
||||
})
|
||||
|
||||
it('folgt ohne Wahl dem System und bleibt sonst dunkel', () => {
|
||||
// Der Auslieferungszustand ist dunkel: das helle Thema greift nur bei
|
||||
// prefers-color-scheme: light oder ausdrücklicher Wahl.
|
||||
expect(css).toContain('@media (prefers-color-scheme: light)')
|
||||
expect(css).toContain(":root:not([data-theme='dark'])")
|
||||
})
|
||||
|
||||
it('behält die Klassifizierungsfarben als eigenes System', () => {
|
||||
// Die Farbtöne dürfen für hellen Grund nachgedunkelt werden, aber es muss
|
||||
// sie geben — sonst verschwindet der Stempel auf hellem Papier.
|
||||
const hell = css.slice(css.indexOf("[data-theme='light']"))
|
||||
for (const stufe of [0, 1, 2, 3, 4, 5]) {
|
||||
expect(hell).toContain(`--color-stamp-${stufe}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('lässt die Grundpalette unangetastet', () => {
|
||||
// `--ink-*` und `--paper*` sind die Rohfarben. Würde das helle Thema sie
|
||||
// umdefinieren, hinge daran alles, was sie direkt benutzt.
|
||||
const hell = block("[data-theme='light'] {")
|
||||
expect(hell).not.toContain('--color-ink-')
|
||||
expect(hell).not.toContain('--color-paper')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
// @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-${String(Date.now()).slice(-4)}`,
|
||||
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-${String(Date.now()).slice(-4)}`,
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user