398 lines
17 KiB
TypeScript
398 lines
17 KiB
TypeScript
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"]')]);
|
||
}
|
||
|
||
/** Die Vorlage aus `pnpm beispiel:vorlage` – diese Ablaeufe arbeiten mit ihr. */
|
||
const BEISPIEL_VORLAGE = "PC-Neuinstallation (Beispiel)";
|
||
|
||
/** Legt einen Lauf an und gibt seine Adresse zurueck. */
|
||
async function laufStarten(seite: Page, titel: string): Promise<string> {
|
||
await seite.goto("/laeufe/starten");
|
||
|
||
/*
|
||
* Die Vorlage wird **am Namen** gewaehlt und nicht ueber `index: 1`. Seit dem
|
||
* Seed der sieben Vorlagen (#017) steht dort an erster Stelle, was die
|
||
* Sortierung gerade nach vorn stellt — bei einer frisch eingerichteten
|
||
* Installation etwa „Mitarbeiter-Offboarding“ mit Pflichtangaben, die dieser
|
||
* Helfer nicht kennt. Der Ablauf lief dann in den Zeitablauf, und die Meldung
|
||
* sagte nichts ueber die Ursache.
|
||
*/
|
||
const vorlagen = await seite
|
||
.locator('select[name="vorlagenVersionId"] option')
|
||
.evaluateAll((liste) =>
|
||
liste
|
||
.filter((option): option is HTMLOptionElement => option instanceof HTMLOptionElement)
|
||
.map((option) => ({ wert: option.value, text: option.textContent })),
|
||
);
|
||
const beispiel = vorlagen.find((eintrag) => (eintrag.text ?? "").includes(BEISPIEL_VORLAGE));
|
||
if (!beispiel) {
|
||
throw new Error(`Die Vorlage „${BEISPIEL_VORLAGE}“ fehlt. Erst „pnpm beispiel:vorlage“ laufen lassen.`);
|
||
}
|
||
await seite.selectOption('select[name="vorlagenVersionId"]', beispiel.wert);
|
||
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");
|
||
}
|
||
for (const feld of await seite.locator('fieldset input[type="date"]').all()) {
|
||
await feld.fill("2026-10-01");
|
||
}
|
||
for (const feld of await seite.locator('fieldset input[type="text"]').all()) {
|
||
// Nur leere Felder: die Namensvorschlaege sollen stehen bleiben.
|
||
if ((await feld.inputValue()) === "") await feld.fill("E2E");
|
||
}
|
||
|
||
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();
|
||
});
|