import { describe, expect, it, vi, afterEach } from "vitest"; import type { PrismaClient } from "@prisma/client"; import type { PermissionAuthorizer } from "../../src/permission.js"; import type { RuntimeSettings } from "../../src/settings/runtime.js"; import { sendApprovalCard, resolveCard, type CardActionEvent, type FeishuRuntime } from "../../src/feishu/client.js"; import { ApprovalManager, makeTriggerHandler } from "../../src/feishu/trigger.js"; describe("Feishu approval cards", () => { afterEach(() => { vi.useRealTimers(); }); it("sendApprovalCard builds correct card JSON", async () => { const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } })); const rt = mockRuntime({ messageCreate }); await expect(sendApprovalCard(rt, "chat-1", "Approve?", "Please approve.", [ { label: "Approve", value: "approve", style: "primary" }, { label: "Reject", value: "reject", style: "danger" }, { label: "Later", value: "later" }, ])).resolves.toBe("message-1"); expect(messageCreate).toHaveBeenCalledTimes(1); const payload = messageCreate.mock.calls[0]?.[0] as { data: { content: string } }; expect(payload).toMatchObject({ params: { receive_id_type: "chat_id" }, data: { receive_id: "chat-1", msg_type: "interactive" }, }); expect(JSON.parse(payload.data.content)).toEqual({ config: { wide_screen_mode: true }, header: { title: { content: "Approve?", tag: "plain_text" }, template: "orange" }, elements: [ { tag: "markdown", content: "Please approve." }, { tag: "action", actions: [ { tag: "button", text: { tag: "plain_text", content: "Approve" }, type: "primary", value: { approval_action: "approve" }, }, { tag: "button", text: { tag: "plain_text", content: "Reject" }, type: "danger", value: { approval_action: "reject" }, }, { tag: "button", text: { tag: "plain_text", content: "Later" }, type: "default", value: { approval_action: "later" }, }, ], }, ], }); }); it("resolveCard patches with resolved state", async () => { const messagePatch = vi.fn(async () => ({})); const rt = mockRuntime({ messagePatch }); await resolveCard(rt, "message-1", "OK", "Approved", "green", "ou_user"); expect(messagePatch).toHaveBeenCalledTimes(1); expect(messagePatch).toHaveBeenCalledWith({ path: { message_id: "message-1" }, params: { msg_type: "interactive" }, data: { content: JSON.stringify({ config: { wide_screen_mode: true }, header: { title: { content: "OK Approved", tag: "plain_text" }, template: "green" }, elements: [{ tag: "markdown", content: "OK **Approved** by ou_user" }], }), }, }); }); it("ApprovalManager register+resolve lifecycle", async () => { const manager = new ApprovalManager({ timeoutMs: 10_000 }); const result = manager.register("message-1", "chat-1"); expect(manager.hasPending("message-1")).toBe(true); manager.resolve("message-1", "approve", "ou_user"); await expect(result).resolves.toEqual({ action: "approve", resolvedBy: "ou_user" }); expect(manager.hasPending("message-1")).toBe(false); }); it("ApprovalManager timeout", async () => { vi.useFakeTimers(); const manager = new ApprovalManager({ timeoutMs: 100 }); const result = manager.register("message-1", "chat-1"); const assertion = expect(result).rejects.toThrow("approval timed out after 100ms"); await vi.advanceTimersByTimeAsync(100); await assertion; expect(manager.hasPending("message-1")).toBe(false); }); it("routes card actions through the trigger handler", async () => { const messagePatch = vi.fn(async () => ({})); const rt = mockRuntime({ messagePatch }); const trigger = makeTriggerHandler({ prisma: {} as PrismaClient, settings: {} as RuntimeSettings, logger: silentLogger(), authorizer: allowAllAuthorizer(), projectWorkspaceRoot: "/tmp", publicBaseUrl: "https://educraft.example.test", siloOrganizationId: "org_test_default", runAgent: async () => ({ status: "completed", text: "", usage: { inputTokens: 0, outputTokens: 0 }, numTurns: 0, }), }); const result = trigger.approvalManager.register("message-1", "chat-1"); await trigger.onCardAction(makeCardActionEvent("message-1", "approve", "ou_user"), rt); await expect(result).resolves.toEqual({ action: "approve", resolvedBy: "ou_user" }); expect(messagePatch).toHaveBeenCalledTimes(1); const payload = messagePatch.mock.calls[0]?.[0] as { data: { content: string } }; expect(payload).toMatchObject({ path: { message_id: "message-1" }, params: { msg_type: "interactive" }, }); expect(JSON.parse(payload.data.content)).toEqual({ config: { wide_screen_mode: true }, header: { title: { content: "✅ Approved", tag: "plain_text" }, template: "green" }, elements: [{ tag: "markdown", content: "✅ **Approved** by ou_user" }], }); expect(trigger.approvalManager.hasPending("message-1")).toBe(false); }); }); function makeCardActionEvent(messageId: string, action: string, openId: string): CardActionEvent { return { operator: { open_id: openId }, action: { value: { approval_action: action }, tag: "button" }, context: { open_message_id: messageId, open_chat_id: "chat-1" }, }; } function mockRuntime(options: { readonly messageCreate?: (payload: unknown) => Promise; readonly messagePatch?: (payload: unknown) => Promise; } = {}): FeishuRuntime { return { client: { im: { v1: { message: { create: options.messageCreate ?? vi.fn(async () => ({ data: { message_id: "message-1" } })), patch: options.messagePatch ?? vi.fn(async () => ({})), }, }, }, } as unknown as FeishuRuntime["client"], logger: silentLogger(), }; } function allowAllAuthorizer(): PermissionAuthorizer { return { async can(req) { return { allowed: true, reason: "test allow", action: req.action, resource: req.resource, actor: req.actor, organizationId: "org_test_default", actorUserId: "user-1", principals: [{ type: "USER", id: "ou_user" }], requiredRole: "EDIT", effectiveRole: "EDIT", }; }, }; } function silentLogger(): FeishuRuntime["logger"] { return { info() {}, warn() {}, error() {}, debug() {}, fatal() {}, child() { return this; }, level: "silent", } as unknown as FeishuRuntime["logger"]; }