Files
Laufzettel/tests/e2e/geraete.spec.ts
T
Florian WildeandClaude Opus 5 86d55917f9
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(stammdaten): Mitarbeiter, Abteilungen und Geraete
Die Stammdaten, an denen die Historie haengt. Personen werden deaktiviert,
Geraete und Abteilungen archiviert — geloescht wird nichts, woran ein Lauf
haengen kann.

- Mitarbeiterliste mit Suche ueber Anzeigename, Mail, UPN und sAMAccountName,
  Filter nach Abteilung, standardmaessig nur aktive; die Suche ist ein eigenes
  Bauteil, weil #013 und #014 sie wieder brauchen
- Personenseite mit Stammdaten, Geraeten, Vorgesetztem; was aus dem Verzeichnis
  kommt, ist gesperrt — der naechste Abgleich wuerde es ohnehin zurueckdrehen
- Abteilungen unter Admin: Ziel-OU, Standardgruppen, Mail-Domain, dazu die
  Namen, die bei Personen vorkommen, aber nicht gepflegt sind
- Geraete mit Suche ueber Hostname, Bezeichnung, Seriennummer und Tag-Code;
  CSV-Import mit einem Ergebnis je Zeile
- Neues Recht mitarbeiterBearbeiten; die Seitenleiste fuehrt Personen und
  Geraete unter einem Eintrag, im Modus Dienstleistung anders beschriftet

Der unabhaengige Review fand zwei Blocker. Der schwerere meldete Erfolg: ein
nicht geschlossenes Anfuehrungszeichen verschluckte im CSV-Import alle
Folgezeilen bis zum naechsten `"`, und der Import sagte „3 von 3 Zeilen
angelegt". Ein Wert reicht jetzt nicht mehr ueber die Zeile hinaus — fuer
Hostnamen und Seriennummern ist ein Umbruch praktisch immer ein vergessenes
Anfuehrungszeichen, und so endet der Schaden an seiner Zeile. Der zweite:
ungepflegte Abteilungen wurden ueber alle Kunden hinweg gruppiert.

Dazu sieben weitere Befunde, alle behoben — darunter eine Besitzerauswahl, die
Personen fremder Kunden anbot, und ein Techniker, der ein Geraet endgueltig
loeschen konnte.

610 Tests gruen, 18 E2E-Ablaeufe gegen den gebauten Stand.

Schliesst #011

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 12:51:54 +02:00

199 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});