1
8import { describe, it, expect, vi } from "vitest";
9import { runReviewDueSweep, runTaskDueSweep } from "../processors/tasks.js";
10
11const NOW = new Date("2026-10-11T12:00:00.000Z");
12const ISO = (ms: number) => new Date(NOW.getTime() + ms).toISOString();
13const H = 3_600_000;
14
15
16const task = (id: string, dueMs: number, status: string, assignees: string[], pageId = `pg_${id}`) => ({
17 id,
18 workspaceId: "ws1",
19 pageId,
20 title: `Task ${id}`,
21 dueAt: new Date(ISO(dueMs)),
22 status,
23 assigneeIds: assignees
24});
25
26function fakePrisma(
27 allTasks: Array<ReturnType<typeof task>>,
28 pages: Array<{ id: string; trashedAt: Date | null }>,
29 now: Date = NOW
30) {
31 const created: Array<Record<string, unknown>> = [];
32
33
34 const findMany = vi.fn(async (a?: { where?: Record<string, unknown> }) => {
35 const w = a?.where ?? {};
36 const statuses: string[] = (w.status as { in?: string[] } | undefined)?.in ?? [];
37 const due = w.dueAt as { gt?: Date; lte?: Date } | undefined;
38 return allTasks.filter((t) => {
39 if (statuses.length && !statuses.includes(t.status)) return false;
40 if (due) {
41 if (due.gt && !(t.dueAt > due.gt)) return false;
42 if (due.lte && !(t.dueAt <= due.lte)) return false;
43 }
44 return true;
45 });
46 });
47 return {
48 prisma: {
49 task: { findMany },
50 page: { findMany: vi.fn(async () => pages) },
51 notification: {
52 create: vi.fn(async (a: { data: Record<string, unknown> }) => {
53 created.push(a.data);
54 return { id: `n${created.length}` };
55 })
56 }
57 } as never,
58 created,
59 findMany
60 };
61}
62
63function fakeRedis() {
64 const flags = new Map<string, string>();
65 const events: Array<{ event: string; payload: string }> = [];
66 return {
67 redis: {
68
69 set: vi.fn(async (...args: unknown[]) => {
70 const key = args[0] as string;
71 const nx = (args as string[]).includes("NX");
72 if (nx && flags.has(key)) return null;
73 flags.set(key, "1");
74 return "OK";
75 }),
76 xadd: vi.fn(async (_stream: string, _id: string, ...fields: string[]) => {
77 const kv: Record<string, string> = {};
78 for (let i = 0; i < fields.length; i += 2) kv[fields[i]!] = fields[i + 1]!;
79 events.push({ event: kv.event!, payload: kv.payload! });
80 return "1-1";
81 })
82 } as never,
83 flags,
84 events
85 };
86}
87
88describe("runTaskDueSweep (T-160)", () => {
89 it("reminds assignees of tasks due in (now−1h, now+24h]; one notification per assignee + a stream event", async () => {
90 const tasks = [
91 task("t1", -0.5 * H, "todo", ["u1", "u2"]),
92 task("t2", 12 * H, "in_progress", ["u3"]),
93 task("t3", 20 * H, "todo", ["u1"])
94 ];
95 const { prisma, created } = fakePrisma(tasks, tasks.map((t) => ({ id: t.pageId, trashedAt: null })));
96 const { redis, events } = fakeRedis();
97 const res = await runTaskDueSweep(prisma, redis, NOW);
98 expect(res.checked).toBe(3);
99 expect(res.reminded).toBe(3);
100 expect(res.notifications).toBe(4);
101 const types = created.map((c) => c.type);
102 expect(types.every((t) => t === "TASK_DUE")).toBe(true);
103
104 const pairs = created.map((c) => `${c.title}:${c.userId}`);
105 expect(pairs).toContain("Task due: Task t1:u1");
106 expect(pairs).toContain("Task due: Task t1:u2");
107 expect(pairs).toContain("Task due: Task t2:u3");
108 expect(pairs).toContain("Task due: Task t3:u1");
109
110 expect(events.length).toBe(4);
111 expect(events.every((e) => e.event === "notification.created")).toBe(true);
112 });
113
114 it("is IDEMPOTENT: a second run for the same (task, dueAt) sends nothing (NX flag)", async () => {
115 const tasks = [task("t1", 6 * H, "todo", ["u1", "u2"])];
116 const { prisma } = fakePrisma(tasks, [{ id: "pg_t1", trashedAt: null }]);
117 const { redis, flags } = fakeRedis();
118 const first = await runTaskDueSweep(prisma, redis, NOW);
119 expect(first.notifications).toBe(2);
120 expect(flags.size).toBe(1);
121
122 const second = await runTaskDueSweep(fakePrisma(tasks, [{ id: "pg_t1", trashedAt: null }]).prisma as never, redis, NOW);
123 expect(second.reminded).toBe(0);
124 expect(second.notifications).toBe(0);
125 });
126
127 it("skips tasks with no assignees, closed statuses, a trashed page, and dueAt outside the window", async () => {
128 const tasks = [
129 task("t-nounit", 6 * H, "todo", []),
130 task("t-done", 6 * H, "done", ["u1"]),
131 task("t-cancelled", 6 * H, "cancelled", ["u1"]),
132 task("t-far", 30 * H, "todo", ["u1"]),
133 task("t-trashed", 6 * H, "todo", ["u1"], "pg_trashed")
134 ];
135 const { prisma, created } = fakePrisma(tasks, [
136 { id: "pg_t-nounit", trashedAt: null },
137 { id: "pg_t-done", trashedAt: null },
138 { id: "pg_t-cancelled", trashedAt: null },
139 { id: "pg_t-far", trashedAt: null },
140 { id: "pg_trashed", trashedAt: NOW }
141 ]);
142 const { redis } = fakeRedis();
143 const res = await runTaskDueSweep(prisma, redis, NOW);
144
145 expect(res.reminded).toBe(0);
146 expect(res.notifications).toBe(0);
147 expect(created.length).toBe(0);
148 });
149
150 it("a CHANGED dueAt re-reminds (the flag key embeds the timestamp)", async () => {
151 const tasks = [task("t1", 6 * H, "todo", ["u1"])];
152 const { prisma } = fakePrisma(tasks, [{ id: "pg_t1", trashedAt: null }]);
153 const { redis } = fakeRedis();
154 await runTaskDueSweep(prisma, redis, NOW);
155
156 const rescheduled = [task("t1", 8 * H, "todo", ["u1"])];
157 const second = await runTaskDueSweep(fakePrisma(rescheduled, [{ id: "pg_t1", trashedAt: null }]).prisma as never, redis, NOW);
158 expect(second.reminded).toBe(1);
159 expect(second.notifications).toBe(1);
160 });
161});
162
163
164
165const review = (id: string, dueMs: number, opts: { state?: string; deadlineMs?: number | null; closed?: boolean; reviewers?: string[]; requester?: string; title?: string; trashed?: boolean } = {}) => ({
166 id,
167 pageId: `pg_${id}`,
168 state: opts.state ?? "in_review",
169 reviewerIds: opts.reviewers ?? ["u1", "u2"],
170 requestedBy: opts.requester ?? "editor",
171 deadline:
172 opts.deadlineMs === undefined
173 ? new Date(ISO(6 * H))
174 : opts.deadlineMs === null
175 ? null
176 : new Date(ISO(opts.deadlineMs)),
177 closedAt: opts.closed ? NOW : null,
178 page: { workspaceId: "ws1", title: opts.title ?? "Doc", trashedAt: opts.trashed ? NOW : null }
179});
180
181function fakeReviewPrisma(all: Array<ReturnType<typeof review>>) {
182 const created: Array<Record<string, unknown>> = [];
183 const findMany = vi.fn(async (a?: { where?: Record<string, unknown> }) => {
184 const w = a?.where ?? {};
185 const dl = w.deadline as { gt?: Date; lte?: Date } | undefined;
186 return all.filter((r) => {
187 if (r.closedAt !== null) return false;
188 if (w.state && r.state !== w.state) return false;
189 if (!r.deadline) return false;
190 if (dl) {
191 if (dl.gt && !(r.deadline > dl.gt)) return false;
192 if (dl.lte && !(r.deadline <= dl.lte)) return false;
193 }
194 return true;
195 });
196 });
197 return {
198 prisma: {
199 review: { findMany },
200 notification: {
201 create: vi.fn(async (arg: { data: Record<string, unknown> }) => {
202 created.push(arg.data);
203 return { id: `n${created.length}` };
204 })
205 }
206 } as never,
207 created,
208 findMany
209 };
210}
211
212describe("runReviewDueSweep (T-162/T-163 deadline reminders)", () => {
213 it("reminds each reviewer of record (NOT the requester) for open reviews due in the window", async () => {
214 const all = [
215 review("r1", 6 * H, { reviewers: ["u1", "u2"], requester: "editor" }),
216 review("r2", 20 * H, { reviewers: ["u3"], requester: "u3" })
217 ];
218 const { prisma, created } = fakeReviewPrisma(all);
219 const { redis, events } = fakeRedis();
220 const res = await runReviewDueSweep(prisma, redis, NOW);
221 expect(res.checked).toBe(2);
222 expect(res.reminded).toBe(2);
223 expect(res.notifications).toBe(2);
224 expect(created.every((c) => c.type === "REVIEW_REQUESTED")).toBe(true);
225 const pairs = created.map((c) => c.userId as string).sort();
226 expect(pairs).toEqual(["u1", "u2"]);
227 expect(events.length).toBe(2);
228 });
229
230 it("is IDEMPOTENT (NX flag) and skips closed reviews, missing deadlines, trashed pages, out-of-window", async () => {
231 const all = [
232 review("r1", 6 * H),
233 review("r-closed", 6 * H, { closed: true }),
234 review("r-nodeadline", 6 * H, { deadlineMs: null }),
235 review("r-trashed", 6 * H, { trashed: true }),
236 review("r-far", 0, { deadlineMs: 30 * H })
237 ];
238 const { prisma, created } = fakeReviewPrisma(all);
239 const { redis, flags } = fakeRedis();
240 const first = await runReviewDueSweep(prisma, redis, NOW);
241 expect(first.reminded).toBe(1);
242 expect(first.notifications).toBe(2);
243
244 const second = await runReviewDueSweep(fakeReviewPrisma(all).prisma as never, redis, NOW);
245 expect(second.reminded).toBe(0);
246 expect(flags.size).toBe(1);
247 void created;
248 });
249});
250