Files
curriculum-project-hub/hub/test/integration/helpers.ts
T

426 lines
15 KiB
TypeScript

/**
* 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";
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
export const DEFAULT_ORG_ID = "org_test_default";
export const TEST_SECRET_KEY_ID = "test-active";
export const TEST_SECRET_KEY = Buffer.alloc(32, "k");
export const testSecretEnvelope = new LocalSecretEnvelope({
activeKeyId: TEST_SECRET_KEY_ID,
keys: new Map([[TEST_SECRET_KEY_ID, TEST_SECRET_KEY]]),
});
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 = [
"OrganizationAgentRoleSkill",
"OrganizationAgentRole",
"OrganizationAgentSkill",
"FeishuEventReceipt",
"FeishuUserIdentity",
"FeishuApplicationCredentialVersion",
"OrganizationFeishuApplicationConnection",
"ProviderCredentialVersion",
"OrganizationProviderConnection",
"AgentFileChange",
"AgentMessage",
"AuditEntry",
"PermissionSettings",
"PermissionGrant",
"RoleTriggerGrant",
"ExternalPrincipalMembership",
"ExternalDirectoryConnection",
"TeamExternalBinding",
"TeamMembership",
"Team",
"ProjectAgentLock",
"AgentRun",
"AgentSession",
"ProjectGroupBinding",
"Folder",
"OrganizationProjectSettings",
"OrganizationMembership",
"PlatformRoleAssignment",
"User",
"Project",
"Organization",
];
// Truncate with CASCADE to wipe dependent rows in one shot.
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
await seedTestOrganization();
}
export async function seedTestOrganization(
id: string = DEFAULT_ORG_ID,
slug: string = "test-default",
): Promise<void> {
await prisma.organization.upsert({
where: { id },
update: {},
create: {
id,
slug,
name: "Test Default Organization",
},
});
await prisma.organizationProjectSettings.upsert({
where: { organizationId: id },
update: {},
create: { organizationId: id, membersCanCreateProjects: true },
});
const inbox = await prisma.folder.findFirst({
where: { organizationId: id, parentId: null, name: "Inbox", archivedAt: null },
select: { id: true },
});
if (inbox === null) {
await prisma.folder.create({
data: {
id: `folder_inbox_${id}`,
organizationId: id,
name: "Inbox",
sortKey: "000000",
},
});
}
}
/** 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<string, MockFeishuMessage>;
readonly threadMessages: Map<string, readonly MockFeishuMessage[]>;
}
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<string, MockFeishuMessage>();
const threadMessages = new Map<string, readonly MockFeishuMessage[]>();
// 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<string, RegExp[]> = {};
readonly calls: LanguageModelV4CallOptions[] = [];
constructor(
readonly modelId: string,
private readonly responses: readonly MockModelResponse[] = [{ text: "mock response" }],
) {}
async doGenerate(options: LanguageModelV4CallOptions): Promise<LanguageModelV4GenerateResult> {
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<LanguageModelV4StreamResult> {
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<string, MockLanguageModel> } {
const models = new Map<string, MockLanguageModel>();
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<void> {
const principal = options.principal ?? "ou_test_user";
const role = options.role ?? "EDIT";
await prisma.project.create({
data: {
id: projectId,
organizationId: DEFAULT_ORG_ID,
name: `Test ${projectId}`,
workspaceDir: `/tmp/test-${projectId}`,
},
});
await prisma.user.create({
data: {
id: "u_" + projectId,
feishuOpenId: principal,
displayName: "Test User",
platformRoles: { create: { role: "TEACHER" } },
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "MEMBER" } },
permissionGrants: {
create: {
resourceType: "PROJECT",
resourceId: projectId,
principalType: "USER",
principalId: principal,
role,
},
},
},
});
await prisma.projectGroupBinding.create({
data: { projectId, chatId, createdByUserId: "u_" + projectId },
});
}