forked from EduCraft/curriculum-project-hub
199 lines
6.8 KiB
TypeScript
199 lines
6.8 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(),
|
|
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<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,
|
|
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"];
|
|
}
|