feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen und archiviert. Der Archiv-Schreibschutz sitzt in genau einer Funktion und wird vor jeder Aenderung an Kopfdaten, Schrittergebnis und Kommentar gerufen; abgeschlossen bleibt aenderbar, archiviert nicht. Die Abarbeitungsansicht speichert jeden Schritt einzeln, frischt sich alle zehn Sekunden auf und pausiert im Hintergrund. Sie traegt in der Polling-Antwort keine Befehle und laedt sich auch nicht von selbst neu, solange ein generiertes Passwort auf der Seite steht: beides tauschte es dem Techniker zwischen Lesen und Einfuegen aus. Der unabhaengige Review fand zwei Blocker: ein Befehl konnte zwei verschiedene Passwoerter tragen (Konto bekommt A, Benutzer erfaehrt B), und „nicht zutreffend“ liess sich nicht zuruecknehmen, waehrend die Oberflaeche behauptete, nichts sei gewaehlt. Beides behoben und in Tests festgehalten. Closes #13 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
eec5de404a
commit
ed6e6b81eb
@@ -0,0 +1,367 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Ein Lauf von vorn bis hinten durch die Oberflaeche (#013).
|
||||
*
|
||||
* Das ist der Hauptablauf des ganzen Auftrags: Lauf starten, abarbeiten,
|
||||
* abschliessen, archivieren — und danach ist er unveraenderlich. Geprueft wird,
|
||||
* was nur mit angehaengtem React gelingt, und danach, ob es wirklich in der
|
||||
* Datenbank steht: durch Neuladen.
|
||||
*
|
||||
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
|
||||
* `pnpm beispiel:vorlage` hat die Beispielvorlage angelegt.
|
||||
*/
|
||||
|
||||
const STEMPEL = Date.now();
|
||||
const TITEL = `ZZ E2E Lauf ${STEMPEL}`;
|
||||
|
||||
async function anmelden(seite: Page) {
|
||||
const email = process.env["SEED_ADMIN_EMAIL"];
|
||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
||||
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
|
||||
await seite.goto("/anmelden");
|
||||
await seite.fill('input[name="email"]', email);
|
||||
await seite.fill('input[name="passwort"]', passwort);
|
||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
||||
}
|
||||
|
||||
/** Legt einen Lauf an und gibt seine Adresse zurueck. */
|
||||
async function laufStarten(seite: Page, titel: string): Promise<string> {
|
||||
await seite.goto("/laeufe/starten");
|
||||
await seite.selectOption('select[name="vorlagenVersionId"]', { index: 1 });
|
||||
await seite.selectOption('select[name="geraetId"]', { index: 1 });
|
||||
await seite.locator("details summary", { hasText: "Titel, Ticket" }).click();
|
||||
await seite.getByLabel("Titel", { exact: true }).fill(titel);
|
||||
|
||||
// Pflichtvariablen der Vorlage fuellen – welche es sind, weiss nur die Vorlage.
|
||||
for (const feld of await seite.locator("fieldset select").all()) {
|
||||
await feld.selectOption({ index: 1 });
|
||||
}
|
||||
for (const feld of await seite.locator('fieldset input[type="number"]').all()) {
|
||||
await feld.fill("16");
|
||||
}
|
||||
|
||||
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
|
||||
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
|
||||
return new URL(seite.url()).pathname;
|
||||
}
|
||||
|
||||
test.describe("Ein Lauf von vorn bis hinten", () => {
|
||||
test("starten, abarbeiten, abschließen, archivieren", async ({ page }) => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
const pfad = await laufStarten(page, TITEL);
|
||||
|
||||
// Ein neuer Lauf ist **offen** – das ist die Zuweisung, nicht der Start.
|
||||
await expect(page.getByRole("button", { name: "Lauf beginnen" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Lauf beginnen" }).click();
|
||||
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeVisible();
|
||||
|
||||
// Solange Pflichtschritte offen sind, ist der Abschluss gesperrt.
|
||||
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeDisabled();
|
||||
|
||||
/*
|
||||
* Alle Schritte erledigen – einzeln und abschnittsweise, so wie ein
|
||||
* Techniker es täte. Nach jedem Haken wird auf den **Zähler im Kopf des
|
||||
* Abschnitts** gewartet: der steht erst, wenn der Server gespeichert hat.
|
||||
* Ohne dieses Warten trifft der nächste Klick einen Knopf aus dem alten
|
||||
* Baum — und weil ein Klick auf einen schon gehakten Schritt ihn
|
||||
* zurücknimmt, zählte der Test rückwärts.
|
||||
*/
|
||||
const abschnitte = page.locator("section").filter({ has: page.locator("button[aria-expanded]") });
|
||||
const anzahlAbschnitte = await abschnitte.count();
|
||||
expect(anzahlAbschnitte).toBeGreaterThan(0);
|
||||
|
||||
for (let i = 0; i < anzahlAbschnitte; i += 1) {
|
||||
const abschnitt = abschnitte.nth(i);
|
||||
const kopf = abschnitt.locator("button[aria-expanded]").first();
|
||||
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
|
||||
|
||||
// Über die Haken zählen, nicht über `listitem`: die Kommentare eines
|
||||
// Schritts stehen ebenfalls in einer Liste und zählten sonst mit.
|
||||
const schritte = await abschnitt.getByRole("button", { name: "erledigt", exact: true }).count();
|
||||
for (let j = 0; j < schritte; j += 1) {
|
||||
await abschnitt.getByRole("button", { name: "erledigt", exact: true }).nth(j).click();
|
||||
await expect(kopf).toContainText(`${j + 1} von ${schritte}`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Der Beweis, dass es nicht nur im Browser steht: neu laden. Alles ist vom
|
||||
* Tisch, also sind alle Abschnitte zugeklappt — erst wieder aufklappen und
|
||||
* dann nachsehen.
|
||||
*/
|
||||
await page.reload();
|
||||
const ersterAbschnitt = abschnitte.first();
|
||||
await ersterAbschnitt.locator("button[aria-expanded]").first().click();
|
||||
await expect(
|
||||
ersterAbschnitt.getByRole("button", { name: "erledigt", exact: true }).first(),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Lauf abschließen" }).click();
|
||||
await expect(page.getByRole("button", { name: "archivieren" })).toBeVisible();
|
||||
|
||||
// Archivieren ist die zweite, ausdrückliche Handlung – mit Rückfrage.
|
||||
await page.getByRole("button", { name: "archivieren" }).click();
|
||||
await page.getByRole("button", { name: "Ja, archivieren" }).click();
|
||||
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
|
||||
|
||||
// Und danach ändert sich nichts mehr: keine Knöpfe, kein Kommentarfeld.
|
||||
await page.goto(pfad);
|
||||
await expect(page.getByRole("button", { name: "erledigt", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Kommentar hinzufügen" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("verlangt für „nicht zutreffend“ eine Begründung", async ({ page }) => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await laufStarten(page, `${TITEL} B`);
|
||||
await page.getByRole("button", { name: "Lauf beginnen" }).click();
|
||||
|
||||
// Der Klick allein speichert nicht – erst erscheint das Begründungsfeld.
|
||||
await page.getByRole("button", { name: "nicht zutreffend" }).first().click();
|
||||
const speichern = page.getByRole("button", { name: "Mit Begründung speichern" });
|
||||
await expect(speichern).toBeVisible();
|
||||
await expect(speichern).toBeDisabled();
|
||||
|
||||
await page.getByLabel("Begründung").fill("Dieser Kunde hat kein Active Directory.");
|
||||
await expect(speichern).toBeEnabled();
|
||||
await speichern.click();
|
||||
|
||||
/*
|
||||
* Erst auf den gespeicherten Stand warten, dann neu laden: die Aktion
|
||||
* frischt die Seite selbst auf, und ein `reload` mitten hinein bricht sie
|
||||
* ab. Am Handy war dieses Fenster groß genug, dass der Test daran
|
||||
* gescheitert ist.
|
||||
*/
|
||||
const schritt = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" });
|
||||
await expect(schritt.getByText("nicht zutreffend", { exact: true }).last()).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
// Die Begründung steht im Feld, wo sie sich auch ändern lässt – und nur dort.
|
||||
await expect(schritt.getByLabel("Begründung")).toHaveValue("Dieser Kunde hat kein Active Directory.");
|
||||
});
|
||||
|
||||
test("nimmt „nicht zutreffend“ auf Klick wieder zurück", async ({ page }) => {
|
||||
/*
|
||||
* Der Blocker aus dem Review zu #013: ein Klick auf die schon gewählte Pille
|
||||
* setzte den Zustand nur im Browser zurück. Auf dem Bildschirm war danach
|
||||
* nichts mehr gewählt, in der Datenbank stand weiter „nicht zutreffend“ —
|
||||
* und es gab keinen Weg mehr, das zu ändern.
|
||||
*/
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await laufStarten(page, `${TITEL} F`);
|
||||
await page.getByRole("button", { name: "Lauf beginnen" }).click();
|
||||
|
||||
const schritt = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" });
|
||||
const pille = schritt.getByRole("button", { name: "nicht zutreffend" });
|
||||
await pille.click();
|
||||
await schritt.getByLabel("Begründung").fill("Gerät ist neu ausgepackt.");
|
||||
await schritt.getByRole("button", { name: "Mit Begründung speichern" }).click();
|
||||
await expect(pille).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
// Zurücknehmen braucht keine Begründung – es nimmt ja gerade eine zurück.
|
||||
await pille.click();
|
||||
// Erst das Speichern abwarten: ein `reload` mitten in der Aktion bricht sie ab.
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.reload();
|
||||
await expect(pille).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(schritt.getByLabel("Begründung")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("zeigt einen Befehl mit eingesetzten Werten und einem Kopier-Knopf", async ({ page }) => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await laufStarten(page, `${TITEL} C`);
|
||||
|
||||
/*
|
||||
* Der Befehl **am Schritt**, nicht die Skript-Kopie: die steckt in einem
|
||||
* zugeklappten `details` und ist deshalb unsichtbar, bis jemand sie
|
||||
* aufklappt.
|
||||
*/
|
||||
const befehl = page.locator("li pre.mono").first();
|
||||
await expect(befehl).toBeVisible();
|
||||
await expect(befehl).not.toContainText("{{hostname}}");
|
||||
await expect(page.getByRole("button", { name: "Befehl kopieren" }).first()).toBeVisible();
|
||||
await expect(page.getByText("Alle Befehle als Skript kopieren")).toBeVisible();
|
||||
});
|
||||
|
||||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await laufStarten(page, `${TITEL} D`);
|
||||
|
||||
const masse = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
sichtbar: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Der Zustands-Endpunkt", () => {
|
||||
/*
|
||||
* Geprueft wird hier der **Route Handler**, nicht die Abfrage darunter: dass
|
||||
* er sein Recht selbst prueft, einen fremden oder unbekannten Lauf als „nicht
|
||||
* gefunden“ beantwortet und nichts zwischenspeichern laesst. Die Huelle unter
|
||||
* `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab — das ist genau der
|
||||
* Grund, warum es diesen Test gibt (Reviewbefund zu #013).
|
||||
*/
|
||||
test("antwortet ohne Anmeldung mit 401", async ({ request }) => {
|
||||
const antwort = await request.get("/api/laeufe/egal/zustand");
|
||||
expect(antwort.status()).toBe(401);
|
||||
});
|
||||
|
||||
test("liefert angemeldet den Zustand ohne Befehle und ohne Zwischenspeicher", async ({ page }) => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
const pfad = await laufStarten(page, `${TITEL} E`);
|
||||
const laufId = pfad.split("/").pop();
|
||||
|
||||
const antwort = await page.request.get(`/api/laeufe/${laufId}/zustand`);
|
||||
expect(antwort.status()).toBe(200);
|
||||
expect(antwort.headers()["cache-control"]).toBe("no-store");
|
||||
|
||||
const zustand: unknown = await antwort.json();
|
||||
expect(Object.keys(zustand as object).sort()).toEqual([
|
||||
"anzahlKommentare",
|
||||
"anzahlPflichtOffen",
|
||||
"geaendertAm",
|
||||
"schritte",
|
||||
"status",
|
||||
]);
|
||||
expect(JSON.stringify(zustand)).not.toContain("Rename-Computer");
|
||||
});
|
||||
|
||||
test("gibt einen Lauf, den es nicht gibt, als 404 zurück", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
const antwort = await page.request.get("/api/laeufe/gibtsnicht/zustand");
|
||||
expect(antwort.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Namensvorschläge im Start-Dialog", () => {
|
||||
/*
|
||||
* Der Onboarding-Fall: die Person steht noch nicht im Verzeichnis, ihre
|
||||
* Anmeldenamen entstehen erst hier. Die Regeln liegen im Dienst (#012) — dass
|
||||
* der Dialog sie auch wirklich erreicht, sieht man nur so.
|
||||
*/
|
||||
const VORLAGE = `ZZ E2E Onboarding ${STEMPEL}`;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await prisma.einstellung.update({
|
||||
where: { id: "global" },
|
||||
data: {
|
||||
namensregeln: {
|
||||
upn: "{vorname}.{nachname}@firma.de",
|
||||
sam: "{v}{nachname}",
|
||||
mail: "{vorname}.{nachname}@firma.de",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const vorlage = await prisma.vorlage.create({ data: { name: VORLAGE, kategorie: "ZZ Test" } });
|
||||
const version = await prisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
|
||||
});
|
||||
for (const [position, schluessel] of ["upn", "sam", "mail"].entries()) {
|
||||
await prisma.vorlagenVariable.create({
|
||||
data: { versionId: version.id, schluessel, beschriftung: schluessel, position },
|
||||
});
|
||||
}
|
||||
const abschnitt = await prisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Konto" },
|
||||
});
|
||||
await prisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Konto angelegt" },
|
||||
});
|
||||
await prisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
});
|
||||
});
|
||||
|
||||
/** Waehlt die Testvorlage – ueber den Wert, nicht ueber die Beschriftung. */
|
||||
async function vorlageWaehlen(seite: Page) {
|
||||
const wert = await seite
|
||||
.locator('select[name="vorlagenVersionId"] option', { hasText: VORLAGE })
|
||||
.getAttribute("value");
|
||||
if (!wert) throw new Error(`Die Vorlage „${VORLAGE}“ steht nicht zur Auswahl.`);
|
||||
await seite.selectOption('select[name="vorlagenVersionId"]', wert);
|
||||
}
|
||||
|
||||
test("füllt upn, sam und mail nach den Regeln", async ({ page }) => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await page.goto("/laeufe/starten");
|
||||
await vorlageWaehlen(page);
|
||||
await page.getByLabel("… oder Name als Freitext").fill("Jörg Müller-Lüdenscheid");
|
||||
|
||||
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
|
||||
|
||||
/*
|
||||
* Genau der Fall aus dem Auftrag: Umlaute gefaltet, `sam` auf 20 Zeichen
|
||||
* gekuerzt. Die Zahl am Ende ist kein Zufall — traegt die Datenbank den
|
||||
* Namen schon (die Beispieldaten tun es), haengt die Regel einen Zaehler an.
|
||||
* Ob mit oder ohne, entscheidet der Bestand, deshalb steht hier ein Muster.
|
||||
*/
|
||||
await expect(page.getByLabel("upn")).toHaveValue(/^joerg\.mueller-luedenscheid\d*@firma\.de$/u);
|
||||
await expect(page.getByLabel("sam")).toHaveValue(/^jmuellerluedensch\d*$/u);
|
||||
await expect(page.getByLabel("mail")).toHaveValue(/^joerg\.mueller-luedenscheid\d*@firma\.de$/u);
|
||||
});
|
||||
|
||||
test("überschreibt nichts, was schon dasteht", async ({ page }) => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await page.goto("/laeufe/starten");
|
||||
await vorlageWaehlen(page);
|
||||
await page.getByLabel("… oder Name als Freitext").fill("Maria Huber");
|
||||
await page.getByLabel("upn").fill("chefin@firma.de");
|
||||
|
||||
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
|
||||
|
||||
await expect(page.getByLabel("sam")).toHaveValue(/^mhuber\d*$/u);
|
||||
await expect(page.getByLabel("upn")).toHaveValue("chefin@firma.de");
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
const vorlage = await prisma.vorlage.findFirst({
|
||||
where: { name: VORLAGE },
|
||||
select: { id: true },
|
||||
});
|
||||
if (vorlage) await prisma.vorlage.delete({ where: { id: vorlage.id } });
|
||||
await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: {} } });
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
/*
|
||||
* Aufgeraeumt wird ueber die Datenbank: die Anwendung kennt fuer einen Lauf
|
||||
* keinen Loeschweg, und das ist richtig so. Ohne diesen Nachlauf saehe jede
|
||||
* Datenbank, gegen die diese Tests laufen, nach ein paar Durchlaeufen aus wie
|
||||
* ein Messie-Keller.
|
||||
*/
|
||||
const laeufe = await prisma.lauf.findMany({
|
||||
where: { titel: { startsWith: "ZZ E2E Lauf" } },
|
||||
select: { id: true },
|
||||
});
|
||||
const kennungen = laeufe.map((lauf) => lauf.id);
|
||||
if (kennungen.length > 0) {
|
||||
await prisma.kommentar.deleteMany({ where: { laufId: { in: kennungen } } });
|
||||
await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } });
|
||||
// `Protokoll.laufId` steht auf `Restrict` – ohne diese Zeile bleibt der Lauf
|
||||
// stehen, und der Fremdschluessel meldet sich.
|
||||
await prisma.protokoll.deleteMany({
|
||||
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
|
||||
});
|
||||
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import "dotenv/config";
|
||||
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
|
||||
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt. Gegen `pnpm dev`
|
||||
* schlaegt das hier fehl, und das ist richtig so — siehe CLAUDE.md,
|
||||
* „Oberflaeche pruefen".
|
||||
* „Oberflaeche pruefen“.
|
||||
*
|
||||
* Der Ablauf legt seine eigene Vorlage an und **archiviert** sie am Ende. Nicht
|
||||
* loescht: eine veroeffentlichte Version laesst sich nicht mehr entfernen, auch
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Betriebsmodus, LaufStatus, Rolle, SchrittZustand, VersionStatus } from "@/generated/prisma/enums";
|
||||
import { LaufFehler, laufAbschliessen, laufAnlegen } from "@/server/services/laeufe";
|
||||
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
|
||||
import { KOMMENTAR_HOECHSTLAENGE, kommentarHinzufuegen } from "@/server/services/kommentare";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Kommentare an Läufen und Schrittergebnissen (#013).
|
||||
*
|
||||
* Ein Kommentar ist der Ort für das, was in kein Feld passt. Er trägt Urheber und
|
||||
* Zeitpunkt selbst, wird nie geändert — und kommt an einem archivierten Lauf
|
||||
* nicht mehr dazu.
|
||||
*/
|
||||
|
||||
async function grundbestand() {
|
||||
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 adminZeile = await testPrisma.benutzer.create({
|
||||
data: { email: "admin@firma.example", name: "Alex Admin", rolle: Rolle.ADMIN },
|
||||
});
|
||||
const technikerZeile = await testPrisma.benutzer.create({
|
||||
data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER },
|
||||
});
|
||||
return {
|
||||
eigene,
|
||||
admin: { id: adminZeile.id, rolle: Rolle.ADMIN },
|
||||
techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER },
|
||||
};
|
||||
}
|
||||
|
||||
async function vorlageMitVersion() {
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Offboarding" } });
|
||||
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: "Rückgabe", position: 0 },
|
||||
});
|
||||
const geraet = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Notebook zurück", pflicht: true, position: 0 },
|
||||
});
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||
});
|
||||
return { version, schritte: { geraet } };
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Kommentare", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
});
|
||||
|
||||
it("schreibt einen Kommentar an den Lauf", async () => {
|
||||
const { admin, techniker } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
const kommentar = await kommentarHinzufuegen(
|
||||
{ laufId: lauf.id, rumpf: " Kunde war nicht erreichbar, neuer Termin am Montag. " },
|
||||
techniker,
|
||||
);
|
||||
expect(kommentar.laufId).toBe(lauf.id);
|
||||
expect(kommentar.schrittergebnisId).toBeNull();
|
||||
expect(kommentar.benutzerId).toBe(techniker.id);
|
||||
expect(kommentar.rumpf).toBe("Kunde war nicht erreichbar, neuer Termin am Montag.");
|
||||
});
|
||||
|
||||
it("schreibt einen Kommentar an ein Schrittergebnis", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
const ergebnis = await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.geraet.id,
|
||||
zustand: SchrittZustand.FEHLGESCHLAGEN,
|
||||
notiz: "Netzteil fehlt.",
|
||||
},
|
||||
admin,
|
||||
);
|
||||
|
||||
const kommentar = await kommentarHinzufuegen(
|
||||
{ laufId: lauf.id, schrittergebnisId: ergebnis.id, rumpf: "Ersatz ist bestellt." },
|
||||
admin,
|
||||
);
|
||||
expect(kommentar.schrittergebnisId).toBe(ergebnis.id);
|
||||
});
|
||||
|
||||
it("startet den Lauf nicht", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Rückfrage an den Kunden gestellt." }, admin);
|
||||
|
||||
// Eine Rückfrage zu notieren ist keine Abarbeitung.
|
||||
const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
|
||||
expect(nachher.status).toBe(LaufStatus.OFFEN);
|
||||
expect(nachher.gestartetAm).toBeNull();
|
||||
});
|
||||
|
||||
it("nimmt einen Kommentar an einem abgeschlossenen Lauf noch an", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: schritte.geraet.id, zustand: SchrittZustand.ERLEDIGT },
|
||||
admin,
|
||||
);
|
||||
await laufAbschliessen(lauf.id, admin);
|
||||
|
||||
// Das Nachfassen kommt nach dem Abschluss — gesperrt wird erst durch das
|
||||
// Archivieren (siehe laufMussVeraenderbarSein).
|
||||
const kommentar = await kommentarHinzufuegen(
|
||||
{ laufId: lauf.id, rumpf: "Nachtrag: Netzteil ist eine Woche später eingetroffen." },
|
||||
admin,
|
||||
);
|
||||
expect(kommentar.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lehnt einen leeren und einen zu langen Kommentar ab", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await expect(kommentarHinzufuegen({ laufId: lauf.id, rumpf: " " }, admin)).rejects.toThrow(LaufFehler);
|
||||
await expect(
|
||||
kommentarHinzufuegen({ laufId: lauf.id, rumpf: "x".repeat(KOMMENTAR_HOECHSTLAENGE + 1) }, admin),
|
||||
).rejects.toThrow(new RegExp(String(KOMMENTAR_HOECHSTLAENGE)));
|
||||
expect(await testPrisma.kommentar.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("lehnt ein Schrittergebnis eines anderen Laufs ab", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const einer = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
const anderer = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
const fremdesErgebnis = await testPrisma.schrittergebnis.findFirstOrThrow({
|
||||
where: { laufId: anderer.id, vorlagenSchrittId: schritte.geraet.id },
|
||||
});
|
||||
|
||||
// Die Kennung kommt von außen: ohne diese Prüfung hinge der Kommentar an
|
||||
// einem Schritt eines fremden Laufs.
|
||||
await expect(
|
||||
kommentarHinzufuegen(
|
||||
{ laufId: einer.id, schrittergebnisId: fremdesErgebnis.id, rumpf: "Gehört hier nicht hin." },
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/gehört nicht zu diesem Lauf/);
|
||||
expect(await testPrisma.kommentar.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("lässt einen Techniker nicht an den Lauf eines fremden Kunden", async () => {
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "laufzettel-";
|
||||
const eigene = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({
|
||||
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
|
||||
});
|
||||
const adminZeile = await testPrisma.benutzer.create({
|
||||
data: { email: "admin@firma.example", name: "Alex Admin", rolle: Rolle.ADMIN },
|
||||
});
|
||||
const technikerZeile = await testPrisma.benutzer.create({
|
||||
data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER },
|
||||
});
|
||||
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } });
|
||||
await testPrisma.benutzerKunde.create({
|
||||
data: { benutzerId: technikerZeile.id, kundeId: eigene.id },
|
||||
});
|
||||
const { version } = await vorlageMitVersion();
|
||||
|
||||
const lauf = await laufAnlegen(
|
||||
{ vorlagenVersionId: version.id, kundeId: kundeA.id },
|
||||
{ id: adminZeile.id, rolle: Rolle.ADMIN },
|
||||
);
|
||||
|
||||
await expect(
|
||||
kommentarHinzufuegen(
|
||||
{ laufId: lauf.id, rumpf: "Darf ich das sehen?" },
|
||||
{
|
||||
id: technikerZeile.id,
|
||||
rolle: Rolle.TECHNIKER,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/Berechtigung/);
|
||||
expect(await testPrisma.kommentar.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,888 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
Betriebsmodus,
|
||||
LaufStatus,
|
||||
Rolle,
|
||||
SchrittTyp,
|
||||
SchrittZustand,
|
||||
VariablenTyp,
|
||||
VersionStatus,
|
||||
} from "@/generated/prisma/enums";
|
||||
import {
|
||||
LaufFehler,
|
||||
laufAbschliessen,
|
||||
laufAnlegen,
|
||||
laufArchivieren,
|
||||
laufKopfdatenAendern,
|
||||
laufMitAdKontoVerknuepfen,
|
||||
laufMussVeraenderbarSein,
|
||||
laufZurBearbeitungOeffnen,
|
||||
laufZurueckholen,
|
||||
titelVorschlag,
|
||||
titelVorschlagErmitteln,
|
||||
} from "@/server/services/laeufe";
|
||||
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
|
||||
import { kommentarHinzufuegen } from "@/server/services/kommentare";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Läufe: anlegen, starten, abschließen, archivieren (#013).
|
||||
*
|
||||
* Die Zusage, an der der Wert des ganzen Archivs hängt: ein archivierter Lauf
|
||||
* ändert sich nicht mehr, und nur ein ADMIN hebt das auf. Dazu die Regeln, die
|
||||
* einen Lauf überhaupt brauchbar machen — er hängt für immer an seiner Version,
|
||||
* `OFFEN` ist der Zuweisungszustand, und die Mandantengrenze gilt an jeder
|
||||
* Kennung, die von außen kommt.
|
||||
*/
|
||||
|
||||
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
|
||||
const eigene = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global", modus } });
|
||||
const adminZeile = await testPrisma.benutzer.create({
|
||||
data: { email: "admin@firma.example", name: "Alex Admin", rolle: Rolle.ADMIN },
|
||||
});
|
||||
const technikerZeile = await testPrisma.benutzer.create({
|
||||
data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER },
|
||||
});
|
||||
const leserZeile = await testPrisma.benutzer.create({
|
||||
data: { email: "leser@firma.example", name: "Lars Leser", rolle: Rolle.LESER },
|
||||
});
|
||||
return {
|
||||
eigene,
|
||||
admin: { id: adminZeile.id, rolle: Rolle.ADMIN },
|
||||
techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER },
|
||||
leser: { id: leserZeile.id, rolle: Rolle.LESER },
|
||||
};
|
||||
}
|
||||
|
||||
type VariablenVorgabe = {
|
||||
readonly schluessel: string;
|
||||
readonly beschriftung: string;
|
||||
readonly typ?: VariablenTyp;
|
||||
readonly optionen?: readonly string[];
|
||||
readonly pflicht?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Eine Vorlage mit einer veröffentlichten Version 1: vier Schritte, drei davon
|
||||
* Pflicht.
|
||||
*
|
||||
* Der Inhalt entsteht **vor** dem Veröffentlichen — die Trigger aus #009 lassen
|
||||
* an einer veröffentlichten Version nichts mehr einfügen, und genau das ist der
|
||||
* Sinn dieser Tests von der anderen Seite.
|
||||
*/
|
||||
async function vorlageMitVersion(vorgabe?: {
|
||||
readonly kundeId?: string | null;
|
||||
readonly variablen?: readonly VariablenVorgabe[];
|
||||
readonly name?: string;
|
||||
}) {
|
||||
const vorlage = await testPrisma.vorlage.create({
|
||||
data: { name: vorgabe?.name ?? "PC-Neuinstallation", kundeId: vorgabe?.kundeId ?? null },
|
||||
});
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
|
||||
for (const [index, variable] of (vorgabe?.variablen ?? []).entries()) {
|
||||
await testPrisma.vorlagenVariable.create({
|
||||
data: {
|
||||
versionId: version.id,
|
||||
schluessel: variable.schluessel,
|
||||
beschriftung: variable.beschriftung,
|
||||
typ: variable.typ ?? VariablenTyp.TEXT,
|
||||
optionen: [...(variable.optionen ?? [])],
|
||||
pflicht: variable.pflicht ?? false,
|
||||
position: index,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const vorbereitung = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Vorbereitung", position: 0 },
|
||||
});
|
||||
const sicherung = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: vorbereitung.id, titel: "Datensicherung geprüft", pflicht: true, position: 0 },
|
||||
});
|
||||
const notizSchritt = await testPrisma.vorlagenSchritt.create({
|
||||
data: {
|
||||
abschnittId: vorbereitung.id,
|
||||
titel: "Auffälligkeiten notiert",
|
||||
typ: SchrittTyp.TEXT,
|
||||
pflicht: false,
|
||||
position: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const konto = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Konto im AD", position: 1 },
|
||||
});
|
||||
const kontoAngelegt = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: konto.id, titel: "Konto angelegt", pflicht: true, position: 0 },
|
||||
});
|
||||
const gruppen = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: konto.id, titel: "Gruppen zugewiesen", pflicht: true, position: 1 },
|
||||
});
|
||||
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
return {
|
||||
vorlage,
|
||||
version,
|
||||
abschnitte: { vorbereitung, konto },
|
||||
schritte: { sicherung, notizSchritt, kontoAngelegt, gruppen },
|
||||
pflichtIds: [sicherung.id, kontoAngelegt.id, gruppen.id],
|
||||
};
|
||||
}
|
||||
|
||||
/** Setzt die genannten Schritte auf ERLEDIGT – über den Dienst, wie die Oberfläche. */
|
||||
async function erledigen(
|
||||
laufId: string,
|
||||
schrittIds: readonly string[],
|
||||
benutzer: { readonly id: string; readonly rolle: Rolle },
|
||||
): Promise<void> {
|
||||
for (const vorlagenSchrittId of schrittIds) {
|
||||
await schrittergebnisSetzen({ laufId, vorlagenSchrittId, zustand: SchrittZustand.ERLEDIGT }, benutzer);
|
||||
}
|
||||
}
|
||||
|
||||
async function aktionen(laufId: string): Promise<readonly string[]> {
|
||||
const eintraege = await testPrisma.protokoll.findMany({
|
||||
where: { laufId },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
});
|
||||
return eintraege.map((eintrag) => eintrag.aktion);
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Läufe", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
});
|
||||
|
||||
describe("Anlegen und Zuweisen", () => {
|
||||
// Akzeptanzkriterium 1
|
||||
it("legt einen Lauf ohne Mitarbeiter und ohne Gerät an, schließt ihn ab und archiviert ihn", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const { version, pflichtIds } = await vorlageMitVersion();
|
||||
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
expect(lauf.status).toBe(LaufStatus.OFFEN);
|
||||
expect(lauf.mitarbeiterId).toBeNull();
|
||||
expect(lauf.geraetId).toBeNull();
|
||||
expect(lauf.kundeId).toBe(eigene.id);
|
||||
expect(lauf.erstelltVonId).toBe(admin.id);
|
||||
// Die Ergebniszeilen entstehen beim Anlegen, damit der Fortschritt ab der
|
||||
// ersten Sekunde stimmt.
|
||||
expect(await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id } })).toBe(4);
|
||||
|
||||
await erledigen(lauf.id, pflichtIds, admin);
|
||||
const abgeschlossen = await laufAbschliessen(lauf.id, admin);
|
||||
expect(abgeschlossen.status).toBe(LaufStatus.ABGESCHLOSSEN);
|
||||
expect(abgeschlossen.abgeschlossenVonId).toBe(admin.id);
|
||||
expect(abgeschlossen.abgeschlossenAm).not.toBeNull();
|
||||
|
||||
const archiviert = await laufArchivieren(lauf.id, admin);
|
||||
expect(archiviert.status).toBe(LaufStatus.ARCHIVIERT);
|
||||
expect(archiviert.archiviertVonId).toBe(admin.id);
|
||||
expect(archiviert.archiviertAm).not.toBeNull();
|
||||
|
||||
expect(await aktionen(lauf.id)).toEqual(
|
||||
expect.arrayContaining(["Lauf angelegt", "Lauf gestartet", "Lauf abgeschlossen", "Lauf archiviert"]),
|
||||
);
|
||||
});
|
||||
|
||||
// Akzeptanzkriterium 6
|
||||
it("lässt gestartetAm leer, solange der Lauf nur OFFEN und zugewiesen ist", async () => {
|
||||
const { admin, techniker } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
|
||||
const lauf = await laufAnlegen(
|
||||
{ vorlagenVersionId: version.id, zugewiesenAnId: techniker.id, ticket: "TICK-7" },
|
||||
admin,
|
||||
);
|
||||
expect(lauf.status).toBe(LaufStatus.OFFEN);
|
||||
expect(lauf.gestartetAm).toBeNull();
|
||||
expect(lauf.zugewiesenAnId).toBe(techniker.id);
|
||||
|
||||
// Ansehen startet nichts: das Lesemodell rührt den Lauf nicht an, und auch
|
||||
// nach dem Lesen der Kopfdaten steht gestartetAm noch leer.
|
||||
const gelesen = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
|
||||
expect(gelesen.gestartetAm).toBeNull();
|
||||
expect(await aktionen(lauf.id)).toEqual(["Lauf angelegt"]);
|
||||
});
|
||||
|
||||
it("startet den Lauf beim ersten Öffnen zur Bearbeitung und bleibt danach idempotent", async () => {
|
||||
const { admin, techniker } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
const erstes = await laufZurBearbeitungOeffnen(lauf.id, techniker);
|
||||
expect(erstes.status).toBe(LaufStatus.IN_ARBEIT);
|
||||
expect(erstes.gestartetAm).not.toBeNull();
|
||||
|
||||
const zweites = await laufZurBearbeitungOeffnen(lauf.id, techniker);
|
||||
expect(zweites.gestartetAm?.getTime()).toBe(erstes.gestartetAm?.getTime());
|
||||
expect((await aktionen(lauf.id)).filter((a) => a === "Lauf gestartet")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("wirft einen abgeschlossenen oder archivierten Lauf beim Öffnen nicht zurück", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, pflichtIds } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
await erledigen(lauf.id, pflichtIds, admin);
|
||||
await laufAbschliessen(lauf.id, admin);
|
||||
|
||||
expect((await laufZurBearbeitungOeffnen(lauf.id, admin)).status).toBe(LaufStatus.ABGESCHLOSSEN);
|
||||
|
||||
await laufArchivieren(lauf.id, admin);
|
||||
// Ein archivierter Lauf lässt sich aufschlagen, ohne dass das Öffnen wirft —
|
||||
// sonst wäre er nicht mehr ansehbar.
|
||||
expect((await laufZurBearbeitungOeffnen(lauf.id, admin)).status).toBe(LaufStatus.ARCHIVIERT);
|
||||
});
|
||||
|
||||
it("schließt einen Lauf ohne Pflichtschritte direkt aus OFFEN ab, mit Startzeitpunkt", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Patchday" } });
|
||||
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: "Durchsicht", position: 0 },
|
||||
});
|
||||
await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Kurz geschaut", pflicht: false, position: 0 },
|
||||
});
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
const abgeschlossen = await laufAbschliessen(lauf.id, admin);
|
||||
// Lauf_begonnen_mit_zeitpunkt in der Datenbank verlangt den Startzeitpunkt,
|
||||
// und ein abgeschlossener Lauf ohne ihn sähe im Archiv wie ein Fehler aus.
|
||||
expect(abgeschlossen.gestartetAm).not.toBeNull();
|
||||
expect(abgeschlossen.status).toBe(LaufStatus.ABGESCHLOSSEN);
|
||||
});
|
||||
|
||||
it("lehnt einen Entwurf und eine archivierte Vorlage als Grundlage ab", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { vorlage, version } = await vorlageMitVersion();
|
||||
|
||||
const entwurf = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 2, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
await expect(laufAnlegen({ vorlagenVersionId: entwurf.id }, admin)).rejects.toThrow(LaufFehler);
|
||||
await expect(laufAnlegen({ vorlagenVersionId: entwurf.id }, admin)).rejects.toThrow(
|
||||
/veröffentlichte Version/,
|
||||
);
|
||||
|
||||
await testPrisma.vorlage.update({ where: { id: vorlage.id }, data: { archiviert: true } });
|
||||
await expect(laufAnlegen({ vorlagenVersionId: version.id }, admin)).rejects.toThrow(/archiviert/);
|
||||
});
|
||||
|
||||
it("lehnt Person und Freitextname zugleich ab", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const person = await testPrisma.mitarbeiter.create({
|
||||
data: { kundeId: eigene.id, vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
laufAnlegen(
|
||||
{ vorlagenVersionId: version.id, mitarbeiterId: person.id, mitarbeiterName: "Ada L." },
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/beides zugleich/);
|
||||
expect(await testPrisma.lauf.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("nimmt einen deaktivierten Mitarbeiter an – das Offboarding braucht ihn", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const person = await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
vorname: "Moritz",
|
||||
nachname: "Geht",
|
||||
anzeigename: "Moritz Geht",
|
||||
aktiv: false,
|
||||
},
|
||||
});
|
||||
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, admin);
|
||||
expect(lauf.mitarbeiterId).toBe(person.id);
|
||||
});
|
||||
|
||||
it("lehnt ein archiviertes Gerät ab", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const geraet = await testPrisma.geraet.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
tagCode: "LZ-AAAAAA",
|
||||
hostname: "PC-ALT-01",
|
||||
archiviert: true,
|
||||
registriertAm: new Date(),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
laufAnlegen({ vorlagenVersionId: version.id, geraetId: geraet.id }, admin),
|
||||
).rejects.toThrow(/archiviert/);
|
||||
});
|
||||
|
||||
it("weist eine Zuweisung an einen LESER und an ein deaktiviertes Konto ab", async () => {
|
||||
const { admin, leser } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const gesperrt = await testPrisma.benutzer.create({
|
||||
data: { email: "weg@firma.example", name: "Wanda Weg", rolle: Rolle.TECHNIKER, aktiv: false },
|
||||
});
|
||||
|
||||
await expect(
|
||||
laufAnlegen({ vorlagenVersionId: version.id, zugewiesenAnId: leser.id }, admin),
|
||||
).rejects.toThrow(/darf Läufe nicht bearbeiten/);
|
||||
await expect(
|
||||
laufAnlegen({ vorlagenVersionId: version.id, zugewiesenAnId: gesperrt.id }, admin),
|
||||
).rejects.toThrow(/deaktiviert/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Variablen", () => {
|
||||
it("nimmt gültige Werte auf und weist fehlende, unbekannte und falsche ab", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion({
|
||||
variablen: [
|
||||
{ schluessel: "os_version", beschriftung: "Betriebssystem", pflicht: true },
|
||||
{
|
||||
schluessel: "join_typ",
|
||||
beschriftung: "Einbindung",
|
||||
typ: VariablenTyp.AUSWAHL,
|
||||
optionen: ["Active Directory", "Entra ID"],
|
||||
},
|
||||
{ schluessel: "speicher_gb", beschriftung: "Speicher in GB", typ: VariablenTyp.ZAHL },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(laufAnlegen({ vorlagenVersionId: version.id }, admin)).rejects.toThrow(/„Betriebssystem“/);
|
||||
await expect(
|
||||
laufAnlegen(
|
||||
{ vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", tipfehler: "x" } },
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/„tipfehler“/);
|
||||
await expect(
|
||||
laufAnlegen(
|
||||
{ vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", join_typ: "Novell" } },
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/keine Auswahl/);
|
||||
await expect(
|
||||
laufAnlegen(
|
||||
{ vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", speicher_gb: "viel" } },
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/erwartet eine Zahl/);
|
||||
|
||||
const lauf = await laufAnlegen(
|
||||
{
|
||||
vorlagenVersionId: version.id,
|
||||
variablen: { os_version: " Windows 11 Pro ", join_typ: "Entra ID", speicher_gb: 32 },
|
||||
},
|
||||
admin,
|
||||
);
|
||||
expect(lauf.variablen).toEqual({
|
||||
os_version: "Windows 11 Pro",
|
||||
join_typ: "Entra ID",
|
||||
speicher_gb: 32,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Titelvorschlag", () => {
|
||||
it("baut <Vorlage> – <Hostname oder Mitarbeiter> – <Datum>", () => {
|
||||
const datum = new Date("2026-09-25T10:00:00Z");
|
||||
expect(titelVorschlag({ vorlage: "Onboarding", geraet: "NB-ADA", datum })).toBe(
|
||||
"Onboarding – NB-ADA – 25.09.2026",
|
||||
);
|
||||
// Der Hostname gewinnt: er steht auf dem Gerät, vor dem der Techniker sitzt.
|
||||
expect(titelVorschlag({ vorlage: "Onboarding", geraet: "NB-ADA", person: "Ada Lovelace", datum })).toBe(
|
||||
"Onboarding – NB-ADA – 25.09.2026",
|
||||
);
|
||||
expect(titelVorschlag({ vorlage: "Onboarding", person: "Ada Lovelace", datum })).toBe(
|
||||
"Onboarding – Ada Lovelace – 25.09.2026",
|
||||
);
|
||||
// Fehlt der Bezug, fällt er weg statt als leere Stelle zu erscheinen.
|
||||
expect(titelVorschlag({ vorlage: "Patchday", datum })).toBe("Patchday – 25.09.2026");
|
||||
});
|
||||
|
||||
it("löst den Vorschlag auf, bevor der Lauf existiert", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const geraet = await testPrisma.geraet.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
tagCode: "LZ-BBBBBB",
|
||||
hostname: "PC-VERTRIEB-07",
|
||||
registriertAm: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const vorschlag = await titelVorschlagErmitteln(
|
||||
{ vorlagenVersionId: version.id, geraetId: geraet.id },
|
||||
admin,
|
||||
);
|
||||
expect(vorschlag).toContain("PC-Neuinstallation");
|
||||
expect(vorschlag).toContain("PC-VERTRIEB-07");
|
||||
expect(await testPrisma.lauf.count()).toBe(0);
|
||||
|
||||
// Und das Anlegen ohne eigenen Titel setzt genau diesen Vorschlag ein.
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, geraetId: geraet.id }, admin);
|
||||
expect(lauf.titel).toBe(vorschlag);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Abschließen", () => {
|
||||
// Akzeptanzkriterium 2
|
||||
it("scheitert, solange ein Pflichtschritt OFFEN ist, und nennt ihn", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await erledigen(lauf.id, [schritte.sicherung.id, schritte.kontoAngelegt.id], admin);
|
||||
|
||||
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(LaufFehler);
|
||||
// Nicht „es fehlen noch Schritte“, sondern welche.
|
||||
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/Konto im AD · Gruppen zugewiesen/);
|
||||
expect((await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } })).status).toBe(
|
||||
LaufStatus.IN_ARBEIT,
|
||||
);
|
||||
});
|
||||
|
||||
it("zählt FEHLGESCHLAGEN nicht als erledigt", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte, pflichtIds } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await erledigen(
|
||||
lauf.id,
|
||||
pflichtIds.filter((id) => id !== schritte.gruppen.id),
|
||||
admin,
|
||||
);
|
||||
await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.gruppen.id,
|
||||
zustand: SchrittZustand.FEHLGESCHLAGEN,
|
||||
notiz: "Gruppenrichtlinie greift nicht, Ticket an das Netzwerkteam.",
|
||||
},
|
||||
admin,
|
||||
);
|
||||
|
||||
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/fehlgeschlagener Schritt/);
|
||||
|
||||
// NICHT_ZUTREFFEND mit Begründung macht den Weg frei.
|
||||
await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.gruppen.id,
|
||||
zustand: SchrittZustand.NICHT_ZUTREFFEND,
|
||||
notiz: "Der Kunde pflegt Gruppen selbst.",
|
||||
},
|
||||
admin,
|
||||
);
|
||||
expect((await laufAbschliessen(lauf.id, admin)).status).toBe(LaufStatus.ABGESCHLOSSEN);
|
||||
});
|
||||
|
||||
it("lässt einen offenen Nicht-Pflichtschritt durchgehen", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, pflichtIds } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
await erledigen(lauf.id, pflichtIds, admin);
|
||||
|
||||
expect((await laufAbschliessen(lauf.id, admin)).status).toBe(LaufStatus.ABGESCHLOSSEN);
|
||||
});
|
||||
|
||||
it("ist beim zweiten Abschließen still – ein Doppelklick ist kein Fehler", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, pflichtIds } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
await erledigen(lauf.id, pflichtIds, admin);
|
||||
|
||||
const erstes = await laufAbschliessen(lauf.id, admin);
|
||||
const zweites = await laufAbschliessen(lauf.id, admin);
|
||||
expect(zweites.abgeschlossenAm?.getTime()).toBe(erstes.abgeschlossenAm?.getTime());
|
||||
expect((await aktionen(lauf.id)).filter((a) => a === "Lauf abgeschlossen")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hält zwei gleichzeitige Abschlüsse und ein Starten dazwischen aus", async () => {
|
||||
const { admin, techniker } = await grundbestand();
|
||||
const { version, pflichtIds } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
await erledigen(lauf.id, pflichtIds, admin);
|
||||
|
||||
// Zwei Techniker am selben Lauf: die Zeilensperre reiht die Schreibzugriffe
|
||||
// auf, es entsteht genau ein Abschluss.
|
||||
await Promise.all([
|
||||
laufAbschliessen(lauf.id, admin),
|
||||
laufAbschliessen(lauf.id, techniker),
|
||||
laufZurBearbeitungOeffnen(lauf.id, techniker),
|
||||
]);
|
||||
|
||||
const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
|
||||
expect(nachher.status).toBe(LaufStatus.ABGESCHLOSSEN);
|
||||
expect((await aktionen(lauf.id)).filter((a) => a === "Lauf abgeschlossen")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Archiv", () => {
|
||||
// Akzeptanzkriterium 4
|
||||
it("lehnt an einem archivierten Lauf Schrittergebnis, Kommentar und Kopfdaten ab", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte, pflichtIds } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
await erledigen(lauf.id, pflichtIds, admin);
|
||||
const ergebnis = await testPrisma.schrittergebnis.findFirstOrThrow({
|
||||
where: { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id },
|
||||
});
|
||||
await laufAbschliessen(lauf.id, admin);
|
||||
await laufArchivieren(lauf.id, admin);
|
||||
|
||||
await expect(
|
||||
schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.notizSchritt.id,
|
||||
zustand: SchrittZustand.ERLEDIGT,
|
||||
},
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/archiviert/);
|
||||
await expect(
|
||||
kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Noch ein Nachtrag." }, admin),
|
||||
).rejects.toThrow(/archiviert/);
|
||||
await expect(
|
||||
kommentarHinzufuegen(
|
||||
{ laufId: lauf.id, schrittergebnisId: ergebnis.id, rumpf: "Und hier auch." },
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/archiviert/);
|
||||
await expect(laufKopfdatenAendern(lauf.id, { ticket: "TICK-NEU" }, admin)).rejects.toThrow(
|
||||
/archiviert/,
|
||||
);
|
||||
await expect(laufMitAdKontoVerknuepfen(lauf.id, { upn: "ada@firma.local" }, admin)).rejects.toThrow(
|
||||
/archiviert/,
|
||||
);
|
||||
|
||||
// Und es ist wirklich nichts angekommen.
|
||||
const unberuehrt = await testPrisma.schrittergebnis.findFirstOrThrow({
|
||||
where: { laufId: lauf.id, vorlagenSchrittId: schritte.notizSchritt.id },
|
||||
});
|
||||
expect(unberuehrt.zustand).toBe(SchrittZustand.OFFEN);
|
||||
expect(await testPrisma.kommentar.count({ where: { laufId: lauf.id } })).toBe(0);
|
||||
});
|
||||
|
||||
// Akzeptanzkriterium 5
|
||||
it("holt nur ein ADMIN einen archivierten Lauf zurück, mit Protokolleintrag", async () => {
|
||||
const { admin, techniker } = await grundbestand();
|
||||
const { version, pflichtIds } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
await erledigen(lauf.id, pflichtIds, admin);
|
||||
await laufAbschliessen(lauf.id, admin);
|
||||
await laufArchivieren(lauf.id, admin);
|
||||
|
||||
await expect(laufZurueckholen(lauf.id, techniker)).rejects.toThrow(/Administrator/);
|
||||
expect((await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } })).status).toBe(
|
||||
LaufStatus.ARCHIVIERT,
|
||||
);
|
||||
|
||||
const zurueck = await laufZurueckholen(lauf.id, admin);
|
||||
expect(zurueck.status).toBe(LaufStatus.ABGESCHLOSSEN);
|
||||
expect(zurueck.archiviertAm).toBeNull();
|
||||
expect(zurueck.archiviertVonId).toBeNull();
|
||||
|
||||
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||||
where: { laufId: lauf.id, aktion: "Lauf aus dem Archiv zurückgeholt" },
|
||||
});
|
||||
expect(eintrag.benutzerId).toBe(admin.id);
|
||||
|
||||
// Und danach ist der Lauf wieder änderbar.
|
||||
const geaendert = await laufKopfdatenAendern(lauf.id, { ticket: "TICK-9" }, admin);
|
||||
expect(geaendert.ticket).toBe("TICK-9");
|
||||
});
|
||||
|
||||
it("archiviert nur einen abgeschlossenen Lauf und bleibt beim zweiten Mal still", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, pflichtIds } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await expect(laufArchivieren(lauf.id, admin)).rejects.toThrow(/Erst abschließen/);
|
||||
|
||||
await erledigen(lauf.id, pflichtIds, admin);
|
||||
await laufAbschliessen(lauf.id, admin);
|
||||
const erstes = await laufArchivieren(lauf.id, admin);
|
||||
const zweites = await laufArchivieren(lauf.id, admin);
|
||||
expect(zweites.archiviertAm?.getTime()).toBe(erstes.archiviertAm?.getTime());
|
||||
expect((await aktionen(lauf.id)).filter((a) => a === "Lauf archiviert")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hält den Schreibschutz an genau einer Stelle fest", () => {
|
||||
// Ein abgeschlossener Lauf bleibt änderbar – nachgetragene Notizen und
|
||||
// Kommentare sind der Normalfall. Erst das Archivieren sperrt.
|
||||
for (const status of [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT, LaufStatus.ABGESCHLOSSEN]) {
|
||||
expect(() => laufMussVeraenderbarSein({ status, titel: "Ein Lauf" })).not.toThrow();
|
||||
}
|
||||
expect(() => laufMussVeraenderbarSein({ status: LaufStatus.ARCHIVIERT, titel: "Ein Lauf" })).toThrow(
|
||||
LaufFehler,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bindung an die Version", () => {
|
||||
it("bleibt an seiner Version, auch wenn danach eine neue veröffentlicht wird", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { vorlage, version, pflichtIds } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
// Version 2 mit einem zusätzlichen Pflichtschritt.
|
||||
const zweite = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 2, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: zweite.id, titel: "Neu", position: 0 },
|
||||
});
|
||||
await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "BitLocker geprüft", pflicht: true, position: 0 },
|
||||
});
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: zweite.id },
|
||||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
// Der laufende Lauf kennt den neuen Pflichtschritt nicht: die drei
|
||||
// Pflichtschritte aus Version 1 genügen zum Abschluss.
|
||||
await erledigen(lauf.id, pflichtIds, admin);
|
||||
const abgeschlossen = await laufAbschliessen(lauf.id, admin);
|
||||
expect(abgeschlossen.vorlagenVersionId).toBe(version.id);
|
||||
expect(await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id } })).toBe(4);
|
||||
|
||||
// Und es gibt keinen Weg, ihn auf die neue Version zu heben.
|
||||
await expect(laufKopfdatenAendern(lauf.id, { vorlagenVersionId: zweite.id }, admin)).rejects.toThrow(
|
||||
/gestartet wurde/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kopfdaten", () => {
|
||||
it("ändert nur, was sich wirklich ändert, und protokolliert es", async () => {
|
||||
const { admin, techniker } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, ticket: "TICK-1" }, admin);
|
||||
|
||||
const unveraendert = await laufKopfdatenAendern(lauf.id, { ticket: "TICK-1" }, admin);
|
||||
expect(unveraendert.geaendertAm.getTime()).toBe(lauf.geaendertAm.getTime());
|
||||
expect(await aktionen(lauf.id)).toEqual(["Lauf angelegt"]);
|
||||
|
||||
const faellig = new Date("2026-10-01T08:00:00Z");
|
||||
const geaendert = await laufKopfdatenAendern(
|
||||
lauf.id,
|
||||
{ ticket: "TICK-2", zugewiesenAnId: techniker.id, faelligAm: faellig, titel: "Neuer Titel" },
|
||||
admin,
|
||||
);
|
||||
expect(geaendert.ticket).toBe("TICK-2");
|
||||
expect(geaendert.zugewiesenAnId).toBe(techniker.id);
|
||||
expect(geaendert.faelligAm?.getTime()).toBe(faellig.getTime());
|
||||
expect(geaendert.titel).toBe("Neuer Titel");
|
||||
|
||||
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||||
where: { laufId: lauf.id, entitaet: "Lauf", aktion: { contains: "geändert" } },
|
||||
});
|
||||
expect(eintrag.vorher).toMatchObject({ Ticket: "TICK-1" });
|
||||
expect(eintrag.nachher).toMatchObject({ Ticket: "TICK-2", Titel: "Neuer Titel" });
|
||||
});
|
||||
|
||||
it("prüft geänderte Variablen erneut und meldet nur echte Änderungen", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion({
|
||||
variablen: [
|
||||
{ schluessel: "os_version", beschriftung: "Betriebssystem", pflicht: true },
|
||||
{ schluessel: "notiz", beschriftung: "Bemerkung" },
|
||||
],
|
||||
});
|
||||
const lauf = await laufAnlegen(
|
||||
{ vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", notiz: "eilig" } },
|
||||
admin,
|
||||
);
|
||||
|
||||
// Dieselben Werte in anderer Reihenfolge sind keine Änderung.
|
||||
const gleich = await laufKopfdatenAendern(
|
||||
lauf.id,
|
||||
{ variablen: { notiz: "eilig", os_version: "Windows 11" } },
|
||||
admin,
|
||||
);
|
||||
expect(gleich.geaendertAm.getTime()).toBe(lauf.geaendertAm.getTime());
|
||||
expect(await aktionen(lauf.id)).toEqual(["Lauf angelegt"]);
|
||||
|
||||
// Eine Pflichtvariable lässt sich auch nachträglich nicht leeren.
|
||||
await expect(laufKopfdatenAendern(lauf.id, { variablen: { notiz: "eilig" } }, admin)).rejects.toThrow(
|
||||
/„Betriebssystem“/,
|
||||
);
|
||||
|
||||
const geaendert = await laufKopfdatenAendern(
|
||||
lauf.id,
|
||||
{ variablen: { os_version: "Windows 11 Pro", notiz: "eilig" } },
|
||||
admin,
|
||||
);
|
||||
expect(geaendert.variablen).toEqual({ os_version: "Windows 11 Pro", notiz: "eilig" });
|
||||
expect(await aktionen(lauf.id)).toContain("Variablen geändert");
|
||||
});
|
||||
|
||||
it("lehnt einen leeren Titel ab", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
await expect(laufKopfdatenAendern(lauf.id, { titel: " " }, admin)).rejects.toThrow(/Titel/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mit AD-Konto verknüpfen", () => {
|
||||
it("verbindet einen Freitextnamen über den UPN und behält den alten Namen im Protokoll", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen(
|
||||
{ vorlagenVersionId: version.id, mitarbeiterName: "Ada Lovelace (neu)" },
|
||||
admin,
|
||||
);
|
||||
const person = await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
vorname: "Ada",
|
||||
nachname: "Lovelace",
|
||||
anzeigename: "Ada Lovelace",
|
||||
upn: "ada.lovelace@firma.local",
|
||||
},
|
||||
});
|
||||
|
||||
const verknuepft = await laufMitAdKontoVerknuepfen(lauf.id, { upn: "ADA.LOVELACE@firma.local" }, admin);
|
||||
expect(verknuepft.mitarbeiterId).toBe(person.id);
|
||||
// Die Bedingung Lauf_mitarbeiter_eindeutig schließt beides zugleich aus.
|
||||
expect(verknuepft.mitarbeiterName).toBeNull();
|
||||
|
||||
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||||
where: { laufId: lauf.id, aktion: "Mit AD-Konto verknüpft" },
|
||||
});
|
||||
expect(eintrag.vorher).toMatchObject({ mitarbeiterName: "Ada Lovelace (neu)" });
|
||||
expect(eintrag.nachher).toMatchObject({ anzeigename: "Ada Lovelace" });
|
||||
});
|
||||
|
||||
it("lehnt ein zweites Konto und einen unbekannten UPN ab", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const person = await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
vorname: "Ada",
|
||||
nachname: "Lovelace",
|
||||
anzeigename: "Ada Lovelace",
|
||||
upn: "ada@firma.local",
|
||||
},
|
||||
});
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, admin);
|
||||
|
||||
await expect(laufMitAdKontoVerknuepfen(lauf.id, { mitarbeiterId: person.id }, admin)).rejects.toThrow(
|
||||
/hängt schon eine Person/,
|
||||
);
|
||||
|
||||
const zweiter = await laufAnlegen(
|
||||
{ vorlagenVersionId: version.id, mitarbeiterName: "Niemand Bekannt" },
|
||||
admin,
|
||||
);
|
||||
await expect(
|
||||
laufMitAdKontoVerknuepfen(zweiter.id, { upn: "gibt.es.nicht@firma.local" }, admin),
|
||||
).rejects.toThrow(/kein Konto/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mandantengrenze", () => {
|
||||
it("verwirft im Modus INTERN eine mitgeschickte kundeId", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const fremder = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
|
||||
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: fremder.id }, admin);
|
||||
expect(lauf.kundeId).toBe(eigene.id);
|
||||
});
|
||||
|
||||
it("lehnt Gerät, Person und Abteilung eines fremden Kunden ab", async () => {
|
||||
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
|
||||
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } });
|
||||
const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KB" } });
|
||||
const { version } = await vorlageMitVersion();
|
||||
|
||||
const geraetB = await testPrisma.geraet.create({
|
||||
data: {
|
||||
kundeId: kundeB.id,
|
||||
tagCode: "LZ-CCCCCC",
|
||||
hostname: "PC-B-01",
|
||||
registriertAm: new Date(),
|
||||
},
|
||||
});
|
||||
const personB = await testPrisma.mitarbeiter.create({
|
||||
data: { kundeId: kundeB.id, vorname: "Bea", nachname: "Bee", anzeigename: "Bea Bee" },
|
||||
});
|
||||
const abteilungB = await testPrisma.abteilung.create({
|
||||
data: { kundeId: kundeB.id, name: "Buchhaltung" },
|
||||
});
|
||||
|
||||
for (const eingabe of [
|
||||
{ geraetId: geraetB.id },
|
||||
{ mitarbeiterId: personB.id },
|
||||
{ abteilungId: abteilungB.id },
|
||||
]) {
|
||||
await expect(
|
||||
laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id, ...eingabe }, admin),
|
||||
).rejects.toThrow(/anderen Kunden/);
|
||||
}
|
||||
expect(await testPrisma.lauf.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("lehnt eine kundenspezifische Vorlage bei einem anderen Kunden ab", async () => {
|
||||
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
|
||||
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } });
|
||||
const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KB" } });
|
||||
const { version } = await vorlageMitVersion({ kundeId: kundeB.id, name: "Nur für B" });
|
||||
|
||||
await expect(laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id }, admin)).rejects.toThrow(
|
||||
/anderen Kunden/,
|
||||
);
|
||||
expect((await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeB.id }, admin)).kundeId).toBe(
|
||||
kundeB.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("lässt einen Techniker nicht an den Lauf eines fremden Kunden", async () => {
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "laufzettel-";
|
||||
const { admin, techniker } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
|
||||
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } });
|
||||
const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KB" } });
|
||||
await testPrisma.benutzerKunde.create({
|
||||
data: { benutzerId: techniker.id, kundeId: kundeB.id },
|
||||
});
|
||||
const { version } = await vorlageMitVersion();
|
||||
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id }, admin);
|
||||
|
||||
// Kennung allein öffnet keinen fremden Mandanten – in jeder Funktion.
|
||||
await expect(laufZurBearbeitungOeffnen(lauf.id, techniker)).rejects.toThrow(/Berechtigung/);
|
||||
await expect(laufKopfdatenAendern(lauf.id, { ticket: "X" }, techniker)).rejects.toThrow(/Berechtigung/);
|
||||
await expect(laufAbschliessen(lauf.id, techniker)).rejects.toThrow(/Berechtigung/);
|
||||
await expect(kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Hallo" }, techniker)).rejects.toThrow(
|
||||
/Berechtigung/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { Markdown } from "@/components/markdown";
|
||||
* geoeffnet. Rohes HTML darin waere fremder Code in fremden Browsern; ueber den
|
||||
* JSON-Import kaeme er sogar von ausserhalb des Hauses. `react-markdown` laesst
|
||||
* HTML von sich aus fallen — dieser Test haelt fest, dass niemand spaeter
|
||||
* `rehype-raw` dazustellt, weil „der Kunde Tabellen in HTML schreibt".
|
||||
* `rehype-raw` dazustellt, weil „der Kunde Tabellen in HTML schreibt“.
|
||||
*
|
||||
* Gerendert wird mit `react-dom/server`, damit der Test ohne DOM-Umgebung laeuft.
|
||||
*/
|
||||
@@ -40,7 +40,7 @@ describe("Markdown im Hilfetext", () => {
|
||||
});
|
||||
|
||||
it("laesst weder Bilder mit Ereignisattribut noch eingebettete Rahmen durch", () => {
|
||||
// Geprueft wird die *Auszeichnung*, nicht das Wort: „onerror" steht danach
|
||||
// Geprueft wird die *Auszeichnung*, nicht das Wort: „onerror“ steht danach
|
||||
// noch im Text, aber maskiert – es wird angezeigt und nicht ausgefuehrt.
|
||||
const html = alsHtml('<img src=x onerror="alert(1)"> <iframe src="https://fremd.example"></iframe>');
|
||||
expect(html).not.toContain("<img");
|
||||
|
||||
@@ -161,15 +161,36 @@ describe("Generiertes Passwort", () => {
|
||||
expect(platzhalterErsetzen("Hallo {{vorname}}", WERTE).mitPasswort).toBe(false);
|
||||
});
|
||||
|
||||
it("erzeugt für zwei Vorkommen im selben Text zwei verschiedene Passwörter", () => {
|
||||
it("setzt in einem Text überall dasselbe Passwort ein", () => {
|
||||
/*
|
||||
* Fachlich fragwuerdig, aber die ehrliche Folge der Regel „bei jeder Anzeige
|
||||
* neu“: wer den Platzhalter zweimal in einen Befehl schreibt, meint
|
||||
* vermutlich dasselbe Passwort. Dass es zwei sind, faellt beim Lesen auf —
|
||||
* eines still zu speichern und wiederzuverwenden fiele nicht auf.
|
||||
* Der Fall, den der Review zu #013 gefunden hat: ein `New-ADUser` setzt das
|
||||
* Passwort und teilt es in derselben Zeile mit. Zwei verschiedene hiessen,
|
||||
* dass das Konto A bekommt und der Benutzer B erfaehrt — ein Konto, in das
|
||||
* niemand hineinkommt, und niemand sieht warum. Frueher stand hier ein Test,
|
||||
* der genau das festschrieb; er war falsch.
|
||||
*/
|
||||
const ergebnis = platzhalterErsetzen("{{generiertes_passwort}} und {{generiertes_passwort}}", {});
|
||||
const [erstes, zweites] = ergebnis.text.split(" und ");
|
||||
expect(erstes).not.toBe(zweites);
|
||||
expect(erstes).toBe(zweites);
|
||||
expect(erstes).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("erzeugt bei jedem Aufruf ein neues", () => {
|
||||
// „Bei jeder Anzeige neu“ bleibt: dieselbe Zeile, zweimal gefuellt, ergibt
|
||||
// zwei Passwoerter. Sonst waere es gespeichert, und genau das darf es nicht.
|
||||
const einmal = platzhalterErsetzen("{{generiertes_passwort}}", {}).text;
|
||||
const nochmal = platzhalterErsetzen("{{generiertes_passwort}}", {}).text;
|
||||
expect(einmal).not.toBe(nochmal);
|
||||
});
|
||||
|
||||
it("nimmt das Passwort des Seitenaufbaus, wenn eines hereingereicht wird", () => {
|
||||
// So haelt ein Lauf ueber alle seine Befehlsschritte hinweg dasselbe
|
||||
// Passwort — der Schritt „Konto anlegen“ und der Schritt „Passwort
|
||||
// uebergeben“ meinen dasselbe.
|
||||
const eines = platzhalterErsetzen("{{generiertes_passwort}}", {}, { art: "anzeige" }).text;
|
||||
const erster = platzhalterErsetzen("a {{generiertes_passwort}}", {}, { art: "anzeige" }, eines).text;
|
||||
const zweiter = platzhalterErsetzen("b {{generiertes_passwort}}", {}, { art: "anzeige" }, eines).text;
|
||||
expect(erster).toBe(`a ${eines}`);
|
||||
expect(zweiter).toBe(`b ${eines}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
Betriebsmodus,
|
||||
LaufStatus,
|
||||
Rolle,
|
||||
SchrittTyp,
|
||||
SchrittZustand,
|
||||
VersionStatus,
|
||||
} from "@/generated/prisma/enums";
|
||||
import { LaufFehler, laufAbschliessen, laufAnlegen } from "@/server/services/laeufe";
|
||||
import { abschnittNichtZutreffend, schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Schrittergebnisse (#013).
|
||||
*
|
||||
* Zwei Regeln stehen hier im Mittelpunkt. Die Notizpflicht bei
|
||||
* `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` — ohne sie steht im Archiv ein
|
||||
* übersprungener Schritt ohne Begründung, und das ist genau die Lücke, die ein
|
||||
* Archiv wertlos macht. Und die Abschnitts-Aktion, die achtzehn Haken mit
|
||||
* derselben Begründung erledigt, ohne die Arbeit zu löschen, die jemand schon
|
||||
* getan hat.
|
||||
*/
|
||||
|
||||
async function grundbestand() {
|
||||
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 adminZeile = await testPrisma.benutzer.create({
|
||||
data: { email: "admin@firma.example", name: "Alex Admin", rolle: Rolle.ADMIN },
|
||||
});
|
||||
const technikerZeile = await testPrisma.benutzer.create({
|
||||
data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER },
|
||||
});
|
||||
return {
|
||||
eigene,
|
||||
admin: { id: adminZeile.id, rolle: Rolle.ADMIN },
|
||||
techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine veröffentlichte Version mit zwei Abschnitten: „Vorbereitung“ mit einem
|
||||
* Pflicht-Haken und einem Zahlenschritt, „Konto im AD“ mit zwei Pflichtschritten
|
||||
* — der Abschnitt, der im Onboarding je nach Kunde gar nicht zutrifft.
|
||||
*/
|
||||
async function vorlageMitVersion() {
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
|
||||
const vorbereitung = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Vorbereitung", position: 0 },
|
||||
});
|
||||
const sicherung = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: vorbereitung.id, titel: "Datensicherung geprüft", pflicht: true, position: 0 },
|
||||
});
|
||||
const speicher = await testPrisma.vorlagenSchritt.create({
|
||||
data: {
|
||||
abschnittId: vorbereitung.id,
|
||||
titel: "Freier Speicher in GB",
|
||||
typ: SchrittTyp.ZAHL,
|
||||
pflicht: false,
|
||||
position: 1,
|
||||
konfig: { min: 20, max: 2000, einheit: "GB" },
|
||||
},
|
||||
});
|
||||
|
||||
const konto = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Konto im AD", position: 1 },
|
||||
});
|
||||
const kontoAngelegt = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: konto.id, titel: "Konto angelegt", pflicht: true, position: 0 },
|
||||
});
|
||||
const gruppen = await testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: konto.id, titel: "Gruppen zugewiesen", pflicht: true, position: 1 },
|
||||
});
|
||||
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
return {
|
||||
version,
|
||||
abschnitte: { vorbereitung, konto },
|
||||
schritte: { sicherung, speicher, kontoAngelegt, gruppen },
|
||||
pflichtIds: [sicherung.id, kontoAngelegt.id, gruppen.id],
|
||||
};
|
||||
}
|
||||
|
||||
async function eintraege(laufId: string, entitaet: string): Promise<readonly string[]> {
|
||||
const zeilen = await testPrisma.protokoll.findMany({
|
||||
where: { laufId, entitaet },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
});
|
||||
return zeilen.map((zeile) => zeile.aktion);
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Schrittergebnisse", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
});
|
||||
|
||||
describe("Notizpflicht", () => {
|
||||
// Akzeptanzkriterium 3
|
||||
it("lehnt NICHT_ZUTREFFEND ohne Notiz ab und nimmt es mit Notiz an", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await expect(
|
||||
schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.gruppen.id,
|
||||
zustand: SchrittZustand.NICHT_ZUTREFFEND,
|
||||
},
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(LaufFehler);
|
||||
await expect(
|
||||
schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.gruppen.id,
|
||||
zustand: SchrittZustand.NICHT_ZUTREFFEND,
|
||||
notiz: " ",
|
||||
},
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/Begründung/);
|
||||
|
||||
const ergebnis = await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.gruppen.id,
|
||||
zustand: SchrittZustand.NICHT_ZUTREFFEND,
|
||||
notiz: "Der Kunde pflegt Gruppen selbst.",
|
||||
},
|
||||
admin,
|
||||
);
|
||||
expect(ergebnis.zustand).toBe(SchrittZustand.NICHT_ZUTREFFEND);
|
||||
expect(ergebnis.notiz).toBe("Der Kunde pflegt Gruppen selbst.");
|
||||
expect(ergebnis.aktualisiertVonId).toBe(admin.id);
|
||||
});
|
||||
|
||||
it("verlangt auch bei FEHLGESCHLAGEN eine Notiz", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await expect(
|
||||
schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.kontoAngelegt.id,
|
||||
zustand: SchrittZustand.FEHLGESCHLAGEN,
|
||||
},
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/Notiz/);
|
||||
});
|
||||
|
||||
it("prüft den Zustand danach: eine vorhandene Notiz genügt, das Leeren nicht", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.gruppen.id,
|
||||
zustand: SchrittZustand.FEHLGESCHLAGEN,
|
||||
notiz: "Gruppenrichtlinie greift nicht.",
|
||||
},
|
||||
admin,
|
||||
);
|
||||
|
||||
// Wechsel des Zustands ohne Notiz-Feld: die vorhandene Begründung bleibt.
|
||||
const gewechselt = await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.gruppen.id,
|
||||
zustand: SchrittZustand.NICHT_ZUTREFFEND,
|
||||
},
|
||||
admin,
|
||||
);
|
||||
expect(gewechselt.notiz).toBe("Gruppenrichtlinie greift nicht.");
|
||||
|
||||
// Das Leeren im selben Zug fällt auf die Pflicht.
|
||||
await expect(
|
||||
schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.gruppen.id,
|
||||
zustand: SchrittZustand.NICHT_ZUTREFFEND,
|
||||
notiz: null,
|
||||
},
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/Begründung/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Setzen", () => {
|
||||
it("startet den Lauf beim ersten Schrittergebnis", async () => {
|
||||
const { admin, techniker } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
expect(lauf.gestartetAm).toBeNull();
|
||||
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT },
|
||||
techniker,
|
||||
);
|
||||
|
||||
const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
|
||||
expect(nachher.status).toBe(LaufStatus.IN_ARBEIT);
|
||||
expect(nachher.gestartetAm).not.toBeNull();
|
||||
expect(await eintraege(lauf.id, "Lauf")).toContain("Lauf gestartet");
|
||||
});
|
||||
|
||||
// Akzeptanzkriterium 7
|
||||
it("schreibt jede Änderung ins Protokoll – und eine Wiederholung nicht", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT },
|
||||
admin,
|
||||
);
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.OFFEN },
|
||||
admin,
|
||||
);
|
||||
// Zweimal dasselbe: ein Doppelklick soll keinen zweiten Eintrag ergeben.
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.OFFEN },
|
||||
admin,
|
||||
);
|
||||
|
||||
expect(await eintraege(lauf.id, "Schrittergebnis")).toEqual([
|
||||
"Schritt erledigt",
|
||||
"Schritt zurückgesetzt",
|
||||
]);
|
||||
|
||||
const letzter = await testPrisma.protokoll.findFirstOrThrow({
|
||||
where: { laufId: lauf.id, aktion: "Schritt zurückgesetzt" },
|
||||
});
|
||||
expect(letzter.vorher).toMatchObject({ zustand: SchrittZustand.ERLEDIGT });
|
||||
expect(letzter.nachher).toMatchObject({
|
||||
schritt: "Datensicherung geprüft",
|
||||
pflicht: true,
|
||||
zustand: SchrittZustand.OFFEN,
|
||||
});
|
||||
});
|
||||
|
||||
it("behält den Wert beim Zustandswechsel und löscht ihn nur auf Ansage", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
const mitWert = await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.speicher.id,
|
||||
zustand: SchrittZustand.ERLEDIGT,
|
||||
wert: { zahl: 512 },
|
||||
},
|
||||
admin,
|
||||
);
|
||||
expect(mitWert.wert).toEqual({ zahl: 512 });
|
||||
|
||||
const ohneWertfeld = await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: schritte.speicher.id, zustand: SchrittZustand.OFFEN },
|
||||
admin,
|
||||
);
|
||||
expect(ohneWertfeld.wert).toEqual({ zahl: 512 });
|
||||
|
||||
const geleert = await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.speicher.id,
|
||||
zustand: SchrittZustand.OFFEN,
|
||||
wert: null,
|
||||
},
|
||||
admin,
|
||||
);
|
||||
expect(geleert.wert).toBeNull();
|
||||
});
|
||||
|
||||
it("blockiert eine Zahl außerhalb von Min und Max nicht", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
// Der Auftrag sagt ausdrücklich: markieren, nicht blockieren. Ein Gerät mit
|
||||
// 4 GB frei ist eine Tatsache, und der Dienst hat sie aufzunehmen.
|
||||
const ergebnis = await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.speicher.id,
|
||||
zustand: SchrittZustand.ERLEDIGT,
|
||||
wert: { zahl: 4 },
|
||||
},
|
||||
admin,
|
||||
);
|
||||
expect(ergebnis.wert).toEqual({ zahl: 4 });
|
||||
});
|
||||
|
||||
it("lehnt einen Schritt ab, der zu diesem Lauf nicht gehört", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version } = await vorlageMitVersion();
|
||||
const fremde = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
// Ein Schritt aus einer anderen Version – in der Abarbeitungsansicht
|
||||
// tauchte er nie auf, im Archiv aber schon.
|
||||
await expect(
|
||||
schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: fremde.schritte.sicherung.id,
|
||||
zustand: SchrittZustand.ERLEDIGT,
|
||||
},
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/gehört nicht zu diesem Lauf/);
|
||||
await expect(
|
||||
schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: "gibt-es-nicht", zustand: SchrittZustand.ERLEDIGT },
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/gehört nicht zu diesem Lauf/);
|
||||
});
|
||||
|
||||
it("nimmt den Abschluss zurück, wenn ein Pflichtschritt wieder offen ist", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, schritte, pflichtIds } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
for (const vorlagenSchrittId of pflichtIds) {
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId, zustand: SchrittZustand.ERLEDIGT },
|
||||
admin,
|
||||
);
|
||||
}
|
||||
await laufAbschliessen(lauf.id, admin);
|
||||
|
||||
// Ein abgeschlossener Lauf bleibt änderbar – aber „abgeschlossen“ darf
|
||||
// danach nicht behaupten, alle Pflichtschritte seien erledigt.
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.OFFEN },
|
||||
admin,
|
||||
);
|
||||
|
||||
const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
|
||||
expect(nachher.status).toBe(LaufStatus.IN_ARBEIT);
|
||||
expect(nachher.abgeschlossenAm).toBeNull();
|
||||
expect(nachher.abgeschlossenVonId).toBeNull();
|
||||
expect(await eintraege(lauf.id, "Lauf")).toContain("Abschluss zurückgenommen");
|
||||
});
|
||||
|
||||
it("hält zwei Techniker am selben Lauf aus", async () => {
|
||||
const { admin, techniker } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await Promise.all([
|
||||
schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT },
|
||||
admin,
|
||||
),
|
||||
schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.kontoAngelegt.id,
|
||||
zustand: SchrittZustand.ERLEDIGT,
|
||||
},
|
||||
techniker,
|
||||
),
|
||||
]);
|
||||
|
||||
const erledigt = await testPrisma.schrittergebnis.count({
|
||||
where: { laufId: lauf.id, zustand: SchrittZustand.ERLEDIGT },
|
||||
});
|
||||
expect(erledigt).toBe(2);
|
||||
// Und nur ein „Lauf gestartet“, obwohl beide gleichzeitig angefangen haben.
|
||||
expect((await eintraege(lauf.id, "Lauf")).filter((a) => a === "Lauf gestartet")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hält zwei gleichzeitige Änderungen am selben Schritt aus", async () => {
|
||||
const { admin, techniker } = await grundbestand();
|
||||
const { version, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await Promise.all([
|
||||
schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT },
|
||||
admin,
|
||||
),
|
||||
schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.sicherung.id,
|
||||
zustand: SchrittZustand.FEHLGESCHLAGEN,
|
||||
notiz: "Sicherung schlug fehl.",
|
||||
},
|
||||
techniker,
|
||||
),
|
||||
]);
|
||||
|
||||
// Eine Zeile, nicht zwei: der eindeutige Index (laufId, vorlagenSchrittId)
|
||||
// und die Zeilensperre halten zusammen.
|
||||
const zeilen = await testPrisma.schrittergebnis.findMany({
|
||||
where: { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id },
|
||||
});
|
||||
expect(zeilen).toHaveLength(1);
|
||||
expect([SchrittZustand.ERLEDIGT, SchrittZustand.FEHLGESCHLAGEN]).toContain(zeilen[0]?.zustand);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Abschnitt als nicht zutreffend", () => {
|
||||
// Akzeptanzkriterium 8
|
||||
it("setzt alle Schritte des Abschnitts mit derselben Notiz", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, abschnitte, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
const ergebnis = await abschnittNichtZutreffend(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
abschnittId: abschnitte.konto.id,
|
||||
notiz: "Der Kunde verwaltet Konten selbst.",
|
||||
},
|
||||
admin,
|
||||
);
|
||||
expect(ergebnis.gesetzt).toBe(2);
|
||||
expect(ergebnis.uebersprungen).toEqual([]);
|
||||
|
||||
const zeilen = await testPrisma.schrittergebnis.findMany({
|
||||
where: {
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: { in: [schritte.kontoAngelegt.id, schritte.gruppen.id] },
|
||||
},
|
||||
});
|
||||
expect(zeilen).toHaveLength(2);
|
||||
for (const zeile of zeilen) {
|
||||
expect(zeile.zustand).toBe(SchrittZustand.NICHT_ZUTREFFEND);
|
||||
expect(zeile.notiz).toBe("Der Kunde verwaltet Konten selbst.");
|
||||
}
|
||||
|
||||
// Der andere Abschnitt bleibt unberührt.
|
||||
const sicherung = await testPrisma.schrittergebnis.findFirstOrThrow({
|
||||
where: { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id },
|
||||
});
|
||||
expect(sicherung.zustand).toBe(SchrittZustand.OFFEN);
|
||||
|
||||
// Je geänderter Schritt ein Protokolleintrag.
|
||||
expect(await eintraege(lauf.id, "Schrittergebnis")).toEqual([
|
||||
"Abschnitt als nicht zutreffend eingetragen",
|
||||
"Abschnitt als nicht zutreffend eingetragen",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lässt erledigte Schritte erledigt und meldet sie", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, abschnitte, schritte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.kontoAngelegt.id,
|
||||
zustand: SchrittZustand.ERLEDIGT,
|
||||
},
|
||||
admin,
|
||||
);
|
||||
await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schritte.gruppen.id,
|
||||
zustand: SchrittZustand.FEHLGESCHLAGEN,
|
||||
notiz: "Gruppen fehlen im AD.",
|
||||
},
|
||||
admin,
|
||||
);
|
||||
|
||||
const ergebnis = await abschnittNichtZutreffend(
|
||||
{ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Kunde macht das selbst." },
|
||||
admin,
|
||||
);
|
||||
// „Erledigt“ ist eine Tatsache mit Namen und Zeitstempel; „nicht
|
||||
// zutreffend“ darüberzuschreiben wäre deren Verlust.
|
||||
expect(ergebnis.uebersprungen).toEqual(["Konto angelegt"]);
|
||||
expect(ergebnis.gesetzt).toBe(1);
|
||||
|
||||
const angelegt = await testPrisma.schrittergebnis.findFirstOrThrow({
|
||||
where: { laufId: lauf.id, vorlagenSchrittId: schritte.kontoAngelegt.id },
|
||||
});
|
||||
expect(angelegt.zustand).toBe(SchrittZustand.ERLEDIGT);
|
||||
|
||||
// Ein fehlgeschlagener Schritt wird dagegen überschrieben.
|
||||
const gruppen = await testPrisma.schrittergebnis.findFirstOrThrow({
|
||||
where: { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id },
|
||||
});
|
||||
expect(gruppen.zustand).toBe(SchrittZustand.NICHT_ZUTREFFEND);
|
||||
expect(gruppen.notiz).toBe("Kunde macht das selbst.");
|
||||
});
|
||||
|
||||
it("verlangt eine Notiz und lehnt einen fremden Abschnitt ab", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, abschnitte } = await vorlageMitVersion();
|
||||
const fremde = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await expect(
|
||||
abschnittNichtZutreffend({ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: " " }, admin),
|
||||
).rejects.toThrow(/Begründung/);
|
||||
await expect(
|
||||
abschnittNichtZutreffend(
|
||||
{ laufId: lauf.id, abschnittId: fremde.abschnitte.konto.id, notiz: "Trifft nicht zu." },
|
||||
admin,
|
||||
),
|
||||
).rejects.toThrow(/gehört nicht zu diesem Lauf/);
|
||||
|
||||
expect(
|
||||
await testPrisma.protokoll.count({ where: { laufId: lauf.id, entitaet: "Schrittergebnis" } }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("schreibt beim zweiten Mal mit derselben Notiz nichts nach", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const { version, abschnitte } = await vorlageMitVersion();
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
|
||||
|
||||
await abschnittNichtZutreffend(
|
||||
{ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Trifft hier nicht zu." },
|
||||
admin,
|
||||
);
|
||||
const zweites = await abschnittNichtZutreffend(
|
||||
{ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Trifft hier nicht zu." },
|
||||
admin,
|
||||
);
|
||||
expect(zweites.gesetzt).toBe(0);
|
||||
expect(await eintraege(lauf.id, "Schrittergebnis")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Betriebsmodus, LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { laufZumAbarbeiten, laufZustand } from "@/server/queries/laeufe";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Der Zustand fuer die Aktualisierung alle zehn Sekunden (#013).
|
||||
*
|
||||
* Zwei Zusagen haengen daran: zwei Browser am selben Lauf sehen die Aenderung
|
||||
* des anderen — und die Antwort enthaelt **keine Befehle**, weil ein
|
||||
* `{{generiertes_passwort}}` bei jeder Aufloesung neu entsteht und dem Techniker
|
||||
* sonst unter den Haenden wegtauschte.
|
||||
*/
|
||||
|
||||
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 vorlage = await testPrisma.vorlage.create({ data: { name: "PC" } });
|
||||
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: "Installation", position: 0 },
|
||||
});
|
||||
const schritte = await Promise.all([
|
||||
testPrisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Pflicht", pflicht: true, position: 0 },
|
||||
}),
|
||||
testPrisma.vorlagenSchritt.create({
|
||||
data: {
|
||||
abschnittId: abschnitt.id,
|
||||
titel: "Konto",
|
||||
typ: "BEFEHL",
|
||||
pflicht: true,
|
||||
position: 1,
|
||||
konfig: { shell: "powershell", befehl: '-Password "{{generiertes_passwort}}"' },
|
||||
},
|
||||
}),
|
||||
/*
|
||||
* Ein zweiter Befehl mit demselben Platzhalter – der Fall, an dem der Review
|
||||
* zu #013 den Blocker gefunden hat: anlegen und uebergeben sind zwei
|
||||
* Schritte und muessen dasselbe Passwort meinen.
|
||||
*/
|
||||
testPrisma.vorlagenSchritt.create({
|
||||
data: {
|
||||
abschnittId: abschnitt.id,
|
||||
titel: "Passwort übergeben",
|
||||
typ: "BEFEHL",
|
||||
position: 2,
|
||||
konfig: { shell: "powershell", befehl: 'Write-Host "{{generiertes_passwort}}"' },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: { vorlagenVersionId: version.id, titel: "PC-001", kundeId: eigene.id },
|
||||
});
|
||||
await testPrisma.schrittergebnis.createMany({
|
||||
data: schritte.map((schritt) => ({ laufId: lauf.id, vorlagenSchrittId: schritt.id })),
|
||||
});
|
||||
|
||||
return { eigene, lauf, schritte, benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Zustand eines Laufs", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
it("zählt die offenen Pflichtschritte", async () => {
|
||||
const { lauf, schritte, benutzer } = await aufbau();
|
||||
expect((await laufZustand(lauf.id, benutzer))?.anzahlPflichtOffen).toBe(2);
|
||||
|
||||
await testPrisma.schrittergebnis.updateMany({
|
||||
where: { laufId: lauf.id, vorlagenSchrittId: schritte[0]!.id },
|
||||
data: { zustand: SchrittZustand.ERLEDIGT },
|
||||
});
|
||||
expect((await laufZustand(lauf.id, benutzer))?.anzahlPflichtOffen).toBe(1);
|
||||
});
|
||||
|
||||
it("zählt einen nicht zutreffenden Schritt als vom Tisch", async () => {
|
||||
// „Nicht zutreffend“ heisst: bewusst uebersprungen, mit Begruendung. Als
|
||||
// offen zu zaehlen hiesse, den Abschluss zu verhindern, den die Regel
|
||||
// ausdruecklich erlaubt.
|
||||
const { lauf, schritte, benutzer } = await aufbau();
|
||||
await testPrisma.schrittergebnis.updateMany({
|
||||
where: { laufId: lauf.id, vorlagenSchrittId: schritte[0]!.id },
|
||||
data: { zustand: SchrittZustand.NICHT_ZUTREFFEND, notiz: "kein AD-Konto nötig" },
|
||||
});
|
||||
expect((await laufZustand(lauf.id, benutzer))?.anzahlPflichtOffen).toBe(1);
|
||||
});
|
||||
|
||||
it("zählt einen fehlgeschlagenen Schritt **nicht** als vom Tisch", async () => {
|
||||
const { lauf, schritte, benutzer } = await aufbau();
|
||||
await testPrisma.schrittergebnis.updateMany({
|
||||
where: { laufId: lauf.id, vorlagenSchrittId: schritte[0]!.id },
|
||||
data: { zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "Server antwortet nicht" },
|
||||
});
|
||||
expect((await laufZustand(lauf.id, benutzer))?.anzahlPflichtOffen).toBe(2);
|
||||
});
|
||||
|
||||
it("liefert, wer wann bearbeitet hat", async () => {
|
||||
// Zwei Browser am selben Lauf sollen sehen, dass der andere etwas getan hat
|
||||
// — und wer es war.
|
||||
const { lauf, schritte, benutzer } = await aufbau();
|
||||
await testPrisma.schrittergebnis.updateMany({
|
||||
where: { laufId: lauf.id, vorlagenSchrittId: schritte[0]!.id },
|
||||
data: { zustand: SchrittZustand.ERLEDIGT, aktualisiertVonId: benutzer.id },
|
||||
});
|
||||
const zustand = await laufZustand(lauf.id, benutzer);
|
||||
const schritt = zustand?.schritte.find((eintrag) => eintrag.schrittId === schritte[0]!.id);
|
||||
expect(schritt?.bearbeitetVon).toBe("Ali Demir");
|
||||
expect(schritt?.bearbeitetAm).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("enthält keinen einzigen Befehl", async () => {
|
||||
/*
|
||||
* Die Zusage, an der alles haengt: ein `{{generiertes_passwort}}` entsteht
|
||||
* bei jeder Aufloesung neu. Kaeme der Befehl alle zehn Sekunden mit, taeusche
|
||||
* die Anzeige ein Passwort vor, das schon nicht mehr gilt — und der
|
||||
* Techniker tippt es gerade ab.
|
||||
*/
|
||||
const { lauf, benutzer } = await aufbau();
|
||||
const zustand = await laufZustand(lauf.id, benutzer);
|
||||
const alsText = JSON.stringify(zustand);
|
||||
expect(alsText).not.toContain("Password");
|
||||
expect(alsText).not.toContain("{{");
|
||||
expect(Object.keys(zustand ?? {}).sort()).toEqual([
|
||||
"anzahlKommentare",
|
||||
"anzahlPflichtOffen",
|
||||
"geaendertAm",
|
||||
"schritte",
|
||||
"status",
|
||||
]);
|
||||
});
|
||||
|
||||
it("meldet auch einen Kommentar und eine Änderung an den Kopfdaten", async () => {
|
||||
/*
|
||||
* „Zwei Browser sehen die Arbeit des anderen“ galt vorher nur fuer
|
||||
* Schrittzustaende: ein Kommentar des Kollegen und ein geaenderter Titel
|
||||
* blieben unsichtbar, bis jemand von sich aus neu lud (Reviewbefund zu
|
||||
* #013). Der Client vergleicht die Antwort als Ganzes — was hier fehlt,
|
||||
* loest keine Aktualisierung aus.
|
||||
*/
|
||||
const { lauf, benutzer } = await aufbau();
|
||||
const vorher = JSON.stringify(await laufZustand(lauf.id, benutzer));
|
||||
|
||||
await testPrisma.kommentar.create({
|
||||
data: { laufId: lauf.id, benutzerId: benutzer.id, rumpf: "Rückfrage ans Team" },
|
||||
});
|
||||
const mitKommentar = JSON.stringify(await laufZustand(lauf.id, benutzer));
|
||||
expect(mitKommentar).not.toBe(vorher);
|
||||
|
||||
await testPrisma.lauf.update({ where: { id: lauf.id }, data: { titel: "PC-001 (neuer Titel)" } });
|
||||
expect(JSON.stringify(await laufZustand(lauf.id, benutzer))).not.toBe(mitKommentar);
|
||||
});
|
||||
|
||||
it("liefert den Status mit", async () => {
|
||||
const { lauf, benutzer } = await aufbau();
|
||||
expect((await laufZustand(lauf.id, benutzer))?.status).toBe(LaufStatus.OFFEN);
|
||||
});
|
||||
|
||||
it("gibt einen Lauf eines fremden Kunden nicht heraus", async () => {
|
||||
const { lauf, benutzer } = await aufbau();
|
||||
const fremder = await testPrisma.kunde.create({ data: { name: "Fremd", kuerzel: "FRD" } });
|
||||
await testPrisma.einstellung.update({
|
||||
where: { id: "global" },
|
||||
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
|
||||
});
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
|
||||
await testPrisma.benutzerKunde.create({ data: { benutzerId: benutzer.id, kundeId: fremder.id } });
|
||||
|
||||
expect(await laufZustand(lauf.id, benutzer)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("liefert nichts für einen Lauf, den es nicht gibt", async () => {
|
||||
const { benutzer } = await aufbau();
|
||||
expect(await laufZustand("gibtsnicht", benutzer)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Ein Lauf, ein Passwort je Seitenaufbau", () => {
|
||||
beforeEach(datenbankLeeren);
|
||||
|
||||
it("setzt in alle Befehle einer Seite dasselbe Passwort", async () => {
|
||||
/*
|
||||
* Der Blocker aus dem Review zu #013. Ein Onboarding legt das Konto in einem
|
||||
* Schritt an und uebergibt das Passwort im naechsten; standen dort zwei
|
||||
* verschiedene, kam der Benutzer nicht in sein Konto — und niemand konnte
|
||||
* sagen, welches das richtige war, weil keines gespeichert wird.
|
||||
*/
|
||||
const { lauf, benutzer } = await aufbau();
|
||||
|
||||
const daten = await laufZumAbarbeiten(lauf.id, benutzer);
|
||||
const befehle = (daten?.abschnitte ?? []).flatMap((a) =>
|
||||
a.schritte.map((schritt) => schritt.befehl?.text).filter((text): text is string => !!text),
|
||||
);
|
||||
expect(befehle).toHaveLength(2);
|
||||
|
||||
const passwoerter = befehle.map((text) => /"([^"]+)"/u.exec(text)?.[1]);
|
||||
expect(passwoerter[0]).toHaveLength(20);
|
||||
expect(passwoerter[0]).toBe(passwoerter[1]);
|
||||
// Und das Skript des Abschnitts traegt dasselbe, nicht ein drittes.
|
||||
expect(daten?.abschnitte[0]?.skript).toContain(passwoerter[0]);
|
||||
});
|
||||
|
||||
it("erzeugt beim nächsten Aufbau ein anderes", async () => {
|
||||
// Die Regel „bei jeder Anzeige neu“ bleibt: gespeichert wird nichts.
|
||||
const { lauf, benutzer } = await aufbau();
|
||||
const einmal = (await laufZumAbarbeiten(lauf.id, benutzer))?.abschnitte[0]?.schritte[1]?.befehl?.text;
|
||||
const nochmal = (await laufZumAbarbeiten(lauf.id, benutzer))?.abschnitte[0]?.schritte[1]?.befehl?.text;
|
||||
expect(einmal).toBeDefined();
|
||||
expect(einmal).not.toBe(nochmal);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Die zweite Haelfte derselben Zusage (#013).
|
||||
*
|
||||
* Dass die Polling-Antwort keine Befehle traegt, nuetzt nichts, wenn das Polling
|
||||
* stattdessen die ganze Seite neu bauen laesst: beim naechsten Aufbau entsteht
|
||||
* ein anderes `{{generiertes_passwort}}` als das, das der Techniker gerade
|
||||
* gelesen hat. Steht eines auf der Seite, wird deshalb gefragt statt geladen.
|
||||
*/
|
||||
describe("Die Aktualisierung tauscht kein angezeigtes Passwort aus", () => {
|
||||
it("fragt, wenn ein Passwort auf der Seite steht, und laedt sonst von selbst", async () => {
|
||||
const { reaktionAufAenderung } = await import("@/components/laeufe/lauf-aktualisierung");
|
||||
expect(reaktionAufAenderung(true)).toBe("fragen");
|
||||
expect(reaktionAufAenderung(false)).toBe("neu-laden");
|
||||
});
|
||||
|
||||
it("reicht die Angabe von der Laufseite durch", async () => {
|
||||
/*
|
||||
* Ein Blick in den Quelltext, und zwar bewusst: fuer Client-Komponenten gibt
|
||||
* es in diesem Projekt keine DOM-Umgebung, und die Entscheidung oben waere
|
||||
* wertlos, wenn die Seite sie nie mitgibt. Der Test prueft genau die
|
||||
* Verdrahtung — nicht mehr, aber auch nicht weniger.
|
||||
*/
|
||||
const quelle = await readFile(new URL("../src/app/(app)/laeufe/[id]/page.tsx", import.meta.url), "utf8");
|
||||
expect(quelle).toContain("passwortSichtbar={passwortSichtbar}");
|
||||
expect(quelle).toMatch(/passwortSichtbar\s*=\s*abschnitte\.some\(/u);
|
||||
expect(quelle).toContain("mitPasswort === true");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user