Files
CapaKraken/packages/api/src/__tests__/comment-procedure-support.test.ts
T

239 lines
7.0 KiB
TypeScript

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<typeof import("../lib/comment-entity-registry.js")>();
return {
...actual,
assertCommentEntityAccess,
};
});
vi.mock("../lib/create-notification.js", () => ({
createNotification,
}));
vi.mock("../lib/audit.js", () => ({
createAuditEntry,
}));
function createContext(overrides: Record<string, unknown> = {}) {
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" },
});
});
});