- services/field: Einsatz-Sessions (Anfahrt/Arbeit/Pause als TimeEntry-Segmente, eine aktive Session je User+Auftrag), Zeitkorrektur mit Recht + Grund + Audit, Checkliste, Material (Abweichung nur mit Begründung, Zusatzmaterial), Notizen, Fotos, Sprachnotizen (ohne Transkriptions-Processor Status disabled), Uploads (idempotent je Mandant), autorisierte Dokument-Auslieferung, Lesemodelle + Bundle - services/sync: applyOperations mit Idempotenz, baseVersion-Konfliktprüfung, Registry für Ops anderer Lanes, lane-lokaler requireApiContext - /api/v1/sync, /api/v1/uploads, /api/v1/field/bundle, /api/v1/field/documents/[id] - lib/sync/ops.ts (Zod-Payloads je opType), lib/field/material-rules.ts - Stubs mit Vertragssignatur: transitionWorkOrder (L2), storeFile (§4.3), getSiteHistory (L1) - Processor image-derivatives + Registrierung, Audit-Entity-Labels - Tests: test-einsatz-field (48 Prüfungen), test-einsatz-sync (38 Prüfungen) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
13 KiB
TypeScript
159 lines
13 KiB
TypeScript
// Lane L4 „Einsatz mobil" — Sync- und Upload-Tests: Idempotenz (gleiche clientOpId → duplicate,
|
|
// keine Doppelanlage), Konflikt bei veralteter baseVersion (nichts überschrieben), Scope über die
|
|
// Sync-API (fremder Auftrag → not_found), Mandantentrennung bei Uploads/Dokumenten, Foto-/
|
|
// Sprachnotiz-Anhang, Ops fremder Lanes ohne Implementierung, image-derivatives-Processor.
|
|
//
|
|
// Lauf: npx tsx scripts/test-einsatz-sync.ts (lokale Postgres-DB aus .env; S3 optional)
|
|
|
|
import "dotenv/config";
|
|
import { randomUUID } from "node:crypto";
|
|
import sharp from "sharp";
|
|
import { prisma } from "../src/server/db";
|
|
import { closeJobQueues } from "../src/server/jobs/queues";
|
|
import { applyOperations } from "../src/server/services/sync/apply";
|
|
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
|
import { openFieldDocument } from "../src/server/services/field/documents";
|
|
import { process as imageDerivatives } from "../src/server/jobs/processors/image-derivatives";
|
|
import type { SyncOperationInput, SyncOpType } from "../src/lib/sync/envelope";
|
|
import type { ServiceCtx } from "../src/server/services/context";
|
|
import { createFixture, expectCode, failures, ok } from "./lib/einsatz-fixture";
|
|
|
|
const S3 = !!process.env.S3_ENDPOINT?.trim();
|
|
|
|
function op(opType: SyncOpType, payload: Record<string, unknown>, extra: Partial<SyncOperationInput> = {}): SyncOperationInput {
|
|
return { clientOpId: randomUUID(), opType, payload, clientCreatedAt: new Date().toISOString(), ...extra };
|
|
}
|
|
|
|
async function one(ctx: ServiceCtx, operation: SyncOperationInput) {
|
|
const res = await applyOperations(ctx, { deviceId: "test-device", operations: [operation] });
|
|
return res.results[0];
|
|
}
|
|
|
|
async function main() {
|
|
const f = await createFixture("l4sync");
|
|
const wo = f.orderA.id;
|
|
try {
|
|
console.log("\n— Idempotenz —");
|
|
const noteOp = op("note.create", { workOrderId: wo, clientId: randomUUID(), kind: "work_done", text: "Heizkörper montiert" });
|
|
const r1 = await one(f.ctxTech, noteOp);
|
|
ok(r1.status === "applied" && !!r1.idMap && Object.keys(r1.idMap).length === 1, "erste Op → applied mit idMap");
|
|
const r2 = await one(f.ctxTech, noteOp);
|
|
ok(r2.status === "duplicate" && JSON.stringify(r2.idMap) === JSON.stringify(r1.idMap), "gleiche clientOpId → duplicate mit gespeicherter idMap");
|
|
ok((await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "keine Doppelanlage der Notiz");
|
|
ok((await prisma.syncOperation.count({ where: { tenantId: f.tenantA.id, clientOpId: noteOp.clientOpId } })) === 1, "SyncOperation einmal protokolliert");
|
|
const batch = await applyOperations(f.ctxTech, { deviceId: "test-device", operations: [noteOp, noteOp] });
|
|
ok(batch.results.every((r) => r.status === "duplicate"), "Batch mit Wiederholungen → duplicate");
|
|
|
|
const bad = await one(f.ctxTech, op("note.create", { workOrderId: wo, kind: "unbekannt", text: "" }));
|
|
ok(bad.status === "rejected" && bad.errorCode === "invalid", "ungültige Payload → rejected invalid");
|
|
|
|
console.log("\n— Konflikt (baseVersion) —");
|
|
const before = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } });
|
|
const stale = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }, { baseVersion: before.version + 5 }));
|
|
ok(stale.status === "conflict" && stale.entityVersion === before.version, "veraltete baseVersion → conflict mit aktueller Version");
|
|
const afterConflict = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } });
|
|
ok(afterConflict.status === before.status && afterConflict.version === before.version, "bei Konflikt nichts überschrieben");
|
|
const stored = await prisma.syncOperation.findFirst({ where: { tenantId: f.tenantA.id, clientOpId: stale.clientOpId } });
|
|
ok(stored?.status === "conflict" && stored.errorCode === "conflict", "Konflikt als SyncOperation(status=conflict) für das Backoffice gespeichert");
|
|
const noBase = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }));
|
|
ok(noBase.status === "rejected" && noBase.errorCode === "invalid", "konfliktbehaftete Op ohne baseVersion → invalid");
|
|
const fresh = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }, { baseVersion: before.version }));
|
|
const accepted = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } });
|
|
ok(fresh.status === "applied" && fresh.entityVersion === before.version + 1 && accepted.status === "accepted", "aktuelle baseVersion → applied, Version +1");
|
|
ok((await prisma.workOrderStatusChange.count({ where: { workOrderId: wo, toStatus: "accepted" } })) === 1, "Statuswechsel protokolliert");
|
|
|
|
console.log("\n— Sessions über Sync —");
|
|
const st1 = await one(f.ctxTech, op("session.start", { workOrderId: wo, mode: "work", clientId: randomUUID() }));
|
|
const st2 = await one(f.ctxTech, op("session.start", { workOrderId: wo, mode: "work", clientId: randomUUID() }));
|
|
ok(st1.status === "applied" && st2.status === "rejected" && st2.errorCode === "conflict", "zweiter Start über Sync → rejected conflict");
|
|
ok((await prisma.workSession.count({ where: { workOrderId: wo, status: "running" } })) === 1, "nur eine laufende Session");
|
|
|
|
console.log("\n— Scope über die Sync-API —");
|
|
const foreign = await one(f.ctxOutsider, op("note.create", { workOrderId: wo, kind: "general", text: "darf nicht" }));
|
|
ok(foreign.status === "rejected" && foreign.errorCode === "not_found", "Monteur ohne Zuweisung → rejected not_found");
|
|
const foreignTransition = await one(f.ctxOutsider, op("work_order.transition", { workOrderId: wo, to: "en_route" }, { baseVersion: accepted.version }));
|
|
ok(foreignTransition.status === "rejected" && foreignTransition.errorCode === "not_found", "fremder Auftrag bei Konfliktprüfung → not_found (kein Versions-Leak)");
|
|
const crossTenant = await one(f.ctxB, op("note.create", { workOrderId: wo, kind: "general", text: "Mandant B" }));
|
|
ok(crossTenant.status === "rejected" && crossTenant.errorCode === "not_found", "Mandant B auf Auftrag von A → not_found");
|
|
ok((await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "keine fremden Notizen angelegt");
|
|
const sameOpIdB = await one(f.ctxB, { ...noteOp, payload: { workOrderId: f.orderB.id, kind: "general", text: "B eigene Notiz" } });
|
|
ok(sameOpIdB.status === "applied", "gleiche clientOpId in Mandant B ist unabhängig (Idempotenz je Mandant)");
|
|
const reuseOther = await one(f.ctxLead, noteOp);
|
|
ok(reuseOther.status === "rejected" && !reuseOther.idMap, "fremder Nutzer mit gleicher clientOpId erhält kein gespeichertes Ergebnis");
|
|
|
|
console.log("\n— Ops fremder Lanes —");
|
|
const report = await one(f.ctxTech, op("report.save_draft", { workOrderId: wo }));
|
|
ok(report.status === "rejected" && report.errorCode === "invalid" && /not available/.test(report.message ?? ""), "report.save_draft ohne L5 → rejected invalid mit Hinweis");
|
|
ok((await prisma.syncOperation.count({ where: { clientOpId: report.clientOpId } })) === 0, "nicht verfügbare Op wird nicht gespeichert (später wiederholbar)");
|
|
|
|
console.log("\n— Uploads & Mandantentrennung —");
|
|
const jpeg = await sharp({ create: { width: 1200, height: 800, channels: 3, background: { r: 200, g: 120, b: 40 } } }).jpeg().toBuffer();
|
|
const thumb = await sharp(jpeg).resize(400).jpeg().toBuffer();
|
|
const uploadClient = randomUUID();
|
|
const up1 = await storeFieldUpload(f.ctxTech, { clientId: uploadClient, workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" }, { bytes: thumb, name: "t.jpg", type: "image/jpeg" });
|
|
const up2 = await storeFieldUpload(f.ctxTech, { clientId: uploadClient, workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" });
|
|
ok(!up1.duplicate && up2.duplicate && up1.documentId === up2.documentId, "Upload idempotent über clientId");
|
|
const doc = await prisma.document.findUniqueOrThrow({ where: { id: up1.documentId } });
|
|
ok(doc.tenantId === f.tenantA.id && doc.category === "photo" && doc.mimeType === "image/jpeg" && doc.checksum.length === 64 && !!doc.previewKey, "Dokument mit MIME, Prüfsumme und Vorschaubild");
|
|
|
|
const upB = await storeFieldUpload(f.ctxB, { clientId: uploadClient, workOrderId: f.orderB.id, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" });
|
|
ok(!upB.duplicate && upB.documentId !== up1.documentId, "gleiche clientId in Mandant B → eigenes Dokument, kein Zugriff auf A");
|
|
await expectCode(() => storeFieldUpload(f.ctxB, { clientId: randomUUID(), workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "x.jpg", type: "image/jpeg" }), "not_found", "Mandant B lädt auf Auftrag von A hoch → not_found");
|
|
await expectCode(() => openFieldDocument(f.ctxB, up1.documentId), "not_found", "Mandant B öffnet Dokument von A → not_found");
|
|
await expectCode(() => openFieldDocument(f.ctxOutsider, up1.documentId), "not_found", "Monteur ohne Zuweisung öffnet Dokument → not_found");
|
|
await expectCode(
|
|
() => storeFieldUpload(f.ctxTech, { clientId: randomUUID(), workOrderId: wo, kind: "photo" }, { bytes: Buffer.from("%PDF-1.7 fake pdf content"), name: "x.pdf", type: "application/pdf" }),
|
|
"invalid",
|
|
"PDF als Foto → invalid (Magic Bytes)",
|
|
);
|
|
if (S3) {
|
|
const opened = await openFieldDocument(f.ctxTech, up1.documentId, "preview");
|
|
ok(!!opened.content.stream, "zugewiesener Monteur öffnet eigenes Foto (Vorschau)");
|
|
} else {
|
|
console.log("↷ Byte-Abruf übersprungen (kein S3_ENDPOINT)");
|
|
}
|
|
const internal = await prisma.document.create({
|
|
data: { tenantId: f.tenantA.id, workOrderId: wo, category: "other", fileName: "intern.pdf", storageKey: `${f.tenantA.id}/uploads/x`, mimeType: "application/pdf", fileSize: 1, checksum: "0", lineageId: randomUUID(), visibility: "backoffice_only" },
|
|
});
|
|
await expectCode(() => openFieldDocument(f.ctxTech, internal.id), "not_found", "Backoffice-internes Dokument bleibt für Monteur verborgen");
|
|
|
|
console.log("\n— Foto & Sprachnotiz anhängen —");
|
|
const req = f.orderA.photoRequirements[0];
|
|
const photoClient = randomUUID();
|
|
const attach = await one(f.ctxTech, op("photo.attach", { workOrderId: wo, clientId: photoClient, documentId: up1.documentId, phase: "before", photoRequirementId: req.id, comment: "Typenschild" }));
|
|
ok(attach.status === "applied" && !!attach.idMap?.[photoClient], "photo.attach → applied");
|
|
const photo = await prisma.photo.findFirst({ where: { documentId: up1.documentId } });
|
|
ok(photo?.photoRequirementId === req.id && photo.phase === "before" && photo.takenById === f.tech.id, "Foto mit Phase, Pflichtfoto und Aufnehmendem");
|
|
const attachB = await one(f.ctxB, op("photo.attach", { workOrderId: f.orderB.id, documentId: up1.documentId }));
|
|
ok(attachB.status === "rejected" && attachB.errorCode === "not_found", "Mandant B hängt Dokument von A an → not_found");
|
|
const attachAgain = await one(f.ctxTech, op("photo.attach", { workOrderId: wo, documentId: up1.documentId }));
|
|
ok(attachAgain.status === "rejected" && attachAgain.errorCode === "invalid", "Dokument doppelt anhängen → invalid");
|
|
|
|
const webm = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(512, 1)]);
|
|
const voiceUp = await storeFieldUpload(f.ctxTech, { clientId: randomUUID(), workOrderId: wo, kind: "voice_note" }, { bytes: webm, name: "sprachnotiz.webm", type: "audio/webm;codecs=opus" });
|
|
const voice = await one(f.ctxTech, op("voice.attach", { workOrderId: wo, clientId: randomUUID(), documentId: voiceUp.documentId, durationSeconds: 12 }));
|
|
const vn = await prisma.voiceNote.findFirst({ where: { documentId: voiceUp.documentId } });
|
|
ok(voice.status === "applied" && vn?.transcriptionStatus === "disabled", "Sprachnotiz ohne Transkriptions-Processor → Status disabled statt Fehler");
|
|
|
|
console.log("\n— image-derivatives —");
|
|
const noThumb = await storeFieldUpload(f.ctxTech, { clientId: randomUUID(), workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "ohne-vorschau.jpg", type: "image/jpeg" });
|
|
await imageDerivatives({ tenantId: f.tenantA.id, entityId: noThumb.documentId, actorId: f.tech.id });
|
|
const derived = await prisma.document.findUniqueOrThrow({ where: { id: noThumb.documentId } });
|
|
if (S3) ok(!!derived.previewKey && derived.previewKey.startsWith(`${f.tenantA.id}/`), "Processor erzeugt mandantenpräfixiertes Vorschaubild");
|
|
else ok(derived.previewKey === null, "ohne Byte-Speicher kein Vorschaubild (kein Fehler)");
|
|
await imageDerivatives({ tenantId: f.tenantB.id, entityId: noThumb.documentId });
|
|
ok((await prisma.document.findUniqueOrThrow({ where: { id: noThumb.documentId } })).previewKey === derived.previewKey, "Processor mit fremdem Mandanten ändert nichts");
|
|
} finally {
|
|
await f.cleanup();
|
|
await closeJobQueues();
|
|
await prisma.$disconnect();
|
|
}
|
|
console.log(`\n${failures === 0 ? "✓ alle Prüfungen grün" : `✗ ${failures} Fehler`}`);
|
|
process.exit(failures ? 1 : 0);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|