feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #36.
This commit is contained in:
2026-09-26 09:04:15 +00:00
parent 9bd198a531
commit fbbb634e56
104 changed files with 4527 additions and 299 deletions
+41 -21
View File
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { anmelden, SEED_VORLAGE } from "./helfer";
/**
* Das Archiv am gebauten Stand (#015).
@@ -9,20 +10,18 @@ import { prisma } from "@/lib/db";
* Mandantengrenze hinein. Hier geht es um den Weg, den ein Mensch nimmt: Wort
* eintippen, Treffer sehen, **erkennen warum**, in den Lauf springen. Ein
* Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.
*
* Voraussetzungen prueft `tests/e2e/global-setup.ts`, aufgeraeumt wird am Ende
* ueber die Datenbank: fuer einen Lauf kennt die Anwendung keinen Loeschweg, und
* das ist richtig so (#018).
*/
const STEMPEL = Date.now();
const NOTIZ = `Restposten ${STEMPEL} aus dem Lager`;
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"]')]);
}
/** Fortlaufend, damit zwei Laeufe desselben Durchgangs sich nicht verwechseln. */
let laufende = 0;
type Testlauf = { readonly id: string; readonly titel: string; readonly suchwort: string };
/**
* Ein archivierter Lauf mit einer Notiz, die es sonst nirgends gibt.
@@ -31,19 +30,31 @@ async function anmelden(seite: Page) {
* geprueft werden, nicht der Weg dorthin — den prueft `tests/e2e/laeufe.spec.ts`
* vom Anlegen bis zum Archivieren. Die Zeitstempel gehoeren trotzdem dazu, drei
* CHECK-Bedingungen in der Datenbank verlangen sie.
*
* Vorlage **am Namen**, Kunde ueber `istEigeneOrganisation`: ein
* `findFirstOrThrow` ohne Bedingung nahm die Version, die die Sortierung gerade
* nach vorn stellte, und im Modus DIENSTLEISTUNG einen beliebigen Demo-Kunden.
* Dass es `SEED_VORLAGE` veroeffentlicht gibt, prueft der Global-Setup.
*/
async function archivierterLauf(): Promise<{ id: string; titel: string }> {
async function archivierterLauf(): Promise<Testlauf> {
laufende += 1;
const marke = `${STEMPEL}-${laufende}`;
const suchwort = `Restposten ${marke}`;
const version = await prisma.vorlagenVersion.findFirstOrThrow({
where: { status: "VEROEFFENTLICHT" },
orderBy: { veroeffentlichtAm: "desc" },
where: { status: "VEROEFFENTLICHT", vorlage: { name: SEED_VORLAGE } },
orderBy: { version: "desc" },
select: { id: true, abschnitte: { select: { schritte: { select: { id: true } } } } },
});
const kunde = await prisma.kunde.findFirstOrThrow({ select: { id: true } });
const kunde = await prisma.kunde.findFirstOrThrow({
where: { istEigeneOrganisation: true },
select: { id: true },
});
const schrittId = version.abschnitte.flatMap((abschnitt) => abschnitt.schritte)[0]?.id;
if (!schrittId) throw new Error("Die Vorlage hat keinen Schritt.");
if (!schrittId) throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keinen Schritt.`);
const jetzt = new Date();
const titel = `ZZ E2E Archiv ${STEMPEL}`;
const titel = `ZZ E2E Archiv ${marke}`;
const lauf = await prisma.lauf.create({
data: {
vorlagenVersionId: version.id,
@@ -60,10 +71,10 @@ async function archivierterLauf(): Promise<{ id: string; titel: string }> {
laufId: lauf.id,
vorlagenSchrittId: schrittId,
zustand: "NICHT_ZUTREFFEND",
notiz: NOTIZ,
notiz: `${suchwort} aus dem Lager`,
},
});
return { id: lauf.id, titel };
return { id: lauf.id, titel, suchwort };
}
test.describe("Archiv", () => {
@@ -77,7 +88,7 @@ test.describe("Archiv", () => {
}
await page.getByRole("link", { name: "Archiv" }).first().click();
await page.waitForURL("**/archiv");
await page.getByLabel("Suchbegriff").fill(`Restposten ${STEMPEL}`);
await page.getByLabel("Suchbegriff").fill(lauf.suchwort);
await page.getByRole("button", { name: "Suchen" }).click();
const treffer = page.getByRole("listitem").filter({ hasText: lauf.titel });
@@ -102,8 +113,16 @@ test.describe("Archiv", () => {
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
test.skip(!isMobile, "Nur im Projekt „handy“.");
/*
* Der Ablauf bringt seinen Treffer mit, statt auf einen zu hoffen: gegen
* eine frische Datenbank fand „Restposten“ nichts, und gemessen wurde dann
* die Breite einer Seite ohne Treffer (#018).
*/
const lauf = await archivierterLauf();
await anmelden(page);
await page.goto("/archiv?suche=Restposten");
await page.goto(`/archiv?suche=${encodeURIComponent(lauf.suchwort)}`);
await expect(page.getByRole("listitem").filter({ hasText: lauf.titel })).toHaveCount(1);
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
@@ -114,6 +133,7 @@ test.describe("Archiv", () => {
test.afterAll(async () => {
const laeufe = await prisma.lauf.findMany({
// Der Praefix gehoert dieser Datei allein – geraeumt wird nur, was sie anlegt.
where: { titel: { startsWith: "ZZ E2E Archiv" } },
select: { id: true },
});
+10 -28
View File
@@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { anmelden, SEED_TAG } from "./helfer";
/**
* Der Etikettendruck am gebauten Stand (#014).
@@ -14,34 +14,21 @@ import { prisma } from "@/lib/db";
* Die Aufloesung: 1 mm sind 96/25,4 px, ein `deviceScaleFactor` von 300/96 =
* 3,125 macht daraus 300 dpi. 62 mm werden so zu 62 · 96/25,4 · 3,125 ≈ 732
* Geraetepixeln — genau die Aufloesung, mit der ein Etikettendrucker arbeitet.
*
* Gedruckt wird `SEED_TAG` und nicht irgendein Code aus der Datenbank: dass es
* ihn gibt, prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf. Ein
* `findFirst` haette gegen eine frische Installation „kein Geraet“ gemeldet und
* gegen eine benutzte einen beliebigen Aufkleber gedruckt (#018).
*/
const DPI = 300;
const PX_JE_MM = 96 / 25.4;
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"]')]);
}
/** Irgendein Tag-Code, den es in dieser Datenbank wirklich gibt. */
async function einTagCode(): Promise<string> {
const geraet = await prisma.geraet.findFirst({ select: { tagCode: true } });
if (!geraet) throw new Error("Kein Gerät in der Datenbank — erst pnpm db:seed.");
return geraet.tagCode;
}
test.describe("Etikettendruck", () => {
test("druckt ein Einzeletikett auf 62 × 29 mm", async ({ page }) => {
test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät.");
await anmelden(page);
const code = await einTagCode();
await page.goto(`/drucken/etiketten?layout=etikett&codes=${code}`);
await page.goto(`/drucken/etiketten?layout=etikett&codes=${SEED_TAG}`);
/*
* `page.pdf()` gilt in Chromium und beachtet `@page`. Gemessen wird die
@@ -69,7 +56,6 @@ test.describe("Etikettendruck", () => {
test("ein bei 300 dpi gerastertes Etikett lässt sich zurücklesen", async ({ browser }) => {
test.skip(test.info().project.name !== "desktop", "Der Decoder hängt nicht am Gerät.");
const code = await einTagCode();
/*
* Eigener Kontext: der `deviceScaleFactor` ist die Aufloesung, und die
@@ -84,7 +70,7 @@ test.describe("Etikettendruck", () => {
try {
const tab = await kontext.newPage();
await anmelden(tab);
await tab.goto(`/drucken/etiketten?layout=etikett&codes=${code}`);
await tab.goto(`/drucken/etiketten?layout=etikett&codes=${SEED_TAG}`);
const etikett = tab.locator("[data-etikett]").first();
await expect(etikett).toBeVisible();
const bild = await etikett.screenshot();
@@ -100,7 +86,7 @@ test.describe("Etikettendruck", () => {
* dazulegen koennte.
*/
const adresse = new URL(gelesen[0] ?? "");
expect(adresse.pathname).toBe(`/t/${code}`);
expect(adresse.pathname).toBe(`/t/${SEED_TAG}`);
expect(adresse.search).toBe("");
expect(adresse.hash).toBe("");
} finally {
@@ -157,7 +143,3 @@ function seitenmasseAusPdf(pdf: Buffer): { breite: number; hoehe: number } {
hoehe: Number(treffer[4]) - Number(treffer[2]),
};
}
test.afterAll(async () => {
await prisma.$disconnect();
});
+9 -12
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { anmelden, kundeWaehlen } from "./helfer";
/**
* Die Geraeteliste durch die Oberflaeche (#011).
@@ -11,8 +12,10 @@ import "dotenv/config";
* einlesen, loeschen — und anschliessend, ob es wirklich in der Datenbank steht:
* durch Neuladen.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt.
* Voraussetzungen — ein gebauter Stand antwortet, der Seed-Bestand steht und der
* Server liest dieselbe Datenbank wie dieser Lauf — prueft
* `tests/e2e/global-setup.ts` vor dem ersten Ablauf (#018). Eigene Daten legt
* dieser Ablauf selbst an; auf vorhandene baut er nicht.
*
* Der Ablauf raeumt hinter sich auf: an den angelegten Geraeten haengt kein Lauf,
* sie lassen sich also loeschen. Ihre Tag-Codes bleiben vergeben, und genau das
@@ -21,16 +24,6 @@ import "dotenv/config";
const MARKE = `ZZ-E2E-${Date.now()}`;
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"]')]);
}
/** Der Abschnitt „Gerät anlegen“ – eindeutig ueber seinen eigenen Knopf. */
function anlegenFormular(seite: Page) {
return seite.locator("section", { has: seite.getByRole("button", { name: "Gerät anlegen" }) });
@@ -70,6 +63,7 @@ test.describe("Geräte", () => {
await formular.getByLabel("Hostname").fill(name);
await formular.getByLabel("Seriennummer").fill(`${MARKE}-SN`);
await formular.getByLabel("Typ").selectOption("NOTEBOOK");
await kundeWaehlen(formular);
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
// Der Tag-Code steht in der Meldung – er kommt gleich auf den Aufkleber.
@@ -109,6 +103,7 @@ test.describe("Geräte", () => {
const formular = anlegenFormular(page);
await formular.getByLabel("Hostname").fill(name);
await kundeWaehlen(formular);
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
@@ -150,6 +145,7 @@ test.describe("Geräte", () => {
`${MARKE}-CSV-BOESE;;SN-BOESE;INV-2;Tablet;`,
].join("\r\n"),
);
await kundeWaehlen(bereich);
await bereich.getByRole("button", { name: "Einlesen" }).click();
await expect(bereich.getByRole("status")).toContainText("1 von 2 Zeilen angelegt, 1 Zeile abgewiesen.");
@@ -175,6 +171,7 @@ test.describe("Geräte", () => {
const formular = anlegenFormular(page);
await formular.getByLabel("Bezeichnung").fill(name);
await formular.getByLabel("Typ").selectOption("RAUM");
await kundeWaehlen(formular);
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
+42 -20
View File
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { anmelden, SEED_TAG } from "./helfer";
/**
* Der Weg vom Aufkleber zur Arbeit (#014).
@@ -9,19 +10,17 @@ import { prisma } from "@/lib/db";
* tun ist, loslegen. Geprueft wird er am **gebauten** Stand, weil genau hier die
* Teile zusammenkommen, die einzeln schon funktionierten — die Weiche unter
* `/t/<code>`, das Registrieren, die Geraeteseite und der Lauf.
*
* Voraussetzungen prueft `tests/e2e/global-setup.ts`. Das Beispielgeraet wird
* ueber `SEED_TAG` gesucht und nicht ueber den Hostnamen „PC-001“: im Modus
* DIENSTLEISTUNG traegt der Name das Kundenkuerzel, und in einer benutzten
* Datenbank gibt es „PC-001“ mehr als einmal (#018).
*/
const STEMPEL = Date.now();
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"]')]);
}
/** Praefix der Geraete, die dieser Ablauf anlegt – und nur dieser. */
const HOSTNAME_PRAEFIX = "ZZ-E2E-TAG-";
/**
* Eine schlichte Vorlage **ohne** Pflichtangaben.
@@ -59,7 +58,18 @@ async function tagAnlegen(): Promise<string> {
where: { rolle: "ADMIN" },
select: { id: true, rolle: true },
});
const stapel = await tagsErzeugen(1, null, { id: admin.id, rolle: admin.rolle });
/*
* Der Kunde muss mit: im Modus DIENSTLEISTUNG verlangt `kundeErmitteln` eine
* Wahl und wirft sonst „Im Modus Dienstleistung muss ein Kunde gewählt
* werden“. Genommen wird der Kunde des Beispielgeraets — an ihm haengt auch
* die Geraeteseite, die dieser Ablauf danach oeffnet. Im Modus INTERN wird die
* Kennung verworfen, die Angabe schadet dort also nicht (#008).
*/
const beispiel = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { kundeId: true },
});
const stapel = await tagsErzeugen(1, beispiel.kundeId, { id: admin.id, rolle: admin.rolle });
const code = stapel.tagCodes[0];
if (!code) throw new Error("Der Stapel kam leer zurück.");
eigeneTags.push(code);
@@ -82,13 +92,13 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
* muss er auf **der Geräteseite** stehen und nicht auf der Übersicht — sonst
* sucht er das Gerät noch einmal, das er gerade in der Hand hält.
*/
const geraet = await prisma.geraet.findFirstOrThrow({
where: { hostname: "PC-001" },
select: { id: true, tagCode: true },
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true, hostname: true },
});
await page.context().clearCookies();
await page.goto(`/t/${geraet.tagCode}`);
await page.goto(`/t/${SEED_TAG}`);
await page.waitForURL(/\/anmelden\?ziel=/);
const email = process.env["SEED_ADMIN_EMAIL"];
@@ -99,7 +109,8 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
await page.click('button[type="submit"]');
await page.waitForURL(`**/geraete/${geraet.id}`);
await expect(page.getByRole("heading", { name: "PC-001" })).toBeVisible();
// Der Name kommt aus der Datenbank: er heisst nicht in jedem Modus „PC-001“.
await expect(page.getByRole("heading", { name: geraet.hostname ?? SEED_TAG })).toBeVisible();
});
test("registriert einen vorgedruckten Tag und arbeitet daran weiter", async ({ page }) => {
@@ -111,7 +122,7 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
await page.goto(`/t/${code.toLowerCase()}`);
await expect(page.getByRole("heading", { name: "Gerät registrieren" })).toBeVisible();
const hostname = `ZZ-E2E-${STEMPEL}`;
const hostname = `${HOSTNAME_PRAEFIX}${STEMPEL}`;
await page.getByLabel("Hostname").fill(hostname);
await page.getByLabel("Typ").selectOption("NOTEBOOK");
await page.getByRole("button", { name: "Gerät registrieren" }).click();
@@ -156,9 +167,15 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
test.skip(!isMobile, "Nur im Projekt „handy“.");
/*
* Gesucht wird ueber den Tag-Code und nicht ueber „NB-001“: der Name war eine
* Annahme ueber den Beispielbestand, und in einer benutzten Datenbank fuehrt
* er auf mehr als ein Geraet. Der Code ist eindeutig — die Liste zeigt danach
* genau eine Zeile, und der Weg ueber die Liste bleibt der des Technikers.
*/
await anmelden(page);
await page.goto("/geraete");
await page.getByRole("link", { name: "NB-001" }).first().click();
await page.goto(`/geraete?suche=${SEED_TAG}`);
await page.locator("ul > li").filter({ hasText: SEED_TAG }).getByRole("link").first().click();
await page.waitForURL(/\/geraete\/[^/]+$/);
const masse = await page.evaluate(() => ({
@@ -186,7 +203,12 @@ test.afterAll(async () => {
* Entwicklungsdatenbank ist das derselbe Bestand.
*/
const geraete = await prisma.geraet.findMany({
where: { OR: [{ hostname: { startsWith: "ZZ-E2E-" } }, { tagCode: { in: eigeneTags } }] },
/*
* Der Praefix gehoert dieser Datei allein. Vorher stand hier `ZZ-E2E-`, und
* damit raeumte dieser Nachlauf auch die Geraete aus
* `tests/e2e/geraete.spec.ts` weg — fremde Daten, wenn auch Testdaten (#018).
*/
where: { OR: [{ hostname: { startsWith: HOSTNAME_PRAEFIX } }, { tagCode: { in: eigeneTags } }] },
select: { id: true },
});
const kennungen = geraete.map((geraet) => geraet.id);
+103
View File
@@ -0,0 +1,103 @@
import { chromium } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { anmelden, SEED_GERAET, SEED_TAG, SEED_VORLAGE } from "./helfer";
/**
* Was vor dem ersten Ablauf stimmen muss (#018).
*
* Bisher standen die Voraussetzungen als Satz im Kopfkommentar jeder Datei
* („Voraussetzung: ein gebauter Stand laeuft und `pnpm beispiel:vorlage` hat
* …“). Ein Kommentar prueft nichts. Stimmte etwas davon nicht, fiel das erst
* mitten im Lauf auf, und die Meldung sprach von einem Zeitablauf oder von null
* Treffern — nie von der Ursache.
*
* Hier wird es geprueft, einmal, bevor irgendein Ablauf startet. Jede Meldung
* sagt, **was** fehlt und **was zu tun ist**.
*/
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
function fehlen(was: string, abhilfe: string): never {
throw new Error(`Ende-zu-Ende-Tests: ${was}\n → ${abhilfe}`);
}
export default async function vorbereiten(): Promise<void> {
if (!process.env["SEED_ADMIN_EMAIL"] || !process.env["SEED_ADMIN_PASSWORD"]) {
fehlen(
"SEED_ADMIN_EMAIL oder SEED_ADMIN_PASSWORD fehlt.",
"Beide stehen in der .env und müssen zu dem Administrator passen, den der Seed angelegt hat.",
);
}
// 1. Laeuft ueberhaupt etwas an dieser Adresse?
const antwort = await fetch(`${APP_URL}/api/gesundheit`, { signal: AbortSignal.timeout(5000) }).catch(
() => undefined,
);
if (!antwort?.ok) {
fehlen(
`Unter ${APP_URL} antwortet keine Anwendung.`,
"Erst bauen und starten: pnpm build && PORT=3300 APP_URL=http://127.0.0.1:3300 pnpm start — " +
"und die Adresse über E2E_APP_URL mitgeben. Gegen `pnpm dev` laufen die Abläufe nicht " +
"(siehe CLAUDE.md, „Oberfläche prüfen“).",
);
}
// 2. Steht der Seed-Bestand, mit dem die Ablaeufe arbeiten?
const vorlage = await prisma.vorlage.findFirst({
where: { name: SEED_VORLAGE },
select: { id: true, versionen: { where: { status: "VEROEFFENTLICHT" }, select: { id: true } } },
});
if (!vorlage || vorlage.versionen.length === 0) {
fehlen(
`Die Vorlage „${SEED_VORLAGE}“ fehlt oder ist nicht veröffentlicht.`,
"pnpm db:seed laufen lassen.",
);
}
const geraet = await prisma.geraet.findUnique({ where: { tagCode: SEED_TAG }, select: { id: true } });
if (!geraet) {
fehlen(`Das Beispielgerät ${SEED_GERAET} (${SEED_TAG}) fehlt.`, "pnpm db:seed laufen lassen.");
}
/*
* 2b. In welchem Betriebsmodus laeuft die Installation?
*
* Ein Ablauf soll das nicht selbst aus der Datenbank holen muessen — und zwei
* Quellen fuer dieselbe Auskunft liefen frueher oder spaeter auseinander. Der
* Modus steht danach in `E2E_BETRIEBSMODUS`, und ein Ablauf, der nur in einem
* der beiden Sinn ergibt, ueberspringt sich selbst mit einem Grund.
*/
const einstellung = await prisma.einstellung.findUnique({
where: { id: "global" },
select: { modus: true },
});
process.env["E2E_BETRIEBSMODUS"] = einstellung?.modus ?? "INTERN";
/*
* 3. Liest der Server **dieselbe** Datenbank?
*
* Der Testprozess schreibt über DATABASE_URL aus der .env, der Server bekommt
* seine eigene mit. Zeigen beide woandershin, legt ein Ablauf seine Daten in
* die eine und sucht sie in der anderen. Geprüft wird an einer Vorlage, die
* hier entsteht und gleich wieder verschwindet.
*/
const probe = `ZZ Datenbankprüfung ${Date.now()}`;
const angelegt = await prisma.vorlage.create({ data: { name: probe, kategorie: "ZZ Test" } });
const browser = await chromium.launch();
try {
const tab = await browser.newPage({ baseURL: APP_URL });
await anmelden(tab);
await tab.goto(`/vorlagen?suche=${encodeURIComponent(probe)}`);
if ((await tab.getByRole("link", { name: probe }).count()) === 0) {
fehlen(
"Der Server unter Test liest eine andere Datenbank als dieser Lauf.",
"DATABASE_URL des Testprozesses (aus der .env) und die des Servers müssen auf dieselbe " +
"Datenbank zeigen — sonst schreibt der Ablauf in die eine und sucht in der anderen.",
);
}
} finally {
await browser.close();
await prisma.vorlage.delete({ where: { id: angelegt.id } });
await prisma.$disconnect();
}
}
+332
View File
@@ -0,0 +1,332 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { anmelden, geraetName, SEED_VORLAGE, variablenFuellen, vorlageWaehlen } from "./helfer";
/**
* Der Hauptablauf des ganzen Auftrags, in einem Zug (#018).
*
* Anmelden → „PC Neuinstallation“ für das Beispielgerät starten → abarbeiten →
* einen Schritt mit Begründung überspringen → abschließen → archivieren → im
* Archiv über ein Wort aus der Begründung wiederfinden → und danach ändert sich
* nichts mehr.
*
* Zwei Dinge unterscheiden diesen Ablauf von einem Klickprotokoll:
*
* 1. **Geprüft wird gegen die Datenbank.** Nach jeder Änderung wird neu geladen
* und in der Datenbank nachgesehen. Ein Klick, der nur im Browser wirkt, sieht
* auf einem Screenshot genauso aus wie einer, der gespeichert wurde — und
* genau dieser Unterschied ist der Grund, warum es diese Datei gibt
* (CLAUDE.md, „Oberfläche prüfen“).
* 2. **Die Absage wird erzwungen.** „Nicht zutreffend“ ohne Begründung sperrt die
* Oberfläche nur über einen deaktivierten Knopf. Ein deaktivierter Knopf ist
* keine Zusage: dieser Ablauf entfernt ihn und schickt trotzdem ab, damit die
* Absage vom **Server** bewiesen ist und nicht von einem HTML-Attribut.
*
* Der Ablauf legt an, was er braucht (einen eigenen Lauf mit `ZZ E2E `-Titel) und
* räumt ihn hinterher weg. Vorausgesetzt wird nur der Seed-Bestand, den
* `global-setup.ts` prüft.
*/
const STEMPEL = Date.now();
const TITEL = `ZZ E2E Hauptablauf ${STEMPEL}`;
/** Der Schritt, der übersprungen wird — Pflicht, damit auch der Abschluss etwas davon hat. */
const UEBERSPRUNGEN = "Secure Boot und TPM 2.0 aktiv";
/** Der Schritt, in dem ein Wert erfasst wird. Typ TEXT in der Seed-Vorlage. */
const WERTSCHRITT = "Sondersoftware und Lizenzen des Benutzers notiert";
const WERT = `ZZ E2E Adobe Acrobat ${STEMPEL}`;
/**
* Das Wertfeld eines Schritts – ohne Annahme über das Element.
*
* `TEXT` mit `mehrzeilig` ist ein `textarea`, alles andere ein `input`. Ein
* `input[name="wert"]` lief deshalb in den Zeitablauf, und die Meldung sprach von
* einem fehlenden Element statt von der falschen Annahme.
*/
const WERTFELD = '[name="wert"]';
/**
* Ein Wort, das es in der ganzen Datenbank nur einmal gibt.
*
* Über dieses Wort wird der Lauf später im Archiv wiedergefunden. Ohne Stempel
* fände die Suche auch die Notizen früherer Durchläufe, und der Treffer sagte
* nichts mehr über diesen hier.
*/
const NOTIZWORT = `Fabrikneu${STEMPEL}`;
const NOTIZ = `${NOTIZWORT}: das Gerät kam versiegelt aus dem Karton, TPM war ab Werk aktiv.`;
/**
* Legt den Lauf über die Oberfläche an und gibt seine Adresse zurück.
*
* Bewusst nicht über Prisma: das Anlegen **ist** Teil des Hauptablaufs. Die
* Vorlage wird am Namen gewählt (`vorlageWaehlen`), das Gerät am angezeigten
* Namen — in beiden Betriebsmodi, denn im Modus DIENSTLEISTUNG heißt das
* Beispielgerät anders und der Dialog fragt zusätzlich nach dem Kunden.
*/
async function laufStarten(seite: Page): Promise<string> {
await seite.goto("/laeufe/starten");
await vorlageWaehlen(seite, SEED_VORLAGE);
const gesucht = geraetName();
const geraete = await seite
.locator('select[name="geraetId"] option')
.evaluateAll((liste) =>
liste
.filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement)
.map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })),
);
const treffer = geraete.find((eintrag) => eintrag.text.includes(gesucht));
if (!treffer) {
throw new Error(
`Das Gerät „${gesucht}“ steht nicht zur Auswahl. Zur Wahl stehen: ` +
`${geraete.map((eintrag) => eintrag.text).join(", ")}`,
);
}
await seite.selectOption('select[name="geraetId"]', treffer.wert);
/*
* Im Modus DIENSTLEISTUNG verlangt der Dialog einen Kunden, und die Datenbank
* verlangt, dass es **der Kunde des Geräts** ist: ein zusammengesetzter
* Fremdschlüssel lässt keinen Lauf mit fremdem Gerät zu. Ein „nimm den
* ersten“ wäre also nicht nur ungenau, sondern im Zweifel rot.
*/
const kundenWahl = seite.locator('select[name="kundeId"]');
if ((await kundenWahl.count()) > 0) {
const geraet = await prisma.geraet.findFirst({
where: { OR: [{ hostname: gesucht }, { bezeichnung: gesucht }] },
select: { kunde: { select: { name: true } } },
});
if (!geraet) throw new Error(`Das Gerät „${gesucht}“ steht nicht in der Datenbank.`);
await kundenWahl.selectOption({ label: geraet.kunde.name });
}
const weitere = seite.locator("details").filter({ hasText: "Titel, Ticket" });
await weitere.locator("summary").click();
await weitere.locator('input[name="titel"]').fill(TITEL);
await variablenFuellen(seite);
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 60_000 });
return new URL(seite.url()).pathname;
}
/** Die Abschnitte der Abarbeitungsansicht – am aufklappbaren Kopf erkannt. */
function abschnitteVon(seite: Page): Locator {
return seite.locator("section").filter({ has: seite.locator("button[aria-expanded]") });
}
test.describe("Hauptablauf", () => {
test("starten, abarbeiten, abschließen, archivieren, wiederfinden", async ({ page }) => {
/*
* Die Seed-Vorlage hat über dreißig Schritte, und jeder speichert einzeln —
* so arbeitet ein Techniker, und so soll es geprüft werden. Das dauert; die
* Voreinstellung von 30 Sekunden reicht dafür nicht.
*/
test.setTimeout(420_000);
await anmelden(page);
const pfad = await laufStarten(page);
const laufId = pfad.split("/").pop() as string;
// Ein neuer Lauf ist **offen**: das ist die Zuweisung, nicht der Start.
expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("OFFEN");
await page.getByRole("button", { name: "Lauf beginnen" }).click();
const abschliessen = page.getByRole("button", { name: "Lauf abschließen" });
await expect(abschliessen).toBeVisible();
await expect(abschliessen).toBeDisabled();
// ---- Ein Wert in einem Textschritt --------------------------------------
const wertzeile = page.getByRole("listitem").filter({ hasText: WERTSCHRITT });
await wertzeile.locator(WERTFELD).fill(WERT);
await wertzeile.getByRole("button", { name: "Wert speichern" }).click();
/*
* Auf die **Datenbank** warten, nicht auf den Bildschirm: das Feld trägt den
* Wert schon, bevor irgendetwas gespeichert ist — es ist ein kontrolliertes
* Eingabefeld. Und ein `reload` mitten in der Aktion bricht sie ab, also erst
* warten, dann neu laden.
*/
await expect
.poll(
async () =>
(
await prisma.schrittergebnis.findFirst({
where: { laufId, vorlagenSchritt: { titel: WERTSCHRITT } },
select: { wert: true },
})
)?.wert,
{ timeout: 30_000, message: `Der Wert des Schritts „${WERTSCHRITT}“ steht nicht in der Datenbank.` },
)
.toBe(WERT);
await page.reload();
await expect(wertzeile.locator(WERTFELD)).toHaveValue(WERT);
// ---- „Nicht zutreffend“ verlangt eine Begründung ------------------------
const zeile = page.getByRole("listitem").filter({ hasText: UEBERSPRUNGEN });
const pille = zeile.getByRole("button", { name: "nicht zutreffend" });
await pille.click();
const mitBegruendung = zeile.getByRole("button", { name: "Mit Begründung speichern" });
await expect(mitBegruendung).toBeVisible();
await expect(mitBegruendung).toBeDisabled();
/*
* Und jetzt der eigentliche Nachweis: der gesperrte Knopf wird entsperrt und
* das Pflichtfeld entschärft, dann wird abgeschickt. Ein deaktivierter Knopf
* ist Bequemlichkeit — die Zusage („im Archiv steht kein übersprungener
* Schritt ohne Grund“) muss der Server halten, und nur so ist bewiesen, dass
* er es tut.
*/
await zeile.locator('textarea[name="notiz"]').evaluate((feld) => feld.removeAttribute("required"));
await mitBegruendung.evaluate((knopf) => knopf.removeAttribute("disabled"));
await mitBegruendung.click();
await expect(zeile.getByRole("alert")).toContainText("braucht eine Begründung");
expect(await prisma.schrittergebnis.count({ where: { laufId, zustand: "NICHT_ZUTREFFEND" } })).toBe(0);
// Mit Begründung geht es – und danach steht sie in der Datenbank.
await zeile.locator('textarea[name="notiz"]').fill(NOTIZ);
await zeile.getByRole("button", { name: "Mit Begründung speichern" }).click();
await expect(pille).toHaveAttribute("aria-pressed", "true");
await expect
.poll(
async () =>
prisma.schrittergebnis.findFirst({
where: { laufId, vorlagenSchritt: { titel: UEBERSPRUNGEN } },
select: { zustand: true, notiz: true },
}),
{ timeout: 30_000, message: `Der Schritt „${UEBERSPRUNGEN}“ steht nicht als übersprungen da.` },
)
.toEqual({ zustand: "NICHT_ZUTREFFEND", notiz: NOTIZ });
await page.reload();
await expect(zeile.locator('textarea[name="notiz"]')).toHaveValue(NOTIZ);
// ---- Den Rest abhaken --------------------------------------------------
const abschnitte = abschnitteVon(page);
const anzahlAbschnitte = await abschnitte.count();
expect(anzahlAbschnitte).toBeGreaterThan(0);
for (let nummer = 0; nummer < anzahlAbschnitte; nummer += 1) {
const abschnitt = abschnitte.nth(nummer);
const kopf = abschnitt.locator("button[aria-expanded]").first();
// Nach dem Neuladen ist zu, was fertig ist – erst aufklappen.
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
const zeilen = abschnitt.locator("> ul > li");
const anzahlZeilen = await zeilen.count();
for (let stelle = 0; stelle < anzahlZeilen; stelle += 1) {
const schritt = zeilen.nth(stelle);
// Was schon vom Tisch ist, bleibt es: ein zweiter Klick nähme es zurück.
if ((await schritt.locator('button[aria-pressed="true"]').count()) > 0) continue;
const haken = schritt.getByRole("button", { name: "erledigt", exact: true });
if ((await haken.count()) === 0) continue;
await haken.click();
// Auf den **gespeicherten** Stand warten, nicht auf den nächsten Klick:
// ohne das trifft er einen Knopf aus dem alten Baum.
await expect(haken).toHaveAttribute("aria-pressed", "true");
}
}
// ---- Abschließen und archivieren ---------------------------------------
await expect(abschliessen).toBeEnabled();
await abschliessen.click();
const archivieren = page.getByRole("button", { name: "archivieren" });
await expect(archivieren).toBeVisible();
expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("ABGESCHLOSSEN");
await archivieren.click();
await page.getByRole("button", { name: "Ja, archivieren" }).click();
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("ARCHIVIERT");
// ---- Im Archiv wiederfinden --------------------------------------------
await page.goto("/archiv");
await page.getByLabel("Suchbegriff").fill(NOTIZWORT);
await page.getByRole("button", { name: "Suchen" }).click();
const treffer = page.getByRole("listitem").filter({ hasText: TITEL });
await expect(treffer).toHaveCount(1);
// Und der Grund steht dabei – ein Treffer ohne sichtbare Fundstelle ist wertlos.
await expect(treffer.getByText("Notiz")).toBeVisible();
await expect(treffer.getByText(new RegExp(NOTIZWORT))).toBeVisible();
// ---- Und danach greift der Schreibschutz -------------------------------
await treffer.getByRole("link").first().click();
await page.waitForURL(`**/laeufe/${laufId}`);
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
/*
* **Erst aufklappen, dann prüfen.** An einem abgeschlossenen Lauf ist jeder
* Abschnitt zugeklappt, und in einem zugeklappten Abschnitt steht überhaupt
* nichts im Baum. Ein „keine Knöpfe gefunden“ wäre dort kein Nachweis für den
* Schreibschutz, sondern nur einer dafür, dass niemand hingesehen hat.
*/
const archiviert = abschnitteVon(page);
const anzahlArchiviert = await archiviert.count();
expect(anzahlArchiviert).toBeGreaterThan(0);
for (let nummer = 0; nummer < anzahlArchiviert; nummer += 1) {
const kopf = archiviert.nth(nummer).locator("button[aria-expanded]").first();
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
await expect(kopf).toHaveAttribute("aria-expanded", "true");
}
for (const knopf of [
"erledigt",
"nicht zutreffend",
"fehlgeschlagen",
"Wert speichern",
"Kommentar hinzufügen",
"Abschnitt trifft nicht zu",
"Lauf beginnen",
"Lauf abschließen",
]) {
await expect(
page.getByRole("button", { name: knopf, exact: true }),
`Am archivierten Lauf steht noch der Knopf „${knopf}“.`,
).toHaveCount(0);
}
/*
* Genau **ein** Formular bleibt stehen, und das ist das Zurückholen: das Archiv
* ist die Zusage, dass sich ein abgeschlossener Lauf nicht mehr ändert, und nur
* ein Administrator darf sie aufheben. Gezählt wird deshalb auf eins und nicht
* auf null — sonst hieße „grün“ hier irgendwann, dass auch der eine legitime
* Weg verschwunden ist.
*/
await expect(page.getByRole("button", { name: "aus dem Archiv zurückholen" })).toBeVisible();
await expect(page.locator("main form")).toHaveCount(1);
/*
* Der Wert und die Begründung stehen weiter da – lesbar, nur nicht änderbar.
* Ein Archiv, das seine Inhalte beim Sperren verliert, wäre kein Archiv.
*/
await expect(page.getByText(WERT)).toBeVisible();
await expect(page.getByText(NOTIZ)).toBeVisible();
});
});
test.afterAll(async () => {
/*
* Aufgeräumt wird über die Datenbank: für einen Lauf gibt es in der Anwendung
* keinen Löschweg, und das ist richtig so. `Protokoll.laufId` steht auf
* `Restrict` — ohne diese Zeile bleibt der Lauf stehen und der Fremdschlüssel
* meldet sich.
*/
const laeufe = await prisma.lauf.findMany({
where: { titel: { startsWith: "ZZ E2E Hauptablauf" } },
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 } } });
await prisma.protokoll.deleteMany({
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
});
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
}
await prisma.$disconnect();
});
+138
View File
@@ -0,0 +1,138 @@
import { expect, type Locator, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Was alle Ende-zu-Ende-Abläufe teilen (#018).
*
* Bis hierher hatte jede Datei ihr eigenes `anmelden()` — zehnmal dasselbe, und
* jede Verbesserung musste zehnmal nachgezogen werden. Wichtiger als die
* Wiederholung ist aber, was hier sonst noch steht: **die Voraussetzungen eines
* Laufs, geprüft statt im Kopfkommentar behauptet.**
*
* In #017 kostete beides einen halben Abend. Die Abläufe wählten ihre Vorlage
* über die Position in der Liste, und der Testprozess schrieb seine Daten in
* eine andere Datenbank, als der Server las. Beide Male sagte die Meldung nichts
* über die Ursache: einmal „Timeout 30000ms exceeded“, einmal „0 Treffer“.
*/
/** Die Vorlage, mit der die Abläufe arbeiten — aus dem Seed, nicht aus einem Skript. */
export const SEED_VORLAGE = "PC Neuinstallation";
/** Das Beispielgerät aus dem Seed. Sein Aufkleber steht im Abnahmekriterium. */
export const SEED_GERAET = "PC-001";
export const SEED_TAG = "LZ-7K3F9Q";
export async function anmelden(seite: Page): Promise<void> {
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. Sie stehen in der .env und müssen zu dem " +
"Administrator passen, den der Seed angelegt hat.",
);
}
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"]')]);
}
/**
* Wählt eine Vorlage im Start-Dialog **am Namen**.
*
* Nicht über `{ index: 1 }`: das ist eine Annahme über die Sortierung der
* Vorlagenliste, und sie hielt genau so lange, wie es eine einzige Vorlage gab.
* Steht die gesuchte nicht zur Auswahl, sagt die Meldung, welche fehlt — nicht
* „Timeout beim Warten auf die Adresse“.
*/
export async function vorlageWaehlen(seite: Page, name: string): Promise<void> {
const optionen = await seite
.locator('select[name="vorlagenVersionId"] option')
.evaluateAll((liste) =>
liste
.filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement)
.map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })),
);
const treffer = optionen.find((eintrag) => eintrag.text.includes(name));
if (!treffer) {
const vorhanden = optionen.map((eintrag) => eintrag.text).join(", ");
throw new Error(`Die Vorlage „${name}“ steht nicht zur Auswahl. Zur Wahl stehen: ${vorhanden}`);
}
await seite.selectOption('select[name="vorlagenVersionId"]', treffer.wert);
// Die Variablenfelder kommen nach, sobald der Server die Vorlage geliefert hat.
await expect(seite.locator("fieldset")).toBeVisible();
}
/**
* Füllt alle Pflichtfelder des Start-Dialogs, die noch leer sind.
*
* Bewusst **nur leere**: ein Vorschlag aus den Namensregeln soll stehen bleiben,
* sonst prüfte der Ablauf danach seine eigene Eingabe statt der Regel.
*/
export async function variablenFuellen(seite: Page, ausnahmen: readonly string[] = []): Promise<void> {
const felder = seite.locator("fieldset label");
for (let nummer = 0; nummer < (await felder.count()); nummer += 1) {
const feld = felder.nth(nummer);
const beschriftung = (await feld.locator("span").first().innerText()).replace("*", "").trim();
if (ausnahmen.some((ausnahme) => beschriftung.includes(ausnahme))) continue;
const auswahl = feld.locator("select");
if ((await auswahl.count()) > 0) {
await auswahl.selectOption({ index: 1 });
continue;
}
const eingabe = feld.locator("input");
if ((await eingabe.count()) === 0) continue;
if ((await eingabe.inputValue()) !== "") continue;
const typ = await eingabe.getAttribute("type");
if (typ === "date") await eingabe.fill("2026-10-01");
else if (typ === "number") await eingabe.fill("16");
else await eingabe.fill("E2E");
}
}
/**
* Waehlt den Kunden, wo das Formular danach fragt.
*
* Im Modus INTERN gibt es das Feld nicht — die eigene Organisation ist gesetzt,
* und der Server verwirft eine mitgeschickte Kennung ohnehin (#008). Im Modus
* DIENSTLEISTUNG ist es Pflicht, und ohne Auswahl antwortet der Dienst mit
* „Im Modus Dienstleistung muss ein Kunde gewählt werden“.
*
* `kundeId` gibt man an, wenn es auf **diesen** Kunden ankommt: ein Lauf kann
* strukturell kein Geraet und keine Abteilung eines fremden Kunden waehlen, das
* halten zusammengesetzte Fremdschluessel in der Datenbank fest. Ohne Angabe
* nimmt der Helfer den ersten — richtig ueberall dort, wo nur irgendein Kunde
* gebraucht wird.
*/
export async function kundeWaehlen(
/**
* Seite **oder** der Bereich, in dem das Feld steht. Der Bereich zaehlt, wo
* mehrere Formulare nebeneinander stehen: die Geraeteseite hat eines zum
* Anlegen und eines zum Einlesen, und `.first()` traefe dann das falsche.
*/
bereich: Page | Locator,
kundeId?: string,
): Promise<boolean> {
const feld = bereich.locator('select[name="kundeId"]').first();
if ((await feld.count()) === 0 || !(await feld.isVisible())) return false;
await feld.selectOption(kundeId ?? { index: 1 });
return true;
}
/**
* Der Betriebsmodus der Installation unter Test.
*
* Gesetzt von `global-setup.ts` aus der Datenbank. Ein Ablauf, der nur in einem
* der beiden Modi Sinn ergibt, fragt hier und ueberspringt sich mit einem Grund
* — statt in einer Oberflaeche zu suchen, die es dort nicht gibt.
*/
export function betriebsmodus(): "INTERN" | "DIENSTLEISTUNG" {
return process.env["E2E_BETRIEBSMODUS"] === "DIENSTLEISTUNG" ? "DIENSTLEISTUNG" : "INTERN";
}
/** Das Beispielgerät heisst im Modus DIENSTLEISTUNG anders — dieselbe Regel wie im Seed. */
export function geraetName(): string {
return betriebsmodus() === "DIENSTLEISTUNG" ? "MUS-PC-001" : SEED_GERAET;
}
+97 -66
View File
@@ -1,6 +1,16 @@
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).
@@ -10,68 +20,57 @@ import { prisma } from "@/lib/db";
* 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.
* 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}`;
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"]')]);
/**
* 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;
}
/** 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");
await vorlageWaehlen(seite, SEED_VORLAGE);
/*
* 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.
* 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 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 });
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.
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 variablenFuellen(seite);
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
@@ -153,8 +152,11 @@ test.describe("Ein Lauf von vorn bis hinten", () => {
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 page.getByRole("button", { name: "nicht zutreffend" }).first().click();
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();
@@ -211,15 +213,26 @@ test.describe("Ein Lauf von vorn bis hinten", () => {
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.
* 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 befehl = page.locator("li pre.mono").first();
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}}");
await expect(page.getByRole("button", { name: "Befehl kopieren" }).first()).toBeVisible();
await expect(page.getByText("Alle Befehle als Skript kopieren")).toBeVisible();
// 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 }) => {
@@ -285,7 +298,23 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
*/
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: {
@@ -318,21 +347,19 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
});
});
/** 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 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();
@@ -351,8 +378,9 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
test.slow();
await anmelden(page);
await page.goto("/laeufe/starten");
await vorlageWaehlen(page);
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();
@@ -367,7 +395,10 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
select: { id: true },
});
if (vorlage) await prisma.vorlage.delete({ where: { id: vorlage.id } });
await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: {} } });
await prisma.einstellung.update({
where: { id: "global" },
data: { namensregeln: namensregelnVorher as Prisma.InputJsonValue },
});
});
});
+284
View File
@@ -0,0 +1,284 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { SEED_TAG, SEED_VORLAGE, anmelden, betriebsmodus } from "./helfer";
/**
* Am Handy scrollt nichts nach rechts (#018).
*
* Die Zusage steht in CLAUDE.md und im Auftrag: **bei 390 px kein horizontales
* Scrollen**, auf keiner Hauptseite. Sie ist keine Geschmacksfrage. Ein Techniker
* arbeitet einen Lauf einhändig neben einem aufgeschraubten Rechner ab; eine
* Seite, die seitwärts wegläuft, kostet ihn bei jedem Haken einen Griff.
*
* Geprüft wird das schmalste Maß, das es gibt: `scrollWidth <= clientWidth` am
* `html`-Element. Und geprüft wird es **auf allen** Hauptseiten, nicht auf einer
* Auswahl — die eine Seite, die jemand vergisst, ist die, auf der es bricht.
*
* Die Meldung nennt die Seite, die Zahlen und das breiteste Element. „expected
* true“ beschreibt zwar denselben Fehler, aber niemand kann damit etwas anfangen:
* bei einer Seite mit dreihundert Kästen ist die Frage nicht *ob*, sondern *wo*.
*
* Der Ablauf legt an, was er braucht — einen Lauf für die Abarbeitungsansicht und
* einen Entwurf für den Editor — und räumt beides weg. Ein Ablauf, der sich
* darauf verlässt, dass zufällig ein Lauf in der Datenbank steht, scheitert gegen
* eine frische Installation mit „No record was found“, und das sagt nichts über
* die Ursache (Befund aus #017).
*/
const STEMPEL = Date.now();
const LAUF_TITEL = `ZZ E2E Mobil ${STEMPEL}`;
const ENTWURF_NAME = `ZZ E2E Mobil Entwurf ${STEMPEL}`;
let laufId = "";
let geraetId = "";
let mitarbeiterId = "";
let vorlageId = "";
let entwurfId = "";
let entwurfVorlageId = "";
/*
* Nur im Projekt „handy“. Auf dem Desktop prüfte derselbe Ablauf 1440 px gegen
* 1440 px und wäre immer grün — grün ohne Aussage ist schlimmer als rot.
*/
test.skip(({ isMobile }) => !isMobile, "Nur im Projekt „handy“ — 390 px ist die Zusage.");
/**
* **390 × 844, ausdrücklich.**
*
* Das Projekt „handy“ benutzt `devices["Pixel 7"]`, und das sind 412 × 839. Die
* Zusage lautet aber auf 390 px (CLAUDE.md, „Gestaltung“; Abnahmekriterium in
* #018), und 22 px sind kein Rundungsfehler: eine Seite, die bei 390 px
* ausbricht, kann bei 412 px tadellos aussehen. Die Zahl, auf die es ankommt,
* steht deshalb hier und nicht in einem Gerätenamen, dessen Maße sich mit der
* nächsten Playwright-Fassung ändern dürfen.
*/
test.use({ viewport: { width: 390, height: 844 } });
test.beforeAll(async () => {
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true, kundeId: true },
});
geraetId = geraet.id;
const person = await prisma.mitarbeiter.findFirst({
where: { aktiv: true, kundeId: geraet.kundeId },
orderBy: { anzeigename: "asc" },
select: { id: true },
});
if (!person) throw new Error("Es gibt keine aktive Person — pnpm db:seed laufen lassen.");
mitarbeiterId = person.id;
const vorlage = await prisma.vorlage.findFirstOrThrow({
where: { name: SEED_VORLAGE },
select: {
id: true,
versionen: {
where: { status: "VEROEFFENTLICHT" },
orderBy: { version: "desc" },
take: 1,
select: { id: true },
},
},
});
vorlageId = vorlage.id;
const versionId = vorlage.versionen[0]?.id;
if (!versionId) throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keine veröffentlichte Version.`);
/*
* Ein Lauf in Arbeit: er trägt die langen Befehlszeilen und die Hilfetexte, und
* das sind die beiden Stellen, an denen eine Seite am Handy nach rechts
* ausbricht. Der Kunde kommt vom Gerät — ein zusammengesetzter Fremdschlüssel
* lässt kein Gerät eines fremden Kunden zu.
*/
const lauf = await prisma.lauf.create({
data: {
vorlagenVersionId: versionId,
kundeId: geraet.kundeId,
geraetId: geraet.id,
mitarbeiterId,
titel: LAUF_TITEL,
status: "IN_ARBEIT",
gestartetAm: new Date(),
},
select: { id: true },
});
laufId = lauf.id;
/*
* Ein eigener Entwurf für den Editor. Nicht „irgendein Entwurf“ aus der
* Datenbank: eine frische Installation bringt bewusst keinen mit (so in #017
* entschieden), und die Seed-Vorlagen zum Bearbeiten zu öffnen hinterließe
* einen Entwurf, den niemand angelegt hat.
*/
const eigene = await prisma.vorlage.create({
data: { name: ENTWURF_NAME, kategorie: "ZZ Test" },
select: { id: true },
});
entwurfVorlageId = eigene.id;
const entwurf = await prisma.vorlagenVersion.create({
data: { vorlageId: eigene.id, version: 1, status: "ENTWURF" },
select: { id: true },
});
entwurfId = entwurf.id;
await prisma.vorlagenVariable.create({
data: {
versionId: entwurf.id,
schluessel: "hostname",
beschriftung: "Hostname des neuen Geräts",
position: 0,
pflicht: true,
},
});
const abschnitt = await prisma.vorlagenAbschnitt.create({
data: { versionId: entwurf.id, titel: "Vorbereitung", position: 0 },
select: { id: true },
});
await prisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Rechner umbenennen",
position: 0,
typ: "BEFEHL",
pflicht: true,
// Absichtlich lang: ein kurzer Befehl bricht keine Seite auf.
konfig: {
shell: "powershell",
befehl: 'Rename-Computer -NewName "{{hostname}}" -DomainCredential (Get-Credential) -Restart -Force',
},
},
});
});
/** Eine Hauptseite. Die Adresse kommt als Funktion, weil die Kennungen erst in `beforeAll` entstehen. */
type Hauptseite = {
readonly name: string;
readonly pfad: () => string;
/** Nur in diesem Betriebsmodus vorhanden. Sonst gibt es die Seite nicht. */
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
};
const SEITEN: readonly Hauptseite[] = [
{ name: "Übersicht", pfad: () => "/" },
{ name: "Läufe", pfad: () => "/laeufe" },
{ name: "Lauf-Detail", pfad: () => `/laeufe/${laufId}` },
{ name: "Lauf starten", pfad: () => "/laeufe/starten" },
{ name: "Vorlagen", pfad: () => "/vorlagen" },
{ name: "Vorlagen-Detail", pfad: () => `/vorlagen/${vorlageId}` },
{ name: "Vorlagen-Editor", pfad: () => `/vorlagen/version/${entwurfId}` },
{ name: "Mitarbeiter", pfad: () => "/mitarbeiter" },
{ name: "Mitarbeiter-Detail", pfad: () => `/mitarbeiter/${mitarbeiterId}` },
{ name: "Geräte", pfad: () => "/geraete" },
{ name: "Geräteseite", pfad: () => `/geraete/${geraetId}` },
{ name: "Archiv", pfad: () => "/archiv" },
{ name: "Tags", pfad: () => "/tags" },
{ name: "Scannen", pfad: () => "/scannen" },
{ name: "Admin: Einstellungen", pfad: () => "/admin" },
{ name: "Admin: Benutzer", pfad: () => "/admin/benutzer" },
{ name: "Admin: Abteilungen", pfad: () => "/admin/abteilungen" },
/*
* Das Verzeichnis gibt es nur im Modus INTERN (#008); im Modus DIENSTLEISTUNG
* leitet die Seite auf `/admin` um. Sie dort zu messen hiesse, die Umleitung
* zu messen — die scrollt garantiert nicht.
*/
{ name: "Admin: Verzeichnis", pfad: () => "/admin/verzeichnis", nurModus: "INTERN" },
{ name: "Kunden", pfad: () => "/kunden", nurModus: "DIENSTLEISTUNG" },
];
type Messung = {
readonly scroll: number;
readonly sichtbar: number;
readonly uebeltaeter: readonly string[];
};
/**
* Misst die Seite und benennt, was zu weit rechts endet.
*
* Gemeldet wird nur das **äußerste** Element je Kette: ein Kind, dessen Elter
* schon zu breit ist, sagt nichts Neues und würde die Meldung mit dreißig
* Einträgen zuschütten, die alle dieselbe Ursache haben.
*/
async function messen(seite: Page): Promise<Messung> {
return seite.evaluate(() => {
const wurzel = document.documentElement;
const sichtbar = wurzel.clientWidth;
const gemeldet: Element[] = [];
for (const element of Array.from(document.body.querySelectorAll("*"))) {
const kasten = element.getBoundingClientRect();
if (kasten.width === 0 && kasten.height === 0) continue;
// Ein Pixel Spielraum: gebrochene Bildschirmbreiten runden.
if (kasten.right <= sichtbar + 1) continue;
if (gemeldet.some((frueher) => frueher.contains(element))) continue;
gemeldet.push(element);
}
const benennen = (element: Element): string => {
const klassen = (element.getAttribute("class") ?? "")
.split(/\s+/u)
.filter(Boolean)
.slice(0, 3)
.join(".");
const text = (element.textContent ?? "").trim().replace(/\s+/gu, " ").slice(0, 40);
const breite = Math.round(element.getBoundingClientRect().right);
return `<${element.tagName.toLowerCase()}${klassen ? `.${klassen}` : ""}> endet bei ${breite} px${
text ? ` („${text}“)` : ""
}`;
};
return {
scroll: wurzel.scrollWidth,
sichtbar,
uebeltaeter: gemeldet.slice(0, 3).map(benennen),
};
});
}
test.describe("Am Handy bei 390 px", () => {
for (const hauptseite of SEITEN) {
test(`scrollt „${hauptseite.name}“ nicht horizontal`, async ({ page }) => {
test.slow();
test.skip(
hauptseite.nurModus !== undefined && hauptseite.nurModus !== betriebsmodus(),
`„${hauptseite.name}“ gibt es nur im Modus ${hauptseite.nurModus}.`,
);
await anmelden(page);
const pfad = hauptseite.pfad();
await page.goto(pfad);
/*
* Erst prüfen, dass die Seite überhaupt die gemeinte ist: eine
* Weiterleitung auf `/anmelden` scrollt garantiert nicht, und der Ablauf
* hätte grün gemeldet, ohne die Seite gesehen zu haben.
*/
expect(new URL(page.url()).pathname, `${pfad} hat weitergeleitet.`).toBe(pfad);
await page.waitForLoadState("load");
await expect(page.locator("main, body").first()).toBeVisible();
const masse = await messen(page);
expect(
masse.scroll,
`„${hauptseite.name}“ (${pfad}) scrollt horizontal: erwartet <= ${masse.sichtbar}, ` +
`war ${masse.scroll} — ${masse.scroll - masse.sichtbar} px zu breit.` +
(masse.uebeltaeter.length > 0 ? `\n Zu weit rechts: ${masse.uebeltaeter.join("\n ")}` : ""),
).toBeLessThanOrEqual(masse.sichtbar);
});
}
});
test.afterAll(async () => {
if (laufId) {
await prisma.kommentar.deleteMany({ where: { laufId } });
await prisma.schrittergebnis.deleteMany({ where: { laufId } });
// `Protokoll.laufId` steht auf `Restrict` – sonst bleibt der Lauf stehen.
await prisma.protokoll.deleteMany({ where: { OR: [{ laufId }, { entitaetId: laufId }] } });
await prisma.lauf.deleteMany({ where: { id: laufId } });
}
if (entwurfVorlageId) {
// Ein Entwurf lässt sich löschen; eine veröffentlichte Version nie. Deshalb
// wird hier einer angelegt und keiner veröffentlicht.
await prisma.vorlage.deleteMany({ where: { id: entwurfVorlageId } });
}
await prisma.$disconnect();
});
+367
View File
@@ -0,0 +1,367 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { PASSWORT_IM_DRUCK, PASSWORT_LAENGE } from "@/lib/passwort";
import { anmelden } from "./helfer";
/**
* Der Onboarding-Ablauf (#018).
*
* Er prüft die Kette, an der im Auftrag das meiste hängt: aus einem Namen werden
* über die Namensregeln UPN, Anmeldename und Mail-Adresse, aus der gewählten
* Abteilung werden Ziel-OU und Standardgruppen, und aus allem zusammen entsteht
* ein `New-ADUser`-Befehl, den ein Techniker auf einem Domänencontroller
* ausführt.
*
* Zwei Zusagen werden hier eingelöst, und beide nur hier:
*
* 1. **Der Kopier-Knopf liefert zeichengenau das, was auf dem Bildschirm
* steht.** Geprüft wird über die Zwischenablage, nicht über das erwartete
* Ergebnis: ein Vergleich gegen einen selbst zusammengebauten Text prüfte, ob
* der Test rechnen kann, nicht ob der Knopf richtig kopiert. Ein Befehl, der
* beim Kopieren etwas verliert — eine Zeile, ein Fluchtzeichen —, tut auf dem
* Domänencontroller etwas anderes als er liest.
* 2. **`{{generiertes_passwort}}` wird nie gespeichert.** Nicht im
* `Schrittergebnis`, nicht im `Kommentar`, nicht im `Protokoll` und nicht im
* Ausdruck. Das ist die härteste Zusage des Auftrags, und sie lässt sich nur
* so prüfen: das angezeigte Passwort auslesen und danach in der Datenbank
* danach suchen.
*/
const STEMPEL = Date.now();
const TITEL = `ZZ E2E Onboarding ${STEMPEL}`;
const VORLAGE = "Mitarbeiter-Onboarding";
const ABTEILUNG = "Buchhaltung";
/*
* Der Name der neuen Person. Kein „ZZ E2E“ davor: aus ihm entstehen Anmeldenamen
* nach den Namensregeln, und ein Leerzeichen im Vornamen wäre eine Prüfung der
* Regeln und nicht des Ablaufs. Angelegt wird ohnehin nichts — die Person steht
* beim Onboarding noch nicht im Verzeichnis, sie ist nur Freitext am Lauf.
*/
const VORNAME = "Zoe";
const NACHNAME = "Testfall";
/**
* Was die Abteilung in die Befehle speist – dieselben Werte wie im Seed
* (`prisma/seed-daten/stammdaten.ts`).
*/
const ZIEL_OU = "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local";
const GRUPPEN = "G-Buchhaltung, G-Alle";
/**
* Die Namensregeln, die dieser Ablauf braucht — er setzt sie selbst.
*
* Er darf sie nicht voraussetzen: `Einstellung.namensregeln` sind Betriebsdaten,
* ein Administrator ändert sie, und ein anderer Ablauf hat sie schon einmal
* geleert. Stehen sie leer, antwortet „Namen aus den Regeln vorschlagen“ mit
* „Für diesen Kunden sind keine Namensregeln hinterlegt“ — und der Ablauf
* scheiterte an einer Voraussetzung statt an einem Fehler. Die Werte sind
* dieselben, die `prisma/seed-daten/grundbestand.ts` anlegt.
*/
const REGELN = {
upn: "{v}.{nachname}@firma.example",
mail: "{v}.{nachname}@firma.example",
sam: "{v}{nachname}",
};
/** Was vor dem Ablauf in der Einstellung stand – wird hinterher zurückgelegt. */
let regelnVorher: unknown = null;
/**
* Der Kunde, dem die Abteilung „Buchhaltung“ gehört.
*
* Im Modus DIENSTLEISTUNG fragt der Start-Dialog nach einem Kunden, und die
* Datenbank verlangt, dass es **derselbe** ist wie der der gewählten Abteilung:
* `Lauf_abteilung_gleicher_kunde` lässt keine Abteilung eines fremden Kunden zu.
* Ein „nimm den ersten Kunden“ wäre dort nicht ungenau, sondern rot.
*/
let abteilungsKunde = "";
/** Die Adresse des Laufs, den der erste Ablauf anlegt. */
let laufPfad = "";
test.describe.configure({ mode: "serial" });
test.beforeAll(async () => {
const abteilung = await prisma.abteilung.findFirst({
where: { name: ABTEILUNG },
select: { zielOu: true, standardGruppen: true, kunde: { select: { name: true } } },
});
if (!abteilung) {
throw new Error(
`Die Abteilung „${ABTEILUNG}“ fehlt. Sie kommt aus dem Seed — pnpm db:seed laufen lassen.`,
);
}
abteilungsKunde = abteilung.kunde.name;
// Lieber hier scheitern als später an einem Befehl, dessen -Path nicht stimmt:
// die Erwartung unten und die Datenbank müssen dasselbe sagen.
expect(abteilung.zielOu).toBe(ZIEL_OU);
expect(abteilung.standardGruppen.join(", ")).toBe(GRUPPEN);
const einstellung = await prisma.einstellung.findUniqueOrThrow({
where: { id: "global" },
select: { namensregeln: true },
});
regelnVorher = einstellung.namensregeln;
await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: REGELN } });
});
/** Der Befehl eines Schritts, genau so wie er im `pre` steht – ohne jede Normalisierung. */
async function befehlstext(seite: Page, enthaelt: string): Promise<string> {
const zeile = seite.getByRole("listitem").filter({ hasText: enthaelt });
const text = await zeile
.locator("pre.mono")
.first()
.evaluate((feld) => feld.textContent ?? "");
return text;
}
/** Das Passwort aus dem `New-ADUser`-Befehl. */
function passwortAus(befehl: string): string {
const treffer = /ConvertTo-SecureString "([^"]+)"/u.exec(befehl);
if (!treffer?.[1]) {
throw new Error(`Im Befehl steht kein Passwort. Der Befehl lautet:\n${befehl}`);
}
return treffer[1];
}
test.describe("Onboarding", () => {
test("schlägt Namen vor, füllt OU und Gruppen und kopiert den Befehl zeichengenau", async ({
page,
context,
}) => {
test.setTimeout(180_000);
/*
* Ohne dieses Recht wirft `navigator.clipboard.writeText` in Chromium, und
* der Knopf fängt das ab (der Text bleibt markierbar) — der Ablauf sähe einen
* Knopf, der scheinbar funktioniert, und eine leere Zwischenablage.
*/
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await anmelden(page);
await page.goto("/laeufe/starten");
// ---- Vorlage am Namen wählen -------------------------------------------
const optionen = await page
.locator('select[name="vorlagenVersionId"] option')
.evaluateAll((liste) =>
liste
.filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement)
.map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })),
);
const treffer = optionen.find((eintrag) => eintrag.text.includes(VORLAGE));
if (!treffer) {
throw new Error(
`Die Vorlage „${VORLAGE}“ steht nicht zur Auswahl. Zur Wahl stehen: ` +
`${optionen.map((eintrag) => eintrag.text).join(", ")}`,
);
}
await page.selectOption('select[name="vorlagenVersionId"]', treffer.wert);
const angaben = page.locator("fieldset");
await expect(angaben).toBeVisible();
// ---- Vorname und Nachname eintragen ------------------------------------
/*
* Der Vorschlag entsteht aus dem **Freitextnamen**, nicht aus den
* Vorlagenvariablen: beim Onboarding steht die Person noch nicht im
* Verzeichnis, und der Dialog fragt genau dafür nach einem Namen.
*/
await page.getByLabel("… oder Name als Freitext").fill(`${VORNAME} ${NACHNAME}`);
await angaben.getByLabel(/^Kontotyp/u).selectOption("Active Directory");
await angaben.getByLabel(/^Vorname/u).fill(VORNAME);
await angaben.getByLabel(/^Nachname/u).fill(NACHNAME);
await angaben.getByLabel(/^Abteilung/u).fill(ABTEILUNG);
await angaben.getByLabel(/^Eintrittsdatum/u).fill("2026-10-01");
/*
* Im Modus DIENSTLEISTUNG verlangt der Dialog einen Kunden — und es muss der
* der gewählten Abteilung sein, sonst weist die Datenbank den Lauf ab
* (`Lauf_abteilung_gleicher_kunde`).
*
* Gewählt wird er **vor** dem Namensvorschlag: die Namensregeln können je
* Kunde eigene sein, und ohne Kunden antwortet die Aktion mit „Im Modus
* Dienstleistung muss ein Kunde gewählt werden“ — die Felder blieben leer,
* und der Test läse das als fehlende Regel.
*/
const kundenWahl = page.locator('select[name="kundeId"]');
if ((await kundenWahl.count()) > 0) await kundenWahl.selectOption({ label: abteilungsKunde });
// ---- Namen aus den Regeln vorschlagen ----------------------------------
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
/*
* Die Zahl am Ende ist kein Zufall: trägt die Datenbank den Namensstamm
* schon, hängt die Regel eine Unterscheidung an. Ob mit oder ohne entscheidet
* der Bestand, deshalb steht hier ein Muster und keine Zeichenkette.
*/
await expect(angaben.getByLabel(/^UPN/u)).toHaveValue(/^z\.testfall\d*@firma\.example$/u);
await expect(angaben.getByLabel(/^Anmeldename/u)).toHaveValue(/^ztestfall\d*$/u);
await expect(angaben.getByLabel(/^Mail-Adresse/u)).toHaveValue(/^z\.testfall\d*@firma\.example$/u);
const upn = await angaben.getByLabel(/^UPN/u).inputValue();
const sam = await angaben.getByLabel(/^Anmeldename/u).inputValue();
const mail = await angaben.getByLabel(/^Mail-Adresse/u).inputValue();
// Ziel-OU und Gruppen bleiben **leer**: sie sollen aus der Abteilung kommen.
await expect(angaben.getByLabel(/^Ziel-OU/u)).toHaveValue("");
await expect(angaben.getByLabel(/^Gruppen/u)).toHaveValue("");
// ---- Abteilung im aufklappbaren Teil wählen ----------------------------
const weitere = page.locator("details").filter({ hasText: "Titel, Ticket" });
await weitere.locator("summary").click();
await weitere.locator('input[name="titel"]').fill(TITEL);
await weitere.locator('select[name="abteilungId"]').selectOption({ label: ABTEILUNG });
await page.getByRole("button", { name: "Lauf anlegen" }).click();
await page.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 60_000 });
laufPfad = new URL(page.url()).pathname;
const laufId = laufPfad.split("/").pop() as string;
// ---- Der fertige New-ADUser-Befehl -------------------------------------
const anlegen = page.getByRole("listitem").filter({ hasText: "New-ADUser" });
await expect(anlegen).toHaveCount(1);
const befehl = await befehlstext(page, "New-ADUser");
expect(befehl).toContain(`New-ADUser -Name "${VORNAME} ${NACHNAME}"`);
expect(befehl).toContain(`-GivenName "${VORNAME}"`);
expect(befehl).toContain(`-Surname "${NACHNAME}"`);
expect(befehl).toContain(`-SamAccountName "${sam}"`);
expect(befehl).toContain(`-UserPrincipalName "${upn}"`);
expect(befehl).toContain(`-EmailAddress "${mail}"`);
expect(befehl).toContain(`-Department "${ABTEILUNG}"`);
// Das Abnahmekriterium, wörtlich: die Ziel-OU kommt aus der Abteilung.
expect(befehl).toContain(`-Path "${ZIEL_OU}"`);
// Und kein Platzhalter ist stehen geblieben.
expect(befehl).not.toContain("{{");
// Der Gruppen-Befehl trägt die Standardgruppen der Abteilung.
const gruppenBefehl = await befehlstext(page, "Add-ADGroupMember");
expect(gruppenBefehl).toContain(`"${GRUPPEN}".Split(",")`);
expect(gruppenBefehl).toContain(`-Members "${sam}"`);
// ---- Genau ein generiertes Passwort ------------------------------------
const passwort = passwortAus(befehl);
expect(passwort).toHaveLength(PASSWORT_LAENGE);
/*
* Ein Passwort, nicht zwei: wer `{{generiertes_passwort}}` zweimal schreibt,
* meint dasselbe. Zwei verschiedene hießen — das Konto bekommt A, der
* Benutzer erfährt B.
*/
expect(befehl.split(passwort)).toHaveLength(2);
// Und der Hinweis steht daneben, weil es nirgends gespeichert wird.
await expect(anlegen.getByText(/Passwort sofort in den Passwortmanager/)).toBeVisible();
// ---- Der Kern: die Zwischenablage, zeichengenau ------------------------
await anlegen.getByRole("button", { name: "Befehl kopieren" }).click();
await expect(anlegen.getByRole("button", { name: "Kopiert." })).toBeVisible();
const zwischenablage = await page.evaluate(() => navigator.clipboard.readText());
// `toBe` und nicht `toContain`: ein verlorenes Fluchtzeichen oder ein
// umgewandelter Zeilenumbruch ändert den Befehl, ohne ihn falsch aussehen zu
// lassen.
expect(zwischenablage).toBe(befehl);
// ---- Das Passwort steht nirgends in der Datenbank ----------------------
/*
* Erst einen Schritt speichern: das ist der Vorgang, bei dem etwas in
* `Schrittergebnis` und `Protokoll` geschrieben wird. Ohne ihn prüfte der
* folgende Abschnitt leere Tabellen.
*/
await anlegen.getByRole("button", { name: "erledigt", exact: true }).click();
// Auf die Datenbank warten, nicht auf den Bildschirm: der Protokolleintrag
// entsteht in derselben Transaktion, und genau den soll die Suche unten sehen.
await expect
.poll(async () => prisma.schrittergebnis.count({ where: { laufId, zustand: "ERLEDIGT" } }), {
timeout: 30_000,
message: "Der Schritt „Konto anlegen“ steht nicht als erledigt in der Datenbank.",
})
.toBeGreaterThan(0);
const ergebnisse = await prisma.schrittergebnis.findMany({
where: { laufId },
select: { wert: true, notiz: true },
});
const kommentare = await prisma.kommentar.findMany({ where: { laufId }, select: { rumpf: true } });
const protokolle = await prisma.protokoll.findMany({
where: { OR: [{ laufId }, { entitaetId: laufId }] },
select: { aktion: true, vorher: true, nachher: true },
});
expect(JSON.stringify({ ergebnisse, kommentare, protokolle })).not.toContain(passwort);
/*
* Und zur Sicherheit über **alle** Zeilen, nicht nur die dieses Laufs: ein
* Passwort, das an einer ganz anderen Stelle landet, wäre genauso schlimm.
* `strpos` statt `LIKE`: das Alphabet enthält `_`, und das ist in `LIKE` ein
* Platzhalter — ein Treffer wäre dann Zufall und ein Nicht-Treffer nichts wert.
*/
const spuren = await prisma.$queryRaw<
readonly { readonly ergebnisse: bigint; readonly kommentare: bigint; readonly protokoll: bigint }[]
>`
SELECT
(SELECT count(*) FROM "Schrittergebnis"
WHERE strpos(coalesce("notiz", '') || coalesce("wert"::text, ''), ${passwort}) > 0) AS "ergebnisse",
(SELECT count(*) FROM "Kommentar"
WHERE strpos("rumpf", ${passwort}) > 0) AS "kommentare",
(SELECT count(*) FROM "Protokoll"
WHERE strpos("aktion" || coalesce("vorher"::text, '') || coalesce("nachher"::text, ''), ${passwort}) > 0) AS "protokoll"
`;
expect({
ergebnisse: Number(spuren[0]?.ergebnisse),
kommentare: Number(spuren[0]?.kommentare),
protokoll: Number(spuren[0]?.protokoll),
}).toEqual({ ergebnisse: 0, kommentare: 0, protokoll: 0 });
// ---- Auch nicht auf dem Ausdruck ---------------------------------------
await page.goto(`/drucken/lauf/${laufId}`);
const blatt = (await page.locator("body").innerText()).replace(/\s+/gu, " ");
expect(blatt).not.toContain(passwort);
expect(blatt).toContain(PASSWORT_IM_DRUCK);
});
test("erzeugt bei jedem Aufbau ein anderes Passwort", async ({ page }) => {
/*
* Die Kehrseite der Zusage, und sie gehört geprüft: weil nichts gespeichert
* wird, gibt es auch keinen Weg, dasselbe Passwort noch einmal zu erfahren.
* Wer es nicht sofort weitergibt, hat es verloren — und der Techniker muss
* sich darauf verlassen können, dass das nicht heimlich anders ist.
*/
expect(laufPfad, "Der vorige Ablauf hat keinen Lauf angelegt.").not.toBe("");
await anmelden(page);
await page.goto(laufPfad);
const erstes = passwortAus(await befehlstext(page, "New-ADUser"));
await page.reload();
const zweites = passwortAus(await befehlstext(page, "New-ADUser"));
expect(zweites).not.toBe(erstes);
expect(zweites).toHaveLength(PASSWORT_LAENGE);
});
});
test.afterAll(async () => {
const laeufe = await prisma.lauf.findMany({
where: { titel: { startsWith: "ZZ E2E Onboarding" } },
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` – sonst bleibt der Lauf stehen.
await prisma.protokoll.deleteMany({
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
});
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
}
// Die Namensregeln zurücklegen, wie sie waren – sie sind Betriebsdaten.
if (regelnVorher !== null) {
await prisma.einstellung.update({
where: { id: "global" },
data: { namensregeln: regelnVorher as Record<string, string> },
});
}
await prisma.$disconnect();
});
+269
View File
@@ -0,0 +1,269 @@
import { hash } from "@node-rs/argon2";
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { geraetName, SEED_TAG, SEED_VORLAGE } from "./helfer";
/**
* Was ein `LESER` sieht und was der Server ihm verweigert (#018).
*
* Die Rechtematrix selbst prüft `tests/rollen.test.ts`, Rolle für Rolle gegen
* jedes Recht. Hier geht es um die zwei Richtungen, die eine Tabelle nicht
* prüfen kann:
*
* 1. **Die Oberfläche bietet nichts an, was der Server verbietet.** Ein Knopf,
* der zu einer Fehlermeldung führt, ist ein Versprechen, das nicht gehalten
* wird — und wer ihn sieht, hält sich für berechtigt.
* 2. **Der Server verbietet, was die Oberfläche nicht anbietet.** Das Ausblenden
* ist Bequemlichkeit, keine Absicherung: wer die Adresse kennt, ruft sie
* direkt auf. Geprüft wird deshalb jede Adresse, hinter der geschrieben wird.
*
* Der Ablauf legt seinen Leser selbst an — mit `@node-rs/argon2`, genau wie
* `prisma/seed-daten/grundbestand.ts` — und räumt ihn hinterher weg. Ein Konto
* mit Leserechten in einer Datenbank stehen zu lassen, die weiterbenutzt wird,
* wäre eine Hinterlassenschaft, die niemand erwartet.
*/
const STEMPEL = Date.now();
const LESER_EMAIL = `zz-e2e-leser-${STEMPEL}@firma.example`;
const LESER_NAME = `ZZ E2E Leser ${STEMPEL}`;
const LESER_PASSWORT = `ZZ-E2E-Leser-${STEMPEL}`;
const LAUF_TITEL = `ZZ E2E Rollen ${STEMPEL}`;
let leserId = "";
let laufId = "";
let geraetId = "";
let vorlageId = "";
test.beforeAll(async () => {
// Das Passwort wird nur als argon2-Hash gespeichert, nie im Klartext.
const leser = await prisma.benutzer.create({
data: {
email: LESER_EMAIL,
name: LESER_NAME,
rolle: "LESER",
passwortHash: await hash(LESER_PASSWORT),
aktiv: true,
},
select: { id: true },
});
leserId = leser.id;
/*
* Ein Lauf, an dem sich der Schreibschutz zeigen lässt. Angelegt über Prisma
* und nicht über die Oberfläche: hier geht es um die Rechte und nicht um den
* Weg zum Lauf — den prüft `hauptablauf.spec.ts`. Der Kunde kommt vom Gerät,
* weil ein zusammengesetzter Fremdschlüssel keinen Lauf mit dem Gerät eines
* fremden Kunden zulässt.
*/
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true, kundeId: true },
});
geraetId = geraet.id;
const vorlage = await prisma.vorlage.findFirstOrThrow({
where: { name: SEED_VORLAGE },
select: {
id: true,
versionen: {
where: { status: "VEROEFFENTLICHT" },
orderBy: { version: "desc" },
take: 1,
select: { id: true },
},
},
});
vorlageId = vorlage.id;
const versionId = vorlage.versionen[0]?.id;
if (!versionId) {
throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keine veröffentlichte Version.`);
}
const lauf = await prisma.lauf.create({
data: {
vorlagenVersionId: versionId,
kundeId: geraet.kundeId,
geraetId: geraet.id,
titel: LAUF_TITEL,
status: "IN_ARBEIT",
// `Lauf_begonnen_mit_zeitpunkt` verlangt den Zeitstempel zum Status.
gestartetAm: new Date(),
},
select: { id: true },
});
laufId = lauf.id;
});
async function alsLeserAnmelden(seite: Page): Promise<void> {
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', LESER_EMAIL);
await seite.fill('input[name="passwort"]', LESER_PASSWORT);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
test.describe("Ein Leser", () => {
test("sieht auf Läufen, Vorlagen und Geräten keine Schreib-Knöpfe", async ({ page }) => {
test.slow();
await alsLeserAnmelden(page);
// ---- Läufe --------------------------------------------------------------
await page.goto("/laeufe");
await expect(page.getByRole("heading", { name: "Läufe" })).toBeVisible();
await expect(page.getByRole("link", { name: "Lauf starten" })).toHaveCount(0);
// ---- Ein Lauf in Arbeit -------------------------------------------------
await page.goto(`/laeufe/${laufId}`);
await expect(page.getByRole("heading", { name: LAUF_TITEL })).toBeVisible();
for (const knopf of [
"Lauf beginnen",
"Lauf abschließen",
"archivieren",
"erledigt",
"nicht zutreffend",
"fehlgeschlagen",
"Wert speichern",
"Kommentar hinzufügen",
"Abschnitt trifft nicht zu",
]) {
await expect(
page.getByRole("button", { name: knopf, exact: true }),
`Ein Leser sieht auf dem Lauf den Knopf „${knopf}“.`,
).toHaveCount(0);
}
/*
* Und zwar wirklich keinen Schreibweg: im Inhaltsbereich steht für einen Leser
* **kein einziges** Formular. Die Aufzählung oben prüft Beschriftungen, dieses
* eine prüft, dass keine übersehen wurde. Gezählt wird in `main`, nicht auf der
* ganzen Seite — das Abmelden-Formular der Hülle steht daneben und gehört jedem.
*/
await expect(page.locator("main form")).toHaveCount(0);
// Die Angaben selbst stehen trotzdem da – lesen darf er ja.
await expect(page.getByRole("progressbar")).toBeVisible();
// ---- Vorlagen -----------------------------------------------------------
await page.goto("/vorlagen");
await expect(page.getByRole("heading", { name: "Vorlagen" })).toBeVisible();
await expect(page.getByRole("button", { name: "Vorlage anlegen" })).toHaveCount(0);
await expect(page.getByText("Vorlage aus einer Datei einspielen")).toHaveCount(0);
await page.goto(`/vorlagen/${vorlageId}`);
await expect(page.getByRole("button", { name: /bearbeiten/iu })).toHaveCount(0);
await expect(page.getByRole("button", { name: "archivieren" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Kopfdaten" })).toHaveCount(0);
// ---- Geräte -------------------------------------------------------------
await page.goto("/geraete");
await expect(page.getByRole("heading", { name: "Geräte" })).toBeVisible();
await expect(page.getByRole("button", { name: "Gerät anlegen" })).toHaveCount(0);
await expect(page.getByText("Geräte aus einer CSV-Datei einlesen")).toHaveCount(0);
await page.goto(`/geraete/${geraetId}`);
await expect(page.getByRole("heading", { name: new RegExp(geraetName()) })).toBeVisible();
// „Vorlage zuweisen“ und die Schnellstart-Knöpfe hängen an `laeufeBearbeiten`.
await expect(page.getByRole("heading", { name: "Vorlage zuweisen" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Starten" })).toHaveCount(0);
// ---- Mitarbeiter --------------------------------------------------------
await page.goto("/mitarbeiter");
await expect(page.getByText("Person von Hand anlegen")).toHaveCount(0);
/*
* Und die Verwaltung steht gar nicht in der Navigation. Gesucht wird **in**
* `nav` und mit `exact`: auf der Personenliste steht ein „Systemadministrator“
* als Berufsbezeichnung, und ein unscharfes „Admin“ traf ihn mit.
*/
if (test.info().project.name === "handy") await page.getByLabel("Menü").click();
await expect(page.locator("nav").getByRole("link", { name: "Admin", exact: true })).toHaveCount(0);
});
test("wird abgewiesen, wenn er die Adresse direkt aufruft", async ({ page }) => {
test.slow();
await alsLeserAnmelden(page);
/*
* `/laeufe/starten` verlangt nur `lesen`, damit ein abgelaufenes Lesezeichen
* keine 500er-Seite ergibt — und schickt dann zurück zur Liste. Wer keine
* Läufe bearbeiten darf, soll die Seite nicht als vorhanden erleben.
*/
await page.goto("/laeufe/starten");
await expect(page).toHaveURL(/\/laeufe$/u);
await expect(page.getByRole("button", { name: "Lauf anlegen" })).toHaveCount(0);
/*
* Alles unter `verwaltung` weist der Server ab. Kein geworfener Fehler,
* sondern eine Weiterleitung: für jemanden, der ein altes Lesezeichen öffnet,
* wäre eine 500er-Seite die falsche Auskunft.
*
* Geprüft wird beides: dass er **nicht auf der Seite landet** und dass er
* **erfährt, warum**. Bis #018 schickte `sitzungErforderlich` ihn nach
* `/anmelden?fehler=keinrecht` — und die Anmeldeseite schickte einen bereits
* Angemeldeten sofort weiter auf `/`. Die Erklärung war damit unerreichbar,
* er stand ohne Hinweis auf der Übersicht und wusste nicht, ob er sich
* verklickt hatte oder ob etwas kaputt ist. Seitdem führt der Weg auf
* `/kein-zugriff`.
*/
for (const adresse of [
"/admin",
"/admin/benutzer",
"/admin/abteilungen",
"/admin/verzeichnis",
"/tags",
// Der Etikettenbogen **ohne** Codes zeigt den ganzen Vorrat an noch nicht
// zugeordneten Tags – dieselbe Liste wie /tags, also dasselbe Recht.
"/drucken/etiketten",
]) {
await page.goto(adresse);
const gelandet = new URL(page.url()).pathname;
expect(gelandet, `${adresse} hat einen Leser nicht abgewiesen.`).not.toBe(adresse);
expect(gelandet, `${adresse} weist ab, ohne zu sagen warum.`).toBe("/kein-zugriff");
await expect(page.getByRole("heading", { name: "Dafür fehlt dir die Berechtigung" })).toBeVisible();
// Und die Auskunft nennt die eigene Rolle, damit klar ist, woran es liegt.
// Im Absatz gesucht: „Leser“ steht auch in der Navigation und im Namen des
// Testkontos, und ein `getByText` ohne Rahmen trifft im Modus
// DIENSTLEISTUNG sechs Stellen.
await expect(page.getByRole("main").getByText("mit der Rolle")).toContainText("Leser");
}
});
test("darf lesen, was zum Lesen da ist", async ({ page }) => {
/*
* Die Gegenprobe, und sie gehört dazu: würde hier etwas abgewiesen, wäre die
* Prüfung oben nicht der Nachweis eines Rechts, sondern der eines kaputten
* Kontos. Der Zustands-Endpunkt steht mit dabei, weil ein Route Handler die
* Hülle nicht erbt und sein Recht selbst prüfen muss (CLAUDE.md, „Rechte“).
*/
await alsLeserAnmelden(page);
for (const adresse of ["/", "/laeufe", "/vorlagen", "/mitarbeiter", "/geraete", "/archiv", "/scannen"]) {
const antwort = await page.goto(adresse);
expect(antwort?.status(), `${adresse} antwortet einem Leser nicht mit 200.`).toBe(200);
expect(new URL(page.url()).pathname, `${adresse} hat einen Leser weitergeleitet.`).toBe(adresse);
}
const zustand = await page.request.get(`/api/laeufe/${laufId}/zustand`);
expect(zustand.status()).toBe(200);
// Kein Befehl in der Antwort: sonst stünde dort ein Passwort, das die Seite
// bewusst nicht von selbst neu baut.
expect(await zustand.text()).not.toContain("Rename-Computer");
});
});
test.afterAll(async () => {
if (laufId) {
await prisma.kommentar.deleteMany({ where: { laufId } });
await prisma.schrittergebnis.deleteMany({ where: { laufId } });
await prisma.protokoll.deleteMany({ where: { OR: [{ laufId }, { entitaetId: laufId }] } });
await prisma.lauf.deleteMany({ where: { id: laufId } });
}
if (leserId) {
/*
* `Protokoll.benutzerId` steht auf `Restrict`. Ein Leser schreibt nichts, was
* protokolliert wird — aber „sollte nichts geschrieben haben“ ist kein Grund,
* das Aufräumen an einem Fremdschlüssel scheitern zu lassen.
*/
await prisma.protokoll.deleteMany({ where: { benutzerId: leserId } });
await prisma.benutzer.deleteMany({ where: { id: leserId } });
}
await prisma.$disconnect();
});
+8 -13
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import "dotenv/config";
import { anmelden, SEED_TAG } from "./helfer";
/**
* Der Einstieg ueber den Aufkleber (#014).
@@ -11,18 +12,12 @@ import "dotenv/config";
* bringt den Techniker genauso ans Ziel wie der gescannte. Das ist kein
* Ersatzweg fuer den Notfall, sondern der Weg, den jeder geht, dessen Browser
* keine Kamera freigibt.
*
* Der Code kommt aus `SEED_TAG` und nicht abgetippt aus dem Kopf: dass es ihn in
* dieser Datenbank gibt, prueft `tests/e2e/global-setup.ts` vor dem ersten
* Ablauf (#018).
*/
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"]')]);
}
test.describe("Scannen", () => {
test("ist über den Knopf in der Hülle erreichbar", async ({ page }) => {
await anmelden(page);
@@ -36,10 +31,10 @@ test.describe("Scannen", () => {
await page.goto("/scannen");
// Ohne Kamera bleibt das Feld – und es nimmt auch Kleinschreibung an.
await page.getByLabel("Code eintippen").fill("lz-7k3f9q");
await page.getByLabel("Code eintippen").fill(SEED_TAG.toLowerCase());
await page.getByRole("button", { name: "Öffnen" }).click();
await page.waitForURL("**/t/LZ-7K3F9Q");
await page.waitForURL(`**/t/${SEED_TAG}`);
});
test("lässt einen unsinnigen Code gar nicht erst abschicken", async ({ page }) => {
+57 -17
View File
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { anmelden, kundeWaehlen } from "./helfer";
/**
* Personen und Abteilungen durch die Oberflaeche (#011).
@@ -9,7 +10,10 @@ import { prisma } from "@/lib/db";
* wirklich in der Datenbank steht: durch Neuladen. Ein Screenshot sagt darueber
* nichts (siehe CLAUDE.md, „Oberflaeche pruefen“).
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`).
* Voraussetzungen prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf: ein
* **gebauter** Stand antwortet, der Seed-Bestand steht, und der Server liest
* dieselbe Datenbank wie dieser Lauf. Alles, was die Ablaeufe hier darueber
* hinaus brauchen, legen sie selbst an.
*
* Aufgeraeumt wird am Ende ueber die Datenbank und nicht ueber die Oberflaeche:
* die Anwendung kennt bewusst keinen Loeschweg fuer Personen und Abteilungen
@@ -21,20 +25,14 @@ import { prisma } from "@/lib/db";
const STEMPEL = Date.now();
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"]')]);
}
/** Nachnamen dieses Durchgangs. Beide Praefixe gehoeren dieser Datei allein. */
const PRAEFIX_HAND = "ZZPruef";
const PRAEFIX_QUELLE = "ZZQuelle";
test.describe("Personen", () => {
test("legt an, findet über die Suche und deaktiviert", async ({ page }) => {
test.slow();
const nachname = `ZZPruef${STEMPEL}`;
const nachname = `${PRAEFIX_HAND}${STEMPEL}`;
await anmelden(page);
await page.goto("/mitarbeiter");
@@ -43,6 +41,7 @@ test.describe("Personen", () => {
await page.getByLabel("Vorname").fill("Testa");
await page.getByLabel("Nachname").fill(nachname);
await page.getByLabel("Mail-Adresse (optional)").fill(`testa.${STEMPEL}@firma.de`);
await kundeWaehlen(page);
await page.getByRole("button", { name: "Person anlegen" }).click();
await expect(page.getByText(`„Testa ${nachname}“ wurde angelegt.`)).toBeVisible();
@@ -65,12 +64,51 @@ test.describe("Personen", () => {
});
test("sperrt die Stammdaten einer Person aus dem Verzeichnis", async ({ page }) => {
// Vorausgesetzt wird die Beispielperson aus `pnpm beispiel:stammdaten`.
/*
* Die Person legt dieser Ablauf selbst an.
*
* Vorher hing der Test an „Katrin Winter“ aus `pnpm beispiel:stammdaten` und
* schaltete sich mit `test.skip` ab, wenn sie fehlte — gegen eine frisch
* eingerichtete Installation also immer. Ein Test, der sich stillschweigend
* selbst abschaltet, ist keiner (#018).
*
* Gesperrt wird an der **Herkunft**, nicht an der Quelle: `ABGLEICH` allein
* entscheidet. Die Beispielquelle aus dem Seed kommt trotzdem dran, damit die
* Zeile aussieht wie eine echte — sie gibt es nur im Modus INTERN, deshalb
* bleibt `quelleId` ohne sie leer, statt den Ablauf rot zu faerben.
*/
test.slow();
const nachname = `${PRAEFIX_QUELLE}${STEMPEL}`;
const quelle = await prisma.verzeichnisquelle.findFirst({
where: { name: "Firmen-AD (Beispiel)" },
select: { id: true },
});
const kunde = await prisma.kunde.findFirstOrThrow({
where: { istEigeneOrganisation: true },
select: { id: true },
});
await prisma.mitarbeiter.create({
data: {
kundeId: kunde.id,
quelleId: quelle?.id ?? null,
externeId: quelle ? `e2e-${STEMPEL}` : null,
vorname: "Katrin",
nachname,
anzeigename: `Katrin ${nachname}`,
mail: `k.${nachname.toLowerCase()}@firma.example`,
upn: `k.${nachname.toLowerCase()}@firma.example`,
sam: `k${nachname.toLowerCase()}`,
abteilung: "Buchhaltung",
titel: "Sachbearbeiterin",
herkunft: "ABGLEICH",
aktiv: true,
},
});
await anmelden(page);
await page.goto("/mitarbeiter?suche=kwinter");
const treffer = page.getByRole("link", { name: /Katrin Winter/ });
test.skip((await treffer.count()) === 0, "Beispiel-Stammdaten fehlen (pnpm beispiel:stammdaten).");
await page.goto(`/mitarbeiter?suche=${nachname}`);
const treffer = page.getByRole("link", { name: new RegExp(nachname) });
await expect(treffer).toHaveCount(1);
await treffer.click();
await page.waitForURL(/\/mitarbeiter\/[^/]+$/);
@@ -94,6 +132,7 @@ test.describe("Abteilungen", () => {
await formular.getByLabel("Name").fill(name);
await formular.getByLabel("Ziel-OU (optional)").fill("OU=Test,DC=firma,DC=de");
await formular.getByLabel("Standardgruppen, mit Komma getrennt").fill("G-Test, G-Alle");
await kundeWaehlen(page);
await formular.getByRole("button", { name: "Abteilung anlegen" }).click();
await expect(page.getByText(`„${name}“ wurde angelegt.`)).toBeVisible();
@@ -105,6 +144,7 @@ test.describe("Abteilungen", () => {
// Denselben Namen ein zweites Mal – mit lesbarer Meldung, nicht mit einem
// Datenbankfehler.
await formular.getByLabel("Name").fill(name);
await kundeWaehlen(page);
await formular.getByRole("button", { name: "Abteilung anlegen" }).click();
await expect(page.getByText(/gibt es schon/)).toBeVisible();
@@ -116,7 +156,7 @@ test.describe("Abteilungen", () => {
test.afterAll(async () => {
const personen = await prisma.mitarbeiter.findMany({
where: { nachname: { startsWith: "ZZPruef" } },
where: { OR: [{ nachname: { startsWith: PRAEFIX_HAND } }, { nachname: { startsWith: PRAEFIX_QUELLE } }] },
select: { id: true },
});
const abteilungen = await prisma.abteilung.findMany({
+44 -24
View File
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { anmelden } from "./helfer";
/**
* Uebersicht und Druckansicht am gebauten Stand (#015).
@@ -9,17 +10,22 @@ import { prisma } from "@/lib/db";
* Dringlichkeit, die Druckansicht gehoert auf Papier. Was davon stimmt, zeigt
* erst der Browser — die Lesemodelle dahinter pruefen `tests/uebersicht.test.ts`
* und `tests/druckansicht.test.ts`.
*
* Voraussetzungen prueft `tests/e2e/global-setup.ts`. Jeden Lauf, den diese
* Ablaeufe brauchen, legen sie selbst an und raeumen ihn hinterher weg. Vorher
* holten sie sich mit `findFirstOrThrow` irgendeinen vorhandenen Lauf und
* erwarteten, dass „Zuletzt bearbeitet“ gefuellt ist — gegen eine frische
* Datenbank scheiterte beides, das eine mit „No record was found“, das andere
* mit einem Zeitablauf auf einen Verweis, den es nicht gab (#018).
*/
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"]')]);
}
const STEMPEL = Date.now();
/** Fortlaufend, damit die Laeufe eines Durchgangs sich nicht verwechseln. */
let laufende = 0;
/** Name und Titel tragen diesen Praefix – nur daran raeumt der Nachlauf auf. */
const PRAEFIX = "ZZ E2E Druck";
/**
* Ein Lauf aus einer Vorlage, die `{{generiertes_passwort}}` wirklich enthaelt.
@@ -27,12 +33,16 @@ async function anmelden(seite: Page) {
* Direkt ueber Prisma: geprueft werden soll die **Druckansicht**, nicht der Weg
* zum Lauf — den prueft `tests/e2e/laeufe.spec.ts` vom Anlegen bis zum Archiv.
*/
async function laufMitPasswortschritt(): Promise<string> {
const stempel = Date.now();
const kunde = await prisma.kunde.findFirstOrThrow({ select: { id: true } });
const vorlage = await prisma.vorlage.create({
data: { name: `ZZ E2E Druck ${stempel}`, kategorie: "ZZ Test" },
async function laufMitPasswortschritt(): Promise<{ id: string; titel: string }> {
laufende += 1;
const marke = `${PRAEFIX} ${STEMPEL}-${laufende}`;
// Ueber `istEigeneOrganisation` und nicht ueber den ersten Treffer: im Modus
// DIENSTLEISTUNG waere das ein beliebiger Demo-Kunde.
const kunde = await prisma.kunde.findFirstOrThrow({
where: { istEigeneOrganisation: true },
select: { id: true },
});
const vorlage = await prisma.vorlage.create({ data: { name: marke, kategorie: "ZZ Test" } });
const version = await prisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
@@ -53,9 +63,9 @@ async function laufMitPasswortschritt(): Promise<string> {
});
const lauf = await prisma.lauf.create({
data: { vorlagenVersionId: version.id, kundeId: kunde.id, titel: `ZZ E2E Druck ${stempel}` },
data: { vorlagenVersionId: version.id, kundeId: kunde.id, titel: marke },
});
return lauf.id;
return { id: lauf.id, titel: marke };
}
test.describe("Übersicht", () => {
@@ -66,12 +76,19 @@ test.describe("Übersicht", () => {
});
test("führt von einer Zeile in den Lauf", async ({ page }) => {
/*
* Der Ablauf bringt die Zeile mit, auf die er klickt. „Zuletzt bearbeitet“
* sortiert nach `geaendertAm` absteigend, der eben angelegte Lauf steht also
* oben — und weil der Klick auf **seinen** Titel geht, prueft der Test einen
* Weg und nicht die Datenlage.
*/
const lauf = await laufMitPasswortschritt();
await anmelden(page);
const zuletzt = page
.locator("section")
.filter({ has: page.getByRole("heading", { name: "Zuletzt bearbeitet" }) });
await zuletzt.getByRole("link").first().click();
await page.waitForURL(/\/laeufe\/[^/]+$/);
await zuletzt.getByRole("link", { name: new RegExp(lauf.titel) }).click();
await page.waitForURL(`**/laeufe/${lauf.id}`);
});
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
@@ -88,8 +105,10 @@ test.describe("Übersicht", () => {
test.describe("Druckansicht", () => {
test("ist hell, ohne Navigation und passt auf A4", async ({ page }) => {
test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät.");
// Der eigene Lauf statt `findFirstOrThrow`: gegen eine frische Datenbank gab
// es keinen, und die Meldung sprach von „No record was found“ (#018).
const lauf = await laufMitPasswortschritt();
await anmelden(page);
const lauf = await prisma.lauf.findFirstOrThrow({ select: { id: true } });
await page.goto(`/drucken/lauf/${lauf.id}`);
// Keine Hülle: weder Seitenleiste noch der Scannen-Knopf gehören aufs Blatt.
@@ -123,9 +142,9 @@ test.describe("Druckansicht", () => {
* dann bestanden, wenn die Druckansicht Passwörter erzeugte (Reviewbefund
* zu #015).
*/
const laufId = await laufMitPasswortschritt();
const lauf = await laufMitPasswortschritt();
await anmelden(page);
await page.goto(`/laeufe/${laufId}`);
await page.goto(`/laeufe/${lauf.id}`);
await page.getByRole("link", { name: "Drucken" }).click();
await page.waitForURL(/\/drucken\/lauf\//);
@@ -146,7 +165,7 @@ test.describe("Druckansicht", () => {
test.afterAll(async () => {
const laeufe = await prisma.lauf.findMany({
where: { titel: { startsWith: "ZZ E2E Druck" } },
where: { titel: { startsWith: PRAEFIX } },
select: { id: true },
});
const kennungen = laeufe.map((lauf) => lauf.id);
@@ -157,6 +176,7 @@ test.afterAll(async () => {
});
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
}
await prisma.vorlage.deleteMany({ where: { name: { startsWith: "ZZ E2E Druck" } } });
// Die Vorlagen zuletzt: an ihnen hingen gerade noch die Laeufe.
await prisma.vorlage.deleteMany({ where: { name: { startsWith: PRAEFIX } } });
await prisma.$disconnect();
});
+93 -39
View File
@@ -1,6 +1,10 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test, type Locator, type Page } from "@playwright/test";
import "dotenv/config";
import { verschluesseln } from "@/lib/geheimnisse";
import { AD_ZUORDNUNG } from "@/lib/verzeichnis";
import { prisma } from "@/lib/db";
import { STANDARD_FILTER } from "@/server/ldap/zuordnung";
import { anmelden, betriebsmodus } from "./helfer";
/**
* Admin → Verzeichnis durch die Oberflaeche (#016).
@@ -10,73 +14,108 @@ import { prisma } from "@/lib/db";
* dass eine gute wirklich in der Datenbank landet. Was der Abgleich **tut**,
* steht in `tests/abgleich.test.ts` gegen einen echten Server.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`).
* Im Entwicklungsmodus haengt React sich hier nicht an, und dann tut kein Knopf
* etwas (siehe CLAUDE.md, „Oberflaeche pruefen“).
* Voraussetzungen prueft `tests/e2e/global-setup.ts`. Die Quelle, an der hier
* gearbeitet wird, legt der Ablauf selbst an und loescht sie hinterher: vorher
* nahm er die erste vorhandene, schaltete sich mit `test.skip` ab, wenn es keine
* gab, und schrieb die Attributzuordnung einer fremden — im Zweifel produktiven
* — Quelle um (#018). Angefasst wird nur der Kasten mit dem eigenen Namen.
*
* Die Seite gibt es nur im Modus INTERN (#008); im Modus DIENSTLEISTUNG leitet
* sie um, und dann schlaegt dieser Ablauf fehl. Das ist die richtige Antwort:
* die Ablaeufe pruefen den Modus aus der `.env`, und der ist INTERN.
*/
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"]')]);
/*
* Das Verzeichnis gibt es **nur** im Modus INTERN (#008): im Modus
* DIENSTLEISTUNG leitet die Seite auf `/admin` um, und es gibt nichts zu
* pruefen. Uebersprungen mit Grund — ein Ablauf, der dort rot wuerde, sagte
* etwas ueber den Modus und nichts ueber das Verzeichnis.
*/
test.skip(betriebsmodus() === "DIENSTLEISTUNG", "Das Verzeichnis gibt es nur im Modus INTERN (#008).");
const STEMPEL = Date.now();
const QUELLEN_NAME = `ZZ E2E Verzeichnis ${STEMPEL}`;
/** Der Kasten dieser Quelle – alles andere auf der Seite bleibt unberührt. */
function kasten(seite: Page): Locator {
return seite.locator("li").filter({ has: seite.getByRole("heading", { name: QUELLEN_NAME }) });
}
test.beforeAll(async () => {
const kunde = await prisma.kunde.findFirstOrThrow({
where: { istEigeneOrganisation: true },
select: { id: true },
});
await prisma.verzeichnisquelle.create({
data: {
kundeId: kunde.id,
name: QUELLEN_NAME,
url: "ldaps://dc-e2e.firma.local:636",
bindDn: "CN=e2e,OU=Dienstkonten,DC=firma,DC=local",
// Der CHECK in der Datenbank verlangt das Formatpraefix; verschluesselt wird
// deshalb wirklich und nicht mit einer Zeichenkette getan.
bindPasswortVerschluesselt: verschluesseln(`e2e-${STEMPEL}`),
basisDn: "OU=Benutzer,DC=firma,DC=local",
filter: STANDARD_FILTER,
attributZuordnung: { ...AD_ZUORDNUNG },
abgleichIntervallMinuten: 60,
// Kein Zeitplan: eine aktive Quelle auf einen Rechner, den es nicht gibt,
// liesse den Abgleich im Hintergrund in Fehler laufen.
aktiv: false,
},
});
});
test.describe("Verzeichnis", () => {
test("macht die Attributzuordnung bedienbar und prüft sie", async ({ page }) => {
test.slow();
const quelle = await prisma.verzeichnisquelle.findFirst({ orderBy: { name: "asc" } });
test.skip(quelle === null, "Keine Verzeichnisquelle im Bestand.");
if (!quelle) return;
const vorher = quelle.attributZuordnung;
await anmelden(page);
await page.goto("/admin/verzeichnis");
await page.locator("summary", { hasText: "Einstellungen bearbeiten" }).first().click();
await page.locator("summary", { hasText: "Attributzuordnung anpassen" }).first().click();
const meine = kasten(page);
await expect(meine).toHaveCount(1);
await meine.locator("summary", { hasText: "Einstellungen bearbeiten" }).click();
await meine.locator("summary", { hasText: "Attributzuordnung anpassen" }).click();
const kennung = page.locator('input[name="attr.externeId"]').first();
const kennung = meine.locator('input[name="attr.externeId"]');
await expect(kennung).toBeVisible();
await expect(kennung).toHaveValue("objectGUID");
// Eine Klammer darf nicht in eine LDAP-Anfrage wandern.
const titel = page.locator('input[name="attr.titel"]').first();
const titel = meine.locator('input[name="attr.titel"]');
await titel.fill("(objectClass=*)");
await page.getByRole("button", { name: "Speichern" }).first().click();
await expect(page.locator('[role="alert"]').first()).toContainText("kein Attributname");
await meine.getByRole("button", { name: "Speichern" }).click();
await expect(meine.locator('[role="alert"]').first()).toContainText("kein Attributname");
// Und eine gute Eingabe steht danach wirklich in der Datenbank.
await titel.fill("personalTitle");
await page.getByRole("button", { name: "Speichern" }).first().click();
await expect(page.locator('[role="status"]').last()).toContainText("gespeichert");
await meine.getByRole("button", { name: "Speichern" }).click();
await expect(meine.locator('[role="status"]').last()).toContainText("gespeichert");
const nachher = await prisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
const nachher = await prisma.verzeichnisquelle.findFirstOrThrow({ where: { name: QUELLEN_NAME } });
expect((nachher.attributZuordnung as Record<string, string>)["titel"]).toBe("personalTitle");
// Neu geladen steht sie auch im Formular – sonst waere sie nur gespeichert,
// aber nicht bearbeitbar.
await page.reload();
await page.locator("summary", { hasText: "Einstellungen bearbeiten" }).first().click();
await page.locator("summary", { hasText: "Attributzuordnung anpassen" }).first().click();
await expect(page.locator('input[name="attr.titel"]').first()).toHaveValue("personalTitle");
await prisma.verzeichnisquelle.update({
where: { id: quelle.id },
data: { attributZuordnung: vorher === null ? {} : vorher },
});
const nochmal = kasten(page);
await nochmal.locator("summary", { hasText: "Einstellungen bearbeiten" }).click();
await nochmal.locator("summary", { hasText: "Attributzuordnung anpassen" }).click();
await expect(nochmal.locator('input[name="attr.titel"]')).toHaveValue("personalTitle");
});
test("zeigt das Abgleich-Log und trägt am Handy ohne horizontales Scrollen", async ({ page }) => {
await anmelden(page);
await page.goto("/admin/verzeichnis");
const log = page.locator("summary", { hasText: "Abgleich-Log" }).first();
if ((await log.count()) > 0) {
await log.click();
await expect(page.locator("details", { has: log })).toBeVisible();
}
const meine = kasten(page);
/*
* Ohne Bedingung: die eigene Quelle steht da, also steht auch ihr Log da.
* Vorher hing der Klick an einem `if (count > 0)` — fehlte der Aufklapper,
* bestand der Test, ohne etwas geprueft zu haben.
*/
const log = meine.locator("summary", { hasText: "Abgleich-Log" });
await log.click();
// Noch nie abgeglichen – und genau das sagt die Seite, statt leer zu bleiben.
await expect(meine.getByText(/Noch kein Lauf/)).toBeVisible();
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
@@ -85,3 +124,18 @@ test.describe("Verzeichnis", () => {
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
});
});
test.afterAll(async () => {
// An dieser Quelle haengt keine Person: sie war nie aktiv und hat nie
// abgeglichen. Haengte eine, verweigerte der Fremdschluessel das Loeschen.
const quellen = await prisma.verzeichnisquelle.findMany({
where: { name: { startsWith: "ZZ E2E Verzeichnis" } },
select: { id: true },
});
const kennungen = quellen.map((quelle) => quelle.id);
if (kennungen.length > 0) {
await prisma.protokoll.deleteMany({ where: { entitaetId: { in: kennungen } } });
await prisma.verzeichnisquelle.deleteMany({ where: { id: { in: kennungen } } });
}
await prisma.$disconnect();
});
+41 -20
View File
@@ -1,5 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { anmelden } from "./helfer";
/**
* Der Vorlagen-Editor durch die Oberflaeche (#010).
@@ -10,31 +12,20 @@ import "dotenv/config";
* angehaengtem React gelingt — anlegen, tippen, sortieren, speichern — und
* anschliessend, ob es wirklich in der Datenbank steht: durch Neuladen.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt. Gegen `pnpm dev`
* schlaegt das hier fehl, und das ist richtig so — siehe CLAUDE.md,
* „Oberflaeche pruefen“.
* Voraussetzungen prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf: ein
* **gebauter** Stand antwortet, der Seed-Bestand steht, und der Server liest
* dieselbe Datenbank wie dieser Lauf. Gegen `pnpm dev` schlaegt das hier fehl,
* und das ist richtig so — siehe CLAUDE.md, „Oberflaeche pruefen“.
*
* Der Ablauf legt seine eigene Vorlage an und **archiviert** sie am Ende. Nicht
* loescht: eine veroeffentlichte Version laesst sich nicht mehr entfernen, auch
* nicht ueber einen Kaskadenloeschvorgang — der Trigger aus #009 verweigert das
* ausdruecklich, und genau das ist die Zusage, an der das ganze Archiv haengt.
* Wer diese Tests oft laufen laesst, braucht deshalb eine Wegwerf-Datenbank
* (kommt mit #018).
* Der Ablauf legt seine eigene Vorlage an, **archiviert** sie ueber die
* Oberflaeche — das ist der Weg, den die Anwendung kennt, geloescht wird dort
* nichts — und raeumt sie hinterher ueber die Datenbank weg. Ohne diesen
* Nachlauf blieb nach jedem Durchgang eine archivierte „ZZ E2E Editor“-Vorlage
* stehen; in der Entwicklungsdatenbank lagen davon zuletzt ueber hundert (#018).
*/
const NAME = `ZZ E2E Editor ${Date.now()}`;
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"]')]);
}
/** Legt die Vorlage an und fuehrt in ihren Entwurf. Gibt die Adresse des Editors zurueck. */
async function entwurfAnlegen(seite: Page, name: string): Promise<string> {
await seite.goto("/vorlagen");
@@ -176,3 +167,33 @@ test.describe("Vorlagen-Editor", () => {
await aufraeumen(page, name);
});
});
test.afterAll(async () => {
/*
* Aufgeraeumt wird ueber die Datenbank, weil die Anwendung fuer eine Vorlage
* bewusst keinen Loeschweg kennt — archivieren ist das Gegenteil von
* Aufraeumen. Die veroeffentlichte Version steht dem nicht im Weg: der Trigger
* aus #009 schuetzt Abschnitte und Schritte einer veroeffentlichten Version,
* und beim Kaskadenloeschen ist die Version bereits fort, wenn sie fallen.
*
* An diesen Vorlagen haengt kein Lauf — dieser Ablauf startet keinen. Haengte
* einer, verweigerte der Fremdschluessel das Loeschen, und das waere richtig.
*/
const vorlagen = await prisma.vorlage.findMany({
where: { name: { startsWith: "ZZ E2E Editor" } },
select: { id: true, versionen: { select: { id: true } } },
});
if (vorlagen.length === 0) {
await prisma.$disconnect();
return;
}
const kennungen = vorlagen.flatMap((vorlage) => [
vorlage.id,
...vorlage.versionen.map((version) => version.id),
]);
// Das Protokoll haelt Anlegen und Veroeffentlichen fest; `entitaetId` ist kein
// Fremdschluessel, die Zeilen bleiben also sonst als Waisen stehen.
await prisma.protokoll.deleteMany({ where: { entitaetId: { in: kennungen } } });
await prisma.vorlage.deleteMany({ where: { id: { in: vorlagen.map((zeile) => zeile.id) } } });
await prisma.$disconnect();
});