import { describe, it, expect, vi, afterEach } from "vitest"; // CoreConfiguration requires this env var; set before any SDK module loads. process.env.MM_MAPPING_AUTH_SERVICE_URL = "https://api.test.machinemetrics.com"; import { StandardProcessDrivers } from "./standard-process-drivers.js"; import { MMEntityProcessor } from "./mm-entity-processor.js"; import { ERPObjType } from "../../types/erp-types.js"; import type { FlattenedWorkOrderRow } from "../../types/flattened-work-order.js"; import type { BatchCacheManager } from "../../services/caching-service/batch-cache-manager.js"; const row = (overrides: Partial = {}): FlattenedWorkOrderRow => ({ partNumber: "P1", partRevision: "A", method: "Standard", sequenceNumber: "10", resourceId: "R1", workOrderId: "WO-1", lot: "0", split: "0", sub: "0", status: "RELEASED", opStatus: "RELEASED", ...overrides, }) as FlattenedWorkOrderRow; // Passing an explicit (stub) cache manager keeps the driver from opening SQLite. const stubCache = {} as BatchCacheManager; const spyWrites = () => vi .spyOn(MMEntityProcessor, "writeEntities") .mockResolvedValue({ message: "ok", upsertedEntities: 1, localDedupeCount: 0, apiDedupeCount: 0 }); /** The entity batch the driver handed to `writeEntities` for `type`. */ const writtenBatch = (write: ReturnType, type: ERPObjType): T[] => { const call = write.mock.calls.find((c) => c[0] === type)!; return call[1] as T[]; }; type FullPayload = { toFullApiObject: () => Record }; /** The full API payloads the driver handed to `writeEntities` for `type`. */ const payloadsFor = (write: ReturnType, type: ERPObjType) => writtenBatch(write, type).map((r) => r.toFullApiObject()); describe("StandardProcessDrivers.syncWorkOrderBatchFromFlattened status validation", () => { afterEach(() => vi.restoreAllMocks()); it("fails the batch before any write when a work order row lacks a status", async () => { const write = vi.spyOn(MMEntityProcessor, "writeEntities"); await expect( StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [row(), row({ workOrderId: "WO-2", status: null as never })], stubCache ) ).rejects.toThrow(/1 work order\(s\) and 0 work order operation\(s\) lack a non-blank string status \(e\.g\. work order WO-2\|0\|0\|0\)/); expect(write).not.toHaveBeenCalled(); }); it("rejects a non-string status such as a raw numeric ERP code", async () => { const write = vi.spyOn(MMEntityProcessor, "writeEntities"); await expect( StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [row({ status: 0 as never })], stubCache ) ).rejects.toThrow(/lack a non-blank string status/); expect(write).not.toHaveBeenCalled(); }); it("rejects a non-string opStatus instead of masking it behind the parent status", async () => { const write = vi.spyOn(MMEntityProcessor, "writeEntities"); await expect( StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [row({ opStatus: 0 as never, status: "RELEASED" })], stubCache ) ).rejects.toThrow(/0 work order\(s\) and 1 work order operation\(s\) lack a non-blank string status/); expect(write).not.toHaveBeenCalled(); }); it("falls back to the parent status when opStatus is whitespace-only", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [row({ opStatus: " ", status: "RELEASED" })], stubCache ); expect(writtenBatch<{ status: string }>(write, ERPObjType.WORK_ORDER_OPERATIONS)[0].status).toBe("RELEASED"); }); it("fails when an operation row lacks both opStatus and a parent status to fall back on", async () => { const write = vi.spyOn(MMEntityProcessor, "writeEntities"); await expect( StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [row({ sequenceNumber: "20", opStatus: " " as never, status: "" as never })], stubCache ) ).rejects.toThrow(/lack a non-blank string status/); expect(write).not.toHaveBeenCalled(); }); it("lets an operation without opStatus inherit the parent work order status, and writes", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [row({ opStatus: undefined, status: "active" })], stubCache ); expect(writtenBatch<{ status: string }>(write, ERPObjType.WORK_ORDERS)[0].status).toBe("active"); expect(writtenBatch<{ status: string }>(write, ERPObjType.WORK_ORDER_OPERATIONS)[0].status).toBe("active"); }); }); describe("StandardProcessDrivers.syncWorkOrderBatchFromFlattened work order identity", () => { afterEach(() => vi.restoreAllMocks()); it("keeps two lots of one work order id as separate MM entities", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [ row({ lot: "A", workOrderCustomFields: { pickList: ["from-lot-A"] } }), row({ lot: "B", workOrderCustomFields: { pickList: ["from-lot-B"] } }), ], stubCache ); // Collapsing these would drop lot B entirely, and its operations would then be rejected // by the work_order_fk constraint against a parent that was never created. expect(payloadsFor(write, ERPObjType.WORK_ORDERS)).toEqual([ expect.objectContaining({ workOrderId: "WO-1", lot: "A", customFields: { pickList: ["from-lot-A"] } }), expect.objectContaining({ workOrderId: "WO-1", lot: "B", customFields: { pickList: ["from-lot-B"] } }), ]); }); it("treats split and sub as part of the identity too", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [row({}), row({ split: "2" }), row({ sub: "9" })], stubCache ); expect(payloadsFor(write, ERPObjType.WORK_ORDERS)).toHaveLength(3); }); it("normalizes identity components so equivalent rows build one entity", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [ // Unset and empty-string lot both construct to "", and a numeric id matches its // string form, so all three rows describe the same MM work order. row({ workOrderId: 4321, lot: undefined, sequenceNumber: "10" }), row({ workOrderId: "4321", lot: "", sequenceNumber: "20" }), row({ workOrderId: 4321, lot: "", sequenceNumber: "30" }), ], stubCache ); expect(payloadsFor(write, ERPObjType.WORK_ORDERS)).toEqual([ expect.objectContaining({ workOrderId: "4321", lot: "" }), ]); }); }); describe("StandardProcessDrivers.syncWorkOrderBatchFromFlattened part identity", () => { afterEach(() => vi.restoreAllMocks()); it("builds one part from rows that differ only in un-normalized identity components", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [ // NULL method constructs to "Standard" and NULL revision to "", and a numeric part // number matches its string form. Before normalizing the key these were two entries, // each resolving custom fields from its own rows: the second (null) would have won. row({ partNumber: 700, partRevision: undefined, method: undefined, sequenceNumber: "10", partCustomFields: { family: "housing" } }), row({ partNumber: "700", partRevision: "", method: "Standard", sequenceNumber: "20", partCustomFields: null }), ], stubCache ); expect(payloadsFor(write, ERPObjType.PARTS)).toEqual([ expect.objectContaining({ partNumber: "700", partRevision: "", method: "Standard", customFields: { family: "housing" } }), ]); }); it("applies the same normalization to the part operation key, including the sequence number", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [ row({ method: undefined, sequenceNumber: 10, partOperationCustomFields: { cell: "A" } }), row({ method: "Standard", sequenceNumber: "10", partOperationCustomFields: null }), ], stubCache ); expect(payloadsFor(write, ERPObjType.PART_OPERATION)).toEqual([ expect.objectContaining({ sequenceNumber: "10", method: "Standard", customFields: { cell: "A" } }), ]); }); }); describe("StandardProcessDrivers.syncWorkOrderBatchFromFlattened custom fields", () => { afterEach(() => vi.restoreAllMocks()); const ALL_TYPES = [ ERPObjType.PARTS, ERPObjType.PART_OPERATION, ERPObjType.WORK_ORDERS, ERPObjType.WORK_ORDER_OPERATIONS, ]; it("forwards each per-entity pair to the corresponding MM entity", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [ row({ partCustomFields: { family: "housing" }, partCustomFieldsSchemaRef: 1, partOperationCustomFields: { cell: "A" }, partOperationCustomFieldsSchemaRef: 2, workOrderCustomFields: { pickList: ["M-1", "M-2"] }, workOrderCustomFieldsSchemaRef: 3, opCustomFields: { fixture: "F-10" }, opCustomFieldsSchemaRef: 4, }), ], stubCache ); expect(payloadsFor(write, ERPObjType.PARTS)).toEqual([ expect.objectContaining({ partNumber: "P1", customFields: { family: "housing" }, customFieldsSchemaRef: 1 }), ]); expect(payloadsFor(write, ERPObjType.PART_OPERATION)).toEqual([ expect.objectContaining({ sequenceNumber: "10", customFields: { cell: "A" }, customFieldsSchemaRef: 2 }), ]); expect(payloadsFor(write, ERPObjType.WORK_ORDERS)).toEqual([ expect.objectContaining({ workOrderId: "WO-1", customFields: { pickList: ["M-1", "M-2"] }, customFieldsSchemaRef: 3 }), ]); expect(payloadsFor(write, ERPObjType.WORK_ORDER_OPERATIONS)).toEqual([ expect.objectContaining({ sequenceNumber: "10", customFields: { fixture: "F-10" }, customFieldsSchemaRef: 4 }), ]); }); it("omits custom-field keys entirely when a row does not set them", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened([row()], stubCache); for (const type of ALL_TYPES) { const [payload] = payloadsFor(write, type); expect(payload).not.toHaveProperty("customFields"); expect(payload).not.toHaveProperty("customFieldsSchemaRef"); } }); it("passes null through on every entity so callers can clear server-side custom fields", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [ row({ partCustomFields: null, partCustomFieldsSchemaRef: null, partOperationCustomFields: null, partOperationCustomFieldsSchemaRef: null, workOrderCustomFields: null, workOrderCustomFieldsSchemaRef: null, opCustomFields: null, opCustomFieldsSchemaRef: null, }), ], stubCache ); for (const type of ALL_TYPES) { const [payload] = payloadsFor(write, type); expect(payload.customFields).toBeNull(); expect(payload.customFieldsSchemaRef).toBeNull(); } }); it("keeps operation custom fields per row while a shared work order takes the first non-null value", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [ row({ sequenceNumber: "10", workOrderCustomFields: { pickList: ["M-1"] }, workOrderCustomFieldsSchemaRef: 3, opCustomFields: { fixture: "F-10" }, }), row({ sequenceNumber: "20", workOrderCustomFields: { pickList: ["ignored"] }, workOrderCustomFieldsSchemaRef: 3, opCustomFields: { fixture: "F-20" }, }), ], stubCache ); expect(payloadsFor(write, ERPObjType.WORK_ORDERS)).toEqual([ expect.objectContaining({ customFields: { pickList: ["M-1"] }, customFieldsSchemaRef: 3 }), ]); expect(payloadsFor(write, ERPObjType.WORK_ORDER_OPERATIONS)).toEqual([ expect.objectContaining({ sequenceNumber: "10", customFields: { fixture: "F-10" } }), expect.objectContaining({ sequenceNumber: "20", customFields: { fixture: "F-20" } }), ]); }); it("takes the schema ref from the row that supplied the values, never mixing two rows", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [ row({ sequenceNumber: "10", workOrderCustomFieldsSchemaRef: 1, workOrderCustomFields: undefined }), row({ sequenceNumber: "20", workOrderCustomFieldsSchemaRef: 2, workOrderCustomFields: { pickList: ["M-1"] } }), ], stubCache ); // Schema 1 never validated { pickList }, so the pair must come from the second row. expect(payloadsFor(write, ERPObjType.WORK_ORDERS)).toEqual([ expect.objectContaining({ customFields: { pickList: ["M-1"] }, customFieldsSchemaRef: 2 }), ]); }); it("drops an earlier row's schema ref when a later row wins the values without one", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [ row({ sequenceNumber: "10", workOrderCustomFields: null, workOrderCustomFieldsSchemaRef: 1 }), row({ sequenceNumber: "20", workOrderCustomFields: { pickList: ["M-1"] } }), ], stubCache ); // Schema 1 was paired with a clear, not with these values, so it must not ride along. const [workOrder] = payloadsFor(write, ERPObjType.WORK_ORDERS); expect(workOrder.customFields).toEqual({ pickList: ["M-1"] }); expect(workOrder).not.toHaveProperty("customFieldsSchemaRef"); }); it("ignores a lone schema ref on the row that creates the entity, as it does on any other row", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [ // An optional join produced no values anywhere, so the constant schema ref on these // rows must not reach MM on its own. Position must not change the outcome. row({ sequenceNumber: "10", partCustomFieldsSchemaRef: 7 }), row({ sequenceNumber: "20", partCustomFieldsSchemaRef: 7 }), ], stubCache ); const [part] = payloadsFor(write, ERPObjType.PARTS); expect(part).not.toHaveProperty("customFieldsSchemaRef"); expect(part).not.toHaveProperty("customFields"); }); it("ignores a lone schema ref on the non-deduplicated operation path too", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [row({ opCustomFieldsSchemaRef: 4, opCustomFields: undefined })], stubCache ); // One row is one operation, but a lone ref is still a no-op, as on every other entity. const [operation] = payloadsFor(write, ERPObjType.WORK_ORDER_OPERATIONS); expect(operation).not.toHaveProperty("customFieldsSchemaRef"); expect(operation).not.toHaveProperty("customFields"); }); it("does not attach a later row's lone schema ref to a clear selected by an earlier row", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [ row({ sequenceNumber: "10", workOrderCustomFields: null }), row({ sequenceNumber: "20", workOrderCustomFieldsSchemaRef: 5 }), ], stubCache ); // The row carrying schema 5 supplied no values, so it wins nothing. const [workOrder] = payloadsFor(write, ERPObjType.WORK_ORDERS); expect(workOrder.customFields).toBeNull(); expect(workOrder).not.toHaveProperty("customFieldsSchemaRef"); }); it("resolves deduplicated custom fields as value > null > undefined regardless of row order", async () => { const write = spyWrites(); await StandardProcessDrivers.syncWorkOrderBatchFromFlattened( [ // Optional join produced nothing on the first rows: they must not mask or clear the value. row({ sequenceNumber: "10", partCustomFields: undefined, workOrderCustomFields: null }), row({ sequenceNumber: "20", partCustomFields: null, workOrderCustomFields: { pickList: ["M-1"] } }), row({ sequenceNumber: "30", partCustomFields: { family: "housing" }, workOrderCustomFields: null }), // A different part with nothing but undefined/null rows: null wins over undefined. row({ sequenceNumber: "10", partNumber: "P2", workOrderId: "WO-2", partCustomFields: undefined }), row({ sequenceNumber: "20", partNumber: "P2", workOrderId: "WO-2", partCustomFields: null }), ], stubCache ); expect(payloadsFor(write, ERPObjType.PARTS)).toEqual([ expect.objectContaining({ partNumber: "P1", customFields: { family: "housing" } }), expect.objectContaining({ partNumber: "P2", customFields: null }), ]); const [wo1, wo2] = payloadsFor(write, ERPObjType.WORK_ORDERS); expect(wo1).toEqual( expect.objectContaining({ workOrderId: "WO-1", customFields: { pickList: ["M-1"] } }) ); expect(wo2).toEqual(expect.objectContaining({ workOrderId: "WO-2" })); expect(wo2).not.toHaveProperty("customFields"); }); });