feat(laeufe): Uebergabeprotokoll als PDF (#041)
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:
co-authored by
Claude Opus 5
parent
d30bb6d235
commit
f3950847f3
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user