refactor(api): extract comment procedures
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user