Files
CapaKraken/packages/api/src/__tests__/assistant-approvals.test.ts
T

222 lines
7.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import {
AssistantApprovalStorageUnavailableError,
clearPendingAssistantApproval,
consumePendingAssistantApproval,
createPendingAssistantApproval,
listPendingAssistantApprovals,
peekPendingAssistantApproval,
resetAssistantApprovalStorageWarningStateForTests,
} from "../router/assistant-approvals.js";
import { logger } from "../lib/logger.js";
import {
createApprovalStoreMock,
createMissingApprovalTableError,
TEST_CONVERSATION_ID,
TEST_USER_ID,
} from "./assistant-approval-test-helpers.js";
describe("assistant approvals", () => {
let approvalStore = createApprovalStoreMock();
beforeEach(() => {
approvalStore = createApprovalStoreMock();
resetAssistantApprovalStorageWarningStateForTests();
});
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("degrades approval reads gracefully when approval storage is missing", async () => {
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => logger);
const missingTableError = createMissingApprovalTableError();
const missingStore = {
assistantApproval: {
findFirst: vi.fn(async () => {
throw missingTableError;
}),
findMany: vi.fn(async () => {
throw missingTableError;
}),
create: vi.fn(async () => {
throw missingTableError;
}),
updateMany: vi.fn(async () => {
throw missingTableError;
}),
},
};
await expect(listPendingAssistantApprovals(missingStore, TEST_USER_ID)).resolves.toEqual([]);
await expect(peekPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
await expect(consumePendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
await expect(clearPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it("returns an explicit error when approval storage is missing for mutation confirmation", async () => {
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => logger);
const missingTableError = createMissingApprovalTableError();
const missingStore = {
assistantApproval: {
findFirst: vi.fn(async () => {
throw missingTableError;
}),
findMany: vi.fn(async () => {
throw missingTableError;
}),
create: vi.fn(async () => {
throw missingTableError;
}),
updateMany: vi.fn(async () => {
throw missingTableError;
}),
},
};
await expect(createPendingAssistantApproval(
missingStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo" }),
)).rejects.toBeInstanceOf(AssistantApprovalStorageUnavailableError);
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});