feat(laeufe): Uebergabeprotokoll als PDF (#041)
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions

Neben "Drucken" steht am Lauf "Als PDF", im Archiv an jedem Treffer "Protokoll
als PDF" — dasselbe Blatt als fertige Datei, zum Verschicken oder Abheften.
Unten darauf jetzt zwei Unterschriftsfelder mit Zeile fuer Ort, Datum und Namen;
wo eine Vier-Augen-Freigabe erteilt wurde, steht sie mit im Kopf.

Erzeugt wird es, indem der Server seine eigene Druckansicht ueber 127.0.0.1
laedt und druckt — mit dem Sitzungscookie des Anfragenden. Damit gibt es genau
eine Vorlage fuer Papier und Datei, und fuer das PDF gilt dieselbe Rechte- und
Mandantenpruefung wie fuer die Seite. Ein zweites Layout waere billiger und
wuerde frueher oder spaeter abweichen, ohne dass es jemandem auffaellt.

Ein eigener Browser-Kontext je PDF, kein geteilter: er traegt die fremde
Sitzung, und ein geteilter gaebe sie an das naechste PDF weiter. Gespeichert
wird nichts — ein abgelegtes PDF waere eine zweite Wahrheit neben dem Lauf.
Ein erzeugtes Passwort steht auch hier nie darauf, geprueft am extrahierten
Text des fertigen PDF und nicht an den Bytes: der Inhalt ist komprimiert, dort
nichts zu finden bewiese nichts.

Chromium bleibt optional, und die gemessene Zahl ist der Grund: das
Laufzeitbild waechst von 370 MB auf 1,52 GB, Alpines chromium kostet allein
1,13 GB. Er kommt nur mit `--build-arg MIT_PDF=1` beziehungsweise
`deploy/install.sh --mit-pdf` mit. Fehlt er, erscheint der Knopf gar nicht
erst — ein Knopf, der zuverlaessig in eine Fehlermeldung fuehrt, ist schlimmer
als keiner. Ein direkter Aufruf bekommt 503 mit einer Meldung, die sagt, was
fehlt und wo es steht; ein leeres PDF gibt es in keinem Fall.

Meine Schaetzung lag um das Dreieinhalbfache daneben: "+328 MB" war das
.apk-Paket, nicht das Bild, und stand schon im Dockerfile-Kommentar, bevor ich
gebaut hatte. Jetzt steht die gemessene Zahl an drei Stellen.

1545 Vitest-Tests (vorher 1526): 15 in tests/pdf.test.ts, die den Handler echt
fahren, 3 in tests/pdf-grenze.test.ts fuer die Obergrenze mit gestelltem
Browser, einer in tests/archiv.test.ts. Dazu drei E2E-Ablaeufe; volle Suite
gruen mit 118 bestanden. Ein Blatt wurde wirklich erzeugt und angesehen.

Sechs Gegenproben, alle angeschlagen — darunter ein echtes Passwort auf dem
Blatt und die Freigabe des Zaehlers aus dem finally genommen, der Fall, in dem
ein Fehlschlag die PDF-Erzeugung bis zum Neustart leise stilllegt.

Closes #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-25 11:14:25 +01:00
co-authored by Claude Opus 5
parent d30bb6d235
commit f3950847f3
25 changed files with 1577 additions and 31 deletions
+126
View File
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Die Obergrenze gleichzeitiger PDF-Erzeugungen (#041).
*
* **Eine eigene Datei, weil hier `@/server/pdf` *nicht* gemockt werden darf.**
* `tests/pdf.test.ts` ersetzt `seiteAlsPdf`, um den Route Handler zu fahren;
* hier läuft genau diese Funktion echt, und nur der Browser darunter ist
* gestellt. Sonst prüfte der Test seine eigene Attrappe.
*
* Geprüft wird das Akzeptanzkriterium „zwei gleichzeitige Erzeugungen laufen,
* die dritte wird abgewiesen“ — und dass die Grenze sich wieder öffnet. Eine
* Grenze, die nach einem Fehler klemmt, legt die PDF-Erzeugung bis zum
* Neustart still, und zwar leise.
*/
/** Hält jede laufende Erzeugung an, bis der Test sie freigibt. */
let anhalten: Array<() => void> = [];
let laufendeSeiten = 0;
const seiteGestellt = {
emulateMedia: async () => undefined,
goto: async () => ({ status: () => 200 }),
evaluate: async () => undefined,
pdf: async () => {
laufendeSeiten += 1;
await new Promise<void>((weiter) => anhalten.push(weiter));
return new Uint8Array(Buffer.from("%PDF-1.4\n%%EOF\n", "utf8"));
},
};
const browserGestellt = {
isConnected: () => true,
newContext: async () => ({
newPage: async () => seiteGestellt,
close: async () => undefined,
}),
close: async () => undefined,
};
vi.mock("playwright-core", () => ({
chromium: { launch: async () => browserGestellt },
}));
const { seiteAlsPdf, PdfFehler, pdfVorratSchliessen } = await import("@/server/pdf");
const VORHER = {
pfad: process.env["CHROMIUM_PFAD"],
max: process.env["PDF_MAX_GLEICHZEITIG"],
};
beforeEach(() => {
// Irgendeine Datei, die es gibt – `chromiumPfad` prüft nur, ob dort etwas liegt.
process.env["CHROMIUM_PFAD"] = process.execPath;
anhalten = [];
laufendeSeiten = 0;
});
afterEach(async () => {
// Alles freigeben, sonst hängt ein Test am nächsten.
for (const weiter of anhalten) weiter();
await pdfVorratSchliessen();
for (const [name, wert] of [
["CHROMIUM_PFAD", VORHER.pfad],
["PDF_MAX_GLEICHZEITIG", VORHER.max],
] as const) {
if (wert === undefined) delete process.env[name];
else process.env[name] = wert;
}
});
describe("Die Obergrenze gleichzeitiger PDF-Erzeugungen", () => {
it("lässt so viele zu wie eingestellt und weist die nächste ab", async () => {
process.env["PDF_MAX_GLEICHZEITIG"] = "2";
const erste = seiteAlsPdf("/drucken/lauf/a", null);
const zweite = seiteAlsPdf("/drucken/lauf/b", null);
// Beide müssen wirklich angefangen haben, bevor die dritte kommt.
await vi.waitFor(() => expect(laufendeSeiten).toBe(2));
await expect(seiteAlsPdf("/drucken/lauf/c", null)).rejects.toThrow(PdfFehler);
await expect(seiteAlsPdf("/drucken/lauf/c", null)).rejects.toThrow(/noch einmal versuchen/u);
// Die dritte hat keinen Browser angefasst.
expect(laufendeSeiten).toBe(2);
for (const weiter of anhalten) weiter();
await expect(erste).resolves.toBeInstanceOf(Uint8Array);
await expect(zweite).resolves.toBeInstanceOf(Uint8Array);
});
it("gibt den Platz wieder frei, sobald eine fertig ist", async () => {
process.env["PDF_MAX_GLEICHZEITIG"] = "1";
const erste = seiteAlsPdf("/drucken/lauf/a", null);
await vi.waitFor(() => expect(laufendeSeiten).toBe(1));
await expect(seiteAlsPdf("/drucken/lauf/b", null)).rejects.toThrow(PdfFehler);
anhalten.shift()?.();
await erste;
// Und jetzt geht es wieder – die Grenze zählt herunter, nicht nur herauf.
const zweite = seiteAlsPdf("/drucken/lauf/b", null);
await vi.waitFor(() => expect(laufendeSeiten).toBe(2));
anhalten.pop()?.();
await expect(zweite).resolves.toBeInstanceOf(Uint8Array);
});
it("gibt den Platz auch frei, wenn die Erzeugung scheitert", async () => {
/*
* **Der Fall, der eine Grenze still klemmen lässt.** Steht die Freigabe
* nicht im `finally`, zählt ein Fehlschlag den Zähler nie zurück: nach
* `PDF_MAX_GLEICHZEITIG` Fehlern entsteht bis zum Neustart kein PDF mehr,
* und die Meldung spricht von Gleichzeitigkeit, obwohl niemand etwas tut.
*/
process.env["PDF_MAX_GLEICHZEITIG"] = "1";
const kaputt = vi.spyOn(seiteGestellt, "goto").mockResolvedValueOnce({ status: () => 500 });
await expect(seiteAlsPdf("/drucken/lauf/a", null)).rejects.toThrow(PdfFehler);
kaputt.mockRestore();
const danach = seiteAlsPdf("/drucken/lauf/b", null);
await vi.waitFor(() => expect(laufendeSeiten).toBe(1));
anhalten.pop()?.();
await expect(danach).resolves.toBeInstanceOf(Uint8Array);
});
});