import { describe, it, expect, vi, beforeEach } from "vitest"; import { MMReceiveTimesheet } from "../../services/mm-api-service/types/receive-types.js"; const api = vi.hoisted(() => ({ getCheckpoint: vi.fn(), fetchTimesheetUpdates: vi.fn(), saveCheckpoint: vi.fn(), destroy: vi.fn(), })); const localState = vi.hoisted(() => new Map()); vi.mock("../../services/mm-api-service/mm-api-service.js", () => ({ MMApiClient: vi.fn(() => api), })); vi.mock("../local-data-store/sdk-runtime-state-db.js", () => ({ getRuntimeStateJson: (key: string) => localState.get(key) ?? null, setRuntimeStateJson: (key: string, value: unknown) => { localState.set(key, JSON.parse(JSON.stringify(value))); }, })); import { TimesheetERPSynchronizer, planTimesheetRetries, parseTimesheetRetries, compareUpdatedAt, TIMESHEET_FAILURE_GIVE_UP_MS, type TimesheetCheckpointValue, } from "./timesheet-erp-synchronizer.js"; const NOW = Date.parse("2026-10-08T12:00:00.000Z"); const MINUTE = 60 * 1000; const HOUR = 60 * MINUTE; const PENDING_KEY = "timesheetExportPendingCheckpoint:erp"; function ts( ref: number, updatedAt: string | null, extra: Partial = {} ) { return new MMReceiveTimesheet({ timesheetRef: ref, personId: `E${ref}`, clockIn: "2026-10-08T06:00:00.000Z", clockOut: null, updatedAt, ...extra, }); } function iso(ms: number) { return new Date(ms).toISOString(); } /** * Fake MM: stores the checkpoint value as saved and returns rows with `updatedAt > timestamp`, * like `/erp/v1/timesheets/updates`. */ const mm = { rows: [] as MMReceiveTimesheet[], checkpoint: null as TimesheetCheckpointValue | null, }; function useFakeMM(checkpoint: TimesheetCheckpointValue | null, rows: MMReceiveTimesheet[] = []) { mm.checkpoint = checkpoint; mm.rows = rows; api.getCheckpoint.mockImplementation(async () => ({ ...(mm.checkpoint ?? {}), httpStatus: 200 })); api.saveCheckpoint.mockImplementation(async ({ checkpointValue }) => { mm.checkpoint = JSON.parse(JSON.stringify(checkpointValue)); return { httpStatus: 200 }; }); api.fetchTimesheetUpdates.mockImplementation(async () => mm.rows .filter( (row) => row.updatedAt && compareUpdatedAt(row.updatedAt, mm.checkpoint!.timestamp) > 0 ) .map((row) => new MMReceiveTimesheet({ ...row })) ); } /** Scripted MM: the n-th fetch returns `responses[n]` regardless of the checkpoint. */ function serveFetches(...responses: MMReceiveTimesheet[][]) { let n = 0; api.fetchTimesheetUpdates.mockImplementation(async () => responses[Math.min(n++, responses.length - 1)] ); } const START = iso(NOW - 10 * HOUR); const sent = (handler: { processTimesheetInERP: ReturnType }) => handler.processTimesheetInERP.mock.calls.map(([t]) => Number(t.timesheetRef)); const failedRefs = () => mm.checkpoint!.failed.map((e) => Number(e.timesheet.timesheetRef)); function failing(...refs: number[]) { const failSet = new Set(refs); return { failSet, handler: { processTimesheetInERP: vi.fn(async (t: MMReceiveTimesheet) => { if (failSet.has(Number(t.timesheetRef))) throw new Error("ERP rejected"); }), }, }; } describe("TimesheetERPSynchronizer.syncToERP", () => { beforeEach(() => { vi.clearAllMocks(); localState.clear(); vi.useFakeTimers(); vi.setSystemTime(NOW); useFakeMM({ timestamp: START, failed: [] }); }); it("checkpoints at the newest MM updatedAt seen, not the host clock, when everything succeeds", async () => { mm.rows = [ts(1, iso(NOW - 3 * HOUR)), ts(2, iso(NOW - 2 * HOUR))]; const handler = { processTimesheetInERP: vi.fn().mockResolvedValue(undefined) }; await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([1, 2]); expect(mm.checkpoint).toEqual({ timestamp: iso(NOW - 2 * HOUR), failed: [] }); expect(api.destroy).toHaveBeenCalled(); }); it("retries a failed row without re-sending rows that already succeeded", async () => { mm.rows = [ ts(1, iso(NOW - 3 * HOUR)), ts(2, iso(NOW - 2 * HOUR), { personId: "BOB" }), ts(3, iso(NOW - 1 * HOUR), { personId: "ALICE" }), ]; const { handler, failSet } = failing(2); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([1, 2, 3]); expect(mm.checkpoint!.timestamp).toBe(iso(NOW - 1 * HOUR)); expect(failedRefs()).toEqual([2]); expect(mm.checkpoint!.failed[0]).toMatchObject({ firstFailedAt: iso(NOW), attempts: 1 }); // Still failing: only Bob's row is attempted, every cycle. for (let cycle = 0; cycle < 5; cycle++) { handler.processTimesheetInERP.mockClear(); vi.setSystemTime(NOW + (cycle + 1) * 5 * MINUTE); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([2]); } expect(mm.checkpoint!.failed[0]).toMatchObject({ firstFailedAt: iso(NOW), attempts: 6 }); failSet.clear(); handler.processTimesheetInERP.mockClear(); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([2]); expect(mm.checkpoint!.failed).toEqual([]); handler.processTimesheetInERP.mockClear(); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([]); }); it("keeps a person's later rows behind their failed row, in order, without holding anyone else", async () => { mm.rows = [ ts(1, iso(NOW - 4 * HOUR), { personId: "BOB" }), ts(2, iso(NOW - 3 * HOUR), { personId: "ALICE" }), ts(3, iso(NOW - 2 * HOUR), { personId: "BOB" }), ts(4, iso(NOW - 1 * HOUR), { personId: "ALICE" }), ]; const { handler, failSet } = failing(1); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([1, 2, 4]); expect(mm.checkpoint!.timestamp).toBe(iso(NOW - 1 * HOUR)); expect(mm.checkpoint!.failed.map((e) => [e.timesheet.timesheetRef, e.attempts, e.firstFailedAt])).toEqual([ [1, 1, iso(NOW)], [3, 0, null], ]); failSet.clear(); handler.processTimesheetInERP.mockClear(); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([1, 3]); expect(mm.checkpoint!.failed).toEqual([]); }); it("sends a changed failed row before the same person's waiting row", async () => { mm.rows = [ ts(1, iso(NOW - 3 * HOUR), { personId: "BOB" }), ts(2, iso(NOW - 2 * HOUR), { personId: "BOB" }), ts(3, iso(NOW - 2 * HOUR), { personId: "ALICE" }), ]; const { handler, failSet } = failing(1); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(failedRefs()).toEqual([1, 2]); // Row 1 is corrected in MM, so it arrives as a fetched row while row 2 is carried. mm.rows[0] = ts(1, iso(NOW - 1 * HOUR), { personId: "BOB", clockOut: iso(NOW - 1 * HOUR) }); failSet.clear(); handler.processTimesheetInERP.mockClear(); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([1, 2]); expect(mm.checkpoint!.failed).toEqual([]); }); it("does not hold a row behind a later row of the same person", async () => { // Row 6 fails, then an edit to the same person's earlier row 5 arrives on a later pass. const row6 = ts(6, iso(NOW - 2 * HOUR), { personId: "BOB" }); const row5 = ts(5, iso(NOW - 1 * HOUR), { personId: "BOB", clockOut: iso(NOW - 1 * HOUR) }); serveFetches([row6], [row6, row5], [row6, row5]); const { handler } = failing(6); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([6, 5]); expect(failedRefs()).toEqual([6]); }); it("keeps a person's row behind their earlier row that is still waiting", async () => { mm.rows = [ ts(1, iso(NOW - 3 * HOUR), { personId: "BOB" }), ts(2, iso(NOW - 2 * HOUR), { personId: "BOB" }), ts(3, iso(NOW - 1 * HOUR), { personId: "BOB" }), ts(4, iso(NOW - 1 * HOUR), { personId: "ALICE" }), ]; const { handler } = failing(1); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([1, 4]); expect(failedRefs()).toEqual([1, 2, 3]); }); it("does not advance or grow the list while the ERP is unreachable", async () => { mm.rows = [ts(1, iso(NOW - 2 * HOUR)), ts(2, iso(NOW - 1 * HOUR))]; const handler = { processTimesheetInERP: vi.fn().mockRejectedValue(new Error("ERP down")) }; await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(mm.checkpoint).toEqual({ timestamp: START, failed: [] }); handler.processTimesheetInERP.mockReset().mockResolvedValue(undefined); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([1, 2]); expect(mm.checkpoint).toEqual({ timestamp: iso(NOW - 1 * HOUR), failed: [] }); }); it("keeps carried rows, counting attempts but never giving up, while the ERP is unreachable", async () => { mm.rows = [ts(1, iso(NOW - 2 * HOUR)), ts(2, iso(NOW - 1 * HOUR))]; const { handler } = failing(1); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(failedRefs()).toEqual([1]); mm.rows.push(ts(3, iso(NOW + 1 * HOUR))); vi.setSystemTime(NOW + 48 * HOUR); handler.processTimesheetInERP.mockReset().mockRejectedValue(new Error("ERP down")); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(mm.checkpoint!.timestamp).toBe(iso(NOW - 1 * HOUR)); expect(mm.checkpoint!.failed.map((e) => [e.timesheet.timesheetRef, e.attempts])).toEqual([[1, 2]]); }); it("measures the give-up window from the first failure, not the row's age in MM", async () => { // A backlog row after a 30h outage: old in MM, but failing for the first time. useFakeMM({ timestamp: iso(NOW - 31 * HOUR), failed: [] }, [ ts(1, iso(NOW - 30 * HOUR)), ts(2, iso(NOW - 1 * HOUR)), ]); const { handler } = failing(1); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(failedRefs()).toEqual([1]); mm.rows.push(ts(3, iso(NOW + 23 * HOUR))); vi.setSystemTime(NOW + 24 * HOUR); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(failedRefs()).toEqual([1]); mm.rows.push(ts(4, iso(NOW + 24 * HOUR))); vi.setSystemTime(NOW + 24 * HOUR + 1); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(mm.checkpoint!.failed).toEqual([]); }); it("starts the give-up window over when the row changes in MM", async () => { mm.rows = [ts(1, iso(NOW - 2 * HOUR)), ts(2, iso(NOW - 1 * HOUR))]; const { handler } = failing(1); await TimesheetERPSynchronizer.syncToERP("erp", handler); vi.setSystemTime(NOW + 20 * HOUR); mm.rows[0] = ts(1, iso(NOW + 19 * HOUR), { clockOut: iso(NOW + 19 * HOUR) }); mm.rows.push(ts(3, iso(NOW + 19 * HOUR + 1))); handler.processTimesheetInERP.mockClear(); await TimesheetERPSynchronizer.syncToERP("erp", handler); // The changed row is attempted once, in its new state, not also as the carried old version. expect(handler.processTimesheetInERP.mock.calls.map(([t]) => [t.timesheetRef, t.clockOut])).toEqual([ [1, iso(NOW + 19 * HOUR)], [3, null], ]); expect(mm.checkpoint!.failed[0]).toMatchObject({ firstFailedAt: iso(NOW + 20 * HOUR), attempts: 1 }); }); it("saves the newest updatedAt with MM's microseconds so that row is not re-delivered next cycle", async () => { mm.rows = [ ts(1, "2026-10-08T10:00:00.123999+00:00"), ts(2, "2026-10-08T10:00:00.123456+00:00"), ts(3, "2026-10-08T09:00:00.5+00:00"), ]; const handler = { processTimesheetInERP: vi.fn().mockResolvedValue(undefined) }; await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(mm.checkpoint!.timestamp).toBe("2026-10-08T10:00:00.123999+00:00"); handler.processTimesheetInERP.mockClear(); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([]); }); it("re-processes a row updated in MM mid-run (clock-out after an attempted clock-in)", async () => { const clockIn = ts(1, iso(NOW - 2 * HOUR)); const clockOut = ts(1, iso(NOW - 1 * HOUR), { clockOut: iso(NOW - 1 * HOUR) }); serveFetches([clockIn], [clockOut], [clockOut]); const handler = { processTimesheetInERP: vi.fn().mockResolvedValue(undefined) }; await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(handler.processTimesheetInERP).toHaveBeenCalledTimes(2); expect(handler.processTimesheetInERP.mock.calls[1][0].clockOut).toBe(iso(NOW - 1 * HOUR)); expect(mm.checkpoint).toEqual({ timestamp: iso(NOW - 1 * HOUR), failed: [] }); }); it("does not carry a failure superseded by a later successful update of the same row", async () => { const first = ts(1, iso(NOW - 2 * HOUR)); const second = ts(1, iso(NOW - 1 * HOUR), { clockOut: iso(NOW - 1 * HOUR) }); serveFetches([first], [second], [second]); const handler = { processTimesheetInERP: vi.fn(async (t: MMReceiveTimesheet) => { if (!t.clockOut) throw new Error("transient"); }), }; await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(mm.checkpoint).toEqual({ timestamp: iso(NOW - 1 * HOUR), failed: [] }); }); it("carries rows still unattempted at the pass limit", async () => { // Each fetch returns one new row, so the run never drains within MAX_PASSES (10). let n = 0; api.fetchTimesheetUpdates.mockImplementation(async () => { n += 1; return Array.from({ length: n }, (_, i) => ts(i + 1, iso(NOW - 100 * HOUR + i * HOUR))); }); const handler = { processTimesheetInERP: vi.fn().mockResolvedValue(undefined) }; await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(handler.processTimesheetInERP).toHaveBeenCalledTimes(10); expect(mm.checkpoint!.timestamp).toBe(iso(NOW - 100 * HOUR + 10 * HOUR)); expect(mm.checkpoint!.failed.map((e) => [e.timesheet.timesheetRef, e.attempts, e.firstFailedAt])).toEqual([ [11, 0, null], ]); }); it("does not save when there is nothing to do", async () => { const handler = { processTimesheetInERP: vi.fn() }; await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(api.saveCheckpoint).not.toHaveBeenCalled(); }); it("initializes the checkpoint at the host clock when MM has none", async () => { useFakeMM(null); const handler = { processTimesheetInERP: vi.fn() }; await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(mm.checkpoint).toEqual({ timestamp: iso(NOW), failed: [] }); }); it("keeps a checkpoint MM rejected locally and saves it before fetching next cycle", async () => { mm.rows = [ts(1, iso(NOW - 2 * HOUR)), ts(2, iso(NOW - 1 * HOUR))]; const { handler } = failing(1); api.saveCheckpoint.mockRejectedValueOnce(new Error("MM down")); await expect(TimesheetERPSynchronizer.syncToERP("erp", handler)).rejects.toThrow("MM down"); expect(mm.checkpoint).toEqual({ timestamp: START, failed: [] }); expect(localState.get(PENDING_KEY)).toMatchObject({ timestamp: iso(NOW - 1 * HOUR) }); handler.processTimesheetInERP.mockClear(); await TimesheetERPSynchronizer.syncToERP("erp", handler); expect(sent(handler)).toEqual([1]); expect(localState.get(PENDING_KEY)).toBeNull(); }); it("skips the cycle when the left-over checkpoint still cannot be saved", async () => { localState.set(PENDING_KEY, { timestamp: iso(NOW - HOUR), failed: [] }); api.saveCheckpoint.mockRejectedValueOnce(new Error("MM down")); const handler = { processTimesheetInERP: vi.fn() }; await expect(TimesheetERPSynchronizer.syncToERP("erp", handler)).rejects.toThrow("MM down"); expect(api.fetchTimesheetUpdates).not.toHaveBeenCalled(); expect(localState.get(PENDING_KEY)).toEqual({ timestamp: iso(NOW - HOUR), failed: [] }); expect(api.destroy).toHaveBeenCalled(); }); it("saves progress before surfacing a fetch error mid-run", async () => { let n = 0; api.fetchTimesheetUpdates.mockImplementation(async () => { if (n++ === 0) return [ts(1, iso(NOW - HOUR))]; throw new Error("MM down"); }); const handler = { processTimesheetInERP: vi.fn().mockResolvedValue(undefined) }; await expect(TimesheetERPSynchronizer.syncToERP("erp", handler)).rejects.toThrow("MM down"); expect(mm.checkpoint).toEqual({ timestamp: iso(NOW - HOUR), failed: [] }); }); it("propagates a first fetch error and still releases the client", async () => { api.fetchTimesheetUpdates.mockRejectedValue(new Error("MM down")); const handler = { processTimesheetInERP: vi.fn() }; await expect(TimesheetERPSynchronizer.syncToERP("erp", handler)).rejects.toThrow("MM down"); expect(api.saveCheckpoint).not.toHaveBeenCalled(); expect(api.destroy).toHaveBeenCalled(); }); it("throws on a malformed retry list instead of dropping rows", async () => { useFakeMM({ timestamp: START, failed: "oops" } as unknown as TimesheetCheckpointValue); const handler = { processTimesheetInERP: vi.fn() }; await expect(TimesheetERPSynchronizer.syncToERP("erp", handler)).rejects.toThrow(/malformed/); expect(handler.processTimesheetInERP).not.toHaveBeenCalled(); }); }); describe("planTimesheetRetries", () => { const failure = (t: MMReceiveTimesheet) => ({ timesheet: t, outcome: "failure" as const }); it("does not give up on a row failing for exactly the limit", () => { const row = ts(1, iso(NOW - HOUR)); const result = planTimesheetRetries({ latestByRef: new Map([["1", failure(row)]]), carried: [{ timesheet: row, firstFailedAt: iso(NOW - TIMESHEET_FAILURE_GIVE_UP_MS), attempts: 3 }], anySucceeded: true, nowMs: NOW, }); expect(result.givenUp).toHaveLength(0); expect(result.failed[0]).toMatchObject({ attempts: 4 }); }); }); describe("parseTimesheetRetries", () => { it("treats a missing list as empty and rebuilds rows as MMReceiveTimesheet", () => { expect(parseTimesheetRetries(undefined)).toEqual([]); const [entry] = parseTimesheetRetries([ { timesheet: { timesheetRef: 7, personId: "E7" }, firstFailedAt: null, attempts: 0 }, ]); expect(entry.timesheet).toBeInstanceOf(MMReceiveTimesheet); }); it("throws on an entry without a timesheetRef", () => { expect(() => parseTimesheetRetries([{ timesheet: { personId: "E7" }, firstFailedAt: null, attempts: 0 }]) ).toThrow(/index 0/); }); }); describe("compareUpdatedAt", () => { it("orders values within the same millisecond by their microseconds", () => { expect(compareUpdatedAt("2026-10-08T10:00:00.123456+00:00", "2026-10-08T10:00:00.123457+00:00")).toBeLessThan(0); expect(compareUpdatedAt("2026-10-08T10:00:00.1235+00:00", "2026-10-08T10:00:00.123456+00:00")).toBeGreaterThan(0); expect(compareUpdatedAt("2026-10-08T10:00:00.123+00:00", "2026-10-08T10:00:00.123Z")).toBe(0); }); it("orders across offsets by instant", () => { expect(compareUpdatedAt("2026-10-08T12:00:00.000001+02:00", "2026-10-08T10:00:00Z")).toBeGreaterThan(0); expect(compareUpdatedAt("2026-10-08T11:59:59.999+02:00", "2026-10-08T10:00:00Z")).toBeLessThan(0); }); });