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(/ { const html = renderToStaticMarkup( createElement(ArchivTreffer, { zeile: { ...zeile, fundstellen: [] }, begriff: "", kundenEbene: true }), ); expect(html).not.toContain("