cm_whatsapp_bot_v1/apps/web/src/actions/reminders.run-actions.test.ts
yiekheng 376bbe595b feat(web,bot): resumeReminderRunAction + cancelReminderRunAction
Web actions:

* resumeReminderRunAction({ runId }) → validates ownership and that
  the run is in 'paused' state, then publishes a reminder.resume
  command via pg_notify('bot.command'). The bot's command-consumer
  picks it up and enqueues a fresh pg-boss job at REMINDER_FIRE_QUEUE
  carrying { reminderId, runId }; fire-reminder's existing resume
  branch attaches to the row.
* cancelReminderRunAction({ runId }) → flips remaining 'pending'
  targets to 'skipped' with error="canceled by operator", marks the
  run 'partial' with a clear errorSummary, and lifts the parent
  reminder out of 'paused' (recurring → active so the next
  occurrence fires; one-off → ended).

Bot:

* New BotCommand variant { type: "reminder.resume"; reminderId; runId }
* command-consumer registers handleResumeReminder which calls
  enqueueReminderResume(boss, reminderId, runId) — a sibling of
  scheduleReminderFire that posts the job at REMINDER_FIRE_QUEUE
  with { reminderId, runId } and singletonKey "reminder:resume:<runId>"
  so the resume doesn't conflict with a future-occurrence schedule.

Tests:
* reminders.run-actions.test.ts (11 tests) — every guard rail
  (invalid uuid, missing run, missing reminder, foreign operator,
  wrong status) and the recurring/one-off lifecycle branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:54:21 +08:00

212 lines
7.9 KiB
TypeScript

/**
* 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<unknown>) => {
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<unknown>) => {
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<string, unknown>;
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<unknown>) => {
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<string, unknown>;
expect(lastPayload.status).toBe("ended");
});
});