Files
Laufzettel/tests/schrittergebnisse.test.ts
T
Florian WildeandClaude Opus 5 ed6e6b81eb
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) 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 18:55:59 +02:00

554 lines
20 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,
VersionStatus,
} from "@/generated/prisma/enums";
import { LaufFehler, laufAbschliessen, laufAnlegen } from "@/server/services/laeufe";
import { abschnittNichtZutreffend, schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Schrittergebnisse (#013).
*
* Zwei Regeln stehen hier im Mittelpunkt. Die Notizpflicht bei
* `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` — ohne sie steht im Archiv ein
* übersprungener Schritt ohne Begründung, und das ist genau die Lücke, die ein
* Archiv wertlos macht. Und die Abschnitts-Aktion, die achtzehn Haken mit
* derselben Begründung erledigt, ohne die Arbeit zu löschen, die jemand schon
* getan hat.
*/
async function grundbestand() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
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 },
});
return {
eigene,
admin: { id: adminZeile.id, rolle: Rolle.ADMIN },
techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER },
};
}
/**
* Eine veröffentlichte Version mit zwei Abschnitten: „Vorbereitung“ mit einem
* Pflicht-Haken und einem Zahlenschritt, „Konto im AD“ mit zwei Pflichtschritten
* — der Abschnitt, der im Onboarding je nach Kunde gar nicht zutrifft.
*/
async function vorlageMitVersion() {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
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 speicher = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: vorbereitung.id,
titel: "Freier Speicher in GB",
typ: SchrittTyp.ZAHL,
pflicht: false,
position: 1,
konfig: { min: 20, max: 2000, einheit: "GB" },
},
});
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 {
version,
abschnitte: { vorbereitung, konto },
schritte: { sicherung, speicher, kontoAngelegt, gruppen },
pflichtIds: [sicherung.id, kontoAngelegt.id, gruppen.id],
};
}
async function eintraege(laufId: string, entitaet: string): Promise<readonly string[]> {
const zeilen = await testPrisma.protokoll.findMany({
where: { laufId, entitaet },
orderBy: { erstelltAm: "asc" },
});
return zeilen.map((zeile) => zeile.aktion);
}
describe.runIf(datenbankVerfuegbar)("Schrittergebnisse", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
describe("Notizpflicht", () => {
// Akzeptanzkriterium 3
it("lehnt NICHT_ZUTREFFEND ohne Notiz ab und nimmt es mit Notiz an", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await expect(
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
},
admin,
),
).rejects.toThrow(LaufFehler);
await expect(
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz: " ",
},
admin,
),
).rejects.toThrow(/Begründung/);
const ergebnis = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz: "Der Kunde pflegt Gruppen selbst.",
},
admin,
);
expect(ergebnis.zustand).toBe(SchrittZustand.NICHT_ZUTREFFEND);
expect(ergebnis.notiz).toBe("Der Kunde pflegt Gruppen selbst.");
expect(ergebnis.aktualisiertVonId).toBe(admin.id);
});
it("verlangt auch bei FEHLGESCHLAGEN eine Notiz", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await expect(
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.kontoAngelegt.id,
zustand: SchrittZustand.FEHLGESCHLAGEN,
},
admin,
),
).rejects.toThrow(/Notiz/);
});
it("prüft den Zustand danach: eine vorhandene Notiz genügt, das Leeren nicht", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Gruppenrichtlinie greift nicht.",
},
admin,
);
// Wechsel des Zustands ohne Notiz-Feld: die vorhandene Begründung bleibt.
const gewechselt = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
},
admin,
);
expect(gewechselt.notiz).toBe("Gruppenrichtlinie greift nicht.");
// Das Leeren im selben Zug fällt auf die Pflicht.
await expect(
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz: null,
},
admin,
),
).rejects.toThrow(/Begründung/);
});
});
describe("Setzen", () => {
it("startet den Lauf beim ersten Schrittergebnis", async () => {
const { admin, techniker } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
expect(lauf.gestartetAm).toBeNull();
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT },
techniker,
);
const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
expect(nachher.status).toBe(LaufStatus.IN_ARBEIT);
expect(nachher.gestartetAm).not.toBeNull();
expect(await eintraege(lauf.id, "Lauf")).toContain("Lauf gestartet");
});
// Akzeptanzkriterium 7
it("schreibt jede Änderung ins Protokoll – und eine Wiederholung nicht", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT },
admin,
);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.OFFEN },
admin,
);
// Zweimal dasselbe: ein Doppelklick soll keinen zweiten Eintrag ergeben.
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.OFFEN },
admin,
);
expect(await eintraege(lauf.id, "Schrittergebnis")).toEqual([
"Schritt erledigt",
"Schritt zurückgesetzt",
]);
const letzter = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId: lauf.id, aktion: "Schritt zurückgesetzt" },
});
expect(letzter.vorher).toMatchObject({ zustand: SchrittZustand.ERLEDIGT });
expect(letzter.nachher).toMatchObject({
schritt: "Datensicherung geprüft",
pflicht: true,
zustand: SchrittZustand.OFFEN,
});
});
it("behält den Wert beim Zustandswechsel und löscht ihn nur auf Ansage", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const mitWert = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.speicher.id,
zustand: SchrittZustand.ERLEDIGT,
wert: { zahl: 512 },
},
admin,
);
expect(mitWert.wert).toEqual({ zahl: 512 });
const ohneWertfeld = await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.speicher.id, zustand: SchrittZustand.OFFEN },
admin,
);
expect(ohneWertfeld.wert).toEqual({ zahl: 512 });
const geleert = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.speicher.id,
zustand: SchrittZustand.OFFEN,
wert: null,
},
admin,
);
expect(geleert.wert).toBeNull();
});
it("blockiert eine Zahl außerhalb von Min und Max nicht", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
// Der Auftrag sagt ausdrücklich: markieren, nicht blockieren. Ein Gerät mit
// 4 GB frei ist eine Tatsache, und der Dienst hat sie aufzunehmen.
const ergebnis = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.speicher.id,
zustand: SchrittZustand.ERLEDIGT,
wert: { zahl: 4 },
},
admin,
);
expect(ergebnis.wert).toEqual({ zahl: 4 });
});
it("lehnt einen Schritt ab, der zu diesem Lauf nicht gehört", async () => {
const { admin } = await grundbestand();
const { version } = await vorlageMitVersion();
const fremde = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
// Ein Schritt aus einer anderen Version – in der Abarbeitungsansicht
// tauchte er nie auf, im Archiv aber schon.
await expect(
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: fremde.schritte.sicherung.id,
zustand: SchrittZustand.ERLEDIGT,
},
admin,
),
).rejects.toThrow(/gehört nicht zu diesem Lauf/);
await expect(
schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: "gibt-es-nicht", zustand: SchrittZustand.ERLEDIGT },
admin,
),
).rejects.toThrow(/gehört nicht zu diesem Lauf/);
});
it("nimmt den Abschluss zurück, wenn ein Pflichtschritt wieder offen ist", async () => {
const { admin } = await grundbestand();
const { version, schritte, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
for (const vorlagenSchrittId of pflichtIds) {
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId, zustand: SchrittZustand.ERLEDIGT },
admin,
);
}
await laufAbschliessen(lauf.id, admin);
// Ein abgeschlossener Lauf bleibt änderbar – aber „abgeschlossen“ darf
// danach nicht behaupten, alle Pflichtschritte seien erledigt.
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.OFFEN },
admin,
);
const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
expect(nachher.status).toBe(LaufStatus.IN_ARBEIT);
expect(nachher.abgeschlossenAm).toBeNull();
expect(nachher.abgeschlossenVonId).toBeNull();
expect(await eintraege(lauf.id, "Lauf")).toContain("Abschluss zurückgenommen");
});
it("hält zwei Techniker am selben Lauf aus", async () => {
const { admin, techniker } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await Promise.all([
schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT },
admin,
),
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.kontoAngelegt.id,
zustand: SchrittZustand.ERLEDIGT,
},
techniker,
),
]);
const erledigt = await testPrisma.schrittergebnis.count({
where: { laufId: lauf.id, zustand: SchrittZustand.ERLEDIGT },
});
expect(erledigt).toBe(2);
// Und nur ein „Lauf gestartet“, obwohl beide gleichzeitig angefangen haben.
expect((await eintraege(lauf.id, "Lauf")).filter((a) => a === "Lauf gestartet")).toHaveLength(1);
});
it("hält zwei gleichzeitige Änderungen am selben Schritt aus", async () => {
const { admin, techniker } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await Promise.all([
schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT },
admin,
),
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.sicherung.id,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Sicherung schlug fehl.",
},
techniker,
),
]);
// Eine Zeile, nicht zwei: der eindeutige Index (laufId, vorlagenSchrittId)
// und die Zeilensperre halten zusammen.
const zeilen = await testPrisma.schrittergebnis.findMany({
where: { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id },
});
expect(zeilen).toHaveLength(1);
expect([SchrittZustand.ERLEDIGT, SchrittZustand.FEHLGESCHLAGEN]).toContain(zeilen[0]?.zustand);
});
});
describe("Abschnitt als nicht zutreffend", () => {
// Akzeptanzkriterium 8
it("setzt alle Schritte des Abschnitts mit derselben Notiz", async () => {
const { admin } = await grundbestand();
const { version, abschnitte, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const ergebnis = await abschnittNichtZutreffend(
{
laufId: lauf.id,
abschnittId: abschnitte.konto.id,
notiz: "Der Kunde verwaltet Konten selbst.",
},
admin,
);
expect(ergebnis.gesetzt).toBe(2);
expect(ergebnis.uebersprungen).toEqual([]);
const zeilen = await testPrisma.schrittergebnis.findMany({
where: {
laufId: lauf.id,
vorlagenSchrittId: { in: [schritte.kontoAngelegt.id, schritte.gruppen.id] },
},
});
expect(zeilen).toHaveLength(2);
for (const zeile of zeilen) {
expect(zeile.zustand).toBe(SchrittZustand.NICHT_ZUTREFFEND);
expect(zeile.notiz).toBe("Der Kunde verwaltet Konten selbst.");
}
// Der andere Abschnitt bleibt unberührt.
const sicherung = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id },
});
expect(sicherung.zustand).toBe(SchrittZustand.OFFEN);
// Je geänderter Schritt ein Protokolleintrag.
expect(await eintraege(lauf.id, "Schrittergebnis")).toEqual([
"Abschnitt als nicht zutreffend eingetragen",
"Abschnitt als nicht zutreffend eingetragen",
]);
});
it("lässt erledigte Schritte erledigt und meldet sie", async () => {
const { admin } = await grundbestand();
const { version, abschnitte, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.kontoAngelegt.id,
zustand: SchrittZustand.ERLEDIGT,
},
admin,
);
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Gruppen fehlen im AD.",
},
admin,
);
const ergebnis = await abschnittNichtZutreffend(
{ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Kunde macht das selbst." },
admin,
);
// „Erledigt“ ist eine Tatsache mit Namen und Zeitstempel; „nicht
// zutreffend“ darüberzuschreiben wäre deren Verlust.
expect(ergebnis.uebersprungen).toEqual(["Konto angelegt"]);
expect(ergebnis.gesetzt).toBe(1);
const angelegt = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: schritte.kontoAngelegt.id },
});
expect(angelegt.zustand).toBe(SchrittZustand.ERLEDIGT);
// Ein fehlgeschlagener Schritt wird dagegen überschrieben.
const gruppen = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id },
});
expect(gruppen.zustand).toBe(SchrittZustand.NICHT_ZUTREFFEND);
expect(gruppen.notiz).toBe("Kunde macht das selbst.");
});
it("verlangt eine Notiz und lehnt einen fremden Abschnitt ab", async () => {
const { admin } = await grundbestand();
const { version, abschnitte } = await vorlageMitVersion();
const fremde = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await expect(
abschnittNichtZutreffend({ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: " " }, admin),
).rejects.toThrow(/Begründung/);
await expect(
abschnittNichtZutreffend(
{ laufId: lauf.id, abschnittId: fremde.abschnitte.konto.id, notiz: "Trifft nicht zu." },
admin,
),
).rejects.toThrow(/gehört nicht zu diesem Lauf/);
expect(
await testPrisma.protokoll.count({ where: { laufId: lauf.id, entitaet: "Schrittergebnis" } }),
).toBe(0);
});
it("schreibt beim zweiten Mal mit derselben Notiz nichts nach", async () => {
const { admin } = await grundbestand();
const { version, abschnitte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await abschnittNichtZutreffend(
{ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Trifft hier nicht zu." },
admin,
);
const zweites = await abschnittNichtZutreffend(
{ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Trifft hier nicht zu." },
admin,
);
expect(zweites.gesetzt).toBe(0);
expect(await eintraege(lauf.id, "Schrittergebnis")).toHaveLength(2);
});
});
});