feat(stammdaten): Mitarbeiter, Abteilungen und Geraete (#25)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #25.
This commit is contained in:
2026-09-25 10:52:44 +00:00
parent e3d80e5982
commit f85bc87324
81 changed files with 6784 additions and 59 deletions
+198
View File
@@ -0,0 +1,198 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Die Geraeteliste durch die Oberflaeche (#011).
*
* Diese Datei gibt es aus demselben Grund wie tests/e2e/vorlagen-editor.spec.ts:
* ein Screenshot sagt in dieser Umgebung **nichts** ueber Bedienbarkeit, weil
* `next dev` nicht hydriert (siehe CLAUDE.md, „Oberflaeche pruefen“). Geprueft
* wird deshalb, was nur mit angehaengtem React gelingt — anlegen, suchen,
* einlesen, loeschen — und anschliessend, ob es wirklich in der Datenbank steht:
* durch Neuladen.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt.
*
* Der Ablauf raeumt hinter sich auf: an den angelegten Geraeten haengt kein Lauf,
* sie lassen sich also loeschen. Ihre Tag-Codes bleiben vergeben, und genau das
* ist die Zusage aus #011 — ein Aufkleber zeigt nie auf ein zweites Geraet.
*/
const MARKE = `ZZ-E2E-${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"]')]);
}
/** Der Abschnitt „Gerät anlegen“ – eindeutig ueber seinen eigenen Knopf. */
function anlegenFormular(seite: Page) {
return seite.locator("section", { has: seite.getByRole("button", { name: "Gerät anlegen" }) });
}
function karte(seite: Page, name: string) {
return seite.locator("li").filter({ hasText: name });
}
/** Loescht alle Geraete, deren Name mit der Marke dieses Durchlaufs beginnt. */
async function aufraeumen(seite: Page) {
await seite.goto(`/geraete?suche=${MARKE}`);
for (let versuch = 0; versuch < 10; versuch += 1) {
const karten = seite.locator("li").filter({ hasText: MARKE });
if ((await karten.count()) === 0) return;
const erste = karten.first();
await erste.getByRole("button", { name: "löschen", exact: true }).click();
await erste.getByRole("button", { name: "ja, löschen" }).click();
await expect(karten).toHaveCount(await karten.count().then((anzahl) => anzahl - 1));
}
}
test.describe("Geräte", () => {
test("legt ein Gerät an, findet es in der Liste und sucht es", async ({ page }) => {
test.slow();
const name = `${MARKE}-PC-01`;
await anmelden(page);
await page.goto("/geraete");
// Der Reiter fuehrt zwischen Personen und Geraeten hin und her.
await expect(page.getByRole("link", { name: "Geräte", exact: true })).toHaveAttribute(
"aria-current",
"page",
);
const formular = anlegenFormular(page);
await formular.getByLabel("Hostname").fill(name);
await formular.getByLabel("Seriennummer").fill(`${MARKE}-SN`);
await formular.getByLabel("Typ").selectOption("NOTEBOOK");
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
// Der Tag-Code steht in der Meldung – er kommt gleich auf den Aufkleber.
const meldung = formular.getByRole("status");
await expect(meldung).toContainText(/wurde angelegt, Tag-Code LZ-[A-Z2-9]{6}\./);
const tagCode = (await meldung.textContent())?.match(/LZ-[A-Z2-9]{6}/)?.[0];
expect(tagCode).toBeTruthy();
// Der Beweis, dass es nicht nur im Browser steht: neu laden.
await page.goto("/geraete");
await expect(karte(page, name)).toContainText("Notebook");
await expect(karte(page, name)).toContainText(tagCode!);
// Suche über den Hostname …
await page.locator('input[name="suche"]').fill(name);
await page.getByRole("button", { name: "Filtern" }).click();
await expect(page.locator("ul > li").filter({ hasText: MARKE })).toHaveCount(1);
// … und über den abgetippten Tag-Code ohne Präfix.
await page.locator('input[name="suche"]').fill(tagCode!.slice(3));
await page.getByRole("button", { name: "Filtern" }).click();
await expect(karte(page, name)).toBeVisible();
// Ein Suchbegriff, der zu nichts passt, sagt das auch.
await page.locator('input[name="suche"]').fill(`${MARKE}-gibt-es-nicht`);
await page.getByRole("button", { name: "Filtern" }).click();
await expect(page.getByText("Kein Gerät passt zu diesen Filtern.")).toBeVisible();
await aufraeumen(page);
});
test("ändert Stammdaten und archiviert statt zu löschen", async ({ page }) => {
test.slow();
const name = `${MARKE}-PC-02`;
await anmelden(page);
await page.goto("/geraete");
const formular = anlegenFormular(page);
await formular.getByLabel("Hostname").fill(name);
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
await page.goto(`/geraete?suche=${MARKE}`);
const meine = karte(page, name);
await meine.getByText("Stammdaten bearbeiten").click();
await meine.getByLabel("Seriennummer").fill(`${MARKE}-SN-NEU`);
await meine.getByRole("button", { name: "Speichern" }).click();
await expect(meine.getByRole("status")).toContainText("Gespeichert.");
// Und wieder der Beweis, dass es in der Datenbank steht.
await page.reload();
await expect(karte(page, name)).toContainText(`S/N ${MARKE}-SN-NEU`);
// Archivieren nimmt das Gerät aus der Liste, ohne es zu entfernen.
await karte(page, name).getByRole("button", { name: "archivieren" }).click();
await expect(karte(page, name)).toHaveCount(0);
await page.goto(`/geraete?suche=${MARKE}&archiviert=1`);
await expect(karte(page, name)).toContainText("archiviert");
await karte(page, name).getByRole("button", { name: "wieder freigeben" }).click();
await expect(karte(page, name).getByRole("button", { name: "archivieren" })).toBeVisible();
await aufraeumen(page);
});
test("liest eine CSV-Datei ein und meldet die kaputte Zeile einzeln", async ({ page }) => {
test.slow();
await anmelden(page);
await page.goto("/geraete");
const bereich = page.locator("details", { hasText: "Geräte aus einer CSV-Datei einlesen" });
await bereich.getByText("Geräte aus einer CSV-Datei einlesen").click();
await bereich
.locator('textarea[name="inhalt"]')
.fill(
[
"hostname;label;serialNumber;inventoryNumber;type;model",
`${MARKE}-CSV-GUT;;SN-GUT;INV-1;PC;OptiPlex`,
`${MARKE}-CSV-BOESE;;SN-BOESE;INV-2;Tablet;`,
].join("\r\n"),
);
await bereich.getByRole("button", { name: "Einlesen" }).click();
await expect(bereich.getByRole("status")).toContainText("1 von 2 Zeilen angelegt, 1 Zeile abgewiesen.");
// Mit Zeilennummer, damit die Datei an genau dieser Stelle zu ändern ist.
await expect(bereich.getByText("Zeile 3")).toBeVisible();
await expect(bereich.getByText(/kein bekannter Gerätetyp/)).toBeVisible();
// Die gute Zeile steht in der Datenbank, die kaputte nicht.
await page.goto(`/geraete?suche=${MARKE}-CSV`);
await expect(karte(page, `${MARKE}-CSV-GUT`)).toBeVisible();
await expect(page.locator("ul > li").filter({ hasText: MARKE })).toHaveCount(1);
await aufraeumen(page);
});
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile, viewport }) => {
test.skip(!isMobile, "Nur im Projekt „handy“.");
test.slow();
const name = `${MARKE}-RAUM`;
await anmelden(page);
await page.goto("/geraete");
const formular = anlegenFormular(page);
await formular.getByLabel("Bezeichnung").fill(name);
await formular.getByLabel("Typ").selectOption("RAUM");
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
await page.goto(`/geraete?suche=${MARKE}`);
await expect(karte(page, name)).toBeVisible();
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
expect(viewport?.width).toBeLessThanOrEqual(430);
// Die Bedienelemente sind am Handy mindestens 44 px hoch.
const knopf = karte(page, name).getByRole("button", { name: "archivieren" });
const kasten = await knopf.boundingBox();
expect(kasten?.height ?? 0).toBeGreaterThanOrEqual(44);
await aufraeumen(page);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
/**
* Personen und Abteilungen durch die Oberflaeche (#011).
*
* Geprueft wird, was nur mit angehaengtem React gelingt — und danach, ob es
* wirklich in der Datenbank steht: durch Neuladen. Ein Screenshot sagt darueber
* nichts (siehe CLAUDE.md, „Oberflaeche pruefen“).
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`).
*
* Aufgeraeumt wird am Ende ueber die Datenbank und nicht ueber die Oberflaeche:
* die Anwendung kennt bewusst keinen Loeschweg fuer Personen und Abteilungen
* (deaktivieren und archivieren sind das Gegenteil von Aufraeumen). Ohne diesen
* Nachlauf saehe jede Datenbank, gegen die diese Tests laufen, nach ein paar
* Durchlaeufen aus wie ein Messie-Keller — in der Entwicklungsdatenbank faellt
* das sofort auf den Screenshots auf.
*/
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"]')]);
}
test.describe("Personen", () => {
test("legt an, findet über die Suche und deaktiviert", async ({ page }) => {
test.slow();
const nachname = `ZZPruef${STEMPEL}`;
await anmelden(page);
await page.goto("/mitarbeiter");
// `summary` ist in Chromium kein `button`, sondern ein Aufklapp-Element.
await page.locator("summary", { hasText: "Person von Hand anlegen" }).click();
await page.getByLabel("Vorname").fill("Testa");
await page.getByLabel("Nachname").fill(nachname);
await page.getByLabel("Mail-Adresse (optional)").fill(`testa.${STEMPEL}@firma.de`);
await page.getByRole("button", { name: "Person anlegen" }).click();
await expect(page.getByText(`„Testa ${nachname}“ wurde angelegt.`)).toBeVisible();
// Über die Suche finden – das ist der Weg, den ein Techniker geht.
await page.goto(`/mitarbeiter?suche=${nachname}`);
const treffer = page.getByRole("link", { name: new RegExp(nachname) });
await expect(treffer).toHaveCount(1);
await treffer.click();
await page.waitForURL(/\/mitarbeiter\/[^/]+$/);
// Deaktivieren – der einzige Weg hinaus, geloescht wird nichts.
await page.getByRole("button", { name: "deaktivieren" }).click();
await expect(page.getByText("deaktiviert")).toBeVisible();
// Und damit aus der Standardliste verschwinden, aber nicht aus der Welt.
await page.goto(`/mitarbeiter?suche=${nachname}`);
await expect(page.getByRole("link", { name: new RegExp(nachname) })).toHaveCount(0);
await page.goto(`/mitarbeiter?suche=${nachname}&deaktivierte=1`);
await expect(page.getByRole("link", { name: new RegExp(nachname) })).toHaveCount(1);
});
test("sperrt die Stammdaten einer Person aus dem Verzeichnis", async ({ page }) => {
// Vorausgesetzt wird die Beispielperson aus `pnpm beispiel:stammdaten`.
test.slow();
await anmelden(page);
await page.goto("/mitarbeiter?suche=kwinter");
const treffer = page.getByRole("link", { name: /Katrin Winter/ });
test.skip((await treffer.count()) === 0, "Beispiel-Stammdaten fehlen (pnpm beispiel:stammdaten).");
await treffer.click();
await page.waitForURL(/\/mitarbeiter\/[^/]+$/);
await expect(page.getByText(/kommt aus dem Verzeichnis/)).toBeVisible();
await expect(page.getByLabel("Vorname")).toHaveAttribute("readonly", "");
await expect(page.getByRole("button", { name: "Stammdaten speichern" })).toHaveCount(0);
// Deaktivieren muss trotzdem gehen: das Offboarding wartet nicht auf den
// naechsten Abgleich.
await expect(page.getByRole("button", { name: "deaktivieren" })).toBeVisible();
});
});
test.describe("Abteilungen", () => {
test("legt eine Abteilung mit Ziel-OU und Gruppen an", async ({ page }) => {
test.slow();
const name = `ZZ Abteilung ${STEMPEL}`;
await anmelden(page);
await page.goto("/admin/abteilungen");
const formular = page.locator("section", { hasText: "Abteilung anlegen" });
await formular.getByLabel("Name").fill(name);
await formular.getByLabel("Ziel-OU (optional)").fill("OU=Test,DC=firma,DC=de");
await formular.getByLabel("Standardgruppen, mit Komma getrennt").fill("G-Test, G-Alle");
await formular.getByRole("button", { name: "Abteilung anlegen" }).click();
await expect(page.getByText(`„${name}“ wurde angelegt.`)).toBeVisible();
await page.reload();
const eintrag = page.locator("li", { hasText: name }).first();
await expect(eintrag).toContainText("OU=Test,DC=firma,DC=de");
await expect(eintrag).toContainText("G-Test, G-Alle");
// Denselben Namen ein zweites Mal – mit lesbarer Meldung, nicht mit einem
// Datenbankfehler.
await formular.getByLabel("Name").fill(name);
await formular.getByRole("button", { name: "Abteilung anlegen" }).click();
await expect(page.getByText(/gibt es schon/)).toBeVisible();
// Aufräumen: archivieren, denn geloescht wird nichts.
await eintrag.getByRole("button", { name: "archivieren" }).click();
await expect(eintrag.getByRole("button", { name: "wieder freigeben" })).toBeVisible();
});
});
test.afterAll(async () => {
const personen = await prisma.mitarbeiter.findMany({
where: { nachname: { startsWith: "ZZPruef" } },
select: { id: true },
});
const abteilungen = await prisma.abteilung.findMany({
where: { name: { startsWith: "ZZ Abteilung" } },
select: { id: true },
});
const kennungen = [...personen, ...abteilungen].map((zeile) => zeile.id);
if (kennungen.length > 0) {
await prisma.protokoll.deleteMany({ where: { entitaetId: { in: kennungen } } });
await prisma.mitarbeiter.deleteMany({ where: { id: { in: personen.map((z) => z.id) } } });
await prisma.abteilung.deleteMany({ where: { id: { in: abteilungen.map((z) => z.id) } } });
}
await prisma.$disconnect();
});