/** * T-160 — task due-reminder sweep (worker core, unit). * * Deterministic `now` + a fake Prisma + a fake Redis (the NX-flag dedupe is * the security-relevant part — a re-run must be a no-op). Mirrors the * retention.test.ts conventions (no Docker needed). */ import { describe, it, expect, vi } from "vitest"; import { runReviewDueSweep, runTaskDueSweep } from "../processors/tasks.js"; const NOW = new Date("2026-10-11T12:00:00.000Z"); const ISO = (ms: number) => new Date(NOW.getTime() + ms).toISOString(); const H = 3_600_000; /** A task row for the sweep. */ const task = (id: string, dueMs: number, status: string, assignees: string[], pageId = `pg_${id}`) => ({ id, workspaceId: "ws1", pageId, title: `Task ${id}`, dueAt: new Date(ISO(dueMs)), status, assigneeIds: assignees }); function fakePrisma( allTasks: Array>, pages: Array<{ id: string; trashedAt: Date | null }>, now: Date = NOW ) { const created: Array> = []; // Apply the SAME where clause the real query uses (status in, dueAt gt/lte) // so the test verifies the QUERY, not just the loop. const findMany = vi.fn(async (a?: { where?: Record }) => { const w = a?.where ?? {}; const statuses: string[] = (w.status as { in?: string[] } | undefined)?.in ?? []; const due = w.dueAt as { gt?: Date; lte?: Date } | undefined; return allTasks.filter((t) => { if (statuses.length && !statuses.includes(t.status)) return false; if (due) { if (due.gt && !(t.dueAt > due.gt)) return false; if (due.lte && !(t.dueAt <= due.lte)) return false; } return true; }); }); return { prisma: { task: { findMany }, page: { findMany: vi.fn(async () => pages) }, notification: { create: vi.fn(async (a: { data: Record }) => { created.push(a.data); return { id: `n${created.length}` }; }) } } as never, created, findMany }; } function fakeRedis() { const flags = new Map(); const events: Array<{ event: string; payload: string }> = []; return { redis: { // Mirror the real call shape: set(key, val, "EX", ttl, "NX"). set: vi.fn(async (...args: unknown[]) => { const key = args[0] as string; const nx = (args as string[]).includes("NX"); if (nx && flags.has(key)) return null; flags.set(key, "1"); return "OK"; }), xadd: vi.fn(async (_stream: string, _id: string, ...fields: string[]) => { const kv: Record = {}; for (let i = 0; i < fields.length; i += 2) kv[fields[i]!] = fields[i + 1]!; events.push({ event: kv.event!, payload: kv.payload! }); return "1-1"; }) } as never, flags, events }; } describe("runTaskDueSweep (T-160)", () => { it("reminds assignees of tasks due in (now−1h, now+24h]; one notification per assignee + a stream event", async () => { const tasks = [ task("t1", -0.5 * H, "todo", ["u1", "u2"]), // due 30m ago (in the look-back) task("t2", 12 * H, "in_progress", ["u3"]), // due in 12h task("t3", 20 * H, "todo", ["u1"]) // due in 20h ]; const { prisma, created } = fakePrisma(tasks, tasks.map((t) => ({ id: t.pageId, trashedAt: null }))); const { redis, events } = fakeRedis(); const res = await runTaskDueSweep(prisma, redis, NOW); expect(res.checked).toBe(3); expect(res.reminded).toBe(3); expect(res.notifications).toBe(4); // t1→2 + t2→1 + t3→1 const types = created.map((c) => c.type); expect(types.every((t) => t === "TASK_DUE")).toBe(true); // each (task, assignee) pair is notified exactly once const pairs = created.map((c) => `${c.title}:${c.userId}`); expect(pairs).toContain("Task due: Task t1:u1"); expect(pairs).toContain("Task due: Task t1:u2"); expect(pairs).toContain("Task due: Task t2:u3"); expect(pairs).toContain("Task due: Task t3:u1"); // a stream event per notification (the email-decision consumer) expect(events.length).toBe(4); expect(events.every((e) => e.event === "notification.created")).toBe(true); }); it("is IDEMPOTENT: a second run for the same (task, dueAt) sends nothing (NX flag)", async () => { const tasks = [task("t1", 6 * H, "todo", ["u1", "u2"])]; const { prisma } = fakePrisma(tasks, [{ id: "pg_t1", trashedAt: null }]); const { redis, flags } = fakeRedis(); const first = await runTaskDueSweep(prisma, redis, NOW); expect(first.notifications).toBe(2); expect(flags.size).toBe(1); // Re-run (a concurrent worker / the next hourly tick) → the flag is set → no new rows. const second = await runTaskDueSweep(fakePrisma(tasks, [{ id: "pg_t1", trashedAt: null }]).prisma as never, redis, NOW); expect(second.reminded).toBe(0); expect(second.notifications).toBe(0); }); it("skips tasks with no assignees, closed statuses, a trashed page, and dueAt outside the window", async () => { const tasks = [ task("t-nounit", 6 * H, "todo", []), // no assignees task("t-done", 6 * H, "done", ["u1"]), // closed task("t-cancelled", 6 * H, "cancelled", ["u1"]), // closed task("t-far", 30 * H, "todo", ["u1"]), // due in 30h → outside the 24h window task("t-trashed", 6 * H, "todo", ["u1"], "pg_trashed") // page trashed ]; const { prisma, created } = fakePrisma(tasks, [ { id: "pg_t-nounit", trashedAt: null }, { id: "pg_t-done", trashedAt: null }, { id: "pg_t-cancelled", trashedAt: null }, { id: "pg_t-far", trashedAt: null }, { id: "pg_trashed", trashedAt: NOW } ]); const { redis } = fakeRedis(); const res = await runTaskDueSweep(prisma, redis, NOW); // none of them should be reminded expect(res.reminded).toBe(0); expect(res.notifications).toBe(0); expect(created.length).toBe(0); }); it("a CHANGED dueAt re-reminds (the flag key embeds the timestamp)", async () => { const tasks = [task("t1", 6 * H, "todo", ["u1"])]; const { prisma } = fakePrisma(tasks, [{ id: "pg_t1", trashedAt: null }]); const { redis } = fakeRedis(); await runTaskDueSweep(prisma, redis, NOW); // The user re-schedules the task (a new dueAt) → a NEW flag key → reminds again. const rescheduled = [task("t1", 8 * H, "todo", ["u1"])]; const second = await runTaskDueSweep(fakePrisma(rescheduled, [{ id: "pg_t1", trashedAt: null }]).prisma as never, redis, NOW); expect(second.reminded).toBe(1); expect(second.notifications).toBe(1); }); }); // ---------- T-162/T-163: review deadline reminders ---------- const review = (id: string, dueMs: number, opts: { state?: string; deadlineMs?: number | null; closed?: boolean; reviewers?: string[]; requester?: string; title?: string; trashed?: boolean } = {}) => ({ id, pageId: `pg_${id}`, state: opts.state ?? "in_review", reviewerIds: opts.reviewers ?? ["u1", "u2"], requestedBy: opts.requester ?? "editor", deadline: opts.deadlineMs === undefined ? new Date(ISO(6 * H)) : opts.deadlineMs === null ? null : new Date(ISO(opts.deadlineMs)), closedAt: opts.closed ? NOW : null, page: { workspaceId: "ws1", title: opts.title ?? "Doc", trashedAt: opts.trashed ? NOW : null } }); function fakeReviewPrisma(all: Array>) { const created: Array> = []; const findMany = vi.fn(async (a?: { where?: Record }) => { const w = a?.where ?? {}; const dl = w.deadline as { gt?: Date; lte?: Date } | undefined; return all.filter((r) => { if (r.closedAt !== null) return false; // closedAt: null if (w.state && r.state !== w.state) return false; if (!r.deadline) return false; if (dl) { if (dl.gt && !(r.deadline > dl.gt)) return false; if (dl.lte && !(r.deadline <= dl.lte)) return false; } return true; }); }); return { prisma: { review: { findMany }, notification: { create: vi.fn(async (arg: { data: Record }) => { created.push(arg.data); return { id: `n${created.length}` }; }) } } as never, created, findMany }; } describe("runReviewDueSweep (T-162/T-163 deadline reminders)", () => { it("reminds each reviewer of record (NOT the requester) for open reviews due in the window", async () => { const all = [ review("r1", 6 * H, { reviewers: ["u1", "u2"], requester: "editor" }), review("r2", 20 * H, { reviewers: ["u3"], requester: "u3" }) // reviewer IS the requester → skipped ]; const { prisma, created } = fakeReviewPrisma(all); const { redis, events } = fakeRedis(); const res = await runReviewDueSweep(prisma, redis, NOW); expect(res.checked).toBe(2); expect(res.reminded).toBe(2); expect(res.notifications).toBe(2); // r1→u1,u2 (2); r2→u3 is the requester (0) expect(created.every((c) => c.type === "REVIEW_REQUESTED")).toBe(true); const pairs = created.map((c) => c.userId as string).sort(); expect(pairs).toEqual(["u1", "u2"]); expect(events.length).toBe(2); }); it("is IDEMPOTENT (NX flag) and skips closed reviews, missing deadlines, trashed pages, out-of-window", async () => { const all = [ review("r1", 6 * H), review("r-closed", 6 * H, { closed: true }), review("r-nodeadline", 6 * H, { deadlineMs: null }), review("r-trashed", 6 * H, { trashed: true }), review("r-far", 0, { deadlineMs: 30 * H }) // deadline outside the 24h window ]; const { prisma, created } = fakeReviewPrisma(all); const { redis, flags } = fakeRedis(); const first = await runReviewDueSweep(prisma, redis, NOW); expect(first.reminded).toBe(1); // only r1 expect(first.notifications).toBe(2); // Re-run → the flag suppresses r1. const second = await runReviewDueSweep(fakeReviewPrisma(all).prisma as never, redis, NOW); expect(second.reminded).toBe(0); expect(flags.size).toBe(1); void created; }); });