feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen und archiviert. Der Archiv-Schreibschutz sitzt in genau einer Funktion und wird vor jeder Aenderung an Kopfdaten, Schrittergebnis und Kommentar gerufen; abgeschlossen bleibt aenderbar, archiviert nicht. Die Abarbeitungsansicht speichert jeden Schritt einzeln, frischt sich alle zehn Sekunden auf und pausiert im Hintergrund. Sie traegt in der Polling-Antwort keine Befehle und laedt sich auch nicht von selbst neu, solange ein generiertes Passwort auf der Seite steht: beides tauschte es dem Techniker zwischen Lesen und Einfuegen aus. Der unabhaengige Review fand zwei Blocker: ein Befehl konnte zwei verschiedene Passwoerter tragen (Konto bekommt A, Benutzer erfaehrt B), und „nicht zutreffend“ liess sich nicht zuruecknehmen, waehrend die Oberflaeche behauptete, nichts sei gewaehlt. Beides behoben und in Tests festgehalten. Closes #13 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #27.
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Ein Lauf von vorn bis hinten durch die Oberflaeche (#013).
|
||||
*
|
||||
* Das ist der Hauptablauf des ganzen Auftrags: Lauf starten, abarbeiten,
|
||||
* abschliessen, archivieren — und danach ist er unveraenderlich. Geprueft wird,
|
||||
* was nur mit angehaengtem React gelingt, und danach, ob es wirklich in der
|
||||
* Datenbank steht: durch Neuladen.
|
||||
*
|
||||
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
|
||||
* `pnpm beispiel:vorlage` hat die Beispielvorlage angelegt.
|
||||
*/
|
||||
|
||||
const STEMPEL = Date.now();
|
||||
const TITEL = `ZZ E2E Lauf ${STEMPEL}`;
|
||||
|
||||
async function anmelden(seite: Page) {
|
||||
const email = process.env["SEED_ADMIN_EMAIL"];
|
||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
||||
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
|
||||
await seite.goto("/anmelden");
|
||||
await seite.fill('input[name="email"]', email);
|
||||
await seite.fill('input[name="passwort"]', passwort);
|
||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
||||
}
|
||||
|
||||
/** Legt einen Lauf an und gibt seine Adresse zurueck. */
|
||||
async function laufStarten(seite: Page, titel: string): Promise<string> {
|
||||
await seite.goto("/laeufe/starten");
|
||||
await seite.selectOption('select[name="vorlagenVersionId"]', { index: 1 });
|
||||
await seite.selectOption('select[name="geraetId"]', { index: 1 });
|
||||
await seite.locator("details summary", { hasText: "Titel, Ticket" }).click();
|
||||
await seite.getByLabel("Titel", { exact: true }).fill(titel);
|
||||
|
||||
// Pflichtvariablen der Vorlage fuellen – welche es sind, weiss nur die Vorlage.
|
||||
for (const feld of await seite.locator("fieldset select").all()) {
|
||||
await feld.selectOption({ index: 1 });
|
||||
}
|
||||
for (const feld of await seite.locator('fieldset input[type="number"]').all()) {
|
||||
await feld.fill("16");
|
||||
}
|
||||
|
||||
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
|
||||
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
|
||||
return new URL(seite.url()).pathname;
|
||||
}
|
||||
|
||||
test.describe("Ein Lauf von vorn bis hinten", () => {
|
||||
test("starten, abarbeiten, abschließen, archivieren", async ({ page }) => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
const pfad = await laufStarten(page, TITEL);
|
||||
|
||||
// Ein neuer Lauf ist **offen** – das ist die Zuweisung, nicht der Start.
|
||||
await expect(page.getByRole("button", { name: "Lauf beginnen" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Lauf beginnen" }).click();
|
||||
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeVisible();
|
||||
|
||||
// Solange Pflichtschritte offen sind, ist der Abschluss gesperrt.
|
||||
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeDisabled();
|
||||
|
||||
/*
|
||||
* Alle Schritte erledigen – einzeln und abschnittsweise, so wie ein
|
||||
* Techniker es täte. Nach jedem Haken wird auf den **Zähler im Kopf des
|
||||
* Abschnitts** gewartet: der steht erst, wenn der Server gespeichert hat.
|
||||
* Ohne dieses Warten trifft der nächste Klick einen Knopf aus dem alten
|
||||
* Baum — und weil ein Klick auf einen schon gehakten Schritt ihn
|
||||
* zurücknimmt, zählte der Test rückwärts.
|
||||
*/
|
||||
const abschnitte = page.locator("section").filter({ has: page.locator("button[aria-expanded]") });
|
||||
const anzahlAbschnitte = await abschnitte.count();
|
||||
expect(anzahlAbschnitte).toBeGreaterThan(0);
|
||||
|
||||
for (let i = 0; i < anzahlAbschnitte; i += 1) {
|
||||
const abschnitt = abschnitte.nth(i);
|
||||
const kopf = abschnitt.locator("button[aria-expanded]").first();
|
||||
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
|
||||
|
||||
// Über die Haken zählen, nicht über `listitem`: die Kommentare eines
|
||||
// Schritts stehen ebenfalls in einer Liste und zählten sonst mit.
|
||||
const schritte = await abschnitt.getByRole("button", { name: "erledigt", exact: true }).count();
|
||||
for (let j = 0; j < schritte; j += 1) {
|
||||
await abschnitt.getByRole("button", { name: "erledigt", exact: true }).nth(j).click();
|
||||
await expect(kopf).toContainText(`${j + 1} von ${schritte}`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Der Beweis, dass es nicht nur im Browser steht: neu laden. Alles ist vom
|
||||
* Tisch, also sind alle Abschnitte zugeklappt — erst wieder aufklappen und
|
||||
* dann nachsehen.
|
||||
*/
|
||||
await page.reload();
|
||||
const ersterAbschnitt = abschnitte.first();
|
||||
await ersterAbschnitt.locator("button[aria-expanded]").first().click();
|
||||
await expect(
|
||||
ersterAbschnitt.getByRole("button", { name: "erledigt", exact: true }).first(),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Lauf abschließen" }).click();
|
||||
await expect(page.getByRole("button", { name: "archivieren" })).toBeVisible();
|
||||
|
||||
// Archivieren ist die zweite, ausdrückliche Handlung – mit Rückfrage.
|
||||
await page.getByRole("button", { name: "archivieren" }).click();
|
||||
await page.getByRole("button", { name: "Ja, archivieren" }).click();
|
||||
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
|
||||
|
||||
// Und danach ändert sich nichts mehr: keine Knöpfe, kein Kommentarfeld.
|
||||
await page.goto(pfad);
|
||||
await expect(page.getByRole("button", { name: "erledigt", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Kommentar hinzufügen" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("verlangt für „nicht zutreffend“ eine Begründung", async ({ page }) => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await laufStarten(page, `${TITEL} B`);
|
||||
await page.getByRole("button", { name: "Lauf beginnen" }).click();
|
||||
|
||||
// Der Klick allein speichert nicht – erst erscheint das Begründungsfeld.
|
||||
await page.getByRole("button", { name: "nicht zutreffend" }).first().click();
|
||||
const speichern = page.getByRole("button", { name: "Mit Begründung speichern" });
|
||||
await expect(speichern).toBeVisible();
|
||||
await expect(speichern).toBeDisabled();
|
||||
|
||||
await page.getByLabel("Begründung").fill("Dieser Kunde hat kein Active Directory.");
|
||||
await expect(speichern).toBeEnabled();
|
||||
await speichern.click();
|
||||
|
||||
/*
|
||||
* Erst auf den gespeicherten Stand warten, dann neu laden: die Aktion
|
||||
* frischt die Seite selbst auf, und ein `reload` mitten hinein bricht sie
|
||||
* ab. Am Handy war dieses Fenster groß genug, dass der Test daran
|
||||
* gescheitert ist.
|
||||
*/
|
||||
const schritt = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" });
|
||||
await expect(schritt.getByText("nicht zutreffend", { exact: true }).last()).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
// Die Begründung steht im Feld, wo sie sich auch ändern lässt – und nur dort.
|
||||
await expect(schritt.getByLabel("Begründung")).toHaveValue("Dieser Kunde hat kein Active Directory.");
|
||||
});
|
||||
|
||||
test("nimmt „nicht zutreffend“ auf Klick wieder zurück", async ({ page }) => {
|
||||
/*
|
||||
* Der Blocker aus dem Review zu #013: ein Klick auf die schon gewählte Pille
|
||||
* setzte den Zustand nur im Browser zurück. Auf dem Bildschirm war danach
|
||||
* nichts mehr gewählt, in der Datenbank stand weiter „nicht zutreffend“ —
|
||||
* und es gab keinen Weg mehr, das zu ändern.
|
||||
*/
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await laufStarten(page, `${TITEL} F`);
|
||||
await page.getByRole("button", { name: "Lauf beginnen" }).click();
|
||||
|
||||
const schritt = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" });
|
||||
const pille = schritt.getByRole("button", { name: "nicht zutreffend" });
|
||||
await pille.click();
|
||||
await schritt.getByLabel("Begründung").fill("Gerät ist neu ausgepackt.");
|
||||
await schritt.getByRole("button", { name: "Mit Begründung speichern" }).click();
|
||||
await expect(pille).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
// Zurücknehmen braucht keine Begründung – es nimmt ja gerade eine zurück.
|
||||
await pille.click();
|
||||
// Erst das Speichern abwarten: ein `reload` mitten in der Aktion bricht sie ab.
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.reload();
|
||||
await expect(pille).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(schritt.getByLabel("Begründung")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("zeigt einen Befehl mit eingesetzten Werten und einem Kopier-Knopf", async ({ page }) => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await laufStarten(page, `${TITEL} C`);
|
||||
|
||||
/*
|
||||
* Der Befehl **am Schritt**, nicht die Skript-Kopie: die steckt in einem
|
||||
* zugeklappten `details` und ist deshalb unsichtbar, bis jemand sie
|
||||
* aufklappt.
|
||||
*/
|
||||
const befehl = page.locator("li pre.mono").first();
|
||||
await expect(befehl).toBeVisible();
|
||||
await expect(befehl).not.toContainText("{{hostname}}");
|
||||
await expect(page.getByRole("button", { name: "Befehl kopieren" }).first()).toBeVisible();
|
||||
await expect(page.getByText("Alle Befehle als Skript kopieren")).toBeVisible();
|
||||
});
|
||||
|
||||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await laufStarten(page, `${TITEL} D`);
|
||||
|
||||
const masse = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
sichtbar: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Der Zustands-Endpunkt", () => {
|
||||
/*
|
||||
* Geprueft wird hier der **Route Handler**, nicht die Abfrage darunter: dass
|
||||
* er sein Recht selbst prueft, einen fremden oder unbekannten Lauf als „nicht
|
||||
* gefunden“ beantwortet und nichts zwischenspeichern laesst. Die Huelle unter
|
||||
* `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab — das ist genau der
|
||||
* Grund, warum es diesen Test gibt (Reviewbefund zu #013).
|
||||
*/
|
||||
test("antwortet ohne Anmeldung mit 401", async ({ request }) => {
|
||||
const antwort = await request.get("/api/laeufe/egal/zustand");
|
||||
expect(antwort.status()).toBe(401);
|
||||
});
|
||||
|
||||
test("liefert angemeldet den Zustand ohne Befehle und ohne Zwischenspeicher", async ({ page }) => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
const pfad = await laufStarten(page, `${TITEL} E`);
|
||||
const laufId = pfad.split("/").pop();
|
||||
|
||||
const antwort = await page.request.get(`/api/laeufe/${laufId}/zustand`);
|
||||
expect(antwort.status()).toBe(200);
|
||||
expect(antwort.headers()["cache-control"]).toBe("no-store");
|
||||
|
||||
const zustand: unknown = await antwort.json();
|
||||
expect(Object.keys(zustand as object).sort()).toEqual([
|
||||
"anzahlKommentare",
|
||||
"anzahlPflichtOffen",
|
||||
"geaendertAm",
|
||||
"schritte",
|
||||
"status",
|
||||
]);
|
||||
expect(JSON.stringify(zustand)).not.toContain("Rename-Computer");
|
||||
});
|
||||
|
||||
test("gibt einen Lauf, den es nicht gibt, als 404 zurück", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
const antwort = await page.request.get("/api/laeufe/gibtsnicht/zustand");
|
||||
expect(antwort.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Namensvorschläge im Start-Dialog", () => {
|
||||
/*
|
||||
* Der Onboarding-Fall: die Person steht noch nicht im Verzeichnis, ihre
|
||||
* Anmeldenamen entstehen erst hier. Die Regeln liegen im Dienst (#012) — dass
|
||||
* der Dialog sie auch wirklich erreicht, sieht man nur so.
|
||||
*/
|
||||
const VORLAGE = `ZZ E2E Onboarding ${STEMPEL}`;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await prisma.einstellung.update({
|
||||
where: { id: "global" },
|
||||
data: {
|
||||
namensregeln: {
|
||||
upn: "{vorname}.{nachname}@firma.de",
|
||||
sam: "{v}{nachname}",
|
||||
mail: "{vorname}.{nachname}@firma.de",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const vorlage = await prisma.vorlage.create({ data: { name: VORLAGE, kategorie: "ZZ Test" } });
|
||||
const version = await prisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
|
||||
});
|
||||
for (const [position, schluessel] of ["upn", "sam", "mail"].entries()) {
|
||||
await prisma.vorlagenVariable.create({
|
||||
data: { versionId: version.id, schluessel, beschriftung: schluessel, position },
|
||||
});
|
||||
}
|
||||
const abschnitt = await prisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Konto" },
|
||||
});
|
||||
await prisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Konto angelegt" },
|
||||
});
|
||||
await prisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
});
|
||||
});
|
||||
|
||||
/** Waehlt die Testvorlage – ueber den Wert, nicht ueber die Beschriftung. */
|
||||
async function vorlageWaehlen(seite: Page) {
|
||||
const wert = await seite
|
||||
.locator('select[name="vorlagenVersionId"] option', { hasText: VORLAGE })
|
||||
.getAttribute("value");
|
||||
if (!wert) throw new Error(`Die Vorlage „${VORLAGE}“ steht nicht zur Auswahl.`);
|
||||
await seite.selectOption('select[name="vorlagenVersionId"]', wert);
|
||||
}
|
||||
|
||||
test("füllt upn, sam und mail nach den Regeln", async ({ page }) => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await page.goto("/laeufe/starten");
|
||||
await vorlageWaehlen(page);
|
||||
await page.getByLabel("… oder Name als Freitext").fill("Jörg Müller-Lüdenscheid");
|
||||
|
||||
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
|
||||
|
||||
/*
|
||||
* Genau der Fall aus dem Auftrag: Umlaute gefaltet, `sam` auf 20 Zeichen
|
||||
* gekuerzt. Die Zahl am Ende ist kein Zufall — traegt die Datenbank den
|
||||
* Namen schon (die Beispieldaten tun es), haengt die Regel einen Zaehler an.
|
||||
* Ob mit oder ohne, entscheidet der Bestand, deshalb steht hier ein Muster.
|
||||
*/
|
||||
await expect(page.getByLabel("upn")).toHaveValue(/^joerg\.mueller-luedenscheid\d*@firma\.de$/u);
|
||||
await expect(page.getByLabel("sam")).toHaveValue(/^jmuellerluedensch\d*$/u);
|
||||
await expect(page.getByLabel("mail")).toHaveValue(/^joerg\.mueller-luedenscheid\d*@firma\.de$/u);
|
||||
});
|
||||
|
||||
test("überschreibt nichts, was schon dasteht", async ({ page }) => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await page.goto("/laeufe/starten");
|
||||
await vorlageWaehlen(page);
|
||||
await page.getByLabel("… oder Name als Freitext").fill("Maria Huber");
|
||||
await page.getByLabel("upn").fill("chefin@firma.de");
|
||||
|
||||
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
|
||||
|
||||
await expect(page.getByLabel("sam")).toHaveValue(/^mhuber\d*$/u);
|
||||
await expect(page.getByLabel("upn")).toHaveValue("chefin@firma.de");
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
const vorlage = await prisma.vorlage.findFirst({
|
||||
where: { name: VORLAGE },
|
||||
select: { id: true },
|
||||
});
|
||||
if (vorlage) await prisma.vorlage.delete({ where: { id: vorlage.id } });
|
||||
await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: {} } });
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
/*
|
||||
* Aufgeraeumt wird ueber die Datenbank: die Anwendung kennt fuer einen Lauf
|
||||
* keinen Loeschweg, und das ist richtig so. Ohne diesen Nachlauf saehe jede
|
||||
* Datenbank, gegen die diese Tests laufen, nach ein paar Durchlaeufen aus wie
|
||||
* ein Messie-Keller.
|
||||
*/
|
||||
const laeufe = await prisma.lauf.findMany({
|
||||
where: { titel: { startsWith: "ZZ E2E Lauf" } },
|
||||
select: { id: true },
|
||||
});
|
||||
const kennungen = laeufe.map((lauf) => lauf.id);
|
||||
if (kennungen.length > 0) {
|
||||
await prisma.kommentar.deleteMany({ where: { laufId: { in: kennungen } } });
|
||||
await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } });
|
||||
// `Protokoll.laufId` steht auf `Restrict` – ohne diese Zeile bleibt der Lauf
|
||||
// stehen, und der Fremdschluessel meldet sich.
|
||||
await prisma.protokoll.deleteMany({
|
||||
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
|
||||
});
|
||||
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Reference in New Issue
Block a user