feat(archiv): Uebersicht, Volltextsuche und Druckansicht
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

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:
2026-09-25 20:59:38 +00:00
co-authored by Claude Opus 5
parent e7a2e2d5c7
commit ab5777a57e
32 changed files with 4202 additions and 38 deletions
+692
View File
@@ -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");
});
});
+350
View File
@@ -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");
});
});
});
+129
View File
@@ -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();
});
+162
View File
@@ -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();
});
+45
View File
@@ -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);
});
});
+301
View File
@@ -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");
});
});
});