238 lines
10 KiB
TypeScript
238 lines
10 KiB
TypeScript
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/<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();
|
||
|
||
/** 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<string> {
|
||
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<string> {
|
||
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();
|
||
});
|