feat(archiv): Uebersicht, Volltextsuche und Druckansicht
Der Einstieg am Schreibtisch und das Gedaechtnis der Anwendung. Die Uebersicht
sortiert nach Dringlichkeit — ueberfaellig zuerst, und das ist das Einzige in
Warnfarbe; alles andere auf der Seite ist Auskunft.
Das Archiv sucht ueber Titel, Notizen, erfasste Werte und Kommentare in einer
SQL-Anweisung: `Schrittergebnis.wert` ist ein Json, und nur `("wert")::text`
trifft den Trigramm-Index aus #006. Vor allem aber gehoert die Fundstelle zum
Treffer — ein Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.
Die Druckansicht ist ein eigenes Lesemodell und kein Schalter an
`laufZumAbarbeiten`: dort entsteht beim Fuellen der Befehle ein Passwort, und auf
einem Blatt, das jemand abheftet, darf keines stehen. Vom Mitarbeiter holt sie
nur Name, UPN und Abteilung — nicht alles und dann gefiltert.
Der unabhaengige Review fand zwei Blocker, beide im Dashboard: „ueberfaellig“
verglich mit dem Augenblick statt mit dem Tagesbeginn, und der Schnellstart
zaehlte Laeufe fremder Kunden mit. Was „ueberfaellig“ heisst, steht seitdem in
`src/lib/fristen.ts` — eine Regel fuer Lesemodell und Anzeige.
Closes #15
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #29.
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user