Files
craftvia/scripts/test-zeiterfassung-sync.ts
T
msolarczekandClaude Opus 5 9f4eecf6e2 L12 Zeiterfassung: Tests und authentifizierter Smoke
test-zeiterfassung-service (78 Prüfungen) und test-zeiterfassung-sync (29 Prüfungen),
Rollen-Matrix um field:record_own_time und time:approve ergänzt, smoke-auth um
/m/time, /m/time/new, /m/approvals, Uhr-Leiste und /work-orders/time-approvals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:29:46 +02:00

142 lines
12 KiB
TypeScript

// Lane L12 „Zeiterfassung" — Sync/Offline: neue Ops (time.add_manual, time.propose_correction,
// session.stop_day, session.segment, session.start mit switchFromOther) über denselben Pfad wie
// die Outbox (applyOperations), Idempotenz (gleiche clientOpId → duplicate), Überlappung → rejected
// mit Klartext-Schlüssel, Auto-Wechsel-Konflikt mit Auftragsnummer, optimistische lokale Ansicht,
// Mandantentrennung und Scope.
//
// Lauf: npx tsx scripts/test-zeiterfassung-sync.ts
import "dotenv/config";
import { randomUUID } from "node:crypto";
import { prisma } from "../src/server/db";
import { applyOperations } from "../src/server/services/sync/apply";
import type { SyncOperationInput, SyncOpType } from "../src/lib/sync/envelope";
import { OP_PAYLOAD_SCHEMAS } from "../src/lib/sync/ops";
import { VERSION_CHANGING_OPS, createEntry, problemKey } from "../src/lib/offline/outbox-core";
import { buildOrderView } from "../src/lib/offline/bundle-core";
import type { BundleOrderData } from "../src/lib/offline/types";
import { otherSessionOf, timeErrorKey } from "../src/lib/field/time-rules";
import { berlinAt, createTenant, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture";
const SLUG_A = "zz-l12-sync-a";
const SLUG_B = "zz-l12-sync-b";
const DEVICE = "device-l12";
const op = (opType: SyncOpType, payload: Record<string, unknown>, clientOpId: string = randomUUID()): SyncOperationInput => ({
clientOpId,
opType,
payload,
clientCreatedAt: new Date().toISOString(),
});
async function order(A: TenantFixture, status: string) {
return prisma.workOrder.create({
data: {
tenantId: A.tenantId,
number: `S-${randomUUID().slice(0, 6)}`,
customerId: A.customerId,
siteId: A.siteId,
title: "Heizung",
status: status as never,
assignedTeamId: A.teamId,
assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] },
},
});
}
runSuite("L12 Zeiterfassung (Sync/Offline)", [SLUG_A, SLUG_B], async () => {
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const wo1 = await order(A, "accepted");
const wo2 = await order(A, "accepted");
section("Payload-Schemas");
ok(!OP_PAYLOAD_SCHEMAS["time.add_manual"].safeParse({ workOrderId: wo1.id, startedAt: new Date().toISOString(), durationMinutes: 30 }).success, "time.add_manual ohne Begründung → ungültig");
ok(OP_PAYLOAD_SCHEMAS["session.start"].safeParse({ workOrderId: wo1.id, switchFromOther: true }).success, "session.start akzeptiert switchFromOther");
ok(!OP_PAYLOAD_SCHEMAS["session.segment"].safeParse({ workOrderId: wo1.id, type: "break" }).success, "session.segment nur work/return_travel/material_procurement");
section("Ops angewendet + idempotent");
const manualClient = randomUUID();
const batch = [
op("time.add_manual", { workOrderId: wo1.id, clientId: manualClient, type: "work", startedAt: berlinAt(-1, 8).toISOString(), endedAt: berlinAt(-1, 9).toISOString(), reason: "Kein Netz" }),
op("session.start", { workOrderId: wo1.id, mode: "work", clientId: randomUUID(), offline: true }),
op("session.segment", { workOrderId: wo1.id, type: "material_procurement" }),
op("session.stop_day", { workOrderId: wo1.id }),
];
const first = await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: batch });
ok(first.results.every((r) => r.status === "applied"), `4 Ops applied (${first.results.map((r) => `${r.status}${r.message ? `:${r.message}` : ""}`).join(", ")})`);
const manualId = first.results[0].idMap?.[manualClient];
const manual = manualId ? await prisma.timeEntry.findUnique({ where: { id: manualId } }) : null;
ok(manual?.approvalStatus === "pending" && manual.source === "manual", "idMap liefert den Nachtrag (pending, manual)");
const wo1After = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo1.id } });
ok(wo1After.status === "paused" && first.results[3].entityVersion === wo1After.version, `stop_day: Auftrag paused, entityVersion ${first.results[3].entityVersion}`);
const countBefore = await prisma.timeEntry.count({ where: { tenantId: A.tenantId } });
const again = await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: batch });
ok(again.results.every((r) => r.status === "duplicate"), "gleiche clientOpIds → duplicate");
ok((await prisma.timeEntry.count({ where: { tenantId: A.tenantId } })) === countBefore, "keine Doppelanlage");
ok(again.results[0].idMap?.[manualClient] === manualId, "duplicate liefert gespeicherte idMap");
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.lead.id, type: "time.approval_requested", entityId: manualId } })) === 1, "Event über Sync ausgelöst (Teamleiter)");
section("Überlappung → rejected mit Klartext");
const overlapOp = op("time.add_manual", { workOrderId: wo1.id, clientId: randomUUID(), type: "work", startedAt: berlinAt(-1, 8, 30).toISOString(), endedAt: berlinAt(-1, 9, 30).toISOString(), reason: "Kein Netz" });
const [overlap] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [overlapOp] })).results;
ok(overlap.status === "rejected" && overlap.errorCode === "invalid" && overlap.message === "overlap", "Überlappung → rejected invalid (overlap)");
ok(timeErrorKey(overlap) === "overlap", "Client-Schlüssel field.myTime.errors.overlap");
ok(problemKey({ status: "rejected", opType: "time.add_manual", lastError: { code: "invalid", message: overlap.message } }) === "timeOverlap", "/m/sync: Klartext offline.problem.timeOverlap");
ok(problemKey({ status: "rejected", opType: "time.add_manual", lastError: { code: "invalid", message: "too_old" } }) === "timeWindow", "/m/sync: Zeitfenster → offline.problem.timeWindow");
const stored = await prisma.syncOperation.findFirst({ where: { clientOpId: overlapOp.clientOpId } });
ok(stored?.status === "rejected" && stored.errorCode === "invalid", "SyncOperation rejected gespeichert (deterministisch)");
section("Korrekturvorschlag über Sync");
const workEntry = await prisma.timeEntry.findFirstOrThrow({ where: { userId: A.users.tech.id, source: "tracked", type: "work", workSession: { workOrderId: wo1.id } } });
const [proposal] = (
await applyOperations(A.ctx.tech, {
deviceId: DEVICE,
operations: [op("time.propose_correction", { workOrderId: wo1.id, timeEntryId: workEntry.id, startedAt: new Date(workEntry.startedAt.getTime() - 10 * 60_000).toISOString(), endedAt: workEntry.endedAt!.toISOString(), reason: "Start vergessen" })],
})
).results;
const proposed = await prisma.timeEntry.findUniqueOrThrow({ where: { id: workEntry.id } });
ok(proposal.status === "applied" && !!proposed.pendingChange && proposed.startedAt.getTime() === workEntry.startedAt.getTime(), "time.propose_correction applied, alte Werte bleiben");
section("Auto-Wechsel über Sync");
const [resume1] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [op("session.start", { workOrderId: wo1.id, mode: "work", clientId: randomUUID() })] })).results;
ok(resume1.status === "applied", "Arbeit auf Auftrag 1 gestartet");
const conflictOp = op("session.start", { workOrderId: wo2.id, mode: "work", clientId: randomUUID() });
const [conflict] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [conflictOp] })).results;
const other = otherSessionOf(conflict);
ok(conflict.status === "rejected" && conflict.errorCode === "conflict" && other?.number === wo1.number && other.workOrderId === wo1.id, `ohne Flag → rejected conflict mit Auftragsnummer ${other?.number}`);
ok(problemKey({ status: "rejected", opType: "session.start", lastError: { code: "conflict", message: conflict.message } }) === "otherSession", "/m/sync: Klartext offline.problem.otherSession");
const [dupConflict] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [conflictOp] })).results;
ok(dupConflict.status === "duplicate" && dupConflict.errorCode === "conflict", "Wiederholung der abgelehnten Op → duplicate (Ergebnis gespeichert)");
const [switched] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [op("session.start", { workOrderId: wo2.id, mode: "work", clientId: randomUUID(), switchFromOther: true })] })).results;
const s1 = await prisma.workSession.findFirstOrThrow({ where: { workOrderId: wo1.id, userId: A.users.tech.id, status: { not: "ended" } } });
ok(switched.status === "applied" && s1.status === "paused", "mit switchFromOther → applied, Auftrag 1 pausiert");
ok(VERSION_CHANGING_OPS.includes("session.stop_day") && VERSION_CHANGING_OPS.includes("session.segment"), "Outbox verkettet baseVersion auch nach stop_day/segment");
section("Optimistische lokale Ansicht");
const ctx = { tenantId: A.tenantId, userId: A.users.tech.id };
const now = new Date();
const data = {
id: wo2.id, number: wo2.number, title: "Heizung", status: "in_progress", statusGroup: "in_progress", priority: "normal", isEmergency: false,
plannedStart: null, plannedEnd: null, version: 3, mySession: { id: "s", status: "running", startedAt: now.toISOString() },
customer: { companyName: "K", firstName: null, lastName: null, street: null, houseNumber: null, postalCode: null, city: null },
checklistItems: [], photoRequirements: [], materialPlans: [], materialUsages: [], documents: [], siteHistory: [],
} as BundleOrderData;
const stopEntry = createEntry(ctx, { opType: "session.stop_day", payload: { workOrderId: wo2.id, at: now.toISOString() } }, { clientOpId: randomUUID(), now, existing: [] });
const manualEntry = createEntry(ctx, { opType: "time.add_manual", payload: { workOrderId: wo2.id, clientId: randomUUID(), type: "work", startedAt: berlinAt(-2, 8).toISOString(), durationMinutes: 30, reason: "Kein Netz" } }, { clientOpId: randomUUID(), now, existing: [stopEntry] });
const view = buildOrderView({ ctxKey: `${ctx.tenantId}:${ctx.userId}`, workOrderId: wo2.id, data, syncedAt: now.toISOString() }, [stopEntry, manualEntry]);
ok(view.local.session === null && view.status === "paused", "stop_day offline: Uhr aus, Auftrag pausiert (offen)");
ok(view.local.manualTimes.length === 1 && view.local.manualTimes[0].pending && view.local.manualTimes[0].durationMinutes === 30, "add_manual offline: Nachtrag mit pending-Badge sichtbar");
section("Mandantentrennung + Scope");
const [foreign] = (await applyOperations(B.ctx.tech, { deviceId: DEVICE, operations: [op("time.add_manual", { workOrderId: wo1.id, type: "work", startedAt: berlinAt(-2, 8).toISOString(), durationMinutes: 30, reason: "fremd" })] })).results;
ok(foreign.status === "rejected" && foreign.errorCode === "not_found", "Mandant B: time.add_manual auf A-Auftrag → not_found");
const [foreignStop] = (await applyOperations(B.ctx.tech, { deviceId: DEVICE, operations: [op("session.stop_day", { workOrderId: wo2.id })] })).results;
ok(foreignStop.status === "rejected" && foreignStop.errorCode === "not_found", "Mandant B: session.stop_day → not_found");
const [foreignProposal] = (await applyOperations(B.ctx.tech, { deviceId: DEVICE, operations: [op("time.propose_correction", { workOrderId: wo1.id, timeEntryId: workEntry.id, startedAt: berlinAt(-1, 8).toISOString(), endedAt: berlinAt(-1, 9).toISOString(), reason: "fremd" })] })).results;
ok(foreignProposal.status === "rejected" && foreignProposal.errorCode === "not_found", "Mandant B: Korrekturvorschlag → not_found");
const [outsider] = (await applyOperations(A.ctx.outsider, { deviceId: DEVICE, operations: [op("session.segment", { workOrderId: wo2.id, type: "return_travel" })] })).results;
ok(outsider.status === "rejected" && outsider.errorCode === "not_found", "Monteur ohne Zuweisung: session.segment → not_found");
ok((await prisma.timeEntry.count({ where: { tenantId: B.tenantId } })) === 0, "in Mandant B nichts angelegt");
});