import { describe, expect, it, vi, beforeEach } from "vitest"; import type { FastifyBaseLogger } from "fastify"; import type { CardActionEvent, FeishuRuntime } from "../../src/feishu/client.js"; import { startFeishuListenerWithClient } from "../../src/feishu/client.js"; const larkMock = vi.hoisted(() => { const starts: Array<{ readonly eventDispatcher?: unknown }> = []; class MockEventDispatcher { handlers: Record Promise> = {}; register(handlers: Record Promise>): this { this.handlers = handlers; return this; } } class MockWSClient { start(params: { readonly eventDispatcher?: unknown }): void { starts.push(params); } } return { starts, MockEventDispatcher, MockWSClient }; }); vi.mock("@larksuiteoapi/node-sdk", () => ({ AppType: { SelfBuild: "SelfBuild" }, Domain: { Feishu: "Feishu" }, LoggerLevel: { info: "info" }, Client: vi.fn(), EventDispatcher: larkMock.MockEventDispatcher, WSClient: larkMock.MockWSClient, })); describe("Feishu card action callbacks", () => { beforeEach(() => { larkMock.starts.length = 0; }); it("acks card action callbacks before the business handler settles", async () => { const action = deferred(); const onCardAction = vi.fn(async () => action.promise); const logger = silentLogger(); startFeishuListenerWithClient( { appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" }, fakeClient(), logger, async () => {}, onCardAction, ); const handler = cardActionHandler(); const handlerDone = handler(makeCardActionEvent()).then(() => "done" as const); const earlyResult = await Promise.race([handlerDone, delay(25).then(() => "timeout" as const)]); action.resolve(); await handlerDone; expect(earlyResult).toBe("done"); expect(onCardAction).toHaveBeenCalledTimes(1); }); it("logs background card action failures after acking", async () => { const logger = silentLogger(); const err = new Error("handler failed"); startFeishuListenerWithClient( { appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" }, fakeClient(), logger, async () => {}, async () => { throw err; }, ); await cardActionHandler()(makeCardActionEvent()); await vi.waitFor(() => { expect(logger.error).toHaveBeenCalledWith({ err }, "feishu card action handler threw"); }); }); }); function cardActionHandler(): (data: unknown) => Promise { const start = larkMock.starts[0]; if (start === undefined) throw new Error("WSClient.start was not called"); const dispatcher = start.eventDispatcher as { readonly handlers?: Record Promise> }; const handler = dispatcher.handlers?.["card.action.trigger"]; if (handler === undefined) throw new Error("card.action.trigger handler was not registered"); return handler; } function makeCardActionEvent(): CardActionEvent { return { operator: { open_id: "ou_user" }, action: { value: { interrupt_run: "run-1" }, tag: "button" }, context: { open_chat_id: "chat-1", open_message_id: "message-1" }, }; } function fakeClient(): FeishuRuntime["client"] { return {} as FeishuRuntime["client"]; } function silentLogger(): FastifyBaseLogger { return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), fatal: vi.fn(), child() { return this; }, level: "silent", } as unknown as FastifyBaseLogger; } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } interface Deferred { readonly promise: Promise; readonly resolve: (value: T) => void; } function deferred(): Deferred { let resolve!: (value: T) => void; const promise = new Promise((res) => { resolve = res; }); return { promise, resolve }; }