Files
Laufzettel/tests/druckansicht.test.ts
florian.wildeandClaude Opus 5 ab5777a57e
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(archiv): Uebersicht, Volltextsuche und Druckansicht
Der Einstieg am Schreibtisch und das Gedaechtnis der Anwendung. Die Uebersicht
sortiert nach Dringlichkeit — ueberfaellig zuerst, und das ist das Einzige in
Warnfarbe; alles andere auf der Seite ist Auskunft.

Das Archiv sucht ueber Titel, Notizen, erfasste Werte und Kommentare in einer
SQL-Anweisung: `Schrittergebnis.wert` ist ein Json, und nur `("wert")::text`
trifft den Trigramm-Index aus #006. Vor allem aber gehoert die Fundstelle zum
Treffer — ein Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.

Die Druckansicht ist ein eigenes Lesemodell und kein Schalter an
`laufZumAbarbeiten`: dort entsteht beim Fuellen der Befehle ein Passwort, und auf
einem Blatt, das jemand abheftet, darf keines stehen. Vom Mitarbeiter holt sie
nur Name, UPN und Abteilung — nicht alles und dann gefiltert.

Der unabhaengige Review fand zwei Blocker, beide im Dashboard: „ueberfaellig“
verglich mit dem Augenblick statt mit dem Tagesbeginn, und der Schnellstart
zaehlte Laeufe fremder Kunden mit. Was „ueberfaellig“ heisst, steht seitdem in
`src/lib/fristen.ts` — eine Regel fuer Lesemodell und Anzeige.

Closes #15

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

351 lines
14 KiB
TypeScript
Raw Permalink 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 { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, Rolle, SchrittTyp, SchrittZustand, VersionStatus } from "@/generated/prisma/enums";
import { LaufBeleg } from "@/components/druck/lauf-beleg";
import { PASSWORT_IM_DRUCK } from "@/lib/passwort";
import { laufZumDrucken, type LaufZumDrucken } from "@/server/queries/druck";
import { kommentarHinzufuegen } from "@/server/services/kommentare";
import { laufAnlegen, laufZurBearbeitungOeffnen } from "@/server/services/laeufe";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Die Druckansicht eines Laufs (#015).
*
* Geprüft wird am **gerenderten Markup**, nicht am Lesemodell allein: die drei
* Zusagen dieser Seite sind Aussagen darüber, was auf dem Papier steht, und
* genau dort müssen sie gelten.
*
* 1. `{{generiertes_passwort}}` erscheint nie — es wird nicht gespeichert und
* für den Ausdruck auch nicht neu erzeugt.
* 2. Vom Mitarbeiter stehen nur Name, UPN und Abteilung darauf.
* 3. Ein Lauf eines fremden Kunden ist nicht druckbar.
*/
const UMGEBUNG = { ...process.env };
/**
* Sentinel-Werte: sie tauchen in keinem Vorlagentext auf und sind auch kein Teil
* des Anzeigenamens. Steht einer davon im Markup, hat das Lesemodell ein Feld
* mitgebracht, das auf keinen Ausdruck gehört.
*/
const GEHEIM = {
vorname: "VornameNichtDrucken",
nachname: "NachnameNichtDrucken",
mail: "nichtdrucken@firma.example",
sam: "samNichtDrucken",
dn: "CN=DnNichtDrucken,OU=Benutzer,DC=firma,DC=local",
titel: "TitelNichtDrucken",
} as const;
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 technikerZeile = await testPrisma.benutzer.create({
data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER },
});
return { eigene, techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER } };
}
/**
* Eine veröffentlichte Version mit vier Schritten: ein Haken, ein Textschritt,
* ein Befehl mit `{{generiertes_passwort}}` und ein Schritt, dessen **Titel** das
* Passwort enthält.
*
* Der Titel ist kein Wunschfall, sondern der zweite Weg ins Markup: würde nur der
* Befehl mit `{ art: "druck" }` aufgelöst, stünde das Passwort trotzdem auf dem
* Blatt.
*/
async function vorlageMitVersion() {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding", kundeId: null } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 3, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Konto im AD", position: 0 },
});
const haken = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Konto für {{mitarbeiter}} angelegt",
pflicht: true,
position: 0,
},
});
const textSchritt = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Auffälligkeiten notiert",
typ: SchrittTyp.TEXT,
pflicht: false,
position: 1,
},
});
const befehl = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Konto anlegen",
typ: SchrittTyp.BEFEHL,
pflicht: false,
position: 2,
konfig: {
shell: "powershell",
befehl: 'New-ADUser -Name "{{mitarbeiter}}" -AccountPassword "{{generiertes_passwort}}"',
},
},
});
const passwortImTitel = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Passwort {{generiertes_passwort}} übergeben",
pflicht: false,
position: 3,
},
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return { version, schritte: { haken, textSchritt, befehl, passwortImTitel } };
}
async function mitarbeiterAnlegen(kundeId: string) {
return testPrisma.mitarbeiter.create({
data: {
kundeId,
anzeigename: "Maria Huber",
upn: "maria.huber@firma.example",
abteilung: "Buchhaltung",
vorname: GEHEIM.vorname,
nachname: GEHEIM.nachname,
mail: GEHEIM.mail,
sam: GEHEIM.sam,
dn: GEHEIM.dn,
titel: GEHEIM.titel,
},
});
}
function markup(daten: LaufZumDrucken): string {
// Ohne JSX, damit der Test eine .ts-Datei bleiben kann: `createElement` baut
// dieselbe Komponente, die die Seite rendert.
return renderToStaticMarkup(createElement(LaufBeleg, { daten }));
}
describe.runIf(datenbankVerfuegbar)("Druckansicht eines Laufs", () => {
beforeEach(async () => {
await datenbankLeeren();
// Ohne Kundenbindung greift die Einschraenkung nie – die Mandantentests
// setzen sie selbst.
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
afterEach(() => {
process.env = { ...UMGEBUNG };
});
describe("Das generierte Passwort", () => {
it("erscheint weder im Befehl noch im Titel", async () => {
const { eigene, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await mitarbeiterAnlegen(eigene.id);
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, techniker);
const daten = await laufZumDrucken(lauf.id, techniker);
expect(daten).toBeDefined();
const html = markup(daten as LaufZumDrucken);
// An beiden Stellen steht der Hinweis statt eines Passworts.
expect(html).toContain("[Passwort – nicht gespeichert]");
expect(PASSWORT_IM_DRUCK).toBe("[Passwort – nicht gespeichert]");
expect(html.match(/\[Passwort – nicht gespeichert\]/gu)).toHaveLength(2);
// Und der Platzhalter selbst bleibt nicht stehen.
expect(html).not.toContain("generiertes_passwort");
});
it("macht zwei Ausdrucke desselben Laufs gleich", async () => {
const { eigene, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await mitarbeiterAnlegen(eigene.id);
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, techniker);
/*
* Der eigentliche Nachweis: ein erzeugtes Passwort ist bei jedem Aufruf ein
* anderes. Sind zwei Ausdrucke Zeichen für Zeichen gleich, steckt in
* keinem von beiden eines.
*/
const ersteFassung = markup((await laufZumDrucken(lauf.id, techniker)) as LaufZumDrucken);
const zweiteFassung = markup((await laufZumDrucken(lauf.id, techniker)) as LaufZumDrucken);
expect(ersteFassung).toBe(zweiteFassung);
});
});
describe("Der Mitarbeiter", () => {
it("steht mit Name, UPN und Abteilung darauf – und mit nichts sonst", async () => {
const { eigene, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await mitarbeiterAnlegen(eigene.id);
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, techniker);
const html = markup((await laufZumDrucken(lauf.id, techniker)) as LaufZumDrucken);
expect(html).toContain("Maria Huber");
expect(html).toContain("maria.huber@firma.example");
expect(html).toContain("Buchhaltung");
for (const [feld, wert] of Object.entries(GEHEIM)) {
expect(html, `${feld} gehört auf kein Blatt, das herumliegt`).not.toContain(wert);
}
});
it("holt auch im Lesemodell nur diese drei Felder", async () => {
const { eigene, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await mitarbeiterAnlegen(eigene.id);
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, techniker);
const daten = await laufZumDrucken(lauf.id, techniker);
// Nicht „alles holen und beim Anzeigen weglassen“: was nicht im Lesemodell
// steht, kann auch keine künftige Komponente versehentlich zeigen.
expect(Object.keys(daten?.kopf.person ?? {}).sort()).toEqual([
"abteilung",
"name",
"nurFreitext",
"upn",
]);
});
it("sagt bei einem Freitext-Namen, dass kein Konto dahintersteht", async () => {
const { techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen(
{ vorlagenVersionId: version.id, mitarbeiterName: "Noch Nicht Im Ad" },
techniker,
);
const daten = await laufZumDrucken(lauf.id, techniker);
expect(daten?.kopf.person).toEqual({
name: "Noch Nicht Im Ad",
upn: null,
abteilung: null,
nurFreitext: true,
});
expect(markup(daten as LaufZumDrucken)).toContain("kein Konto im Verzeichnis");
});
});
describe("Der Beleg selbst", () => {
it("trägt Zustand, Wert, Notiz, Bearbeiter und Kommentare", async () => {
const { eigene, techniker } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const person = await mitarbeiterAnlegen(eigene.id);
const lauf = await laufAnlegen(
{ vorlagenVersionId: version.id, mitarbeiterId: person.id, ticket: "INC-4711" },
techniker,
);
await laufZurBearbeitungOeffnen(lauf.id, techniker);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.haken.id, zustand: SchrittZustand.ERLEDIGT },
techniker,
);
const ergebnis = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.textSchritt.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
wert: "Lüfter läuft laut",
notiz: "Gerät geht ohnehin in die Reparatur",
},
techniker,
);
await kommentarHinzufuegen(
{ laufId: lauf.id, schrittergebnisId: ergebnis.id, rumpf: "Rückfrage beim Hersteller läuft" },
techniker,
);
await kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Übergabe am Montag" }, techniker);
const daten = await laufZumDrucken(lauf.id, techniker);
const html = markup(daten as LaufZumDrucken);
expect(html).toContain("Onboarding");
expect(html).toContain("Version 3");
expect(html).toContain("INC-4711");
expect(html).toContain("Konto im AD");
// Der Titel des Hakenschritts mit aufgelöstem Platzhalter.
expect(html).toContain("Konto für Maria Huber angelegt");
expect(html).toContain("erledigt");
expect(html).toContain("nicht zutreffend");
expect(html).toContain("Lüfter läuft laut");
expect(html).toContain("Gerät geht ohnehin in die Reparatur");
expect(html).toContain("Tina Technik");
expect(html).toContain("Rückfrage beim Hersteller läuft");
expect(html).toContain("Übergabe am Montag");
// Zwei vom Tisch, zwei offen – die Zählung auf dem Blatt.
expect(html).toContain("2 von 4 erledigt oder nicht zutreffend");
expect(daten?.kommentare).toHaveLength(1);
expect(daten?.abschnitte[0]?.schritte[1]?.kommentare).toHaveLength(1);
});
it("zeigt den Kunden nur im Modus Dienstleistung", async () => {
const intern = await grundbestand(Betriebsmodus.INTERN);
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, intern.techniker);
const daten = await laufZumDrucken(lauf.id, intern.techniker);
// Im Modus INTERN gibt es keine Kundenebene – auch nicht auf Papier (#008).
expect(daten?.kopf.kundeName).toBeNull();
expect(markup(daten as LaufZumDrucken)).not.toContain("Eigene Organisation");
});
});
describe("Die Mandantengrenze", () => {
it("macht einen Lauf eines fremden Kunden nicht druckbar", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
// Nur mit gepflegtem Präfix greift die Kundenbindung (siehe sichtbareKundenIds).
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KUA" } });
const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KUB" } });
const { version } = await vorlageMitVersion();
const zeile = await testPrisma.benutzer.create({
data: { email: "a@dienst.example", name: "Techniker A", rolle: Rolle.TECHNIKER },
});
const nurKundeA = { id: zeile.id, rolle: Rolle.TECHNIKER };
await testPrisma.benutzerKunde.create({ data: { benutzerId: zeile.id, kundeId: kundeA.id } });
const adminZeile = await testPrisma.benutzer.create({
data: { email: "admin@dienst.example", name: "Alex Admin", rolle: Rolle.ADMIN },
});
const admin = { id: adminZeile.id, rolle: Rolle.ADMIN };
const eigener = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id }, admin);
const fremder = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeB.id }, admin);
expect(await laufZumDrucken(eigener.id, nurKundeA)).toBeDefined();
// „Gibt es nicht“ und „darfst du nicht“ sind dieselbe Antwort.
expect(await laufZumDrucken(fremder.id, nurKundeA)).toBeUndefined();
// Für einen ungebundenen Admin bleiben beide erreichbar.
expect(await laufZumDrucken(fremder.id, admin)).toBeDefined();
});
it("nennt im Modus Dienstleistung den Kunden auf dem Blatt", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const kunde = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KUA" } });
const { version } = await vorlageMitVersion();
const adminZeile = await testPrisma.benutzer.create({
data: { email: "admin@dienst.example", name: "Alex Admin", rolle: Rolle.ADMIN },
});
const admin = { id: adminZeile.id, rolle: Rolle.ADMIN };
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kunde.id }, admin);
const daten = await laufZumDrucken(lauf.id, admin);
expect(daten?.kopf.kundeName).toBe("Kunde A");
expect(markup(daten as LaufZumDrucken)).toContain("Kunde A");
});
});
});