Neun Kapitel unter docs/anleitung/ fuer Technik und Verwaltung, mit 35 Bildern aus `pnpm docs:screenshots` und einem Fehlerbehebungskapitel aus echten Meldungen. `pnpm docs:pdf` macht daraus 72 Seiten A4 — hell gedruckt, mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen, ohne neue Abhaengigkeit. Die Ablaeufe pruefen ihre Voraussetzungen, bevor der erste startet: antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der Server dieselbe Datenbank wie der Testprozess. Jeder Ablauf legt an, was er braucht, und raeumt es weg. Vier neue kommen dazu: Hauptablauf, Onboarding mit Vergleich der Zwischenablage, Rollen und 390 px auf achtzehn Seiten. Drei Fehler kamen dabei heraus. Das Projekt „handy" pruefte 412 px statt der verlangten 390. Ein angemeldeter Benutzer ohne Recht erfuhr nie, warum er abgewiesen wurde — die Meldung stand auf einer Seite, die ihn sofort weiterleitet; jetzt gibt es /kein-zugriff. Und der Verzeichnis-Ablauf schrieb die Attributzuordnung der ersten vorhandenen Quelle um, in einer echten Installation also der produktiven. 101 Ablaeufe in INTERN und 97 in DIENSTLEISTUNG, beide ohne Fehlschlag. Closes #18 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
150 lines
5.9 KiB
TypeScript
150 lines
5.9 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import "dotenv/config";
|
||
import { prisma } from "@/lib/db";
|
||
import { anmelden, SEED_VORLAGE } from "./helfer";
|
||
|
||
/**
|
||
* Das Archiv am gebauten Stand (#015).
|
||
*
|
||
* Die Suche selbst prueft `tests/archiv.test.ts` gegen die Datenbank, bis in die
|
||
* Mandantengrenze hinein. Hier geht es um den Weg, den ein Mensch nimmt: Wort
|
||
* eintippen, Treffer sehen, **erkennen warum**, in den Lauf springen. Ein
|
||
* Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.
|
||
*
|
||
* Voraussetzungen prueft `tests/e2e/global-setup.ts`, aufgeraeumt wird am Ende
|
||
* ueber die Datenbank: fuer einen Lauf kennt die Anwendung keinen Loeschweg, und
|
||
* das ist richtig so (#018).
|
||
*/
|
||
|
||
const STEMPEL = Date.now();
|
||
|
||
/** Fortlaufend, damit zwei Laeufe desselben Durchgangs sich nicht verwechseln. */
|
||
let laufende = 0;
|
||
|
||
type Testlauf = { readonly id: string; readonly titel: string; readonly suchwort: string };
|
||
|
||
/**
|
||
* Ein archivierter Lauf mit einer Notiz, die es sonst nirgends gibt.
|
||
*
|
||
* Direkt ueber Prisma und nicht ueber die Dienste: hier soll **das Archiv**
|
||
* geprueft werden, nicht der Weg dorthin — den prueft `tests/e2e/laeufe.spec.ts`
|
||
* vom Anlegen bis zum Archivieren. Die Zeitstempel gehoeren trotzdem dazu, drei
|
||
* CHECK-Bedingungen in der Datenbank verlangen sie.
|
||
*
|
||
* Vorlage **am Namen**, Kunde ueber `istEigeneOrganisation`: ein
|
||
* `findFirstOrThrow` ohne Bedingung nahm die Version, die die Sortierung gerade
|
||
* nach vorn stellte, und im Modus DIENSTLEISTUNG einen beliebigen Demo-Kunden.
|
||
* Dass es `SEED_VORLAGE` veroeffentlicht gibt, prueft der Global-Setup.
|
||
*/
|
||
async function archivierterLauf(): Promise<Testlauf> {
|
||
laufende += 1;
|
||
const marke = `${STEMPEL}-${laufende}`;
|
||
const suchwort = `Restposten ${marke}`;
|
||
|
||
const version = await prisma.vorlagenVersion.findFirstOrThrow({
|
||
where: { status: "VEROEFFENTLICHT", vorlage: { name: SEED_VORLAGE } },
|
||
orderBy: { version: "desc" },
|
||
select: { id: true, abschnitte: { select: { schritte: { select: { id: true } } } } },
|
||
});
|
||
const kunde = await prisma.kunde.findFirstOrThrow({
|
||
where: { istEigeneOrganisation: true },
|
||
select: { id: true },
|
||
});
|
||
const schrittId = version.abschnitte.flatMap((abschnitt) => abschnitt.schritte)[0]?.id;
|
||
if (!schrittId) throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keinen Schritt.`);
|
||
|
||
const jetzt = new Date();
|
||
const titel = `ZZ E2E Archiv ${marke}`;
|
||
const lauf = await prisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
kundeId: kunde.id,
|
||
titel,
|
||
status: "ARCHIVIERT",
|
||
gestartetAm: jetzt,
|
||
abgeschlossenAm: jetzt,
|
||
archiviertAm: jetzt,
|
||
},
|
||
});
|
||
await prisma.schrittergebnis.create({
|
||
data: {
|
||
laufId: lauf.id,
|
||
vorlagenSchrittId: schrittId,
|
||
zustand: "NICHT_ZUTREFFEND",
|
||
notiz: `${suchwort} aus dem Lager`,
|
||
},
|
||
});
|
||
return { id: lauf.id, titel, suchwort };
|
||
}
|
||
|
||
test.describe("Archiv", () => {
|
||
test("findet einen Lauf über ein Wort aus einer Schritt-Notiz", async ({ page }) => {
|
||
const lauf = await archivierterLauf();
|
||
await anmelden(page);
|
||
|
||
// Am Handy steckt die Navigation im Menü – der Weg dorthin gehört zum Test.
|
||
if (test.info().project.name === "handy") {
|
||
await page.getByLabel("Menü").click();
|
||
}
|
||
await page.getByRole("link", { name: "Archiv" }).first().click();
|
||
await page.waitForURL("**/archiv");
|
||
await page.getByLabel("Suchbegriff").fill(lauf.suchwort);
|
||
await page.getByRole("button", { name: "Suchen" }).click();
|
||
|
||
const treffer = page.getByRole("listitem").filter({ hasText: lauf.titel });
|
||
await expect(treffer).toHaveCount(1);
|
||
// Und der Grund steht dabei: Fundstelle und Ausschnitt.
|
||
await expect(treffer.getByText("Notiz")).toBeVisible();
|
||
await expect(treffer.getByText(/Restposten/)).toBeVisible();
|
||
|
||
await treffer.getByRole("link").first().click();
|
||
await page.waitForURL(`**/laeufe/${lauf.id}`);
|
||
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
|
||
});
|
||
|
||
test("sagt, dass ein einzelnes Zeichen nicht sucht", async ({ page }) => {
|
||
// Ein stilles Nichtstun wäre die schlechtere Antwort: der Suchende wartet.
|
||
await anmelden(page);
|
||
await page.goto("/archiv?suche=a");
|
||
// Die Meldung **zum Ergebnis**, nicht der Hinweis am Formular: beide nennen
|
||
// die zwei Zeichen, und nur die erste sagt, dass gerade nichts gesucht wurde.
|
||
await expect(page.getByText(/ist als Suchbegriff zu kurz/)).toBeVisible();
|
||
});
|
||
|
||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
||
/*
|
||
* Der Ablauf bringt seinen Treffer mit, statt auf einen zu hoffen: gegen
|
||
* eine frische Datenbank fand „Restposten“ nichts, und gemessen wurde dann
|
||
* die Breite einer Seite ohne Treffer (#018).
|
||
*/
|
||
const lauf = await archivierterLauf();
|
||
await anmelden(page);
|
||
await page.goto(`/archiv?suche=${encodeURIComponent(lauf.suchwort)}`);
|
||
await expect(page.getByRole("listitem").filter({ hasText: lauf.titel })).toHaveCount(1);
|
||
|
||
const masse = await page.evaluate(() => ({
|
||
scroll: document.documentElement.scrollWidth,
|
||
sichtbar: document.documentElement.clientWidth,
|
||
}));
|
||
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
|
||
});
|
||
});
|
||
|
||
test.afterAll(async () => {
|
||
const laeufe = await prisma.lauf.findMany({
|
||
// Der Praefix gehoert dieser Datei allein – geraeumt wird nur, was sie anlegt.
|
||
where: { titel: { startsWith: "ZZ E2E Archiv" } },
|
||
select: { id: true },
|
||
});
|
||
const kennungen = laeufe.map((lauf) => lauf.id);
|
||
if (kennungen.length > 0) {
|
||
await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } });
|
||
await prisma.protokoll.deleteMany({
|
||
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
|
||
});
|
||
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
|
||
}
|
||
await prisma.$disconnect();
|
||
});
|