forked from bai/curriculum-project-hub
423 lines
15 KiB
TypeScript
423 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 = [
|
|
"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 },
|
|
});
|
|
}
|