Files
Florian WildeandClaude Opus 5 80d97467da
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
fix: Reviewbefunde aus der Durchsicht von Phase 2 (#036, #027)
Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.

Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.

Schwer:

- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
  (/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
  alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
  jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
  dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
  eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.

Mittel:

- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
  durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
  Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
  jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
  exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
  Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
  Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
  Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
  der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
  angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
  Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
  Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
  Graph-Leser sich im Kommentar darauf berief.

Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.

Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.

1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.

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

441 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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();
/*
* 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();
});