/** * AI Software Factory — Integration: Comments & Mentions (Phase 8) * * Comment threads on pages/blocks/records, replies, resolve/reopen, * block-anchored comments, @mentions → MentionNotification + Notification * rows for the mentioned user (and a COMMENT notification for the author), * and permission denials (outsiders cannot comment). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; async function setupPage(): Promise<{ headers: Record; workspaceId: string; pageId: string; userId: string; }> { const user = await createUser(app.request, { name: "Comment Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), "Comments WS", ); const pg = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(user.accessToken!), body: { title: "Commented Page" }, }); expect(pg.status).toBe(201); // The page-create response is the BARE page (page.service.ts `create` → // `{ ...page, databaseId, blockIds }`) — no wrapper (D4 repair, TASK-0095 S9). return { headers: authHeaders(user.accessToken!), workspaceId, pageId: ((await json(pg)) as { id: string }).id, userId: user.userId, }; } /** * Add a registered user to the workspace via the internal test seam (TASK-0095 * S9 — the D3 unblock). The public invite flow hashes the token and mails it, * so a test cannot complete it without a mail server; the seam adds the member * directly (service-token guard, enabled only in the test app). */ async function addMember( workspaceId: string, email: string, role: "MEMBER" | "GUEST" | "ADMIN" = "MEMBER", ): Promise { await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, email, role); } describe("Comments integration", () => { it("create comment on a page: stored with author, status open", async () => { const { headers, pageId, userId } = await setupPage(); const res = await app.request("POST", `/api/v1/pages/${pageId}/comments`, { headers, body: { body: "First thought" }, }); expect([200, 201]).toContain(res.status); const commentId = ((await json(res)).comment as { id: string }).id; const row = await app.prisma.comment.findUniqueOrThrow({ where: { id: commentId } }); expect(row.body).toBe("First thought"); expect(row.createdBy).toBe(userId); expect(row.status).toBe("open"); expect(row.resolvedAt).toBeNull(); expect(row.pageId).toBe(pageId); }); it("comment anchored to a block: blockId + selection stored", async () => { const { headers, workspaceId, pageId } = await setupPage(); // A block to anchor to. const blk = await app.request("POST", `/api/v1/pages/${pageId}/blocks`, { headers, body: { type: "PARAGRAPH", content: { text: "anchor text" } }, }); const blockId = ((await json(blk)).block as { id: string }).id; const res = await app.request("POST", `/api/v1/pages/${pageId}/comments`, { headers, body: { body: "About this line", blockId, selection: { anchorOffset: 0, headOffset: 7 }, }, }); expect([200, 201]).toContain(res.status); const row = await app.prisma.comment.findUniqueOrThrow({ where: { id: ((await json(res)).comment as { id: string }).id }, }); expect(row.blockId).toBe(blockId); expect(row.selection).toBeTruthy(); }); it("reply: nested under the comment, authored by the replier", async () => { const { headers, pageId } = await setupPage(); const comment = await app.request("POST", `/api/v1/pages/${pageId}/comments`, { headers, body: { body: "Top" }, }); const commentId = ((await json(comment)).comment as { id: string }).id; const reply = await app.request("POST", `/api/v1/comments/${commentId}/replies`, { headers, body: { body: "A reply" }, }); expect([200, 201]).toContain(reply.status); const row = await app.prisma.commentReply.findUniqueOrThrow({ where: { id: ((await json(reply)).reply as { id: string }).id }, }); expect(row.commentId).toBe(commentId); expect(row.body).toBe("A reply"); }); it("resolve: sets resolvedAt + status; reopen clears it", async () => { const { headers, pageId } = await setupPage(); const comment = await app.request("POST", `/api/v1/pages/${pageId}/comments`, { headers, body: { body: "Resolve me" }, }); const commentId = ((await json(comment)).comment as { id: string }).id; const res = await app.request("POST", `/api/v1/comments/${commentId}/resolve`, { headers, }); expect([200]).toContain(res.status); let row = await app.prisma.comment.findUniqueOrThrow({ where: { id: commentId } }); expect(row.resolvedAt).not.toBeNull(); expect(row.status).toBe("resolved"); const reopen = await app.request("POST", `/api/v1/comments/${commentId}/reopen`, { headers, }); expect([200]).toContain(reopen.status); row = await app.prisma.comment.findUniqueOrThrow({ where: { id: commentId } }); expect(row.resolvedAt).toBeNull(); expect(row.status).toBe("open"); }); it("@mention: creates MentionNotification + a Notification for the mentioned user", async () => { const { headers, workspaceId, pageId, userId } = await setupPage(); const mentioned = await createUser(app.request, { name: "Mentioned" }); await addMember(workspaceId, mentioned.email, "MEMBER"); // The mentioned user's id (the API resolves @mention → user id; // mentionIds stores ids). const mentionedRow = await app.prisma.user.findUniqueOrThrow({ where: { email: mentioned.email }, }); const res = await app.request("POST", `/api/v1/pages/${pageId}/comments`, { headers, body: { body: `@${mentioned.email} take a look`, mentionIds: [mentionedRow.id] }, }); expect([200, 201]).toContain(res.status); const commentId = ((await json(res)).comment as { id: string }).id; const row = await app.prisma.comment.findUniqueOrThrow({ where: { id: commentId } }); expect(row.mentionIds).toContain(mentionedRow.id); // MentionNotification for the mentioned user. const mentionNotif = await app.prisma.mentionNotification.findFirst({ where: { userId: mentionedRow.id, targetId: commentId }, }); expect(mentionNotif).not.toBeNull(); expect(mentionNotif?.targetType).toBe("comment"); // An in-app Notification row for the mentioned user. const notif = await app.prisma.notification.findFirst({ where: { userId: mentionedRow.id, type: "MENTION" }, }); expect(notif).not.toBeNull(); // The author does NOT get a mention notification for their own mention. const selfMention = await app.prisma.mentionNotification.count({ where: { userId: userId, targetId: commentId }, }); expect(selfMention).toBe(0); }); it("comment notification: workspace members get a COMMENT notification; the commenter does not", async () => { const { headers, workspaceId, pageId, userId } = await setupPage(); const other = await createUser(app.request, { name: "Other Commenter" }); await addMember(workspaceId, other.email, "MEMBER"); const otherRow = await app.prisma.user.findUniqueOrThrow({ where: { email: other.email }, }); // Comment as the OTHER user (so author != page creator). const res = await app.request("POST", `/api/v1/pages/${pageId}/comments`, { headers: authHeaders(other.accessToken!), body: { body: "Noted" }, }); expect([200, 201]).toContain(res.status); // The page creator (a member who did NOT comment) gets a COMMENT // notification. const forCreator = await app.prisma.notification.findFirst({ where: { userId, type: "COMMENT", workspaceId }, }); expect(forCreator, "page creator should be notified of a new comment").not.toBeNull(); // The commenter is never notified of their own comment. const addressedToCommenter = await app.prisma.notification.count({ where: { userId: otherRow.id, type: "COMMENT", workspaceId }, }); expect(addressedToCommenter).toBe(0); }); it("permission: a workspace OUTSIDER cannot create a comment on the page", async () => { const { headers, pageId } = await setupPage(); const outsider = await createUser(app.request, { name: "Comment Outsider" }); const res = await app.request("POST", `/api/v1/pages/${pageId}/comments`, { headers: authHeaders(outsider.accessToken!), body: { body: "Intrusion" }, }); expect([403, 404]).toContain(res.status); const count = await app.prisma.comment.count({ where: { pageId } }); expect(count).toBe(0); }); it("permission: a GUEST member can comment (COMMENT level) but the row is attributed correctly", async () => { const owner = await createUser(app.request, { name: "GuestComment Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(owner.accessToken!), "GuestC WS", ); const pg = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(owner.accessToken!), body: { title: "G Page" }, }); const pageId = ((await json(pg)) as { id: string }).id; const guest = await createUser(app.request, { name: "GuestComment Guest" }); await addMember(workspaceId, guest.email, "GUEST"); const guestRow = await app.prisma.user.findUniqueOrThrow({ where: { email: guest.email }, }); const res = await app.request("POST", `/api/v1/pages/${pageId}/comments`, { headers: authHeaders(guest.accessToken!), body: { body: "Guest note" }, }); // COMMENT level is granted to GUEST (per the permission model); if the // product tightens this to MEMBER+, the API must deny with 403. if (res.status === 201 || res.status === 200) { const row = await app.prisma.comment.findFirst({ where: { pageId } }); expect(row?.createdBy).toBe(guestRow.id); } else { expect(res.status).toBe(403); } }); });