test(hub): integration 测试套件(13 tests,真 prisma+真 cph)

三层 integration,对齐 unit 层:
- trigger.test.ts: @bot→run→lock→release→card 全链路;权限拒绝(READ
  不能 triggerAgent ADR-0004);竞态 busy(ADR-0002);未绑定 chat 忽略
  (ADR-0001);无 @bot 忽略 —— 5 tests
- lock.test.ts: acquire/release 回环;排他性(同 project 二次 acquire 抛);
  WellFormed(终止 run 持锁即抛);release 幂等;isTerminal 对齐 spec —— 5 tests
- cph.test.ts: 真 cph binary 跑 TH-141 examples 的 check(exit 0)+
  build student PDF(exit 0)+ 空目录 check(非 0)—— 3 tests
helpers: resetDb(truncate CASCADE)、mockFeishuRuntime(记录 sentTexts/
  sentCards)、MockProvider(fixed stop 响应)、seedProject(project→user→grant)
prisma migrate dev init 生成初始 migration 并应用到 cph_hub_test 库。
vitest config 改 fileParallelism=false(integration 共享一 DB,串行避免
truncate/insert 竞态)。
vitest run 35/35(22 unit + 13 integration)通过;tsc rc=0。
This commit is contained in:
2026-07-07 15:40:28 +08:00
parent 09cadd0148
commit e60aa43731
7 changed files with 679 additions and 1 deletions
@@ -0,0 +1,271 @@
-- CreateEnum
CREATE TYPE "PlatformRole" AS ENUM ('ADMIN', 'TEACHER');
-- CreateEnum
CREATE TYPE "AgentRunStatus" AS ENUM ('ACTIVE', 'WAITING_FOR_USER', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELED');
-- CreateEnum
CREATE TYPE "AgentEntrypoint" AS ENUM ('FEISHU', 'WEB', 'CLI');
-- CreateEnum
CREATE TYPE "PermissionRole" AS ENUM ('READ', 'EDIT', 'MANAGE');
-- CreateEnum
CREATE TYPE "PermissionResourceType" AS ENUM ('PROJECT', 'ARTIFACT', 'PROJECT_GROUP');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"feishuOpenId" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"avatarUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PlatformRoleAssignment" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "PlatformRole" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "PlatformRoleAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"workspaceDir" TEXT NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectGroupBinding" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"chatId" TEXT NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectGroupBinding_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentSession" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"model" TEXT NOT NULL,
"title" TEXT,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "AgentSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentRun" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"sessionId" TEXT,
"requestedByUserId" TEXT,
"entrypoint" "AgentEntrypoint" NOT NULL,
"status" "AgentRunStatus" NOT NULL DEFAULT 'ACTIVE',
"prompt" TEXT NOT NULL,
"model" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"summary" TEXT,
"inputTokens" INTEGER,
"outputTokens" INTEGER,
"metadata" JSONB NOT NULL,
"error" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AgentRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectAgentLock" (
"projectId" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"holderUserId" TEXT,
"acquiredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3),
CONSTRAINT "ProjectAgentLock_pkey" PRIMARY KEY ("projectId")
);
-- CreateTable
CREATE TABLE "PermissionGrant" (
"id" TEXT NOT NULL,
"resourceType" "PermissionResourceType" NOT NULL,
"resourceId" TEXT NOT NULL,
"principal" TEXT NOT NULL,
"role" "PermissionRole" NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "PermissionGrant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PermissionSettings" (
"id" TEXT NOT NULL,
"resourceType" "PermissionResourceType" NOT NULL,
"resourceId" TEXT NOT NULL,
"externalShare" TEXT NOT NULL,
"comment" TEXT NOT NULL,
"copyDownload" TEXT NOT NULL,
"collaboratorMgmt" TEXT NOT NULL,
"agentTrigger" TEXT NOT NULL,
"agentCancel" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PermissionSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditEntry" (
"id" TEXT NOT NULL,
"runId" TEXT,
"actorUserId" TEXT,
"action" TEXT NOT NULL,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_feishuOpenId_key" ON "User"("feishuOpenId");
-- CreateIndex
CREATE INDEX "PlatformRoleAssignment_userId_revokedAt_idx" ON "PlatformRoleAssignment"("userId", "revokedAt");
-- CreateIndex
CREATE INDEX "PlatformRoleAssignment_role_revokedAt_idx" ON "PlatformRoleAssignment"("role", "revokedAt");
-- CreateIndex
CREATE INDEX "Project_archivedAt_idx" ON "Project"("archivedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectGroupBinding_projectId_key" ON "ProjectGroupBinding"("projectId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectGroupBinding_chatId_key" ON "ProjectGroupBinding"("chatId");
-- CreateIndex
CREATE INDEX "ProjectGroupBinding_chatId_idx" ON "ProjectGroupBinding"("chatId");
-- CreateIndex
CREATE INDEX "AgentSession_projectId_archivedAt_idx" ON "AgentSession"("projectId", "archivedAt");
-- CreateIndex
CREATE INDEX "AgentSession_provider_model_idx" ON "AgentSession"("provider", "model");
-- CreateIndex
CREATE INDEX "AgentSession_updatedAt_idx" ON "AgentSession"("updatedAt");
-- CreateIndex
CREATE INDEX "AgentRun_projectId_status_idx" ON "AgentRun"("projectId", "status");
-- CreateIndex
CREATE INDEX "AgentRun_sessionId_idx" ON "AgentRun"("sessionId");
-- CreateIndex
CREATE INDEX "AgentRun_requestedByUserId_idx" ON "AgentRun"("requestedByUserId");
-- CreateIndex
CREATE INDEX "AgentRun_updatedAt_idx" ON "AgentRun"("updatedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectAgentLock_runId_key" ON "ProjectAgentLock"("runId");
-- CreateIndex
CREATE INDEX "ProjectAgentLock_expiresAt_idx" ON "ProjectAgentLock"("expiresAt");
-- CreateIndex
CREATE INDEX "PermissionGrant_resourceType_resourceId_revokedAt_idx" ON "PermissionGrant"("resourceType", "resourceId", "revokedAt");
-- CreateIndex
CREATE INDEX "PermissionGrant_principal_revokedAt_idx" ON "PermissionGrant"("principal", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "PermissionGrant_resourceType_resourceId_principal_role_revo_key" ON "PermissionGrant"("resourceType", "resourceId", "principal", "role", "revokedAt");
-- CreateIndex
CREATE INDEX "PermissionSettings_resourceType_resourceId_idx" ON "PermissionSettings"("resourceType", "resourceId");
-- CreateIndex
CREATE UNIQUE INDEX "PermissionSettings_resourceType_resourceId_key" ON "PermissionSettings"("resourceType", "resourceId");
-- CreateIndex
CREATE INDEX "AuditEntry_runId_idx" ON "AuditEntry"("runId");
-- CreateIndex
CREATE INDEX "AuditEntry_actorUserId_idx" ON "AuditEntry"("actorUserId");
-- CreateIndex
CREATE INDEX "AuditEntry_createdAt_idx" ON "AuditEntry"("createdAt");
-- AddForeignKey
ALTER TABLE "PlatformRoleAssignment" ADD CONSTRAINT "PlatformRoleAssignment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectGroupBinding" ADD CONSTRAINT "ProjectGroupBinding_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectGroupBinding" ADD CONSTRAINT "ProjectGroupBinding_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AgentSession"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_requestedByUserId_fkey" FOREIGN KEY ("requestedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PermissionGrant" ADD CONSTRAINT "PermissionGrant_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PermissionGrant" ADD CONSTRAINT "PermissionGrant_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PermissionSettings" ADD CONSTRAINT "PermissionSettings_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditEntry" ADD CONSTRAINT "AuditEntry_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ToolRegistry } from "../../src/agent/tools.js";
import { cphCheckTool, cphBuildTool } from "../../src/agent/cph.js";
const EXAMPLES_DIR = join(process.cwd(), "..", "examples", "TH-141");
describe("cph subprocess tools (integration, real cph binary)", () => {
let ws: string;
let tools: ToolRegistry;
beforeEach(async () => {
// Use the real TH-141 example as the workspace.
ws = EXAMPLES_DIR;
tools = new ToolRegistry();
tools.register(cphCheckTool());
tools.register(cphBuildTool());
});
it("cph_check runs on the TH-141 example and returns diagnostics", async () => {
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
const out = await tools.execute("cph_check", {}, ctx);
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string };
// cph check exits 0 on a legal lesson (no error diagnostics, ADR-0010).
// If the example has warnings, exit is still 0.
expect(result.exitCode).toBe(0);
}, 30000);
it("cph_build renders the student target PDF", async () => {
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
const out = await tools.execute("cph_build", { target: "student", output: "build/test-student.pdf" }, ctx);
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string; output: string };
expect(result.exitCode).toBe(0);
}, 30000);
it("cph_check on a temp dir with no engineering file returns non-zero", async () => {
const empty = await mkdtemp(join(tmpdir(), "hub-cph-empty-"));
try {
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: empty };
const out = await tools.execute("cph_check", {}, ctx);
const result = JSON.parse(out) as { exitCode: number };
expect(result.exitCode).not.toBe(0);
} finally {
await rm(empty, { recursive: true, force: true });
}
}, 15000);
});
+135
View File
@@ -0,0 +1,135 @@
/**
* Test helpers for integration tests.
*
* Each test gets a clean DB (tables truncated before the test), a mock
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
* AgentProvider (chat() returns a fixed "stop" response — no real network).
*/
import { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import type { FeishuRuntime } from "../src/feishu/client.js";
import type { AgentProvider, ChatRequest, ChatResponse, Message } from "../src/agent/provider.js";
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
export const prisma = new PrismaClient({
datasources: { db: { url: TEST_DATABASE_URL } },
});
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
const tables = [
"AuditEntry",
"PermissionSettings",
"PermissionGrant",
"ProjectAgentLock",
"AgentRun",
"AgentSession",
"ProjectGroupBinding",
"PlatformRoleAssignment",
"User",
"Project",
];
// Truncate with CASCADE to wipe dependent rows in one shot.
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
}
/** A logger that discards everything (tests don't need fastify's pino). */
export const silentLogger: FastifyBaseLogger = {
info() {}, warn() {}, error() {}, debug() {}, fatal() {},
child() { return this; },
level: "silent",
} as unknown as FastifyBaseLogger;
/** Records sendText/sendCard calls so tests can assert on them. */
export interface MockFeishuRuntime extends FeishuRuntime {
readonly sentTexts: string[];
readonly sentCards: unknown[];
}
export function mockFeishuRuntime(): MockFeishuRuntime {
const sentTexts: string[] = [];
const sentCards: unknown[] = [];
// Mock the client — sendText/sendCard are in client.ts, not on the runtime
// object directly. We patch by providing a runtime whose client is a stub;
// the actual send functions cast through shape, so a minimal stub works.
const rt: MockFeishuRuntime = {
client: {
im: {
v1: {
message: {
create: async (p: unknown) => {
const payload = p as { data?: { msg_type?: string; content?: string } };
if (payload.data?.msg_type === "interactive") {
sentCards.push(payload.data.content ? JSON.parse(payload.data.content) : null);
} else {
try {
const c = JSON.parse(payload.data?.content ?? "{}") as { text?: string };
sentTexts.push(c.text ?? payload.data?.content ?? "");
} catch {
sentTexts.push(payload.data?.content ?? "");
}
}
return { data: { message_id: "mock-msg-id" } };
},
},
},
},
} as unknown as FeishuRuntime["client"],
logger: silentLogger,
sentTexts,
sentCards,
};
return rt;
}
/** A provider that always returns a fixed "stop" response — no network. */
export class MockProvider implements AgentProvider {
readonly id = "mock";
readonly calls: ChatRequest[] = [];
async chat(req: ChatRequest): Promise<ChatResponse> {
this.calls.push(req);
const message: Message = {
role: "assistant",
parts: [{ type: "text", text: "mock response" }],
};
return { finishReason: "stop", message, usage: { inputTokens: 10, outputTokens: 5 } };
}
}
/** Create a project + binding + user + grant for a test. */
export async function seedProject(
projectId: string,
chatId: string,
options: { principal?: string; role?: "READ" | "EDIT" | "MANAGE" } = {},
): Promise<void> {
const principal = options.principal ?? "ou_test_user";
const role = options.role ?? "EDIT";
await prisma.project.create({
data: {
id: projectId,
name: `Test ${projectId}`,
workspaceDir: `/tmp/test-${projectId}`,
},
});
await prisma.user.create({
data: {
id: "u_" + projectId,
feishuOpenId: principal,
displayName: "Test User",
platformRoles: { create: { role: "TEACHER" } },
permissionGrants: {
create: {
resourceType: "PROJECT",
resourceId: projectId,
principal,
role,
},
},
},
});
await prisma.projectGroupBinding.create({
data: { projectId, chatId, createdByUserId: "u_" + projectId },
});
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { prisma, resetDb } from "./helpers.js";
import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js";
describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
beforeEach(async () => {
await resetDb();
});
it("acquireLock + currentLockRunId round-trip", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-1", name: "Test", workspaceDir: "/tmp/x" },
});
const run = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
});
await acquireLock(prisma, project.id, run.id, null);
const holder = await currentLockRunId(prisma, project.id);
expect(holder).toBe(run.id);
await releaseLock(prisma, run.id);
const after = await currentLockRunId(prisma, project.id);
expect(after).toBeNull();
});
it("acquireLock fails when project already locked (exclusivity)", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-2", name: "Test", workspaceDir: "/tmp/x" },
});
const run1 = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
});
const run2 = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "y", model: "m", provider: "mock", metadata: {} },
});
await acquireLock(prisma, project.id, run1.id, null);
await expect(acquireLock(prisma, project.id, run2.id, null)).rejects.toThrow();
});
it("currentLockRunId throws when a terminal run holds the lock (WellFormed)", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-3", name: "Test", workspaceDir: "/tmp/x" },
});
// Create a run that is already COMPLETED (terminal), then manually insert a lock.
const run = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "COMPLETED", prompt: "x", model: "m", provider: "mock", metadata: {}, finishedAt: new Date() },
});
await prisma.projectAgentLock.create({
data: { projectId: project.id, runId: run.id },
});
// WellFormed invariant: reading a lock held by a terminal run must throw.
await expect(currentLockRunId(prisma, project.id)).rejects.toThrow(/lock invariant violated/);
});
it("releaseLock is idempotent", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-4", name: "Test", workspaceDir: "/tmp/x" },
});
const run = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
});
await acquireLock(prisma, project.id, run.id, null);
await releaseLock(prisma, run.id);
// Second release is a no-op.
await releaseLock(prisma, run.id);
expect(await currentLockRunId(prisma, project.id)).toBeNull();
});
it("isTerminal matches spec RunState.Terminal", () => {
expect(isTerminal("ACTIVE")).toBe(false);
expect(isTerminal("WAITING_FOR_USER")).toBe(false);
expect(isTerminal("COMPLETED")).toBe(true);
expect(isTerminal("FAILED")).toBe(true);
expect(isTerminal("TIMED_OUT")).toBe(true);
expect(isTerminal("CANCELED")).toBe(true);
});
});
afterAll(async () => {
await prisma.$disconnect();
});
+130
View File
@@ -0,0 +1,130 @@
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
import { prisma, resetDb, mockFeishuRuntime, MockProvider, seedProject, silentLogger } from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { ToolRegistry, feishuContextTool } from "../../src/agent/tools.js";
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user"): MessageReceiveEvent {
return {
message: {
message_id: "m_" + Math.random().toString(36).slice(2),
chat_id: chatId,
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text }),
mentions: [bot],
},
sender: { sender_id: { open_id: senderOpenId }, sender_type: "user" },
};
}
describe("trigger full lifecycle (integration)", () => {
let provider: MockProvider;
let tools: ToolRegistry;
let models: InMemoryModelRegistry;
let rt: ReturnType<typeof mockFeishuRuntime>;
beforeEach(async () => {
await resetDb();
provider = new MockProvider();
tools = new ToolRegistry();
models = new InMemoryModelRegistry(
[{ id: "mock-model", label: "Mock", toolCapable: true }],
[{ id: "draft", label: "草稿", defaultModel: "mock-model" }],
);
rt = mockFeishuRuntime();
});
it("creates a run, acquires + releases the lock, sends status card", async () => {
await seedProject("proj-1", "chat-1");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt);
// Wait for the async run to complete (fire-and-forget in trigger).
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
});
// Lock released (no lock row remains).
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("已开始处理(model: mock-model)。");
});
it("rejects a sender without edit grant (ADR-0004)", async () => {
await seedProject("proj-2", "chat-2", { role: "READ" });
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("无权限触发。");
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
it("replies busy when project is already locked (ADR-0002)", async () => {
await seedProject("proj-3", "chat-3");
// Manually create a lock by inserting a run + lock.
const existingRun = await prisma.agentRun.create({
data: { projectId: "proj-3", entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
});
await prisma.projectAgentLock.create({
data: { projectId: "proj-3", runId: existingRun.id },
});
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
// No new run created.
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
});
it("ignores messages from unbound chats (ADR-0001)", async () => {
await seedProject("proj-4", "chat-4");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toHaveLength(0);
expect(rt.sentCards).toHaveLength(0);
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
it("ignores messages without @bot mention", async () => {
await seedProject("proj-5", "chat-5");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const event: MessageReceiveEvent = {
message: {
message_id: "m_nobot",
chat_id: "chat-5",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello no bot" }),
mentions: undefined,
},
sender: { sender_id: { open_id: "ou_test_user" }, sender_type: "user" },
};
await trigger(event, rt);
expect(rt.sentTexts).toHaveLength(0);
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
});
afterAll(async () => {
await prisma.$disconnect();
});
+4 -1
View File
@@ -3,7 +3,10 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
// Unit tests run without a DB; integration tests set their own env.
// Integration tests share one DB; run files sequentially to avoid
// concurrent truncate/insert races. Unit tests are fast either way.
pool: "forks",
fileParallelism: false,
env: { NODE_ENV: "test" },
},
});