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

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

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

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

Closes #15

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

693 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { 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");
});
});