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>
212 lines
7.9 KiB
TypeScript
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");
|
|
});
|
|
});
|