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); }); });