fix(hub): 稳定 Feishu trigger 集成测试

This commit is contained in:
2026-07-08 15:13:50 +08:00
parent ecbc2c8666
commit ead5cf04d6
5 changed files with 167 additions and 28 deletions
+54 -25
View File
@@ -1,12 +1,12 @@
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
import { prisma, resetDb, mockFeishuRuntime, createMockModelFactory, seedProject, silentLogger } from "./helpers.js";
import { prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { ToolRegistry } from "../../src/agent/tools.js";
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.js";
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
type TestRunner = (req: RunRequest) => Promise<RunResult>;
function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user", eventId?: string): MessageReceiveEvent {
const header = eventId !== undefined ? { event_id: eventId, event_type: "im.message.receive_v1" } : undefined;
@@ -24,16 +24,39 @@ function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user",
};
}
function createMockRunAgent(calls: RunRequest[] = []): TestRunner {
return async (req) => {
calls.push(req);
req.onStream?.({ type: "text-delta", text: "mock response" });
await req.prisma.agentMessage.create({
data: {
sessionId: req.sessionId,
runId: req.runId,
role: "assistant",
content: "mock response",
attachments: [],
},
});
return {
status: "completed",
text: "mock response",
usage: { inputTokens: 10, outputTokens: 5 },
numTurns: 1,
sdkSessionId: "sdk-session-1",
};
};
}
describe("trigger full lifecycle (integration)", () => {
let modelFactory: ModelFactory;
let tools: ToolRegistry;
let models: InMemoryModelRegistry;
let rt: ReturnType<typeof mockFeishuRuntime>;
let runAgentCalls: RunRequest[];
let runAgent: TestRunner;
beforeEach(async () => {
await resetDb();
modelFactory = createMockModelFactory().modelFactory;
tools = new ToolRegistry();
runAgentCalls = [];
runAgent = createMockRunAgent(runAgentCalls);
models = new InMemoryModelRegistry(
[{ id: "mock-model", label: "Mock", toolCapable: true }],
[
@@ -46,7 +69,7 @@ describe("trigger full lifecycle (integration)", () => {
it("creates a run, acquires + releases the lock, sends status card", async () => {
await seedProject("proj-1", "chat-1");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt);
@@ -58,21 +81,25 @@ describe("trigger full lifecycle (integration)", () => {
});
// Lock released (no lock row remains).
const locks = await prisma.projectAgentLock.findMany();
expect(locks).toHaveLength(0);
await vi.waitFor(async () => {
const locks = await prisma.projectAgentLock.findMany();
expect(locks).toHaveLength(0);
});
// A status card was sent.
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
expect(rt.sentTexts).toContain("已开始处理(role: draft, model: mock-model)。");
expect(rt.sentTexts).toContain("mock response");
expect(runAgentCalls).toHaveLength(1);
});
it("rejects a sender without edit grant (ADR-0004)", async () => {
await seedProject("proj-2", "chat-2", { role: "READ" });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("无权限触发。");
expect(runAgentCalls).toHaveLength(0);
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
@@ -87,10 +114,11 @@ describe("trigger full lifecycle (integration)", () => {
data: { projectId: "proj-3", runId: existingRun.id },
});
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
expect(runAgentCalls).toHaveLength(0);
// No new run created.
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
@@ -98,7 +126,7 @@ describe("trigger full lifecycle (integration)", () => {
it("ignores messages from unbound chats (ADR-0001)", async () => {
await seedProject("proj-4", "chat-4");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
@@ -110,7 +138,7 @@ describe("trigger full lifecycle (integration)", () => {
it("ignores messages without @bot mention", async () => {
await seedProject("proj-5", "chat-5");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
const event: MessageReceiveEvent = {
message: {
@@ -132,7 +160,7 @@ describe("trigger full lifecycle (integration)", () => {
it("/new archives current session (no run created)", async () => {
await seedProject("proj-6", "chat-6");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
// First @bot creates a session + run.
await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt);
@@ -155,7 +183,7 @@ describe("trigger full lifecycle (integration)", () => {
it("/resume un-archives the most recent session", async () => {
await seedProject("proj-7", "chat-7");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
// Create + archive a session via /new.
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
@@ -175,7 +203,7 @@ describe("trigger full lifecycle (integration)", () => {
it("/reset archives current session", async () => {
await seedProject("proj-8", "chat-8");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
@@ -194,7 +222,7 @@ describe("trigger full lifecycle (integration)", () => {
it("unknown slash command falls through to agent", async () => {
await seedProject("proj-9", "chat-9");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
// Should create a run (falls through as a normal prompt).
@@ -211,11 +239,12 @@ describe("trigger full lifecycle (integration)", () => {
await prisma.roleTriggerGrant.create({
data: { projectId: "proj-10", roleId: "review", principal: "ou_other" },
});
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
expect(rt.sentTexts).toContain("无权限使用角色 review。");
expect(runAgentCalls).toHaveLength(0);
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
@@ -225,7 +254,7 @@ describe("trigger full lifecycle (integration)", () => {
await prisma.roleTriggerGrant.create({
data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" },
});
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt);
@@ -239,7 +268,7 @@ describe("trigger full lifecycle (integration)", () => {
it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => {
await seedProject("proj-12", "chat-12");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt);
@@ -253,7 +282,7 @@ describe("trigger full lifecycle (integration)", () => {
it("dedups a redelivered event by event_id (no second run)", async () => {
await seedProject("proj-13", "chat-13");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
// First delivery: processes normally.
await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt);
@@ -276,7 +305,7 @@ describe("trigger full lifecycle (integration)", () => {
it("writes audit entries across the run lifecycle", async () => {
await seedProject("proj-14", "chat-14");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-14", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
@@ -291,7 +320,7 @@ describe("trigger full lifecycle (integration)", () => {
it("persists AgentMessage rows for the run (A-3 structured history)", async () => {
await seedProject("proj-15", "chat-15");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-15", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {