feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
This commit was merged in pull request #36.
This commit is contained in:
+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