import { describe, it, expect, vi } from "vitest"; import { WorkOrderReconciler, ReconcileDeletedWorkOrdersAbortedError, normalizeWorkOrderKey, normalizeWorkOrderOperationKey, isReconcileCandidateStatus, isReconcileCandidateWorkOrderOperation, selectWorkOrdersMissingFromErp, selectWorkOrderOperationsMissingFromErp, type WorkOrderReconcilerDeps, } from "./work-order-reconciler.js"; import { MMReceiveWorkOrder, MMReceiveWorkOrderOperation, } from "../../services/mm-api-service/types/receive-types.js"; import { MMSendWorkOrderOperationStatusPatch, MMSendWorkOrderStatusPatch, } from "../../services/mm-api-service/types/send-types.js"; import { MMWorkOrderOperationStatus, MMWorkOrderStatus, } from "../../types/erp-types.js"; import type { ErpWorkOrderKey, ErpWorkOrderOperationKey, } from "../../types/erp-connector.js"; const CONNECTOR = "test-erp"; function wo( overrides: Partial = {} ): MMReceiveWorkOrder { return new MMReceiveWorkOrder({ companyId: "c1", workOrderId: "WO-1", lot: "0", split: "0", sub: "0", status: "RELEASED", dueDate: null, description: "desc", scheduledStartDate: "2026-01-01T00:00:00Z", scheduledEndDate: null, closedDate: null, quantityRequired: 5, partNumber: "P1", partRevision: "A", method: "Standard", createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-02T00:00:00Z", customFieldsSchemaRef: null, customFields: null, ...overrides, }); } function woOp( overrides: Partial = {} ): MMReceiveWorkOrderOperation { return new MMReceiveWorkOrderOperation({ companyId: "c1", workOrderId: "WO-1", lot: "0", split: "0", sub: "0", sequenceNumber: "10", resourceId: "R1", startQuantity: 5, finishQuantity: 5, expectedRejectRate: 0, scheduledStartDate: "2026-01-01T00:00:00Z", scheduledFinishDate: null, closedDate: null, cycleTimeMs: 1000, setupTimeMs: 2000, productionburdenRateHourly: 1.5, setupburdenRatehourly: 2.5, operationType: "Production", quantityPerPart: 1, status: "RELEASED", createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-02T00:00:00Z", customFieldsSchemaRef: null, customFields: null, ...overrides, }); } function makeDeps(overrides: Partial = {}): { deps: WorkOrderReconcilerDeps; written: MMSendWorkOrderStatusPatch[][]; writtenOps: MMSendWorkOrderOperationStatusPatch[][]; } { const written: MMSendWorkOrderStatusPatch[][] = []; const writtenOps: MMSendWorkOrderOperationStatusPatch[][] = []; const ok = (count: number) => ({ message: "ok", upsertedEntities: count, localDedupeCount: 0, apiDedupeCount: 0, }); const deps: WorkOrderReconcilerDeps = { fetchAllWorkOrders: vi.fn(async () => []), writeWorkOrders: vi.fn(async (records: MMSendWorkOrderStatusPatch[]) => { written.push(records); return ok(records.length); }), fetchAllWorkOrderOperations: vi.fn(async () => []), writeWorkOrderOperations: vi.fn(async (records: MMSendWorkOrderOperationStatusPatch[]) => { writtenOps.push(records); return ok(records.length); }), ...overrides, }; return { deps, written, writtenOps }; } // The canonical connector implementation: delegate matching to the SDK helper. const handlerReturning = (keys: ErpWorkOrderKey[]) => ({ selectWorkOrdersToDelete: vi.fn(async (candidates: MMReceiveWorkOrder[]) => selectWorkOrdersMissingFromErp(candidates, keys) ), }); // Same, for a connector that also opts into operation reconciliation. const fullHandlerReturning = ( keys: ErpWorkOrderKey[], opKeys: ErpWorkOrderOperationKey[] ) => ({ ...handlerReturning(keys), selectWorkOrderOperationsToDelete: vi.fn( async (candidates: MMReceiveWorkOrderOperation[]) => selectWorkOrderOperationsMissingFromErp(candidates, opKeys) ), }); const WO1_KEY: ErpWorkOrderKey = { workOrderId: "WO-1", lot: "0", split: "0", sub: "0" }; const opKey = (sequenceNumber: string, wo: ErpWorkOrderKey = WO1_KEY): ErpWorkOrderOperationKey => ({ ...wo, sequenceNumber, }); describe("normalizeWorkOrderKey", () => { it("joins all four parts, trimmed and uppercased", () => { expect( normalizeWorkOrderKey({ workOrderId: " wo-1 ", lot: "a", split: "0", sub: "1" }) ).toBe("WO-1|A|0|1"); }); it("treats null/undefined/empty lot, split, and sub identically", () => { const full = normalizeWorkOrderKey({ workOrderId: "WO-1", lot: "", split: null, sub: undefined, }); expect(full).toBe("WO-1|||"); expect(normalizeWorkOrderKey({ workOrderId: "WO-1" })).toBe(full); }); }); describe("isReconcileCandidateStatus", () => { it("accepts RELEASED and ACTIVE case-insensitively with whitespace", () => { expect(isReconcileCandidateStatus("released")).toBe(true); expect(isReconcileCandidateStatus(" Active ")).toBe(true); }); it("rejects all other statuses and empty values", () => { for (const status of ["COMPLETED", "PENDING", "CANCELLED", "NOT RELEASED", "DELETED", "", null, undefined]) { expect(isReconcileCandidateStatus(status)).toBe(false); } }); }); describe("WorkOrderReconciler.reconcileDeleted", () => { it("marks candidates missing from the ERP as DELETED with a status-only patch", async () => { const missing = wo({ workOrderId: "WO-GONE", customFields: { a: 1 } }); const { deps, written } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [ wo({ workOrderId: "WO-KEEP" }), missing, ]), }); const handler = handlerReturning([{ workOrderId: "wo-keep", lot: "0", split: "0", sub: "0" }]); const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, {}, deps ); expect(result.status).toBe("completed"); expect(result.markedDeletedCount).toBe(1); expect(result.workOrdersToDelete).toEqual([ { workOrderId: "WO-GONE", lot: "0", split: "0", sub: "0" }, ]); expect(written).toHaveLength(1); const record = written[0][0]; expect(record).toBeInstanceOf(MMSendWorkOrderStatusPatch); // Only identity keys + status: no other column (numbers coerced from NULL, // closedDate, custom fields) can be clobbered by the deletion write. expect(record.toRESTApiObject()).toEqual({ workOrderId: "WO-GONE", lot: "0", split: "0", sub: "0", status: MMWorkOrderStatus.DELETED, }); expect(missing.customFields).toEqual({ a: 1 }); // untouched input }); it("only considers RELEASED/ACTIVE work orders as candidates", async () => { const { deps, written } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [ wo({ workOrderId: "WO-COMPLETED", status: "COMPLETED" }), wo({ workOrderId: "WO-DELETED", status: "DELETED" }), wo({ workOrderId: "WO-ACTIVE", status: "active" }), ]), }); // ERP reports nothing active; only the ACTIVE candidate may be deleted. const handler = handlerReturning([]); const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, {}, deps ); expect(result.status).toBe("completed"); expect(result.candidateCount).toBe(1); expect(result.markedDeletedCount).toBe(1); expect(written[0].map((r) => r.workOrderId)).toEqual(["WO-ACTIVE"]); }); it("matches composite keys case-insensitively so cosmetic differences never delete", async () => { const { deps } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [ wo({ workOrderId: "wo-100", lot: "A", sub: "" }), ]), }); const handler = handlerReturning([ { workOrderId: "WO-100", lot: " a ", split: "0", sub: null }, ]); const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, {}, deps ); expect(result.status).toBe("completed"); expect(result.markedDeletedCount).toBe(0); }); it("throws a typed abort error without writing when the deletion set exceeds the fraction guard", async () => { const candidates = Array.from({ length: 20 }, (_, i) => wo({ workOrderId: `WO-${i}` }) ); const { deps, written } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => candidates), }); // ERP only reports 5 of 20 → 15 deletions (75% > 20%, count > 10) const handler = handlerReturning( candidates.slice(0, 5).map((c) => ({ workOrderId: c.workOrderId, lot: c.lot, split: c.split, sub: c.sub, })) ); const promise = WorkOrderReconciler.reconcileDeleted(CONNECTOR, handler, {}, deps); await expect(promise).rejects.toBeInstanceOf(ReconcileDeletedWorkOrdersAbortedError); const error = await promise.catch((e: ReconcileDeletedWorkOrdersAbortedError) => e); expect(error.candidateCount).toBe(20); expect(error.workOrdersToDelete).toHaveLength(15); expect(error.deleteFraction).toBeCloseTo(0.75); expect(written).toHaveLength(0); }); it("permits small absolute deletion counts even at a high fraction", async () => { const candidates = [wo({ workOrderId: "WO-1" }), wo({ workOrderId: "WO-2" })]; const { deps, written } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => candidates), }); // Both deleted: fraction 100%, but count 2 <= guard floor of 10. const handler = handlerReturning([]); const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, {}, deps ); expect(result.status).toBe("completed"); expect(result.markedDeletedCount).toBe(2); expect(written[0]).toHaveLength(2); }); it("honors deleteGuardMinCount and maxDeleteFraction overrides", async () => { const candidates = Array.from({ length: 4 }, (_, i) => wo({ workOrderId: `WO-${i}` }) ); const { deps, written } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => candidates), }); const handler = handlerReturning([]); await expect( WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, { deleteGuardMinCount: 0, maxDeleteFraction: 0.5 }, deps ) ).rejects.toBeInstanceOf(ReconcileDeletedWorkOrdersAbortedError); expect(written).toHaveLength(0); }); it("parses fractional RECONCILE_MAX_DELETE_FRACTION from the environment", async () => { // Regression: getEnvNumber parses via parseInt and would turn "0.8" into 0, // making the guard abort every >10-row deletion; the driver must use getEnvFloat. vi.stubEnv("RECONCILE_MAX_DELETE_FRACTION", "0.8"); try { const candidates = Array.from({ length: 20 }, (_, i) => wo({ workOrderId: `WO-${i}` }) ); const { deps, written } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => candidates), }); // 15 of 20 deleted = 75%: aborts at the parseInt-mangled 0, completes at 0.8 const handler = handlerReturning( candidates.slice(0, 5).map((c) => ({ workOrderId: c.workOrderId, lot: c.lot, split: c.split, sub: c.sub, })) ); const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, {}, deps ); expect(result.status).toBe("completed"); expect(result.markedDeletedCount).toBe(15); expect(written).toHaveLength(1); } finally { vi.unstubAllEnvs(); } }); it("clamps out-of-range guard settings instead of trusting them", async () => { const candidates = Array.from({ length: 20 }, (_, i) => wo({ workOrderId: `WO-${i}` }) ); const handler = handlerReturning([]); // 100% would be deleted // fraction > 1 clamps to 1 → fraction check disabled → completes const loose = makeDeps({ fetchAllWorkOrders: vi.fn(async () => candidates) }); const disabled = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, { maxDeleteFraction: 5 }, loose.deps ); expect(disabled.status).toBe("completed"); expect(disabled.markedDeletedCount).toBe(20); // NaN falls back to the 0.2 default → 100% deletion aborts const strict = makeDeps({ fetchAllWorkOrders: vi.fn(async () => candidates) }); await expect( WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, { maxDeleteFraction: Number.NaN, deleteGuardMinCount: -3 }, strict.deps ) ).rejects.toBeInstanceOf(ReconcileDeletedWorkOrdersAbortedError); expect(strict.written).toHaveLength(0); }); it("dry run previews a guard-tripping deletion set instead of throwing", async () => { const candidates = Array.from({ length: 20 }, (_, i) => wo({ workOrderId: `WO-${i}` }) ); const { deps, written } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => candidates), }); const handler = handlerReturning([]); // 100% would be deleted const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, { dryRun: true }, deps ); expect(result.status).toBe("dry-run"); expect(result.workOrdersToDelete).toHaveLength(20); expect(result.reason).toMatch(/would abort/); expect(written).toHaveLength(0); }); it("dry run identifies deletions but writes nothing", async () => { const { deps, written } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [wo({ workOrderId: "WO-GONE" })]), }); const handler = handlerReturning([]); const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, { dryRun: true }, deps ); expect(result.status).toBe("dry-run"); expect(result.workOrdersToDelete).toHaveLength(1); expect(result.markedDeletedCount).toBe(0); expect(written).toHaveLength(0); }); it("propagates handler fetch failures without writing", async () => { const { deps, written } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [wo()]), }); const handler = { selectWorkOrdersToDelete: vi.fn(async () => { throw new Error("ERP unavailable"); }), }; await expect( WorkOrderReconciler.reconcileDeleted(CONNECTOR, handler, {}, deps) ).rejects.toThrow("ERP unavailable"); expect(written).toHaveLength(0); }); it("dedupes candidates repeated across pages by composite key", async () => { const { deps, written } = makeDeps({ // Same work order twice, as offset paging can produce under concurrent writes fetchAllWorkOrders: vi.fn(async () => [ wo({ workOrderId: "WO-GONE" }), wo({ workOrderId: "wo-gone " }), // key-equivalent duplicate wo({ workOrderId: "WO-KEEP" }), ]), }); const handler = handlerReturning([ { workOrderId: "WO-KEEP", lot: "0", split: "0", sub: "0" }, ]); const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, {}, deps ); expect(result.candidateCount).toBe(2); expect(result.markedDeletedCount).toBe(1); expect(written[0]).toHaveLength(1); }); it("rejects a selection containing rows that are not candidates", async () => { const { deps, written } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [wo({ workOrderId: "WO-1" })]), }); const handler = { // Returns a fabricated row instead of one of the given candidates selectWorkOrdersToDelete: vi.fn(async () => [ wo({ workOrderId: "WO-FOREIGN" }), ]), }; await expect( WorkOrderReconciler.reconcileDeleted(CONNECTOR, handler, {}, deps) ).rejects.toThrow(/not among the candidates/); expect(written).toHaveLength(0); }); it("dedupes a selection that repeats the same candidate", async () => { const gone = wo({ workOrderId: "WO-GONE" }); const { deps, written } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [gone]), }); const handler = { selectWorkOrdersToDelete: vi.fn(async () => [gone, gone]), }; const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, {}, deps ); expect(result.markedDeletedCount).toBe(1); expect(written[0]).toHaveLength(1); }); it("throws when the handler returns a non-array", async () => { const { deps } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [wo()]), }); const handler = { selectWorkOrdersToDelete: vi.fn(async () => null as never), }; await expect( WorkOrderReconciler.reconcileDeleted(CONNECTOR, handler, {}, deps) ).rejects.toThrow(/must return an array/); }); it("completes without calling the handler when there are no candidates", async () => { const { deps } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [ wo({ status: "COMPLETED" }), ]), }); const handler = handlerReturning([]); const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, {}, deps ); expect(result.status).toBe("completed"); expect(result.candidateCount).toBe(0); expect(handler.selectWorkOrdersToDelete).not.toHaveBeenCalled(); // The operation phase still runs (cascade of previously deleted parents). expect(deps.fetchAllWorkOrderOperations).toHaveBeenCalledTimes(1); expect(result.workOrderOperationsReconciled).toBe(false); }); }); describe("WorkOrderReconciler.reconcileDeleted — work order operations", () => { it("cascades open operations of work orders deleted in this run, without consulting the handler or the guard", async () => { const { deps, written, writtenOps } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [wo({ workOrderId: "WO-GONE" }), wo()]), fetchAllWorkOrderOperations: vi.fn(async () => [ woOp({ workOrderId: "WO-GONE", sequenceNumber: "10", customFields: { x: 1 } }), woOp({ workOrderId: "WO-GONE", sequenceNumber: "20" }), woOp({ workOrderId: "WO-GONE", sequenceNumber: "30", status: "COMPLETED" }), woOp({ sequenceNumber: "10" }), ]), }); // Floor 1 / fraction 0: the single work order deletion passes (count not > 1), but // the two cascaded operations would trip the guard if it applied to them. const handler = handlerReturning([WO1_KEY]); const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, { deleteGuardMinCount: 1, maxDeleteFraction: 0 }, deps ); expect(result.status).toBe("completed"); expect(result.markedDeletedCount).toBe(1); expect(written).toHaveLength(1); expect(result.workOrderOperationsReconciled).toBe(false); expect(result.workOrderOperationCandidateCount).toBe(1); // WO-1 / 10 (handler absent) expect(result.workOrderOperationsToDelete).toEqual([]); expect(result.cascadedWorkOrderOperationsToDelete).toEqual([ { workOrderId: "WO-GONE", lot: "0", split: "0", sub: "0", sequenceNumber: "10" }, { workOrderId: "WO-GONE", lot: "0", split: "0", sub: "0", sequenceNumber: "20" }, ]); expect(result.markedDeletedWorkOrderOperationCount).toBe(2); expect(writtenOps).toHaveLength(1); const record = writtenOps[0][0]; expect(record).toBeInstanceOf(MMSendWorkOrderOperationStatusPatch); expect(record.toRESTApiObject()).toEqual({ workOrderId: "WO-GONE", lot: "0", split: "0", sub: "0", sequenceNumber: "10", status: MMWorkOrderOperationStatus.DELETED, }); }); it("cascades open operations of work orders already DELETED in MM by an earlier run", async () => { const { deps, written, writtenOps } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [ wo({ workOrderId: "WO-OLD", status: "deleted" }), wo(), ]), fetchAllWorkOrderOperations: vi.fn(async () => [ woOp({ workOrderId: "WO-OLD", sequenceNumber: "10" }), woOp({ workOrderId: "WO-OLD", sequenceNumber: "20", status: "DELETED" }), woOp({ workOrderId: "WO-OLD", sequenceNumber: "30", closedDate: "2026-02-01T00:00:00Z" }), ]), }); const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handlerReturning([WO1_KEY]), {}, deps ); expect(written).toHaveLength(0); expect(result.cascadedWorkOrderOperationsToDelete.map((k) => k.sequenceNumber)).toEqual(["10"]); expect(writtenOps[0].map((r) => r.sequenceNumber)).toEqual(["10"]); }); it("offers only open RELEASED/ACTIVE operations of surviving candidate work orders to the handler", async () => { const candidatesSeen: MMReceiveWorkOrderOperation[][] = []; const { deps } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [ wo(), wo({ workOrderId: "WO-DONE", status: "COMPLETED" }), ]), fetchAllWorkOrderOperations: vi.fn(async () => [ woOp({ sequenceNumber: "10" }), woOp({ sequenceNumber: "20", status: " active " }), woOp({ sequenceNumber: "30", status: "COMPLETED" }), woOp({ sequenceNumber: "40", closedDate: "2026-02-01T00:00:00Z" }), woOp({ workOrderId: "WO-DONE", sequenceNumber: "10" }), // parent not a candidate woOp({ workOrderId: "WO-UNKNOWN", sequenceNumber: "10" }), // parent absent from MM ]), }); const handler = { ...handlerReturning([WO1_KEY]), selectWorkOrderOperationsToDelete: vi.fn( async (candidates: MMReceiveWorkOrderOperation[]) => { candidatesSeen.push(candidates); return []; } ), }; const result = await WorkOrderReconciler.reconcileDeleted(CONNECTOR, handler, {}, deps); expect(result.workOrderOperationsReconciled).toBe(true); expect(result.workOrderOperationCandidateCount).toBe(2); expect(candidatesSeen[0].map((op) => op.sequenceNumber).sort()).toEqual(["10", "20"]); expect(result.markedDeletedWorkOrderOperationCount).toBe(0); }); it("marks handler-selected operations of surviving jobs as DELETED with a status-only patch", async () => { const gone = woOp({ sequenceNumber: "20", productionburdenRateHourly: 42 }); const { deps, written, writtenOps } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [wo()]), fetchAllWorkOrderOperations: vi.fn(async () => [woOp({ sequenceNumber: "10" }), gone]), }); const handler = fullHandlerReturning([WO1_KEY], [opKey(" 10 ")]); const result = await WorkOrderReconciler.reconcileDeleted(CONNECTOR, handler, {}, deps); expect(written).toHaveLength(0); expect(result.workOrderOperationsToDelete).toEqual([{ ...WO1_KEY, sequenceNumber: "20" }]); expect(result.cascadedWorkOrderOperationsToDelete).toEqual([]); expect(result.markedDeletedWorkOrderOperationCount).toBe(1); expect(writtenOps).toHaveLength(1); expect(writtenOps[0][0].status).toBe(MMWorkOrderOperationStatus.DELETED); expect(writtenOps[0][0].sequenceNumber).toBe("20"); expect(Object.keys(writtenOps[0][0].toRESTApiObject())).not.toContain("closedDate"); }); it("writes cascaded and selected operations together", async () => { const { deps, writtenOps } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [wo({ workOrderId: "WO-GONE" }), wo()]), fetchAllWorkOrderOperations: vi.fn(async () => [ woOp({ workOrderId: "WO-GONE", sequenceNumber: "10" }), woOp({ sequenceNumber: "10" }), woOp({ sequenceNumber: "20" }), ]), }); const handler = fullHandlerReturning([WO1_KEY], [opKey("10")]); const result = await WorkOrderReconciler.reconcileDeleted(CONNECTOR, handler, {}, deps); expect(result.markedDeletedCount).toBe(1); expect(result.cascadedWorkOrderOperationsToDelete).toHaveLength(1); expect(result.workOrderOperationsToDelete).toHaveLength(1); expect(result.markedDeletedWorkOrderOperationCount).toBe(2); expect(writtenOps).toHaveLength(1); expect(writtenOps[0].map((r) => `${r.workOrderId}|${r.sequenceNumber}`).sort()).toEqual([ "WO-1|20", "WO-GONE|10", ]); }); it("applies the blast-radius guard to the handler's operation selection with a typed, entity-tagged error", async () => { const ops = Array.from({ length: 20 }, (_, i) => woOp({ sequenceNumber: String((i + 1) * 10) })); const { deps, writtenOps } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [wo()]), fetchAllWorkOrderOperations: vi.fn(async () => ops), }); // ERP reports only 5 of 20 ops → 75% deletions, count 15 > floor 10. const handler = fullHandlerReturning( [WO1_KEY], ops.slice(0, 5).map((op) => opKey(op.sequenceNumber)) ); const promise = WorkOrderReconciler.reconcileDeleted(CONNECTOR, handler, {}, deps); await expect(promise).rejects.toBeInstanceOf(ReconcileDeletedWorkOrdersAbortedError); const error = (await promise.catch((e) => e)) as ReconcileDeletedWorkOrdersAbortedError; expect(error.entity).toBe("workOrderOperations"); expect(error.candidateCount).toBe(20); expect(error.workOrderOperationsToDelete).toHaveLength(15); expect(error.workOrdersToDelete).toEqual([]); expect(writtenOps).toHaveLength(0); }); it("dry run previews operation deletions and guard trips without writing", async () => { const ops = Array.from({ length: 20 }, (_, i) => woOp({ sequenceNumber: String((i + 1) * 10) })); const { deps, written, writtenOps } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [wo({ workOrderId: "WO-GONE" }), wo()]), fetchAllWorkOrderOperations: vi.fn(async () => [ woOp({ workOrderId: "WO-GONE", sequenceNumber: "10" }), ...ops, ]), }); const handler = fullHandlerReturning( [WO1_KEY], ops.slice(0, 5).map((op) => opKey(op.sequenceNumber)) ); const result = await WorkOrderReconciler.reconcileDeleted( CONNECTOR, handler, { dryRun: true }, deps ); expect(result.status).toBe("dry-run"); expect(result.reason).toMatch(/work order operations/); expect(result.workOrdersToDelete).toHaveLength(1); expect(result.cascadedWorkOrderOperationsToDelete).toHaveLength(1); expect(result.workOrderOperationsToDelete).toHaveLength(15); expect(result.markedDeletedCount).toBe(0); expect(result.markedDeletedWorkOrderOperationCount).toBe(0); expect(written).toHaveLength(0); expect(writtenOps).toHaveLength(0); }); it("rejects an operation selection containing rows that are not candidates", async () => { const { deps, writtenOps } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [wo()]), fetchAllWorkOrderOperations: vi.fn(async () => [woOp({ sequenceNumber: "10" })]), }); const handler = { ...handlerReturning([WO1_KEY]), selectWorkOrderOperationsToDelete: vi.fn(async () => [woOp({ sequenceNumber: "99" })]), }; await expect( WorkOrderReconciler.reconcileDeleted(CONNECTOR, handler, {}, deps) ).rejects.toThrow(/selectWorkOrderOperationsToDelete\(\) returned 1 row/); expect(writtenOps).toHaveLength(0); }); it("propagates operation handler fetch failures after the work order phase", async () => { const { deps, written, writtenOps } = makeDeps({ fetchAllWorkOrders: vi.fn(async () => [wo({ workOrderId: "WO-GONE" }), wo()]), fetchAllWorkOrderOperations: vi.fn(async () => [woOp({ sequenceNumber: "10" })]), }); const handler = { ...handlerReturning([WO1_KEY]), selectWorkOrderOperationsToDelete: vi.fn(async () => { throw new Error("ERP down"); }), }; await expect( WorkOrderReconciler.reconcileDeleted(CONNECTOR, handler, {}, deps) ).rejects.toThrow("ERP down"); expect(written).toHaveLength(1); // work order phase already stood expect(writtenOps).toHaveLength(0); }); }); describe("isReconcileCandidateWorkOrderOperation", () => { it("requires a candidate status and no closedDate", () => { expect(isReconcileCandidateWorkOrderOperation({ status: "released", closedDate: null })).toBe(true); expect(isReconcileCandidateWorkOrderOperation({ status: "ACTIVE", closedDate: "" })).toBe(true); expect(isReconcileCandidateWorkOrderOperation({ status: "RELEASED", closedDate: "2026-01-01T00:00:00Z" })).toBe(false); expect(isReconcileCandidateWorkOrderOperation({ status: "COMPLETED", closedDate: null })).toBe(false); expect(isReconcileCandidateWorkOrderOperation({ status: "DELETED", closedDate: null })).toBe(false); }); }); describe("normalizeWorkOrderOperationKey", () => { it("appends the trimmed, uppercased sequence number to the work order key", () => { expect(normalizeWorkOrderOperationKey({ ...WO1_KEY, lot: "a", sequenceNumber: " 10 " })).toBe("WO-1|A|0|0|10"); }); it("canonicalizes numeric-looking sequence numbers so ERP formatting matches MM's numeric rendering", () => { for (const seq of ["010", "10.0", 10, " 10 ", "1e1"]) { expect(normalizeWorkOrderOperationKey({ ...WO1_KEY, sequenceNumber: seq })).toBe("WO-1|0|0|0|10"); } expect(normalizeWorkOrderOperationKey({ ...WO1_KEY, sequenceNumber: "10A" })).toBe("WO-1|0|0|0|10A"); expect(normalizeWorkOrderOperationKey({ ...WO1_KEY, sequenceNumber: "" })).toBe("WO-1|0|0|0|"); }); it("stringifies numeric work order key parts", () => { expect(normalizeWorkOrderKey({ workOrderId: 1001, lot: 0, split: null, sub: 2 })).toBe("1001|0||2"); }); }); describe("selectWorkOrderOperationsMissingFromErp", () => { it("returns candidates absent from the active key set, matching case-insensitively", () => { const keep = woOp({ sequenceNumber: "10" }); const gone = woOp({ sequenceNumber: "20" }); expect( selectWorkOrderOperationsMissingFromErp([keep, gone], [opKey(" 10 ", { ...WO1_KEY, workOrderId: "wo-1" })]) ).toEqual([gone]); }); it("throws on a non-array active list", () => { expect(() => selectWorkOrderOperationsMissingFromErp([woOp()], null as never)).toThrow(/must be an array/); }); it("throws when active keys lack a usable workOrderId or sequenceNumber", () => { expect(() => selectWorkOrderOperationsMissingFromErp([woOp()], [opKey("10"), { ...WO1_KEY, sequenceNumber: "" }]) ).toThrow(/lack a usable workOrderId or sequenceNumber/); expect(() => selectWorkOrderOperationsMissingFromErp([woOp()], [{ ...opKey("10"), workOrderId: NaN as never }]) ).toThrow(/lack a usable workOrderId or sequenceNumber/); }); it("accepts numeric ids and padded sequence numbers from ERP drivers", () => { const keep = woOp({ workOrderId: "1001", sequenceNumber: "10" }); const gone = woOp({ workOrderId: "1001", sequenceNumber: "20" }); expect( selectWorkOrderOperationsMissingFromErp( [keep, gone], [{ workOrderId: 1001, lot: 0, split: 0, sub: 0, sequenceNumber: "0010" }] ) ).toEqual([gone]); }); }); describe("selectWorkOrdersMissingFromErp", () => { it("returns candidates absent from the active key set, matching case-insensitively", () => { const keep = wo({ workOrderId: "wo-100", lot: "A", sub: "" }); const gone = wo({ workOrderId: "WO-200" }); const result = selectWorkOrdersMissingFromErp( [keep, gone], [{ workOrderId: "WO-100", lot: " a ", split: "0", sub: null }] ); expect(result).toEqual([gone]); }); it("throws on a non-array active list", () => { expect(() => selectWorkOrdersMissingFromErp([wo()], null as never) ).toThrow(/must be an array/); }); it("throws when active keys lack a workOrderId", () => { expect(() => selectWorkOrdersMissingFromErp( [wo()], [ { workOrderId: "WO-OK" }, { workOrderId: " " }, // blank id — e.g. a wrong-field-name handler bug ] ) ).toThrow(/lack a usable workOrderId/); }); it("accepts numeric work order ids", () => { const gone = wo({ workOrderId: "2002" }); expect( selectWorkOrdersMissingFromErp([wo({ workOrderId: "1001" }), gone], [{ workOrderId: 1001, lot: 0, split: 0, sub: 0 }]) ).toEqual([gone]); }); });