/** * 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", "ExternalPrincipalMembership", "TeamExternalBinding", "TeamMembership", "Team", "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 sentReplies: Array<{ readonly messageId: string; readonly msgType: string; readonly content: string; readonly replyInThread: unknown; }>; readonly reactions: Array<{ readonly messageId: string; readonly emoji: string }>; readonly readableMessages: Map; readonly threadMessages: Map; } export interface MockFeishuMessage { readonly message_id?: string; readonly root_id?: string; readonly parent_id?: string; readonly thread_id?: string; readonly msg_type?: string; readonly create_time?: string; readonly chat_id?: string; readonly sender?: { readonly id?: string; readonly sender_type?: string }; readonly body?: { readonly content?: string }; readonly content?: string; } export function mockFeishuRuntime(): MockFeishuRuntime { const sentTexts: string[] = []; const sentCards: unknown[] = []; const sentPatches: unknown[] = []; const sentReplies: MockFeishuRuntime["sentReplies"] = []; const reactions: Array<{ messageId: string; emoji: string }> = []; const readableMessages = new Map(); const threadMessages = new Map(); // 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 } }; recordSentMessage(payload.data, sentTexts, sentCards); return { data: { message_id: "mock-msg-id" } }; }, reply: async (p: unknown) => { const payload = p as { path?: { message_id?: string }; data?: { msg_type?: string; content?: string; reply_in_thread?: unknown }; }; sentReplies.push({ messageId: payload.path?.message_id ?? "", msgType: payload.data?.msg_type ?? "", content: payload.data?.content ?? "", replyInThread: payload.data?.reply_in_thread, }); recordSentMessage(payload.data, sentTexts, sentCards); 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 {}; }, get: async (p: unknown) => { const payload = p as { path?: { message_id?: string } }; const messageId = payload.path?.message_id ?? ""; const message = readableMessages.get(messageId); return { data: { items: message === undefined ? [] : [message] } }; }, list: async (p: unknown) => { const payload = p as { params?: { container_id?: string } }; const containerId = payload.params?.container_id ?? ""; const items = threadMessages.get(containerId) ?? [...readableMessages.values()].filter((message) => message.parent_id === containerId); return { data: { items } }; }, }, }, }, } as unknown as FeishuRuntime["client"], logger: silentLogger, sentTexts, sentCards, sentPatches, sentReplies, reactions, readableMessages, threadMessages, }; return rt; } function recordSentMessage( data: { msg_type?: string; content?: string } | undefined, sentTexts: string[], sentCards: unknown[], ): void { if (data?.msg_type === "interactive") { const card = data.content ? JSON.parse(data.content) : null; sentCards.push(card); const text = textFromCard(card); if (text !== null) sentTexts.push(text); return; } try { const c = JSON.parse(data?.content ?? "{}") as { text?: string }; sentTexts.push(c.text ?? data?.content ?? ""); } catch { sentTexts.push(data?.content ?? ""); } } 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; // New format: { tag: "markdown", content: string } const tag = (element as { tag?: unknown }).tag; const directContent = (element as { content?: unknown }).content; if (tag === "markdown" && typeof directContent === "string") { parts.push(directContent); continue; } // Old format: { text: { content: string } } const text = (element as { text?: unknown }).text; if (typeof text === "object" && text !== null) { 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, principalType: "USER", principalId: principal, role, }, }, }, }); await prisma.projectGroupBinding.create({ data: { projectId, chatId, createdByUserId: "u_" + projectId }, }); }