Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Das Auswahlfeld enthielt nur die gerade erreichbaren Zustaende. Damit sah man weder, wo die Akte steht, noch welche Zustaende es ueberhaupt gibt - und nach jedem Wechsel standen andere Eintraege darin. Eine Auswahl, die ihre Eintraege tauscht, ist keine Orientierung. Jetzt stehen immer alle sieben da, in der Reihenfolge des Lebenszyklus: Entwurf, Offen, In Pruefung, Abgeschlossen, Archiviert als eine Linie, darunter Ruhend und Versiegelt als Nebenwege. Der jetzige Zustand ist markiert, erreichbare sind Knoepfe, gesperrte bleiben blass stehen statt zu verschwinden - mit dem Grund als Titel daran. Der Grund ist dabei nicht dasselbe wie die Sperre: "von hier fuehrt kein Weg dorthin" laesst sich durch kein Recht beheben, "dir fehlt case.close" schon. Wer das verwechselt, sucht an der falschen Stelle. Was ein Wechsel verlangt, steht jetzt vorher da statt als Absage danach: die Pflichtbegruendung beim Wiedereroeffnen und beim Versiegeln, und dass vor dem Abschluss die noetigen Abschnitte gefuellt sein muessen. Bei Pflichtbegruendung bleibt der Knopf gesperrt, bis sie dasteht. Die Rechtepruefung rechnet der Server, mit derselben Funktion, die der Dienst beim Wechsel anwendet - vorher zeigte die Liste auch Ziele, die danach abgelehnt wurden. Zwei Pruefungen halten das fest, und zwar in beide Richtungen: nichts angeboten, was scheitern wuerde, und nichts verschwiegen, was ginge. Die zweite ist die wichtigere, weil ihr Fehlen nicht auffaellt.
170 lines
6.2 KiB
TypeScript
170 lines
6.2 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
findTransition,
|
|
LIFECYCLE_MAIN,
|
|
LIFECYCLE_SIDE,
|
|
statusOptions,
|
|
type CaseStatusName,
|
|
} from '@/lib/cases/lifecycle'
|
|
import { canEditCase } from '@/lib/access/caseWrite'
|
|
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
|
|
|
|
/**
|
|
* Die Zustandskarte.
|
|
*
|
|
* Sie ersetzt eine Auswahlliste, die nur die gerade erreichbaren Zustände
|
|
* enthielt - man sah nie, wo die Akte steht und was es sonst noch gibt, und
|
|
* nach jedem Wechsel standen andere Einträge darin.
|
|
*
|
|
* Zwei Zusicherungen hängen daran, und beide stehen hier:
|
|
*
|
|
* 1. Es sind **immer alle** Zustände dabei, in derselben Reihenfolge. Sonst
|
|
* wandert die Karte wieder.
|
|
* 2. Was die Karte als schaltbar zeigt, führt der Dienst auch aus. Ein Knopf,
|
|
* der in eine Absage läuft, ist schlimmer als kein Knopf.
|
|
*/
|
|
function perms(
|
|
clearanceLevel: number,
|
|
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
|
|
): EffectivePermissions {
|
|
return {
|
|
userId: 'u',
|
|
clearanceLevel,
|
|
permissions: new Set<Permission>(options.permissions ?? []),
|
|
isAdmin: options.isAdmin ?? false,
|
|
hasMembership: true,
|
|
}
|
|
}
|
|
|
|
const ALLE = [...LIFECYCLE_MAIN, ...LIFECYCLE_SIDE]
|
|
|
|
describe('Die Karte zeigt immer dasselbe', () => {
|
|
it('enthält aus jedem Zustand alle sieben, in gleicher Reihenfolge', () => {
|
|
const admin = perms(5, { isAdmin: true })
|
|
for (const von of ALLE) {
|
|
const karte = statusOptions(admin, { status: von, classification: 1 })
|
|
expect(karte.map((o) => o.status), von).toEqual(ALLE)
|
|
}
|
|
})
|
|
|
|
it('markiert genau einen Zustand als den jetzigen', () => {
|
|
for (const von of ALLE) {
|
|
const karte = statusOptions(perms(5, { isAdmin: true }), { status: von, classification: 1 })
|
|
const jetzt = karte.filter((o) => o.block?.kind === 'current')
|
|
expect(jetzt.map((o) => o.status)).toEqual([von])
|
|
// Der jetzige Zustand ist kein Ziel.
|
|
expect(jetzt[0]?.available).toBe(false)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Was die Karte anbietet, führt der Dienst auch aus', () => {
|
|
const faelle: { p: EffectivePermissions; name: string }[] = [
|
|
{ p: perms(5, { isAdmin: true }), name: 'Admin' },
|
|
{ p: perms(3, { permissions: ['case.edit'] }), name: 'nur case.edit' },
|
|
{ p: perms(3, { permissions: ['case.edit', 'case.close'] }), name: 'edit und close' },
|
|
{ p: perms(1, { permissions: ['case.edit'] }), name: 'zu niedrige Stufe' },
|
|
{ p: perms(3), name: 'ohne jedes Recht' },
|
|
]
|
|
|
|
it('bietet nichts an, was an Übergang oder Recht scheitern würde', () => {
|
|
// Der Kern: dieselbe Prüfung, die changeStatus anwendet, gegen jede
|
|
// Zelle der Karte.
|
|
for (const { p, name } of faelle) {
|
|
for (const von of ALLE) {
|
|
const akte = { status: von, classification: 3 }
|
|
for (const option of statusOptions(p, akte)) {
|
|
if (!option.available) continue
|
|
|
|
const uebergang = findTransition(von, option.status)
|
|
expect(uebergang, `${name}: ${von} -> ${option.status}`).toBeDefined()
|
|
expect(
|
|
canEditCase(p, akte, uebergang!.permission).ok,
|
|
`${name}: ${von} -> ${option.status}`,
|
|
).toBe(true)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('lässt nichts aus, was ginge', () => {
|
|
// Die Gegenrichtung. Eine Karte, die einen gültigen Weg verschweigt, wäre
|
|
// genauso unbrauchbar - nur unauffälliger.
|
|
for (const { p, name } of faelle) {
|
|
for (const von of ALLE) {
|
|
const akte = { status: von, classification: 3 }
|
|
const karte = statusOptions(p, akte)
|
|
|
|
for (const nach of ALLE) {
|
|
if (nach === von) continue
|
|
const uebergang = findTransition(von, nach)
|
|
if (!uebergang) continue
|
|
if (!canEditCase(p, akte, uebergang.permission).ok) continue
|
|
|
|
expect(
|
|
karte.find((o) => o.status === nach)?.available,
|
|
`${name}: ${von} -> ${nach}`,
|
|
).toBe(true)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Der Grund für eine Sperre', () => {
|
|
it('nennt das fehlende Recht', () => {
|
|
const karte = statusOptions(perms(3), { status: 'OPEN', classification: 1 })
|
|
const ruhend = karte.find((o) => o.status === 'DORMANT')
|
|
expect(ruhend?.available).toBe(false)
|
|
expect(ruhend?.block).toEqual({ kind: 'no-permission', permission: 'case.edit' })
|
|
})
|
|
|
|
it('unterscheidet "geht von hier nicht" von "darfst du nicht"', () => {
|
|
// Beides sperrt, aber nur eines lässt sich durch Rechte beheben. Wer das
|
|
// verwechselt, sucht an der falschen Stelle.
|
|
const karte = statusOptions(perms(5, { isAdmin: true }), { status: 'DRAFT', classification: 1 })
|
|
expect(karte.find((o) => o.status === 'ARCHIVED')?.block).toEqual({ kind: 'unreachable' })
|
|
expect(karte.find((o) => o.status === 'OPEN')?.available).toBe(true)
|
|
})
|
|
|
|
it('sperrt aus einer archivierten Akte alles, auch für den Admin', () => {
|
|
const karte = statusOptions(perms(5, { isAdmin: true }), {
|
|
status: 'ARCHIVED',
|
|
classification: 1,
|
|
})
|
|
expect(karte.filter((o) => o.available)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('Was ein Wechsel verlangt', () => {
|
|
it('meldet die Pflichtbegründung beim Wiedereröffnen', () => {
|
|
const karte = statusOptions(perms(5, { isAdmin: true }), {
|
|
status: 'CLOSED',
|
|
classification: 1,
|
|
})
|
|
expect(karte.find((o) => o.status === 'OPEN')?.requiresReason).toBe(true)
|
|
})
|
|
|
|
it('meldet die Pflichtbegründung beim Versiegeln', () => {
|
|
for (const von of ['DRAFT', 'OPEN', 'REVIEW'] as CaseStatusName[]) {
|
|
const karte = statusOptions(perms(5, { isAdmin: true }), { status: von, classification: 1 })
|
|
expect(karte.find((o) => o.status === 'SEALED')?.requiresReason, von).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('meldet die vollständigen Abschnitte vor dem Abschluss', () => {
|
|
const karte = statusOptions(perms(5, { isAdmin: true }), {
|
|
status: 'REVIEW',
|
|
classification: 1,
|
|
})
|
|
expect(karte.find((o) => o.status === 'CLOSED')?.requiresCompleteSections).toBe(true)
|
|
})
|
|
|
|
it('verlangt sonst nichts', () => {
|
|
const karte = statusOptions(perms(5, { isAdmin: true }), { status: 'DRAFT', classification: 1 })
|
|
const offen = karte.find((o) => o.status === 'OPEN')
|
|
expect(offen?.requiresReason).toBe(false)
|
|
expect(offen?.requiresCompleteSections).toBe(false)
|
|
})
|
|
})
|