import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; vi.stubEnv("MM_MAPPING_AUTH_SERVICE_URL", "https://api.example.test"); vi.stubEnv("MM_MAPPING_SERVICE_URL", "https://erp-api.example.test"); vi.stubEnv("MM_MAPPING_SERVICE_TOKEN", "st-test-token"); vi.mock("../../utils/local-data-store/jobs-shared-data.js", () => ({ getCachedMMToken: () => null, setCachedMMToken: () => undefined, })); type ClientModule = typeof import("./mm-api-service.js"); type SendModule = typeof import("./types/send-types.js"); type LoggerModule = typeof import("../reporting-service/logger.js"); let MMApiClient: ClientModule["MMApiClient"]; let MMSendDispatchPriority: SendModule["MMSendDispatchPriority"]; let sdkLogger: LoggerModule["sdkLogger"]; beforeAll(async () => { ({ MMApiClient } = await import("./mm-api-service.js")); ({ MMSendDispatchPriority } = await import("./types/send-types.js")); ({ sdkLogger } = await import("../reporting-service/logger.js")); }); const send = ( authorityOperationId: string, overrides: Partial<{ workOrderId: string; sequenceNumber: string | number; sub: string; priority: number; scopeResourceId: string; }> = {} ) => new MMSendDispatchPriority( authorityOperationId, overrides.scopeResourceId ?? "", overrides.workOrderId ?? "JR-1", overrides.sequenceNumber ?? 10, "0", "0", overrides.sub ?? "0", overrides.priority ?? 1 ); function clientWithGraphql( responses: | unknown[] | (( op: string, query: string, variables?: Record ) => unknown) ) { const client = new MMApiClient(); const spy = vi.fn( async (op: string, query: string, variables?: Record) => { if (typeof responses === "function") return responses(op, query, variables); if (responses.length === 0) throw new Error("unexpected graphqlRequest call"); return responses.shift(); } ); (client as unknown as { graphqlRequest: typeof spy }).graphqlRequest = spy; return { client, spy }; } describe("MMApiClient.replaceDispatchPriorityScope", () => { beforeEach(() => vi.clearAllMocks()); it("sends one delete+insert document with the scope stamped on every object and reports counts", async () => { const { client, spy } = clientWithGraphql([ { data: { deleted: { affected_rows: 4 }, inserted: { affected_rows: 2 } }, }, ]); const result = await client.replaceDispatchPriorityScope(" CELL-A ", [ send("PFM-1", { priority: 1 }), send("PFM-2", { sub: "1", priority: 2, scopeResourceId: "CELL-A" }), ]); expect(spy).toHaveBeenCalledTimes(1); const [operationName, query, variables] = spy.mock.calls[0]; expect(operationName).toBe("replaceDispatchPriorityScope"); expect(query).toContain("deleteErpWorkOrderOperationDispatchPriorities"); expect(query).toContain( "insertErpWorkOrderOperationDispatchPriorities(objects: $objects)" ); expect(variables?.scope).toBe("CELL-A"); const objects = variables?.objects as Record[]; expect(objects).toHaveLength(2); expect(objects.every((o) => o.scopeResourceId === "CELL-A")).toBe(true); expect(objects[1]).toMatchObject({ authorityOperationId: "PFM-2", sequenceNumber: 10, sub: "1", priority: 2, }); expect(result).toEqual({ scopeResourceId: "CELL-A", deletedRows: 4, insertedRows: 2, droppedDuplicates: [], }); }); it("sends only the better-ranked of two rows naming one operation", async () => { const { client, spy } = clientWithGraphql([ { data: { deleted: { affected_rows: 0 }, inserted: { affected_rows: 1 } }, }, ]); const first = send("PFM-1"); const dup = send("PFM-9", { sequenceNumber: "010" }); const result = await client.replaceDispatchPriorityScope("CELL-A", [ first, dup, ]); const objects = spy.mock.calls[0][2]?.objects as Record[]; expect(objects.map((o) => o.authorityOperationId)).toEqual(["PFM-1"]); expect(result.droppedDuplicates).toHaveLength(1); expect(result.droppedDuplicates[0]).toMatchObject({ row: dup, duplicateOf: first, index: 1, }); }); it("treats two rows for one operation whose ranks MM cannot tell apart as a tie — earlier stands, scope succeeds", async () => { // Same operation, priorities that collapse in float32. Dedupe compares ranks as MM stores // them, so this is a tie and the earlier row is kept; the rank check runs on the one // survivor and passes. Before, the double-precision compare picked the lower double. const { client, spy } = clientWithGraphql([ { data: { deleted: { affected_rows: 0 }, inserted: { affected_rows: 1 } }, }, ]); const first = send("FIRST", { priority: 1700000000001 }); const second = send("SECOND", { priority: 1700000000000 }); const result = await client.replaceDispatchPriorityScope("CELL-A", [ first, second, ]); expect(spy).toHaveBeenCalledTimes(1); expect(result.insertedRows).toBe(1); expect( result.droppedDuplicates.map((d) => d.row.authorityOperationId) ).toEqual(["SECOND"]); }); it("accepts a null filter as 'everything', like the undefined default", async () => { const { client, spy } = clientWithGraphql([{ data: { rows: [] } }]); await expect( client.fetchDispatchPrioritiesFromGraphQL(null as unknown as undefined) ).resolves.toEqual([]); expect(spy.mock.calls[0][2]).toMatchObject({ where: {} }); }); it("sends colliding ranks in the authority's rank order, rather than failing the scope", async () => { // Two DIFFERENT operations whose ranks MM stores as one value. Failing would leave the // scope's previous list standing; sending in rank order lets MM's ref tie-break keep the // authority's order. Passed deliberately backwards. const { client, spy } = clientWithGraphql([ { data: { deleted: { affected_rows: 0 }, inserted: { affected_rows: 2 } }, }, ]); const later = send("LATER", { priority: 1700000000001 }); const earlier = send("EARLIER", { sub: "1", priority: 1700000000000 }); const result = await client.replaceDispatchPriorityScope("CELL-A", [ later, earlier, ]); expect(result.insertedRows).toBe(2); const objects = spy.mock.calls[0][2]?.objects as Record[]; expect(objects.map((o) => o.authorityOperationId)).toEqual([ "EARLIER", "LATER", ]); }); it("sends rows in rank order whatever order the caller passed them in", async () => { const { client, spy } = clientWithGraphql([ { data: { deleted: { affected_rows: 0 }, inserted: { affected_rows: 3 } }, }, ]); await client.replaceDispatchPriorityScope("CELL-A", [ send("THIRD", { priority: 3 }), send("FIRST", { sub: "1", priority: 1 }), send("SECOND", { sub: "2", priority: 2 }), ]); const objects = spy.mock.calls[0][2]?.objects as Record[]; expect(objects.map((o) => o.authorityOperationId)).toEqual([ "FIRST", "SECOND", "THIRD", ]); }); it("reports collapsed ranks as one warning with a capped sample, not one per collapse", async () => { // A scope ranked by epoch milliseconds collapses on nearly every row: 300 rows would be // 299 warnings per replacement if each were logged on its own line. const warn = vi.spyOn(sdkLogger, "warn").mockImplementation(() => sdkLogger); const { client } = clientWithGraphql([ { data: { deleted: { affected_rows: 0 }, inserted: { affected_rows: 300 } }, }, ]); const rows = Array.from({ length: 300 }, (_, i) => send(`PFM-${i}`, { sub: String(i), priority: 1700000000000 + i }) ); await client.replaceDispatchPriorityScope("CELL-A", rows); const collapsed = warn.mock.calls.filter((c) => String(c[0]).includes("land on a value MM already stores") ); expect(collapsed).toHaveLength(1); expect(collapsed[0][0]).toContain("299 rank(s)"); expect(collapsed[0][1]).toMatchObject({ scopeResourceId: "CELL-A", collapsedRanks: 299, }); expect( (collapsed[0][1] as { examples: string[] }).examples ).toHaveLength(5); warn.mockRestore(); }); it("clears the scope through the same replace document with an empty objects list", async () => { const { client, spy } = clientWithGraphql([ { data: { deleted: { affected_rows: 3 }, inserted: { affected_rows: 0 } }, }, ]); const result = await client.replaceDispatchPriorityScope("CELL-A", []); expect(spy).toHaveBeenCalledTimes(1); expect(spy.mock.calls[0][0]).toBe("replaceDispatchPriorityScope"); expect(spy.mock.calls[0][2]).toEqual({ scope: "CELL-A", objects: [] }); expect(result).toEqual({ scopeResourceId: "CELL-A", deletedRows: 3, insertedRows: 0, droppedDuplicates: [], }); }); it("does not read a null affected_rows as zero", async () => { const { client } = clientWithGraphql([ { data: { deleted: { affected_rows: null }, inserted: { affected_rows: null }, }, }, ]); await expect( client.replaceDispatchPriorityScope("CELL-A", [send("A")]) ).rejects.toThrow( /data\.deleted\.affected_rows is missing or not a number/ ); }); it("validates before any request: empty scope, missing authority id, foreign scope", async () => { const { client, spy } = clientWithGraphql([]); await expect( client.replaceDispatchPriorityScope(" ", [send("A")]) ).rejects.toThrow(/scopeResourceId is required/); await expect( client.replaceDispatchPriorityScope("CELL-A", [send("")]) ).rejects.toThrow(/authorityOperationId is required/); await expect( client.replaceDispatchPriorityScope("CELL-A", [ send("A", { scopeResourceId: "CELL-B" }), ]) ).rejects.toThrow(/belongs to scope "CELL-B"/); expect(spy).not.toHaveBeenCalled(); }); it("treats a response without affected_rows as malformed", async () => { const { client } = clientWithGraphql([ { data: { deleted: { affected_rows: 1 } } }, ]); await expect( client.replaceDispatchPriorityScope("CELL-A", [send("A")]) ).rejects.toThrow( /data\.inserted\.affected_rows is missing or not a number/ ); }); it("propagates a GraphQL rejection (the document is atomic, nothing was written)", async () => { const { client } = clientWithGraphql(() => { throw new Error("Uniqueness violation"); }); await expect( client.replaceDispatchPriorityScope("CELL-A", [send("A")]) ).rejects.toThrow(/Uniqueness violation/); }); }); describe("MMApiClient.fetchDispatchPriorityScopes", () => { it("pages the distinct scopes and returns them in order", async () => { const { client, spy } = clientWithGraphql([ { data: { rows: [{ scopeResourceId: "CELL-A" }, { scopeResourceId: "CELL-B" }], }, }, { data: { rows: [{ scopeResourceId: "CELL-C" }] } }, ]); await expect( client.fetchDispatchPriorityScopes({ pageSize: 2 }) ).resolves.toEqual(["CELL-A", "CELL-B", "CELL-C"]); expect(spy).toHaveBeenCalledTimes(2); expect(spy.mock.calls[0][2]).toEqual({ limit: 2, offset: 0 }); expect(spy.mock.calls[1][2]).toEqual({ limit: 2, offset: 2 }); }); it("throws on a malformed page rather than reporting fewer scopes", async () => { const { client } = clientWithGraphql([{ data: {} }]); await expect(client.fetchDispatchPriorityScopes()).rejects.toThrow( /malformed GraphQL response at offset 0/ ); }); it("returns scopes exactly as MM holds them, whitespace included, so each can be removed verbatim", async () => { const { client } = clientWithGraphql([ { data: { rows: [ { scopeResourceId: "CELL-A" }, { scopeResourceId: " CELL-B " }, ], }, }, ]); await expect(client.fetchDispatchPriorityScopes()).resolves.toEqual([ "CELL-A", " CELL-B ", ]); }); it("throws on a row without a scope rather than skipping it", async () => { const { client } = clientWithGraphql([ { data: { rows: [{ scopeResourceId: "CELL-A" }, { scopeResourceId: "" }], }, }, ]); await expect(client.fetchDispatchPriorityScopes()).rejects.toThrow( /has no scopeResourceId/ ); }); }); describe("MMApiClient.removeDispatchPriorityScope", () => { it("deletes by scope and returns the count", async () => { const { client, spy } = clientWithGraphql([ { data: { deleted: { affected_rows: 2 } } }, ]); await expect(client.removeDispatchPriorityScope("CELL-A")).resolves.toEqual( { scopeResourceId: "CELL-A", deletedRows: 2 } ); expect(spy.mock.calls[0][2]).toEqual({ scope: "CELL-A" }); }); it("addresses the scope verbatim — a padded scope removes nothing rather than a different scope", async () => { const { client, spy } = clientWithGraphql([ { data: { deleted: { affected_rows: 0 } } }, ]); await expect( client.removeDispatchPriorityScope(" CELL-A ") ).resolves.toEqual({ scopeResourceId: " CELL-A ", deletedRows: 0, }); expect(spy.mock.calls[0][2]).toEqual({ scope: " CELL-A " }); }); it("rejects an empty scope without a request", async () => { const { client, spy } = clientWithGraphql([]); await expect(client.removeDispatchPriorityScope("")).rejects.toThrow( /scopeResourceId is required/ ); expect(spy).not.toHaveBeenCalled(); }); }); const dispatchRow = (ref: number, extra: Record = {}) => ({ dispatchPriorityRef: ref, companyId: "c1", authorityOperationId: `PFM-${ref}`, scopeResourceId: "CELL-A", workOrderId: "JR-1", sequenceNumber: 10, lot: "0", split: "0", sub: "0", preallocatedMachineResourceId: null, priority: ref, createdAt: "t", updatedAt: "t", customFieldsSchemaRef: null, customFields: null, ...extra, }); describe("MMApiClient.fetchDispatchPrioritiesFromGraphQL", () => { it("pages until a short page and passes the filter as a where variable", async () => { const { client, spy } = clientWithGraphql([ { data: { rows: [dispatchRow(1), dispatchRow(2)] } }, { data: { rows: [dispatchRow(3)] } }, ]); const rows = await client.fetchDispatchPrioritiesFromGraphQL({ scopeResourceId: "CELL-A", unresolvedOnly: true, pageSize: 2, }); expect(rows.map((r) => r.dispatchPriorityRef)).toEqual([1, 2, 3]); expect(rows[0].sequenceNumber).toBe("10"); expect(spy).toHaveBeenCalledTimes(2); expect(spy.mock.calls[0][2]).toEqual({ where: { scopeResourceId: { _in: ["CELL-A"] }, _not: { workOrderOperation: {} }, }, limit: 2, offset: 0, }); expect(spy.mock.calls[1][2]).toMatchObject({ offset: 2 }); }); it("returns [] without a request when a list filter is empty after trimming", async () => { const { client, spy } = clientWithGraphql([]); await expect( client.fetchDispatchPrioritiesFromGraphQL({ scopeResourceIds: [" "] }) ).resolves.toEqual([]); expect(spy).not.toHaveBeenCalled(); }); it("throws on a malformed page instead of returning a truncated list", async () => { const { client } = clientWithGraphql([{ data: {} }]); await expect(client.fetchDispatchPrioritiesFromGraphQL()).rejects.toThrow( /malformed GraphQL response at offset 0/ ); }); }); const wooRow = (workOrderId: string, sequenceNumber: number, sub = "0") => ({ companyId: "c1", workOrderId, lot: "0", split: "0", sub, sequenceNumber, resourceId: "RES", startQuantity: "5", finishQuantity: null, expectedRejectRate: null, scheduledStartDate: null, scheduledFinishDate: null, closedDate: null, cycleTimeMs: null, setupTimeMs: null, productionburdenRateHourly: null, setupburdenRatehourly: null, operationType: "", quantityPerPart: null, status: "RELEASED", createdAt: "t", updatedAt: "t", customFieldsSchemaRef: null, customFields: null, }); describe("MMApiClient.fetchErpWorkOrderOperationsByWorkOrderIdsFromGraphQL", () => { it("chunks the id list, pages each chunk, and maps rows like the fetch-all method", async () => { const { client, spy } = clientWithGraphql((_op, _query, variables) => { const ids = variables?.ids as string[]; const offset = variables?.offset as number; if (ids.join(",") === "JR-1,JR-2") { return offset === 0 ? { data: { workOrderOperations: [ wooRow("JR-1", 10), wooRow("JR-1", 10, "1"), ], }, } : { data: { workOrderOperations: [wooRow("JR-2", 20)] } }; } return { data: { workOrderOperations: [wooRow("JR-3", 30)] } }; }); const rows = await client.fetchErpWorkOrderOperationsByWorkOrderIdsFromGraphQL( [" JR-1", "JR-2", "JR-1", "", "JR-3"], { chunkSize: 2, pageSize: 2 } ); expect(spy).toHaveBeenCalledTimes(3); expect(spy.mock.calls[0][0]).toBe( "getErpWorkOrderOperationsByWorkOrderIds" ); expect(spy.mock.calls[0][2]).toEqual({ ids: ["JR-1", "JR-2"], limit: 2, offset: 0, }); expect(spy.mock.calls[1][2]).toEqual({ ids: ["JR-1", "JR-2"], limit: 2, offset: 2, }); expect(spy.mock.calls[2][2]).toEqual({ ids: ["JR-3"], limit: 2, offset: 0, }); expect( rows.map((r) => `${r.workOrderId}/${r.sub}/${r.sequenceNumber}`) ).toEqual(["JR-1/0/10", "JR-1/1/10", "JR-2/0/20", "JR-3/0/30"]); expect(rows[0].startQuantity).toBe(5); expect(rows[0].finishQuantity).toBeNull(); }); it("returns [] without a request for an empty or blank id list", async () => { const { client, spy } = clientWithGraphql([]); await expect( client.fetchErpWorkOrderOperationsByWorkOrderIdsFromGraphQL([]) ).resolves.toEqual([]); await expect( client.fetchErpWorkOrderOperationsByWorkOrderIdsFromGraphQL(["", " "]) ).resolves.toEqual([]); expect(spy).not.toHaveBeenCalled(); }); it("throws on a malformed page", async () => { const { client } = clientWithGraphql([ { data: { workOrderOperations: null } }, ]); await expect( client.fetchErpWorkOrderOperationsByWorkOrderIdsFromGraphQL(["JR-1"]) ).rejects.toThrow(/malformed GraphQL response at offset 0/); }); });