Files
Laufzettel/tests/uebersicht.test.ts
T
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

302 lines
12 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 { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, LaufStatus } from "@/generated/prisma/enums";
import { haeufigeVorlagen, uebersicht } from "@/server/queries/uebersicht";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Die Uebersicht (#015).
*
* Geprueft wird vor allem eines: **was „ueberfaellig“ heisst.** Der Auftrag sagt
* es genau — `faelligAm` verstrichen **und** Status `OFFEN` oder `IN_ARBEIT`.
* Ein abgeschlossener Lauf mit verstrichenem Datum ist fertig, kein Rueckstand;
* ein archivierter erst recht nicht. Waere das falsch, riefe die Seite nach
* Arbeit, die niemand mehr tun kann — und der Ruf waere bald nichts mehr wert.
*/
const GESTERN = new Date("2026-09-24T09:00:00Z");
const HEUTE = new Date("2026-09-25T09:00:00Z");
const MORGEN = new Date("2026-09-26T09:00:00Z");
async function aufbau() {
const eigene = await testPrisma.kunde.create({
data: { name: "Muster", kuerzel: "MUS", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const benutzer = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Ali Demir", rolle: "TECHNIKER" },
});
const andere = await testPrisma.benutzer.create({
data: { email: "k@firma.example", name: "Katrin Winter", rolle: "TECHNIKER" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC-Neuinstallation" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung" },
});
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Ein Schritt", pflicht: true },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
async function lauf(daten: {
titel: string;
status?: LaufStatus;
faelligAm?: Date | null;
zugewiesenAnId?: string | null;
}) {
const zeile = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
kundeId: eigene.id,
titel: daten.titel,
status: daten.status ?? LaufStatus.OFFEN,
faelligAm: daten.faelligAm ?? null,
zugewiesenAnId: daten.zugewiesenAnId ?? null,
/*
* Die Zeitstempel gehoeren zum Status, und zwar nicht nur der Form
* halber: drei CHECK-Bedingungen in der Datenbank verlangen sie
* (`Lauf_begonnen_mit_zeitpunkt` und die beiden daneben, #006). Ein Test
* darf an ihnen nicht vorbeischreiben — sonst pruefte er eine Lage, die
* es in der Anwendung nie gibt.
*/
...(daten.status && daten.status !== LaufStatus.OFFEN ? { gestartetAm: GESTERN } : {}),
...(daten.status === LaufStatus.ABGESCHLOSSEN || daten.status === LaufStatus.ARCHIVIERT
? { abgeschlossenAm: GESTERN }
: {}),
...(daten.status === LaufStatus.ARCHIVIERT ? { archiviertAm: GESTERN } : {}),
},
});
await testPrisma.schrittergebnis.create({
data: { laufId: zeile.id, vorlagenSchrittId: schritt.id },
});
return zeile;
}
return { eigene, vorlage, version, benutzer: { id: benutzer.id, rolle: benutzer.rolle }, andere, lauf };
}
describe.runIf(datenbankVerfuegbar)("Übersicht", () => {
beforeEach(datenbankLeeren);
describe("überfällig", () => {
it("sind genau die verstrichenen, die noch laufen", async () => {
const { benutzer, lauf } = await aufbau();
await lauf({ titel: "Gestern fällig, offen", faelligAm: GESTERN });
await lauf({ titel: "Gestern fällig, in Arbeit", faelligAm: GESTERN, status: LaufStatus.IN_ARBEIT });
await lauf({ titel: "Morgen fällig", faelligAm: MORGEN });
await lauf({ titel: "Ohne Frist" });
await lauf({
titel: "Gestern fällig, aber fertig",
faelligAm: GESTERN,
status: LaufStatus.ABGESCHLOSSEN,
});
await lauf({
titel: "Gestern fällig, aber archiviert",
faelligAm: GESTERN,
status: LaufStatus.ARCHIVIERT,
});
const daten = await uebersicht(benutzer, HEUTE);
expect(daten.ueberfaellig.map((zeile) => zeile.titel).sort()).toEqual([
"Gestern fällig, in Arbeit",
"Gestern fällig, offen",
]);
});
it("zählt einen Lauf, der heute fällig ist, nicht dazu", async () => {
/*
* Der Blocker aus dem Review: verglichen wurde mit dem Augenblick statt
* mit dem Tagesbeginn. Ein Datumsfeld im Browser speichert Mitternacht
* UTC — ein heute faelliger Lauf stand damit hierzulande ab zwei Uhr
* morgens in Warnfarbe, an genau dem Tag, an dem er erledigt werden soll.
*/
const { benutzer, lauf } = await aufbau();
await lauf({ titel: "Heute fällig", faelligAm: new Date("2026-09-25T00:00:00Z") });
await lauf({ titel: "Gestern fällig", faelligAm: GESTERN });
const daten = await uebersicht(benutzer, HEUTE);
expect(daten.ueberfaellig.map((zeile) => zeile.titel)).toEqual(["Gestern fällig"]);
// Auch spät am Abend gilt der heutige Tag noch nicht als versäumt.
const spaet = await uebersicht(benutzer, new Date("2026-09-25T23:45:00+02:00"));
expect(spaet.ueberfaellig.map((zeile) => zeile.titel)).toEqual(["Gestern fällig"]);
});
it("zeigt das längst Fällige zuerst", async () => {
const { benutzer, lauf } = await aufbau();
await lauf({ titel: "Seit gestern", faelligAm: GESTERN });
await lauf({ titel: "Seit letzter Woche", faelligAm: new Date("2026-09-18T09:00:00Z") });
const daten = await uebersicht(benutzer, HEUTE);
expect(daten.ueberfaellig[0]?.titel).toBe("Seit letzter Woche");
});
});
describe("mir zugewiesen", () => {
it("zeigt nur die eigenen laufenden", async () => {
const { benutzer, andere, lauf } = await aufbau();
await lauf({ titel: "Meiner", zugewiesenAnId: benutzer.id });
await lauf({ titel: "Meiner, fertig", zugewiesenAnId: benutzer.id, status: LaufStatus.ABGESCHLOSSEN });
await lauf({ titel: "Von Katrin", zugewiesenAnId: andere.id });
await lauf({ titel: "Niemandem" });
const daten = await uebersicht(benutzer, HEUTE);
expect(daten.meine.map((zeile) => zeile.titel)).toEqual(["Meiner"]);
});
});
describe("zuletzt bearbeitet", () => {
it("zeigt auch die Läufe der Kollegen, aber keine archivierten", async () => {
/*
* Bewusst nicht auf die eigene Person eingeschraenkt: ein Team teilt sich
* die Arbeit, und was der Kollege gestern angefasst hat, ist heute oft
* genau die Frage.
*/
const { benutzer, andere, lauf } = await aufbau();
await lauf({ titel: "Von Katrin", zugewiesenAnId: andere.id });
await lauf({ titel: "Archiviert", status: LaufStatus.ARCHIVIERT });
const titel = (await uebersicht(benutzer, HEUTE)).zuletzt.map((zeile) => zeile.titel);
expect(titel).toContain("Von Katrin");
expect(titel).not.toContain("Archiviert");
});
});
describe("Mandantensicht", () => {
it("zeigt keinen Lauf eines fremden Kunden", async () => {
const { benutzer, version } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await testPrisma.benutzerKunde.create({
data: { benutzerId: benutzer.id, kundeId: meiner.id },
});
/*
* Der sichtbare Lauf gehoert **dem gebundenen Kunden**, nicht der eigenen
* Organisation: im Modus Dienstleistung sieht die Technikerin genau die
* Kunden, an die sie gebunden ist. Ein Lauf der eigenen Organisation waere
* hier zu Recht auch weg — beim ersten Anlauf hat dieser Test genau das
* uebersehen und damit nur sich selbst geprueft.
*/
await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
kundeId: meiner.id,
titel: "Eigener",
faelligAm: GESTERN,
},
});
const fremd = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
kundeId: fremder.id,
titel: "Fremder Lauf",
faelligAm: GESTERN,
zugewiesenAnId: benutzer.id,
},
});
expect(fremd.id).toBeTruthy();
const daten = await uebersicht(benutzer, HEUTE);
const alle = [...daten.ueberfaellig, ...daten.meine, ...daten.zuletzt].map((zeile) => zeile.titel);
expect(alle).not.toContain("Fremder Lauf");
// Und der eigene fehlt auch nicht – sonst filterte der Test nur sich selbst.
expect(daten.zuletzt.map((zeile) => zeile.titel)).toContain("Eigener");
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
});
describe("Schnellstart", () => {
it("zählt über alle Versionen einer Vorlage", async () => {
/*
* „PC-Neuinstallation“ ist dieselbe Arbeit, ob sie in v1 oder v7 steht.
* Eine Rangfolge je Version finge nach jedem Veroeffentlichen bei null an.
*/
const { benutzer, vorlage, version, lauf } = await aufbau();
await lauf({ titel: "Erster" });
await lauf({ titel: "Zweiter" });
const zweite = await testPrisma.vorlagenVersion.create({
data: {
vorlageId: vorlage.id,
version: 2,
status: "VEROEFFENTLICHT",
veroeffentlichtAm: new Date(),
},
});
expect(zweite.version).toBe(2);
const andere = await testPrisma.vorlage.create({ data: { name: "Offboarding" } });
const andereVersion = await testPrisma.vorlagenVersion.create({
data: {
vorlageId: andere.id,
version: 1,
status: "VEROEFFENTLICHT",
veroeffentlichtAm: new Date(),
},
});
const haeufig = await haeufigeVorlagen(benutzer);
expect(haeufig[0]?.name).toBe("PC-Neuinstallation");
expect(haeufig[0]?.anzahlLaeufe).toBe(2);
// Gestartet wird die **neueste** Version, gezaehlt werden alle.
expect(haeufig[0]?.versionId).toBe(zweite.id);
expect(haeufig[0]?.versionId).not.toBe(version.id);
expect(haeufig.map((eintrag) => eintrag.versionId)).toContain(andereVersion.id);
});
it("zählt keine Läufe fremder Kunden mit", async () => {
/*
* Der zweite Blocker: der Zaehler trug keine Mandantenbedingung. Eine
* Vorlage ohne Kundenbezug gilt fuer alle (#009) — ihr „12× gestartet“
* war damit eine Auskunft ueber fremde Betriebe, und die Reihenfolge des
* Schnellstarts richtete sich nach deren Arbeit.
*/
const { benutzer, version } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await testPrisma.benutzerKunde.create({ data: { benutzerId: benutzer.id, kundeId: meiner.id } });
for (const [nummer, kundeId] of [meiner.id, fremder.id, fremder.id, fremder.id].entries()) {
await testPrisma.lauf.create({
data: { vorlagenVersionId: version.id, kundeId, titel: `Lauf ${nummer}` },
});
}
const haeufig = await haeufigeVorlagen(benutzer);
expect(haeufig[0]?.anzahlLaeufe).toBe(1);
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
it("lässt eine Vorlage ohne veröffentlichte Version weg", async () => {
// Ein Entwurf ist noch niemandes Arbeitsanweisung (#009).
const { benutzer } = await aufbau();
const entwurf = await testPrisma.vorlage.create({ data: { name: "Nur Entwurf" } });
await testPrisma.vorlagenVersion.create({
data: { vorlageId: entwurf.id, version: 1, status: "ENTWURF" },
});
const namen = (await haeufigeVorlagen(benutzer)).map((eintrag) => eintrag.name);
expect(namen).not.toContain("Nur Entwurf");
});
});
});