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>
127 lines
4.5 KiB
TypeScript
127 lines
4.5 KiB
TypeScript
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);
|
||
});
|
||
});
|