Files
craftvia/scripts/test-berichte-pdf.ts
T
msolarczekandClaude Opus 5 9e35bb4e47 L5 Berichte & Unterschrift: Tests und Lane-Bericht
Flow-Tests (Content, Status, Unterschrift, Versionierung, Mandantentrennung, Scope) und
PDF-Render-Smoke; Lane-Bericht docs/craftvia/lanes/berichte.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:22:40 +02:00

144 lines
7.4 KiB
TypeScript

// L5 Berichte — PDF-Render-Smoke: Freigabe → generateReportPdf → Datei beginnt mit %PDF, Checksumme gespeichert,
// PDF unveränderlich (zweiter Lauf überspringt), Download nur im eigenen Mandanten.
// Ohne startbaren Chromium/Chrome wird der Render-Teil mit klarer Meldung übersprungen (Exit 0).
//
// Lauf: npx tsx scripts/test-berichte-pdf.ts
import "dotenv/config";
import { prisma, dbForTenant } from "../src/server/db";
import { ROLE_DEFS, type RoleKey } from "../src/server/rbac";
import { ServiceError, type ServiceCtx } from "../src/server/services/context";
import { pdfRendererAvailable } from "../src/server/pdf/render";
import { createDailyReport } from "../src/server/services/reports/create";
import { updateReportTexts } from "../src/server/services/reports/edit";
import { submitReport } from "../src/server/services/reports/submit";
import { approveReport } from "../src/server/services/reports/approve";
import { generateReportPdf } from "../src/server/services/reports/pdf";
import { openReportFile } from "../src/server/services/reports/files";
import { readFileBytes, sha256Hex, storeFile } from "../src/server/services/reports/_stubs/documents";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const SLUG_A = "zz-berichte-pdf-a";
const SLUG_B = "zz-berichte-pdf-b";
const PNG_1PX = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64");
async function cleanup() {
const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } });
const ids = tenants.map((t) => t.id);
if (ids.length) {
const w = { where: { tenantId: { in: ids } } };
await prisma.signature.deleteMany(w);
await prisma.report.deleteMany(w);
await prisma.photo.deleteMany(w);
await prisma.workOrderStatusChange.deleteMany(w);
await prisma.workOrderAssignee.deleteMany(w);
await prisma.document.deleteMany(w);
await prisma.workOrder.deleteMany(w);
await prisma.customer.deleteMany(w);
await prisma.numberSequence.deleteMany(w);
await prisma.auditLog.deleteMany(w);
await prisma.tenantSettings.deleteMany(w);
await prisma.user.deleteMany(w);
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
}
await prisma.identity.deleteMany({ where: { email: { endsWith: "@zz-berichte-pdf.test" } } });
}
const ctxOf = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({
db: dbForTenant(tenantId),
tenantId,
userId,
permissions: new Set(ROLE_DEFS[role].permissions),
});
async function main() {
await cleanup();
const available = await pdfRendererAvailable();
if (!available.ok) {
console.log(`⚠ ÜBERSPRUNGEN: kein Chromium startbar (${available.reason}). Abhilfe: PDF_CHROMIUM_PATH setzen oder "npx playwright-core install chromium".`);
return;
}
const tA = await prisma.tenant.create({ data: { name: "PDF A", slug: SLUG_A } });
const tB = await prisma.tenant.create({ data: { name: "PDF B", slug: SLUG_B } });
await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Musterbetrieb PDF GmbH", address: "Hafenstraße 12, 20457 Hamburg", phone: "+49 40 1", email: "info@pdf.example" } });
const mk = async (tenantId: string, key: string) => {
const identity = await prisma.identity.create({ data: { email: `${key}@zz-berichte-pdf.test`, passwordHash: "x" } });
return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name: `Nutzer ${key}` } });
};
const tech = await mk(tA.id, "tech");
const office = await mk(tA.id, "office");
const officeB = await mk(tB.id, "officeb");
const techCtx = ctxOf(tA.id, tech.id, "technician");
const officeCtx = ctxOf(tA.id, office.id, "backoffice");
const officeBCtx = ctxOf(tB.id, officeB.id, "backoffice");
const customer = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Kundin & Söhne <GmbH>" } });
const wo = await prisma.workOrder.create({
data: { tenantId: tA.id, number: "A-PDF1", customerId: customer.id, title: "Heizung warten", status: "in_progress", assignees: { create: [{ tenantId: tA.id, userId: tech.id }] } },
});
for (let i = 0; i < 3; i++) {
const doc = await storeFile(techCtx, { bytes: PNG_1PX, fileName: `foto-${i}.png`, declaredMime: "image/png", category: "photo", visibility: "team", links: { workOrderId: wo.id } });
await prisma.photo.create({ data: { tenantId: tA.id, workOrderId: wo.id, documentId: doc.id, phase: "after", comment: `Foto ${i}`, takenAt: new Date() } });
}
const { report } = await createDailyReport(techCtx, { workOrderId: wo.id });
await updateReportTexts(techCtx, { reportId: report.id, texts: { workPerformed: "Brenner gereinigt.\nDruck geprüft.".repeat(40), hints: "Filter in 6 Monaten tauschen" } });
await submitReport(techCtx, { reportId: report.id });
const approved = await approveReport(officeCtx, { reportId: report.id }, { dispatchPdf: async () => {} });
ok(approved.status === "approved" && !approved.pdfDocumentId, "Bericht freigegeben, PDF noch nicht erzeugt");
const t0 = Date.now();
const res = await generateReportPdf(officeCtx, report.id);
ok(!res.skipped, `PDF gerendert (${Date.now() - t0} ms)`);
const stored = await prisma.report.findUniqueOrThrow({ where: { id: report.id } });
const doc = await prisma.document.findUniqueOrThrow({ where: { id: stored.pdfDocumentId! } });
ok(doc.fileSize > 0 && doc.mimeType === "application/pdf", `Dokument gespeichert (${doc.fileSize} Bytes)`);
ok(doc.category === "daily_report" && doc.visibility === "customer_report" && doc.workOrderId === wo.id, "Kategorie daily_report, Sichtbarkeit customer_report, am Auftrag");
ok(stored.pdfChecksum === doc.checksum && /^[0-9a-f]{64}$/.test(stored.pdfChecksum ?? ""), "SHA-256-Prüfsumme am Bericht gespeichert");
const bytes = await readFileBytes(doc.storageKey);
if (bytes) {
ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF");
ok(sha256Hex(bytes) === stored.pdfChecksum, "Prüfsumme entspricht den gespeicherten Bytes");
} else {
console.log("⚠ Storage-Stub ohne Bytes (S3_* nicht gesetzt) — Byte-Prüfung übersprungen");
}
const again = await generateReportPdf(officeCtx, report.id);
ok(again.skipped && again.documentId === doc.id, "Zweiter Lauf überschreibt das freigegebene PDF nicht");
if (bytes) {
const file = await openReportFile(officeCtx, report.id, "pdf");
ok(file.mimeType === "application/pdf", "Download im eigenen Mandanten möglich");
}
try {
await openReportFile(officeBCtx, report.id, "pdf");
ok(false, "Mandant B: PDF von A → not_found erwartet");
} catch (err) {
ok(err instanceof ServiceError && err.code === "not_found", "Mandant B: PDF von A → not_found");
}
try {
await openReportFile(officeCtx, report.id, doc.id === "x" ? "y" : "unrelated-document-id");
ok(false, "Nicht referenziertes Dokument → not_found erwartet");
} catch (err) {
ok(err instanceof ServiceError && err.code === "not_found", "Nicht referenziertes Dokument über Berichtsroute → not_found");
}
}
main()
.catch((err) => {
console.error(err);
failures++;
})
.finally(async () => {
await cleanup().catch((e) => console.error("cleanup failed", e));
await prisma.$disconnect();
console.log(failures ? `\n✗ ${failures} Fehler` : "\n✓ PDF-Smoke abgeschlossen");
process.exit(failures ? 1 : 0);
});