forked from EduCraft/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,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 },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user