feat(archiv): Uebersicht, Volltextsuche und Druckansicht
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) 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 is contained in:
Florian Wilde
2026-09-25 22:59:02 +02:00
co-authored by Claude Opus 5
parent e7a2e2d5c7
commit 8146e33f48
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");
});
});