feat(tags): QR-Aufkleber, Geraeteseite, Etikettendruck und Scanner
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Der Einstieg, fuer den die Anwendung gebaut ist: scannen, sehen was zu tun ist,
loslegen. `/t/<tagCode>` kennt drei Faelle — registriertes Geraet, vorgedruckter
Tag, unbekannter Code —, die Geraeteseite zeigt Zuweisungen, Schnellstart und
Historie, und beides ist am Handy bedienbar.

Im QR steht ausschliesslich `{APP_URL}/t/{tagCode}`: kein Name, keine
Seriennummer, kein Besitzer. Ein Aufkleber haengt jahrelang sichtbar am Geraet
und wird von jedem Telefon gelesen; was darauf steht, ist veroeffentlicht. Die
Masse sind gemessen, nicht behauptet: ein echtes PDF liefert 62 x 29 mm und A4,
und ein bei 300 dpi gerastertes Etikett wird mit demselben Decoder
zurueckgelesen, den auch der Scanner benutzt.

Drei Defekte zeigte erst der gebaute Stand, alle an den Naehten zwischen den
parallel gebauten Teilen: „Starten“ setzte IN_ARBEIT und sprang nicht, „Vorlage
zuweisen“ bot Vorlagen an, die der Dienst danach ablehnte, und die Geraeteliste
verlinkte nichts. Der unabhaengige Review fand drei Blocker — darunter, dass der
Scanner nie die native BarcodeDetector-API benutzte und dass ein Unterpfad in
APP_URL aus dem gedruckten QR herausfiel.

Closes #14

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #28.
This commit is contained in:
2026-09-25 19:30:50 +00:00
co-authored by Claude Opus 5
parent c78a6ac1af
commit e7a2e2d5c7
58 changed files with 4828 additions and 54 deletions
+215
View File
@@ -0,0 +1,215 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
/**
* 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.
*/
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"]')]);
}
/**
* 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 },
});
const stapel = await tagsErzeugen(1, null, { 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.findFirstOrThrow({
where: { hostname: "PC-001" },
select: { id: true, tagCode: true },
});
await page.context().clearCookies();
await page.goto(`/t/${geraet.tagCode}`);
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}`);
await expect(page.getByRole("heading", { name: "PC-001" })).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 = `ZZ-E2E-${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“.");
await anmelden(page);
await page.goto("/geraete");
await page.getByRole("link", { name: "NB-001" }).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({
where: { OR: [{ hostname: { startsWith: "ZZ-E2E-" } }, { 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();
});