222 lines
7.5 KiB
TypeScript
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);
|
|
});
|
|
});
|