Files
Laufzettel/tests/e2e/hauptablauf.spec.ts
T
florian.wilde fbbb634e56
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
2026-09-26 09:04:15 +00:00

333 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Locator, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { anmelden, geraetName, SEED_VORLAGE, variablenFuellen, vorlageWaehlen } from "./helfer";
/**
* Der Hauptablauf des ganzen Auftrags, in einem Zug (#018).
*
* Anmelden → „PC Neuinstallation“ für das Beispielgerät starten → abarbeiten →
* einen Schritt mit Begründung überspringen → abschließen → archivieren → im
* Archiv über ein Wort aus der Begründung wiederfinden → und danach ändert sich
* nichts mehr.
*
* Zwei Dinge unterscheiden diesen Ablauf von einem Klickprotokoll:
*
* 1. **Geprüft wird gegen die Datenbank.** Nach jeder Änderung wird neu geladen
* und in der Datenbank nachgesehen. Ein Klick, der nur im Browser wirkt, sieht
* auf einem Screenshot genauso aus wie einer, der gespeichert wurde — und
* genau dieser Unterschied ist der Grund, warum es diese Datei gibt
* (CLAUDE.md, „Oberfläche prüfen“).
* 2. **Die Absage wird erzwungen.** „Nicht zutreffend“ ohne Begründung sperrt die
* Oberfläche nur über einen deaktivierten Knopf. Ein deaktivierter Knopf ist
* keine Zusage: dieser Ablauf entfernt ihn und schickt trotzdem ab, damit die
* Absage vom **Server** bewiesen ist und nicht von einem HTML-Attribut.
*
* Der Ablauf legt an, was er braucht (einen eigenen Lauf mit `ZZ E2E `-Titel) und
* räumt ihn hinterher weg. Vorausgesetzt wird nur der Seed-Bestand, den
* `global-setup.ts` prüft.
*/
const STEMPEL = Date.now();
const TITEL = `ZZ E2E Hauptablauf ${STEMPEL}`;
/** Der Schritt, der übersprungen wird — Pflicht, damit auch der Abschluss etwas davon hat. */
const UEBERSPRUNGEN = "Secure Boot und TPM 2.0 aktiv";
/** Der Schritt, in dem ein Wert erfasst wird. Typ TEXT in der Seed-Vorlage. */
const WERTSCHRITT = "Sondersoftware und Lizenzen des Benutzers notiert";
const WERT = `ZZ E2E Adobe Acrobat ${STEMPEL}`;
/**
* Das Wertfeld eines Schritts – ohne Annahme über das Element.
*
* `TEXT` mit `mehrzeilig` ist ein `textarea`, alles andere ein `input`. Ein
* `input[name="wert"]` lief deshalb in den Zeitablauf, und die Meldung sprach von
* einem fehlenden Element statt von der falschen Annahme.
*/
const WERTFELD = '[name="wert"]';
/**
* Ein Wort, das es in der ganzen Datenbank nur einmal gibt.
*
* Über dieses Wort wird der Lauf später im Archiv wiedergefunden. Ohne Stempel
* fände die Suche auch die Notizen früherer Durchläufe, und der Treffer sagte
* nichts mehr über diesen hier.
*/
const NOTIZWORT = `Fabrikneu${STEMPEL}`;
const NOTIZ = `${NOTIZWORT}: das Gerät kam versiegelt aus dem Karton, TPM war ab Werk aktiv.`;
/**
* Legt den Lauf über die Oberfläche an und gibt seine Adresse zurück.
*
* Bewusst nicht über Prisma: das Anlegen **ist** Teil des Hauptablaufs. Die
* Vorlage wird am Namen gewählt (`vorlageWaehlen`), das Gerät am angezeigten
* Namen — in beiden Betriebsmodi, denn im Modus DIENSTLEISTUNG heißt das
* Beispielgerät anders und der Dialog fragt zusätzlich nach dem Kunden.
*/
async function laufStarten(seite: Page): Promise<string> {
await seite.goto("/laeufe/starten");
await vorlageWaehlen(seite, SEED_VORLAGE);
const gesucht = geraetName();
const geraete = await seite
.locator('select[name="geraetId"] option')
.evaluateAll((liste) =>
liste
.filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement)
.map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })),
);
const treffer = geraete.find((eintrag) => eintrag.text.includes(gesucht));
if (!treffer) {
throw new Error(
`Das Gerät „${gesucht}“ steht nicht zur Auswahl. Zur Wahl stehen: ` +
`${geraete.map((eintrag) => eintrag.text).join(", ")}`,
);
}
await seite.selectOption('select[name="geraetId"]', treffer.wert);
/*
* Im Modus DIENSTLEISTUNG verlangt der Dialog einen Kunden, und die Datenbank
* verlangt, dass es **der Kunde des Geräts** ist: ein zusammengesetzter
* Fremdschlüssel lässt keinen Lauf mit fremdem Gerät zu. Ein „nimm den
* ersten“ wäre also nicht nur ungenau, sondern im Zweifel rot.
*/
const kundenWahl = seite.locator('select[name="kundeId"]');
if ((await kundenWahl.count()) > 0) {
const geraet = await prisma.geraet.findFirst({
where: { OR: [{ hostname: gesucht }, { bezeichnung: gesucht }] },
select: { kunde: { select: { name: true } } },
});
if (!geraet) throw new Error(`Das Gerät „${gesucht}“ steht nicht in der Datenbank.`);
await kundenWahl.selectOption({ label: geraet.kunde.name });
}
const weitere = seite.locator("details").filter({ hasText: "Titel, Ticket" });
await weitere.locator("summary").click();
await weitere.locator('input[name="titel"]').fill(TITEL);
await variablenFuellen(seite);
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 60_000 });
return new URL(seite.url()).pathname;
}
/** Die Abschnitte der Abarbeitungsansicht – am aufklappbaren Kopf erkannt. */
function abschnitteVon(seite: Page): Locator {
return seite.locator("section").filter({ has: seite.locator("button[aria-expanded]") });
}
test.describe("Hauptablauf", () => {
test("starten, abarbeiten, abschließen, archivieren, wiederfinden", async ({ page }) => {
/*
* Die Seed-Vorlage hat über dreißig Schritte, und jeder speichert einzeln —
* so arbeitet ein Techniker, und so soll es geprüft werden. Das dauert; die
* Voreinstellung von 30 Sekunden reicht dafür nicht.
*/
test.setTimeout(420_000);
await anmelden(page);
const pfad = await laufStarten(page);
const laufId = pfad.split("/").pop() as string;
// Ein neuer Lauf ist **offen**: das ist die Zuweisung, nicht der Start.
expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("OFFEN");
await page.getByRole("button", { name: "Lauf beginnen" }).click();
const abschliessen = page.getByRole("button", { name: "Lauf abschließen" });
await expect(abschliessen).toBeVisible();
await expect(abschliessen).toBeDisabled();
// ---- Ein Wert in einem Textschritt --------------------------------------
const wertzeile = page.getByRole("listitem").filter({ hasText: WERTSCHRITT });
await wertzeile.locator(WERTFELD).fill(WERT);
await wertzeile.getByRole("button", { name: "Wert speichern" }).click();
/*
* Auf die **Datenbank** warten, nicht auf den Bildschirm: das Feld trägt den
* Wert schon, bevor irgendetwas gespeichert ist — es ist ein kontrolliertes
* Eingabefeld. Und ein `reload` mitten in der Aktion bricht sie ab, also erst
* warten, dann neu laden.
*/
await expect
.poll(
async () =>
(
await prisma.schrittergebnis.findFirst({
where: { laufId, vorlagenSchritt: { titel: WERTSCHRITT } },
select: { wert: true },
})
)?.wert,
{ timeout: 30_000, message: `Der Wert des Schritts „${WERTSCHRITT}“ steht nicht in der Datenbank.` },
)
.toBe(WERT);
await page.reload();
await expect(wertzeile.locator(WERTFELD)).toHaveValue(WERT);
// ---- „Nicht zutreffend“ verlangt eine Begründung ------------------------
const zeile = page.getByRole("listitem").filter({ hasText: UEBERSPRUNGEN });
const pille = zeile.getByRole("button", { name: "nicht zutreffend" });
await pille.click();
const mitBegruendung = zeile.getByRole("button", { name: "Mit Begründung speichern" });
await expect(mitBegruendung).toBeVisible();
await expect(mitBegruendung).toBeDisabled();
/*
* Und jetzt der eigentliche Nachweis: der gesperrte Knopf wird entsperrt und
* das Pflichtfeld entschärft, dann wird abgeschickt. Ein deaktivierter Knopf
* ist Bequemlichkeit — die Zusage („im Archiv steht kein übersprungener
* Schritt ohne Grund“) muss der Server halten, und nur so ist bewiesen, dass
* er es tut.
*/
await zeile.locator('textarea[name="notiz"]').evaluate((feld) => feld.removeAttribute("required"));
await mitBegruendung.evaluate((knopf) => knopf.removeAttribute("disabled"));
await mitBegruendung.click();
await expect(zeile.getByRole("alert")).toContainText("braucht eine Begründung");
expect(await prisma.schrittergebnis.count({ where: { laufId, zustand: "NICHT_ZUTREFFEND" } })).toBe(0);
// Mit Begründung geht es – und danach steht sie in der Datenbank.
await zeile.locator('textarea[name="notiz"]').fill(NOTIZ);
await zeile.getByRole("button", { name: "Mit Begründung speichern" }).click();
await expect(pille).toHaveAttribute("aria-pressed", "true");
await expect
.poll(
async () =>
prisma.schrittergebnis.findFirst({
where: { laufId, vorlagenSchritt: { titel: UEBERSPRUNGEN } },
select: { zustand: true, notiz: true },
}),
{ timeout: 30_000, message: `Der Schritt „${UEBERSPRUNGEN}“ steht nicht als übersprungen da.` },
)
.toEqual({ zustand: "NICHT_ZUTREFFEND", notiz: NOTIZ });
await page.reload();
await expect(zeile.locator('textarea[name="notiz"]')).toHaveValue(NOTIZ);
// ---- Den Rest abhaken --------------------------------------------------
const abschnitte = abschnitteVon(page);
const anzahlAbschnitte = await abschnitte.count();
expect(anzahlAbschnitte).toBeGreaterThan(0);
for (let nummer = 0; nummer < anzahlAbschnitte; nummer += 1) {
const abschnitt = abschnitte.nth(nummer);
const kopf = abschnitt.locator("button[aria-expanded]").first();
// Nach dem Neuladen ist zu, was fertig ist – erst aufklappen.
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
const zeilen = abschnitt.locator("> ul > li");
const anzahlZeilen = await zeilen.count();
for (let stelle = 0; stelle < anzahlZeilen; stelle += 1) {
const schritt = zeilen.nth(stelle);
// Was schon vom Tisch ist, bleibt es: ein zweiter Klick nähme es zurück.
if ((await schritt.locator('button[aria-pressed="true"]').count()) > 0) continue;
const haken = schritt.getByRole("button", { name: "erledigt", exact: true });
if ((await haken.count()) === 0) continue;
await haken.click();
// Auf den **gespeicherten** Stand warten, nicht auf den nächsten Klick:
// ohne das trifft er einen Knopf aus dem alten Baum.
await expect(haken).toHaveAttribute("aria-pressed", "true");
}
}
// ---- Abschließen und archivieren ---------------------------------------
await expect(abschliessen).toBeEnabled();
await abschliessen.click();
const archivieren = page.getByRole("button", { name: "archivieren" });
await expect(archivieren).toBeVisible();
expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("ABGESCHLOSSEN");
await archivieren.click();
await page.getByRole("button", { name: "Ja, archivieren" }).click();
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("ARCHIVIERT");
// ---- Im Archiv wiederfinden --------------------------------------------
await page.goto("/archiv");
await page.getByLabel("Suchbegriff").fill(NOTIZWORT);
await page.getByRole("button", { name: "Suchen" }).click();
const treffer = page.getByRole("listitem").filter({ hasText: TITEL });
await expect(treffer).toHaveCount(1);
// Und der Grund steht dabei – ein Treffer ohne sichtbare Fundstelle ist wertlos.
await expect(treffer.getByText("Notiz")).toBeVisible();
await expect(treffer.getByText(new RegExp(NOTIZWORT))).toBeVisible();
// ---- Und danach greift der Schreibschutz -------------------------------
await treffer.getByRole("link").first().click();
await page.waitForURL(`**/laeufe/${laufId}`);
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
/*
* **Erst aufklappen, dann prüfen.** An einem abgeschlossenen Lauf ist jeder
* Abschnitt zugeklappt, und in einem zugeklappten Abschnitt steht überhaupt
* nichts im Baum. Ein „keine Knöpfe gefunden“ wäre dort kein Nachweis für den
* Schreibschutz, sondern nur einer dafür, dass niemand hingesehen hat.
*/
const archiviert = abschnitteVon(page);
const anzahlArchiviert = await archiviert.count();
expect(anzahlArchiviert).toBeGreaterThan(0);
for (let nummer = 0; nummer < anzahlArchiviert; nummer += 1) {
const kopf = archiviert.nth(nummer).locator("button[aria-expanded]").first();
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
await expect(kopf).toHaveAttribute("aria-expanded", "true");
}
for (const knopf of [
"erledigt",
"nicht zutreffend",
"fehlgeschlagen",
"Wert speichern",
"Kommentar hinzufügen",
"Abschnitt trifft nicht zu",
"Lauf beginnen",
"Lauf abschließen",
]) {
await expect(
page.getByRole("button", { name: knopf, exact: true }),
`Am archivierten Lauf steht noch der Knopf „${knopf}“.`,
).toHaveCount(0);
}
/*
* Genau **ein** Formular bleibt stehen, und das ist das Zurückholen: das Archiv
* ist die Zusage, dass sich ein abgeschlossener Lauf nicht mehr ändert, und nur
* ein Administrator darf sie aufheben. Gezählt wird deshalb auf eins und nicht
* auf null — sonst hieße „grün“ hier irgendwann, dass auch der eine legitime
* Weg verschwunden ist.
*/
await expect(page.getByRole("button", { name: "aus dem Archiv zurückholen" })).toBeVisible();
await expect(page.locator("main form")).toHaveCount(1);
/*
* Der Wert und die Begründung stehen weiter da – lesbar, nur nicht änderbar.
* Ein Archiv, das seine Inhalte beim Sperren verliert, wäre kein Archiv.
*/
await expect(page.getByText(WERT)).toBeVisible();
await expect(page.getByText(NOTIZ)).toBeVisible();
});
});
test.afterAll(async () => {
/*
* Aufgeräumt wird über die Datenbank: für einen Lauf gibt es in der Anwendung
* keinen Löschweg, und das ist richtig so. `Protokoll.laufId` steht auf
* `Restrict` — ohne diese Zeile bleibt der Lauf stehen und der Fremdschlüssel
* meldet sich.
*/
const laeufe = await prisma.lauf.findMany({
where: { titel: { startsWith: "ZZ E2E Hauptablauf" } },
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 } } });
await prisma.protokoll.deleteMany({
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
});
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
}
await prisma.$disconnect();
});