Files
curriculum-project-hub/hub/test/unit/feishu-reactions.test.ts
T
hongjr03 6f7497bce8 fix(hub): stamp CheckMark/CrossMark when agent run finishes (v0.0.41)
After removing the Typing reaction, add CheckMark on success or CrossMark
on failure so teachers can see completion on the source message without
opening the card.
2026-07-21 06:36:09 +00:00

331 lines
10 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 replaces it with CheckMark 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" },
{ kind: "add", messageId: "message-1", emoji: "CheckMark", reactionId: "reaction-2" },
]);
});
});
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(),
projectWorkspaceRoot: "/tmp",
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: "org_test_default",
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,
async openAgentLease() {
return {
sdkEnv: {},
sensitiveValues: [],
async close() {},
};
},
};
},
async modelRegistry() {
return registry;
},
async runPolicy() {
return { maxTurns: 1, maxConcurrentRuns: 1, maxRunSeconds: 300 };
},
};
}
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: {} };
const client = {
organization: {
findFirst: vi.fn(async () => ({ id: "org_test_default", slug: "test-default" })),
},
feishuUserIdentity: {
findFirst: vi.fn(async () => ({
user: {
organizationMemberships: [{
role: "OWNER",
organization: { id: "org_test_default", name: "Test Organization" },
}],
},
})),
},
feishuEventReceipt: {
findUnique: vi.fn(async () => null),
create: vi.fn(async () => ({ id: "receipt-1" })),
},
projectGroupBinding: {
findFirst: vi.fn(async () => ({ projectId: "project-1", selectedRole: { roleId: "draft" } })),
},
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: {
count: vi.fn(async () => 0),
create: vi.fn(async () => ({ id: "run-1" })),
findUnique: vi.fn(async () => null),
update: vi.fn(async () => ({ id: "run-1" })),
},
usageFact: {
create: vi.fn(async () => ({ id: "fact-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;
Object.assign(client, {
$transaction: vi.fn(async (callback: (tx: PrismaClient) => Promise<unknown>) => callback(client)),
$queryRaw: vi.fn(async () => [{ id: "org_test_default", status: "ACTIVE" }]),
$executeRaw: vi.fn(async () => 1),
});
return client;
}
function silentLogger(): FeishuRuntime["logger"] {
return {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"];
}