import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock the per-key mutex module BEFORE importing fire-reminder so the // runtime sees our spy when it dereferences `accountMutex.run`. vi.mock("./per-key-mutex.js", () => { return { PerKeyMutex: class {}, accountMutex: { run: vi.fn(async (_key: string, fn: () => Promise) => fn()), }, }; }); // Stub everything fire-reminder pulls in so the import succeeds without // actually starting a Baileys session, hitting the DB, or talking to // pg-boss. const getReminderMock = vi.fn(); vi.mock("../reminders/crud.js", () => ({ getReminderWithDetails: (...args: unknown[]) => getReminderMock(...args), })); vi.mock("../db.js", () => ({ db: { insert: () => ({ values: () => ({ returning: async () => [{ id: "run-1" }] }) }), update: () => ({ set: () => ({ where: async () => undefined }) }), query: { whatsappGroups: { findMany: async () => [] }, mediaFiles: { findMany: async () => [] }, }, }, })); vi.mock("../whatsapp/session-manager.js", () => ({ sessionManager: { getSession: () => null }, })); vi.mock("../ipc/notify.js", () => ({ pgNotifyWeb: vi.fn(async () => undefined) })); vi.mock("../audit.js", () => ({ writeAuditLog: vi.fn(async () => undefined) })); vi.mock("./pgboss-client.js", () => ({ getBoss: () => ({}) })); vi.mock("./reminder-jobs.js", () => ({ scheduleReminderFire: vi.fn() })); import { fireReminder } from "./fire-reminder.js"; import { accountMutex } from "./per-key-mutex.js"; describe("fireReminder", () => { beforeEach(() => { vi.mocked(accountMutex.run).mockClear(); getReminderMock.mockReset(); }); it("acquires accountMutex keyed by accountId for active reminders", async () => { getReminderMock.mockResolvedValue({ id: "r-1", accountId: "acct-A", status: "active", targets: [], messages: [], createdBy: "op-1", scheduleKind: "one_off", rrule: null, timezone: "Asia/Kuala_Lumpur", name: "Test", }); await fireReminder({ reminderId: "r-1" }); expect(accountMutex.run).toHaveBeenCalledTimes(1); expect(accountMutex.run).toHaveBeenCalledWith("acct-A", expect.any(Function)); }); it("does NOT acquire the mutex when the reminder is inactive", async () => { getReminderMock.mockResolvedValue({ id: "r-1", accountId: "acct-A", status: "ended", targets: [], messages: [], createdBy: "op-1", scheduleKind: "one_off", rrule: null, timezone: "Asia/Kuala_Lumpur", name: "Test", }); await fireReminder({ reminderId: "r-1" }); expect(accountMutex.run).not.toHaveBeenCalled(); }); it("does NOT acquire the mutex when the reminder row is missing", async () => { getReminderMock.mockResolvedValue(undefined); await fireReminder({ reminderId: "r-missing" }); expect(accountMutex.run).not.toHaveBeenCalled(); }); it("uses different mutex keys for different accounts (cross-account isolation)", async () => { getReminderMock.mockResolvedValueOnce({ id: "r-A", accountId: "acct-A", status: "active", targets: [], messages: [], createdBy: "op-1", scheduleKind: "one_off", rrule: null, timezone: "Asia/Kuala_Lumpur", name: "A", }); getReminderMock.mockResolvedValueOnce({ id: "r-B", accountId: "acct-B", status: "active", targets: [], messages: [], createdBy: "op-1", scheduleKind: "one_off", rrule: null, timezone: "Asia/Kuala_Lumpur", name: "B", }); await fireReminder({ reminderId: "r-A" }); await fireReminder({ reminderId: "r-B" }); const calls = vi.mocked(accountMutex.run).mock.calls; expect(calls[0]?.[0]).toBe("acct-A"); expect(calls[1]?.[0]).toBe("acct-B"); }); });