1
9import { afterAll, beforeAll, describe, expect, it } from "vitest";
10import { createTestApp, type TestAppHandle } from "./helpers/api";
11import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed";
12
13let app: TestAppHandle;
14
15beforeAll(async () => {
16 app = await createTestApp();
17}, 180_000);
18afterAll(async () => {
19 await app?.close();
20});
21
22const json = async (res: Response): Promise<Record<string, unknown>> =>
23 (await res.json().catch(() => ({}))) as Record<string, unknown>;
24
25async function setupPage(): Promise<{
26 headers: Record<string, string>;
27 workspaceId: string;
28 pageId: string;
29 userId: string;
30}> {
31 const user = await createUser(app.request, { name: "Comment Owner" });
32 const { workspaceId } = await createWorkspace(
33 app.request,
34 authHeaders(user.accessToken!),
35 "Comments WS",
36 );
37 const pg = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
38 headers: authHeaders(user.accessToken!),
39 body: { title: "Commented Page" },
40 });
41 expect(pg.status).toBe(201);
42
43
44 return {
45 headers: authHeaders(user.accessToken!),
46 workspaceId,
47 pageId: ((await json(pg)) as { id: string }).id,
48 userId: user.userId,
49 };
50}
51
52
58async function addMember(
59 workspaceId: string,
60 email: string,
61 role: "MEMBER" | "GUEST" | "ADMIN" = "MEMBER",
62): Promise<void> {
63 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, email, role);
64}
65
66describe("Comments integration", () => {
67 it("create comment on a page: stored with author, status open", async () => {
68 const { headers, pageId, userId } = await setupPage();
69 const res = await app.request("POST", `/api/v1/pages/${pageId}/comments`, {
70 headers,
71 body: { body: "First thought" },
72 });
73 expect([200, 201]).toContain(res.status);
74 const commentId = ((await json(res)).comment as { id: string }).id;
75
76 const row = await app.prisma.comment.findUniqueOrThrow({ where: { id: commentId } });
77 expect(row.body).toBe("First thought");
78 expect(row.createdBy).toBe(userId);
79 expect(row.status).toBe("open");
80 expect(row.resolvedAt).toBeNull();
81 expect(row.pageId).toBe(pageId);
82 });
83
84 it("comment anchored to a block: blockId + selection stored", async () => {
85 const { headers, workspaceId, pageId } = await setupPage();
86
87 const blk = await app.request("POST", `/api/v1/pages/${pageId}/blocks`, {
88 headers,
89 body: { type: "PARAGRAPH", content: { text: "anchor text" } },
90 });
91 const blockId = ((await json(blk)).block as { id: string }).id;
92
93 const res = await app.request("POST", `/api/v1/pages/${pageId}/comments`, {
94 headers,
95 body: {
96 body: "About this line",
97 blockId,
98 selection: { anchorOffset: 0, headOffset: 7 },
99 },
100 });
101 expect([200, 201]).toContain(res.status);
102 const row = await app.prisma.comment.findUniqueOrThrow({
103 where: { id: ((await json(res)).comment as { id: string }).id },
104 });
105 expect(row.blockId).toBe(blockId);
106 expect(row.selection).toBeTruthy();
107 });
108
109 it("reply: nested under the comment, authored by the replier", async () => {
110 const { headers, pageId } = await setupPage();
111 const comment = await app.request("POST", `/api/v1/pages/${pageId}/comments`, {
112 headers,
113 body: { body: "Top" },
114 });
115 const commentId = ((await json(comment)).comment as { id: string }).id;
116
117 const reply = await app.request("POST", `/api/v1/comments/${commentId}/replies`, {
118 headers,
119 body: { body: "A reply" },
120 });
121 expect([200, 201]).toContain(reply.status);
122 const row = await app.prisma.commentReply.findUniqueOrThrow({
123 where: { id: ((await json(reply)).reply as { id: string }).id },
124 });
125 expect(row.commentId).toBe(commentId);
126 expect(row.body).toBe("A reply");
127 });
128
129 it("resolve: sets resolvedAt + status; reopen clears it", async () => {
130 const { headers, pageId } = await setupPage();
131 const comment = await app.request("POST", `/api/v1/pages/${pageId}/comments`, {
132 headers,
133 body: { body: "Resolve me" },
134 });
135 const commentId = ((await json(comment)).comment as { id: string }).id;
136
137 const res = await app.request("POST", `/api/v1/comments/${commentId}/resolve`, {
138 headers,
139 });
140 expect([200]).toContain(res.status);
141 let row = await app.prisma.comment.findUniqueOrThrow({ where: { id: commentId } });
142 expect(row.resolvedAt).not.toBeNull();
143 expect(row.status).toBe("resolved");
144
145 const reopen = await app.request("POST", `/api/v1/comments/${commentId}/reopen`, {
146 headers,
147 });
148 expect([200]).toContain(reopen.status);
149 row = await app.prisma.comment.findUniqueOrThrow({ where: { id: commentId } });
150 expect(row.resolvedAt).toBeNull();
151 expect(row.status).toBe("open");
152 });
153
154 it("@mention: creates MentionNotification + a Notification for the mentioned user", async () => {
155 const { headers, workspaceId, pageId, userId } = await setupPage();
156 const mentioned = await createUser(app.request, { name: "Mentioned" });
157 await addMember(workspaceId, mentioned.email, "MEMBER");
158
159
160
161 const mentionedRow = await app.prisma.user.findUniqueOrThrow({
162 where: { email: mentioned.email },
163 });
164
165 const res = await app.request("POST", `/api/v1/pages/${pageId}/comments`, {
166 headers,
167 body: { body: `@${mentioned.email} take a look`, mentionIds: [mentionedRow.id] },
168 });
169 expect([200, 201]).toContain(res.status);
170 const commentId = ((await json(res)).comment as { id: string }).id;
171
172 const row = await app.prisma.comment.findUniqueOrThrow({ where: { id: commentId } });
173 expect(row.mentionIds).toContain(mentionedRow.id);
174
175
176 const mentionNotif = await app.prisma.mentionNotification.findFirst({
177 where: { userId: mentionedRow.id, targetId: commentId },
178 });
179 expect(mentionNotif).not.toBeNull();
180 expect(mentionNotif?.targetType).toBe("comment");
181
182
183 const notif = await app.prisma.notification.findFirst({
184 where: { userId: mentionedRow.id, type: "MENTION" },
185 });
186 expect(notif).not.toBeNull();
187
188
189 const selfMention = await app.prisma.mentionNotification.count({
190 where: { userId: userId, targetId: commentId },
191 });
192 expect(selfMention).toBe(0);
193 });
194
195 it("comment notification: workspace members get a COMMENT notification; the commenter does not", async () => {
196 const { headers, workspaceId, pageId, userId } = await setupPage();
197 const other = await createUser(app.request, { name: "Other Commenter" });
198 await addMember(workspaceId, other.email, "MEMBER");
199 const otherRow = await app.prisma.user.findUniqueOrThrow({
200 where: { email: other.email },
201 });
202
203
204 const res = await app.request("POST", `/api/v1/pages/${pageId}/comments`, {
205 headers: authHeaders(other.accessToken!),
206 body: { body: "Noted" },
207 });
208 expect([200, 201]).toContain(res.status);
209
210
211
212 const forCreator = await app.prisma.notification.findFirst({
213 where: { userId, type: "COMMENT", workspaceId },
214 });
215 expect(forCreator, "page creator should be notified of a new comment").not.toBeNull();
216
217
218 const addressedToCommenter = await app.prisma.notification.count({
219 where: { userId: otherRow.id, type: "COMMENT", workspaceId },
220 });
221 expect(addressedToCommenter).toBe(0);
222 });
223
224 it("permission: a workspace OUTSIDER cannot create a comment on the page", async () => {
225 const { headers, pageId } = await setupPage();
226 const outsider = await createUser(app.request, { name: "Comment Outsider" });
227 const res = await app.request("POST", `/api/v1/pages/${pageId}/comments`, {
228 headers: authHeaders(outsider.accessToken!),
229 body: { body: "Intrusion" },
230 });
231 expect([403, 404]).toContain(res.status);
232 const count = await app.prisma.comment.count({ where: { pageId } });
233 expect(count).toBe(0);
234 });
235
236 it("permission: a GUEST member can comment (COMMENT level) but the row is attributed correctly", async () => {
237 const owner = await createUser(app.request, { name: "GuestComment Owner" });
238 const { workspaceId } = await createWorkspace(
239 app.request,
240 authHeaders(owner.accessToken!),
241 "GuestC WS",
242 );
243 const pg = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
244 headers: authHeaders(owner.accessToken!),
245 body: { title: "G Page" },
246 });
247 const pageId = ((await json(pg)) as { id: string }).id;
248 const guest = await createUser(app.request, { name: "GuestComment Guest" });
249 await addMember(workspaceId, guest.email, "GUEST");
250 const guestRow = await app.prisma.user.findUniqueOrThrow({
251 where: { email: guest.email },
252 });
253
254 const res = await app.request("POST", `/api/v1/pages/${pageId}/comments`, {
255 headers: authHeaders(guest.accessToken!),
256 body: { body: "Guest note" },
257 });
258
259
260 if (res.status === 201 || res.status === 200) {
261 const row = await app.prisma.comment.findFirst({ where: { pageId } });
262 expect(row?.createdBy).toBe(guestRow.id);
263 } else {
264 expect(res.status).toBe(403);
265 }
266 });
267});
268