- Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2 - Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes, readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage - Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts - Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf - L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay - PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF - Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned - Dateilinks in Auftragsdetail auf /files/[documentId] - proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect - ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams) Gate: tsc, lint, build, 42/42 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
7.5 KiB
TypeScript
145 lines
7.5 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 { readStoredBytes } from "../src/server/services/documents/read";
|
|
import { sha256Hex, storeFile } from "../src/server/services/documents/store";
|
|
|
|
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 readStoredBytes(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);
|
|
});
|