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(), destroy: vi.fn(), })); vi.mock("../../services/mm-api-service/mm-api-service.js", () => ({ MMApiClient: class { sendPartsToMM = mocks.sendPartsToMM; sendWorkOrdersToMM = mocks.sendWorkOrdersToMM; destroy = mocks.destroy; }, })); import { StandardProcessDrivers, MMBatchValidationError, } from "./standard-process-drivers.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) => ({ partNumber: `part-${i}`, toRESTApiObject: () => ({ partNumber: `part-${i}`, partRevision: "A", method: "M", }), })) as unknown as IToRESTApiObject[]; } function makeWorkOrders(count: number): IToRESTApiObject[] { return Array.from({ length: count }, (_, i) => ({ workOrderId: `wo-${i}`, toRESTApiObject: () => ({ workOrderId: `wo-${i}`, lot: "", split: "", sub: "", closedDate: null, }), })) 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 fakeCacheManager() { return { dedupeBatch: vi.fn(async (_type: unknown, records: IToRESTApiObject[]) => ({ nonDuplicates: records, duplicates: [], })), storeBatch: vi.fn(async () => undefined), } as unknown as BatchCacheManager; } function batchValidationError( affectedEntities: IToRESTApiObject[] ): MMBatchValidationError { return new MMBatchValidationError({ message: "Parts partially imported with errors", upsertedEntities: 5, localDedupeCount: 0, apiDedupeCount: 0, errorCount: affectedEntities.length, httpStatus: 207, batchErrors: [ { message: "GraphQL Error: constraint violation", affectedEntities, }, ], }); } describe("StandardProcessDrivers.retryRejectedIndividually", () => { beforeEach(() => { mocks.sendPartsToMM.mockReset(); mocks.sendWorkOrdersToMM.mockReset(); mocks.destroy.mockReset(); }); it("forwards options.queryParams to each individual retry write so clearColumns survives recovery", async () => { const records = makeWorkOrders(2); const error = batchValidationError(records); mocks.sendWorkOrdersToMM .mockResolvedValueOnce(success200(1)) .mockResolvedValueOnce(success200(1)); await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.WORK_ORDERS, error, records, null, { queryParams: { clearColumns: "closedDate" } } ); expect(mocks.sendWorkOrdersToMM).toHaveBeenCalledTimes(2); for (const call of mocks.sendWorkOrdersToMM.mock.calls) { expect(call[1]).toEqual({ clearColumns: "closedDate" }); } }); it("retries each affected entity alone and separates collateral successes from residual failures", async () => { const records = makeRecords(3); const error = batchValidationError(records); mocks.sendPartsToMM .mockResolvedValueOnce(success200(1)) // part-0: collateral, succeeds alone .mockResolvedValueOnce(partial207([records[1]], 0)) // part-1: genuinely invalid .mockResolvedValueOnce(success200(1)); // part-2: collateral, succeeds alone const result = await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, records, null ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(3); expect(mocks.sendPartsToMM.mock.calls.map((c) => c[0])).toEqual([ [records[0]], [records[1]], [records[2]], ]); expect(result.succeeded).toEqual([records[0], records[2]]); expect(result.failed).toEqual([ { entity: records[1], message: "GraphQL Error: constraint violation" }, ]); }); it("returns an empty result without any API calls when the error carries no entities", async () => { const error = batchValidationError([]); const result = await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, [], null ); expect(mocks.sendPartsToMM).not.toHaveBeenCalled(); expect(result).toEqual({ succeeded: [], failed: [] }); }); it("caches entities that succeed on individual retry", async () => { const records = makeRecords(2); const error = batchValidationError(records); const cache = fakeCacheManager(); mocks.sendPartsToMM .mockResolvedValueOnce(success200(1)) .mockResolvedValueOnce(partial207([records[1]], 0)); await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, records, cache ); expect(vi.mocked(cache.storeBatch)).toHaveBeenCalledTimes(1); expect(vi.mocked(cache.storeBatch).mock.calls[0][1]).toEqual([records[0]]); }); it("rethrows non-validation errors instead of classifying them as residual failures", async () => { const records = makeRecords(2); const error = batchValidationError(records); mocks.sendPartsToMM .mockResolvedValueOnce(success200(1)) .mockRejectedValueOnce(new Error("connect ECONNREFUSED")); await expect( StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, records, null ) ).rejects.toThrow("connect ECONNREFUSED"); }); it("joins every validation message when a single-record retry reports multiple batch errors", async () => { const records = makeRecords(1); const error = batchValidationError(records); mocks.sendPartsToMM.mockResolvedValueOnce({ httpStatus: 207, message: "Parts partially imported with errors", data: { successfulBatches: 0, totalBatches: 1, affectedRows: 0, errors: [ { message: "missing part number", batch: 1, path: "$.x", code: "validation", batchData: [records[0]], }, { message: "invalid unit of measure", batch: 1, path: "$.y", code: "validation", batchData: [records[0]], }, ], }, }); const result = await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, records, null ); expect(result.failed).toEqual([ { entity: records[0], message: "missing part number; invalid unit of measure", }, ]); }); it("collects entities across multiple failed sub-batches", async () => { const records = makeRecords(3); const error = new MMBatchValidationError({ message: "Parts partially imported with errors", upsertedEntities: 0, localDedupeCount: 0, apiDedupeCount: 0, errorCount: 3, httpStatus: 207, batchErrors: [ { message: "sub-batch 1 failed", affectedEntities: [records[0]] }, { message: "sub-batch 2 failed", affectedEntities: [records[1], records[2]], }, ], }); mocks.sendPartsToMM.mockResolvedValue(success200(1)); const result = await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, records, null ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(3); expect(result.succeeded).toEqual(records); expect(result.failed).toEqual([]); }); it("retries an entity only once when it appears under multiple batchErrors entries", async () => { // MM reports one errors[] entry per validation message, each echoing the whole // sub-batch, so the same entity arrives as distinct reconstructions of equal key. const makePart = (partNumber: string): IToRESTApiObject => ({ toRESTApiObject: () => ({ partNumber, partRevision: "A", method: "M" }), }) as unknown as IToRESTApiObject; const first = makePart("part-dup"); const second = makePart("part-dup"); const other = makePart("part-other"); const error = new MMBatchValidationError({ message: "Parts partially imported with errors", upsertedEntities: 0, localDedupeCount: 0, apiDedupeCount: 0, errorCount: 4, httpStatus: 207, batchErrors: [ { message: "missing part number", affectedEntities: [first, other] }, { message: "invalid unit of measure", affectedEntities: [second, other] }, ], }); mocks.sendPartsToMM.mockResolvedValue(success200(1)); const result = await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, [first, other], null ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(2); expect(result.succeeded).toEqual([first, other]); expect(result.failed).toEqual([]); }); it("retries the caller's original object rather than the API's reconstruction", async () => { // Reconstructions default fields absent from the API's echo; re-sending one // could overwrite real MM values, so the matched original must be sent instead. const original = { toRESTApiObject: () => ({ partNumber: "part-0", partRevision: "A", method: "M", description: "real description", }), } as unknown as IToRESTApiObject; const reconstruction = { toRESTApiObject: () => ({ partNumber: "part-0", partRevision: "A", method: "M", description: "", }), } as unknown as IToRESTApiObject; const error = batchValidationError([reconstruction]); mocks.sendPartsToMM.mockResolvedValueOnce(success200(1)); const result = await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, [original], null ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(1); expect(mocks.sendPartsToMM.mock.calls[0][0]).toEqual([original]); expect(result.succeeded).toEqual([original]); expect(result.failed).toEqual([]); }); it("fails without retrying an entity that matches no original record", async () => { const records = makeRecords(2); const error = batchValidationError(records); mocks.sendPartsToMM.mockResolvedValueOnce(success200(1)); const result = await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, [records[0]], null ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(1); expect(mocks.sendPartsToMM.mock.calls[0][0]).toEqual([records[0]]); expect(result.succeeded).toEqual([records[0]]); expect(result.failed).toEqual([ { entity: records[1], message: "not retried: no record in originalRecords matches this rejected entity's primary key (rejected with: GraphQL Error: constraint violation)", }, ]); }); it("dedupes keyless entities by serialized payload and fails them unmatched", async () => { // No partNumber, so key extraction throws: the dedupe falls back to the // serialized REST payload, and a keyless entity can never match an original. const keyless = { toRESTApiObject: () => ({ partId: "part-0" }), } as unknown as IToRESTApiObject; const error = new MMBatchValidationError({ message: "Parts partially imported with errors", upsertedEntities: 0, localDedupeCount: 0, apiDedupeCount: 0, errorCount: 2, httpStatus: 207, batchErrors: [ { message: "missing part number", affectedEntities: [keyless] }, { message: "invalid unit of measure", affectedEntities: [keyless] }, ], }); const result = await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, [keyless], null ); expect(mocks.sendPartsToMM).not.toHaveBeenCalled(); expect(result.succeeded).toEqual([]); expect(result.failed).toEqual([ { entity: keyless, message: "not retried: no record in originalRecords matches this rejected entity's primary key (rejected with: missing part number; invalid unit of measure)", }, ]); }); it("retries distinct records that share a primary key instead of collapsing them", async () => { // Two partial-patch-style records can legitimately carry the same primary key // with different payloads; both must be retried, each exactly once. const makeSameKeyPart = (description: string): IToRESTApiObject => ({ toRESTApiObject: () => ({ partNumber: "part-0", partRevision: "A", method: "M", description, }), }) as unknown as IToRESTApiObject; const first = makeSameKeyPart("first payload"); const second = makeSameKeyPart("second payload"); const error = batchValidationError([first, second]); mocks.sendPartsToMM.mockResolvedValue(success200(1)); const result = await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, [first, second], null ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(2); expect(result.succeeded).toEqual([first, second]); expect(result.failed).toEqual([]); }); it("sends every retry to MM even when the cache would dedupe it, then stores the success", async () => { // A stale hash in the shared cache must not turn a rejected record into a // silent no-op "success": the dedupe read is bypassed, the write goes to MM, // and the success is stored afterwards. const records = makeRecords(1); const error = batchValidationError(records); const cache = fakeCacheManager(); vi.mocked(cache.dedupeBatch).mockResolvedValue({ nonDuplicates: [], duplicates: records, }); mocks.sendPartsToMM.mockResolvedValueOnce(success200(1)); const result = await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, records, cache ); expect(vi.mocked(cache.dedupeBatch)).not.toHaveBeenCalled(); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(1); expect(vi.mocked(cache.storeBatch)).toHaveBeenCalledTimes(1); expect(vi.mocked(cache.storeBatch).mock.calls[0][1]).toEqual([records[0]]); expect(result.succeeded).toEqual([records[0]]); }); it("reports an entity whose serialization throws instead of aborting the loop", async () => { const broken = { toRESTApiObject: () => { throw new TypeError("cannot serialize"); }, } as unknown as IToRESTApiObject; const good = makeRecords(1); const error = batchValidationError([broken, good[0]]); mocks.sendPartsToMM.mockResolvedValueOnce(success200(1)); const result = await StandardProcessDrivers.retryRejectedIndividually( ERPObjType.PARTS, error, good, null ); expect(mocks.sendPartsToMM).toHaveBeenCalledTimes(1); expect(result.succeeded).toEqual([good[0]]); expect(result.failed).toEqual([ { entity: broken, message: "not retried: entity could not be serialized (cannot serialize)", }, ]); }); });