feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme
Neun Kapitel unter docs/anleitung/ fuer Technik und Verwaltung, mit 35 Bildern aus `pnpm docs:screenshots` und einem Fehlerbehebungskapitel aus echten Meldungen. `pnpm docs:pdf` macht daraus 72 Seiten A4 — hell gedruckt, mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen, ohne neue Abhaengigkeit. Die Ablaeufe pruefen ihre Voraussetzungen, bevor der erste startet: antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der Server dieselbe Datenbank wie der Testprozess. Jeder Ablauf legt an, was er braucht, und raeumt es weg. Vier neue kommen dazu: Hauptablauf, Onboarding mit Vergleich der Zwischenablage, Rollen und 390 px auf achtzehn Seiten. Drei Fehler kamen dabei heraus. Das Projekt „handy" pruefte 412 px statt der verlangten 390. Ein angemeldeter Benutzer ohne Recht erfuhr nie, warum er abgewiesen wurde — die Meldung stand auf einer Seite, die ihn sofort weiterleitet; jetzt gibt es /kein-zugriff. Und der Verzeichnis-Ablauf schrieb die Attributzuordnung der ersten vorhandenen Quelle um, in einer echten Installation also der produktiven. 101 Ablaeufe in INTERN und 97 in DIENSTLEISTUNG, beide ohne Fehlschlag. Closes #18 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9bd198a531
commit
10e272654f
+41
-21
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user