/** * Unit-tests the resume + cancel server actions in isolation. We mock * the seeded operator, drizzle db, and the pgNotifyBot helper so the * tests exercise the action's auth / status / lifecycle logic without * a real Postgres connection. */ import { describe, it, expect, vi, beforeEach } from "vitest"; const findRunMock = vi.fn(); const findReminderMock = vi.fn(); const findAccountMock = vi.fn(); const updateMock = vi.fn(); const transactionMock = vi.fn(); const pgNotifyMock = vi.fn(); vi.mock("@/lib/db", () => ({ db: { query: { reminderRuns: { findFirst: (...a: unknown[]) => findRunMock(...a) }, reminders: { findFirst: (...a: unknown[]) => findReminderMock(...a) }, whatsappAccounts: { findFirst: (...a: unknown[]) => findAccountMock(...a), }, }, update: () => ({ set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }), }), // The cancel action does its DB mutations inside a transaction. // Run the callback against the same shape as `db` so its inner // `tx.update(...).set(...).where(...)` calls land in updateMock. transaction: async (fn: (tx: unknown) => Promise) => { transactionMock(); const tx = { update: () => ({ set: () => ({ where: async (...a: unknown[]) => updateMock(...a), }), }), }; return fn(tx); }, }, })); vi.mock("@/lib/operator", () => ({ getSeededOperator: async () => ({ id: "op-1" }), })); vi.mock("@/lib/notify", () => ({ pgNotifyBot: (...a: unknown[]) => pgNotifyMock(...a), })); // Rate limiter doesn't fire from these actions, but stub it anyway in // case the implementation grows it later. vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: async () => ({ limited: false }), })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/headers", () => ({ headers: async () => new Map() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() })); import { resumeReminderRunAction, cancelReminderRunAction, } from "./reminders"; const PAUSED_RUN = { id: "11111111-1111-1111-1111-111111111111", reminderId: "r-1", status: "paused" }; const REMINDER = { id: "r-1", accountId: "acc-1", scheduleKind: "recurring" }; const REMINDER_ONE_OFF = { ...REMINDER, scheduleKind: "one_off" }; const ACCOUNT = { id: "acc-1", operatorId: "op-1" }; beforeEach(() => { findRunMock.mockReset(); findReminderMock.mockReset(); findAccountMock.mockReset(); updateMock.mockReset(); transactionMock.mockReset(); pgNotifyMock.mockReset(); }); describe("resumeReminderRunAction", () => { it("rejects a non-uuid runId", async () => { const r = await resumeReminderRunAction({ runId: "not-a-uuid" }); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toMatch(/Invalid/); }); it("returns 'Run not found' when the run row is missing", async () => { findRunMock.mockResolvedValue(undefined); const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id }); expect(r).toEqual({ ok: false, error: "Run not found" }); }); it("returns 'Reminder not found' when the run is orphaned", async () => { findRunMock.mockResolvedValue(PAUSED_RUN); findReminderMock.mockResolvedValue(undefined); const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id }); expect(r).toEqual({ ok: false, error: "Reminder not found" }); }); it("returns 'Run not yours' when another operator owns the account", async () => { findRunMock.mockResolvedValue(PAUSED_RUN); findReminderMock.mockResolvedValue(REMINDER); findAccountMock.mockResolvedValue(undefined); const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id }); expect(r).toEqual({ ok: false, error: "Run not yours" }); }); it("rejects when run.status !== 'paused'", async () => { findRunMock.mockResolvedValue({ ...PAUSED_RUN, status: "success" }); findReminderMock.mockResolvedValue(REMINDER); findAccountMock.mockResolvedValue(ACCOUNT); const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id }); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toMatch(/Cannot resume a success run/); }); it("happy path: notifies the bot with reminder.resume and runId", async () => { findRunMock.mockResolvedValue(PAUSED_RUN); findReminderMock.mockResolvedValue(REMINDER); findAccountMock.mockResolvedValue(ACCOUNT); const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id }); expect(r).toEqual({ ok: true }); expect(pgNotifyMock).toHaveBeenCalledTimes(1); expect(pgNotifyMock).toHaveBeenCalledWith({ type: "reminder.resume", reminderId: REMINDER.id, runId: PAUSED_RUN.id, }); }); }); describe("cancelReminderRunAction", () => { it("rejects a non-uuid runId", async () => { const r = await cancelReminderRunAction({ runId: "nope" }); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toMatch(/Invalid/); }); it("rejects when the run isn't paused", async () => { findRunMock.mockResolvedValue({ ...PAUSED_RUN, status: "success" }); findReminderMock.mockResolvedValue(REMINDER); findAccountMock.mockResolvedValue(ACCOUNT); const r = await cancelReminderRunAction({ runId: PAUSED_RUN.id }); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toMatch(/Cannot cancel/); }); it("happy path: opens a transaction and runs three updates (targets / run / reminder)", async () => { findRunMock.mockResolvedValue(PAUSED_RUN); findReminderMock.mockResolvedValue(REMINDER); findAccountMock.mockResolvedValue(ACCOUNT); const r = await cancelReminderRunAction({ runId: PAUSED_RUN.id }); expect(r).toEqual({ ok: true }); expect(transactionMock).toHaveBeenCalledTimes(1); // Three separate set/where calls inside the tx: update targets, // update run, update reminder lifecycle. expect(updateMock).toHaveBeenCalledTimes(3); // Cancel does NOT enqueue the bot — it's purely a DB-side operation. expect(pgNotifyMock).not.toHaveBeenCalled(); }); it("recurring reminder: lifecycle goes back to active so the next occurrence fires", async () => { // Use a tx-update spy that captures the SET payload. const setSpy = vi.fn(); const { db } = await import("@/lib/db"); // eslint-disable-next-line @typescript-eslint/no-explicit-any (db as any).transaction = async (fn: (tx: unknown) => Promise) => { const tx = { update: () => ({ set: (payload: unknown) => { setSpy(payload); return { where: async () => undefined }; }, }), }; return fn(tx); }; findRunMock.mockResolvedValue(PAUSED_RUN); findReminderMock.mockResolvedValue(REMINDER); // recurring findAccountMock.mockResolvedValue(ACCOUNT); await cancelReminderRunAction({ runId: PAUSED_RUN.id }); // Last set call is on the reminders table — status flips to active. const calls = setSpy.mock.calls; const lastPayload = calls[calls.length - 1]?.[0] as Record; expect(lastPayload.status).toBe("active"); }); it("one-off reminder: lifecycle ends (no future occurrence to wait for)", async () => { const setSpy = vi.fn(); const { db } = await import("@/lib/db"); // eslint-disable-next-line @typescript-eslint/no-explicit-any (db as any).transaction = async (fn: (tx: unknown) => Promise) => { const tx = { update: () => ({ set: (payload: unknown) => { setSpy(payload); return { where: async () => undefined }; }, }), }; return fn(tx); }; findRunMock.mockResolvedValue(PAUSED_RUN); findReminderMock.mockResolvedValue(REMINDER_ONE_OFF); findAccountMock.mockResolvedValue(ACCOUNT); await cancelReminderRunAction({ runId: PAUSED_RUN.id }); const calls = setSpy.mock.calls; const lastPayload = calls[calls.length - 1]?.[0] as Record; expect(lastPayload.status).toBe("ended"); }); });