Files
curriculum-project-hub/hub/test/unit/feishu-approval.test.ts
T
hongjr03 4736610cf3 feat: add interactive approval/confirmation cards
- sendApprovalCard: interactive card with configurable buttons
- resolveCard: patch card to show resolution state (green/red)
- ApprovalManager: register/resolve lifecycle with 5min timeout
- request_approval MCP tool: agent can ask user for confirmation
- Wire onCardAction in trigger handler and server startup
- Add unit tests (5) for card JSON, manager, and routing
2026-07-08 16:47:31 +08:00

189 lines
6.5 KiB
TypeScript

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(),
});
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<unknown>;
readonly messagePatch?: (payload: unknown) => Promise<unknown>;
} = {}): 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,
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"];
}