forked from bai/curriculum-project-hub
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:
@@ -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"
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -3,7 +3,10 @@ import { defineConfig } from "vitest/config";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
include: ["test/**/*.test.ts"],
|
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" },
|
env: { NODE_ENV: "test" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user