feat(tags): QR-Aufkleber, Geraeteseite, Etikettendruck und Scanner
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:
@@ -0,0 +1,163 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Der Etikettendruck am gebauten Stand (#014).
|
||||
*
|
||||
* Diese Pruefungen gibt es nur hier, weil sie ohne Browser nicht ehrlich zu
|
||||
* fuehren sind: ob Tailwind die Millimeterklassen wirklich erzeugt, ob `@page`
|
||||
* das Blatt auf 62 × 29 mm stellt und ob der gedruckte QR **zurueckgelesen**
|
||||
* werden kann, entscheidet sich erst im gerenderten Dokument. `tests/qr.test.ts`
|
||||
* prueft die Geometrie rechnerisch; hier steht das Ergebnis.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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}`);
|
||||
|
||||
/*
|
||||
* `page.pdf()` gilt in Chromium und beachtet `@page`. Gemessen wird die
|
||||
* Seitengroesse in Punkten (1 pt = 1/72 Zoll): 62 mm sind 175,7 pt, 29 mm
|
||||
* sind 82,2 pt. Ein Etikettendrucker schneidet, was darueber hinausgeht —
|
||||
* eine falsche Seitengroesse faellt deshalb erst auf der Rolle auf.
|
||||
*/
|
||||
const pdf = await page.pdf({ preferCSSPageSize: true });
|
||||
const masse = seitenmasseAusPdf(pdf);
|
||||
expect(masse.breite).toBeCloseTo((62 / 25.4) * 72, 0);
|
||||
expect(masse.hoehe).toBeCloseTo((29 / 25.4) * 72, 0);
|
||||
});
|
||||
|
||||
test("druckt den A4-Bogen auf A4", async ({ page }) => {
|
||||
test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät.");
|
||||
await anmelden(page);
|
||||
await page.goto("/drucken/etiketten?layout=bogen");
|
||||
|
||||
const pdf = await page.pdf({ preferCSSPageSize: true });
|
||||
const masse = seitenmasseAusPdf(pdf);
|
||||
// A4 sind 210 × 297 mm, in Punkten 595 × 842.
|
||||
expect(masse.breite).toBeCloseTo(595, 0);
|
||||
expect(masse.hoehe).toBeCloseTo(842, 0);
|
||||
});
|
||||
|
||||
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
|
||||
* gehoert zu diesem einen Test. Die Breite entspricht dem Etikett, damit
|
||||
* gerastert wird, was auch gedruckt wird.
|
||||
*/
|
||||
const kontext = await browser.newContext({
|
||||
baseURL: process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000",
|
||||
viewport: { width: Math.round(62 * PX_JE_MM), height: Math.round(29 * PX_JE_MM) },
|
||||
deviceScaleFactor: DPI / 96,
|
||||
});
|
||||
try {
|
||||
const tab = await kontext.newPage();
|
||||
await anmelden(tab);
|
||||
await tab.goto(`/drucken/etiketten?layout=etikett&codes=${code}`);
|
||||
const etikett = tab.locator("[data-etikett]").first();
|
||||
await expect(etikett).toBeVisible();
|
||||
const bild = await etikett.screenshot();
|
||||
|
||||
const gelesen = await zurueckgelesen(bild);
|
||||
|
||||
expect(gelesen).toHaveLength(1);
|
||||
/*
|
||||
* Der ganze Inhalt, nicht nur das Ende: „enthaelt keinen Hostnamen“ waere
|
||||
* keine Pruefung — dieser Aufkleber koennte zu einem Geraet ohne Hostname
|
||||
* gehoeren, und der Test waere gruen, ohne etwas zu wissen. Ein Vergleich
|
||||
* auf Gleichheit schliesst alles andere aus, was jemand spaeter
|
||||
* dazulegen koennte.
|
||||
*/
|
||||
const adresse = new URL(gelesen[0] ?? "");
|
||||
expect(adresse.pathname).toBe(`/t/${code}`);
|
||||
expect(adresse.search).toBe("");
|
||||
expect(adresse.hash).toBe("");
|
||||
} finally {
|
||||
await kontext.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Liest die QR-Codes aus einem PNG zurueck.
|
||||
*
|
||||
* Mit **demselben** Decoder, den der Scanner im Browser benutzt
|
||||
* (`barcode-detector` ueber `zxing-wasm`): ein anderer Leser bewiese etwas
|
||||
* anderes als „unsere Kamera erkennt das“. Das WebAssembly kommt aus `public/`,
|
||||
* wohin `scripts/wasm-bereitstellen.mjs` es legt — nicht aus dem Netz.
|
||||
*
|
||||
* Der kleine Behelf ist noetig, weil die Ponyfill ihre Treffer in DOM-Typen
|
||||
* verpackt, die es in Node nicht gibt. Gelesen wird trotzdem im WebAssembly,
|
||||
* also genau wie im Browser; ersetzt wird nur die Huelle um das Ergebnis.
|
||||
*/
|
||||
async function zurueckgelesen(png: Buffer): Promise<string[]> {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { BarcodeDetector, setZXingModuleOverrides } = await import("barcode-detector/ponyfill");
|
||||
|
||||
const global = globalThis as unknown as Record<string, unknown>;
|
||||
global["DOMRectReadOnly"] ??= class {
|
||||
constructor(
|
||||
readonly x = 0,
|
||||
readonly y = 0,
|
||||
readonly width = 0,
|
||||
readonly height = 0,
|
||||
) {}
|
||||
};
|
||||
|
||||
setZXingModuleOverrides({ wasmBinary: await readFile("public/zxing/zxing_reader.wasm") });
|
||||
const funde = await new BarcodeDetector({ formats: ["qr_code"] }).detect(
|
||||
new Blob([new Uint8Array(png)], { type: "image/png" }),
|
||||
);
|
||||
return funde.map((fund) => fund.rawValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Breite und Hoehe der ersten Seite eines PDF, in Punkten.
|
||||
*
|
||||
* Reicht fuer diesen Zweck und spart eine Abhaengigkeit: das `/MediaBox`-Feld
|
||||
* steht als Klartext im Dokument, auch wenn die Inhalte komprimiert sind.
|
||||
*/
|
||||
function seitenmasseAusPdf(pdf: Buffer): { breite: number; hoehe: number } {
|
||||
const text = pdf.toString("latin1");
|
||||
const treffer = /\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/u.exec(text);
|
||||
if (!treffer) throw new Error("Im PDF steht keine MediaBox — das Dokument ist unbrauchbar.");
|
||||
return {
|
||||
breite: Number(treffer[3]) - Number(treffer[1]),
|
||||
hoehe: Number(treffer[4]) - Number(treffer[2]),
|
||||
};
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
* Der Einstieg ueber den Aufkleber (#014).
|
||||
*
|
||||
* Die Kamera laesst sich hier nicht pruefen — Chromium bekommt im Testlauf kein
|
||||
* Bild, und ein gefaelschter Videostrom prueft am Ende die Attrappe. Geprueft
|
||||
* wird deshalb das, was ohne Kamera trotzdem gelten muss: die Seite ist
|
||||
* erreichbar, der Knopf in der Huelle fuehrt hin, und der **abgetippte** Code
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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);
|
||||
await page.getByRole("link", { name: "Scannen" }).first().click();
|
||||
await page.waitForURL("**/scannen");
|
||||
await expect(page.getByRole("heading", { name: "Scannen" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("öffnet einen abgetippten Code", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
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.getByRole("button", { name: "Öffnen" }).click();
|
||||
|
||||
await page.waitForURL("**/t/LZ-7K3F9Q");
|
||||
});
|
||||
|
||||
test("lässt einen unsinnigen Code gar nicht erst abschicken", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/scannen");
|
||||
|
||||
await page.getByLabel("Code eintippen").fill("PC-001");
|
||||
await expect(page.getByRole("button", { name: "Öffnen" })).toBeDisabled();
|
||||
await expect(page.getByText(/Die Zeichen 0, O, 1 und I gibt es darin nicht/)).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("/scannen");
|
||||
|
||||
const masse = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
sichtbar: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
Betriebsmodus,
|
||||
GeraeteTyp,
|
||||
LaufStatus,
|
||||
Rolle,
|
||||
SchrittZustand,
|
||||
VersionStatus,
|
||||
} from "@/generated/prisma/enums";
|
||||
import { MandantFehler } from "@/server/mandant";
|
||||
import {
|
||||
GeraeteFehler,
|
||||
geraetAnlegen,
|
||||
geraetArchivieren,
|
||||
geraetBearbeiten,
|
||||
laufFuerGeraetAnlegen,
|
||||
} from "@/server/services/geraete";
|
||||
import {
|
||||
LaufFehler,
|
||||
laufAbschliessen,
|
||||
laufArchivieren,
|
||||
laufZurBearbeitungOeffnen,
|
||||
} from "@/server/services/laeufe";
|
||||
import {
|
||||
HISTORIE_HOECHSTENS,
|
||||
geraetHolen,
|
||||
geraetNachTagCode,
|
||||
laeufeDesGeraets,
|
||||
vorlagenFuerGeraet,
|
||||
} from "@/server/queries/geraete";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Die Geräteseite und der Einstieg über den Aufkleber (#014).
|
||||
*
|
||||
* Festgehalten wird, was ein Techniker vor dem Gerät erlebt: ein Code führt zum
|
||||
* Gerät oder zum Registrierformular, mehrere Zuweisungen stehen gleichzeitig an
|
||||
* einem Gerät, „Vorlage zuweisen“ erzeugt einen `OFFEN`en Lauf, und „Starten“
|
||||
* macht `IN_ARBEIT` daraus, ohne dass der Lauf aus der Liste verschwindet.
|
||||
*
|
||||
* Dazu die Mandantengrenze, und zwar an jeder Tür: ein Gerät eines fremden Kunden
|
||||
* ist über den Tag-Code genauso unerreichbar wie über seine Kennung, und beide
|
||||
* antworten wie „gibt es nicht“.
|
||||
*/
|
||||
|
||||
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
|
||||
const eigene = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global", modus } });
|
||||
const benutzer = await testPrisma.benutzer.create({
|
||||
data: { email: "admin@firma.example", name: "Administrator", rolle: Rolle.ADMIN },
|
||||
});
|
||||
return { eigene, admin: { id: benutzer.id, rolle: Rolle.ADMIN } };
|
||||
}
|
||||
|
||||
type VorlagenWunsch = {
|
||||
readonly name?: string;
|
||||
readonly kategorie?: string | null;
|
||||
readonly kundeId?: string | null;
|
||||
readonly status?: VersionStatus;
|
||||
readonly archiviert?: boolean;
|
||||
readonly schritte?: number;
|
||||
/** Standard sind Pflichtschritte – ohne sie lässt sich ein Lauf sofort abschließen. */
|
||||
readonly pflicht?: boolean;
|
||||
readonly pflichtVariable?: boolean;
|
||||
/** Legt die Vorgabe in `GeraetetypVorlage` an – damit taucht sie im Schnellstart auf. */
|
||||
readonly fuerTyp?: GeraeteTyp;
|
||||
readonly position?: number;
|
||||
};
|
||||
|
||||
/** Eine Vorlage mit einer Version und ein paar Pflichtschritten. */
|
||||
async function vorlageAnlegen(wunsch: VorlagenWunsch = {}) {
|
||||
const vorlage = await testPrisma.vorlage.create({
|
||||
data: {
|
||||
name: wunsch.name ?? "PC-Neuinstallation",
|
||||
kategorie: wunsch.kategorie ?? null,
|
||||
kundeId: wunsch.kundeId ?? null,
|
||||
archiviert: wunsch.archiviert ?? false,
|
||||
},
|
||||
});
|
||||
const status = wunsch.status ?? VersionStatus.VEROEFFENTLICHT;
|
||||
/*
|
||||
* Erst als Entwurf mit Abschnitten und Schritten, dann veroeffentlichen: eine
|
||||
* veroeffentlichte Version ist in der Datenbank unveraenderlich, ein Trigger
|
||||
* weist den nachtraeglichen Abschnitt ab. Genau die Regel, die #009 zugesagt hat.
|
||||
*/
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: {
|
||||
vorlageId: vorlage.id,
|
||||
version: 1,
|
||||
status: VersionStatus.ENTWURF,
|
||||
abschnitte: {
|
||||
create: {
|
||||
titel: "Vorbereitung",
|
||||
position: 0,
|
||||
schritte: {
|
||||
create: Array.from({ length: wunsch.schritte ?? 2 }, (_, index) => ({
|
||||
titel: `Schritt ${index + 1}`,
|
||||
position: index,
|
||||
pflicht: wunsch.pflicht ?? true,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
...(wunsch.pflichtVariable
|
||||
? {
|
||||
variablen: {
|
||||
create: { schluessel: "upn", beschriftung: "Anmeldename", pflicht: true, position: 0 },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (status === VersionStatus.VEROEFFENTLICHT) {
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status, veroeffentlichtAm: new Date() },
|
||||
});
|
||||
}
|
||||
if (wunsch.fuerTyp) {
|
||||
await testPrisma.geraetetypVorlage.create({
|
||||
data: { geraeteTyp: wunsch.fuerTyp, vorlageId: vorlage.id, position: wunsch.position ?? 0 },
|
||||
});
|
||||
}
|
||||
return { vorlage, versionId: version.id };
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Geräteseite", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
});
|
||||
|
||||
describe("Einstieg über den Tag", () => {
|
||||
it("findet das Gerät zum Tag-Code, gleich wie er geschrieben steht", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const geraet = await geraetAnlegen({ hostname: "PC-001", typ: GeraeteTyp.PC }, admin);
|
||||
|
||||
// Abgetippt wird klein, und ein Handy schreibt von sich aus klein.
|
||||
expect((await geraetNachTagCode(geraet.tagCode, admin))?.id).toBe(geraet.id);
|
||||
expect((await geraetNachTagCode(geraet.tagCode.toLowerCase(), admin))?.id).toBe(geraet.id);
|
||||
expect((await geraetNachTagCode(` ${geraet.tagCode}\n`, admin))?.id).toBe(geraet.id);
|
||||
});
|
||||
|
||||
it("liefert einen vorgedruckten Tag mit leerer Registrierung", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const tag = await geraetAnlegen({ registrieren: false }, admin);
|
||||
|
||||
const zeile = await geraetNachTagCode(tag.tagCode, admin);
|
||||
expect(zeile?.registriertAm).toBeNull();
|
||||
// Ohne Namen bleibt der Code selbst die Anzeige – leer ist die Zeile nie.
|
||||
expect(zeile?.anzeige).toBe(tag.tagCode);
|
||||
});
|
||||
|
||||
it("antwortet auf einen unbekannten Code wie auf einen falschen: mit nichts", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
await geraetAnlegen({ hostname: "PC-001" }, admin);
|
||||
|
||||
expect(await geraetNachTagCode("LZ-ZZZZZZ", admin)).toBeUndefined();
|
||||
expect(await geraetNachTagCode("Unsinn", admin)).toBeUndefined();
|
||||
expect(await geraetNachTagCode("", admin)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("biegt verwechselbare Zeichen nicht auf ein vorhandenes Gerät", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
await testPrisma.geraet.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
tagCode: "LZ-LLLLLL",
|
||||
hostname: "PC-LLL",
|
||||
registriertAm: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Das Alphabet kennt kein I. Ein getipptes „I“ auf das „L“ zu biegen führte
|
||||
// auf ein anderes, vorhandenes Gerät – und wer davor steht, sähe es nie.
|
||||
expect(await geraetNachTagCode("LZ-IIIIII", admin)).toBeUndefined();
|
||||
expect((await geraetNachTagCode("lz-llllll", admin))?.hostname).toBe("PC-LLL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Registrieren", () => {
|
||||
it("macht aus dem Aufkleber ein Gerät mit Typ, Namen und Besitzer", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const tag = await geraetAnlegen({ registrieren: false }, admin);
|
||||
const person = await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
vorname: "Ada",
|
||||
nachname: "Lovelace",
|
||||
anzeigename: "Ada Lovelace",
|
||||
abteilung: "Vertrieb",
|
||||
},
|
||||
});
|
||||
|
||||
await geraetBearbeiten(
|
||||
tag.id,
|
||||
{
|
||||
registrieren: true,
|
||||
typ: GeraeteTyp.NOTEBOOK,
|
||||
hostname: "NB-ADA",
|
||||
seriennummer: "SN-4711",
|
||||
mitarbeiterId: person.id,
|
||||
},
|
||||
admin,
|
||||
);
|
||||
|
||||
const zeile = await geraetNachTagCode(tag.tagCode, admin);
|
||||
expect(zeile?.registriertAm).not.toBeNull();
|
||||
expect(zeile?.anzeige).toBe("NB-ADA");
|
||||
expect(zeile?.typ).toBe(GeraeteTyp.NOTEBOOK);
|
||||
expect(zeile?.besitzerName).toBe("Ada Lovelace");
|
||||
// Die Abteilung steht im Kopf der Geräteseite neben dem Besitzer.
|
||||
expect(zeile?.besitzerAbteilung).toBe("Vertrieb");
|
||||
// Der Tag-Code ändert sich dabei nicht.
|
||||
expect(zeile?.tagCode).toBe(tag.tagCode);
|
||||
});
|
||||
|
||||
it("lässt einen Tag unregistriert, wenn kein Name eingetragen wurde", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const tag = await geraetAnlegen({ registrieren: false }, admin);
|
||||
|
||||
await expect(
|
||||
geraetBearbeiten(tag.id, { registrieren: true, typ: GeraeteTyp.PC }, admin),
|
||||
).rejects.toThrow(GeraeteFehler);
|
||||
expect((await geraetNachTagCode(tag.tagCode, admin))?.registriertAm).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zugewiesen und Historie", () => {
|
||||
it("zeigt mehrere Zuweisungen an einem Gerät gleichzeitig", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const geraet = await geraetAnlegen({ hostname: "PC-001", typ: GeraeteTyp.PC }, admin);
|
||||
const ersteVorlage = await vorlageAnlegen({ name: "Patchday" });
|
||||
const zweiteVorlage = await vorlageAnlegen({ name: "Rechnerwechsel" });
|
||||
|
||||
await laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: ersteVorlage.versionId }, admin);
|
||||
await laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: zweiteVorlage.versionId }, admin);
|
||||
|
||||
const laeufe = await laeufeDesGeraets(geraet.id, admin);
|
||||
expect(laeufe.zugewiesen).toHaveLength(2);
|
||||
expect(laeufe.zugewiesen.every((zeile) => zeile.status === LaufStatus.OFFEN)).toBe(true);
|
||||
expect(laeufe.historie).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("zeigt den Fortschritt: erledigt und nicht zutreffend zählen, fehlgeschlagen nicht", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin);
|
||||
const { versionId } = await vorlageAnlegen({ schritte: 4 });
|
||||
const lauf = await laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: versionId }, admin);
|
||||
|
||||
const ergebnisse = await testPrisma.schrittergebnis.findMany({
|
||||
where: { laufId: lauf.id },
|
||||
orderBy: { vorlagenSchrittId: "asc" },
|
||||
});
|
||||
const zustaende = [
|
||||
SchrittZustand.ERLEDIGT,
|
||||
SchrittZustand.NICHT_ZUTREFFEND,
|
||||
SchrittZustand.FEHLGESCHLAGEN,
|
||||
SchrittZustand.OFFEN,
|
||||
];
|
||||
for (const [index, ergebnis] of ergebnisse.entries()) {
|
||||
await testPrisma.schrittergebnis.update({
|
||||
where: { id: ergebnis.id },
|
||||
data: { zustand: zustaende[index] ?? SchrittZustand.OFFEN, notiz: "Grund" },
|
||||
});
|
||||
}
|
||||
|
||||
const laeufe = await laeufeDesGeraets(geraet.id, admin);
|
||||
expect(laeufe.zugewiesen[0]?.anzahlSchritte).toBe(4);
|
||||
expect(laeufe.zugewiesen[0]?.anzahlErledigt).toBe(2);
|
||||
});
|
||||
|
||||
it("trennt Abgeschlossenes und Archiviertes von der Arbeit, die wartet", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin);
|
||||
// Ohne Pflichtschritte lässt sich ein Lauf sofort abschließen; geprüft wird
|
||||
// hier die Aufteilung des Lesemodells, nicht die Abschlussbedingung.
|
||||
const { versionId } = await vorlageAnlegen({ pflicht: false });
|
||||
|
||||
const wartend = await laufFuerGeraetAnlegen(
|
||||
{ geraetId: geraet.id, vorlagenVersionId: versionId },
|
||||
admin,
|
||||
);
|
||||
const fertig = await laufFuerGeraetAnlegen(
|
||||
{ geraetId: geraet.id, vorlagenVersionId: versionId },
|
||||
admin,
|
||||
);
|
||||
const weggelegt = await laufFuerGeraetAnlegen(
|
||||
{ geraetId: geraet.id, vorlagenVersionId: versionId },
|
||||
admin,
|
||||
);
|
||||
// Über die Dienste, nicht an ihnen vorbei: Abschluss und Archiv setzen
|
||||
// Zeitstempel, die die Datenbank auch verlangt.
|
||||
await laufAbschliessen(fertig.id, admin);
|
||||
await laufAbschliessen(weggelegt.id, admin);
|
||||
await laufArchivieren(weggelegt.id, admin);
|
||||
|
||||
const laeufe = await laeufeDesGeraets(geraet.id, admin);
|
||||
expect(laeufe.zugewiesen.map((zeile) => zeile.id)).toEqual([wartend.id]);
|
||||
expect(laeufe.historie.map((zeile) => zeile.id).sort()).toEqual([fertig.id, weggelegt.id].sort());
|
||||
expect(laeufe.historieGekuerzt).toBe(false);
|
||||
});
|
||||
|
||||
it("kappt die Historie und sagt, dass sie gekappt ist", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin);
|
||||
const { versionId } = await vorlageAnlegen();
|
||||
|
||||
// Direkt geschrieben: geprüft wird die Kappung des Lesemodells, nicht das
|
||||
// Anlegen – und Zuweisungen bleiben bewusst ungekappt.
|
||||
await testPrisma.lauf.createMany({
|
||||
data: Array.from({ length: HISTORIE_HOECHSTENS + 3 }, (_, index) => ({
|
||||
vorlagenVersionId: versionId,
|
||||
titel: `Alter Lauf ${index + 1}`,
|
||||
kundeId: eigene.id,
|
||||
geraetId: geraet.id,
|
||||
status: LaufStatus.ABGESCHLOSSEN,
|
||||
// Die Datenbank verlangt beide Zeitstempel, sobald ein Lauf nicht mehr
|
||||
// offen ist (CHECK `Lauf_begonnen_mit_zeitpunkt`).
|
||||
gestartetAm: new Date(2026, 0, index + 1),
|
||||
abgeschlossenAm: new Date(2026, 0, index + 1),
|
||||
})),
|
||||
});
|
||||
|
||||
const laeufe = await laeufeDesGeraets(geraet.id, admin);
|
||||
expect(laeufe.historie).toHaveLength(HISTORIE_HOECHSTENS);
|
||||
expect(laeufe.historieGekuerzt).toBe(true);
|
||||
// Das Jüngste zuerst – die Historie liest sich von oben.
|
||||
expect(laeufe.historie[0]?.titel).toBe(`Alter Lauf ${HISTORIE_HOECHSTENS + 3}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vorlage zuweisen", () => {
|
||||
it("erzeugt einen Lauf in OFFEN, der unter „Zugewiesen“ erscheint", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const person = await testPrisma.mitarbeiter.create({
|
||||
data: { kundeId: eigene.id, vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace" },
|
||||
});
|
||||
const geraet = await geraetAnlegen(
|
||||
{ hostname: "NB-ADA", typ: GeraeteTyp.NOTEBOOK, mitarbeiterId: person.id },
|
||||
admin,
|
||||
);
|
||||
const { versionId } = await vorlageAnlegen({ name: "Rechnerwechsel" });
|
||||
const faelligAm = new Date(2026, 9, 30);
|
||||
|
||||
const lauf = await laufFuerGeraetAnlegen(
|
||||
{ geraetId: geraet.id, vorlagenVersionId: versionId, zugewiesenAnId: admin.id, faelligAm },
|
||||
admin,
|
||||
);
|
||||
|
||||
expect(lauf.status).toBe(LaufStatus.OFFEN);
|
||||
expect(lauf.gestartetAm).toBeNull();
|
||||
expect(lauf.geraetId).toBe(geraet.id);
|
||||
// Kunde und Besitzer kommen vom Gerät, nicht aus dem Formular.
|
||||
expect(lauf.kundeId).toBe(eigene.id);
|
||||
expect(lauf.mitarbeiterId).toBe(person.id);
|
||||
expect(lauf.zugewiesenAnId).toBe(admin.id);
|
||||
expect(lauf.faelligAm?.getTime()).toBe(faelligAm.getTime());
|
||||
// Der Titel kommt aus Vorlage, Gerät und Datum – nicht aus der Oberfläche.
|
||||
expect(lauf.titel).toContain("Rechnerwechsel");
|
||||
expect(lauf.titel).toContain("NB-ADA");
|
||||
|
||||
const laeufe = await laeufeDesGeraets(geraet.id, admin);
|
||||
expect(laeufe.zugewiesen.map((zeile) => zeile.id)).toEqual([lauf.id]);
|
||||
expect(laeufe.zugewiesen[0]?.zugewiesenAn).toBe("Administrator");
|
||||
expect(laeufe.zugewiesen[0]?.anzahlSchritte).toBe(2);
|
||||
});
|
||||
|
||||
it("„Starten“ setzt IN_ARBEIT, ohne den Lauf aus der Liste zu nehmen", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin);
|
||||
const { versionId } = await vorlageAnlegen();
|
||||
const lauf = await laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: versionId }, admin);
|
||||
|
||||
const gestartet = await laufZurBearbeitungOeffnen(lauf.id, admin);
|
||||
expect(gestartet.status).toBe(LaufStatus.IN_ARBEIT);
|
||||
expect(gestartet.gestartetAm).not.toBeNull();
|
||||
|
||||
const laeufe = await laeufeDesGeraets(geraet.id, admin);
|
||||
expect(laeufe.zugewiesen.map((zeile) => zeile.status)).toEqual([LaufStatus.IN_ARBEIT]);
|
||||
expect(laeufe.historie).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("weist eine Vorlage mit Pflichtangaben ab, statt sie halb zu starten", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin);
|
||||
const { versionId } = await vorlageAnlegen({ pflichtVariable: true });
|
||||
|
||||
// Genau deshalb führt der Schnellstart solche Vorlagen in den Start-Dialog:
|
||||
// dort kann jemand die Angabe machen.
|
||||
await expect(
|
||||
laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: versionId }, admin),
|
||||
).rejects.toThrow(/Diese Angaben fehlen/u);
|
||||
expect(await testPrisma.lauf.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("nimmt an einem archivierten Gerät keine neue Arbeit auf", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin);
|
||||
const { versionId } = await vorlageAnlegen();
|
||||
await geraetArchivieren(geraet.id, true, admin);
|
||||
|
||||
await expect(
|
||||
laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: versionId }, admin),
|
||||
).rejects.toThrow(LaufFehler);
|
||||
});
|
||||
|
||||
it("weist einen Entwurf ab – ein Lauf hängt nur an einer veröffentlichten Version", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin);
|
||||
const { versionId } = await vorlageAnlegen({ status: VersionStatus.ENTWURF });
|
||||
|
||||
await expect(
|
||||
laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: versionId }, admin),
|
||||
).rejects.toThrow(LaufFehler);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schnellstart und Vorlagenauswahl", () => {
|
||||
it("zeigt im Schnellstart nur die Vorgaben dieses Gerätetyps, in ihrer Reihenfolge", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const geraet = await geraetAnlegen({ hostname: "PC-001", typ: GeraeteTyp.PC }, admin);
|
||||
await vorlageAnlegen({ name: "Zweiter Griff", fuerTyp: GeraeteTyp.PC, position: 1 });
|
||||
await vorlageAnlegen({ name: "Erster Griff", fuerTyp: GeraeteTyp.PC, position: 0 });
|
||||
await vorlageAnlegen({ name: "Nur für Server", fuerTyp: GeraeteTyp.SERVER });
|
||||
await vorlageAnlegen({ name: "Ohne Vorgabe" });
|
||||
|
||||
const vorlagen = await vorlagenFuerGeraet(geraet, admin);
|
||||
expect(vorlagen.schnellstart.map((eintrag) => eintrag.name)).toEqual(["Erster Griff", "Zweiter Griff"]);
|
||||
// „Vorlage zuweisen“ bietet alles an, was startbar ist.
|
||||
expect(vorlagen.alle.map((eintrag) => eintrag.name).sort()).toEqual([
|
||||
"Erster Griff",
|
||||
"Nur für Server",
|
||||
"Ohne Vorgabe",
|
||||
"Zweiter Griff",
|
||||
]);
|
||||
});
|
||||
|
||||
it("nimmt weder Entwürfe noch archivierte Vorlagen", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const geraet = await geraetAnlegen({ hostname: "PC-001", typ: GeraeteTyp.PC }, admin);
|
||||
await vorlageAnlegen({ name: "Fertig", fuerTyp: GeraeteTyp.PC });
|
||||
await vorlageAnlegen({ name: "Noch Entwurf", fuerTyp: GeraeteTyp.PC, status: VersionStatus.ENTWURF });
|
||||
await vorlageAnlegen({ name: "Weggelegt", fuerTyp: GeraeteTyp.PC, archiviert: true });
|
||||
|
||||
const vorlagen = await vorlagenFuerGeraet(geraet, admin);
|
||||
expect(vorlagen.schnellstart.map((eintrag) => eintrag.name)).toEqual(["Fertig"]);
|
||||
expect(vorlagen.alle.map((eintrag) => eintrag.name)).toEqual(["Fertig"]);
|
||||
});
|
||||
|
||||
it("merkt an einer Vorlage, dass sie Pflichtangaben verlangt", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const geraet = await geraetAnlegen({ hostname: "PC-001", typ: GeraeteTyp.PC }, admin);
|
||||
await vorlageAnlegen({ name: "Mit Frage", fuerTyp: GeraeteTyp.PC, pflichtVariable: true });
|
||||
await vorlageAnlegen({ name: "Ohne Frage", fuerTyp: GeraeteTyp.PC, position: 1 });
|
||||
|
||||
const vorlagen = await vorlagenFuerGeraet(geraet, admin);
|
||||
const mitFrage = vorlagen.schnellstart.find((eintrag) => eintrag.name === "Mit Frage");
|
||||
const ohneFrage = vorlagen.schnellstart.find((eintrag) => eintrag.name === "Ohne Frage");
|
||||
expect(mitFrage?.pflichtangaben).toBe(true);
|
||||
expect(ohneFrage?.pflichtangaben).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Die Mandantengrenze an jeder Tür der Geräteseite. Ein fremdes Gerät ist über
|
||||
* den Tag-Code genauso unerreichbar wie über seine Kennung — und beide
|
||||
* antworten, als gäbe es es nicht.
|
||||
*/
|
||||
describe("Mandantensicht", () => {
|
||||
async function zweiKunden() {
|
||||
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
|
||||
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
|
||||
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
|
||||
|
||||
const benutzer = await testPrisma.benutzer.create({
|
||||
data: { email: "t@firma.example", name: "Technikerin", rolle: Rolle.TECHNIKER },
|
||||
});
|
||||
await testPrisma.benutzerKunde.create({ data: { benutzerId: benutzer.id, kundeId: meiner.id } });
|
||||
|
||||
const meinGeraet = await testPrisma.geraet.create({
|
||||
data: {
|
||||
kundeId: meiner.id,
|
||||
tagCode: "LZ-AAAAAA",
|
||||
hostname: "PC-MEIN",
|
||||
typ: GeraeteTyp.PC,
|
||||
registriertAm: new Date(),
|
||||
},
|
||||
});
|
||||
const fremdesGeraet = await testPrisma.geraet.create({
|
||||
data: {
|
||||
kundeId: fremder.id,
|
||||
tagCode: "LZ-BBBBBB",
|
||||
hostname: "PC-FREMD",
|
||||
typ: GeraeteTyp.PC,
|
||||
registriertAm: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
meiner,
|
||||
fremder,
|
||||
meinGeraet,
|
||||
fremdesGeraet,
|
||||
technikerin: { id: benutzer.id, rolle: Rolle.TECHNIKER },
|
||||
};
|
||||
}
|
||||
|
||||
it("beantwortet ein fremdes Gerät über Tag-Code und Kennung wie ein unbekanntes", async () => {
|
||||
const { fremdesGeraet, meinGeraet, technikerin } = await zweiKunden();
|
||||
|
||||
expect(await geraetNachTagCode("LZ-BBBBBB", technikerin)).toBeUndefined();
|
||||
expect(await geraetHolen(fremdesGeraet.id, technikerin)).toBeUndefined();
|
||||
// Das eigene bleibt erreichbar – sonst prüfte der Test nur eine leere Sicht.
|
||||
expect((await geraetNachTagCode("lz-aaaaaa", technikerin))?.id).toBe(meinGeraet.id);
|
||||
});
|
||||
|
||||
it("zeigt die Läufe eines fremden Geräts nicht", async () => {
|
||||
const { fremder, fremdesGeraet, technikerin } = await zweiKunden();
|
||||
const { versionId } = await vorlageAnlegen();
|
||||
await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: versionId,
|
||||
titel: "Geheime Arbeit",
|
||||
kundeId: fremder.id,
|
||||
geraetId: fremdesGeraet.id,
|
||||
},
|
||||
});
|
||||
|
||||
const laeufe = await laeufeDesGeraets(fremdesGeraet.id, technikerin);
|
||||
expect(laeufe.zugewiesen).toHaveLength(0);
|
||||
expect(laeufe.historie).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("bietet zu einem fremden Gerät keine Vorlagen an", async () => {
|
||||
const { fremder, technikerin } = await zweiKunden();
|
||||
await vorlageAnlegen({ name: "Für alle", fuerTyp: GeraeteTyp.PC });
|
||||
|
||||
const vorlagen = await vorlagenFuerGeraet({ typ: GeraeteTyp.PC, kundeId: fremder.id }, technikerin);
|
||||
expect(vorlagen.schnellstart).toHaveLength(0);
|
||||
expect(vorlagen.alle).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("bietet an einem Gerät nur Vorlagen an, die zu seinem Kunden gehören", async () => {
|
||||
const { meiner, fremder, meinGeraet, technikerin } = await zweiKunden();
|
||||
await vorlageAnlegen({ name: "Für alle" });
|
||||
await vorlageAnlegen({ name: "Nur für meinen", kundeId: meiner.id });
|
||||
await vorlageAnlegen({ name: "Nur für fremde", kundeId: fremder.id });
|
||||
|
||||
const vorlagen = await vorlagenFuerGeraet(meinGeraet, technikerin);
|
||||
expect(vorlagen.alle.map((eintrag) => eintrag.name).sort()).toEqual(["Für alle", "Nur für meinen"]);
|
||||
});
|
||||
|
||||
it("lässt an einem fremden Gerät keinen Lauf anlegen", async () => {
|
||||
const { fremdesGeraet, technikerin } = await zweiKunden();
|
||||
const { versionId } = await vorlageAnlegen();
|
||||
|
||||
await expect(
|
||||
laufFuerGeraetAnlegen({ geraetId: fremdesGeraet.id, vorlagenVersionId: versionId }, technikerin),
|
||||
).rejects.toThrow(MandantFehler);
|
||||
expect(await testPrisma.lauf.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { chromium } from "@playwright/test";
|
||||
import { ETIKETT_QR_FLAECHE, ETIKETT_RAHMEN } from "@/components/tags/etikett";
|
||||
import { QR_ERSATZ_HERKUNFT, QR_FEHLERKORREKTUR, QrFehler, qrSvg, tagAdresse } from "@/lib/qr";
|
||||
|
||||
/**
|
||||
* QR-Codes und Etiketten (#014).
|
||||
*
|
||||
* Zwei Zusagen aus dem Auftrag stehen hier fest:
|
||||
*
|
||||
* 1. Im QR steht **genau** `{APP_URL}/t/{tagCode}` und nichts weiter — kein
|
||||
* Hostname, kein Besitzer, keine Seriennummer.
|
||||
* 2. Ein bei 300 dpi gerastertes 62 × 29-mm-Etikett wird von einem Decoder wieder gelesen.
|
||||
* Das ist der Test, der zaehlt: ein QR, der auf dem Bildschirm huebsch
|
||||
* aussieht und am Etikettendrucker nicht mehr lesbar ist, waere genau der
|
||||
* Fehler, den niemand vor dem ersten Klebeband merkt.
|
||||
*/
|
||||
|
||||
const APP_URL = "https://laufzettel.example.com";
|
||||
const TAGCODE = "LZ-7K3F9Q";
|
||||
|
||||
let vorherigeAppUrl: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vorherigeAppUrl = process.env["APP_URL"];
|
||||
process.env["APP_URL"] = APP_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (vorherigeAppUrl === undefined) delete process.env["APP_URL"];
|
||||
else process.env["APP_URL"] = vorherigeAppUrl;
|
||||
});
|
||||
|
||||
describe("Adresse im QR", () => {
|
||||
it("ist genau {APP_URL}/t/{tagCode}", () => {
|
||||
expect(tagAdresse(TAGCODE)).toBe(`${APP_URL}/t/${TAGCODE}`);
|
||||
});
|
||||
|
||||
it("verschluckt den Unterpfad aus APP_URL nicht", () => {
|
||||
/*
|
||||
* Der Reviewbefund zu #014, und der teuerste von allen: steht die Anwendung
|
||||
* unter `https://firma.de/laufzettel`, dann zeigte der gedruckte Aufkleber
|
||||
* auf `https://firma.de/t/…` — und dort steht nichts. Ein Etikett laesst
|
||||
* sich nicht nachtraeglich berichtigen; es klebt.
|
||||
*/
|
||||
process.env["APP_URL"] = "https://firma.de/laufzettel";
|
||||
expect(tagAdresse(TAGCODE)).toBe(`https://firma.de/laufzettel/t/${TAGCODE}`);
|
||||
|
||||
// Mit Schrägstrich am Ende derselbe Weg, dasselbe Ergebnis.
|
||||
process.env["APP_URL"] = "https://firma.de/laufzettel/";
|
||||
expect(tagAdresse(TAGCODE)).toBe(`https://firma.de/laufzettel/t/${TAGCODE}`);
|
||||
|
||||
process.env["APP_URL"] = "https://intern.example.com/";
|
||||
expect(tagAdresse(TAGCODE)).toBe(`https://intern.example.com/t/${TAGCODE}`);
|
||||
});
|
||||
|
||||
it("fällt ohne APP_URL auf die mitgegebene Ersatzherkunft zurück", () => {
|
||||
delete process.env["APP_URL"];
|
||||
expect(tagAdresse(TAGCODE, "http://127.0.0.1:3300")).toBe(`http://127.0.0.1:3300/t/${TAGCODE}`);
|
||||
expect(tagAdresse(TAGCODE)).toBe(`${QR_ERSATZ_HERKUNFT}/t/${TAGCODE}`);
|
||||
});
|
||||
|
||||
it("weist alles ab, was nicht die Form eines Tag-Codes hat", () => {
|
||||
// Der Code landet in der Adresse und damit im QR jedes gedruckten
|
||||
// Aufklebers. Ein ungeprueftes Zeichen waere dort nicht mehr einzufangen.
|
||||
expect(() => tagAdresse("LZ-0O1I")).toThrow(QrFehler);
|
||||
expect(() => tagAdresse("../../etc/passwd")).toThrow(QrFehler);
|
||||
expect(() => tagAdresse("lz-7k3f9q")).toThrow(QrFehler);
|
||||
});
|
||||
});
|
||||
|
||||
describe("QR-SVG", () => {
|
||||
it("enthält nur Geometrie – keine Personendaten, keine Schrift, kein Skript", async () => {
|
||||
const svg = await qrSvg(TAGCODE);
|
||||
expect(svg).toMatch(/^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" /);
|
||||
// Ein QR besteht aus zwei Pfaden: weisser Grund und schwarze Module.
|
||||
expect(svg.match(/<path /g)).toHaveLength(2);
|
||||
expect(svg).not.toContain("<text");
|
||||
expect(svg).not.toContain("<script");
|
||||
expect(svg).not.toContain("<image");
|
||||
// Der Klartext steht nirgends im SVG – auch nicht der tagCode selbst.
|
||||
expect(svg).not.toContain(TAGCODE);
|
||||
expect(svg).not.toContain(APP_URL);
|
||||
});
|
||||
|
||||
it("nutzt Fehlerkorrektur M", async () => {
|
||||
/*
|
||||
* Geprueft an der Kantenlaenge in Modulen: dieselbe Nutzlast braucht mit
|
||||
* hoeherer Fehlerkorrektur eine groessere Version. 29 Module (Version 3) plus
|
||||
* zweimal die Ruhezone von 4 ergeben 37 — mit "Q" oder "H" waeren es mehr,
|
||||
* mit "L" weniger.
|
||||
*
|
||||
* Die Adresse ist hier 42 Zeichen lang und damit **genau** die Grenze von
|
||||
* Version 3 bei M. Eine laengere APP_URL ist deshalb kein Fehler, macht den
|
||||
* Code aber eine Version groesser und die Module kleiner — bei 300 dpi bleibt
|
||||
* selbst Version 6 noch weit ueber der Lesbarkeitsgrenze (siehe unten).
|
||||
*/
|
||||
expect(QR_FEHLERKORREKTUR).toBe("M");
|
||||
expect(`${APP_URL}/t/${TAGCODE}`).toHaveLength(42);
|
||||
const svg = await qrSvg(TAGCODE);
|
||||
expect(svg).toContain('viewBox="0 0 37 37"');
|
||||
});
|
||||
|
||||
it("trägt eine feste Kantenlänge nur, wenn eine verlangt ist", async () => {
|
||||
// Ohne Angabe fuellt das SVG seinen Platz – genau das braucht ein Etikett,
|
||||
// das in Millimetern gesetzt ist.
|
||||
expect(await qrSvg(TAGCODE)).not.toContain("width=");
|
||||
expect(await qrSvg(TAGCODE, { breite: 256 })).toContain('width="256" height="256"');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* Die Rasterung, ausgerechnet.
|
||||
*
|
||||
* CSS rechnet 1 mm = 96 / 25.4 px. Ein Etikett von 62 × 29 mm ist im Browser
|
||||
* also 62 · 96 / 25.4 = 234,33 × 109,61 CSS-Pixel gross.
|
||||
*
|
||||
* 300 dpi bedeutet 300 / 96 = 3,125 Geraetepixel je CSS-Pixel. Mit diesem
|
||||
* `deviceScaleFactor` kommt der Screenshot mit
|
||||
* 62 / 25,4 · 300 = 732,28 × 342,52 Geraetepixeln heraus — also genau das
|
||||
* Bild, das ein 300-dpi-Etikettendrucker aufs Papier bringt.
|
||||
*
|
||||
* Der QR selbst ist 25 mm breit, das sind 295,3 Geraetepixel fuer 37 Module,
|
||||
* also knapp 8 Pixel je Modul. Kein Decoder hat damit Muehe — und genau
|
||||
* deshalb steht die Zahl hier: faellt sie unter drei Pixel je Modul, wird es
|
||||
* knapp, und dieser Test ist die Stelle, an der das auffaellt.
|
||||
*/
|
||||
const MM_JE_ZOLL = 25.4;
|
||||
const CSS_PX_JE_ZOLL = 96;
|
||||
const DPI = 300;
|
||||
|
||||
/** Liest `62` aus `w-[62mm]` – die Masse stehen nur in `etikett.tsx`. */
|
||||
function mmAus(klassen: string, praefix: string): number {
|
||||
const treffer = klassen.match(new RegExp(`(?:^| )${praefix}-\\[([0-9.]+)mm\\]`));
|
||||
if (!treffer?.[1]) throw new Error(`In „${klassen}“ steht kein ${praefix}-[…mm].`);
|
||||
return Number(treffer[1]);
|
||||
}
|
||||
|
||||
describe("Gerastertes Etikett", () => {
|
||||
it("wird bei 300 dpi im Format 62 × 29 mm von einem Decoder zurückgelesen", async () => {
|
||||
const breiteMm = mmAus(ETIKETT_RAHMEN.etikett, "w");
|
||||
const hoeheMm = mmAus(ETIKETT_RAHMEN.etikett, "h");
|
||||
const randMm = mmAus(ETIKETT_RAHMEN.etikett, "p");
|
||||
const qrMm = mmAus(ETIKETT_QR_FLAECHE.etikett, "size");
|
||||
expect([breiteMm, hoeheMm]).toEqual([62, 29]);
|
||||
|
||||
const svg = await qrSvg(TAGCODE);
|
||||
const skalierung = DPI / CSS_PX_JE_ZOLL;
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const seite = await browser.newPage({
|
||||
viewport: {
|
||||
width: Math.ceil((breiteMm / MM_JE_ZOLL) * CSS_PX_JE_ZOLL),
|
||||
height: Math.ceil((hoeheMm / MM_JE_ZOLL) * CSS_PX_JE_ZOLL),
|
||||
},
|
||||
deviceScaleFactor: skalierung,
|
||||
});
|
||||
|
||||
/*
|
||||
* Nachgebaut und nicht die Komponente gerendert: `Etikett` traegt seine
|
||||
* Masse in Tailwind-Klassen, und ohne den Build gibt es kein Stylesheet
|
||||
* dazu. Die Millimeter kommen deshalb aus denselben Klassen
|
||||
* (`mmAus` oben) — schrumpft das Etikett dort, schrumpft es hier mit.
|
||||
*/
|
||||
await seite.setContent(`<!doctype html>
|
||||
<html><body style="margin:0;background:#ffffff">
|
||||
<div id="etikett" style="box-sizing:border-box;width:${breiteMm}mm;height:${hoeheMm}mm;
|
||||
padding:${randMm}mm;background:#ffffff;display:flex;align-items:center;gap:2mm">
|
||||
<div style="width:${qrMm}mm;height:${qrMm}mm;flex:none">${svg}</div>
|
||||
<div style="font-family:monospace;font-size:3.4mm;font-weight:700">${TAGCODE}</div>
|
||||
</div>
|
||||
</body></html>`);
|
||||
|
||||
const etikett = seite.locator("#etikett");
|
||||
const bild = await etikett.screenshot({ type: "png" });
|
||||
|
||||
// Die Rechnung von oben, gegen das tatsaechliche Bild geprueft.
|
||||
const erwarteteBreite = Math.round((breiteMm / MM_JE_ZOLL) * DPI);
|
||||
const masse = await seite.evaluate(
|
||||
(datenAdresse) =>
|
||||
new Promise<{ breite: number; hoehe: number }>((fertig, gescheitert) => {
|
||||
const bildElement = new Image();
|
||||
bildElement.onload = () =>
|
||||
fertig({ breite: bildElement.naturalWidth, hoehe: bildElement.naturalHeight });
|
||||
bildElement.onerror = () => gescheitert(new Error("Das Bild ließ sich nicht laden."));
|
||||
bildElement.src = datenAdresse;
|
||||
}),
|
||||
`data:image/png;base64,${bild.toString("base64")}`,
|
||||
);
|
||||
// Ein Pixel Spiel: der Screenshot rundet auf ganze Geraetepixel.
|
||||
expect(Math.abs(masse.breite - erwarteteBreite)).toBeLessThanOrEqual(2);
|
||||
|
||||
/*
|
||||
* Zurueckgelesen mit `barcode-detector` im Seitenkontext. Das WASM kommt
|
||||
* als `wasmBinary` aus `node_modules` mit — ohne diese Uebergabe holte
|
||||
* es sich das Modul von einem CDN, und ein Test, der das Netz braucht,
|
||||
* ist kein Test.
|
||||
*/
|
||||
const require = createRequire(import.meta.url);
|
||||
const ponyfill = require.resolve("barcode-detector/ponyfill");
|
||||
const wasm = readFileSync(
|
||||
require.resolve("zxing-wasm/reader/zxing_reader.wasm", { paths: [ponyfill] }),
|
||||
);
|
||||
await seite.addScriptTag({
|
||||
path: ponyfill.replace(/dist[/\\](es|cjs)[/\\]ponyfill\.js$/, "dist/iife/ponyfill.js"),
|
||||
});
|
||||
|
||||
const gelesen = await seite.evaluate(
|
||||
async ({ pngBase64, wasmBase64 }) => {
|
||||
const bytes = (text: string): Uint8Array =>
|
||||
Uint8Array.from(atob(text), (zeichen) => zeichen.charCodeAt(0));
|
||||
const api = (globalThis as unknown as { BarcodeDetectionAPI: BarcodeApi }).BarcodeDetectionAPI;
|
||||
api.prepareZXingModule({ overrides: { wasmBinary: bytes(wasmBase64) } });
|
||||
const decoder = new api.BarcodeDetector({ formats: ["qr_code"] });
|
||||
const treffer = await decoder.detect(
|
||||
new Blob([bytes(pngBase64) as BlobPart], { type: "image/png" }),
|
||||
);
|
||||
return treffer.map((eintrag) => eintrag.rawValue);
|
||||
},
|
||||
{ pngBase64: bild.toString("base64"), wasmBase64: wasm.toString("base64") },
|
||||
);
|
||||
|
||||
// Genau ein Code, und genau die Adresse – nichts sonst steht darin.
|
||||
expect(gelesen).toEqual([`${APP_URL}/t/${TAGCODE}`]);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
/** Nur das, was der Test vom Ponyfill im Seitenkontext braucht. */
|
||||
type BarcodeApi = {
|
||||
prepareZXingModule: (optionen: { overrides: { wasmBinary: Uint8Array } }) => void;
|
||||
BarcodeDetector: new (optionen?: { formats?: readonly string[] }) => {
|
||||
detect: (bild: Blob) => Promise<readonly { rawValue: string }[]>;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tagCodeAusFund } from "@/lib/tagcode";
|
||||
|
||||
/**
|
||||
* Was ein gescannter Code bedeutet (#014).
|
||||
*
|
||||
* Die Kamera findet alles Moegliche: den eigenen Aufkleber, den QR-Code auf
|
||||
* einer Paketmarke, einen Link ins Intranet. Diese Entscheidung liegt deshalb in
|
||||
* einer reinen Funktion und nicht in der Kamerakomponente — dort waere sie nur
|
||||
* von Hand pruefbar, und zwar genau einmal.
|
||||
*/
|
||||
describe("Ein Fund des Scanners", () => {
|
||||
it("nimmt die Adresse vom Aufkleber", () => {
|
||||
expect(tagCodeAusFund("https://laufzettel.firma.de/t/LZ-7K3F9Q")).toBe("LZ-7K3F9Q");
|
||||
});
|
||||
|
||||
it("nimmt sie auch von einem anderen Host", () => {
|
||||
/*
|
||||
* Der Aufkleber klebt jahrelang. Zieht die Anwendung um oder steht ein
|
||||
* zweiter Name davor, waere ein Vergleich mit APP_URL der Grund, warum der
|
||||
* Scanner plötzlich nichts mehr erkennt — obwohl der Code stimmt.
|
||||
*/
|
||||
expect(tagCodeAusFund("http://10.0.0.5:3000/t/LZ-7K3F9Q")).toBe("LZ-7K3F9Q");
|
||||
});
|
||||
|
||||
it("nimmt den blanken Code, auch klein geschrieben", () => {
|
||||
expect(tagCodeAusFund("LZ-7K3F9Q")).toBe("LZ-7K3F9Q");
|
||||
expect(tagCodeAusFund(" lz-7k3f9q ")).toBe("LZ-7K3F9Q");
|
||||
});
|
||||
|
||||
it("weist alles andere ab, statt zu raten", () => {
|
||||
for (const fund of [
|
||||
"",
|
||||
" ",
|
||||
"https://laufzettel.firma.de/geraete/abc",
|
||||
"https://laufzettel.firma.de/t/",
|
||||
"https://laufzettel.firma.de/t/LZ-7K3F9Q/extra",
|
||||
// Verwechselbare Zeichen gibt es im Alphabet nicht – ein Code mit O, I
|
||||
// oder 1 ist keiner, auch wenn er wie einer aussieht.
|
||||
"LZ-7K3F9O",
|
||||
"LZ-ABCDE",
|
||||
"nur text",
|
||||
]) {
|
||||
expect(tagCodeAusFund(fund), fund).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("verkraftet eine prozentkodierte Adresse", () => {
|
||||
expect(tagCodeAusFund("https://laufzettel.firma.de/t/LZ%2D7K3F9Q")).toBe("LZ-7K3F9Q");
|
||||
});
|
||||
});
|
||||
+9
-1
@@ -143,7 +143,15 @@ describe("wertMaskieren fuer PowerShell", () => {
|
||||
});
|
||||
|
||||
describe.skipIf(!pwsh)("gegen ein echtes PowerShell geprueft", () => {
|
||||
it("kommt als genau ein Argument an und fuehrt nichts aus", () => {
|
||||
/*
|
||||
* Eine Minute statt der fuenf Sekunden, die Vitest sonst gibt: liegt kein
|
||||
* `pwsh` auf der Maschine, laeuft dieser Test in einem Container, und der
|
||||
* startet erst. Unter Last — ein Ende-zu-Ende-Lauf nebenher genuegt — reichte
|
||||
* die Vorgabe nicht, und der Test kippte um, ohne dass an der Maskierung
|
||||
* etwas falsch war. Ein Test, der gelegentlich grundlos rot ist, wird bald
|
||||
* uebersehen.
|
||||
*/
|
||||
it("kommt als genau ein Argument an und fuehrt nichts aus", { timeout: 90_000 }, () => {
|
||||
for (const wert of [BOESARTIG, TYPOGRAFISCH]) {
|
||||
const maskiert = wertMaskieren(wert, "powershell");
|
||||
const skript = `$werte = @("${maskiert}"); "$($werte.Count)|$($werte[0])"`;
|
||||
|
||||
@@ -29,8 +29,16 @@ function teile(richtlinie: string): Map<string, string> {
|
||||
describe("Ausgelieferter Stand", () => {
|
||||
const richtlinie = sicherheitsrichtlinie(NONCE, false);
|
||||
|
||||
it("erlaubt kein unsafe-eval", () => {
|
||||
expect(richtlinie).not.toContain("unsafe-eval");
|
||||
it("erlaubt kein unsafe-eval – wohl aber WebAssembly", () => {
|
||||
/*
|
||||
* `wasm-unsafe-eval` enthaelt die Zeichenkette „unsafe-eval“, ist aber etwas
|
||||
* ganz anderes: es erlaubt nur das Uebersetzen von WebAssembly, kein `eval`
|
||||
* auf JavaScript. Der Scanner (#014) braucht es, sonst lehnt Chrome den
|
||||
* QR-Leser ab. Geprueft wird deshalb auf die **Marke**, nicht auf das Wort.
|
||||
*/
|
||||
const script = teile(richtlinie).get("script-src") ?? "";
|
||||
expect(script.split(" ")).not.toContain("'unsafe-eval'");
|
||||
expect(script.split(" ")).toContain("'wasm-unsafe-eval'");
|
||||
});
|
||||
|
||||
it("erlaubt keine WebSocket-Verbindungen", () => {
|
||||
@@ -38,7 +46,9 @@ describe("Ausgelieferter Stand", () => {
|
||||
});
|
||||
|
||||
it("bindet Skripte an die Nonce dieser Anfrage", () => {
|
||||
expect(teile(richtlinie).get("script-src")).toBe(`'self' 'nonce-${NONCE}' 'strict-dynamic'`);
|
||||
expect(teile(richtlinie).get("script-src")).toBe(
|
||||
`'self' 'nonce-${NONCE}' 'strict-dynamic' 'wasm-unsafe-eval'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("verbietet das Einbetten in fremde Seiten und Objekte", () => {
|
||||
@@ -58,7 +68,7 @@ describe("Entwicklungsmodus", () => {
|
||||
const richtlinie = sicherheitsrichtlinie(NONCE, true);
|
||||
|
||||
it("erlaubt unsafe-eval, sonst hydriert React nicht", () => {
|
||||
expect(teile(richtlinie).get("script-src")).toContain("'unsafe-eval'");
|
||||
expect(teile(richtlinie).get("script-src")?.split(" ")).toContain("'unsafe-eval'");
|
||||
});
|
||||
|
||||
it("erlaubt die WebSocket-Verbindung von Fast Refresh", () => {
|
||||
@@ -130,7 +140,10 @@ describe("Die Entscheidung im Proxy selbst", () => {
|
||||
proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"),
|
||||
);
|
||||
expect(richtlinie).toBeTruthy();
|
||||
expect(richtlinie).not.toContain("unsafe-eval");
|
||||
// Auf die Marke geprueft, nicht auf das Wort: `wasm-unsafe-eval` enthaelt es
|
||||
// und ist trotzdem keine Lockerung fuer JavaScript (#014).
|
||||
expect(richtlinie).not.toContain(" 'unsafe-eval'");
|
||||
expect(richtlinie).toContain("'wasm-unsafe-eval'");
|
||||
expect(richtlinie).toContain("connect-src 'self';");
|
||||
expect(richtlinie).toContain("'strict-dynamic'");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Betriebsmodus, Rolle } from "@/generated/prisma/enums";
|
||||
import { Etikett, etikettenPfad } from "@/components/tags/etikett";
|
||||
import { istTagCode } from "@/lib/tagcode";
|
||||
import { etikettenZuCodes, unregistrierteTags } from "@/server/queries/tags";
|
||||
import {
|
||||
TAGS_STAPEL_HOECHSTENS,
|
||||
TAGS_STAPEL_STANDARD,
|
||||
TagsFehler,
|
||||
tagsErzeugen,
|
||||
} from "@/server/services/tags";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Tag-Stapel und Etikettenzeilen (#014).
|
||||
*
|
||||
* Festgehalten wird, was der Auftrag verspricht: ein Stapel legt N Tags an, alle
|
||||
* unregistriert, alle mit verschiedenen Codes, alle im Vergabebuch. Dazu die
|
||||
* Mandantensicht der Lesemodelle — ein Etikett eines fremden Kunden darf
|
||||
* niemand drucken, und ein Bogen ist Papier, das man nicht zurueckrufen kann.
|
||||
*/
|
||||
|
||||
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
|
||||
const eigene = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global", modus } });
|
||||
const benutzer = await testPrisma.benutzer.create({
|
||||
data: { email: "admin@firma.example", name: "Administrator", rolle: Rolle.ADMIN },
|
||||
});
|
||||
return { eigene, admin: { id: benutzer.id, rolle: Rolle.ADMIN } };
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Tags", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
});
|
||||
|
||||
describe("Stapel erzeugen", () => {
|
||||
it("legt N unregistrierte Tags mit verschiedenen Codes im Vergabebuch an", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const stapel = await tagsErzeugen(7, null, admin);
|
||||
|
||||
expect(stapel.tagCodes).toHaveLength(7);
|
||||
expect(stapel.kundeId).toBe(eigene.id);
|
||||
// Jeder Code ist ein Code, und keiner kommt zweimal.
|
||||
expect(stapel.tagCodes.every(istTagCode)).toBe(true);
|
||||
expect(new Set(stapel.tagCodes).size).toBe(7);
|
||||
|
||||
const geraete = await testPrisma.geraet.findMany({
|
||||
where: { tagCode: { in: [...stapel.tagCodes] } },
|
||||
});
|
||||
expect(geraete).toHaveLength(7);
|
||||
for (const geraet of geraete) {
|
||||
// Ein Tag ohne Zuordnung: kein Name, kein Besitzer, nicht registriert.
|
||||
expect(geraet.registriertAm).toBeNull();
|
||||
expect(geraet.hostname).toBeNull();
|
||||
expect(geraet.bezeichnung).toBeNull();
|
||||
expect(geraet.mitarbeiterId).toBeNull();
|
||||
expect(geraet.kundeId).toBe(eigene.id);
|
||||
}
|
||||
|
||||
// Das Vergabebuch ist der Grund, dass ein Code nie zurueckkommt.
|
||||
const vergaben = await testPrisma.tagCodeVergabe.findMany({
|
||||
where: { tagCode: { in: [...stapel.tagCodes] } },
|
||||
});
|
||||
expect(vergaben).toHaveLength(7);
|
||||
|
||||
// Jeder Tag steht mit seinem Erzeuger im Protokoll.
|
||||
const protokoll = await testPrisma.protokoll.findMany({ where: { entitaet: "Geraet" } });
|
||||
expect(protokoll).toHaveLength(7);
|
||||
expect(protokoll.every((zeile) => zeile.benutzerId === admin.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("hält die Obergrenze und nimmt keine krumme Anzahl", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
await expect(tagsErzeugen(0, null, admin)).rejects.toThrow(TagsFehler);
|
||||
await expect(tagsErzeugen(-3, null, admin)).rejects.toThrow(TagsFehler);
|
||||
await expect(tagsErzeugen(2.5, null, admin)).rejects.toThrow(TagsFehler);
|
||||
await expect(tagsErzeugen(TAGS_STAPEL_HOECHSTENS + 1, null, admin)).rejects.toThrow(
|
||||
/Höchstens 500 Tags/,
|
||||
);
|
||||
// Und kein einziger Code ist dabei verbraucht worden.
|
||||
expect(await testPrisma.tagCodeVergabe.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("nennt 50 als Standardstapel", () => {
|
||||
// Die Zahl steht im Auftrag. Sie hier festzuhalten heisst, dass eine
|
||||
// Aenderung eine Entscheidung ist und kein Versehen.
|
||||
expect(TAGS_STAPEL_STANDARD).toBe(50);
|
||||
});
|
||||
|
||||
it("verlangt im Modus Dienstleistung einen Kunden", async () => {
|
||||
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
|
||||
// Die Regel steht in `kundeErmitteln` und gilt damit fuer jeden Dienst
|
||||
// gleich — auch fuer diesen, der sie nicht selbst kennt (#008).
|
||||
await expect(tagsErzeugen(3, null, admin)).rejects.toThrow(/Kunde/);
|
||||
expect(await testPrisma.tagCodeVergabe.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("verwirft im Modus Intern eine mitgeschickte Kundenkennung", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
|
||||
const stapel = await tagsErzeugen(2, fremder.id, admin);
|
||||
expect(stapel.kundeId).toBe(eigene.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Liste der unregistrierten Tags", () => {
|
||||
it("zeigt nur Tags ohne Zuordnung und keine archivierten", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const stapel = await tagsErzeugen(3, null, admin);
|
||||
await testPrisma.geraet.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
tagCode: "LZ-AAAAAA",
|
||||
hostname: "PC-VERTRIEB-07",
|
||||
registriertAm: new Date(),
|
||||
},
|
||||
});
|
||||
const archivierter = stapel.tagCodes[0];
|
||||
await testPrisma.geraet.update({
|
||||
where: { tagCode: archivierter },
|
||||
data: { archiviert: true },
|
||||
});
|
||||
|
||||
const tags = await unregistrierteTags(admin);
|
||||
expect(tags.map((tag) => tag.tagCode).sort()).toEqual([...stapel.tagCodes].slice(1).sort());
|
||||
// Ein Tag hat noch keinen Namen – das Etikett zeigt dann nur den Code.
|
||||
expect(tags.every((tag) => tag.anzeige === null)).toBe(true);
|
||||
expect(tags.every((tag) => tag.kundeKuerzel === "EIGEN")).toBe(true);
|
||||
});
|
||||
|
||||
it("zeigt einem Techniker nur die Tags seiner Kunden", async () => {
|
||||
const { techniker, meinCode, fremdCode } = await zweiKunden();
|
||||
const tags = await unregistrierteTags(techniker);
|
||||
expect(tags.map((tag) => tag.tagCode)).toEqual([meinCode]);
|
||||
expect(tags.map((tag) => tag.tagCode)).not.toContain(fremdCode);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Etiketten zu bestimmten Codes", () => {
|
||||
it("liefert sie in der angefragten Reihenfolge", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const stapel = await tagsErzeugen(3, null, admin);
|
||||
const gewuenscht = [stapel.tagCodes[2], stapel.tagCodes[0], stapel.tagCodes[1]] as string[];
|
||||
|
||||
const zeilen = await etikettenZuCodes(gewuenscht, admin);
|
||||
// Ein Bogen wird der Reihe nach ausgeschnitten und aufgeklebt.
|
||||
expect(zeilen.map((zeile) => zeile.tagCode)).toEqual(gewuenscht);
|
||||
});
|
||||
|
||||
it("lässt unbekannte, doppelte und formal falsche Codes still weg", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const stapel = await tagsErzeugen(1, null, admin);
|
||||
const code = stapel.tagCodes[0] as string;
|
||||
|
||||
const zeilen = await etikettenZuCodes([code, code, "LZ-ZZZZZZ", "quatsch", "", " "], admin);
|
||||
// „Gibt es nicht“ und „darfst du nicht sehen“ sind dieselbe Antwort, und
|
||||
// ein doppelter Code soll nicht zweimal auf den Bogen.
|
||||
expect(zeilen.map((zeile) => zeile.tagCode)).toEqual([code]);
|
||||
});
|
||||
|
||||
it("nimmt einen kleingeschriebenen Code an", async () => {
|
||||
const { admin } = await grundbestand();
|
||||
const stapel = await tagsErzeugen(1, null, admin);
|
||||
const code = stapel.tagCodes[0] as string;
|
||||
// Abgetippt kommt der Code auch mal klein – gemeint ist derselbe Aufkleber.
|
||||
const zeilen = await etikettenZuCodes([code.toLowerCase()], admin);
|
||||
expect(zeilen.map((zeile) => zeile.tagCode)).toEqual([code]);
|
||||
});
|
||||
|
||||
it("gibt einem Techniker kein Etikett eines fremden Kunden", async () => {
|
||||
const { techniker, meinCode, fremdCode } = await zweiKunden();
|
||||
const zeilen = await etikettenZuCodes([fremdCode, meinCode], techniker);
|
||||
expect(zeilen.map((zeile) => zeile.tagCode)).toEqual([meinCode]);
|
||||
});
|
||||
|
||||
it("führt Hostname, Besitzer und Kundenkürzel mit – die Seite entscheidet über das Kürzel", async () => {
|
||||
const { admin, eigene } = await grundbestand();
|
||||
const person = await testPrisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
vorname: "Anna",
|
||||
nachname: "Musterfrau",
|
||||
anzeigename: "Anna Musterfrau",
|
||||
},
|
||||
});
|
||||
await testPrisma.geraet.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
tagCode: "LZ-BBBBBB",
|
||||
hostname: "PC-VERTRIEB-07",
|
||||
mitarbeiterId: person.id,
|
||||
registriertAm: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const [zeile] = await etikettenZuCodes(["LZ-BBBBBB"], admin);
|
||||
expect(zeile?.anzeige).toBe("PC-VERTRIEB-07");
|
||||
expect(zeile?.besitzerName).toBe("Anna Musterfrau");
|
||||
// Das Lesemodell liefert das Kuerzel immer; ob es aufs Etikett kommt,
|
||||
// haengt am Betriebsmodus und entscheidet die Druckseite (#008).
|
||||
expect(zeile?.kundeKuerzel).toBe("EIGEN");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Zwei Kunden, ein Techniker mit Bindung an genau einen davon – der Aufbau fuer
|
||||
* jede Pruefung der Mandantengrenze.
|
||||
*/
|
||||
async function zweiKunden() {
|
||||
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
|
||||
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
|
||||
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
|
||||
|
||||
const benutzer = await testPrisma.benutzer.create({
|
||||
data: { email: "t@firma.example", name: "Techniker", rolle: Rolle.TECHNIKER },
|
||||
});
|
||||
await testPrisma.benutzerKunde.create({ data: { benutzerId: benutzer.id, kundeId: meiner.id } });
|
||||
|
||||
await testPrisma.geraet.create({ data: { kundeId: meiner.id, tagCode: "LZ-CCCCCC" } });
|
||||
await testPrisma.geraet.create({ data: { kundeId: fremder.id, tagCode: "LZ-DDDDDD" } });
|
||||
|
||||
return {
|
||||
techniker: { id: benutzer.id, rolle: Rolle.TECHNIKER },
|
||||
meinCode: "LZ-CCCCCC",
|
||||
fremdCode: "LZ-DDDDDD",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Etikett selbst. Braucht keine Datenbank: die Komponente bekommt fertige
|
||||
* Daten und entscheidet nichts — insbesondere nicht, ob das Kundenkuerzel
|
||||
* erscheint. Genau das wird hier festgehalten.
|
||||
*/
|
||||
describe("Etikett", () => {
|
||||
const daten = {
|
||||
tagCode: "LZ-7K3F9Q",
|
||||
qrSvg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 37"><path d="M0 0h1"/></svg>',
|
||||
anzeige: "PC-VERTRIEB-07",
|
||||
besitzerName: "Anna Musterfrau",
|
||||
};
|
||||
|
||||
it("zeigt Code, Name, Besitzer und das eingesetzte QR-SVG", () => {
|
||||
const markup = renderToStaticMarkup(createElement(Etikett, { daten, layout: "etikett" }));
|
||||
expect(markup).toContain("LZ-7K3F9Q");
|
||||
expect(markup).toContain("PC-VERTRIEB-07");
|
||||
expect(markup).toContain("Anna Musterfrau");
|
||||
expect(markup).toContain('viewBox="0 0 37 37"');
|
||||
// Die Masse stehen in Millimetern und nicht in Pixeln.
|
||||
expect(markup).toContain("w-[62mm]");
|
||||
});
|
||||
|
||||
it("nennt das Kundenkürzel nur, wenn eines mitgegeben wird", () => {
|
||||
// Im Modus INTERN gibt die Seite `null` – dort erscheint die eine eigene
|
||||
// Organisation nirgends, auch nicht auf einem Etikett (#008).
|
||||
expect(renderToStaticMarkup(createElement(Etikett, { daten, layout: "bogen" }))).not.toContain("MEIN");
|
||||
expect(
|
||||
renderToStaticMarkup(
|
||||
createElement(Etikett, { daten: { ...daten, kundeKuerzel: "MEIN" }, layout: "bogen" }),
|
||||
),
|
||||
).toContain("MEIN");
|
||||
});
|
||||
|
||||
it("baut die Adresse der Druckansicht mit Layout und Codes", () => {
|
||||
// Der Knopf „Etikett drucken“ auf der Geraeteseite ruft genau das auf.
|
||||
expect(etikettenPfad(["LZ-7K3F9Q"])).toBe("/drucken/etiketten?layout=etikett&codes=LZ-7K3F9Q");
|
||||
expect(etikettenPfad(["LZ-AAAAAA", "LZ-BBBBBB"], "bogen")).toBe(
|
||||
"/drucken/etiketten?layout=bogen&codes=LZ-AAAAAA%2CLZ-BBBBBB",
|
||||
);
|
||||
// Ohne Codes zeigt die Seite alle noch nicht zugeordneten Tags.
|
||||
expect(etikettenPfad([], "bogen")).toBe("/drucken/etiketten?layout=bogen");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user