Files
CapaKraken/packages/api/src/__tests__/reminder-scheduler.test.ts
T

180 lines
4.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { emitNotificationCreated, emitReminderDue } from "../sse/event-bus.js";
import { logger } from "../lib/logger.js";
import { startReminderScheduler, stopReminderScheduler } from "../lib/reminder-scheduler.js";
const { findMany, create, update } = vi.hoisted(() => ({
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
}));
vi.mock("@capakraken/db", () => ({
prisma: {
notification: {
findMany,
create,
update,
},
},
}));
vi.mock("../sse/event-bus.js", () => ({
emitReminderDue: vi.fn(),
emitNotificationCreated: vi.fn(),
}));
vi.mock("../lib/logger.js", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
describe("reminder scheduler logging", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
findMany.mockResolvedValue([]);
create.mockResolvedValue({ id: "notification_1" });
update.mockResolvedValue({});
});
afterEach(() => {
stopReminderScheduler();
});
it("logs scheduler lifecycle with structured metadata", async () => {
startReminderScheduler();
await vi.waitFor(() => {
expect(findMany).toHaveBeenCalled();
});
expect(logger.info).toHaveBeenCalledWith(
{ pollIntervalMs: 60_000 },
"Starting reminder scheduler",
);
stopReminderScheduler();
expect(logger.info).toHaveBeenCalledWith("Stopped reminder scheduler");
});
it("logs reminder processing failures with reminder context", async () => {
findMany.mockResolvedValueOnce([
{
id: "reminder_1",
userId: "user_1",
recurrence: null,
priority: "HIGH",
title: "Follow up",
body: "Body",
entityId: null,
entityType: null,
link: null,
nextRemindAt: new Date("2026-03-30T08:00:00.000Z"),
},
]);
update.mockRejectedValueOnce(new Error("db write failed"));
startReminderScheduler();
await vi.waitFor(() => {
expect(logger.error).toHaveBeenCalledWith(
{
err: expect.any(Error),
reminderId: "reminder_1",
userId: "user_1",
},
"Failed to process reminder",
);
});
expect(emitReminderDue).not.toHaveBeenCalled();
expect(emitNotificationCreated).not.toHaveBeenCalled();
});
it("does not start a second reminder poll while the previous batch is still running", async () => {
vi.useFakeTimers();
let releaseFindMany: ((value: unknown[]) => void) | null = null;
findMany.mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseFindMany = resolve;
}),
);
startReminderScheduler();
expect(findMany).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(60_000);
expect(findMany).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
"Skipping reminder scheduler tick while previous run is still processing",
);
releaseFindMany?.([]);
await Promise.resolve();
await vi.advanceTimersByTimeAsync(60_000);
expect(findMany).toHaveBeenCalledTimes(2);
});
it("advances overdue recurring reminders to the first occurrence after now", async () => {
const now = new Date();
const staleNextRemindAt = new Date(now);
staleNextRemindAt.setDate(staleNextRemindAt.getDate() - 3);
staleNextRemindAt.setHours(8, 0, 0, 0);
const expectedNextRemindAt = new Date(staleNextRemindAt);
while (expectedNextRemindAt.getTime() <= now.getTime()) {
expectedNextRemindAt.setDate(expectedNextRemindAt.getDate() + 1);
}
const overdueReminder = {
id: "reminder_recurring_1",
userId: "user_1",
recurrence: "daily",
priority: "MEDIUM",
title: "Daily standup prep",
body: "Body",
entityId: null,
entityType: null,
link: null,
nextRemindAt: staleNextRemindAt,
};
findMany.mockResolvedValueOnce([overdueReminder]);
startReminderScheduler();
await vi.waitFor(() => {
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: "user_1",
sourceId: "reminder_recurring_1",
type: "REMINDER_DUE",
}),
}),
);
});
expect(update).toHaveBeenCalledWith({
where: { id: "reminder_recurring_1" },
data: {
nextRemindAt: expectedNextRemindAt,
},
});
expect(emitNotificationCreated).toHaveBeenCalledWith("user_1", "notification_1");
expect(emitReminderDue).toHaveBeenCalledWith("user_1", "notification_1");
});
});