test: Zusagen pruefbar machen, die nur von Hand hielten (#038, #037)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

#038: Die E2E-Ablaeufe laufen ab jetzt vor jedem Merge nach main, nicht vor
jedem Commit. Gemessen: Bau 14 s, Start 7 s, Ablaeufe 256 s — zu den vier
Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor, das
umgangen wird. Vor jedem Merge waeren es in Phase 2 acht Laeufe gewesen, und
jeder der vier Befunde aus #027 waere am Tag seiner Entstehung aufgefallen.

Geprueft wurde jeder der 16 Ablaeufe gegen die Frage, ob es ein
Vitest-Gegenstueck gibt. Neun Zusagen haengen ausschliesslich dort, und keine
laesst sich holen: alle brauchen einen echten Browser. Die Liste steht in
CLAUDE.md.

Der eigentliche Fund dabei war keine Testluecke, sondern eine Konvention ohne
Pruefer: "jede Seite prueft selbst" steht seit #007 in CLAUDE.md, und geprueft
hat es bis hierher nur ein Mensch, der nachsieht. Der Review von Phase 2 hat
alle 27 Seiten durchgesehen — ein Ergebnis, das fuer genau einen Tag gilt.
tests/zugang.test.ts liest sie jetzt, samt jedem Route Handler; die Ausnahmen
(Auth, Healthcheck) stehen namentlich mit Begruendung, und ein Test prueft auch
die Gegenrichtung.

#037: Das Lesemodell der Anhaenge liegt in queries/ und engt selbst ein — vorher
hing das daran, dass beide Aufrufer den Lauf vorher pruefen. /meine-geraete wird
bei 390 px gemessen, mit einem echten MITARBEITER-Konto aus dem E2E-Vorlauf,
dazu ein Test, dass dort kein Link in die Verwaltung angeboten wird.

Dabei selbst einen Fehler gemacht, der die neue Regel begruendet: fuer das
zweite Konto brauchte `anmelden` eine Variante, die nicht auf / wartet, und ich
habe das Warten fuer alle gelockert. Das machte zwei unverwandte Ablaeufe
unzuverlaessig — sichtbar nur im vollen Durchgang, einzeln waren beide gruen.
Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ohne vollen
Durchgang haette ich zwei rote Ablaeufe gemergt.

1457 Vitest-Tests (vorher 1454), 115 E2E-Ablaeufe (vorher 113). Offen bleibt nur
noch #024, und das verlangt einen echten Proxmox-Host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-11 06:05:29 +02:00
co-authored by Claude Opus 5
parent 80d97467da
commit 75c335df23
16 changed files with 639 additions and 80 deletions
+46 -1
View File
@@ -2,7 +2,16 @@ import { chromium } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { ABTEILUNGEN } from "@/../prisma/seed-daten/stammdaten";
import { anmelden, SEED_GERAET, SEED_TAG, SEED_VORLAGE } from "./helfer";
import { passwortHashen } from "@/server/auth/benutzer";
import {
anmelden,
E2E_MITARBEITER_MAIL,
E2E_MITARBEITER_NAME,
E2E_MITARBEITER_PASSWORT,
SEED_GERAET,
SEED_TAG,
SEED_VORLAGE,
} from "./helfer";
import { e2eAufraeumenMitBericht } from "./aufraeumen";
/**
@@ -114,6 +123,42 @@ export default async function vorbereiten(): Promise<void> {
});
process.env["E2E_BETRIEBSMODUS"] = einstellung?.modus ?? "INTERN";
/*
* 2d. Das Konto der Rolle `MITARBEITER` fuer `/meine-geraete` (#037).
*
* Es muss an einer `Mitarbeiter`-Zeile haengen — der CHECK
* `Benutzer_mitarbeiter_nur_bei_rolle` verlangt das in **beide** Richtungen
* (#035): ein MITARBEITER ohne Person waere ein Recht, das sich auf niemanden
* bezieht. Genommen wird die erste Person des Seed-Bestands, denn sie hat
* Geraete, und ohne Geraet zeigte die Seite nur ihren Leerzustand.
*/
const person = await prisma.mitarbeiter.findFirst({
where: { aktiv: true, geraete: { some: {} } },
select: { id: true, kundeId: true, anzeigename: true },
orderBy: { anzeigename: "asc" },
});
if (!person) {
fehlen(
"Es gibt keine Person mit einem Gerät.",
"pnpm db:seed laufen lassen — die Selbstauskunft braucht eine Person, der ein Gerät gehört.",
);
}
await prisma.benutzer.upsert({
where: { email: E2E_MITARBEITER_MAIL },
create: {
email: E2E_MITARBEITER_MAIL,
name: E2E_MITARBEITER_NAME,
rolle: "MITARBEITER",
passwortHash: await passwortHashen(E2E_MITARBEITER_PASSWORT),
mitarbeiterId: person.id,
aktiv: true,
},
// Ein Durchgang, der vorher abgebrochen ist, laesst das Konto stehen. Die
// Person kann dabei eine andere sein als beim letzten Mal.
update: { rolle: "MITARBEITER", mitarbeiterId: person.id, aktiv: true },
});
process.env["E2E_MITARBEITER_PERSON"] = person.anzeigename;
/*
* 3. Liest der Server **dieselbe** Datenbank?
*
+47 -1
View File
@@ -20,6 +20,22 @@ export const SEED_VORLAGE = "PC Neuinstallation";
/** Das Beispielgerät aus dem Seed. Sein Aufkleber steht im Abnahmekriterium. */
export const SEED_GERAET = "PC-001";
/**
* Ein Konto der Rolle `MITARBEITER`, nur fuer die Ablaeufe (#037).
*
* **Nicht im Seed**, obwohl das kuerzer waere: ein Konto mit bekanntem Passwort
* gehoert nicht in einen Seed, der auch produktiv laeuft. Der E2E-Vorlauf legt
* es an, der Nachlauf nimmt es wieder mit — es traegt `ZZ` im Namen und faellt
* damit unter `tests/e2e/aufraeumen.ts`.
*
* Gebraucht wird es, weil `/meine-geraete` das Recht `selbstauskunft` verlangt
* und das **nur** `MITARBEITER` hat: ein Administrator saehe dort „kein
* Zugriff“, und ein Ablauf haette die Verweigerungsseite gemessen.
*/
export const E2E_MITARBEITER_MAIL = "zz.e2e.mitarbeiter@firma.example";
export const E2E_MITARBEITER_PASSWORT = "zz-e2e-nur-fuer-ablaeufe";
export const E2E_MITARBEITER_NAME = "ZZ E2E Mitarbeiterkonto";
export const SEED_TAG = "LZ-7K3F9Q";
export async function anmelden(seite: Page): Promise<void> {
@@ -31,10 +47,40 @@ export async function anmelden(seite: Page): Promise<void> {
"Administrator passen, den der Seed angelegt hat.",
);
}
/*
* Wartet auf `/`, und zwar genau so streng wie vorher. Lockerer („irgendwas
* ausser /anmelden“) sah harmlos aus und machte zwei Ablaeufe im vollen
* Durchgang unzuverlaessig: der naechste Schritt begann, bevor die Uebersicht
* stand. Der Admin landet immer auf `/` — fuer ihn gibt es keinen Grund, das
* aufzuweichen.
*/
await anmeldenMit(seite, email, passwort, "**/");
}
/**
* Anmelden mit beliebigen Zugangsdaten.
*
* `ziel` ist das Muster, auf das gewartet wird. Ohne Angabe genuegt es, dass die
* Anmeldeseite verlassen ist: ein `MITARBEITER` landet auf `/meine-geraete`,
* weil `startseite(rolle)` das Ziel an der Rolle entscheidet (#035), und ein
* Warten auf `/` liefe dort in den Zeitablauf.
*/
export async function anmeldenMit(
seite: Page,
email: string,
passwort: string,
ziel?: string,
): Promise<void> {
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"]')]);
await Promise.all([
seite.waitForURL(ziel ?? ((adresse) => !adresse.pathname.startsWith("/anmelden"))),
seite.click('button[type="submit"]'),
]);
// Und bis die Seite wirklich steht: ein `waitForURL` endet an der Adresse,
// nicht am geladenen Dokument.
await seite.waitForLoadState("load");
}
/**
+72 -5
View File
@@ -1,7 +1,15 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { SEED_TAG, SEED_VORLAGE, anmelden, betriebsmodus } from "./helfer";
import {
E2E_MITARBEITER_MAIL,
E2E_MITARBEITER_PASSWORT,
SEED_TAG,
SEED_VORLAGE,
anmelden,
anmeldenMit,
betriebsmodus,
} from "./helfer";
/**
* Am Handy scrollt nichts nach rechts (#018).
@@ -191,10 +199,10 @@ const SEITEN: readonly Hauptseite[] = [
* Zustellungs-Protokoll sind Tabellen, und Tabellen sind die Stellen, an denen
* horizontales Scrollen entsteht.
*
* Nicht dabei: `/meine-geraete` (#035) — es verlangt das Recht
* `selbstauskunft`, und das hat nur `MITARBEITER`. Dieser Ablauf meldet sich
* als Admin an, der dort „kein Zugriff“ sähe; gemessen würde die
* Verweigerungsseite. Dafür braucht es ein eigenes Konto, siehe #037.
* `/meine-geraete` steht **nicht** in dieser Liste, sondern in einem eigenen
* Block darunter: die Seite verlangt `selbstauskunft`, und das hat nur
* `MITARBEITER`. Mit der Anmeldung dieses Ablaufs (Admin) würde die
* Verweigerungsseite gemessen — und die scrollt garantiert nicht (#037).
*/
{ name: "Vorlagen: Bausteine", pfad: () => "/vorlagen/bausteine" },
{ name: "Admin: Schnellstart", pfad: () => "/admin/schnellstart" },
@@ -283,6 +291,65 @@ test.describe("Am Handy bei 390 px", () => {
}
});
/**
* `/meine-geraete` braucht eine andere Anmeldung (#037).
*
* Eigener Block statt eines Sonderfalls in der Schleife oben: die Seitenliste
* dort gilt für **eine** Rolle, und ein `if` darin hätte beim nächsten Eintrag
* geerbt werden müssen. Die Messung selbst ist dieselbe Funktion.
*/
test.describe("Am Handy bei 390 px: die Selbstauskunft", () => {
const PFAD = "/meine-geraete";
test(`scrollt „Meine Geräte“ nicht horizontal`, async ({ page }) => {
test.slow();
await anmeldenMit(page, E2E_MITARBEITER_MAIL, E2E_MITARBEITER_PASSWORT);
await page.goto(PFAD);
/*
* Dieselbe Vorsicht wie oben, hier noch wichtiger: hätte das Konto die Rolle
* verloren, landete es auf `/kein-zugriff` — und der Ablauf hätte grün
* gemeldet, ohne die Seite gesehen zu haben.
*/
expect(new URL(page.url()).pathname, `${PFAD} hat weitergeleitet.`).toBe(PFAD);
await page.waitForLoadState("load");
// Und es ist wirklich die Selbstauskunft, nicht eine leere Hülle: der Name
// der verknüpften Person steht darauf.
const person = process.env["E2E_MITARBEITER_PERSON"];
if (person) {
await expect(page.getByText(person, { exact: false }).first()).toBeVisible();
}
const masse = await messen(page);
expect(
masse.scroll,
`„Meine Geräte“ (${PFAD}) scrollt horizontal: erwartet <= ${masse.sichtbar}, ` +
`war ${masse.scroll} — ${masse.scroll - masse.sichtbar} px zu breit.` +
(masse.uebeltaeter.length > 0 ? `\n Zu weit rechts: ${masse.uebeltaeter.join("\n ")}` : ""),
).toBeLessThanOrEqual(masse.sichtbar);
});
test("sieht dort keinen Weg in die Verwaltung", async ({ page }) => {
/*
* Die Gegenprobe zur Rolle, und sie gehört hierher: die Seitenleiste nennt
* seit #035 an jedem Eintrag ihr Recht, damit einem `MITARBEITER` keine
* sechs Links angeboten werden, die alle auf `/kein-zugriff` führen. Das
* ist in Vitest als Rollenmatrix geprüft — dass es im **gerenderten** DOM
* ankommt, nur hier.
*/
await anmeldenMit(page, E2E_MITARBEITER_MAIL, E2E_MITARBEITER_PASSWORT);
await page.goto("/meine-geraete");
for (const ziel of ["/admin", "/vorlagen", "/laeufe", "/mitarbeiter", "/geraete", "/archiv"]) {
await expect(
page.locator(`a[href^="${ziel}"]`),
`Ein MITARBEITER bekommt einen Link auf ${ziel} angeboten.`,
).toHaveCount(0);
}
});
});
test.afterAll(async () => {
if (laufId) {
await prisma.kommentar.deleteMany({ where: { laufId } });