Files
curriculum-project-hub/hub/test/unit/feishu-card-action-ack.test.ts
T

194 lines
6.0 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
import type { FastifyBaseLogger } from "fastify";
import type { CardActionEvent, FeishuBindingLifecycleEvent, 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<string, (data: unknown) => Promise<void>> = {};
register(handlers: Record<string, (data: unknown) => Promise<void>>): this {
this.handlers = handlers;
return this;
}
}
class MockWSClient {
constructor(private readonly params: { readonly onReady?: () => void }) {}
start(params: { readonly eventDispatcher?: unknown }): void {
starts.push(params);
this.params.onReady?.();
}
}
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<void>();
const onCardAction = vi.fn(async () => action.promise);
const logger = silentLogger();
await 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");
await 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");
});
});
it("dispatches chat dissolution and bot-removal lifecycle events", async () => {
const onLifecycle = vi.fn(async (_event: FeishuBindingLifecycleEvent) => {});
await startFeishuListenerWithClient(
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
fakeClient(),
silentLogger(),
async () => {},
undefined,
undefined,
onLifecycle,
);
await lifecycleHandler("im.chat.disbanded_v1")({
event_id: "event-dissolved",
chat_id: "chat-dissolved",
});
await lifecycleHandler("im.chat.member.bot.deleted_v1")({
header: { event_id: "event-bot-removed" },
chat_id: "chat-bot-removed",
});
expect(onLifecycle).toHaveBeenNthCalledWith(1, {
eventId: "event-dissolved",
chatId: "chat-dissolved",
reason: "chat_dissolved",
});
expect(onLifecycle).toHaveBeenNthCalledWith(2, {
eventId: "event-bot-removed",
chatId: "chat-bot-removed",
reason: "bot_removed",
});
});
it("propagates lifecycle archival failures to the WS dispatcher", async () => {
const failure = new Error("archive failed");
await startFeishuListenerWithClient(
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
fakeClient(),
silentLogger(),
async () => {},
undefined,
undefined,
async () => { throw failure; },
);
await expect(lifecycleHandler("im.chat.disbanded_v1")({
event_id: "event-failure",
chat_id: "chat-failure",
})).rejects.toThrow(failure);
});
});
function cardActionHandler(): (data: unknown) => Promise<void> {
const start = larkMock.starts[0];
if (start === undefined) throw new Error("WSClient.start was not called");
const dispatcher = start.eventDispatcher as { readonly handlers?: Record<string, (data: unknown) => Promise<void>> };
const handler = dispatcher.handlers?.["card.action.trigger"];
if (handler === undefined) throw new Error("card.action.trigger handler was not registered");
return handler;
}
function lifecycleHandler(eventType: string): (data: unknown) => Promise<void> {
const start = larkMock.starts[0];
if (start === undefined) throw new Error("WSClient.start was not called");
const dispatcher = start.eventDispatcher as { readonly handlers?: Record<string, (data: unknown) => Promise<void>> };
const handler = dispatcher.handlers?.[eventType];
if (handler === undefined) throw new Error(`${eventType} 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}