import { describe, expect, it, vi } from "vitest"; import { correctMissedClockOut, ClockCorrectionError } from "./clock-correction"; const oldIn = { id: "oldin", employee: "emp1", site: "site1", direction: "in", scanned_at: "2026-09-23T14:00:00Z" }; // 16:00 SAST const newIn = { id: "newin", employee: "emp1", site: "site1", direction: "in", scanned_at: "2026-09-25T06:00:00Z" }; function fixture(options: { direct?: Array>; corrections?: Array> } = {}) { const created: Array<{ collection: string; data: Record }> = []; const updated: Array<{ collection: string; id: string; data: Record }> = []; const lockDelete = vi.fn().mockResolvedValue(undefined); const entriesGetFullList = vi.fn().mockResolvedValue(options.direct ?? []); const send = vi.fn().mockResolvedValue([{ status: 200, body: { id: "entry1" } }, { status: 200, body: { id: "corr1" } }]); const pb = { collection(name: string) { if (name === "clock_correction_locks") return { create: vi.fn().mockResolvedValue({ id: "lock1" }), delete: lockDelete, }; if (name === "clock_events") return { getOne: vi.fn().mockResolvedValue(oldIn), getFullList: vi.fn().mockResolvedValue([newIn, oldIn]), }; if (name === "clock_missed_out_corrections") return { getFullList: vi.fn().mockResolvedValue(options.corrections ?? []), }; if (name === "timesheet_entries") return { getFullList: entriesGetFullList, }; throw new Error(`Unexpected collection ${name}`); }, createBatch() { return { collection(name: string) { return { create(data: Record) { created.push({ collection: name, data }); }, update(id: string, data: Record) { updated.push({ collection: name, id, data }); }, }; }, send, }; }, }; return { pb, created, updated, send, lockDelete, entriesGetFullList }; } describe("missed scan-out correction", () => { const now = new Date("2026-09-25T08:00:00Z"); it("records actual time and reason once, adding hours to a sole direct row", async () => { const { pb, created, updated, send, lockDelete, entriesGetFullList } = fixture({ direct: [{ id: "entry1", hours_worked: 9.24, timesheet: "" }], }); const result = await correctMissedClockOut(pb as never, "oldin", { actualOutLocal: "2026-09-23T17:00", reason: "Supervisor confirmed missed exit scan", now, }); expect(result.sessionHours).toBe(1); expect(updated).toContainEqual({ collection: "timesheet_entries", id: "entry1", data: { hours_worked: 10.24 } }); expect(entriesGetFullList).toHaveBeenCalledWith(expect.objectContaining({ filter: expect.stringContaining('date_logged="2026-09-23 00:00:00.000Z"'), })); expect(created.find((row) => row.collection === "clock_missed_out_corrections")?.data) .toMatchObject({ clock_in_event: "oldin", reason: "Supervisor confirmed missed exit scan", timesheet_entry: "entry1" }); expect(send).toHaveBeenCalledOnce(); expect(lockDelete).toHaveBeenCalledWith("lock1"); }); it("rejects an out after the next IN without writing", async () => { const { pb, send } = fixture(); await expect(correctMissedClockOut(pb as never, "oldin", { actualOutLocal: "2026-09-25T09:00", reason: "Missed exit scan", now, })).rejects.toMatchObject({ status: 409 }); expect(send).not.toHaveBeenCalled(); }); it("rejects a duplicate correction and leaves imported time untouched", async () => { const { pb, send } = fixture({ corrections: [{ clock_in_event: "oldin" }] }); await expect(correctMissedClockOut(pb as never, "oldin", { actualOutLocal: "2026-09-23T17:00", reason: "Missed exit scan", now, })).rejects.toBeInstanceOf(ClockCorrectionError); expect(send).not.toHaveBeenCalled(); }); it("rejects missing reason or duplicate direct totals", async () => { const { pb, send } = fixture({ direct: [{ id: "one" }, { id: "two" }] }); await expect(correctMissedClockOut(pb as never, "oldin", { actualOutLocal: "2026-09-23T17:00", reason: "", now, })).rejects.toMatchObject({ status: 400 }); await expect(correctMissedClockOut(pb as never, "oldin", { actualOutLocal: "2026-09-23T17:00", reason: "Missed exit scan", now, })).rejects.toMatchObject({ status: 409 }); expect(send).not.toHaveBeenCalled(); }); it("creates a separate direct entry when the only existing row came from an imported sheet", async () => { const { pb, created, updated } = fixture({ direct: [{ id: "import1", hours_worked: 8, timesheet: "uploaded-sheet" }], }); await correctMissedClockOut(pb as never, "oldin", { actualOutLocal: "2026-09-23T17:00", reason: "Missed exit scan", now, }); expect(updated).toEqual([]); expect(created.find((row) => row.collection === "timesheet_entries")?.data) .toMatchObject({ hours_worked: 1, date_logged: "2026-09-23T00:00:00.000Z" }); }); it("rejects a time before the original IN", async () => { const { pb, send } = fixture(); await expect(correctMissedClockOut(pb as never, "oldin", { actualOutLocal: "2026-09-23T15:00", reason: "Missed exit scan", now, })).rejects.toMatchObject({ status: 400 }); expect(send).not.toHaveBeenCalled(); }); });