fix(web): reuse project combobox in timeline popovers

This commit is contained in:
2026-03-30 13:34:59 +02:00
parent 9268a38df4
commit f0bea6235d
13 changed files with 525 additions and 203 deletions
@@ -361,6 +361,9 @@ describe("assistant router tool gating", () => {
expect(controllerNames).toContain("query_change_history");
expect(controllerNames).toContain("get_entity_timeline");
expect(controllerNames).toContain("search_by_skill");
expect(controllerNames).toContain("list_comments");
expect(controllerNames).toContain("create_comment");
expect(controllerNames).toContain("resolve_comment");
expect(controllerNames).toContain("export_resources_csv");
expect(controllerNames).toContain("export_projects_csv");
expect(controllerNames).toContain("list_audit_log_entries");
@@ -373,6 +376,9 @@ describe("assistant router tool gating", () => {
expect(userNames).not.toContain("query_change_history");
expect(userNames).not.toContain("get_entity_timeline");
expect(userNames).not.toContain("search_by_skill");
expect(userNames).not.toContain("list_comments");
expect(userNames).not.toContain("create_comment");
expect(userNames).not.toContain("resolve_comment");
expect(userNames).not.toContain("export_resources_csv");
expect(userNames).not.toContain("export_projects_csv");
expect(userNames).not.toContain("list_audit_log_entries");
@@ -957,6 +963,11 @@ describe("assistant router tool gating", () => {
expect(toolDescriptions.get("get_current_user")).toContain("authenticated user's own profile");
expect(toolDescriptions.get("search_resources")).toContain("Resource overview access required");
expect(toolDescriptions.get("search_by_skill")).toContain("Controller/manager/admin access required");
expect(toolDescriptions.get("list_comments")).toContain("Currently only estimate comments are enabled");
expect(toolDescriptions.get("list_comments")).toContain("Controller/manager/admin access required");
expect(toolDescriptions.get("create_comment")).toContain("Currently only estimate comments are enabled");
expect(toolDescriptions.get("create_comment")).toContain("Controller/manager/admin access required");
expect(toolDescriptions.get("resolve_comment")).toContain("Currently only estimate comments are enabled");
expect(toolDescriptions.get("list_notifications")).toContain("current user");
expect(toolDescriptions.get("get_unread_notification_count")).toContain("current user");
expect(toolDescriptions.get("list_tasks")).toContain("current user");
@@ -4845,10 +4845,12 @@ describe("assistant import/export and dispo tools", () => {
const commentFindUnique = vi.fn().mockResolvedValue({
id: "comment_1",
authorId: "user_1",
entityType: "estimate",
entityId: "est_1",
});
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
estimate: {
findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
},
comment: {
findMany: vi.fn().mockResolvedValue([
@@ -4931,8 +4933,17 @@ describe("assistant import/export and dispo tools", () => {
},
orderBy: { createdAt: "asc" },
});
expect(db.user.findUnique).toHaveBeenCalledWith({
where: { email: "assistant@example.com" },
expect(db.estimate.findUnique).toHaveBeenCalledTimes(3);
expect(db.estimate.findUnique).toHaveBeenNthCalledWith(1, {
where: { id: "est_1" },
select: { id: true },
});
expect(db.estimate.findUnique).toHaveBeenNthCalledWith(2, {
where: { id: "est_1" },
select: { id: true },
});
expect(db.estimate.findUnique).toHaveBeenNthCalledWith(3, {
where: { id: "est_1" },
select: { id: true },
});
expect(db.comment.create).toHaveBeenCalledWith({
@@ -4949,7 +4960,7 @@ describe("assistant import/export and dispo tools", () => {
});
expect(commentFindUnique).toHaveBeenCalledWith({
where: { id: "comment_1" },
select: { id: true, authorId: true },
select: { id: true, authorId: true, entityType: true, entityId: true },
});
expect(db.comment.update).toHaveBeenCalledWith({
where: { id: "comment_1" },
@@ -5003,7 +5014,7 @@ describe("assistant import/export and dispo tools", () => {
it("returns a stable assistant error when creating a comment with an empty body", async () => {
const ctx = createToolContext(
{
user: {
estimate: {
findUnique: vi.fn(),
},
comment: {
@@ -5026,14 +5037,13 @@ describe("assistant import/export and dispo tools", () => {
expect(JSON.parse(result.content)).toEqual({
error: "Comment body is required.",
});
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
expect(ctx.db.comment.create).not.toHaveBeenCalled();
});
it("returns a stable assistant error when creating a comment with a body that is too long", async () => {
const ctx = createToolContext(
{
user: {
estimate: {
findUnique: vi.fn(),
},
comment: {
@@ -5056,15 +5066,14 @@ describe("assistant import/export and dispo tools", () => {
expect(JSON.parse(result.content)).toEqual({
error: "Comment body must be at most 10000 characters.",
});
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
expect(ctx.db.comment.create).not.toHaveBeenCalled();
});
it("returns a stable assistant error when the comment author disappears during creation", async () => {
const ctx = createToolContext(
{
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1" }),
estimate: {
findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
},
comment: {
create: vi.fn().mockRejectedValue({
@@ -5095,8 +5104,8 @@ describe("assistant import/export and dispo tools", () => {
it("returns a stable assistant error when a mentioned user disappears during comment creation", async () => {
const ctx = createToolContext(
{
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1" }),
estimate: {
findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
},
comment: {
create: vi.fn().mockResolvedValue({
@@ -5170,8 +5179,13 @@ describe("assistant import/export and dispo tools", () => {
findUnique: vi.fn().mockResolvedValue({
id: "comment_1",
authorId: "user_2",
entityType: "estimate",
entityId: "est_1",
}),
},
estimate: {
findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
},
},
{ userRole: SystemRole.CONTROLLER },
);
@@ -0,0 +1,261 @@
import { SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { commentRouter } from "../router/comment.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(commentRouter);
function createContext(
db: Record<string, unknown>,
options: {
role?: SystemRole;
session?: boolean;
} = {},
) {
const { role = SystemRole.USER, session = true } = options;
return {
session: session
? {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
}
: null,
db: db as never,
dbUser: session
? {
id: role === SystemRole.ADMIN ? "user_admin" : "user_1",
systemRole: role,
permissionOverrides: null,
}
: null,
};
}
describe("comment router authorization", () => {
it("requires authentication before listing estimate comments", async () => {
const estimateFindUnique = vi.fn();
const commentFindMany = vi.fn();
const caller = createCaller(createContext({
estimate: {
findUnique: estimateFindUnique,
},
comment: {
findMany: commentFindMany,
},
}, { session: false }));
await expect(caller.list({ entityType: "estimate", entityId: "est_1" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "Authentication required",
});
expect(estimateFindUnique).not.toHaveBeenCalled();
expect(commentFindMany).not.toHaveBeenCalled();
});
it("forbids plain users from reading or creating estimate comments", async () => {
const estimateFindUnique = vi.fn();
const commentFindMany = vi.fn();
const commentCount = vi.fn();
const commentCreate = vi.fn();
const caller = createCaller(createContext({
estimate: {
findUnique: estimateFindUnique,
},
comment: {
findMany: commentFindMany,
count: commentCount,
create: commentCreate,
},
}));
await expect(caller.list({ entityType: "estimate", entityId: "est_1" })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Controller access required",
});
await expect(caller.count({ entityType: "estimate", entityId: "est_1" })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Controller access required",
});
await expect(caller.create({
entityType: "estimate",
entityId: "est_1",
body: "Please review this estimate.",
})).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Controller access required",
});
expect(estimateFindUnique).not.toHaveBeenCalled();
expect(commentFindMany).not.toHaveBeenCalled();
expect(commentCount).not.toHaveBeenCalled();
expect(commentCreate).not.toHaveBeenCalled();
});
it("allows controllers to list, count, and create estimate comments", async () => {
const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_1" });
const commentFindMany = vi.fn().mockResolvedValue([]);
const commentCount = vi.fn().mockResolvedValue(2);
const commentCreate = vi.fn().mockResolvedValue({
id: "comment_1",
body: "Please review this estimate.",
author: { id: "user_1", name: "Controller User", email: "user@example.com", image: null },
});
const caller = createCaller(createContext({
estimate: {
findUnique: estimateFindUnique,
},
comment: {
findMany: commentFindMany,
count: commentCount,
create: commentCreate,
},
notification: {
create: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
}, { role: SystemRole.CONTROLLER }));
const listResult = await caller.list({ entityType: "estimate", entityId: "est_1" });
const countResult = await caller.count({ entityType: "estimate", entityId: "est_1" });
const createResult = await caller.create({
entityType: "estimate",
entityId: "est_1",
body: "Please review this estimate.",
});
expect(listResult).toEqual([]);
expect(countResult).toBe(2);
expect(createResult.id).toBe("comment_1");
expect(estimateFindUnique).toHaveBeenCalledTimes(3);
expect(commentCreate).toHaveBeenCalledWith({
data: {
entityType: "estimate",
entityId: "est_1",
authorId: "user_1",
body: "Please review this estimate.",
mentions: [],
},
include: {
author: { select: { id: true, name: true, email: true, image: true } },
},
});
});
it("rejects unsupported comment entity types before touching the database", async () => {
const estimateFindUnique = vi.fn();
const commentFindMany = vi.fn();
const caller = createCaller(createContext({
estimate: {
findUnique: estimateFindUnique,
},
comment: {
findMany: commentFindMany,
},
}, { role: SystemRole.CONTROLLER }));
await expect(caller.list({
entityType: "scope_item" as never,
entityId: "scope_1",
})).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(estimateFindUnique).not.toHaveBeenCalled();
expect(commentFindMany).not.toHaveBeenCalled();
});
it("rejects replies whose parent comment belongs to another entity", async () => {
const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_1" });
const parentFindUnique = vi.fn().mockResolvedValue({
id: "comment_parent",
entityType: "estimate",
entityId: "est_2",
});
const commentCreate = vi.fn();
const caller = createCaller(createContext({
estimate: {
findUnique: estimateFindUnique,
},
comment: {
findUnique: parentFindUnique,
create: commentCreate,
},
}, { role: SystemRole.CONTROLLER }));
await expect(caller.create({
entityType: "estimate",
entityId: "est_1",
parentId: "comment_parent",
body: "Replying on the right estimate.",
})).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "Parent comment does not belong to the requested entity",
});
expect(commentCreate).not.toHaveBeenCalled();
});
it("requires comment authorship or admin rights after entity visibility is granted", async () => {
const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_1" });
const commentFindUnique = vi.fn().mockResolvedValue({
id: "comment_1",
authorId: "user_2",
entityType: "estimate",
entityId: "est_1",
});
const commentUpdate = vi.fn();
const controllerCaller = createCaller(createContext({
estimate: {
findUnique: estimateFindUnique,
},
comment: {
findUnique: commentFindUnique,
update: commentUpdate,
},
}, { role: SystemRole.CONTROLLER }));
await expect(controllerCaller.resolve({ id: "comment_1", resolved: true })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Only the comment author or an admin can resolve comments",
});
const adminUpdate = vi.fn().mockResolvedValue({
id: "comment_1",
body: "Needs review",
resolved: true,
author: { id: "user_2", name: "Other User", email: "other@example.com", image: null },
});
const adminCaller = createCaller(createContext({
estimate: {
findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
},
comment: {
findUnique: vi.fn().mockResolvedValue({
id: "comment_1",
authorId: "user_2",
entityType: "estimate",
entityId: "est_1",
}),
update: adminUpdate,
},
auditLog: {
create: vi.fn(),
},
}, { role: SystemRole.ADMIN }));
const result = await adminCaller.resolve({ id: "comment_1", resolved: true });
expect(result.resolved).toBe(true);
expect(adminUpdate).toHaveBeenCalledWith({
where: { id: "comment_1" },
data: { resolved: true },
include: {
author: { select: { id: true, name: true, email: true, image: true } },
},
});
});
});