429 lines
18 KiB
TypeScript
429 lines
18 KiB
TypeScript
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();
|
||
});
|