Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.
Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.
Schwer:
- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
(/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.
Mittel:
- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
Graph-Leser sich im Kommentar darauf berief.
Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.
Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.
1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { LaufStatus, SchrittZustand, VersionStatus } from "@/generated/prisma/enums";
|
||
import { LaufFehler, laufAnlegen, laufAbschliessen } from "@/server/services/laeufe";
|
||
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
|
||
import { laufZumAbarbeiten } from "@/server/queries/laeufe";
|
||
import { laufZumDrucken } from "@/server/queries/druck";
|
||
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
|
||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||
|
||
/**
|
||
* Abteilungsspezifische Zusatzabschnitte (#031).
|
||
*
|
||
* Die Auswahl wird **beim Start festgeschrieben**. Alles hier dreht sich darum:
|
||
* dass sie stimmt, dass sie sich danach nicht mehr aendert, dass ein
|
||
* ausgefilterter Pflichtschritt den Abschluss nicht blockiert, und dass die
|
||
* Druckansicht genau dasselbe zeigt wie die Abarbeitung.
|
||
*/
|
||
|
||
describe("Auswahlregel", () => {
|
||
const abschnitte = [
|
||
{ id: "a-alle", nurFuerAbteilungen: [] },
|
||
{ id: "a-buch", nurFuerAbteilungen: ["Buchhaltung"] },
|
||
{ id: "a-beide", nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"] },
|
||
];
|
||
|
||
it("nimmt uneingeschränkte Abschnitte immer", () => {
|
||
expect(geltendeAbschnitteErmitteln(abschnitte, null)).toEqual(["a-alle"]);
|
||
});
|
||
|
||
it("nimmt den Abschnitt der passenden Abteilung dazu", () => {
|
||
expect(geltendeAbschnitteErmitteln(abschnitte, "Buchhaltung")).toEqual(["a-alle", "a-buch", "a-beide"]);
|
||
expect(geltendeAbschnitteErmitteln(abschnitte, "Vertrieb")).toEqual(["a-alle", "a-beide"]);
|
||
});
|
||
|
||
it("vergleicht ohne Rücksicht auf Groß- und Kleinschreibung und Ränder", () => {
|
||
// Die Namen kommen aus zwei Quellen – der gepflegten Abteilung und dem
|
||
// Freitextfeld im Editor. „buchhaltung“ ist dieselbe Abteilung.
|
||
expect(geltendeAbschnitteErmitteln(abschnitte, " buchhaltung ")).toContain("a-buch");
|
||
});
|
||
|
||
it("lässt eine unbekannte Abteilung nur die uneingeschränkten Abschnitte", () => {
|
||
expect(geltendeAbschnitteErmitteln(abschnitte, "Einkauf")).toEqual(["a-alle"]);
|
||
});
|
||
});
|
||
|
||
describe("nurGeltende", () => {
|
||
const abschnitte = [{ id: "eins" }, { id: "zwei" }, { id: "drei" }];
|
||
|
||
it("gibt bei leerer Liste alles zurück – so bleiben alte Läufe unberührt", () => {
|
||
expect(nurGeltende(abschnitte, [])).toEqual(abschnitte);
|
||
});
|
||
|
||
it("filtert und behält die Reihenfolge der Version", () => {
|
||
expect(nurGeltende(abschnitte, ["drei", "eins"])).toEqual([{ id: "eins" }, { id: "drei" }]);
|
||
});
|
||
});
|
||
|
||
async function aufbau(
|
||
abschnitte: readonly {
|
||
readonly titel: string;
|
||
readonly nurFuerAbteilungen?: readonly string[];
|
||
readonly schritte: readonly { readonly titel: string; readonly pflicht?: boolean }[];
|
||
}[],
|
||
) {
|
||
const kunde = await testPrisma.kunde.create({
|
||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||
});
|
||
await testPrisma.einstellung.create({ data: { id: "global" } });
|
||
const admin = await testPrisma.benutzer.create({
|
||
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
|
||
});
|
||
const buchhaltung = await testPrisma.abteilung.create({
|
||
data: { kundeId: kunde.id, name: "Buchhaltung" },
|
||
});
|
||
const vertrieb = await testPrisma.abteilung.create({
|
||
data: { kundeId: kunde.id, name: "Vertrieb" },
|
||
});
|
||
|
||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
|
||
const version = await testPrisma.vorlagenVersion.create({
|
||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||
});
|
||
for (const [position, abschnitt] of abschnitte.entries()) {
|
||
const angelegt = await testPrisma.vorlagenAbschnitt.create({
|
||
data: {
|
||
versionId: version.id,
|
||
titel: abschnitt.titel,
|
||
position,
|
||
nurFuerAbteilungen: [...(abschnitt.nurFuerAbteilungen ?? [])],
|
||
},
|
||
});
|
||
await testPrisma.vorlagenSchritt.createMany({
|
||
data: abschnitt.schritte.map((schritt, stelle) => ({
|
||
abschnittId: angelegt.id,
|
||
titel: schritt.titel,
|
||
pflicht: schritt.pflicht ?? false,
|
||
position: stelle,
|
||
})),
|
||
});
|
||
}
|
||
await testPrisma.vorlagenVersion.update({
|
||
where: { id: version.id },
|
||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||
});
|
||
|
||
return {
|
||
kunde,
|
||
admin: { id: admin.id, rolle: admin.rolle },
|
||
buchhaltung,
|
||
vertrieb,
|
||
versionId: version.id,
|
||
};
|
||
}
|
||
|
||
const VORLAGE = [
|
||
{ titel: "Vorbereitung", schritte: [{ titel: "Papiere", pflicht: true }] },
|
||
{
|
||
titel: "Buchhaltung",
|
||
nurFuerAbteilungen: ["Buchhaltung"],
|
||
schritte: [{ titel: "DATEV einrichten", pflicht: true }],
|
||
},
|
||
{ titel: "Abschluss", schritte: [{ titel: "Übergabe", pflicht: true }] },
|
||
] as const;
|
||
|
||
describe.runIf(datenbankVerfuegbar)("Abteilungsabschnitte am Lauf", () => {
|
||
beforeEach(async () => {
|
||
await datenbankLeeren();
|
||
});
|
||
|
||
it("nimmt den Buchhaltungs-Abschnitt für einen Lauf der Buchhaltung", async () => {
|
||
const { admin, buchhaltung, versionId } = await aufbau(VORLAGE);
|
||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: buchhaltung.id }, admin);
|
||
|
||
const daten = await laufZumAbarbeiten(lauf.id, admin);
|
||
expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual([
|
||
"Vorbereitung",
|
||
"Buchhaltung",
|
||
"Abschluss",
|
||
]);
|
||
// Die Nummerierung zählt nur die geltenden Schritte.
|
||
expect(daten?.abschnitte.flatMap((a) => a.schritte.map((s) => s.nummer))).toEqual([1, 2, 3]);
|
||
});
|
||
|
||
it("lässt ihn für einen Lauf des Vertriebs weg", async () => {
|
||
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
|
||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
|
||
|
||
const daten = await laufZumAbarbeiten(lauf.id, admin);
|
||
expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual(["Vorbereitung", "Abschluss"]);
|
||
expect(daten?.anzahlPflicht).toBe(2);
|
||
});
|
||
|
||
it("gibt einem Lauf ohne Abteilung nur die uneingeschränkten Abschnitte", async () => {
|
||
// Ein Firewall-Rollout hat keine Abteilung, und das ist kein Mangel.
|
||
const { admin, versionId } = await aufbau(VORLAGE);
|
||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
|
||
|
||
const daten = await laufZumAbarbeiten(lauf.id, admin);
|
||
expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual(["Vorbereitung", "Abschluss"]);
|
||
});
|
||
|
||
it("legt nur für die geltenden Schritte ein Schrittergebnis an", async () => {
|
||
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
|
||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
|
||
|
||
const ergebnisse = await testPrisma.schrittergebnis.findMany({
|
||
where: { laufId: lauf.id },
|
||
include: { vorlagenSchritt: true },
|
||
});
|
||
expect(ergebnisse.map((e) => e.vorlagenSchritt.titel).sort()).toEqual(["Papiere", "Übergabe"]);
|
||
});
|
||
|
||
it("schreibt im Protokoll die Schritte dieses Laufs, nicht die der Version", async () => {
|
||
/*
|
||
* **Die Zahl war die der Version** (Reviewbefund zu #029). Bei einer Vorlage
|
||
* mit abteilungsgebundenen Abschnitten behauptete der Eintrag „Lauf angelegt“
|
||
* mehr Schritte, als der Lauf hat.
|
||
*
|
||
* Das Protokoll ist seit #023 ansehbar: eine Zahl darin, die nicht zur Liste
|
||
* darunter passt, schickt jemanden auf die Suche nach Schritten, die es nie
|
||
* gab.
|
||
*/
|
||
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
|
||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
|
||
|
||
const wirklich = await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id } });
|
||
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||
where: { laufId: lauf.id, aktion: "Lauf angelegt" },
|
||
});
|
||
|
||
expect(wirklich).toBe(2);
|
||
expect(eintrag.nachher).toMatchObject({ anzahlSchritte: wirklich });
|
||
});
|
||
|
||
it("ändert die Abschnitte nicht, wenn später die Abteilung des Laufs wechselt", async () => {
|
||
/*
|
||
* Der Grund für das Festschreiben: ein archivierter Lauf muss genau die
|
||
* Schritte zeigen, die damals abgearbeitet wurden. Ein Abteilungswechsel
|
||
* darf keinen Schritt entfernen, an dem schon eine Notiz hängt.
|
||
*/
|
||
const { admin, buchhaltung, vertrieb, versionId } = await aufbau(VORLAGE);
|
||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: buchhaltung.id }, admin);
|
||
expect((await laufZumAbarbeiten(lauf.id, admin))?.abschnitte).toHaveLength(3);
|
||
|
||
await testPrisma.lauf.update({ where: { id: lauf.id }, data: { abteilungId: vertrieb.id } });
|
||
|
||
const nachher = await laufZumAbarbeiten(lauf.id, admin);
|
||
expect(nachher?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual([
|
||
"Vorbereitung",
|
||
"Buchhaltung",
|
||
"Abschluss",
|
||
]);
|
||
});
|
||
|
||
it("lässt den Abschluss zu, obwohl ein ausgefilterter Pflichtschritt existiert", async () => {
|
||
// Ohne diese Regel wäre der Abschluss unerreichbar: der Pflichtschritt der
|
||
// Buchhaltung ließe sich in einem Vertriebs-Lauf nie abhaken.
|
||
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
|
||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
|
||
|
||
const ergebnisse = await testPrisma.schrittergebnis.findMany({ where: { laufId: lauf.id } });
|
||
for (const ergebnis of ergebnisse) {
|
||
await schrittergebnisSetzen(
|
||
{
|
||
laufId: lauf.id,
|
||
vorlagenSchrittId: ergebnis.vorlagenSchrittId,
|
||
zustand: SchrittZustand.ERLEDIGT,
|
||
},
|
||
admin,
|
||
);
|
||
}
|
||
|
||
const abgeschlossen = await laufAbschliessen(lauf.id, admin);
|
||
expect(abgeschlossen.status).toBe(LaufStatus.ABGESCHLOSSEN);
|
||
});
|
||
|
||
it("zeigt in der Druckansicht genau dieselben Abschnitte", async () => {
|
||
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
|
||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
|
||
|
||
const abarbeitung = await laufZumAbarbeiten(lauf.id, admin);
|
||
const druck = await laufZumDrucken(lauf.id, admin);
|
||
expect(druck?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual(
|
||
abarbeitung?.abschnitte.map((abschnitt) => abschnitt.titel),
|
||
);
|
||
expect(druck?.abschnitte.map((abschnitt) => abschnitt.titel)).not.toContain("Buchhaltung");
|
||
});
|
||
|
||
it("weist einen Lauf ab, für den kein Abschnitt übrig bleibt", async () => {
|
||
const { admin, versionId } = await aufbau([
|
||
{
|
||
titel: "Nur Buchhaltung",
|
||
nurFuerAbteilungen: ["Buchhaltung"],
|
||
schritte: [{ titel: "DATEV" }],
|
||
},
|
||
]);
|
||
await expect(laufAnlegen({ vorlagenVersionId: versionId }, admin)).rejects.toThrow(LaufFehler);
|
||
await expect(laufAnlegen({ vorlagenVersionId: versionId }, admin)).rejects.toThrow(/kein Abschnitt/);
|
||
});
|
||
|
||
it("lässt einen Lauf ohne Einschränkungen unverändert – geltendeAbschnitte zählt alle", async () => {
|
||
// Kein Sonderfall im Leseweg: die Liste enthält auch dann alle Abschnitte.
|
||
const { admin, versionId } = await aufbau([
|
||
{ titel: "Eins", schritte: [{ titel: "a" }] },
|
||
{ titel: "Zwei", schritte: [{ titel: "b" }] },
|
||
]);
|
||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
|
||
const zeile = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
|
||
expect(zeile.geltendeAbschnitte).toHaveLength(2);
|
||
expect((await laufZumAbarbeiten(lauf.id, admin))?.abschnitte).toHaveLength(2);
|
||
});
|
||
|
||
it("zeigt bei einem Lauf von vor dieser Fassung alle Abschnitte", async () => {
|
||
// `geltendeAbschnitte` leer heißt alle – so verhalten sich bestehende Läufe
|
||
// unverändert, ohne dass eine Migration Daten nachführen müsste.
|
||
const { admin, versionId } = await aufbau(VORLAGE);
|
||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
|
||
await testPrisma.lauf.update({ where: { id: lauf.id }, data: { geltendeAbschnitte: [] } });
|
||
|
||
const daten = await laufZumAbarbeiten(lauf.id, admin);
|
||
expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual([
|
||
"Vorbereitung",
|
||
"Buchhaltung",
|
||
"Abschluss",
|
||
]);
|
||
});
|
||
});
|