/** * 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 * AI SDK model factory (doGenerate() returns canned responses - no network). */ import { PrismaClient } from "@prisma/client"; import type { FastifyBaseLogger } from "fastify"; import type { LanguageModelV4, LanguageModelV4CallOptions, LanguageModelV4Content, LanguageModelV4GenerateResult, LanguageModelV4StreamPart, LanguageModelV4StreamResult, } from "@ai-sdk/provider"; import type { FeishuRuntime } from "../../src/feishu/client.js"; import type { ModelFactory } from "../../src/agent/runner.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 { const tables = [ "FeishuEventReceipt", "AgentFileChange", "AgentMessage", "AuditEntry", "PermissionSettings", "PermissionGrant", "RoleTriggerGrant", "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[]; readonly sentPatches: unknown[]; readonly reactions: Array<{ readonly messageId: string; readonly emoji: string }>; } export function mockFeishuRuntime(): MockFeishuRuntime { const sentTexts: string[] = []; const sentCards: unknown[] = []; const sentPatches: unknown[] = []; const reactions: Array<{ messageId: string; emoji: string }> = []; // 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: { request: async (p: unknown) => { const payload = p as { url?: string; data?: { reaction_type?: { emoji_type?: string } } }; const match = payload.url?.match(/\/messages\/([^/]+)\/reactions$/); if (match?.[1] !== undefined) { reactions.push({ messageId: match[1], emoji: payload.data?.reaction_type?.emoji_type ?? "", }); } return {}; }, im: { v1: { message: { create: async (p: unknown) => { const payload = p as { data?: { msg_type?: string; content?: string } }; if (payload.data?.msg_type === "interactive") { const card = payload.data.content ? JSON.parse(payload.data.content) : null; sentCards.push(card); const text = textFromCard(card); if (text !== null) sentTexts.push(text); } 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" } }; }, patch: async (p: unknown) => { const payload = p as { data?: { content?: string } }; const card = payload.data?.content ? JSON.parse(payload.data.content) : null; sentPatches.push(card); const text = textFromCard(card); if (text !== null) sentTexts.push(text); return {}; }, }, }, }, } as unknown as FeishuRuntime["client"], logger: silentLogger, sentTexts, sentCards, sentPatches, reactions, }; return rt; } function textFromCard(card: unknown): string | null { if (typeof card !== "object" || card === null) return null; const elements = (card as { elements?: unknown }).elements; if (!Array.isArray(elements)) return null; const parts: string[] = []; for (const element of elements) { if (typeof element !== "object" || element === null) continue; const text = (element as { text?: unknown }).text; if (typeof text !== "object" || text === null) continue; const content = (text as { content?: unknown }).content; if (typeof content === "string") parts.push(content); } return parts.length === 0 ? null : parts.join("\n"); } export interface MockToolCall { readonly toolCallId: string; readonly toolName: string; readonly input: unknown; } export interface MockModelResponse { readonly text?: string; readonly toolCalls?: readonly MockToolCall[]; readonly finishReason?: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other"; readonly inputTokens?: number; readonly outputTokens?: number; } export class MockLanguageModel implements LanguageModelV4 { readonly specificationVersion = "v4"; readonly provider = "mock"; readonly supportedUrls: Record = {}; readonly calls: LanguageModelV4CallOptions[] = []; constructor( readonly modelId: string, private readonly responses: readonly MockModelResponse[] = [{ text: "mock response" }], ) {} async doGenerate(options: LanguageModelV4CallOptions): Promise { this.calls.push(options); const configured = this.responses[this.calls.length - 1]; const last = this.responses[this.responses.length - 1]; const response = configured ?? ((last?.toolCalls?.length ?? 0) > 0 ? { text: "mock response" } : last ?? { text: "mock response" }); const content: LanguageModelV4Content[] = []; if (response.text !== undefined) { content.push({ type: "text", text: response.text }); } for (const call of response.toolCalls ?? []) { content.push({ type: "tool-call", toolCallId: call.toolCallId, toolName: call.toolName, input: JSON.stringify(call.input), }); } const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop"); return { content, finishReason: { unified: finishReason, raw: finishReason }, usage: { inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined }, }, response: { id: `mock-${this.calls.length}`, timestamp: new Date(), modelId: this.modelId }, warnings: [], }; } async doStream(options: LanguageModelV4CallOptions): Promise { this.calls.push(options); const response = this.responses[this.calls.length - 1 % this.responses.length] ?? this.responses[0]!; const text = response.text ?? ""; const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop"); const id = `mock-${this.calls.length}`; const parts: LanguageModelV4StreamPart[] = [ { type: "text-start", id }, ]; if (text.length > 0) { parts.push({ type: "text-delta", id, delta: text }); } parts.push({ type: "text-end", id }); for (const call of response.toolCalls ?? []) { const tcId = `tc-${this.calls.length}-${call.toolName}`; parts.push({ type: "tool-input-start", id: tcId, toolName: call.toolName }); parts.push({ type: "tool-input-delta", id: tcId, delta: JSON.stringify(call.input) }); parts.push({ type: "tool-input-end", id: tcId }); } parts.push({ type: "finish", finishReason: { unified: finishReason, raw: finishReason }, usage: { inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined }, }, }); return { stream: new ReadableStream({ start(controller) { for (const part of parts) controller.enqueue(part); controller.close(); }, }), response: { id, timestamp: new Date(), modelId: "mock-model" }, }; } } export function createMockModelFactory( responses: readonly MockModelResponse[] = [{ text: "mock response" }], ): { readonly modelFactory: ModelFactory; readonly models: ReadonlyMap } { const models = new Map(); return { models, modelFactory: (modelId) => { const model = new MockLanguageModel(modelId, responses); models.set(modelId, model); return model; }, }; } /** 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 { 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 }, }); }