Files
Laufzettel/tests/kommentare.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

195 lines
8.0 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, LaufStatus, Rolle, SchrittZustand, VersionStatus } from "@/generated/prisma/enums";
import { LaufFehler, laufAbschliessen, laufAnlegen } from "@/server/services/laeufe";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { KOMMENTAR_HOECHSTLAENGE, kommentarHinzufuegen } from "@/server/services/kommentare";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Kommentare an Läufen und Schrittergebnissen (#013).
*
* Ein Kommentar ist der Ort für das, was in kein Feld passt. Er trägt Urheber und
* Zeitpunkt selbst, wird nie geändert — und kommt an einem archivierten Lauf
* nicht mehr dazu.
*/
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 },
};
}
async function vorlageMitVersion() {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Offboarding" } });
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: "Rückgabe", position: 0 },
});
const geraet = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Notebook zurück", pflicht: true, position: 0 },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return { version, schritte: { geraet } };
}
describe.runIf(datenbankVerfuegbar)("Kommentare", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
it("schreibt einen Kommentar an den Lauf", async () => {
const { admin, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const kommentar = await kommentarHinzufuegen(
{ laufId: lauf.id, rumpf: " Kunde war nicht erreichbar, neuer Termin am Montag. " },
techniker,
);
expect(kommentar.laufId).toBe(lauf.id);
expect(kommentar.schrittergebnisId).toBeNull();
expect(kommentar.benutzerId).toBe(techniker.id);
expect(kommentar.rumpf).toBe("Kunde war nicht erreichbar, neuer Termin am Montag.");
});
it("schreibt einen Kommentar an ein Schrittergebnis", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const ergebnis = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.geraet.id,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Netzteil fehlt.",
},
admin,
);
const kommentar = await kommentarHinzufuegen(
{ laufId: lauf.id, schrittergebnisId: ergebnis.id, rumpf: "Ersatz ist bestellt." },
admin,
);
expect(kommentar.schrittergebnisId).toBe(ergebnis.id);
});
it("startet den Lauf nicht", async () => {
const { admin } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Rückfrage an den Kunden gestellt." }, admin);
// Eine Rückfrage zu notieren ist keine Abarbeitung.
const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
expect(nachher.status).toBe(LaufStatus.OFFEN);
expect(nachher.gestartetAm).toBeNull();
});
it("nimmt einen Kommentar an einem abgeschlossenen Lauf noch an", 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.geraet.id, zustand: SchrittZustand.ERLEDIGT },
admin,
);
await laufAbschliessen(lauf.id, admin);
// Das Nachfassen kommt nach dem Abschluss — gesperrt wird erst durch das
// Archivieren (siehe laufMussVeraenderbarSein).
const kommentar = await kommentarHinzufuegen(
{ laufId: lauf.id, rumpf: "Nachtrag: Netzteil ist eine Woche später eingetroffen." },
admin,
);
expect(kommentar.id).toBeTruthy();
});
it("lehnt einen leeren und einen zu langen Kommentar ab", async () => {
const { admin } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await expect(kommentarHinzufuegen({ laufId: lauf.id, rumpf: " " }, admin)).rejects.toThrow(LaufFehler);
await expect(
kommentarHinzufuegen({ laufId: lauf.id, rumpf: "x".repeat(KOMMENTAR_HOECHSTLAENGE + 1) }, admin),
).rejects.toThrow(new RegExp(String(KOMMENTAR_HOECHSTLAENGE)));
expect(await testPrisma.kommentar.count()).toBe(0);
});
it("lehnt ein Schrittergebnis eines anderen Laufs ab", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const einer = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const anderer = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const fremdesErgebnis = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: anderer.id, vorlagenSchrittId: schritte.geraet.id },
});
// Die Kennung kommt von außen: ohne diese Prüfung hinge der Kommentar an
// einem Schritt eines fremden Laufs.
await expect(
kommentarHinzufuegen(
{ laufId: einer.id, schrittergebnisId: fremdesErgebnis.id, rumpf: "Gehört hier nicht hin." },
admin,
),
).rejects.toThrow(/gehört nicht zu diesem Lauf/);
expect(await testPrisma.kommentar.count()).toBe(0);
});
it("lässt einen Techniker nicht an den Lauf eines fremden Kunden", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "laufzettel-";
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
});
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 kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } });
await testPrisma.benutzerKunde.create({
data: { benutzerId: technikerZeile.id, kundeId: eigene.id },
});
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen(
{ vorlagenVersionId: version.id, kundeId: kundeA.id },
{ id: adminZeile.id, rolle: Rolle.ADMIN },
);
await expect(
kommentarHinzufuegen(
{ laufId: lauf.id, rumpf: "Darf ich das sehen?" },
{
id: technikerZeile.id,
rolle: Rolle.TECHNIKER,
},
),
).rejects.toThrow(/Berechtigung/);
expect(await testPrisma.kommentar.count()).toBe(0);
});
});