Files
Laufzettel/tests/e2e/laeufe.spec.ts
T
Florian WildeandClaude Opus 5 10e272654f
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme
Neun Kapitel unter docs/anleitung/ fuer Technik und Verwaltung, mit 35
Bildern aus `pnpm docs:screenshots` und einem Fehlerbehebungskapitel aus
echten Meldungen. `pnpm docs:pdf` macht daraus 72 Seiten A4 — hell
gedruckt, mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen, ohne neue
Abhaengigkeit.

Die Ablaeufe pruefen ihre Voraussetzungen, bevor der erste startet:
antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der
Server dieselbe Datenbank wie der Testprozess. Jeder Ablauf legt an, was
er braucht, und raeumt es weg. Vier neue kommen dazu: Hauptablauf,
Onboarding mit Vergleich der Zwischenablage, Rollen und 390 px auf
achtzehn Seiten.

Drei Fehler kamen dabei heraus. Das Projekt „handy" pruefte 412 px statt
der verlangten 390. Ein angemeldeter Benutzer ohne Recht erfuhr nie,
warum er abgewiesen wurde — die Meldung stand auf einer Seite, die ihn
sofort weiterleitet; jetzt gibt es /kein-zugriff. Und der
Verzeichnis-Ablauf schrieb die Attributzuordnung der ersten vorhandenen
Quelle um, in einer echten Installation also der produktiven.

101 Ablaeufe in INTERN und 97 in DIENSTLEISTUNG, beide ohne Fehlschlag.

Closes #18

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 11:03:02 +02:00

429 lines
18 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 Page } from "@playwright/test";
import "dotenv/config";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
SEED_GERAET,
SEED_TAG,
SEED_VORLAGE,
anmelden,
kundeWaehlen,
variablenFuellen,
vorlageWaehlen,
} from "./helfer";
/**
* 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.
*
* Voraussetzungen prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf: ein
* **gebauter** Stand antwortet, `SEED_VORLAGE` ist veroeffentlicht, das
* Beispielgeraet steht, und der Server liest dieselbe Datenbank wie dieser Lauf.
* Gearbeitet wird mit dem Bestand aus `pnpm db:seed` — die Vorlage aus
* `pnpm beispiel:vorlage` braucht hier niemand mehr (#018).
*/
const STEMPEL = Date.now();
const TITEL = `ZZ E2E Lauf ${STEMPEL}`;
/**
* Das Beispielgeraet, geholt ueber den **Tag-Code**.
*
* Nicht ueber `{ index: 1 }` und nicht ueber den Hostnamen: die Position in der
* Liste haengt an der Sortierung, und „PC-001“ gibt es in einer benutzten
* Datenbank mehr als einmal. Der Tag-Code gehoert genau einem Geraet — und dass
* es dieses gibt, prueft der Global-Setup.
*/
async function seedGeraetId(): Promise<string> {
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true },
});
return geraet.id;
}
/** Legt einen Lauf an und gibt seine Adresse zurueck. */
async function laufStarten(seite: Page, titel: string): Promise<string> {
await seite.goto("/laeufe/starten");
await vorlageWaehlen(seite, SEED_VORLAGE);
/*
* Erst der Kunde, dann das Geraet — und zwar **der Kunde des Geraets**. Im
* Modus DIENSTLEISTUNG fragt der Dialog danach, und ein Lauf kann strukturell
* kein Geraet eines fremden Kunden waehlen: der zusammengesetzte
* Fremdschluessel `Lauf_geraet_gleicher_kunde` weist das ab. „Irgendein Kunde“
* waere hier also nicht ungenau, sondern rot. Im Modus INTERN gibt es das Feld
* nicht.
*/
const geraetId = await seedGeraetId();
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { id: geraetId },
select: { kundeId: true },
});
await kundeWaehlen(seite, geraet.kundeId);
// Das Geraet setzt `{{hostname}}` im Befehlsschritt – ohne es bliebe der
// Platzhalter stehen, und der Test darauf pruefte nichts.
await seite.selectOption('select[name="geraetId"]', geraetId);
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.
await variablenFuellen(seite);
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();
// Am Schritt und nicht ueber `.first()`: welcher Schritt zuerst kommt, ist
// eine Annahme ueber die Vorlage (#018).
const schrittZuvor = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" });
// Der Klick allein speichert nicht – erst erscheint das Begründungsfeld.
await schrittZuvor.getByRole("button", { name: "nicht zutreffend" }).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`);
/*
* Am **Schritt**, der den Platzhalter traegt, und nicht am ersten `pre.mono`
* der Seite: nur dort beweist „enthaelt `{{hostname}}` nicht“ etwas. Ein
* Befehl ohne Platzhalter waere auch gruen, wenn nie etwas eingesetzt wuerde.
*/
const schritt = page.getByRole("listitem").filter({ hasText: "Hostname gesetzt" });
const befehl = schritt.locator("pre.mono").first();
await expect(befehl).toBeVisible();
await expect(befehl).not.toContainText("{{hostname}}");
// Und was stattdessen dasteht: der Name des gewaehlten Geraets.
await expect(befehl).toContainText(SEED_GERAET);
await expect(schritt.getByRole("button", { name: "Befehl kopieren" })).toBeVisible();
/*
* Die Skript-Kopie steckt in einem zugeklappten `details` und steht **je
* Abschnitt**, der Befehle hat — bei dieser Vorlage in dreien. Geprueft wird
* deshalb der Abschnitt dieses Schritts, nicht „irgendwo auf der Seite“:
* ohne die Eingrenzung fand der Test drei Treffer und brach ab (#018).
*/
const abschnitt = page.locator("section").filter({ has: schritt });
await expect(abschnitt.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}`;
/*
* Die Namensregeln des Hauses, wie sie vorher dastanden.
*
* Sie werden fuer diese beiden Ablaeufe gesetzt und hinterher **zurueckgestellt**
* — nicht auf `{}` geleert. Genau das tat der Nachlauf vorher, und damit nahm
* er der Datenbank die Regeln, die `pnpm db:seed` eingerichtet hatte: in der
* Entwicklungsdatenbank stand danach `namensregeln = {}`, und der Schnellstart
* eines Onboardings schlug ohne erkennbaren Grund fehl (#018).
*/
let namensregelnVorher: Prisma.JsonValue = {};
test.beforeAll(async () => {
const vorher = await prisma.einstellung.findUniqueOrThrow({
where: { id: "global" },
select: { namensregeln: true },
});
namensregelnVorher = vorher.namensregeln;
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() },
});
});
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, VORLAGE);
await page.getByLabel("… oder Name als Freitext").fill("Jörg Müller-Lüdenscheid");
/*
* Im Modus DIENSTLEISTUNG braucht der Vorschlag einen Kunden: die Regeln
* koennen je Kunde eigene sein, und `istVergeben` fragt innerhalb seiner
* Grenze. Ohne Auswahl antwortet die Aktion mit „Im Modus Dienstleistung
* muss ein Kunde gewählt werden“ und die Felder bleiben leer.
*/
await kundeWaehlen(page);
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, VORLAGE);
await page.getByLabel("… oder Name als Freitext").fill("Maria Huber");
await kundeWaehlen(page);
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: namensregelnVorher as Prisma.InputJsonValue },
});
});
});
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();
});