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"); }); });