forked from bai/curriculum-project-hub
aaa098bb8b
Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
330 lines
10 KiB
TypeScript
330 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 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(),
|
|
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"];
|
|
}
|