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 is contained in:
co-authored by
Claude Opus 5
parent
e7a2e2d5c7
commit
8146e33f48
@@ -0,0 +1,692 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
Betriebsmodus,
|
||||
LaufStatus,
|
||||
Rolle,
|
||||
SchrittTyp,
|
||||
SchrittZustand,
|
||||
VersionStatus,
|
||||
} from "@/generated/prisma/enums";
|
||||
import { ArchivTreffer } from "@/components/archiv/archiv-treffer";
|
||||
import {
|
||||
ARCHIV_HOECHSTENS,
|
||||
FUNDSTELLEN_JE_LAUF,
|
||||
archivFilterwerte,
|
||||
archivSuche,
|
||||
datumAusText,
|
||||
type ArchivFilter,
|
||||
type ArchivZeile,
|
||||
} from "@/server/queries/archiv";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Das Archiv: Volltextsuche und Filter (#015).
|
||||
*
|
||||
* Zwei Zusagen hängen an dieser Datei. Die erste ist die Suche selbst: ein Lauf,
|
||||
* dessen Kennzeichen nur in einer Schritt-Notiz oder in einem Kommentar steht,
|
||||
* wird gefunden — sonst ist das Archiv nur ein Stapel.
|
||||
*
|
||||
* Die zweite ist die Mandantengrenze, und sie ist der Grund, warum hier so viele
|
||||
* Fälle stehen. In #009 hat ein zweites `OR` im selben Objekt die
|
||||
* Kundenbedingung überschrieben; ein Buchstabe im Suchfeld genügte, um fremde
|
||||
* Daten zu sehen. Die Suche im Archiv liest drei Tabellen und nimmt sieben
|
||||
* Filter — jeder davon ist eine Gelegenheit, dieselbe Lücke neu aufzureißen.
|
||||
*/
|
||||
|
||||
const WORT_NOTIZ = "Kaltgeraetestecker";
|
||||
const WORT_KOMMENTAR = "Ruecksendeschein";
|
||||
const WORT_WERT = "Zuckerhut";
|
||||
|
||||
async function benutzerAnlegen(name: string, email: string, rolle: Rolle, aktiv = true) {
|
||||
return testPrisma.benutzer.create({ data: { email, name, rolle, aktiv } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Vorlage mit einer veröffentlichten Version 1 und zwei Schritten.
|
||||
*
|
||||
* Der Aufbau entsteht **vor** dem Veröffentlichen: die Trigger aus #009 lassen
|
||||
* an einer veröffentlichten Version nichts mehr einfügen.
|
||||
*/
|
||||
async function vorlageMitVersion(name: string, kundeId: string | null = null, archiviert = false) {
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name, kundeId, archiviert } });
|
||||
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: "Vorbereitung", position: 0 },
|
||||
});
|
||||
const haken = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Datensicherung geprüft", pflicht: true, position: 0 },
|
||||
});
|
||||
const text = await testPrisma.vorlagenSchritt.create({
|
||||
data: {
|
||||
abschnittId: abschnitt.id,
|
||||
titel: "Auffälligkeiten notiert",
|
||||
typ: SchrittTyp.TEXT,
|
||||
position: 1,
|
||||
},
|
||||
});
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||
});
|
||||
return { vorlageId: vorlage.id, versionId: version.id, haken: haken.id, text: text.id };
|
||||
}
|
||||
|
||||
type LaufVorgabe = {
|
||||
readonly titel: string;
|
||||
readonly versionId: string;
|
||||
readonly kundeId: string;
|
||||
readonly status?: LaufStatus;
|
||||
readonly abgelegtAm?: Date;
|
||||
readonly zugewiesenAnId?: string;
|
||||
readonly abgeschlossenVonId?: string;
|
||||
readonly mitarbeiterId?: string;
|
||||
readonly geraetId?: string;
|
||||
readonly abteilungId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ein Lauf direkt in der Datenbank – ohne den Dienst.
|
||||
*
|
||||
* Die Zeitstempel müssen zum Status passen, das erzwingen drei CHECK-Bedingungen
|
||||
* aus #006. Genau deshalb steht hier auch kein `OFFEN` mit `archiviertAm`.
|
||||
*/
|
||||
async function laufAnlegen(vorgabe: LaufVorgabe) {
|
||||
const status = vorgabe.status ?? LaufStatus.ARCHIVIERT;
|
||||
const zeitpunkt = vorgabe.abgelegtAm ?? new Date("2026-03-10T09:00:00");
|
||||
const fertig = status === LaufStatus.ABGESCHLOSSEN || status === LaufStatus.ARCHIVIERT;
|
||||
|
||||
return testPrisma.lauf.create({
|
||||
data: {
|
||||
titel: vorgabe.titel,
|
||||
vorlagenVersionId: vorgabe.versionId,
|
||||
kundeId: vorgabe.kundeId,
|
||||
status,
|
||||
erstelltAm: zeitpunkt,
|
||||
...(status === LaufStatus.OFFEN ? {} : { gestartetAm: zeitpunkt }),
|
||||
...(fertig ? { abgeschlossenAm: zeitpunkt } : {}),
|
||||
...(status === LaufStatus.ARCHIVIERT ? { archiviertAm: zeitpunkt } : {}),
|
||||
...(vorgabe.zugewiesenAnId ? { zugewiesenAnId: vorgabe.zugewiesenAnId } : {}),
|
||||
...(vorgabe.abgeschlossenVonId ? { abgeschlossenVonId: vorgabe.abgeschlossenVonId } : {}),
|
||||
...(vorgabe.mitarbeiterId ? { mitarbeiterId: vorgabe.mitarbeiterId } : {}),
|
||||
...(vorgabe.geraetId ? { geraetId: vorgabe.geraetId } : {}),
|
||||
...(vorgabe.abteilungId ? { abteilungId: vorgabe.abteilungId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Bestand für die Suchtests: ein archiviertes Notebook mit Notiz, Wert und
|
||||
* Kommentar, ein abgeschlossenes Offboarding und ein laufender Lauf.
|
||||
*/
|
||||
async function bestand() {
|
||||
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 tina = await benutzerAnlegen("Tina Technik", "tina@firma.example", Rolle.TECHNIKER);
|
||||
const tom = await benutzerAnlegen("Tom Technik", "tom@firma.example", Rolle.TECHNIKER);
|
||||
const leser = await benutzerAnlegen("Lars Leser", "lars@firma.example", Rolle.LESER);
|
||||
|
||||
const abteilung = await testPrisma.abteilung.create({
|
||||
data: { name: "Buchhaltung", kundeId: eigene.id },
|
||||
});
|
||||
const person = await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
vorname: "Mara",
|
||||
nachname: "Meier",
|
||||
anzeigename: "Mara Meier",
|
||||
upn: "mara@firma.example",
|
||||
kundeId: eigene.id,
|
||||
},
|
||||
});
|
||||
const geraet = await testPrisma.geraet.create({
|
||||
data: { tagCode: "LZ-AAAAAA", hostname: "nb-meier", kundeId: eigene.id, registriertAm: new Date() },
|
||||
});
|
||||
|
||||
const neuinstallation = await vorlageMitVersion("PC-Neuinstallation");
|
||||
const offboarding = await vorlageMitVersion("Offboarding");
|
||||
|
||||
const notebook = await laufAnlegen({
|
||||
titel: "Neuinstallation nb-meier",
|
||||
versionId: neuinstallation.versionId,
|
||||
kundeId: eigene.id,
|
||||
abgelegtAm: new Date("2026-03-10T09:00:00"),
|
||||
zugewiesenAnId: tina.id,
|
||||
mitarbeiterId: person.id,
|
||||
geraetId: geraet.id,
|
||||
abteilungId: abteilung.id,
|
||||
});
|
||||
await testPrisma.schrittergebnis.create({
|
||||
data: {
|
||||
laufId: notebook.id,
|
||||
vorlagenSchrittId: neuinstallation.haken,
|
||||
zustand: SchrittZustand.ERLEDIGT,
|
||||
notiz: `Netzteil fehlte, ${WORT_NOTIZ} nachgelegt.`,
|
||||
aktualisiertVonId: tina.id,
|
||||
},
|
||||
});
|
||||
await testPrisma.schrittergebnis.create({
|
||||
data: {
|
||||
laufId: notebook.id,
|
||||
vorlagenSchrittId: neuinstallation.text,
|
||||
zustand: SchrittZustand.ERLEDIGT,
|
||||
wert: WORT_WERT,
|
||||
aktualisiertVonId: tom.id,
|
||||
},
|
||||
});
|
||||
await testPrisma.kommentar.create({
|
||||
data: {
|
||||
laufId: notebook.id,
|
||||
benutzerId: tina.id,
|
||||
rumpf: `${WORT_KOMMENTAR} liegt beim Hersteller vor.`,
|
||||
},
|
||||
});
|
||||
|
||||
const austritt = await laufAnlegen({
|
||||
titel: "Offboarding Meier",
|
||||
versionId: offboarding.versionId,
|
||||
kundeId: eigene.id,
|
||||
status: LaufStatus.ABGESCHLOSSEN,
|
||||
abgelegtAm: new Date("2026-01-15T14:00:00"),
|
||||
zugewiesenAnId: tom.id,
|
||||
});
|
||||
|
||||
const laufender = await laufAnlegen({
|
||||
titel: "Neuinstallation nb-schmidt",
|
||||
versionId: neuinstallation.versionId,
|
||||
kundeId: eigene.id,
|
||||
status: LaufStatus.IN_ARBEIT,
|
||||
abgelegtAm: new Date("2026-04-01T08:00:00"),
|
||||
});
|
||||
await testPrisma.schrittergebnis.create({
|
||||
data: {
|
||||
laufId: laufender.id,
|
||||
vorlagenSchrittId: neuinstallation.haken,
|
||||
zustand: SchrittZustand.ERLEDIGT,
|
||||
notiz: `Auch hier fehlt ein ${WORT_NOTIZ}.`,
|
||||
aktualisiertVonId: tina.id,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
eigene,
|
||||
tina,
|
||||
tom,
|
||||
leser: { id: leser.id, rolle: Rolle.LESER },
|
||||
admin: { id: "admin-ohne-zeile", rolle: Rolle.ADMIN },
|
||||
technikerin: { id: tina.id, rolle: Rolle.TECHNIKER },
|
||||
neuinstallation,
|
||||
offboarding,
|
||||
notebook,
|
||||
austritt,
|
||||
laufender,
|
||||
person,
|
||||
geraet,
|
||||
abteilung,
|
||||
};
|
||||
}
|
||||
|
||||
async function titel(benutzer: { id: string; rolle: Rolle }, filter: ArchivFilter) {
|
||||
const ergebnis = await archivSuche(benutzer, filter);
|
||||
return ergebnis.zeilen.map((zeile) => zeile.titel);
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Archivsuche", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
});
|
||||
|
||||
describe("Volltext über drei Tabellen", () => {
|
||||
it("findet einen archivierten Lauf über ein Wort aus einer Schritt-Notiz", async () => {
|
||||
const { technikerin } = await bestand();
|
||||
const ergebnis = await archivSuche(technikerin, { suche: WORT_NOTIZ });
|
||||
|
||||
expect(ergebnis.zeilen.map((zeile) => zeile.titel)).toEqual(["Neuinstallation nb-meier"]);
|
||||
const stellen = ergebnis.zeilen[0]?.fundstellen ?? [];
|
||||
expect(stellen.map((stelle) => stelle.feld)).toEqual(["NOTIZ"]);
|
||||
// Der Ausschnitt trägt den Grund des Treffers, nicht nur die Zusage, es
|
||||
// gäbe einen.
|
||||
expect(stellen[0]?.ausschnitt).toContain(WORT_NOTIZ);
|
||||
expect(stellen[0]?.ausschnitt).toContain("Netzteil fehlte");
|
||||
});
|
||||
|
||||
it("findet einen archivierten Lauf über ein Wort aus einem Kommentar", async () => {
|
||||
const { technikerin } = await bestand();
|
||||
const ergebnis = await archivSuche(technikerin, { suche: WORT_KOMMENTAR });
|
||||
|
||||
expect(ergebnis.zeilen.map((zeile) => zeile.titel)).toEqual(["Neuinstallation nb-meier"]);
|
||||
expect(ergebnis.zeilen[0]?.fundstellen.map((stelle) => stelle.feld)).toEqual(["KOMMENTAR"]);
|
||||
expect(ergebnis.zeilen[0]?.fundstellen[0]?.ausschnitt).toContain(WORT_KOMMENTAR);
|
||||
});
|
||||
|
||||
it("findet einen Lauf über den Json-Wert eines Schritts", async () => {
|
||||
const { technikerin } = await bestand();
|
||||
const ergebnis = await archivSuche(technikerin, { suche: WORT_WERT });
|
||||
|
||||
expect(ergebnis.zeilen.map((zeile) => zeile.titel)).toEqual(["Neuinstallation nb-meier"]);
|
||||
expect(ergebnis.zeilen[0]?.fundstellen.map((stelle) => stelle.feld)).toEqual(["WERT"]);
|
||||
});
|
||||
|
||||
it("findet einen Lauf über seinen Titel und sucht ohne Rücksicht auf Groß- und Kleinschreibung", async () => {
|
||||
const { technikerin } = await bestand();
|
||||
const ergebnis = await archivSuche(technikerin, { suche: "NB-MEIER" });
|
||||
|
||||
expect(ergebnis.zeilen.map((zeile) => zeile.titel)).toEqual(["Neuinstallation nb-meier"]);
|
||||
expect(ergebnis.zeilen[0]?.fundstellen.map((stelle) => stelle.feld)).toEqual(["TITEL"]);
|
||||
});
|
||||
|
||||
it("nennt jede Fundstelle mit ihrem Feld, Titel zuerst", async () => {
|
||||
const { technikerin, notebook, tina } = await bestand();
|
||||
await testPrisma.lauf.update({
|
||||
where: { id: notebook.id },
|
||||
data: { titel: `Neuinstallation ${WORT_NOTIZ}` },
|
||||
});
|
||||
await testPrisma.kommentar.create({
|
||||
data: { laufId: notebook.id, benutzerId: tina.id, rumpf: `${WORT_NOTIZ} besorgt.` },
|
||||
});
|
||||
|
||||
const ergebnis = await archivSuche(technikerin, { suche: WORT_NOTIZ });
|
||||
expect(ergebnis.zeilen[0]?.fundstellen.map((stelle) => stelle.feld)).toEqual([
|
||||
"TITEL",
|
||||
"NOTIZ",
|
||||
"KOMMENTAR",
|
||||
]);
|
||||
});
|
||||
|
||||
it("zeigt höchstens vier Fundstellen und zählt die übrigen", async () => {
|
||||
const { technikerin, notebook, tina } = await bestand();
|
||||
for (let nummer = 0; nummer < 5; nummer += 1) {
|
||||
await testPrisma.kommentar.create({
|
||||
data: { laufId: notebook.id, benutzerId: tina.id, rumpf: `${WORT_NOTIZ} Nummer ${nummer}` },
|
||||
});
|
||||
}
|
||||
|
||||
const ergebnis = await archivSuche(technikerin, { suche: WORT_NOTIZ });
|
||||
// Eine Notiz und fünf Kommentare tragen das Wort: sechs Stellen.
|
||||
expect(ergebnis.zeilen[0]?.fundstellen).toHaveLength(FUNDSTELLEN_JE_LAUF);
|
||||
expect(ergebnis.zeilen[0]?.weitereFundstellen).toBe(6 - FUNDSTELLEN_JE_LAUF);
|
||||
});
|
||||
|
||||
it("schneidet einen langen Ausschnitt zu und markiert die Schnittstellen mit Auslassung", async () => {
|
||||
const { technikerin, notebook, tina } = await bestand();
|
||||
await testPrisma.kommentar.create({
|
||||
data: {
|
||||
laufId: notebook.id,
|
||||
benutzerId: tina.id,
|
||||
rumpf: `${"A".repeat(400)} ${WORT_KOMMENTAR} ${"B".repeat(400)}`,
|
||||
},
|
||||
});
|
||||
|
||||
const ergebnis = await archivSuche(technikerin, { suche: WORT_KOMMENTAR });
|
||||
const kommentare = (ergebnis.zeilen[0]?.fundstellen ?? []).filter(
|
||||
(stelle) => stelle.feld === "KOMMENTAR",
|
||||
);
|
||||
const lang = kommentare.find((stelle) => stelle.ausschnitt.startsWith("…"));
|
||||
expect(lang?.ausschnitt).toContain(WORT_KOMMENTAR);
|
||||
expect(lang?.ausschnitt.endsWith("…")).toBe(true);
|
||||
expect(lang?.ausschnitt.length).toBeLessThan(300);
|
||||
});
|
||||
|
||||
it("behandelt ein Prozentzeichen als Zeichen und nicht als Platzhalter", async () => {
|
||||
const { technikerin, notebook, tina } = await bestand();
|
||||
await testPrisma.kommentar.create({
|
||||
data: { laufId: notebook.id, benutzerId: tina.id, rumpf: "Platte zu 50% belegt." },
|
||||
});
|
||||
await testPrisma.kommentar.create({
|
||||
data: { laufId: notebook.id, benutzerId: tina.id, rumpf: "Platte zu 5099 Blöcken belegt." },
|
||||
});
|
||||
|
||||
const ergebnis = await archivSuche(technikerin, { suche: "50%" });
|
||||
const ausschnitte = (ergebnis.zeilen[0]?.fundstellen ?? []).map((stelle) => stelle.ausschnitt);
|
||||
expect(ausschnitte.some((text) => text.includes("50%"))).toBe(true);
|
||||
expect(ausschnitte.some((text) => text.includes("5099"))).toBe(false);
|
||||
});
|
||||
|
||||
it("verwirft einen zu kurzen Begriff, filtert aber weiter", async () => {
|
||||
const { technikerin, offboarding } = await bestand();
|
||||
const ergebnis = await archivSuche(technikerin, { suche: "a", vorlageId: offboarding.vorlageId });
|
||||
|
||||
expect(ergebnis.sucheZuKurz).toBe(true);
|
||||
expect(ergebnis.zeilen.map((zeile) => zeile.titel)).toEqual(["Offboarding Meier"]);
|
||||
});
|
||||
|
||||
it("liefert ohne Begriff und ohne Filter das Archiv, neueste Ablage zuerst", async () => {
|
||||
const { technikerin } = await bestand();
|
||||
const ergebnis = await archivSuche(technikerin, {});
|
||||
|
||||
expect(ergebnis.zeilen.map((zeile) => zeile.titel)).toEqual([
|
||||
"Neuinstallation nb-meier",
|
||||
"Offboarding Meier",
|
||||
]);
|
||||
expect(ergebnis.zeilen.every((zeile) => zeile.fundstellen.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Was im Archiv steht", () => {
|
||||
it("sucht über archivierte und abgeschlossene Läufe, aber nicht über laufende", async () => {
|
||||
const { technikerin } = await bestand();
|
||||
const gefunden = await titel(technikerin, { suche: WORT_NOTIZ });
|
||||
|
||||
expect(gefunden).toEqual(["Neuinstallation nb-meier"]);
|
||||
expect(gefunden).not.toContain("Neuinstallation nb-schmidt");
|
||||
});
|
||||
|
||||
it("nimmt laufende Läufe auf ausdrücklichen Wunsch dazu", async () => {
|
||||
const { technikerin } = await bestand();
|
||||
const gefunden = await titel(technikerin, { suche: WORT_NOTIZ, auchLaufende: true });
|
||||
|
||||
expect(gefunden).toContain("Neuinstallation nb-schmidt");
|
||||
});
|
||||
|
||||
it("sagt, dass die Liste gekappt ist", async () => {
|
||||
const { technikerin, eigene, neuinstallation } = await bestand();
|
||||
for (let nummer = 0; nummer < ARCHIV_HOECHSTENS; nummer += 1) {
|
||||
await laufAnlegen({
|
||||
titel: `Massenlauf ${nummer}`,
|
||||
versionId: neuinstallation.versionId,
|
||||
kundeId: eigene.id,
|
||||
});
|
||||
}
|
||||
|
||||
const ergebnis = await archivSuche(technikerin, {});
|
||||
expect(ergebnis.zeilen).toHaveLength(ARCHIV_HOECHSTENS);
|
||||
expect(ergebnis.gekappt).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filter", () => {
|
||||
it("kombiniert Vorlage, Zeitraum und Bearbeiter", async () => {
|
||||
const { technikerin, neuinstallation, tina } = await bestand();
|
||||
const filter: ArchivFilter = {
|
||||
vorlageId: neuinstallation.vorlageId,
|
||||
von: datumAusText("2026-03-01"),
|
||||
bis: datumAusText("2026-03-31"),
|
||||
bearbeiterId: tina.id,
|
||||
};
|
||||
|
||||
expect(await titel(technikerin, filter)).toEqual(["Neuinstallation nb-meier"]);
|
||||
});
|
||||
|
||||
it("lässt einen Lauf weg, sobald einer der drei Filter nicht passt", async () => {
|
||||
const { technikerin, neuinstallation, offboarding, tina, leser } = await bestand();
|
||||
const passend: ArchivFilter = {
|
||||
vorlageId: neuinstallation.vorlageId,
|
||||
von: datumAusText("2026-03-01"),
|
||||
bis: datumAusText("2026-03-31"),
|
||||
bearbeiterId: tina.id,
|
||||
};
|
||||
|
||||
expect(await titel(technikerin, { ...passend, vorlageId: offboarding.vorlageId })).toEqual([]);
|
||||
expect(await titel(technikerin, { ...passend, von: datumAusText("2026-04-01") })).toEqual([]);
|
||||
expect(await titel(technikerin, { ...passend, bis: datumAusText("2026-03-09") })).toEqual([]);
|
||||
// Lars hat an diesem Lauf nichts getan – weder zugewiesen noch
|
||||
// abgeschlossen noch an einem Schritt.
|
||||
expect(await titel(technikerin, { ...passend, bearbeiterId: leser.id })).toEqual([]);
|
||||
});
|
||||
|
||||
it("nimmt den Bis-Tag vollständig mit", async () => {
|
||||
const { technikerin } = await bestand();
|
||||
// Abgelegt am 10. März um 9 Uhr – ein „bis 10. März“ muss ihn finden.
|
||||
expect(await titel(technikerin, { bis: datumAusText("2026-03-10") })).toContain(
|
||||
"Neuinstallation nb-meier",
|
||||
);
|
||||
expect(await titel(technikerin, { von: datumAusText("2026-03-10") })).toContain(
|
||||
"Neuinstallation nb-meier",
|
||||
);
|
||||
});
|
||||
|
||||
it("zählt als Bearbeiter auch, wer nur an einem Schritt gearbeitet hat", async () => {
|
||||
const { technikerin, tom } = await bestand();
|
||||
// Tom ist dem Notebook nicht zugewiesen, hat aber einen Wert eingetragen.
|
||||
expect(await titel(technikerin, { bearbeiterId: tom.id })).toEqual([
|
||||
"Neuinstallation nb-meier",
|
||||
"Offboarding Meier",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filtert nach Mitarbeiter, Abteilung und Gerät", async () => {
|
||||
const { technikerin, person, abteilung, geraet } = await bestand();
|
||||
|
||||
expect(await titel(technikerin, { mitarbeiterId: person.id })).toEqual(["Neuinstallation nb-meier"]);
|
||||
expect(await titel(technikerin, { abteilungId: abteilung.id })).toEqual(["Neuinstallation nb-meier"]);
|
||||
expect(await titel(technikerin, { geraetId: geraet.id })).toEqual(["Neuinstallation nb-meier"]);
|
||||
});
|
||||
|
||||
it("verwirft im Modus INTERN eine mitgeschickte Kundenkennung", async () => {
|
||||
const { technikerin } = await bestand();
|
||||
// Es gibt keine Kundenwahl in diesem Modus – also auch nichts zu
|
||||
// entscheiden (#008). Ein erfundener Wert darf die Liste nicht leeren.
|
||||
expect(await titel(technikerin, { kundeId: "erfunden" })).toEqual([
|
||||
"Neuinstallation nb-meier",
|
||||
"Offboarding Meier",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filterlisten", () => {
|
||||
it("nimmt archivierte Vorlagen und deaktivierte Konten mit", async () => {
|
||||
const { eigene, technikerin } = await bestand();
|
||||
await vorlageMitVersion("Alte Vorlage", null, true);
|
||||
await benutzerAnlegen("Erik Ehemalig", "erik@firma.example", Rolle.TECHNIKER, false);
|
||||
await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
vorname: "Peter",
|
||||
nachname: "Pensioniert",
|
||||
anzeigename: "Peter Pensioniert",
|
||||
upn: "peter@firma.example",
|
||||
kundeId: eigene.id,
|
||||
aktiv: false,
|
||||
},
|
||||
});
|
||||
|
||||
const werte = await archivFilterwerte(technikerin);
|
||||
expect(werte.vorlagen.map((eintrag) => eintrag.name)).toContain("Alte Vorlage");
|
||||
expect(werte.bearbeiter.map((eintrag) => eintrag.name)).toContain("Erik Ehemalig");
|
||||
expect(werte.mitarbeiter.map((eintrag) => eintrag.name)).toContain("Peter Pensioniert");
|
||||
expect(werte.gekappt).toBe(false);
|
||||
// Im Modus INTERN gibt es keine Kundenebene (#008).
|
||||
expect(werte.kunden).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Die Mandantengrenze – der Teil, der in #009 der Blocker war.
|
||||
*
|
||||
* Geprüft wird nicht „filtert die Liste“, sondern „kommt der fremde Lauf über
|
||||
* **irgendeinen** Weg herein“: über den Titel, über eine Notiz, über einen
|
||||
* Kommentar, über jeden einzelnen Filter und über eine mitgeschickte
|
||||
* Kundenkennung.
|
||||
*/
|
||||
describe.runIf(datenbankVerfuegbar)("Archiv und Mandantengrenze", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
|
||||
});
|
||||
|
||||
async function zweiKunden() {
|
||||
await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({
|
||||
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
|
||||
});
|
||||
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
|
||||
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
|
||||
|
||||
const tina = await benutzerAnlegen("Tina Technik", "tina@firma.example", Rolle.TECHNIKER);
|
||||
await testPrisma.benutzerKunde.create({ data: { benutzerId: tina.id, kundeId: meiner.id } });
|
||||
|
||||
async function laufMit(name: string, kundeId: string, tagCode: string) {
|
||||
const vorlage = await vorlageMitVersion(`Vorlage ${name}`, kundeId);
|
||||
const person = await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
vorname: "Person",
|
||||
nachname: name,
|
||||
anzeigename: `Person ${name}`,
|
||||
upn: `${name}@example.test`,
|
||||
kundeId,
|
||||
},
|
||||
});
|
||||
const abteilung = await testPrisma.abteilung.create({ data: { name: `Abteilung ${name}`, kundeId } });
|
||||
const geraet = await testPrisma.geraet.create({
|
||||
data: { tagCode, hostname: `pc-${name}`, kundeId, registriertAm: new Date() },
|
||||
});
|
||||
const lauf = await laufAnlegen({
|
||||
titel: `Geheimvorgang ${name}`,
|
||||
versionId: vorlage.versionId,
|
||||
kundeId,
|
||||
zugewiesenAnId: tina.id,
|
||||
mitarbeiterId: person.id,
|
||||
geraetId: geraet.id,
|
||||
abteilungId: abteilung.id,
|
||||
});
|
||||
await testPrisma.schrittergebnis.create({
|
||||
data: {
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: vorlage.haken,
|
||||
zustand: SchrittZustand.ERLEDIGT,
|
||||
notiz: `Geheimnotiz ${WORT_NOTIZ}`,
|
||||
aktualisiertVonId: tina.id,
|
||||
},
|
||||
});
|
||||
await testPrisma.schrittergebnis.create({
|
||||
data: {
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: vorlage.text,
|
||||
zustand: SchrittZustand.ERLEDIGT,
|
||||
wert: WORT_WERT,
|
||||
aktualisiertVonId: tina.id,
|
||||
},
|
||||
});
|
||||
await testPrisma.kommentar.create({
|
||||
data: { laufId: lauf.id, benutzerId: tina.id, rumpf: `Geheimkommentar ${WORT_KOMMENTAR}` },
|
||||
});
|
||||
return { vorlage, person, abteilung, geraet, lauf, kundeId };
|
||||
}
|
||||
|
||||
return {
|
||||
meiner,
|
||||
fremder,
|
||||
meins: await laufMit("Meiner", meiner.id, "LZ-AAAAAA"),
|
||||
fremdes: await laufMit("Fremder", fremder.id, "LZ-BBBBBB"),
|
||||
technikerin: { id: tina.id, rolle: Rolle.TECHNIKER },
|
||||
admin: { id: tina.id, rolle: Rolle.ADMIN },
|
||||
};
|
||||
}
|
||||
|
||||
it("hält bei jedem Suchbegriff, der über eine andere Tabelle trifft", async () => {
|
||||
const { technikerin } = await zweiKunden();
|
||||
|
||||
for (const begriff of ["Geheimvorgang", WORT_NOTIZ, WORT_WERT, WORT_KOMMENTAR, "Geheim"]) {
|
||||
const gefunden = await titel(technikerin, { suche: begriff });
|
||||
expect(gefunden, begriff).toEqual(["Geheimvorgang Meiner"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("hält bei jedem einzelnen Filter", async () => {
|
||||
const { technikerin, fremdes, fremder } = await zweiKunden();
|
||||
const filter: ArchivFilter[] = [
|
||||
{ vorlageId: fremdes.vorlage.vorlageId },
|
||||
{ mitarbeiterId: fremdes.person.id },
|
||||
{ abteilungId: fremdes.abteilung.id },
|
||||
{ geraetId: fremdes.geraet.id },
|
||||
{ kundeId: fremder.id },
|
||||
];
|
||||
|
||||
for (const einzeln of filter) {
|
||||
expect(await titel(technikerin, einzeln), JSON.stringify(einzeln)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("hält, wenn Suche und Filter zusammenkommen", async () => {
|
||||
const { technikerin, fremdes, fremder } = await zweiKunden();
|
||||
const gefunden = await titel(technikerin, {
|
||||
suche: "Geheim",
|
||||
vorlageId: fremdes.vorlage.vorlageId,
|
||||
kundeId: fremder.id,
|
||||
bearbeiterId: fremdes.lauf.zugewiesenAnId ?? undefined,
|
||||
auchLaufende: true,
|
||||
});
|
||||
|
||||
expect(gefunden).toEqual([]);
|
||||
});
|
||||
|
||||
it("zeigt einem Administrator beide Läufe", async () => {
|
||||
const { admin } = await zweiKunden();
|
||||
expect((await titel(admin, { suche: "Geheim" })).sort()).toEqual([
|
||||
"Geheimvorgang Fremder",
|
||||
"Geheimvorgang Meiner",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filtert die Auswahllisten mit", async () => {
|
||||
const { technikerin } = await zweiKunden();
|
||||
const werte = await archivFilterwerte(technikerin);
|
||||
|
||||
expect(werte.vorlagen.map((eintrag) => eintrag.name)).toEqual(["Vorlage Meiner"]);
|
||||
expect(werte.mitarbeiter.map((eintrag) => eintrag.name)).toEqual(["Person Meiner"]);
|
||||
expect(werte.abteilungen.map((eintrag) => eintrag.name)).toEqual(["Abteilung Meiner"]);
|
||||
expect(werte.geraete.map((eintrag) => eintrag.name)).toEqual(["pc-Meiner · LZ-AAAAAA"]);
|
||||
expect(werte.kunden.map((eintrag) => eintrag.name)).toEqual(["Meiner"]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Die Trefferzeile im gerenderten HTML.
|
||||
*
|
||||
* Hier steht der einzige eigene Darstellungscode des Archivs: das Hervorheben
|
||||
* des Begriffs im Ausschnitt. Es laeuft ueber `indexOf` statt ueber einen
|
||||
* regulaeren Ausdruck, weil der Begriff aus der Adresszeile kommt — und es soll
|
||||
* jedes Vorkommen treffen, nicht nur das erste.
|
||||
*/
|
||||
describe("Trefferzeile", () => {
|
||||
const zeile: ArchivZeile = {
|
||||
id: "lauf-1",
|
||||
titel: "Neuinstallation nb-meier",
|
||||
status: LaufStatus.ARCHIVIERT,
|
||||
vorlagenName: "PC-Neuinstallation",
|
||||
version: 3,
|
||||
geraetName: "nb-meier",
|
||||
mitarbeiterName: "Mara Meier",
|
||||
abteilungName: "Buchhaltung",
|
||||
ticket: "INC-4711",
|
||||
kundeName: "Eigene Organisation",
|
||||
abgelegtAm: new Date("2026-03-10T09:00:00"),
|
||||
abgeschlossenAm: new Date("2026-03-10T09:00:00"),
|
||||
archiviertAm: new Date("2026-03-10T09:00:00"),
|
||||
abgeschlossenVon: "Tina Technik",
|
||||
zugewiesenAn: "Tina Technik",
|
||||
fundstellen: [{ feld: "NOTIZ", ausschnitt: "Meier gemeldet, Meier bestätigt" }],
|
||||
weitereFundstellen: 2,
|
||||
};
|
||||
|
||||
it("führt in den Lauf und hebt jedes Vorkommen hervor", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(ArchivTreffer, { zeile, begriff: "meier", kundenEbene: false }),
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/laeufe/lauf-1"');
|
||||
// Zwei im Ausschnitt, eines im Titel.
|
||||
expect(html.match(/<mark/g)).toHaveLength(3);
|
||||
expect(html).toContain("Notiz");
|
||||
expect(html).toContain("2 weitere Fundstellen");
|
||||
// Der Kundenname erscheint nur, wo es eine Kundenebene gibt (#008).
|
||||
expect(html).not.toContain("Eigene Organisation");
|
||||
});
|
||||
|
||||
it("markiert ohne Begriff nichts", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(ArchivTreffer, { zeile: { ...zeile, fundstellen: [] }, begriff: "", kundenEbene: true }),
|
||||
);
|
||||
|
||||
expect(html).not.toContain("<mark");
|
||||
expect(html).toContain("Eigene Organisation");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Das Archiv am gebauten Stand (#015).
|
||||
*
|
||||
* Die Suche selbst prueft `tests/archiv.test.ts` gegen die Datenbank, bis in die
|
||||
* Mandantengrenze hinein. Hier geht es um den Weg, den ein Mensch nimmt: Wort
|
||||
* eintippen, Treffer sehen, **erkennen warum**, in den Lauf springen. Ein
|
||||
* Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.
|
||||
*/
|
||||
|
||||
const STEMPEL = Date.now();
|
||||
const NOTIZ = `Restposten ${STEMPEL} aus dem Lager`;
|
||||
|
||||
async function anmelden(seite: Page) {
|
||||
const email = process.env["SEED_ADMIN_EMAIL"];
|
||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
||||
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
|
||||
await seite.goto("/anmelden");
|
||||
await seite.fill('input[name="email"]', email);
|
||||
await seite.fill('input[name="passwort"]', passwort);
|
||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein archivierter Lauf mit einer Notiz, die es sonst nirgends gibt.
|
||||
*
|
||||
* Direkt ueber Prisma und nicht ueber die Dienste: hier soll **das Archiv**
|
||||
* geprueft werden, nicht der Weg dorthin — den prueft `tests/e2e/laeufe.spec.ts`
|
||||
* vom Anlegen bis zum Archivieren. Die Zeitstempel gehoeren trotzdem dazu, drei
|
||||
* CHECK-Bedingungen in der Datenbank verlangen sie.
|
||||
*/
|
||||
async function archivierterLauf(): Promise<{ id: string; titel: string }> {
|
||||
const version = await prisma.vorlagenVersion.findFirstOrThrow({
|
||||
where: { status: "VEROEFFENTLICHT" },
|
||||
orderBy: { veroeffentlichtAm: "desc" },
|
||||
select: { id: true, abschnitte: { select: { schritte: { select: { id: true } } } } },
|
||||
});
|
||||
const kunde = await prisma.kunde.findFirstOrThrow({ select: { id: true } });
|
||||
const schrittId = version.abschnitte.flatMap((abschnitt) => abschnitt.schritte)[0]?.id;
|
||||
if (!schrittId) throw new Error("Die Vorlage hat keinen Schritt.");
|
||||
|
||||
const jetzt = new Date();
|
||||
const titel = `ZZ E2E Archiv ${STEMPEL}`;
|
||||
const lauf = await prisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
kundeId: kunde.id,
|
||||
titel,
|
||||
status: "ARCHIVIERT",
|
||||
gestartetAm: jetzt,
|
||||
abgeschlossenAm: jetzt,
|
||||
archiviertAm: jetzt,
|
||||
},
|
||||
});
|
||||
await prisma.schrittergebnis.create({
|
||||
data: {
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schrittId,
|
||||
zustand: "NICHT_ZUTREFFEND",
|
||||
notiz: NOTIZ,
|
||||
},
|
||||
});
|
||||
return { id: lauf.id, titel };
|
||||
}
|
||||
|
||||
test.describe("Archiv", () => {
|
||||
test("findet einen Lauf über ein Wort aus einer Schritt-Notiz", async ({ page }) => {
|
||||
const lauf = await archivierterLauf();
|
||||
await anmelden(page);
|
||||
|
||||
// Am Handy steckt die Navigation im Menü – der Weg dorthin gehört zum Test.
|
||||
if (test.info().project.name === "handy") {
|
||||
await page.getByLabel("Menü").click();
|
||||
}
|
||||
await page.getByRole("link", { name: "Archiv" }).first().click();
|
||||
await page.waitForURL("**/archiv");
|
||||
await page.getByLabel("Suchbegriff").fill(`Restposten ${STEMPEL}`);
|
||||
await page.getByRole("button", { name: "Suchen" }).click();
|
||||
|
||||
const treffer = page.getByRole("listitem").filter({ hasText: lauf.titel });
|
||||
await expect(treffer).toHaveCount(1);
|
||||
// Und der Grund steht dabei: Fundstelle und Ausschnitt.
|
||||
await expect(treffer.getByText("Notiz")).toBeVisible();
|
||||
await expect(treffer.getByText(/Restposten/)).toBeVisible();
|
||||
|
||||
await treffer.getByRole("link").first().click();
|
||||
await page.waitForURL(`**/laeufe/${lauf.id}`);
|
||||
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("sagt, dass ein einzelnes Zeichen nicht sucht", async ({ page }) => {
|
||||
// Ein stilles Nichtstun wäre die schlechtere Antwort: der Suchende wartet.
|
||||
await anmelden(page);
|
||||
await page.goto("/archiv?suche=a");
|
||||
// Die Meldung **zum Ergebnis**, nicht der Hinweis am Formular: beide nennen
|
||||
// die zwei Zeichen, und nur die erste sagt, dass gerade nichts gesucht wurde.
|
||||
await expect(page.getByText(/ist als Suchbegriff zu kurz/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
||||
await anmelden(page);
|
||||
await page.goto("/archiv?suche=Restposten");
|
||||
const masse = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
sichtbar: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
const laeufe = await prisma.lauf.findMany({
|
||||
where: { titel: { startsWith: "ZZ E2E Archiv" } },
|
||||
select: { id: true },
|
||||
});
|
||||
const kennungen = laeufe.map((lauf) => lauf.id);
|
||||
if (kennungen.length > 0) {
|
||||
await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } });
|
||||
await prisma.protokoll.deleteMany({
|
||||
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
|
||||
});
|
||||
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Uebersicht und Druckansicht am gebauten Stand (#015).
|
||||
*
|
||||
* Beide Seiten leben von ihrer Darstellung: die Uebersicht sortiert nach
|
||||
* Dringlichkeit, die Druckansicht gehoert auf Papier. Was davon stimmt, zeigt
|
||||
* erst der Browser — die Lesemodelle dahinter pruefen `tests/uebersicht.test.ts`
|
||||
* und `tests/druckansicht.test.ts`.
|
||||
*/
|
||||
|
||||
async function anmelden(seite: Page) {
|
||||
const email = process.env["SEED_ADMIN_EMAIL"];
|
||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
||||
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
|
||||
await seite.goto("/anmelden");
|
||||
await seite.fill('input[name="email"]', email);
|
||||
await seite.fill('input[name="passwort"]', passwort);
|
||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Lauf aus einer Vorlage, die `{{generiertes_passwort}}` wirklich enthaelt.
|
||||
*
|
||||
* Direkt ueber Prisma: geprueft werden soll die **Druckansicht**, nicht der Weg
|
||||
* zum Lauf — den prueft `tests/e2e/laeufe.spec.ts` vom Anlegen bis zum Archiv.
|
||||
*/
|
||||
async function laufMitPasswortschritt(): Promise<string> {
|
||||
const stempel = Date.now();
|
||||
const kunde = await prisma.kunde.findFirstOrThrow({ select: { id: true } });
|
||||
const vorlage = await prisma.vorlage.create({
|
||||
data: { name: `ZZ E2E Druck ${stempel}`, kategorie: "ZZ Test" },
|
||||
});
|
||||
const version = await prisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
|
||||
});
|
||||
const abschnitt = await prisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Konto" },
|
||||
});
|
||||
await prisma.vorlagenSchritt.create({
|
||||
data: {
|
||||
abschnittId: abschnitt.id,
|
||||
titel: "Konto angelegt",
|
||||
typ: "BEFEHL",
|
||||
konfig: { shell: "powershell", befehl: 'New-ADUser -Password "{{generiertes_passwort}}"' },
|
||||
},
|
||||
});
|
||||
await prisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
const lauf = await prisma.lauf.create({
|
||||
data: { vorlagenVersionId: version.id, kundeId: kunde.id, titel: `ZZ E2E Druck ${stempel}` },
|
||||
});
|
||||
return lauf.id;
|
||||
}
|
||||
|
||||
test.describe("Übersicht", () => {
|
||||
test("zeigt die vier Blöcke in ihrer Reihenfolge", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
const ueberschriften = await page.getByRole("heading", { level: 2 }).allInnerTexts();
|
||||
expect(ueberschriften).toEqual(["Überfällig", "Mir zugewiesen", "Zuletzt bearbeitet", "Schnellstart"]);
|
||||
});
|
||||
|
||||
test("führt von einer Zeile in den Lauf", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
const zuletzt = page
|
||||
.locator("section")
|
||||
.filter({ has: page.getByRole("heading", { name: "Zuletzt bearbeitet" }) });
|
||||
await zuletzt.getByRole("link").first().click();
|
||||
await page.waitForURL(/\/laeufe\/[^/]+$/);
|
||||
});
|
||||
|
||||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
||||
await anmelden(page);
|
||||
const masse = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
sichtbar: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Druckansicht", () => {
|
||||
test("ist hell, ohne Navigation und passt auf A4", async ({ page }) => {
|
||||
test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät.");
|
||||
await anmelden(page);
|
||||
const lauf = await prisma.lauf.findFirstOrThrow({ select: { id: true } });
|
||||
await page.goto(`/drucken/lauf/${lauf.id}`);
|
||||
|
||||
// Keine Hülle: weder Seitenleiste noch der Scannen-Knopf gehören aufs Blatt.
|
||||
await expect(page.getByRole("navigation", { name: "Hauptnavigation" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Scannen" })).toHaveCount(0);
|
||||
|
||||
// Hell heisst hell: der Grund des Belegs ist keine dunkle Fläche.
|
||||
const grund = await page
|
||||
.locator(".druck-hell")
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
expect(grund).toBe("rgb(255, 255, 255)");
|
||||
|
||||
/*
|
||||
* A4 kommt aus `@page`, und nur ein echtes PDF zeigt, ob die Regel greift:
|
||||
* 210 × 297 mm sind 595 × 842 Punkte.
|
||||
*/
|
||||
const pdf = await page.pdf({ preferCSSPageSize: true });
|
||||
const text = pdf.toString("latin1");
|
||||
const treffer = /\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/u.exec(text);
|
||||
expect(treffer).not.toBeNull();
|
||||
expect(Number(treffer?.[3]) - Number(treffer?.[1])).toBeCloseTo(595, 0);
|
||||
expect(Number(treffer?.[4]) - Number(treffer?.[2])).toBeCloseTo(842, 0);
|
||||
});
|
||||
|
||||
test("ist vom Lauf aus erreichbar und erzeugt kein Passwort", async ({ page }) => {
|
||||
/*
|
||||
* Der Lauf bringt seine eigene Vorlage mit — eine **mit**
|
||||
* `{{generiertes_passwort}}` im Befehl. Vorher lief dieser Test gegen
|
||||
* NB-001, dessen Vorlage den Platzhalter gar nicht enthält: er hätte auch
|
||||
* dann bestanden, wenn die Druckansicht Passwörter erzeugte (Reviewbefund
|
||||
* zu #015).
|
||||
*/
|
||||
const laufId = await laufMitPasswortschritt();
|
||||
await anmelden(page);
|
||||
await page.goto(`/laeufe/${laufId}`);
|
||||
await page.getByRole("link", { name: "Drucken" }).click();
|
||||
await page.waitForURL(/\/drucken\/lauf\//);
|
||||
|
||||
const blatt = await page.locator("body").innerText();
|
||||
expect(blatt).toContain("[Passwort – nicht gespeichert]");
|
||||
expect(blatt).not.toContain("generiertes_passwort");
|
||||
|
||||
/*
|
||||
* Und der zweite Beweis, der ohne Kenntnis des Textes auskommt: ein
|
||||
* erzeugtes Passwort wäre bei jedem Aufbau ein anderes. Sind beide Blätter
|
||||
* Zeichen für Zeichen gleich, kann keines darin stecken.
|
||||
*/
|
||||
await page.reload();
|
||||
const nochmal = await page.locator("body").innerText();
|
||||
expect(nochmal.replace(/Gedruckt am[^\n]*/u, "")).toBe(blatt.replace(/Gedruckt am[^\n]*/u, ""));
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
const laeufe = await prisma.lauf.findMany({
|
||||
where: { titel: { startsWith: "ZZ E2E Druck" } },
|
||||
select: { id: true },
|
||||
});
|
||||
const kennungen = laeufe.map((lauf) => lauf.id);
|
||||
if (kennungen.length > 0) {
|
||||
await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } });
|
||||
await prisma.protokoll.deleteMany({
|
||||
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
|
||||
});
|
||||
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
|
||||
}
|
||||
await prisma.vorlage.deleteMany({ where: { name: { startsWith: "ZZ E2E Druck" } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fristVerstrichen, tagesbeginn } from "@/lib/fristen";
|
||||
|
||||
/**
|
||||
* Die Grenze zwischen „heute faellig“ und „ueberfaellig“ (#015).
|
||||
*
|
||||
* Der Review hat genau hier zugeschlagen: verglichen wurde mit dem Augenblick
|
||||
* statt mit dem Tagesbeginn, und ein heute faelliger Lauf stand ab zwei Uhr
|
||||
* morgens in Warnfarbe. Der alte Test kannte nur „gestern“ und „morgen“ — die
|
||||
* Grenze selbst blieb ungeprueft, und genau dort sass der Fehler.
|
||||
*/
|
||||
describe("Fristen", () => {
|
||||
const HEUTE_FRUEH = new Date(2026, 8, 25, 2, 0, 0);
|
||||
const HEUTE_SPAET = new Date(2026, 8, 25, 23, 30, 0);
|
||||
|
||||
it("zählt den heutigen Tag nicht als verstrichen", () => {
|
||||
const heuteMitternacht = new Date(2026, 8, 25, 0, 0, 0);
|
||||
expect(fristVerstrichen(heuteMitternacht, HEUTE_FRUEH)).toBe(false);
|
||||
expect(fristVerstrichen(heuteMitternacht, HEUTE_SPAET)).toBe(false);
|
||||
});
|
||||
|
||||
it("zählt gestern als verstrichen, egal zu welcher Uhrzeit", () => {
|
||||
const gestern = new Date(2026, 8, 24, 23, 59, 0);
|
||||
expect(fristVerstrichen(gestern, HEUTE_FRUEH)).toBe(true);
|
||||
expect(fristVerstrichen(gestern, HEUTE_SPAET)).toBe(true);
|
||||
});
|
||||
|
||||
it("zählt morgen nicht als verstrichen", () => {
|
||||
expect(fristVerstrichen(new Date(2026, 8, 26, 0, 0, 0), HEUTE_SPAET)).toBe(false);
|
||||
});
|
||||
|
||||
it("nimmt eine fehlende Frist als nicht verstrichen", () => {
|
||||
expect(fristVerstrichen(null, HEUTE_FRUEH)).toBe(false);
|
||||
expect(fristVerstrichen(undefined, HEUTE_FRUEH)).toBe(false);
|
||||
});
|
||||
|
||||
it("gibt den Tagesbeginn in lokaler Zeit zurück", () => {
|
||||
const beginn = tagesbeginn(HEUTE_SPAET);
|
||||
expect(beginn.getFullYear()).toBe(2026);
|
||||
expect(beginn.getMonth()).toBe(8);
|
||||
expect(beginn.getDate()).toBe(25);
|
||||
expect(beginn.getHours()).toBe(0);
|
||||
expect(beginn.getMinutes()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Betriebsmodus, LaufStatus } from "@/generated/prisma/enums";
|
||||
import { haeufigeVorlagen, uebersicht } from "@/server/queries/uebersicht";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Die Uebersicht (#015).
|
||||
*
|
||||
* Geprueft wird vor allem eines: **was „ueberfaellig“ heisst.** Der Auftrag sagt
|
||||
* es genau — `faelligAm` verstrichen **und** Status `OFFEN` oder `IN_ARBEIT`.
|
||||
* Ein abgeschlossener Lauf mit verstrichenem Datum ist fertig, kein Rueckstand;
|
||||
* ein archivierter erst recht nicht. Waere das falsch, riefe die Seite nach
|
||||
* Arbeit, die niemand mehr tun kann — und der Ruf waere bald nichts mehr wert.
|
||||
*/
|
||||
|
||||
const GESTERN = new Date("2026-09-24T09:00:00Z");
|
||||
const HEUTE = new Date("2026-09-25T09:00:00Z");
|
||||
const MORGEN = new Date("2026-09-26T09:00:00Z");
|
||||
|
||||
async function aufbau() {
|
||||
const eigene = await testPrisma.kunde.create({
|
||||
data: { name: "Muster", kuerzel: "MUS", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
|
||||
const benutzer = await testPrisma.benutzer.create({
|
||||
data: { email: "t@firma.example", name: "Ali Demir", rolle: "TECHNIKER" },
|
||||
});
|
||||
const andere = await testPrisma.benutzer.create({
|
||||
data: { email: "k@firma.example", name: "Katrin Winter", rolle: "TECHNIKER" },
|
||||
});
|
||||
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC-Neuinstallation" } });
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
|
||||
});
|
||||
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Vorbereitung" },
|
||||
});
|
||||
const schritt = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Ein Schritt", pflicht: true },
|
||||
});
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
async function lauf(daten: {
|
||||
titel: string;
|
||||
status?: LaufStatus;
|
||||
faelligAm?: Date | null;
|
||||
zugewiesenAnId?: string | null;
|
||||
}) {
|
||||
const zeile = await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
kundeId: eigene.id,
|
||||
titel: daten.titel,
|
||||
status: daten.status ?? LaufStatus.OFFEN,
|
||||
faelligAm: daten.faelligAm ?? null,
|
||||
zugewiesenAnId: daten.zugewiesenAnId ?? null,
|
||||
/*
|
||||
* Die Zeitstempel gehoeren zum Status, und zwar nicht nur der Form
|
||||
* halber: drei CHECK-Bedingungen in der Datenbank verlangen sie
|
||||
* (`Lauf_begonnen_mit_zeitpunkt` und die beiden daneben, #006). Ein Test
|
||||
* darf an ihnen nicht vorbeischreiben — sonst pruefte er eine Lage, die
|
||||
* es in der Anwendung nie gibt.
|
||||
*/
|
||||
...(daten.status && daten.status !== LaufStatus.OFFEN ? { gestartetAm: GESTERN } : {}),
|
||||
...(daten.status === LaufStatus.ABGESCHLOSSEN || daten.status === LaufStatus.ARCHIVIERT
|
||||
? { abgeschlossenAm: GESTERN }
|
||||
: {}),
|
||||
...(daten.status === LaufStatus.ARCHIVIERT ? { archiviertAm: GESTERN } : {}),
|
||||
},
|
||||
});
|
||||
await testPrisma.schrittergebnis.create({
|
||||
data: { laufId: zeile.id, vorlagenSchrittId: schritt.id },
|
||||
});
|
||||
return zeile;
|
||||
}
|
||||
|
||||
return { eigene, vorlage, version, benutzer: { id: benutzer.id, rolle: benutzer.rolle }, andere, lauf };
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Übersicht", () => {
|
||||
beforeEach(datenbankLeeren);
|
||||
|
||||
describe("überfällig", () => {
|
||||
it("sind genau die verstrichenen, die noch laufen", async () => {
|
||||
const { benutzer, lauf } = await aufbau();
|
||||
await lauf({ titel: "Gestern fällig, offen", faelligAm: GESTERN });
|
||||
await lauf({ titel: "Gestern fällig, in Arbeit", faelligAm: GESTERN, status: LaufStatus.IN_ARBEIT });
|
||||
await lauf({ titel: "Morgen fällig", faelligAm: MORGEN });
|
||||
await lauf({ titel: "Ohne Frist" });
|
||||
await lauf({
|
||||
titel: "Gestern fällig, aber fertig",
|
||||
faelligAm: GESTERN,
|
||||
status: LaufStatus.ABGESCHLOSSEN,
|
||||
});
|
||||
await lauf({
|
||||
titel: "Gestern fällig, aber archiviert",
|
||||
faelligAm: GESTERN,
|
||||
status: LaufStatus.ARCHIVIERT,
|
||||
});
|
||||
|
||||
const daten = await uebersicht(benutzer, HEUTE);
|
||||
expect(daten.ueberfaellig.map((zeile) => zeile.titel).sort()).toEqual([
|
||||
"Gestern fällig, in Arbeit",
|
||||
"Gestern fällig, offen",
|
||||
]);
|
||||
});
|
||||
|
||||
it("zählt einen Lauf, der heute fällig ist, nicht dazu", async () => {
|
||||
/*
|
||||
* Der Blocker aus dem Review: verglichen wurde mit dem Augenblick statt
|
||||
* mit dem Tagesbeginn. Ein Datumsfeld im Browser speichert Mitternacht
|
||||
* UTC — ein heute faelliger Lauf stand damit hierzulande ab zwei Uhr
|
||||
* morgens in Warnfarbe, an genau dem Tag, an dem er erledigt werden soll.
|
||||
*/
|
||||
const { benutzer, lauf } = await aufbau();
|
||||
await lauf({ titel: "Heute fällig", faelligAm: new Date("2026-09-25T00:00:00Z") });
|
||||
await lauf({ titel: "Gestern fällig", faelligAm: GESTERN });
|
||||
|
||||
const daten = await uebersicht(benutzer, HEUTE);
|
||||
expect(daten.ueberfaellig.map((zeile) => zeile.titel)).toEqual(["Gestern fällig"]);
|
||||
|
||||
// Auch spät am Abend gilt der heutige Tag noch nicht als versäumt.
|
||||
const spaet = await uebersicht(benutzer, new Date("2026-09-25T23:45:00+02:00"));
|
||||
expect(spaet.ueberfaellig.map((zeile) => zeile.titel)).toEqual(["Gestern fällig"]);
|
||||
});
|
||||
|
||||
it("zeigt das längst Fällige zuerst", async () => {
|
||||
const { benutzer, lauf } = await aufbau();
|
||||
await lauf({ titel: "Seit gestern", faelligAm: GESTERN });
|
||||
await lauf({ titel: "Seit letzter Woche", faelligAm: new Date("2026-09-18T09:00:00Z") });
|
||||
|
||||
const daten = await uebersicht(benutzer, HEUTE);
|
||||
expect(daten.ueberfaellig[0]?.titel).toBe("Seit letzter Woche");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mir zugewiesen", () => {
|
||||
it("zeigt nur die eigenen laufenden", async () => {
|
||||
const { benutzer, andere, lauf } = await aufbau();
|
||||
await lauf({ titel: "Meiner", zugewiesenAnId: benutzer.id });
|
||||
await lauf({ titel: "Meiner, fertig", zugewiesenAnId: benutzer.id, status: LaufStatus.ABGESCHLOSSEN });
|
||||
await lauf({ titel: "Von Katrin", zugewiesenAnId: andere.id });
|
||||
await lauf({ titel: "Niemandem" });
|
||||
|
||||
const daten = await uebersicht(benutzer, HEUTE);
|
||||
expect(daten.meine.map((zeile) => zeile.titel)).toEqual(["Meiner"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zuletzt bearbeitet", () => {
|
||||
it("zeigt auch die Läufe der Kollegen, aber keine archivierten", async () => {
|
||||
/*
|
||||
* Bewusst nicht auf die eigene Person eingeschraenkt: ein Team teilt sich
|
||||
* die Arbeit, und was der Kollege gestern angefasst hat, ist heute oft
|
||||
* genau die Frage.
|
||||
*/
|
||||
const { benutzer, andere, lauf } = await aufbau();
|
||||
await lauf({ titel: "Von Katrin", zugewiesenAnId: andere.id });
|
||||
await lauf({ titel: "Archiviert", status: LaufStatus.ARCHIVIERT });
|
||||
|
||||
const titel = (await uebersicht(benutzer, HEUTE)).zuletzt.map((zeile) => zeile.titel);
|
||||
expect(titel).toContain("Von Katrin");
|
||||
expect(titel).not.toContain("Archiviert");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mandantensicht", () => {
|
||||
it("zeigt keinen Lauf eines fremden Kunden", async () => {
|
||||
const { benutzer, version } = await aufbau();
|
||||
await testPrisma.einstellung.update({
|
||||
where: { id: "global" },
|
||||
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
|
||||
});
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
|
||||
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
|
||||
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
|
||||
await testPrisma.benutzerKunde.create({
|
||||
data: { benutzerId: benutzer.id, kundeId: meiner.id },
|
||||
});
|
||||
|
||||
/*
|
||||
* Der sichtbare Lauf gehoert **dem gebundenen Kunden**, nicht der eigenen
|
||||
* Organisation: im Modus Dienstleistung sieht die Technikerin genau die
|
||||
* Kunden, an die sie gebunden ist. Ein Lauf der eigenen Organisation waere
|
||||
* hier zu Recht auch weg — beim ersten Anlauf hat dieser Test genau das
|
||||
* uebersehen und damit nur sich selbst geprueft.
|
||||
*/
|
||||
await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
kundeId: meiner.id,
|
||||
titel: "Eigener",
|
||||
faelligAm: GESTERN,
|
||||
},
|
||||
});
|
||||
const fremd = await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
kundeId: fremder.id,
|
||||
titel: "Fremder Lauf",
|
||||
faelligAm: GESTERN,
|
||||
zugewiesenAnId: benutzer.id,
|
||||
},
|
||||
});
|
||||
expect(fremd.id).toBeTruthy();
|
||||
|
||||
const daten = await uebersicht(benutzer, HEUTE);
|
||||
const alle = [...daten.ueberfaellig, ...daten.meine, ...daten.zuletzt].map((zeile) => zeile.titel);
|
||||
expect(alle).not.toContain("Fremder Lauf");
|
||||
// Und der eigene fehlt auch nicht – sonst filterte der Test nur sich selbst.
|
||||
expect(daten.zuletzt.map((zeile) => zeile.titel)).toContain("Eigener");
|
||||
|
||||
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schnellstart", () => {
|
||||
it("zählt über alle Versionen einer Vorlage", async () => {
|
||||
/*
|
||||
* „PC-Neuinstallation“ ist dieselbe Arbeit, ob sie in v1 oder v7 steht.
|
||||
* Eine Rangfolge je Version finge nach jedem Veroeffentlichen bei null an.
|
||||
*/
|
||||
const { benutzer, vorlage, version, lauf } = await aufbau();
|
||||
await lauf({ titel: "Erster" });
|
||||
await lauf({ titel: "Zweiter" });
|
||||
|
||||
const zweite = await testPrisma.vorlagenVersion.create({
|
||||
data: {
|
||||
vorlageId: vorlage.id,
|
||||
version: 2,
|
||||
status: "VEROEFFENTLICHT",
|
||||
veroeffentlichtAm: new Date(),
|
||||
},
|
||||
});
|
||||
expect(zweite.version).toBe(2);
|
||||
|
||||
const andere = await testPrisma.vorlage.create({ data: { name: "Offboarding" } });
|
||||
const andereVersion = await testPrisma.vorlagenVersion.create({
|
||||
data: {
|
||||
vorlageId: andere.id,
|
||||
version: 1,
|
||||
status: "VEROEFFENTLICHT",
|
||||
veroeffentlichtAm: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const haeufig = await haeufigeVorlagen(benutzer);
|
||||
expect(haeufig[0]?.name).toBe("PC-Neuinstallation");
|
||||
expect(haeufig[0]?.anzahlLaeufe).toBe(2);
|
||||
// Gestartet wird die **neueste** Version, gezaehlt werden alle.
|
||||
expect(haeufig[0]?.versionId).toBe(zweite.id);
|
||||
expect(haeufig[0]?.versionId).not.toBe(version.id);
|
||||
expect(haeufig.map((eintrag) => eintrag.versionId)).toContain(andereVersion.id);
|
||||
});
|
||||
|
||||
it("zählt keine Läufe fremder Kunden mit", async () => {
|
||||
/*
|
||||
* Der zweite Blocker: der Zaehler trug keine Mandantenbedingung. Eine
|
||||
* Vorlage ohne Kundenbezug gilt fuer alle (#009) — ihr „12× gestartet“
|
||||
* war damit eine Auskunft ueber fremde Betriebe, und die Reihenfolge des
|
||||
* Schnellstarts richtete sich nach deren Arbeit.
|
||||
*/
|
||||
const { benutzer, version } = await aufbau();
|
||||
await testPrisma.einstellung.update({
|
||||
where: { id: "global" },
|
||||
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
|
||||
});
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
|
||||
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
|
||||
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
|
||||
await testPrisma.benutzerKunde.create({ data: { benutzerId: benutzer.id, kundeId: meiner.id } });
|
||||
|
||||
for (const [nummer, kundeId] of [meiner.id, fremder.id, fremder.id, fremder.id].entries()) {
|
||||
await testPrisma.lauf.create({
|
||||
data: { vorlagenVersionId: version.id, kundeId, titel: `Lauf ${nummer}` },
|
||||
});
|
||||
}
|
||||
|
||||
const haeufig = await haeufigeVorlagen(benutzer);
|
||||
expect(haeufig[0]?.anzahlLaeufe).toBe(1);
|
||||
|
||||
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
});
|
||||
|
||||
it("lässt eine Vorlage ohne veröffentlichte Version weg", async () => {
|
||||
// Ein Entwurf ist noch niemandes Arbeitsanweisung (#009).
|
||||
const { benutzer } = await aufbau();
|
||||
const entwurf = await testPrisma.vorlage.create({ data: { name: "Nur Entwurf" } });
|
||||
await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: entwurf.id, version: 1, status: "ENTWURF" },
|
||||
});
|
||||
|
||||
const namen = (await haeufigeVorlagen(benutzer)).map((eintrag) => eintrag.name);
|
||||
expect(namen).not.toContain("Nur Entwurf");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user