1
9import { describe, expect, it } from "vitest";
10
11import { commentEventKindForEvent, COMMENT_EVENT_KINDS } from "./comment-events.js";
12import {
13 NOTIFICATION_POLICY,
14 NOTIFICATION_TYPES,
15 notificationPolicy,
16 notificationToasts,
17} from "./notification-policy.js";
18import {
19 DEFAULT_NOTIFICATION_PREFS,
20 NotificationPrefsSchema,
21 effectivePrefMode,
22 prefIsOff,
23 prefSendsInstant,
24} from "./notification-prefs.js";
25import type { DomainEvent } from "../events.js";
26
27const evt = (name: string, payload: Record<string, unknown>): DomainEvent =>
28 ({
29 id: "evt_x",
30 name: name as DomainEvent["name"],
31 payload,
32 ts: "2026-01-01T00:00:00.000Z",
33 }) as DomainEvent;
34
35describe("commentEventKindForEvent (TASK-0094 R1)", () => {
36 it("comment.created without a replyId → 'created' (a new thread)", () => {
37 expect(
38 commentEventKindForEvent(evt("comment.created", { commentId: "c1", pageId: "p1" })),
39 ).toBe("created");
40 });
41
42 it("comment.created WITH a replyId → 'replied' (a reply = a comment with a parent)", () => {
43 expect(
44 commentEventKindForEvent(
45 evt("comment.created", { commentId: "c1", replyId: "r1", pageId: "p1" }),
46 ),
47 ).toBe("replied");
48 });
49
50 it("comment.resolved → 'resolved'", () => {
51 expect(
52 commentEventKindForEvent(evt("comment.resolved", { commentId: "c1", pageId: "p1" })),
53 ).toBe("resolved");
54 });
55
56 it("comment.reopened → 'reopened' (additive internal event)", () => {
57 expect(
58 commentEventKindForEvent(evt("comment.reopened", { commentId: "c1", pageId: "p1" })),
59 ).toBe("reopened");
60 });
61
62 it("comment.reaction → 'reacted' (additive internal event)", () => {
63 expect(
64 commentEventKindForEvent(
65 evt("comment.reaction", { commentId: "c1", emoji: "👍", pageId: "p1" }),
66 ),
67 ).toBe("reacted");
68 });
69
70 it("comment.deleted → 'deleted' (additive internal event)", () => {
71 expect(
72 commentEventKindForEvent(evt("comment.deleted", { commentId: "c1", pageId: "p1" })),
73 ).toBe("deleted");
74 });
75
76 it("an unrelated event → null (the mapper skips it)", () => {
77 expect(commentEventKindForEvent(evt("page.updated", { pageId: "p1" }))).toBeNull();
78 expect(
79 commentEventKindForEvent(evt("notification.created", { notificationId: "n1" })),
80 ).toBeNull();
81 });
82
83 it("the kind set is exactly the frame schema's enum (lockstep)", () => {
84
85 expect(COMMENT_EVENT_KINDS).toEqual([
86 "created",
87 "replied",
88 "resolved",
89 "reopened",
90 "reacted",
91 "deleted",
92 ]);
93 });
94});
95
96describe("notification policy table (TASK-0094 R5/R7)", () => {
97 it("MENTION + ASSIGNMENT toast; everything else does not", () => {
98 expect(notificationToasts("MENTION")).toBe(true);
99 expect(notificationToasts("ASSIGNMENT")).toBe(true);
100 expect(notificationToasts("COMMENT")).toBe(false);
101 expect(notificationToasts("PAGE_SHARED")).toBe(false);
102 expect(notificationToasts("DATABASE_CHANGE")).toBe(false);
103 expect(notificationToasts("AUTOMATION")).toBe(false);
104 expect(notificationToasts("INTEGRATION")).toBe(false);
105 expect(notificationToasts("TASK_ASSIGNED")).toBe(false);
106 });
107
108 it("the kind is the snake_case type (the frame's `kind` field)", () => {
109 expect(notificationPolicy("MENTION").kind).toBe("mention");
110 expect(notificationPolicy("PAGE_SHARED").kind).toBe("page_shared");
111 expect(notificationPolicy("DATABASE_CHANGE").kind).toBe("database_change");
112 });
113
114 it("covers every NotificationType exactly once (lockstep with Prisma)", () => {
115 expect([...NOTIFICATION_TYPES].sort()).toEqual([
116 "APPROVAL_DECISION",
117 "ASSIGNMENT",
118 "AUTOMATION",
119 "COMMENT",
120 "DATABASE_CHANGE",
121 "FORM_SUBMITTED",
122 "INTEGRATION",
123 "MENTION",
124 "PAGE_SHARED",
125 "REVIEW_REQUESTED",
126 "TASK_ASSIGNED",
127 "TASK_DUE",
128 "WATCH_UPDATE",
129 ]);
130 for (const t of NOTIFICATION_TYPES) {
131 expect(NOTIFICATION_POLICY[t]).toBeDefined();
132 }
133 });
134
135 it("an unknown type → conservative policy (no toast, kind = lower-case type) — never a crash", () => {
136 const p = notificationPolicy("SOME_FUTURE_TYPE");
137 expect(p.toast).toBe(false);
138 expect(p.kind).toBe("some_future_type");
139 });
140});
141
142describe("notification email prefs (TASK-0094 R6)", () => {
143 it("defaults: MENTION/ASSIGNMENT instant, the rest digest", () => {
144 expect(DEFAULT_NOTIFICATION_PREFS.MENTION).toBe("instant");
145 expect(DEFAULT_NOTIFICATION_PREFS.ASSIGNMENT).toBe("instant");
146 expect(DEFAULT_NOTIFICATION_PREFS.COMMENT).toBe("digest");
147 expect(DEFAULT_NOTIFICATION_PREFS.DATABASE_CHANGE).toBe("digest");
148 expect(DEFAULT_NOTIFICATION_PREFS.PAGE_SHARED).toBe("digest");
149 expect(DEFAULT_NOTIFICATION_PREFS.AUTOMATION).toBe("digest");
150 expect(DEFAULT_NOTIFICATION_PREFS.INTEGRATION).toBe("digest");
151 expect(DEFAULT_NOTIFICATION_PREFS.TASK_ASSIGNED).toBe("digest");
152 });
153
154 it("effectivePrefMode falls back to the default for an absent type", () => {
155 expect(effectivePrefMode("MENTION", {})).toBe("instant");
156 expect(effectivePrefMode("COMMENT", {})).toBe("digest");
157 });
158
159 it("an explicit pref overrides the default", () => {
160 expect(effectivePrefMode("COMMENT", { COMMENT: "instant" })).toBe("instant");
161 expect(effectivePrefMode("MENTION", { MENTION: "off" })).toBe("off");
162 });
163
164 it("prefSendsInstant: instant | all → true; digest → false", () => {
165 expect(prefSendsInstant("MENTION", {})).toBe(true);
166 expect(prefSendsInstant("COMMENT", {})).toBe(false);
167 expect(prefSendsInstant("COMMENT", { COMMENT: "all" })).toBe(true);
168 expect(prefSendsInstant("COMMENT", { COMMENT: "digest" })).toBe(false);
169 });
170
171 it("prefIsOff: only 'off' suppresses", () => {
172 expect(prefIsOff("MENTION", { MENTION: "off" })).toBe(true);
173 expect(prefIsOff("MENTION", {})).toBe(false);
174 });
175
176 it("NotificationPrefsSchema accepts a partial map + rejects a bad mode", () => {
177 const ok = NotificationPrefsSchema.safeParse({ MENTION: "off", COMMENT: "instant" });
178 expect(ok.success).toBe(true);
179 if (ok.success) expect(ok.data).toEqual({ MENTION: "off", COMMENT: "instant" });
180
181 const empty = NotificationPrefsSchema.safeParse({});
182 expect(empty.success).toBe(true);
183 if (empty.success) expect(empty.data).toEqual({});
184
185 const bad = NotificationPrefsSchema.safeParse({ MENTION: "sometimes" });
186 expect(bad.success).toBe(false);
187 });
188});
189