import { TRPCError } from "@trpc/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { countComments, createComment, deleteComment, listCommentMentionCandidates, listComments, resolveComment, } from "../router/comment-procedure-support.js"; const { assertCommentEntityAccess, createNotification, createAuditEntry, } = vi.hoisted(() => ({ assertCommentEntityAccess: vi.fn(), createNotification: vi.fn(), createAuditEntry: vi.fn(), })); vi.mock("../lib/comment-entity-registry.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, assertCommentEntityAccess, }; }); vi.mock("../lib/create-notification.js", () => ({ createNotification, })); vi.mock("../lib/audit.js", () => ({ createAuditEntry, })); function createContext(overrides: Record = {}) { return { db: { comment: { findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(2), findUnique: vi.fn().mockResolvedValue({ id: "comment_1", authorId: "user_1", entityType: "estimate", entityId: "est_1", }), create: vi.fn().mockResolvedValue({ id: "comment_1", body: "Hi @[Bob](user_2)", author: { id: "user_1", name: "Alice", email: "alice@example.com", image: null }, }), update: vi.fn().mockResolvedValue({ id: "comment_1", resolved: true, author: { id: "user_1", name: "Alice", email: "alice@example.com", image: null }, }), deleteMany: vi.fn().mockResolvedValue({ count: 1 }), delete: vi.fn().mockResolvedValue(undefined), }, }, dbUser: { id: "user_1", systemRole: "CONTROLLER", permissionOverrides: null, }, roleDefaults: null, ...overrides, }; } describe("comment procedure support", () => { beforeEach(() => { assertCommentEntityAccess.mockReset(); createNotification.mockReset(); createAuditEntry.mockReset(); assertCommentEntityAccess.mockResolvedValue({ buildLink: (entityId: string) => `/estimates/${entityId}?tab=comments`, listMentionCandidates: vi.fn().mockResolvedValue([ { id: "user_2", name: "Bob", email: "bob@example.com" }, ]), }); }); it("lists and counts comments after access checks", async () => { const ctx = createContext(); const listResult = await listComments(ctx as never, { entityType: "estimate", entityId: "est_1", }); const countResult = await countComments(ctx as never, { entityType: "estimate", entityId: "est_1", }); expect(listResult).toEqual([]); expect(countResult).toBe(2); expect(assertCommentEntityAccess).toHaveBeenNthCalledWith(1, ctx, "estimate", "est_1"); expect(assertCommentEntityAccess).toHaveBeenNthCalledWith(2, ctx, "estimate", "est_1"); }); it("normalizes mention candidate queries via the policy returned from access checks", async () => { const listMentionCandidates = vi.fn().mockResolvedValue([ { id: "user_2", name: "Bob", email: "bob@example.com" }, ]); assertCommentEntityAccess.mockResolvedValue({ buildLink: (entityId: string) => `/estimates/${entityId}?tab=comments`, listMentionCandidates, }); const result = await listCommentMentionCandidates(createContext() as never, { entityType: "estimate", entityId: "est_1", query: "", }); expect(result).toEqual([{ id: "user_2", name: "Bob", email: "bob@example.com" }]); expect(listMentionCandidates).toHaveBeenCalledWith(expect.anything(), "est_1", undefined); }); it("creates comments, validates parent ownership, and sends mention notifications", async () => { const ctx = createContext({ db: { comment: { findMany: vi.fn(), count: vi.fn(), findUnique: vi.fn().mockResolvedValue({ id: "comment_parent", entityType: "estimate", entityId: "est_1", }), create: vi.fn().mockResolvedValue({ id: "comment_1", body: "Hi @[Bob](user_2) and @[Alice](user_1)", author: { id: "user_1", name: "Alice", email: "alice@example.com", image: null }, }), update: vi.fn(), deleteMany: vi.fn(), delete: vi.fn(), }, }, }); const result = await createComment(ctx as never, { entityType: "estimate", entityId: "est_1", parentId: "comment_parent", body: "Hi @[Bob](user_2) and @[Alice](user_1)", }); expect(result.id).toBe("comment_1"); expect(ctx.db.comment.create).toHaveBeenCalledWith({ data: { entityType: "estimate", entityId: "est_1", parentId: "comment_parent", authorId: "user_1", body: "Hi @[Bob](user_2) and @[Alice](user_1)", mentions: ["user_2", "user_1"], }, include: { author: { select: { id: true, name: true, email: true, image: true } }, }, }); expect(createNotification).toHaveBeenCalledTimes(1); expect(createNotification).toHaveBeenCalledWith(expect.objectContaining({ userId: "user_2", entityId: "est_1", entityType: "estimate", })); }); it("rejects mismatched parent entities before creating a reply", async () => { const ctx = createContext({ db: { comment: { findMany: vi.fn(), count: vi.fn(), findUnique: vi.fn().mockResolvedValue({ id: "comment_parent", entityType: "estimate", entityId: "est_other", }), create: vi.fn(), update: vi.fn(), deleteMany: vi.fn(), delete: vi.fn(), }, }, }); await expect(createComment(ctx as never, { entityType: "estimate", entityId: "est_1", parentId: "comment_parent", body: "Reply", })).rejects.toThrowError(new TRPCError({ code: "BAD_REQUEST", message: "Parent comment does not belong to the requested entity", })); expect(ctx.db.comment.create).not.toHaveBeenCalled(); }); it("resolves and deletes comments only after management checks", async () => { const ctx = createContext({ dbUser: { id: "user_admin", systemRole: "ADMIN", permissionOverrides: null, }, }); const resolved = await resolveComment(ctx as never, { id: "comment_1", resolved: true, }); await deleteComment(ctx as never, { id: "comment_1" }); expect(resolved.resolved).toBe(true); expect(ctx.db.comment.update).toHaveBeenCalledWith({ where: { id: "comment_1" }, data: { resolved: true }, include: { author: { select: { id: true, name: true, email: true, image: true } }, }, }); expect(ctx.db.comment.deleteMany).toHaveBeenCalledWith({ where: { parentId: "comment_1" }, }); expect(ctx.db.comment.delete).toHaveBeenCalledWith({ where: { id: "comment_1" }, }); }); });