import { describe, it, expect, vi, beforeEach } 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"; const mocks = vi.hoisted(() => ({ sendPartsToMM: vi.fn(), sendWorkOrdersToMM: vi.fn(), sendWorkOrderOperationsToMM: vi.fn(), destroy: vi.fn(), })); vi.mock("../../services/mm-api-service/mm-api-service.js", () => ({ MMApiClient: class { sendPartsToMM = mocks.sendPartsToMM; sendWorkOrdersToMM = mocks.sendWorkOrdersToMM; sendWorkOrderOperationsToMM = mocks.sendWorkOrderOperationsToMM; destroy = mocks.destroy; }, })); import { MMEntityProcessor } from "./mm-entity-processor.js"; import { MMBatchValidationError } from "./standard-process-drivers.js"; import { CoreConfiguration } from "../../services/data-sync-service/configuration-manager.js"; import { ERPObjType } from "../../types/erp-types.js"; import type { IToRESTApiObject } from "../../services/mm-api-service/index.js"; import type { BatchCacheManager } from "../../services/caching-service/batch-cache-manager.js"; function makeRecords(count: number): IToRESTApiObject[] { return Array.from({ length: count }, (_, i) => ({ partId: `part-${i}`, toRESTApiObject: () => ({ partId: `part-${i}` }), })) as unknown as IToRESTApiObject[]; } function success200(affectedRows: number) { return { httpStatus: 200, message: "Parts imported successfully", affectedRows, }; } function partial207(failedRecords: IToRESTApiObject[], affectedRows: number) { return { httpStatus: 207, message: "Parts partially imported with errors", data: { successfulBatches: 1, totalBatches: 2, affectedRows, errors: [ { message: "GraphQL Error: constraint violation", batch: 1, path: "$.x", code: "constraint-violation", batchData: failedRecords, }, ], }, }; } function structured500(failedRecords: IToRESTApiObject[]) { return { status: 500, data: { error: "Failed to import Parts", message: { errors: [ { message: "GraphQL Error: everything failed", batchData: failedRecords, }, ], }, }, }; } function fakeCacheManager() { return { dedupeBatch: vi.fn(async (_type: unknown, records: IToRESTApiObject[]) => ({ nonDuplicates: records, duplicates: [], })), storeBatch: vi.fn(async () => undefined), } as unknown as BatchCacheManager; } describe("MMEntityProcessor.writeEntities chunking", () => { beforeEach(() => { mocks.sendPartsToMM.mockReset(); mocks.sendWorkOrdersToMM.mockReset(); mocks.sendWorkOrderOperationsToMM.mockReset(); mocks.destroy.mockReset(); }); it("forwards options.queryParams to the work-order write", async () => { const records = makeRecords(3); mocks.sendWorkOrdersToMM.mockResolvedValueOnce(success200(3)); await MMEntityProcessor.writeEntities(ERPObjType.WORK_ORDERS, records, null, { queryParams: { clearColumns: "closedDate" }, }); expect(mocks.sendWorkOrdersToMM).toHaveBeenCalledWith(records, { clearColumns: "closedDate", }); }); it("passes undefined queryParams to the work-order write when none are given", async () => { const records = makeRecords(3); mocks.sendWorkOrdersToMM.mockResolvedValueOnce(success200(3)); await MMEntityProcessor.writeEntities(ERPObjType.WORK_ORDERS, records, null); expect(mocks.sendWorkOrdersToMM).toHaveBeenCalledWith(records, undefined); }); it("forwards options.queryParams to the work-order-operation write", async () => { const records = makeRecords(3); mocks.sendWorkOrderOperationsToMM.mockResolvedValueOnce(success200(3)); await MMEntityProcessor.writeEntities( ERPObjType.WORK_ORDER_OPERATIONS, records, null, { queryParams: { clearColumns: "closedDate" } } ); expect(mocks.sendWorkOrderOperationsToMM).toHaveBeenCalledWith(records, { clearColumns: "closedDate", }); }); it("passes undefined queryParams to the work-order-operation write when none are given", async () => { const records = makeRecords(3); mocks.sendWorkOrderOperationsToMM.mockResolvedValueOnce(success200(3)); await MMEntityProcessor.writeEntities( ERPObjType.WORK_ORDER_OPERATIONS, records, null ); expect(mocks.sendWorkOrderOperationsToMM).toHaveBeenCalledWith( records, undefined ); }); it("sends a single request when the record count is within the chunk size", async () => { const records = makeRecords(5); mocks.sendPartsToMM.mockResolvedValueOnce(success200(5)); const result = await MMEntityProcessor.writeEntities( ERPObjType.PARTS, records, null, { chunkSize: 10 } ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(1); expect(mocks.sendPartsToMM).toHaveBeenCalledWith(records); expect(result.upsertedEntities).toBe(5); expect(result.apiDedupeCount).toBe(0); }); it("splits large record sets into sequential chunks and aggregates results", async () => { const records = makeRecords(7); mocks.sendPartsToMM .mockResolvedValueOnce(success200(3)) .mockResolvedValueOnce(success200(2)) // 1 API-deduped .mockResolvedValueOnce(success200(1)); const result = await MMEntityProcessor.writeEntities( ERPObjType.PARTS, records, null, { chunkSize: 3 } ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(3); expect(mocks.sendPartsToMM.mock.calls[0][0]).toEqual(records.slice(0, 3)); expect(mocks.sendPartsToMM.mock.calls[1][0]).toEqual(records.slice(3, 6)); expect(mocks.sendPartsToMM.mock.calls[2][0]).toEqual(records.slice(6)); expect(result.upsertedEntities).toBe(6); expect(result.apiDedupeCount).toBe(1); expect(result.message).toContain("3 chunks"); }); it("disables chunking when chunkSize is 0", async () => { const records = makeRecords(1200); mocks.sendPartsToMM.mockResolvedValueOnce(success200(1200)); const result = await MMEntityProcessor.writeEntities( ERPObjType.PARTS, records, null, { chunkSize: 0 } ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(1); expect(result.upsertedEntities).toBe(1200); }); it("floors a fractional chunkSize to a whole number of records", async () => { const records = makeRecords(7); mocks.sendPartsToMM .mockResolvedValueOnce(success200(2)) .mockResolvedValueOnce(success200(2)) .mockResolvedValueOnce(success200(2)) .mockResolvedValueOnce(success200(1)); const result = await MMEntityProcessor.writeEntities( ERPObjType.PARTS, records, null, { chunkSize: 2.5 } ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(4); expect(mocks.sendPartsToMM.mock.calls[0][0]).toEqual(records.slice(0, 2)); expect(mocks.sendPartsToMM.mock.calls[3][0]).toEqual(records.slice(6)); for (const call of mocks.sendPartsToMM.mock.calls) { expect(call[0].length).toBeGreaterThan(0); } expect(result.upsertedEntities).toBe(7); }); it("disables chunking when a fractional chunkSize floors to 0", async () => { const records = makeRecords(10); mocks.sendPartsToMM.mockResolvedValueOnce(success200(10)); const result = await MMEntityProcessor.writeEntities( ERPObjType.PARTS, records, null, { chunkSize: 0.5 } ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(1); expect(mocks.sendPartsToMM).toHaveBeenCalledWith(records); expect(result.upsertedEntities).toBe(10); }); it("does not chunk by default — connector-layer chunking stays untouched", async () => { // A connector that chunks at its own layer may pass batches of any size; // without an explicit opt-in the SDK must send them through as-is. const records = makeRecords(1001); mocks.sendPartsToMM.mockResolvedValueOnce(success200(1001)); const result = await MMEntityProcessor.writeEntities( ERPObjType.PARTS, records, null ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(1); expect(mocks.sendPartsToMM.mock.calls[0][0]).toHaveLength(1001); expect(result.upsertedEntities).toBe(1001); }); it("chunks when opted in via MM_API_WRITE_CHUNK_SIZE", async () => { const resetConfigSingleton = () => { (CoreConfiguration as unknown as { instance?: CoreConfiguration }).instance = undefined; }; process.env.MM_API_WRITE_CHUNK_SIZE = "3"; resetConfigSingleton(); try { const records = makeRecords(7); mocks.sendPartsToMM .mockResolvedValueOnce(success200(3)) .mockResolvedValueOnce(success200(3)) .mockResolvedValueOnce(success200(1)); const result = await MMEntityProcessor.writeEntities( ERPObjType.PARTS, records, null ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(3); expect(result.upsertedEntities).toBe(7); } finally { delete process.env.MM_API_WRITE_CHUNK_SIZE; resetConfigSingleton(); } }); it("caches each successful chunk immediately", async () => { const records = makeRecords(6); const cache = fakeCacheManager(); mocks.sendPartsToMM .mockResolvedValueOnce(success200(3)) .mockResolvedValueOnce(success200(3)); await MMEntityProcessor.writeEntities(ERPObjType.PARTS, records, cache, { chunkSize: 3, }); expect(cache.storeBatch).toHaveBeenCalledTimes(2); expect(vi.mocked(cache.storeBatch).mock.calls[0][1]).toEqual( records.slice(0, 3) ); expect(vi.mocked(cache.storeBatch).mock.calls[1][1]).toEqual( records.slice(3) ); }); it("continues past a 207 chunk failure and throws one aggregated error", async () => { const records = makeRecords(9); mocks.sendPartsToMM .mockResolvedValueOnce(success200(3)) .mockResolvedValueOnce(partial207(records.slice(4, 6), 1)) // 2 failed, 1 upserted .mockResolvedValueOnce(success200(3)); let caught: MMBatchValidationError | undefined; try { await MMEntityProcessor.writeEntities(ERPObjType.PARTS, records, null, { chunkSize: 3, }); } catch (e) { caught = e as MMBatchValidationError; } expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(3); // chunk 3 still ran expect(caught).toBeInstanceOf(MMBatchValidationError); expect(caught!.httpStatus).toBe(207); expect(caught!.upsertedEntities).toBe(7); expect(caught!.errorCount).toBe(2); expect(caught!.batchErrors).toHaveLength(1); expect(caught!.batchErrors[0].affectedEntities).toHaveLength(2); }); it("aggregates multiple failing chunks into one error", async () => { const records = makeRecords(6); mocks.sendPartsToMM .mockResolvedValueOnce(partial207(records.slice(0, 1), 2)) .mockResolvedValueOnce(partial207(records.slice(3, 5), 1)); let caught: MMBatchValidationError | undefined; try { await MMEntityProcessor.writeEntities(ERPObjType.PARTS, records, null, { chunkSize: 3, }); } catch (e) { caught = e as MMBatchValidationError; } expect(caught).toBeInstanceOf(MMBatchValidationError); expect(caught!.httpStatus).toBe(207); expect(caught!.upsertedEntities).toBe(3); expect(caught!.errorCount).toBe(3); expect(caught!.batchErrors).toHaveLength(2); expect(caught!.message).toContain("2 of 2 chunks"); }); it("reports 500 when every chunk fails completely with a structured 500", async () => { const records = makeRecords(4); mocks.sendPartsToMM .mockRejectedValueOnce(structured500(records.slice(0, 2))) .mockRejectedValueOnce(structured500(records.slice(2))); let caught: MMBatchValidationError | undefined; try { await MMEntityProcessor.writeEntities(ERPObjType.PARTS, records, null, { chunkSize: 2, }); } catch (e) { caught = e as MMBatchValidationError; } expect(caught).toBeInstanceOf(MMBatchValidationError); expect(caught!.httpStatus).toBe(500); expect(caught!.upsertedEntities).toBe(0); expect(caught!.errorCount).toBe(4); }); it("reports 207 when some chunks succeed and others fail with a structured 500", async () => { const records = makeRecords(4); mocks.sendPartsToMM .mockResolvedValueOnce(success200(2)) .mockRejectedValueOnce(structured500(records.slice(2))); let caught: MMBatchValidationError | undefined; try { await MMEntityProcessor.writeEntities(ERPObjType.PARTS, records, null, { chunkSize: 2, }); } catch (e) { caught = e as MMBatchValidationError; } expect(caught).toBeInstanceOf(MMBatchValidationError); expect(caught!.httpStatus).toBe(207); expect(caught!.upsertedEntities).toBe(2); expect(caught!.errorCount).toBe(2); }); it("aborts remaining chunks and rethrows on non-structured errors", async () => { const records = makeRecords(6); const networkError = new Error("ETIMEDOUT"); mocks.sendPartsToMM .mockResolvedValueOnce(success200(2)) .mockRejectedValueOnce(networkError); await expect( MMEntityProcessor.writeEntities(ERPObjType.PARTS, records, null, { chunkSize: 2, }) ).rejects.toThrow("ETIMEDOUT"); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(2); // chunk 3 never sent }); it("includes local dedupe counts in the aggregated error", async () => { const records = makeRecords(6); const cache = fakeCacheManager(); vi.mocked(cache.dedupeBatch).mockResolvedValueOnce({ nonDuplicates: records.slice(2), duplicates: records.slice(0, 2), }); mocks.sendPartsToMM .mockResolvedValueOnce(success200(2)) .mockResolvedValueOnce(partial207(records.slice(4, 5), 1)); let caught: MMBatchValidationError | undefined; try { await MMEntityProcessor.writeEntities(ERPObjType.PARTS, records, cache, { chunkSize: 2, }); } catch (e) { caught = e as MMBatchValidationError; } expect(caught).toBeInstanceOf(MMBatchValidationError); expect(caught!.localDedupeCount).toBe(2); expect(caught!.upsertedEntities).toBe(3); expect(caught!.errorCount).toBe(1); }); });