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). * * Das ist der Ablauf, fuer den die Anwendung gebaut ist: scannen, sehen was zu * tun ist, loslegen. Geprueft wird er am **gebauten** Stand, weil genau hier die * Teile zusammenkommen, die einzeln schon funktionierten — die Weiche unter * `/t/`, 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(); /** Praefix der Geraete, die dieser Ablauf anlegt – und nur dieser. */ const HOSTNAME_PRAEFIX = "ZZ-E2E-TAG-"; /** * Eine schlichte Vorlage **ohne** Pflichtangaben. * * Die Beispielvorlage verlangt Betriebssystem und Einbindung; „Vorlage zuweisen“ * fragt beides nicht ab und fuehrt dafuer in den Start-Dialog. Dieser Test will * aber den kurzen Weg pruefen, also bringt er seine eigene Vorlage mit. */ async function vorlageAnlegen(): Promise { const name = `ZZ E2E Geraetelauf ${STEMPEL}`; const vorlage = await prisma.vorlage.create({ data: { name, kategorie: "ZZ Test" } }); const version = await prisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" }, }); const abschnitt = await prisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: "Prüfen" }, }); await prisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Gerät angesehen", pflicht: true }, }); await prisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() }, }); return name; } /** Die Tags, die dieser Lauf angelegt hat – nur die raeumt er hinterher weg. */ const eigeneTags: string[] = []; /** Legt einen vorgedruckten Tag an – ueber den Dienst, nicht ueber die Oberflaeche. */ async function tagAnlegen(): Promise { const { tagsErzeugen } = await import("@/server/services/tags"); const admin = await prisma.benutzer.findFirstOrThrow({ where: { rolle: "ADMIN" }, select: { id: true, rolle: true }, }); /* * 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); return code; } test.describe("Vom Aufkleber zur Arbeit", () => { test("führt einen unbekannten Code nicht ins Leere", async ({ page }) => { await anmelden(page); await page.goto("/t/LZ-ZZZZZZ"); await expect(page.getByRole("heading", { name: "Kein Laufzettel-Tag" })).toBeVisible(); await page.getByRole("link", { name: "Gerät suchen" }).click(); await page.waitForURL("**/geraete"); }); test("schickt einen nicht angemeldeten Scan über die Anmeldung ans Ziel", async ({ page }) => { /* * Das erste Akzeptanzkriterium des Auftrags, und der Fall, den ein Techniker * am häufigsten hat: das Handy scannt, die Sitzung ist abgelaufen. Danach * 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.findUniqueOrThrow({ where: { tagCode: SEED_TAG }, select: { id: true, hostname: true }, }); await page.context().clearCookies(); await page.goto(`/t/${SEED_TAG}`); await page.waitForURL(/\/anmelden\?ziel=/); 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 page.fill('input[name="email"]', email); await page.fill('input[name="passwort"]', passwort); await page.click('button[type="submit"]'); await page.waitForURL(`**/geraete/${geraet.id}`); // 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 }) => { test.slow(); const [code, vorlagenName] = await Promise.all([tagAnlegen(), vorlageAnlegen()]); await anmelden(page); // Klein geschrieben, so wie ein Handy es eintippt. await page.goto(`/t/${code.toLowerCase()}`); await expect(page.getByRole("heading", { name: "Gerät registrieren" })).toBeVisible(); 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(); // Danach steht der Techniker auf der Geräteseite – nicht auf einer // Erfolgsmeldung, die er wegklicken muss. await page.waitForURL(/\/geraete\/[^/]+$/); await expect(page.getByRole("heading", { name: hostname })).toBeVisible(); await expect(page.getByText(code)).toBeVisible(); // Derselbe Aufkleber führt von jetzt an direkt hierher. const adresse = page.url(); await page.goto(`/t/${code}`); await page.waitForURL(adresse); // Eine Vorlage zuweisen: der Lauf erscheint als „offen“ unter Zugewiesen. /* * Ueber die Ueberschrift und nicht ueber den Text: der Abschnitt „Vorlage * zuweisen“ erklaert sich mit dem Satz „… erscheint oben unter * Zugewiesen“ — ein Textfilter faende ihn damit unter beiden Namen. */ const zuweisen = page .locator("section") .filter({ has: page.getByRole("heading", { name: "Vorlage zuweisen" }) }); // Ueber den Wert, nicht ueber die Beschriftung: die traegt Kategorie und // Version und liesse sich mit jeder Feinheit am Text brechen. const wert = await zuweisen.locator("option", { hasText: vorlagenName }).getAttribute("value"); if (!wert) throw new Error(`Die Vorlage „${vorlagenName}“ steht nicht zur Auswahl.`); await zuweisen.getByLabel("Vorlage").selectOption(wert); await zuweisen.getByRole("button", { name: "Lauf zuweisen" }).click(); const zugewiesen = page .locator("section") .filter({ has: page.getByRole("heading", { name: "Zugewiesen" }) }); await expect(zugewiesen.getByText("offen")).toBeVisible(); // Und „Starten“ öffnet ihn wirklich – danach ist er in Arbeit. await zugewiesen.getByRole("button", { name: "Starten" }).click(); await page.waitForURL(/\/laeufe\/[^/]+$/); await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeVisible(); }); 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?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(() => ({ scroll: document.documentElement.scrollWidth, sichtbar: document.documentElement.clientWidth, })); expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar); }); }); test.afterAll(async () => { const vorlagen = await prisma.vorlage.findMany({ where: { name: { startsWith: "ZZ E2E Geraetelauf" } }, select: { id: true }, }); /* * Die Testgeraete und ihre Laeufe raeumen sich nicht von selbst weg: ein * Tag-Code bleibt absichtlich fuer immer vergeben, das Geraet dahinter muss * aber nicht stehen bleiben. */ /* * Nur die eigenen: ein „alle Tags ohne Zuordnung“ raeumte auch den Vorrat * weg, den jemand fuer echte Aufkleber angelegt hat — in einer * Entwicklungsdatenbank ist das derselbe Bestand. */ const geraete = await prisma.geraet.findMany({ /* * 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); if (kennungen.length > 0) { const laeufe = await prisma.lauf.findMany({ where: { geraetId: { in: kennungen } }, select: { id: true }, }); const laufIds = laeufe.map((lauf) => lauf.id); if (laufIds.length > 0) { await prisma.kommentar.deleteMany({ where: { laufId: { in: laufIds } } }); await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: laufIds } } }); await prisma.protokoll.deleteMany({ where: { OR: [{ laufId: { in: laufIds } }, { entitaetId: { in: laufIds } }] }, }); await prisma.lauf.deleteMany({ where: { id: { in: laufIds } } }); } await prisma.protokoll.deleteMany({ where: { entitaetId: { in: kennungen } } }); await prisma.geraet.deleteMany({ where: { id: { in: kennungen } } }); } // Die Testvorlage zuletzt: an ihr hingen gerade noch die Laeufe. if (vorlagen.length > 0) { await prisma.vorlage.deleteMany({ where: { id: { in: vorlagen.map((zeile) => zeile.id) } } }); } await prisma.$disconnect(); });