Files
curriculum-project-hub/hub/test/unit/feishu-reactions.test.ts
T

301 lines
9.2 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@prisma/client";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
import type { FeishuRuntime, MessageReceiveEvent } from "../../src/feishu/client.js";
import { addReaction, removeReaction } from "../../src/feishu/client.js";
import { makeTriggerHandler } from "../../src/feishu/trigger.js";
import type { PermissionAuthorizer } from "../../src/permission.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js";
describe("Feishu reactions", () => {
it("addReaction returns reaction_id on success", async () => {
const request = vi.fn(async () => ({ data: { reaction_id: "reaction-1" } }));
const rt = mockRuntime({ request });
await expect(addReaction(rt, "message-1", "Typing")).resolves.toBe("reaction-1");
expect(request).toHaveBeenCalledWith({
method: "POST",
url: "/open-apis/im/v1/messages/message-1/reactions",
data: { reaction_type: { emoji_type: "Typing" } },
});
});
it("addReaction returns null on API failure", async () => {
const rt = mockRuntime({
request: vi.fn(async () => {
throw new Error("api failed");
}),
});
await expect(addReaction(rt, "message-1", "Typing")).resolves.toBeNull();
});
it("removeReaction returns true on success", async () => {
const request = vi.fn(async () => ({}));
const rt = mockRuntime({ request });
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(true);
expect(request).toHaveBeenCalledWith({
method: "DELETE",
url: "/open-apis/im/v1/messages/message-1/reactions/reaction-1",
});
});
it("removeReaction returns false on API failure", async () => {
const rt = mockRuntime({
request: vi.fn(async () => {
throw new Error("api failed");
}),
});
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(false);
});
it("adds Typing on start and removes it on success", async () => {
const run = deferred<RunResult>();
const rt = mockRuntime();
const runAgent = vi.fn((req: RunRequest) => {
req.onStream?.({ type: "text-delta", text: "done" });
return run.promise;
});
await triggerWithRunAgent(runAgent, rt);
expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
]);
run.resolve(completedRunResult("done"));
await vi.waitFor(() => {
expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
]);
});
});
it("replaces Typing with CrossMark on failure", async () => {
const run = deferred<RunResult>();
const rt = mockRuntime();
const runAgent = vi.fn(() => run.promise);
await triggerWithRunAgent(runAgent, rt);
run.reject(new Error("agent failed"));
await vi.waitFor(() => {
expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
{ kind: "add", messageId: "message-1", emoji: "CrossMark", reactionId: "reaction-2" },
]);
});
});
});
async function triggerWithRunAgent(
runAgent: (req: RunRequest) => Promise<RunResult>,
rt: MockRuntime,
): Promise<void> {
const trigger = makeTriggerHandler({
prisma: mockPrisma(),
settings: mockSettings(),
logger: rt.logger,
authorizer: allowAllAuthorizer(),
runAgent,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent(), rt);
}
function completedRunResult(text: string): RunResult {
return {
status: "completed",
text,
usage: { inputTokens: 1, outputTokens: 1 },
numTurns: 1,
};
}
interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
readonly reject: (reason?: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
function makeEvent(): MessageReceiveEvent {
return {
message: {
message_id: "message-1",
chat_id: "chat-1",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "@_user_1 do work" }),
mentions: [bot],
},
sender: { sender_id: { open_id: "ou_user" }, sender_type: "user" },
};
}
type ReactionRequest =
| { readonly kind: "add"; readonly messageId: string; readonly emoji: string; readonly reactionId: string }
| { readonly kind: "remove"; readonly messageId: string; readonly reactionId: string };
interface MockRuntime extends FeishuRuntime {
readonly reactionRequests: ReactionRequest[];
}
function mockRuntime(options: { readonly request?: (payload: unknown) => Promise<unknown> } = {}): MockRuntime {
const reactionRequests: ReactionRequest[] = [];
let nextReactionId = 1;
const request =
options.request ??
vi.fn(async (payload: unknown) => {
const requestPayload = payload as { method?: string; url?: string; data?: { reaction_type?: { emoji_type?: string } } };
const addMatch = requestPayload.url?.match(/\/messages\/([^/]+)\/reactions$/);
if (requestPayload.method === "POST" && addMatch?.[1] !== undefined) {
const reactionId = `reaction-${nextReactionId}`;
nextReactionId += 1;
reactionRequests.push({
kind: "add",
messageId: addMatch[1],
emoji: requestPayload.data?.reaction_type?.emoji_type ?? "",
reactionId,
});
return { data: { reaction_id: reactionId } };
}
const removeMatch = requestPayload.url?.match(/\/messages\/([^/]+)\/reactions\/([^/]+)$/);
if (requestPayload.method === "DELETE" && removeMatch?.[1] !== undefined && removeMatch[2] !== undefined) {
reactionRequests.push({ kind: "remove", messageId: removeMatch[1], reactionId: removeMatch[2] });
}
return {};
});
return {
client: {
request,
im: {
v1: {
message: {
create: vi.fn(async () => ({ data: { message_id: "reply-message-1" } })),
patch: vi.fn(async () => ({})),
},
},
},
} as unknown as FeishuRuntime["client"],
logger: silentLogger(),
reactionRequests,
};
}
function mockSettings(): RuntimeSettings {
const registry = new InMemoryModelRegistry(
[{ id: "mock-model", label: "Mock", toolCapable: true }],
[{ id: "draft", label: "Draft", defaultModel: "mock-model" }],
);
return {
async provider(providerId) {
return {
id: providerId,
baseUrl: "https://example.invalid",
authToken: "test-token",
anthropicApiKey: "",
sdkEnv: {},
};
},
async modelRegistry() {
return registry;
},
async runPolicy() {
return { maxTurns: 1 };
},
};
}
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 mockPrisma(): PrismaClient {
let lock: { projectId: string; runId: string } | null = null;
const session = { id: "session-1", metadata: {} };
return {
feishuEventReceipt: {
findUnique: vi.fn(async () => null),
create: vi.fn(async () => ({ id: "receipt-1" })),
},
projectGroupBinding: {
findUnique: vi.fn(async () => ({ projectId: "project-1" })),
},
project: {
findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })),
},
agentSession: {
findFirst: vi.fn(async () => null),
create: vi.fn(async () => session),
update: vi.fn(async () => session),
},
agentRun: {
create: vi.fn(async () => ({ id: "run-1" })),
findUnique: vi.fn(async () => null),
update: vi.fn(async () => ({ id: "run-1" })),
},
projectAgentLock: {
findUnique: vi.fn(async () => (lock === null ? null : { runId: lock.runId })),
create: vi.fn(async (args: { data: { projectId: string; runId: string } }) => {
lock = { projectId: args.data.projectId, runId: args.data.runId };
return lock;
}),
deleteMany: vi.fn(async () => {
lock = null;
return { count: 1 };
}),
},
auditEntry: {
create: vi.fn(async () => ({ id: "audit-1" })),
},
} as unknown as PrismaClient;
}
function silentLogger(): FeishuRuntime["logger"] {
return {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"];
}