forked from EduCraft/curriculum-project-hub
adce8fb6f5
ADR-0023 / Spec.System.PlatformAdministration pins the platform
administrator as a separate identity/session/audit control plane,
intentionally not modeled in alpha (ADR-0025). The legacy
PlatformRoleAssignment / PlatformRole{ADMIN,TEACHER} table had no runtime
reader (no guard, route, or service queried it for an authorization
decision) and ADR-0023 requires it to be replaced before the platform
panel ships.
Drop the model, the PlatformRole enum, the User.platformRoles relation,
and the migration. Stop seeding platformRoles in externalSync principal
ingestion and the integration test helper. Update the doc comments on
OrganizationMembership and PermissionRole to point at the platform-admin
control plane instead of the dropped model.
The 20260709180000_organization_tenant_root backfill only referenced
PlatformRoleAssignment in a one-time INSERT...SELECT; no persistent
object references it, so dropping the table is safe after that migration.
425 lines
15 KiB
TypeScript
425 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> {
|
|
// User and Organization are the aggregate roots for all domain rows; their
|
|
// declared FK cascades clear projects, search documents, permissions,
|
|
// sessions and connections without repeatedly truncating pg_trgm indexes.
|
|
// Event receipts and global audit rows are independent roots.
|
|
await prisma.$transaction([
|
|
prisma.feishuEventReceipt.deleteMany(),
|
|
prisma.auditEntry.deleteMany(),
|
|
// Permission resource ids are intentionally polymorphic strings, so these
|
|
// two tables have no FK to Project and must be cleared explicitly.
|
|
prisma.permissionGrant.deleteMany(),
|
|
prisma.permissionSettings.deleteMany(),
|
|
prisma.user.deleteMany(),
|
|
prisma.organization.deleteMany(),
|
|
]);
|
|
await seedTestOrganization();
|
|
}
|
|
|
|
export async function seedTestOrganization(
|
|
id: string = DEFAULT_ORG_ID,
|
|
slug: string = "test-default",
|
|
): Promise<void> {
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.organization.upsert({
|
|
where: { id },
|
|
update: {},
|
|
create: { id, slug, name: "Test Default Organization" },
|
|
});
|
|
await tx.organizationProjectSettings.upsert({
|
|
where: { organizationId: id },
|
|
update: {},
|
|
create: { organizationId: id, membersCanCreateProjects: true },
|
|
});
|
|
const defaultRole = await tx.organizationAgentRole.upsert({
|
|
where: { organizationId_roleId: { organizationId: id, roleId: "draft" } },
|
|
update: { label: "草稿", isDefault: true, disabledAt: null },
|
|
create: {
|
|
id: `agent_role_draft_${id}`,
|
|
organizationId: id,
|
|
roleId: "draft",
|
|
label: "草稿",
|
|
sortOrder: 10,
|
|
isDefault: true,
|
|
},
|
|
});
|
|
await tx.organizationAgentRole.updateMany({
|
|
where: { organizationId: id, id: { not: defaultRole.id }, isDefault: true },
|
|
data: { isDefault: false },
|
|
});
|
|
});
|
|
const inbox = await prisma.folder.findFirst({
|
|
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
|
|
select: { id: true },
|
|
});
|
|
if (inbox === null) {
|
|
await prisma.folder.create({
|
|
data: {
|
|
id: `folder_inbox_${id}`,
|
|
organizationId: id,
|
|
name: "Inbox",
|
|
kind: "SYSTEM_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",
|
|
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "MEMBER" } },
|
|
permissionGrants: {
|
|
create: {
|
|
resourceType: "PROJECT",
|
|
resourceId: projectId,
|
|
principalType: "USER",
|
|
principalId: principal,
|
|
role,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await prisma.projectGroupBinding.create({
|
|
data: {
|
|
organizationId: DEFAULT_ORG_ID,
|
|
projectId,
|
|
chatId,
|
|
createdByUserId: "u_" + projectId,
|
|
selectedAgentRoleId: `agent_role_draft_${DEFAULT_ORG_ID}`,
|
|
},
|
|
});
|
|
}
|