Files
Laufzettel/tests/abteilungsabschnitte.test.ts
T
florian.wilde 855164066c
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen (#58)
2026-10-04 18:04:51 +00:00

266 lines
11 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 { 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("ä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",
]);
});
});