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>
This commit was merged in pull request #29.
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user