import { describe, it, expect, vi, beforeEach } from "vitest"; // pg-boss + db mocks. Hoisted so the vi.mock factories below can refer // to the spies — see https://vitest.dev/api/vi.html#vi-hoisted. const { bossSendMock, dbExecuteMock, } = vi.hoisted(() => ({ bossSendMock: vi.fn(async (..._args: unknown[]) => "new-job-id"), dbExecuteMock: vi.fn(async (..._args: unknown[]) => ({ rows: [] as unknown[] })), })); vi.mock("../db.js", () => ({ db: { execute: (...a: unknown[]) => dbExecuteMock(...a) }, })); vi.mock("../logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); // We don't import pg-boss directly — scheduleReminderFire receives a // PgBoss instance as its first arg. Build a minimal stub that exposes // just the .send method (and createQueue / work for registerReminderJobs // if we ever wire it here). const fakeBoss = { send: bossSendMock, } as unknown as Parameters[0]; import { scheduleReminderFire } from "./reminder-jobs.js"; const REMINDER_ID = "11111111-1111-1111-1111-111111111111"; const SINGLETON_KEY = `reminder:${REMINDER_ID}`; const FIRE_AT = new Date("2026-05-10T12:20:00.000Z"); beforeEach(() => { bossSendMock.mockReset(); bossSendMock.mockResolvedValue("new-job-id"); dbExecuteMock.mockReset(); dbExecuteMock.mockResolvedValue({ rows: [] }); }); describe("scheduleReminderFire — pre-send cancel of stale created jobs", () => { it("ALWAYS runs the cancel-stale UPDATE before boss.send (regression: stately dedupe dropped reschedules)", async () => { // Repro of the dropped-fire bug: the queue was on policy=stately // and a prior schedule had left a 'created' job in pg-boss with // the same singletonKey. The new send returned null and the // user's 8:20 PM fire was silently lost. Under the fix, we MUST // tombstone any prior created jobs FIRST so the new send wins // even under standard policy. dbExecuteMock.mockResolvedValueOnce({ rows: [{ id: "stale-1" }] }); const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT); // Order matters: cancel happens before send. expect(dbExecuteMock).toHaveBeenCalledTimes(1); expect(bossSendMock).toHaveBeenCalledTimes(1); expect(dbExecuteMock.mock.invocationCallOrder[0]).toBeLessThan( bossSendMock.mock.invocationCallOrder[0]!, ); expect(result).toBe("new-job-id"); }); it("scopes the cancel to state='created' only — active/completed jobs MUST survive", async () => { // The cancel must NOT touch in-flight runs (state='active') nor // historical fires (state='completed'). Otherwise we'd nuke the // run that's currently sending and the user gets phantom 'failed' // rows in the activity feed. await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT); const sqlStmt = dbExecuteMock.mock.calls[0]![0]; // Drizzle's sql template returns an SQL object; serialise to inspect. const text = JSON.stringify(sqlStmt); expect(text).toMatch(/state\s*=\s*'?created'?/); expect(text).not.toMatch(/state\s*=\s*'?active'?/); expect(text).not.toMatch(/state\s*=\s*'?completed'?/); }); it("targets only THIS reminder's singletonKey (doesn't cross-cancel other reminders)", async () => { await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT); const sqlStmt = dbExecuteMock.mock.calls[0]![0]; const text = JSON.stringify(sqlStmt); // The reminderId must appear in the WHERE clause's bound params // (drizzle stores them in the serialised payload). expect(text).toContain(REMINDER_ID); }); it("passes the singleton key through to boss.send for diagnostics", async () => { await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT); const [, , opts] = bossSendMock.mock.calls[0]!; expect(opts).toMatchObject({ singletonKey: SINGLETON_KEY, startAfter: FIRE_AT, retryLimit: 3, }); }); it("still sends when the cancel UPDATE returns zero rows (first-time schedule)", async () => { // First time scheduling a reminder — no stale rows exist. dbExecuteMock.mockResolvedValueOnce({ rows: [] }); const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT); expect(bossSendMock).toHaveBeenCalledTimes(1); expect(result).toBe("new-job-id"); }); it("DEGRADES SAFELY: if the cancel UPDATE throws, the send still runs", async () => { // pg connection blip during cancel must not strand the schedule. // Worst case we end up with two created jobs and the // handler-level recent-run dedupe drops the duplicate fire. dbExecuteMock.mockRejectedValueOnce(new Error("connection reset")); const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT); expect(bossSendMock).toHaveBeenCalledTimes(1); expect(result).toBe("new-job-id"); }); it("returns boss.send's null when pg-boss itself rejects the send", async () => { // Defense check: if pg-boss returns null for any reason (queue // missing, future stately-style policy quirks, etc), surface that // up so the caller's logger captures jobId: null. bossSendMock.mockResolvedValueOnce(null); const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT); expect(result).toBeNull(); }); });