Files
Laufzettel/tests/laeufe.test.ts
T
florian.wildeandClaude Opus 5 c78a6ac1af
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen und archiviert. Der
Archiv-Schreibschutz sitzt in genau einer Funktion und wird vor jeder Aenderung
an Kopfdaten, Schrittergebnis und Kommentar gerufen; abgeschlossen bleibt
aenderbar, archiviert nicht.

Die Abarbeitungsansicht speichert jeden Schritt einzeln, frischt sich alle zehn
Sekunden auf und pausiert im Hintergrund. Sie traegt in der Polling-Antwort
keine Befehle und laedt sich auch nicht von selbst neu, solange ein generiertes
Passwort auf der Seite steht: beides tauschte es dem Techniker zwischen Lesen
und Einfuegen aus.

Der unabhaengige Review fand zwei Blocker: ein Befehl konnte zwei verschiedene
Passwoerter tragen (Konto bekommt A, Benutzer erfaehrt B), und „nicht
zutreffend“ liess sich nicht zuruecknehmen, waehrend die Oberflaeche
behauptete, nichts sei gewaehlt. Beides behoben und in Tests festgehalten.

Closes #13

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 16:56:33 +00:00

889 lines
37 KiB
TypeScript
Raw 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.
import { beforeEach, describe, expect, it } from "vitest";
import {
Betriebsmodus,
LaufStatus,
Rolle,
SchrittTyp,
SchrittZustand,
VariablenTyp,
VersionStatus,
} from "@/generated/prisma/enums";
import {
LaufFehler,
laufAbschliessen,
laufAnlegen,
laufArchivieren,
laufKopfdatenAendern,
laufMitAdKontoVerknuepfen,
laufMussVeraenderbarSein,
laufZurBearbeitungOeffnen,
laufZurueckholen,
titelVorschlag,
titelVorschlagErmitteln,
} from "@/server/services/laeufe";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { kommentarHinzufuegen } from "@/server/services/kommentare";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Läufe: anlegen, starten, abschließen, archivieren (#013).
*
* Die Zusage, an der der Wert des ganzen Archivs hängt: ein archivierter Lauf
* ändert sich nicht mehr, und nur ein ADMIN hebt das auf. Dazu die Regeln, die
* einen Lauf überhaupt brauchbar machen — er hängt für immer an seiner Version,
* `OFFEN` ist der Zuweisungszustand, und die Mandantengrenze gilt an jeder
* Kennung, die von außen kommt.
*/
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const adminZeile = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Alex Admin", rolle: Rolle.ADMIN },
});
const technikerZeile = await testPrisma.benutzer.create({
data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER },
});
const leserZeile = await testPrisma.benutzer.create({
data: { email: "leser@firma.example", name: "Lars Leser", rolle: Rolle.LESER },
});
return {
eigene,
admin: { id: adminZeile.id, rolle: Rolle.ADMIN },
techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER },
leser: { id: leserZeile.id, rolle: Rolle.LESER },
};
}
type VariablenVorgabe = {
readonly schluessel: string;
readonly beschriftung: string;
readonly typ?: VariablenTyp;
readonly optionen?: readonly string[];
readonly pflicht?: boolean;
};
/**
* Eine Vorlage mit einer veröffentlichten Version 1: vier Schritte, drei davon
* Pflicht.
*
* Der Inhalt entsteht **vor** dem Veröffentlichen — die Trigger aus #009 lassen
* an einer veröffentlichten Version nichts mehr einfügen, und genau das ist der
* Sinn dieser Tests von der anderen Seite.
*/
async function vorlageMitVersion(vorgabe?: {
readonly kundeId?: string | null;
readonly variablen?: readonly VariablenVorgabe[];
readonly name?: string;
}) {
const vorlage = await testPrisma.vorlage.create({
data: { name: vorgabe?.name ?? "PC-Neuinstallation", kundeId: vorgabe?.kundeId ?? null },
});
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
for (const [index, variable] of (vorgabe?.variablen ?? []).entries()) {
await testPrisma.vorlagenVariable.create({
data: {
versionId: version.id,
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ ?? VariablenTyp.TEXT,
optionen: [...(variable.optionen ?? [])],
pflicht: variable.pflicht ?? false,
position: index,
},
});
}
const vorbereitung = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung", position: 0 },
});
const sicherung = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: vorbereitung.id, titel: "Datensicherung geprüft", pflicht: true, position: 0 },
});
const notizSchritt = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: vorbereitung.id,
titel: "Auffälligkeiten notiert",
typ: SchrittTyp.TEXT,
pflicht: false,
position: 1,
},
});
const konto = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Konto im AD", position: 1 },
});
const kontoAngelegt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: konto.id, titel: "Konto angelegt", pflicht: true, position: 0 },
});
const gruppen = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: konto.id, titel: "Gruppen zugewiesen", pflicht: true, position: 1 },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return {
vorlage,
version,
abschnitte: { vorbereitung, konto },
schritte: { sicherung, notizSchritt, kontoAngelegt, gruppen },
pflichtIds: [sicherung.id, kontoAngelegt.id, gruppen.id],
};
}
/** Setzt die genannten Schritte auf ERLEDIGT – über den Dienst, wie die Oberfläche. */
async function erledigen(
laufId: string,
schrittIds: readonly string[],
benutzer: { readonly id: string; readonly rolle: Rolle },
): Promise<void> {
for (const vorlagenSchrittId of schrittIds) {
await schrittergebnisSetzen({ laufId, vorlagenSchrittId, zustand: SchrittZustand.ERLEDIGT }, benutzer);
}
}
async function aktionen(laufId: string): Promise<readonly string[]> {
const eintraege = await testPrisma.protokoll.findMany({
where: { laufId },
orderBy: { erstelltAm: "asc" },
});
return eintraege.map((eintrag) => eintrag.aktion);
}
describe.runIf(datenbankVerfuegbar)("Läufe", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
describe("Anlegen und Zuweisen", () => {
// Akzeptanzkriterium 1
it("legt einen Lauf ohne Mitarbeiter und ohne Gerät an, schließt ihn ab und archiviert ihn", async () => {
const { admin, eigene } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
expect(lauf.status).toBe(LaufStatus.OFFEN);
expect(lauf.mitarbeiterId).toBeNull();
expect(lauf.geraetId).toBeNull();
expect(lauf.kundeId).toBe(eigene.id);
expect(lauf.erstelltVonId).toBe(admin.id);
// Die Ergebniszeilen entstehen beim Anlegen, damit der Fortschritt ab der
// ersten Sekunde stimmt.
expect(await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id } })).toBe(4);
await erledigen(lauf.id, pflichtIds, admin);
const abgeschlossen = await laufAbschliessen(lauf.id, admin);
expect(abgeschlossen.status).toBe(LaufStatus.ABGESCHLOSSEN);
expect(abgeschlossen.abgeschlossenVonId).toBe(admin.id);
expect(abgeschlossen.abgeschlossenAm).not.toBeNull();
const archiviert = await laufArchivieren(lauf.id, admin);
expect(archiviert.status).toBe(LaufStatus.ARCHIVIERT);
expect(archiviert.archiviertVonId).toBe(admin.id);
expect(archiviert.archiviertAm).not.toBeNull();
expect(await aktionen(lauf.id)).toEqual(
expect.arrayContaining(["Lauf angelegt", "Lauf gestartet", "Lauf abgeschlossen", "Lauf archiviert"]),
);
});
// Akzeptanzkriterium 6
it("lässt gestartetAm leer, solange der Lauf nur OFFEN und zugewiesen ist", async () => {
const { admin, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen(
{ vorlagenVersionId: version.id, zugewiesenAnId: techniker.id, ticket: "TICK-7" },
admin,
);
expect(lauf.status).toBe(LaufStatus.OFFEN);
expect(lauf.gestartetAm).toBeNull();
expect(lauf.zugewiesenAnId).toBe(techniker.id);
// Ansehen startet nichts: das Lesemodell rührt den Lauf nicht an, und auch
// nach dem Lesen der Kopfdaten steht gestartetAm noch leer.
const gelesen = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
expect(gelesen.gestartetAm).toBeNull();
expect(await aktionen(lauf.id)).toEqual(["Lauf angelegt"]);
});
it("startet den Lauf beim ersten Öffnen zur Bearbeitung und bleibt danach idempotent", async () => {
const { admin, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const erstes = await laufZurBearbeitungOeffnen(lauf.id, techniker);
expect(erstes.status).toBe(LaufStatus.IN_ARBEIT);
expect(erstes.gestartetAm).not.toBeNull();
const zweites = await laufZurBearbeitungOeffnen(lauf.id, techniker);
expect(zweites.gestartetAm?.getTime()).toBe(erstes.gestartetAm?.getTime());
expect((await aktionen(lauf.id)).filter((a) => a === "Lauf gestartet")).toHaveLength(1);
});
it("wirft einen abgeschlossenen oder archivierten Lauf beim Öffnen nicht zurück", async () => {
const { admin } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, pflichtIds, admin);
await laufAbschliessen(lauf.id, admin);
expect((await laufZurBearbeitungOeffnen(lauf.id, admin)).status).toBe(LaufStatus.ABGESCHLOSSEN);
await laufArchivieren(lauf.id, admin);
// Ein archivierter Lauf lässt sich aufschlagen, ohne dass das Öffnen wirft —
// sonst wäre er nicht mehr ansehbar.
expect((await laufZurBearbeitungOeffnen(lauf.id, admin)).status).toBe(LaufStatus.ARCHIVIERT);
});
it("schließt einen Lauf ohne Pflichtschritte direkt aus OFFEN ab, mit Startzeitpunkt", async () => {
const { admin } = await grundbestand();
const vorlage = await testPrisma.vorlage.create({ data: { name: "Patchday" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Durchsicht", position: 0 },
});
await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Kurz geschaut", pflicht: false, position: 0 },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const abgeschlossen = await laufAbschliessen(lauf.id, admin);
// Lauf_begonnen_mit_zeitpunkt in der Datenbank verlangt den Startzeitpunkt,
// und ein abgeschlossener Lauf ohne ihn sähe im Archiv wie ein Fehler aus.
expect(abgeschlossen.gestartetAm).not.toBeNull();
expect(abgeschlossen.status).toBe(LaufStatus.ABGESCHLOSSEN);
});
it("lehnt einen Entwurf und eine archivierte Vorlage als Grundlage ab", async () => {
const { admin } = await grundbestand();
const { vorlage, version } = await vorlageMitVersion();
const entwurf = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 2, status: VersionStatus.ENTWURF },
});
await expect(laufAnlegen({ vorlagenVersionId: entwurf.id }, admin)).rejects.toThrow(LaufFehler);
await expect(laufAnlegen({ vorlagenVersionId: entwurf.id }, admin)).rejects.toThrow(
/veröffentlichte Version/,
);
await testPrisma.vorlage.update({ where: { id: vorlage.id }, data: { archiviert: true } });
await expect(laufAnlegen({ vorlagenVersionId: version.id }, admin)).rejects.toThrow(/archiviert/);
});
it("lehnt Person und Freitextname zugleich ab", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace" },
});
await expect(
laufAnlegen(
{ vorlagenVersionId: version.id, mitarbeiterId: person.id, mitarbeiterName: "Ada L." },
admin,
),
).rejects.toThrow(/beides zugleich/);
expect(await testPrisma.lauf.count()).toBe(0);
});
it("nimmt einen deaktivierten Mitarbeiter an – das Offboarding braucht ihn", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Moritz",
nachname: "Geht",
anzeigename: "Moritz Geht",
aktiv: false,
},
});
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, admin);
expect(lauf.mitarbeiterId).toBe(person.id);
});
it("lehnt ein archiviertes Gerät ab", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const geraet = await testPrisma.geraet.create({
data: {
kundeId: eigene.id,
tagCode: "LZ-AAAAAA",
hostname: "PC-ALT-01",
archiviert: true,
registriertAm: new Date(),
},
});
await expect(
laufAnlegen({ vorlagenVersionId: version.id, geraetId: geraet.id }, admin),
).rejects.toThrow(/archiviert/);
});
it("weist eine Zuweisung an einen LESER und an ein deaktiviertes Konto ab", async () => {
const { admin, leser } = await grundbestand();
const { version } = await vorlageMitVersion();
const gesperrt = await testPrisma.benutzer.create({
data: { email: "weg@firma.example", name: "Wanda Weg", rolle: Rolle.TECHNIKER, aktiv: false },
});
await expect(
laufAnlegen({ vorlagenVersionId: version.id, zugewiesenAnId: leser.id }, admin),
).rejects.toThrow(/darf Läufe nicht bearbeiten/);
await expect(
laufAnlegen({ vorlagenVersionId: version.id, zugewiesenAnId: gesperrt.id }, admin),
).rejects.toThrow(/deaktiviert/);
});
});
describe("Variablen", () => {
it("nimmt gültige Werte auf und weist fehlende, unbekannte und falsche ab", async () => {
const { admin } = await grundbestand();
const { version } = await vorlageMitVersion({
variablen: [
{ schluessel: "os_version", beschriftung: "Betriebssystem", pflicht: true },
{
schluessel: "join_typ",
beschriftung: "Einbindung",
typ: VariablenTyp.AUSWAHL,
optionen: ["Active Directory", "Entra ID"],
},
{ schluessel: "speicher_gb", beschriftung: "Speicher in GB", typ: VariablenTyp.ZAHL },
],
});
await expect(laufAnlegen({ vorlagenVersionId: version.id }, admin)).rejects.toThrow(/„Betriebssystem“/);
await expect(
laufAnlegen(
{ vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", tipfehler: "x" } },
admin,
),
).rejects.toThrow(/„tipfehler“/);
await expect(
laufAnlegen(
{ vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", join_typ: "Novell" } },
admin,
),
).rejects.toThrow(/keine Auswahl/);
await expect(
laufAnlegen(
{ vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", speicher_gb: "viel" } },
admin,
),
).rejects.toThrow(/erwartet eine Zahl/);
const lauf = await laufAnlegen(
{
vorlagenVersionId: version.id,
variablen: { os_version: " Windows 11 Pro ", join_typ: "Entra ID", speicher_gb: 32 },
},
admin,
);
expect(lauf.variablen).toEqual({
os_version: "Windows 11 Pro",
join_typ: "Entra ID",
speicher_gb: 32,
});
});
});
describe("Titelvorschlag", () => {
it("baut <Vorlage> – <Hostname oder Mitarbeiter> – <Datum>", () => {
const datum = new Date("2026-09-25T10:00:00Z");
expect(titelVorschlag({ vorlage: "Onboarding", geraet: "NB-ADA", datum })).toBe(
"Onboarding – NB-ADA – 25.09.2026",
);
// Der Hostname gewinnt: er steht auf dem Gerät, vor dem der Techniker sitzt.
expect(titelVorschlag({ vorlage: "Onboarding", geraet: "NB-ADA", person: "Ada Lovelace", datum })).toBe(
"Onboarding – NB-ADA – 25.09.2026",
);
expect(titelVorschlag({ vorlage: "Onboarding", person: "Ada Lovelace", datum })).toBe(
"Onboarding – Ada Lovelace – 25.09.2026",
);
// Fehlt der Bezug, fällt er weg statt als leere Stelle zu erscheinen.
expect(titelVorschlag({ vorlage: "Patchday", datum })).toBe("Patchday – 25.09.2026");
});
it("löst den Vorschlag auf, bevor der Lauf existiert", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const geraet = await testPrisma.geraet.create({
data: {
kundeId: eigene.id,
tagCode: "LZ-BBBBBB",
hostname: "PC-VERTRIEB-07",
registriertAm: new Date(),
},
});
const vorschlag = await titelVorschlagErmitteln(
{ vorlagenVersionId: version.id, geraetId: geraet.id },
admin,
);
expect(vorschlag).toContain("PC-Neuinstallation");
expect(vorschlag).toContain("PC-VERTRIEB-07");
expect(await testPrisma.lauf.count()).toBe(0);
// Und das Anlegen ohne eigenen Titel setzt genau diesen Vorschlag ein.
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, geraetId: geraet.id }, admin);
expect(lauf.titel).toBe(vorschlag);
});
});
describe("Abschließen", () => {
// Akzeptanzkriterium 2
it("scheitert, solange ein Pflichtschritt OFFEN ist, und nennt ihn", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, [schritte.sicherung.id, schritte.kontoAngelegt.id], admin);
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(LaufFehler);
// Nicht „es fehlen noch Schritte“, sondern welche.
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/Konto im AD · Gruppen zugewiesen/);
expect((await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } })).status).toBe(
LaufStatus.IN_ARBEIT,
);
});
it("zählt FEHLGESCHLAGEN nicht als erledigt", async () => {
const { admin } = await grundbestand();
const { version, schritte, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(
lauf.id,
pflichtIds.filter((id) => id !== schritte.gruppen.id),
admin,
);
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Gruppenrichtlinie greift nicht, Ticket an das Netzwerkteam.",
},
admin,
);
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/fehlgeschlagener Schritt/);
// NICHT_ZUTREFFEND mit Begründung macht den Weg frei.
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz: "Der Kunde pflegt Gruppen selbst.",
},
admin,
);
expect((await laufAbschliessen(lauf.id, admin)).status).toBe(LaufStatus.ABGESCHLOSSEN);
});
it("lässt einen offenen Nicht-Pflichtschritt durchgehen", async () => {
const { admin } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, pflichtIds, admin);
expect((await laufAbschliessen(lauf.id, admin)).status).toBe(LaufStatus.ABGESCHLOSSEN);
});
it("ist beim zweiten Abschließen still – ein Doppelklick ist kein Fehler", async () => {
const { admin } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, pflichtIds, admin);
const erstes = await laufAbschliessen(lauf.id, admin);
const zweites = await laufAbschliessen(lauf.id, admin);
expect(zweites.abgeschlossenAm?.getTime()).toBe(erstes.abgeschlossenAm?.getTime());
expect((await aktionen(lauf.id)).filter((a) => a === "Lauf abgeschlossen")).toHaveLength(1);
});
it("hält zwei gleichzeitige Abschlüsse und ein Starten dazwischen aus", async () => {
const { admin, techniker } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, pflichtIds, admin);
// Zwei Techniker am selben Lauf: die Zeilensperre reiht die Schreibzugriffe
// auf, es entsteht genau ein Abschluss.
await Promise.all([
laufAbschliessen(lauf.id, admin),
laufAbschliessen(lauf.id, techniker),
laufZurBearbeitungOeffnen(lauf.id, techniker),
]);
const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
expect(nachher.status).toBe(LaufStatus.ABGESCHLOSSEN);
expect((await aktionen(lauf.id)).filter((a) => a === "Lauf abgeschlossen")).toHaveLength(1);
});
});
describe("Archiv", () => {
// Akzeptanzkriterium 4
it("lehnt an einem archivierten Lauf Schrittergebnis, Kommentar und Kopfdaten ab", async () => {
const { admin } = await grundbestand();
const { version, schritte, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, pflichtIds, admin);
const ergebnis = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id },
});
await laufAbschliessen(lauf.id, admin);
await laufArchivieren(lauf.id, admin);
await expect(
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.notizSchritt.id,
zustand: SchrittZustand.ERLEDIGT,
},
admin,
),
).rejects.toThrow(/archiviert/);
await expect(
kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Noch ein Nachtrag." }, admin),
).rejects.toThrow(/archiviert/);
await expect(
kommentarHinzufuegen(
{ laufId: lauf.id, schrittergebnisId: ergebnis.id, rumpf: "Und hier auch." },
admin,
),
).rejects.toThrow(/archiviert/);
await expect(laufKopfdatenAendern(lauf.id, { ticket: "TICK-NEU" }, admin)).rejects.toThrow(
/archiviert/,
);
await expect(laufMitAdKontoVerknuepfen(lauf.id, { upn: "ada@firma.local" }, admin)).rejects.toThrow(
/archiviert/,
);
// Und es ist wirklich nichts angekommen.
const unberuehrt = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: schritte.notizSchritt.id },
});
expect(unberuehrt.zustand).toBe(SchrittZustand.OFFEN);
expect(await testPrisma.kommentar.count({ where: { laufId: lauf.id } })).toBe(0);
});
// Akzeptanzkriterium 5
it("holt nur ein ADMIN einen archivierten Lauf zurück, mit Protokolleintrag", async () => {
const { admin, techniker } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, pflichtIds, admin);
await laufAbschliessen(lauf.id, admin);
await laufArchivieren(lauf.id, admin);
await expect(laufZurueckholen(lauf.id, techniker)).rejects.toThrow(/Administrator/);
expect((await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } })).status).toBe(
LaufStatus.ARCHIVIERT,
);
const zurueck = await laufZurueckholen(lauf.id, admin);
expect(zurueck.status).toBe(LaufStatus.ABGESCHLOSSEN);
expect(zurueck.archiviertAm).toBeNull();
expect(zurueck.archiviertVonId).toBeNull();
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId: lauf.id, aktion: "Lauf aus dem Archiv zurückgeholt" },
});
expect(eintrag.benutzerId).toBe(admin.id);
// Und danach ist der Lauf wieder änderbar.
const geaendert = await laufKopfdatenAendern(lauf.id, { ticket: "TICK-9" }, admin);
expect(geaendert.ticket).toBe("TICK-9");
});
it("archiviert nur einen abgeschlossenen Lauf und bleibt beim zweiten Mal still", async () => {
const { admin } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await expect(laufArchivieren(lauf.id, admin)).rejects.toThrow(/Erst abschließen/);
await erledigen(lauf.id, pflichtIds, admin);
await laufAbschliessen(lauf.id, admin);
const erstes = await laufArchivieren(lauf.id, admin);
const zweites = await laufArchivieren(lauf.id, admin);
expect(zweites.archiviertAm?.getTime()).toBe(erstes.archiviertAm?.getTime());
expect((await aktionen(lauf.id)).filter((a) => a === "Lauf archiviert")).toHaveLength(1);
});
it("hält den Schreibschutz an genau einer Stelle fest", () => {
// Ein abgeschlossener Lauf bleibt änderbar – nachgetragene Notizen und
// Kommentare sind der Normalfall. Erst das Archivieren sperrt.
for (const status of [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT, LaufStatus.ABGESCHLOSSEN]) {
expect(() => laufMussVeraenderbarSein({ status, titel: "Ein Lauf" })).not.toThrow();
}
expect(() => laufMussVeraenderbarSein({ status: LaufStatus.ARCHIVIERT, titel: "Ein Lauf" })).toThrow(
LaufFehler,
);
});
});
describe("Bindung an die Version", () => {
it("bleibt an seiner Version, auch wenn danach eine neue veröffentlicht wird", async () => {
const { admin } = await grundbestand();
const { vorlage, version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
// Version 2 mit einem zusätzlichen Pflichtschritt.
const zweite = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 2, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: zweite.id, titel: "Neu", position: 0 },
});
await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "BitLocker geprüft", pflicht: true, position: 0 },
});
await testPrisma.vorlagenVersion.update({
where: { id: zweite.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
// Der laufende Lauf kennt den neuen Pflichtschritt nicht: die drei
// Pflichtschritte aus Version 1 genügen zum Abschluss.
await erledigen(lauf.id, pflichtIds, admin);
const abgeschlossen = await laufAbschliessen(lauf.id, admin);
expect(abgeschlossen.vorlagenVersionId).toBe(version.id);
expect(await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id } })).toBe(4);
// Und es gibt keinen Weg, ihn auf die neue Version zu heben.
await expect(laufKopfdatenAendern(lauf.id, { vorlagenVersionId: zweite.id }, admin)).rejects.toThrow(
/gestartet wurde/,
);
});
});
describe("Kopfdaten", () => {
it("ändert nur, was sich wirklich ändert, und protokolliert es", async () => {
const { admin, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, ticket: "TICK-1" }, admin);
const unveraendert = await laufKopfdatenAendern(lauf.id, { ticket: "TICK-1" }, admin);
expect(unveraendert.geaendertAm.getTime()).toBe(lauf.geaendertAm.getTime());
expect(await aktionen(lauf.id)).toEqual(["Lauf angelegt"]);
const faellig = new Date("2026-10-01T08:00:00Z");
const geaendert = await laufKopfdatenAendern(
lauf.id,
{ ticket: "TICK-2", zugewiesenAnId: techniker.id, faelligAm: faellig, titel: "Neuer Titel" },
admin,
);
expect(geaendert.ticket).toBe("TICK-2");
expect(geaendert.zugewiesenAnId).toBe(techniker.id);
expect(geaendert.faelligAm?.getTime()).toBe(faellig.getTime());
expect(geaendert.titel).toBe("Neuer Titel");
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId: lauf.id, entitaet: "Lauf", aktion: { contains: "geändert" } },
});
expect(eintrag.vorher).toMatchObject({ Ticket: "TICK-1" });
expect(eintrag.nachher).toMatchObject({ Ticket: "TICK-2", Titel: "Neuer Titel" });
});
it("prüft geänderte Variablen erneut und meldet nur echte Änderungen", async () => {
const { admin } = await grundbestand();
const { version } = await vorlageMitVersion({
variablen: [
{ schluessel: "os_version", beschriftung: "Betriebssystem", pflicht: true },
{ schluessel: "notiz", beschriftung: "Bemerkung" },
],
});
const lauf = await laufAnlegen(
{ vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", notiz: "eilig" } },
admin,
);
// Dieselben Werte in anderer Reihenfolge sind keine Änderung.
const gleich = await laufKopfdatenAendern(
lauf.id,
{ variablen: { notiz: "eilig", os_version: "Windows 11" } },
admin,
);
expect(gleich.geaendertAm.getTime()).toBe(lauf.geaendertAm.getTime());
expect(await aktionen(lauf.id)).toEqual(["Lauf angelegt"]);
// Eine Pflichtvariable lässt sich auch nachträglich nicht leeren.
await expect(laufKopfdatenAendern(lauf.id, { variablen: { notiz: "eilig" } }, admin)).rejects.toThrow(
/„Betriebssystem“/,
);
const geaendert = await laufKopfdatenAendern(
lauf.id,
{ variablen: { os_version: "Windows 11 Pro", notiz: "eilig" } },
admin,
);
expect(geaendert.variablen).toEqual({ os_version: "Windows 11 Pro", notiz: "eilig" });
expect(await aktionen(lauf.id)).toContain("Variablen geändert");
});
it("lehnt einen leeren Titel ab", async () => {
const { admin } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await expect(laufKopfdatenAendern(lauf.id, { titel: " " }, admin)).rejects.toThrow(/Titel/);
});
});
describe("Mit AD-Konto verknüpfen", () => {
it("verbindet einen Freitextnamen über den UPN und behält den alten Namen im Protokoll", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen(
{ vorlagenVersionId: version.id, mitarbeiterName: "Ada Lovelace (neu)" },
admin,
);
const person = await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Ada",
nachname: "Lovelace",
anzeigename: "Ada Lovelace",
upn: "ada.lovelace@firma.local",
},
});
const verknuepft = await laufMitAdKontoVerknuepfen(lauf.id, { upn: "ADA.LOVELACE@firma.local" }, admin);
expect(verknuepft.mitarbeiterId).toBe(person.id);
// Die Bedingung Lauf_mitarbeiter_eindeutig schließt beides zugleich aus.
expect(verknuepft.mitarbeiterName).toBeNull();
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId: lauf.id, aktion: "Mit AD-Konto verknüpft" },
});
expect(eintrag.vorher).toMatchObject({ mitarbeiterName: "Ada Lovelace (neu)" });
expect(eintrag.nachher).toMatchObject({ anzeigename: "Ada Lovelace" });
});
it("lehnt ein zweites Konto und einen unbekannten UPN ab", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Ada",
nachname: "Lovelace",
anzeigename: "Ada Lovelace",
upn: "ada@firma.local",
},
});
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, admin);
await expect(laufMitAdKontoVerknuepfen(lauf.id, { mitarbeiterId: person.id }, admin)).rejects.toThrow(
/hängt schon eine Person/,
);
const zweiter = await laufAnlegen(
{ vorlagenVersionId: version.id, mitarbeiterName: "Niemand Bekannt" },
admin,
);
await expect(
laufMitAdKontoVerknuepfen(zweiter.id, { upn: "gibt.es.nicht@firma.local" }, admin),
).rejects.toThrow(/kein Konto/);
});
});
describe("Mandantengrenze", () => {
it("verwirft im Modus INTERN eine mitgeschickte kundeId", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const fremder = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: fremder.id }, admin);
expect(lauf.kundeId).toBe(eigene.id);
});
it("lehnt Gerät, Person und Abteilung eines fremden Kunden ab", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } });
const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KB" } });
const { version } = await vorlageMitVersion();
const geraetB = await testPrisma.geraet.create({
data: {
kundeId: kundeB.id,
tagCode: "LZ-CCCCCC",
hostname: "PC-B-01",
registriertAm: new Date(),
},
});
const personB = await testPrisma.mitarbeiter.create({
data: { kundeId: kundeB.id, vorname: "Bea", nachname: "Bee", anzeigename: "Bea Bee" },
});
const abteilungB = await testPrisma.abteilung.create({
data: { kundeId: kundeB.id, name: "Buchhaltung" },
});
for (const eingabe of [
{ geraetId: geraetB.id },
{ mitarbeiterId: personB.id },
{ abteilungId: abteilungB.id },
]) {
await expect(
laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id, ...eingabe }, admin),
).rejects.toThrow(/anderen Kunden/);
}
expect(await testPrisma.lauf.count()).toBe(0);
});
it("lehnt eine kundenspezifische Vorlage bei einem anderen Kunden ab", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } });
const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KB" } });
const { version } = await vorlageMitVersion({ kundeId: kundeB.id, name: "Nur für B" });
await expect(laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id }, admin)).rejects.toThrow(
/anderen Kunden/,
);
expect((await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeB.id }, admin)).kundeId).toBe(
kundeB.id,
);
});
it("lässt einen Techniker nicht an den Lauf eines fremden Kunden", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "laufzettel-";
const { admin, techniker } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } });
const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KB" } });
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: kundeB.id },
});
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id }, admin);
// Kennung allein öffnet keinen fremden Mandanten – in jeder Funktion.
await expect(laufZurBearbeitungOeffnen(lauf.id, techniker)).rejects.toThrow(/Berechtigung/);
await expect(laufKopfdatenAendern(lauf.id, { ticket: "X" }, techniker)).rejects.toThrow(/Berechtigung/);
await expect(laufAbschliessen(lauf.id, techniker)).rejects.toThrow(/Berechtigung/);
await expect(kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Hallo" }, techniker)).rejects.toThrow(
/Berechtigung/,
);
});
});
});