feat(assistant): add approval inbox and e2e hardening
This commit is contained in:
@@ -1,12 +1,183 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PermissionKey, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||
import { getAvailableAssistantTools } from "../router/assistant.js";
|
||||
import {
|
||||
ASSISTANT_CONFIRMATION_PREFIX,
|
||||
canExecuteMutationTool,
|
||||
clearPendingAssistantApproval,
|
||||
consumePendingAssistantApproval,
|
||||
createPendingAssistantApproval,
|
||||
getAvailableAssistantTools,
|
||||
listPendingAssistantApprovals,
|
||||
peekPendingAssistantApproval,
|
||||
} from "../router/assistant.js";
|
||||
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
|
||||
|
||||
function getToolNames(permissions: PermissionKeyValue[]) {
|
||||
return getAvailableAssistantTools(new Set(permissions)).map((tool) => tool.function.name);
|
||||
}
|
||||
|
||||
const TEST_USER_ID = "assistant-test-user";
|
||||
const TEST_CONVERSATION_ID = "assistant-test-conversation";
|
||||
|
||||
function createApprovalStoreMock() {
|
||||
const records = new Map<string, {
|
||||
id: string;
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
toolName: string;
|
||||
toolArguments: string;
|
||||
summary: string;
|
||||
status: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
|
||||
approvedAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
updatedAt: Date;
|
||||
}>();
|
||||
|
||||
return {
|
||||
assistantApproval: {
|
||||
findFirst: vi.fn(async ({
|
||||
where,
|
||||
orderBy,
|
||||
}: {
|
||||
where: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
conversationId?: string;
|
||||
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
|
||||
};
|
||||
orderBy?: { createdAt: "desc" | "asc" };
|
||||
}) => {
|
||||
const matches = [...records.values()]
|
||||
.filter((record) => (
|
||||
(!where.id || record.id === where.id)
|
||||
&& (!where.userId || record.userId === where.userId)
|
||||
&& (!where.conversationId || record.conversationId === where.conversationId)
|
||||
&& (!where.status || record.status === where.status)
|
||||
))
|
||||
.sort((a, b) => (
|
||||
orderBy?.createdAt === "asc"
|
||||
? a.createdAt.getTime() - b.createdAt.getTime()
|
||||
: b.createdAt.getTime() - a.createdAt.getTime()
|
||||
));
|
||||
return matches[0] ?? null;
|
||||
}),
|
||||
findMany: vi.fn(async ({
|
||||
where,
|
||||
orderBy,
|
||||
}: {
|
||||
where: {
|
||||
userId?: string;
|
||||
conversationId?: string;
|
||||
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
|
||||
expiresAt?: { lte?: Date; gt?: Date };
|
||||
};
|
||||
orderBy?: { createdAt: "desc" | "asc" };
|
||||
}) => (
|
||||
[...records.values()]
|
||||
.filter((record) => (
|
||||
(!where.userId || record.userId === where.userId)
|
||||
&& (!where.conversationId || record.conversationId === where.conversationId)
|
||||
&& (!where.status || record.status === where.status)
|
||||
&& (!where.expiresAt?.lte || record.expiresAt <= where.expiresAt.lte)
|
||||
&& (!where.expiresAt?.gt || record.expiresAt > where.expiresAt.gt)
|
||||
))
|
||||
.sort((a, b) => (
|
||||
orderBy?.createdAt === "asc"
|
||||
? a.createdAt.getTime() - b.createdAt.getTime()
|
||||
: b.createdAt.getTime() - a.createdAt.getTime()
|
||||
))
|
||||
)),
|
||||
create: vi.fn(async ({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
toolName: string;
|
||||
toolArguments: string;
|
||||
summary: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
};
|
||||
}) => {
|
||||
const record = {
|
||||
id: `approval-${records.size + 1}`,
|
||||
...data,
|
||||
status: "PENDING" as const,
|
||||
approvedAt: null,
|
||||
cancelledAt: null,
|
||||
updatedAt: data.createdAt,
|
||||
};
|
||||
records.set(record.id, record);
|
||||
return record;
|
||||
}),
|
||||
updateMany: vi.fn(async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
conversationId?: string;
|
||||
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
|
||||
expiresAt?: { lte?: Date; gt?: Date };
|
||||
};
|
||||
data: Partial<{
|
||||
status: "APPROVED" | "CANCELLED" | "EXPIRED";
|
||||
cancelledAt: Date;
|
||||
approvedAt: Date;
|
||||
}>;
|
||||
}) => {
|
||||
let count = 0;
|
||||
for (const [id, record] of records.entries()) {
|
||||
if (where.id && record.id !== where.id) continue;
|
||||
if (where.userId && record.userId !== where.userId) continue;
|
||||
if (where.conversationId && record.conversationId !== where.conversationId) continue;
|
||||
if (where.status && record.status !== where.status) continue;
|
||||
if (where.expiresAt?.lte && record.expiresAt > where.expiresAt.lte) continue;
|
||||
if (where.expiresAt?.gt && record.expiresAt <= where.expiresAt.gt) continue;
|
||||
records.set(id, {
|
||||
...record,
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
return { count };
|
||||
}),
|
||||
update: vi.fn(async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id: string };
|
||||
data: {
|
||||
status: "APPROVED";
|
||||
approvedAt: Date;
|
||||
};
|
||||
}) => {
|
||||
const record = records.get(where.id);
|
||||
if (!record) throw new Error("Record not found");
|
||||
const next = {
|
||||
...record,
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
records.set(where.id, next);
|
||||
return next;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant router tool gating", () => {
|
||||
let approvalStore = createApprovalStoreMock();
|
||||
|
||||
beforeEach(() => {
|
||||
approvalStore = createApprovalStoreMock();
|
||||
});
|
||||
|
||||
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
|
||||
const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]);
|
||||
const withAdvanced = getToolNames([
|
||||
@@ -31,4 +202,200 @@ describe("assistant router tool gating", () => {
|
||||
|
||||
expect(names).not.toContain("find_best_project_resource");
|
||||
});
|
||||
|
||||
it("blocks mutation tools until the user confirms a prior assistant summary", () => {
|
||||
expect(canExecuteMutationTool([
|
||||
{ role: "user", content: "Lege bitte ein Projekt an" },
|
||||
], "create_project")).toBe(false);
|
||||
|
||||
expect(canExecuteMutationTool([
|
||||
{ role: "user", content: "Lege bitte ein Projekt an" },
|
||||
{ role: "assistant", content: "Ich werde jetzt das Projekt erstellen." },
|
||||
{ role: "user", content: "ja" },
|
||||
], "create_project")).toBe(false);
|
||||
|
||||
expect(canExecuteMutationTool([
|
||||
{ role: "user", content: "Lege bitte ein Projekt an" },
|
||||
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} Ich werde das Projekt \"Apollo\" in DRAFT anlegen. Bitte bestätigen.` },
|
||||
{ role: "user", content: "ja, bitte ausführen" },
|
||||
], "create_project")).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a matching server-side pending approval for mutation execution when provided", async () => {
|
||||
const pendingApproval = await createPendingAssistantApproval(
|
||||
approvalStore,
|
||||
TEST_USER_ID,
|
||||
TEST_CONVERSATION_ID,
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Apollo", status: "DRAFT" }),
|
||||
);
|
||||
|
||||
expect(canExecuteMutationTool([
|
||||
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.` },
|
||||
{ role: "user", content: "ja" },
|
||||
], "create_project", pendingApproval)).toBe(true);
|
||||
|
||||
expect(canExecuteMutationTool([
|
||||
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.` },
|
||||
{ role: "user", content: "ja" },
|
||||
], "delete_project", pendingApproval)).toBe(false);
|
||||
});
|
||||
|
||||
it("stores and consumes pending approvals independently from chat text", async () => {
|
||||
const approval = await createPendingAssistantApproval(
|
||||
approvalStore,
|
||||
TEST_USER_ID,
|
||||
TEST_CONVERSATION_ID,
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Gelddruckmaschine", status: "DRAFT" }),
|
||||
);
|
||||
|
||||
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toMatchObject({
|
||||
id: approval.id,
|
||||
toolName: "create_project",
|
||||
summary: expect.stringContaining("create project"),
|
||||
});
|
||||
|
||||
await expect(consumePendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toMatchObject({
|
||||
id: approval.id,
|
||||
toolName: "create_project",
|
||||
});
|
||||
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("expires stale pending approvals", async () => {
|
||||
await createPendingAssistantApproval(
|
||||
approvalStore,
|
||||
TEST_USER_ID,
|
||||
TEST_CONVERSATION_ID,
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Apollo" }),
|
||||
{ ttlMs: -1 },
|
||||
);
|
||||
|
||||
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("clears pending approvals for cancellation semantics", async () => {
|
||||
await createPendingAssistantApproval(
|
||||
approvalStore,
|
||||
TEST_USER_ID,
|
||||
TEST_CONVERSATION_ID,
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Apollo" }),
|
||||
);
|
||||
|
||||
await clearPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID);
|
||||
|
||||
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("isolates pending approvals by conversation", async () => {
|
||||
const otherConversationId = `${TEST_CONVERSATION_ID}-other`;
|
||||
|
||||
await createPendingAssistantApproval(
|
||||
approvalStore,
|
||||
TEST_USER_ID,
|
||||
TEST_CONVERSATION_ID,
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Apollo" }),
|
||||
);
|
||||
await createPendingAssistantApproval(
|
||||
approvalStore,
|
||||
TEST_USER_ID,
|
||||
otherConversationId,
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Hermes" }),
|
||||
);
|
||||
|
||||
await clearPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID);
|
||||
|
||||
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, otherConversationId)).resolves.toMatchObject({
|
||||
toolName: "create_project",
|
||||
summary: expect.stringContaining("Hermes"),
|
||||
});
|
||||
});
|
||||
|
||||
it("lists only still-pending approvals for the current user across conversations", async () => {
|
||||
const otherConversationId = `${TEST_CONVERSATION_ID}-other`;
|
||||
|
||||
await createPendingAssistantApproval(
|
||||
approvalStore,
|
||||
TEST_USER_ID,
|
||||
TEST_CONVERSATION_ID,
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Apollo" }),
|
||||
);
|
||||
await createPendingAssistantApproval(
|
||||
approvalStore,
|
||||
TEST_USER_ID,
|
||||
otherConversationId,
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Hermes" }),
|
||||
);
|
||||
|
||||
const cancelled = await createPendingAssistantApproval(
|
||||
approvalStore,
|
||||
TEST_USER_ID,
|
||||
`${TEST_CONVERSATION_ID}-cancelled`,
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Cancelled" }),
|
||||
);
|
||||
await approvalStore.assistantApproval.updateMany({
|
||||
where: { id: cancelled.id, userId: TEST_USER_ID, status: "PENDING" },
|
||||
data: { status: "CANCELLED", cancelledAt: new Date() },
|
||||
});
|
||||
|
||||
await createPendingAssistantApproval(
|
||||
approvalStore,
|
||||
"other-user",
|
||||
`${TEST_CONVERSATION_ID}-foreign`,
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Foreign" }),
|
||||
);
|
||||
await createPendingAssistantApproval(
|
||||
approvalStore,
|
||||
TEST_USER_ID,
|
||||
`${TEST_CONVERSATION_ID}-expired`,
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Expired" }),
|
||||
{ ttlMs: -1 },
|
||||
);
|
||||
|
||||
const approvals = await listPendingAssistantApprovals(approvalStore, TEST_USER_ID);
|
||||
const approvalSummaries = approvals.map((approval) => approval.summary).join(" ");
|
||||
|
||||
expect(approvals).toHaveLength(2);
|
||||
expect([...approvals.map((approval) => approval.conversationId)].sort()).toEqual([
|
||||
otherConversationId,
|
||||
TEST_CONVERSATION_ID,
|
||||
].sort());
|
||||
expect(approvals.every((approval) => approval.userId === TEST_USER_ID)).toBe(true);
|
||||
expect(approvalSummaries).toContain("Apollo");
|
||||
expect(approvalSummaries).toContain("Hermes");
|
||||
expect(approvalSummaries).not.toContain("Cancelled");
|
||||
expect(approvalSummaries).not.toContain("Expired");
|
||||
expect(approvalSummaries).not.toContain("Foreign");
|
||||
});
|
||||
|
||||
it("does not require confirmation for read-only assistant tools", () => {
|
||||
expect(canExecuteMutationTool([
|
||||
{ role: "user", content: "Zeig mir meine Notifications" },
|
||||
], "list_notifications")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps assistant tool descriptions aligned with runtime permissions", () => {
|
||||
const toolDescriptions = new Map(
|
||||
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.function.description]),
|
||||
);
|
||||
|
||||
expect(toolDescriptions.get("create_estimate")).toContain("manageProjects");
|
||||
expect(toolDescriptions.get("set_entitlement")).toContain("manageVacations");
|
||||
expect(toolDescriptions.get("create_org_unit")).toContain("manageResources");
|
||||
expect(toolDescriptions.get("update_org_unit")).toContain("manageResources");
|
||||
expect(toolDescriptions.get("list_users")).toContain("manageUsers");
|
||||
expect(toolDescriptions.get("create_task_for_user")).toContain("manageProjects");
|
||||
expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
...actual,
|
||||
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
||||
getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
|
||||
listAssignmentBookings: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -201,6 +202,346 @@ describe("assistant advanced tools and scoping", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns timeline entries view with demand, assignment, and holiday overlay context", async () => {
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
demandRequirement: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "dem_1",
|
||||
projectId: "project_1",
|
||||
resourceId: null,
|
||||
role: "Artist",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-09T00:00:00.000Z"),
|
||||
status: "OPEN",
|
||||
metadata: null,
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
orderType: "CHARGEABLE",
|
||||
clientId: "client_1",
|
||||
budgetCents: 0,
|
||||
winProbability: 100,
|
||||
status: "ACTIVE",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
staffingReqs: null,
|
||||
responsiblePerson: "Larissa",
|
||||
color: "#fff",
|
||||
},
|
||||
roleEntity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "asg_by",
|
||||
projectId: "project_1",
|
||||
resourceId: "res_by",
|
||||
role: "Artist",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-09T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
metadata: null,
|
||||
resource: {
|
||||
id: "res_by",
|
||||
displayName: "Bayern User",
|
||||
eid: "EMP-BY",
|
||||
chapter: "Delivery",
|
||||
lcrCents: 10000,
|
||||
},
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
orderType: "CHARGEABLE",
|
||||
clientId: "client_1",
|
||||
budgetCents: 0,
|
||||
winProbability: 100,
|
||||
status: "ACTIVE",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
staffingReqs: null,
|
||||
responsiblePerson: "Larissa",
|
||||
color: "#fff",
|
||||
},
|
||||
roleEntity: null,
|
||||
},
|
||||
{
|
||||
id: "asg_hh",
|
||||
projectId: "project_1",
|
||||
resourceId: "res_hh",
|
||||
role: "Artist",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-09T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
metadata: null,
|
||||
resource: {
|
||||
id: "res_hh",
|
||||
displayName: "Hamburg User",
|
||||
eid: "EMP-HH",
|
||||
chapter: "Delivery",
|
||||
lcrCents: 10000,
|
||||
},
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
orderType: "CHARGEABLE",
|
||||
clientId: "client_1",
|
||||
budgetCents: 0,
|
||||
winProbability: 100,
|
||||
status: "ACTIVE",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
staffingReqs: null,
|
||||
responsiblePerson: "Larissa",
|
||||
color: "#fff",
|
||||
},
|
||||
roleEntity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_by",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
},
|
||||
{
|
||||
id: "res_hh",
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: "city_hamburg",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
[PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_timeline_entries_view",
|
||||
JSON.stringify({
|
||||
startDate: "2026-01-05",
|
||||
endDate: "2026-01-09",
|
||||
projectIds: ["project_1"],
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
summary: {
|
||||
demandCount: number;
|
||||
assignmentCount: number;
|
||||
overlayCount: number;
|
||||
resourceCount: number;
|
||||
};
|
||||
demands: Array<{ id: string }>;
|
||||
assignments: Array<{ id: string }>;
|
||||
holidayOverlays: Array<{ resourceId: string; startDate: string; note: string; scope: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.summary).toEqual(
|
||||
expect.objectContaining({
|
||||
demandCount: 1,
|
||||
assignmentCount: 2,
|
||||
overlayCount: 1,
|
||||
resourceCount: 2,
|
||||
}),
|
||||
);
|
||||
expect(parsed.demands).toHaveLength(1);
|
||||
expect(parsed.assignments).toHaveLength(2);
|
||||
expect(parsed.holidayOverlays).toEqual([
|
||||
expect.objectContaining({
|
||||
resourceId: "res_by",
|
||||
startDate: "2026-01-06",
|
||||
note: "Heilige Drei Könige",
|
||||
scope: "STATE",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns project timeline context with cross-project overlap summaries", async () => {
|
||||
const project = {
|
||||
id: "project_ctx",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
orderType: "CHARGEABLE",
|
||||
budgetCents: 100000,
|
||||
winProbability: 100,
|
||||
status: "ACTIVE",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
staffingReqs: null,
|
||||
};
|
||||
|
||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "asg_project",
|
||||
projectId: "project_ctx",
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
hoursPerDay: 6,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: { id: "project_ctx", name: "Gelddruckmaschine", shortCode: "GDM", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null },
|
||||
resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" },
|
||||
},
|
||||
{
|
||||
id: "asg_other",
|
||||
projectId: "project_other",
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-08T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-10T00:00:00.000Z"),
|
||||
hoursPerDay: 4,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: { id: "project_other", name: "Other Project", shortCode: "OTH", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null },
|
||||
resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" },
|
||||
},
|
||||
]);
|
||||
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
project: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(project)
|
||||
.mockResolvedValueOnce(project),
|
||||
},
|
||||
demandRequirement: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "dem_ctx",
|
||||
projectId: "project_ctx",
|
||||
resourceId: null,
|
||||
role: "Artist",
|
||||
hoursPerDay: 6,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "OPEN",
|
||||
metadata: null,
|
||||
project,
|
||||
roleEntity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "asg_project",
|
||||
projectId: "project_ctx",
|
||||
resourceId: "res_1",
|
||||
role: "Artist",
|
||||
hoursPerDay: 6,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
metadata: null,
|
||||
resource: {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
eid: "EMP-1",
|
||||
chapter: "Delivery",
|
||||
lcrCents: 10000,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
},
|
||||
project,
|
||||
roleEntity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_1",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
[PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_project_timeline_context",
|
||||
JSON.stringify({
|
||||
projectIdentifier: "project_ctx",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
project: { id: string; shortCode: string };
|
||||
summary: {
|
||||
demandCount: number;
|
||||
assignmentCount: number;
|
||||
conflictedAssignmentCount: number;
|
||||
overlayCount: number;
|
||||
};
|
||||
assignmentConflicts: Array<{
|
||||
assignmentId: string;
|
||||
crossProjectOverlapCount: number;
|
||||
overlaps: Array<{ projectShortCode: string; sameProject: boolean }>;
|
||||
}>;
|
||||
holidayOverlays: Array<{ startDate: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.project).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "project_ctx",
|
||||
shortCode: "GDM",
|
||||
}),
|
||||
);
|
||||
expect(parsed.summary).toEqual(
|
||||
expect.objectContaining({
|
||||
demandCount: 1,
|
||||
assignmentCount: 1,
|
||||
conflictedAssignmentCount: 1,
|
||||
overlayCount: 1,
|
||||
}),
|
||||
);
|
||||
expect(parsed.assignmentConflicts).toEqual([
|
||||
expect.objectContaining({
|
||||
assignmentId: "asg_project",
|
||||
crossProjectOverlapCount: 1,
|
||||
overlaps: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
projectShortCode: "OTH",
|
||||
sameProject: false,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
expect(parsed.holidayOverlays).toEqual([
|
||||
expect.objectContaining({
|
||||
startDate: "2026-01-06",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes assistant notification listing to the current user", async () => {
|
||||
const findMany = vi.fn().mockResolvedValue([]);
|
||||
const ctx = createToolContext({
|
||||
|
||||
Reference in New Issue
Block a user