L4 Einsatz mobil: Field-Services, Sync-API, Uploads und Tests
- 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>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
// Shared fixture of the lane L4 tests (scripts/test-einsatz-*.ts): two zz test tenants with
|
||||
// users, customer, site, team and work orders. Not a test itself (the runner only picks up
|
||||
// scripts/test-*.ts at top level).
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma, dbForTenant } from "../../src/server/db";
|
||||
import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac";
|
||||
import type { ServiceCtx } from "../../src/server/services/context";
|
||||
|
||||
export let failures = 0;
|
||||
export const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
/** Expects `fn` to throw a ServiceError with the given code. */
|
||||
export async function expectCode(fn: () => Promise<unknown>, code: string, msg: string) {
|
||||
try {
|
||||
await fn();
|
||||
ok(false, `${msg} — kein Fehler (erwartet ${code})`);
|
||||
} catch (err) {
|
||||
const actual = (err as { code?: string }).code;
|
||||
ok(actual === code, `${msg}${actual === code ? "" : ` — Code ${actual ?? (err as Error).message}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx {
|
||||
return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set<string>(ROLE_DEFS[role].permissions) };
|
||||
}
|
||||
|
||||
async function cleanupSlug(slug: string, emailDomain: string) {
|
||||
const tenant = await prisma.tenant.findUnique({ where: { slug }, select: { id: true } });
|
||||
if (tenant) {
|
||||
const where = { tenantId: tenant.id };
|
||||
await prisma.auditLog.deleteMany({ where });
|
||||
await prisma.syncOperation.deleteMany({ where });
|
||||
await prisma.photo.deleteMany({ where });
|
||||
await prisma.voiceNote.deleteMany({ where });
|
||||
await prisma.document.deleteMany({ where });
|
||||
await prisma.workOrder.deleteMany({ where });
|
||||
await prisma.teamMember.deleteMany({ where });
|
||||
await prisma.team.deleteMany({ where });
|
||||
await prisma.site.deleteMany({ where });
|
||||
await prisma.contact.deleteMany({ where });
|
||||
await prisma.customer.deleteMany({ where });
|
||||
await prisma.numberSequence.deleteMany({ where });
|
||||
await prisma.userRole.deleteMany({ where: { user: { tenantId: tenant.id } } });
|
||||
await prisma.user.deleteMany({ where });
|
||||
await prisma.role.deleteMany({ where });
|
||||
await prisma.tenant.delete({ where: { id: tenant.id } });
|
||||
}
|
||||
await prisma.identity.deleteMany({ where: { email: { endsWith: emailDomain }, memberships: { none: {} } } });
|
||||
}
|
||||
|
||||
async function user(tenantId: string, email: string, name: string) {
|
||||
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
|
||||
return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name } });
|
||||
}
|
||||
|
||||
export type Fixture = Awaited<ReturnType<typeof createFixture>>;
|
||||
|
||||
export async function createFixture(prefix: string) {
|
||||
const slugA = `zz-${prefix}-a`;
|
||||
const slugB = `zz-${prefix}-b`;
|
||||
const domain = `@zz-${prefix}.test`;
|
||||
await cleanupSlug(slugA, domain);
|
||||
await cleanupSlug(slugB, domain);
|
||||
|
||||
const tenantA = await prisma.tenant.create({ data: { name: `L4 ${prefix} A`, slug: slugA } });
|
||||
const tenantB = await prisma.tenant.create({ data: { name: `L4 ${prefix} B`, slug: slugB } });
|
||||
|
||||
const tech = await user(tenantA.id, `tech${domain}`, "Tech A");
|
||||
const lead = await user(tenantA.id, `lead${domain}`, "Lead A");
|
||||
const outsider = await user(tenantA.id, `outsider${domain}`, "Outsider A");
|
||||
const techB = await user(tenantB.id, `techb${domain}`, "Tech B");
|
||||
|
||||
const customerA = await prisma.customer.create({ data: { tenantId: tenantA.id, companyName: "Kunde A GmbH", city: "Hamburg" } });
|
||||
const siteA = await prisma.site.create({ data: { tenantId: tenantA.id, customerId: customerA.id, name: "Halle 1", street: "Hafenstraße", houseNumber: "1", postalCode: "20457", city: "Hamburg", accessNotes: "Schlüssel beim Pförtner" } });
|
||||
const team = await prisma.team.create({ data: { tenantId: tenantA.id, name: `Team ${prefix}`, leaderUserId: lead.id } });
|
||||
|
||||
const now = new Date();
|
||||
const orderA = await prisma.workOrder.create({
|
||||
data: {
|
||||
tenantId: tenantA.id,
|
||||
number: `A-${prefix}-1`,
|
||||
customerId: customerA.id,
|
||||
siteId: siteA.id,
|
||||
title: "Heizung montieren",
|
||||
status: "assigned",
|
||||
plannedStart: new Date(now.getTime() - 60 * 60 * 1000),
|
||||
plannedEnd: new Date(now.getTime() + 60 * 60 * 1000),
|
||||
teamLeadUserId: lead.id,
|
||||
assignees: { create: [{ tenantId: tenantA.id, userId: tech.id }] },
|
||||
checklistItems: { create: [{ tenantId: tenantA.id, key: "safe", label: "Arbeitsbereich abgesichert", required: true }] },
|
||||
photoRequirements: { create: [{ tenantId: tenantA.id, key: "typenschild", label: "Typenschild" }] },
|
||||
materialPlans: { create: [{ tenantId: tenantA.id, name: "Kupferrohr 15 mm", plannedQuantity: new Prisma.Decimal(10), unit: "m" }] },
|
||||
},
|
||||
include: { checklistItems: true, photoRequirements: true, materialPlans: true },
|
||||
});
|
||||
|
||||
const customerB = await prisma.customer.create({ data: { tenantId: tenantB.id, companyName: "Kunde B AG" } });
|
||||
const orderB = await prisma.workOrder.create({
|
||||
data: {
|
||||
tenantId: tenantB.id,
|
||||
number: `A-${prefix}-B1`,
|
||||
customerId: customerB.id,
|
||||
title: "Wartung B",
|
||||
status: "assigned",
|
||||
assignees: { create: [{ tenantId: tenantB.id, userId: techB.id }] },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
tenantA,
|
||||
tenantB,
|
||||
tech,
|
||||
lead,
|
||||
outsider,
|
||||
techB,
|
||||
team,
|
||||
siteA,
|
||||
orderA,
|
||||
orderB,
|
||||
ctxTech: ctxFor(tenantA.id, tech.id, "technician"),
|
||||
ctxLead: ctxFor(tenantA.id, lead.id, "team-lead"),
|
||||
ctxOutsider: ctxFor(tenantA.id, outsider.id, "technician"),
|
||||
ctxB: ctxFor(tenantB.id, techB.id, "technician"),
|
||||
cleanup: async () => {
|
||||
await cleanupSlug(slugA, domain);
|
||||
await cleanupSlug(slugB, domain);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Lane L4 „Einsatz mobil" — Service-Tests: Einsatz-Sessions (Zustände, Pause-Segmente, Dauer),
|
||||
// Zeitkorrektur (Recht + Grund + Audit), Material (Abweichung ohne Grund → invalid, Zusatzmaterial,
|
||||
// Idempotenz), Checkliste/Notizen, Abschluss-Guards, Rollen/Scope und Mandantentrennung.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-einsatz-field.ts (lokale Postgres-DB aus .env)
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { closeJobQueues } from "../src/server/jobs/queues";
|
||||
import { endSession, pauseSession, resumeSession, startSession } from "../src/server/services/field/sessions";
|
||||
import { correctTimeEntry } from "../src/server/services/field/time-correction";
|
||||
import { upsertMaterialUsage } from "../src/server/services/field/materials";
|
||||
import { toggleChecklistItem } from "../src/server/services/field/checklist";
|
||||
import { createNote } from "../src/server/services/field/notes";
|
||||
import { getFieldOrderDetail, listFieldOrders, listTodayOrders, getFieldBundle } from "../src/server/services/field/queries";
|
||||
import { transitionWorkOrder } from "../src/server/services/field/stubs/work-order-transition";
|
||||
import { createFixture, expectCode, failures, ok } from "./lib/einsatz-fixture";
|
||||
|
||||
const min = (n: number) => n * 60 * 1000;
|
||||
|
||||
async function main() {
|
||||
const f = await createFixture("l4field");
|
||||
const wo = f.orderA.id;
|
||||
try {
|
||||
console.log("\n— Einsatz-Session: Zustände —");
|
||||
const t0 = Date.now() - min(120);
|
||||
const at = (m: number) => new Date(t0 + min(m)).toISOString();
|
||||
|
||||
const travel = await startSession(f.ctxTech, { workOrderId: wo, mode: "travel", at: at(0), offline: true, deviceInfo: "test", latitude: 53.5, longitude: 10 });
|
||||
ok(travel.status === "en_route" && travel.workOrderStatus === "en_route", "Losfahren → Session en_route, Auftrag en_route");
|
||||
const s0 = await prisma.workSession.findUnique({ where: { id: travel.sessionId } });
|
||||
ok(s0?.startedOffline === true && s0.startLat === 53.5 && s0.deviceInfo === "test", "Start speichert Standort, Gerätestatus und Offline-Flag");
|
||||
await expectCode(() => startSession(f.ctxTech, { workOrderId: wo, mode: "travel", offline: false }), "conflict", "zweites Losfahren → conflict");
|
||||
|
||||
const work = await startSession(f.ctxTech, { workOrderId: wo, mode: "work", at: at(10), offline: false });
|
||||
ok(work.sessionId === travel.sessionId && work.status === "running" && work.workOrderStatus === "in_progress", "Arbeit starten übernimmt die Anfahrt-Session → running, Auftrag in_progress");
|
||||
await expectCode(() => startSession(f.ctxTech, { workOrderId: wo, mode: "work", offline: false }), "conflict", "keine doppelte laufende Session je User+Auftrag");
|
||||
const active = await prisma.workSession.count({ where: { workOrderId: wo, userId: f.tech.id, status: { in: ["en_route", "running", "paused"] } } });
|
||||
ok(active === 1, "genau eine aktive Session");
|
||||
|
||||
const paused = await pauseSession(f.ctxTech, { workOrderId: wo, at: at(40) });
|
||||
ok(paused.status === "paused" && paused.workOrderStatus === "paused", "Pause → Session paused, Auftrag paused");
|
||||
await expectCode(() => pauseSession(f.ctxTech, { workOrderId: wo }), "invalid", "Pause ohne laufende Session → invalid");
|
||||
const resumed = await resumeSession(f.ctxTech, { workOrderId: wo, at: at(50) });
|
||||
ok(resumed.status === "running" && resumed.workOrderStatus === "in_progress", "Weiter → running, Auftrag in_progress");
|
||||
|
||||
const entries = await prisma.timeEntry.findMany({ where: { workSessionId: travel.sessionId }, orderBy: { startedAt: "asc" } });
|
||||
ok(entries.map((e) => e.type).join(",") === "travel,work,break,work", "Zeitabschnitte als Segmente travel → work → break → work");
|
||||
ok(entries.slice(0, 3).every((e) => e.endedAt), "abgeschlossene Segmente haben ein Ende");
|
||||
|
||||
console.log("\n— Abschluss-Guards —");
|
||||
await expectCode(() => transitionWorkOrder(f.ctxTech, { workOrderId: wo, to: "technically_completed" }), "blocked", "Abschluss mit offener Pflicht-Checkliste/Pflichtfoto/laufender Session → blocked");
|
||||
|
||||
const ended = await endSession(f.ctxTech, { workOrderId: wo, at: at(80) });
|
||||
ok(ended.status === "ended", "Ende → Session ended");
|
||||
ok(ended.workSeconds === 3600 && ended.breakSeconds === 600 && ended.travelSeconds === 600 && ended.totalSeconds === 4800, `Ende berechnet Dauer (Arbeit ${ended.workSeconds}s, Pause ${ended.breakSeconds}s, Anfahrt ${ended.travelSeconds}s)`);
|
||||
await expectCode(() => endSession(f.ctxTech, { workOrderId: wo }), "invalid", "Ende ohne aktive Session → invalid");
|
||||
|
||||
console.log("\n— Zeitkorrektur —");
|
||||
const entry = entries[1];
|
||||
await expectCode(
|
||||
() => correctTimeEntry(f.ctxTech, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "vergessen" }),
|
||||
"forbidden",
|
||||
"Monteur ohne field:correct_time → forbidden",
|
||||
);
|
||||
await expectCode(() => correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "" }), "invalid", "Korrektur ohne Grund → invalid");
|
||||
await expectCode(() => correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(40), endedAt: at(12), reason: "Ende vor Start" }), "invalid", "Ende vor Beginn → invalid");
|
||||
const corrected = await correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "Start zu früh gestempelt" });
|
||||
ok(corrected.corrected && corrected.correctionReason === "Start zu früh gestempelt" && corrected.correctedById === f.lead.id, "Teamleiter korrigiert mit Grund → corrected-Flag + Grund + Bearbeiter");
|
||||
const auditRow = await prisma.auditLog.findFirst({ where: { tenantId: f.tenantA.id, entity: "time_entry", entityId: entry.id }, orderBy: { createdAt: "desc" } });
|
||||
const before = auditRow?.before as { startedAt?: string } | null;
|
||||
const after = auditRow?.after as { startedAt?: string; correctionReason?: string } | null;
|
||||
ok(!!auditRow && new Date(before!.startedAt!).getTime() === t0 + min(10) && new Date(after!.startedAt!).getTime() === t0 + min(12) && after?.correctionReason === "Start zu früh gestempelt", "Audit mit before/after der Korrektur");
|
||||
await expectCode(() => correctTimeEntry(f.ctxB as never, { timeEntryId: entry.id, startedAt: at(12), reason: "fremd" }), "forbidden", "Mandant B (Monteur) kann nicht korrigieren");
|
||||
const leadB = { ...f.ctxB, permissions: new Set([...f.ctxB.permissions, "field:correct_time"]) };
|
||||
await expectCode(() => correctTimeEntry(leadB, { timeEntryId: entry.id, startedAt: at(12), reason: "fremder Mandant" }), "not_found", "Mandant B mit Korrekturrecht sieht Zeiteintrag von A nicht → not_found");
|
||||
|
||||
console.log("\n— Material —");
|
||||
const plan = f.orderA.materialPlans[0];
|
||||
await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 5, unit: "m", usageStatus: "partially_used" }), "invalid", "teilweise verwendet ohne Begründung → invalid");
|
||||
await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 12, unit: "m", usageStatus: "fully_used" }), "invalid", "Mehrmenge ohne Begründung → invalid");
|
||||
await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 3, unit: "m", usageStatus: "not_used", deviationReason: "x" }), "invalid", "nicht verwendet mit Menge > 0 → invalid");
|
||||
const partial = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 5, unit: "m", usageStatus: "partially_used", deviationReason: "Leitung kürzer" });
|
||||
const full = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 10, unit: "m", usageStatus: "fully_used" });
|
||||
ok(partial.usageId === full.usageId, "Bestätigung derselben Planposition aktualisiert statt doppelt anzulegen");
|
||||
const planUsage = await prisma.materialUsage.findUnique({ where: { id: full.usageId } });
|
||||
ok(planUsage?.usageStatus === "fully_used" && Number(planUsage.actualQuantity) === 10 && planUsage.deviationReason === null, "vollständig verwendet ohne Abweichung braucht keine Begründung");
|
||||
|
||||
await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, name: "Muffe", quantity: 2, unit: "Stk", usageStatus: "additional" }), "invalid", "Zusatzmaterial ohne Grund → invalid");
|
||||
const clientId = "7f1c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f";
|
||||
const add1 = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, clientId, name: "Muffe", quantity: 2, unit: "Stk", usageStatus: "additional", deviationReason: "Übergang fehlte" });
|
||||
const add2 = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, clientId, name: "Muffe", quantity: 3, unit: "Stk", usageStatus: "additional", deviationReason: "Übergang fehlte" });
|
||||
ok(add1.usageId === add2.usageId && (await prisma.materialUsage.count({ where: { workOrderId: wo } })) === 2, "Zusatzmaterial idempotent über clientId");
|
||||
|
||||
console.log("\n— Checkliste & Notizen —");
|
||||
const item = f.orderA.checklistItems[0];
|
||||
const toggled = await toggleChecklistItem(f.ctxTech, { workOrderId: wo, itemId: item.id, checked: true, comment: "erledigt" });
|
||||
const itemRow = await prisma.checklistItem.findUnique({ where: { id: item.id } });
|
||||
ok(toggled.checked && itemRow?.checkedById === f.tech.id && itemRow.comment === "erledigt", "Checklistenpunkt abhaken mit Kommentar");
|
||||
const noteClient = "1b2c3d4e-5f60-4718-8293-a4b5c6d7e8f9";
|
||||
const n1 = await createNote(f.ctxTech, { workOrderId: wo, clientId: noteClient, kind: "problem", text: "Ventil klemmt" });
|
||||
const n2 = await createNote(f.ctxTech, { workOrderId: wo, clientId: noteClient, kind: "problem", text: "Ventil klemmt" });
|
||||
ok(n1.noteId === n2.noteId && (await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "Notiz idempotent über clientId");
|
||||
const noteAudit = await prisma.auditLog.count({ where: { tenantId: f.tenantA.id, entity: "activity_note", entityId: n1.noteId } });
|
||||
ok(noteAudit === 1, "Notiz erzeugt genau einen Audit-Eintrag");
|
||||
|
||||
console.log("\n— Rollen/Scope (Monteur ohne Zuweisung) —");
|
||||
await expectCode(() => startSession(f.ctxOutsider, { workOrderId: wo, mode: "work", offline: false }), "not_found", "fremder Auftrag: Session starten → not_found");
|
||||
await expectCode(() => toggleChecklistItem(f.ctxOutsider, { workOrderId: wo, itemId: item.id, checked: false }), "not_found", "fremder Auftrag: Checkliste → not_found");
|
||||
await expectCode(() => createNote(f.ctxOutsider, { workOrderId: wo, kind: "general", text: "x" }), "not_found", "fremder Auftrag: Notiz → not_found");
|
||||
await expectCode(() => getFieldOrderDetail(f.ctxOutsider, wo), "not_found", "fremder Auftrag: Detail → not_found");
|
||||
ok((await listFieldOrders(f.ctxOutsider, "running")).length === 0 && (await listTodayOrders(f.ctxOutsider)).length === 0, "Monteur ohne Zuweisung sieht keine Aufträge");
|
||||
const noExecute = { ...f.ctxTech, permissions: new Set([...f.ctxTech.permissions].filter((p) => p !== "field:execute")) };
|
||||
await expectCode(() => createNote(noExecute, { workOrderId: wo, kind: "general", text: "x" }), "forbidden", "ohne field:execute → forbidden");
|
||||
|
||||
console.log("\n— Sichtbarkeit für Zugewiesene/Teamleiter —");
|
||||
const running = await listFieldOrders(f.ctxTech, "running");
|
||||
ok(running.some((o) => o.id === wo), "Monteur sieht zugewiesenen Auftrag unter „laufend\"");
|
||||
ok((await listTodayOrders(f.ctxTech)).some((o) => o.id === wo), "Auftrag erscheint unter „Heute\"");
|
||||
const detail = await getFieldOrderDetail(f.ctxLead, wo);
|
||||
ok(detail.card.address === "Hafenstraße 1, 20457 Hamburg" && detail.site?.accessNotes === "Schlüssel beim Pförtner", "Teamleiter sieht Detail inkl. Objektadresse und Zugangshinweis");
|
||||
ok(detail.blockers.some((b) => b.kind === "photo_requirement") && !detail.blockers.some((b) => b.kind === "checklist_item"), "Blocker: nur noch Pflichtfoto offen");
|
||||
const bundle = await getFieldBundle(f.ctxTech);
|
||||
ok(bundle.orders.length === 1 && bundle.orders[0].id === wo && bundle.orders[0].materialPlans.length === 1, "Bundle liefert Auftrag im Scope inkl. Materialvorgabe");
|
||||
|
||||
console.log("\n— Mandantentrennung (B gegen A) —");
|
||||
await expectCode(() => getFieldOrderDetail(f.ctxB, wo), "not_found", "B liest Auftrag von A → not_found");
|
||||
await expectCode(() => startSession(f.ctxB, { workOrderId: wo, mode: "work", offline: false }), "not_found", "B startet Session auf Auftrag von A → not_found");
|
||||
await expectCode(() => upsertMaterialUsage(f.ctxB, { workOrderId: f.orderB.id, materialPlanId: plan.id, quantity: 1, unit: "m", usageStatus: "fully_used" }), "not_found", "B nutzt Planposition von A → not_found");
|
||||
await expectCode(() => toggleChecklistItem(f.ctxB, { workOrderId: f.orderB.id, itemId: item.id, checked: false }), "not_found", "B ändert Checklistenpunkt von A → not_found");
|
||||
const itemAfter = await prisma.checklistItem.findUnique({ where: { id: item.id } });
|
||||
ok(itemAfter?.checked === true, "Checklistenpunkt von A unverändert");
|
||||
ok((await getFieldBundle(f.ctxB)).orders.every((o) => o.id !== wo), "Bundle von B enthält keine Aufträge von A");
|
||||
} 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(async (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user