180 lines
4.6 KiB
TypeScript
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");
|
|
});
|
|
});
|