MEGAINFRA-WEBAPP/src/lib/clock-correction.test.ts

114 lines
5.3 KiB
TypeScript

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<Record<string, unknown>>; corrections?: Array<Record<string, unknown>> } = {}) {
const created: Array<{ collection: string; data: Record<string, unknown> }> = [];
const updated: Array<{ collection: string; id: string; data: Record<string, unknown> }> = [];
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<string, unknown>) { created.push({ collection: name, data }); },
update(id: string, data: Record<string, unknown>) { 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();
});
});