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 { 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 { 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(); /* * Die Felder **namentlich** und vollständig: der Zustand ist die einzige * Antwort, die alle zehn Sekunden (seit #033 bei jeder Änderung) hinausgeht, * und ein Feld, das sich hier einschleicht, wandert ungesehen mit. Deshalb * `toEqual` und nicht `toContain`. * * `anzahlAnhaenge` kam mit #033 dazu und fehlte hier — der Ablauf war seitdem * rot, und niemand hat es gemerkt, weil `pnpm test:e2e` nicht im Tor vor dem * Commit steht (Reviewbefund zu #029). Wer hier ein Feld ergänzt, trägt es * ein; genau das ist der Zweck dieser Liste. */ expect(Object.keys(zustand as object).sort()).toEqual([ "anzahlAnhaenge", "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(); });