239 lines
7.0 KiB
TypeScript
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" },
|
|
});
|
|
});
|
|
});
|