forked from EduCraft/curriculum-project-hub
Merge remote-tracking branch 'educraft/main' into merge/educraft-cph
# Conflicts: # .gitignore # hub/.env.example # hub/deploy/deploy_fleet_release.sh # hub/deploy/deploy_platform.sh # hub/test/integration/helpers.ts
This commit is contained in:
@@ -242,7 +242,8 @@ describe("admin auth + org API guards", () => {
|
||||
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe("/admin");
|
||||
// New users without membership land on login error; session is still set.
|
||||
expect(res.headers.location).toBe("/admin/login?error=no_organization");
|
||||
expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session=");
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } });
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("Organization Agent configuration management", () => {
|
||||
provider: "openrouter",
|
||||
roleId: "draft",
|
||||
model: "anthropic/claude-sonnet-5",
|
||||
metadata: {},
|
||||
metadata: { claudeSessionId: "sdk-session-old", userResumable: true },
|
||||
},
|
||||
});
|
||||
await configuration.setRoleSkills({
|
||||
@@ -59,11 +59,12 @@ describe("Organization Agent configuration management", () => {
|
||||
expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" });
|
||||
expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]);
|
||||
expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]);
|
||||
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } }))
|
||||
.resolves.toMatchObject({
|
||||
archivedAt: expect.any(Date),
|
||||
metadata: expect.objectContaining({ userResumable: false }),
|
||||
});
|
||||
// Execution-surface change invalidates the provider session cursor but
|
||||
// keeps the Hub session alive so its transcript stays reachable.
|
||||
const invalidated = await prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } });
|
||||
expect(invalidated.archivedAt).toBeNull();
|
||||
expect(invalidated.metadata).toEqual(expect.objectContaining({ userResumable: false }));
|
||||
expect(invalidated.metadata).not.toHaveProperty("claudeSessionId");
|
||||
});
|
||||
|
||||
it("rejects unknown, disabled and cross-Organization skills", async () => {
|
||||
@@ -118,6 +119,105 @@ describe("Organization Agent configuration management", () => {
|
||||
})).rejects.toThrow("must have exactly one active default Agent role");
|
||||
});
|
||||
|
||||
it("groups roles and skills in the shared folder tree (ADR-0028)", async () => {
|
||||
const teaching = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "教学" });
|
||||
const lessonPrep = await configuration.createFolder({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "备课",
|
||||
parentId: teaching.id,
|
||||
});
|
||||
expect(lessonPrep.parentId).toBe(teaching.id);
|
||||
const listed = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
|
||||
expect(listed.map((folder) => folder.id).sort()).toEqual([teaching.id, lessonPrep.id].sort());
|
||||
|
||||
const typst = await makeSkill(root, "typst");
|
||||
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
|
||||
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: lessonPrep.id });
|
||||
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: teaching.id });
|
||||
|
||||
const skills = await configuration.listSkills({ organizationId: DEFAULT_ORG_ID });
|
||||
expect(skills.find((skill) => skill.name === "typst")?.folderId).toBe(lessonPrep.id);
|
||||
const roles = await configuration.listRoles({ organizationId: DEFAULT_ORG_ID });
|
||||
expect(roles.find((role) => role.roleId === "draft")?.folderId).toBe(teaching.id);
|
||||
|
||||
const renamed = await configuration.updateFolder({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
folderId: teaching.id,
|
||||
name: "教研",
|
||||
});
|
||||
expect(renamed.name).toBe("教研");
|
||||
});
|
||||
|
||||
it("moves folders within the tree and rejects moves below a descendant", async () => {
|
||||
const a = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "a" });
|
||||
const b = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "b", parentId: a.id });
|
||||
const c = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "c" });
|
||||
const moved = await configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: c.id, parentId: b.id });
|
||||
expect(moved.parentId).toBe(b.id);
|
||||
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: c.id }))
|
||||
.rejects.toThrow("folder cannot be moved below its descendant");
|
||||
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: a.id }))
|
||||
.rejects.toThrow("folder cannot be its own parent");
|
||||
});
|
||||
|
||||
it("deletes only empty folders", async () => {
|
||||
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "非空" });
|
||||
const typst = await makeSkill(root, "typst");
|
||||
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
|
||||
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: folder.id });
|
||||
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id }))
|
||||
.rejects.toThrow("still has");
|
||||
|
||||
const parent = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "父" });
|
||||
await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "子", parentId: parent.id });
|
||||
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: parent.id }))
|
||||
.rejects.toThrow("child folder");
|
||||
|
||||
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: null });
|
||||
await configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id });
|
||||
const remaining = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
|
||||
expect(remaining.map((f) => f.id)).not.toContain(folder.id);
|
||||
const skill = (await configuration.listSkills({ organizationId: DEFAULT_ORG_ID })).find((s) => s.name === "typst");
|
||||
expect(skill?.folderId).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects cross-Organization folder assignment", async () => {
|
||||
await seedTestOrganization("org_other", "other");
|
||||
const otherFolder = await configuration.createFolder({ organizationId: "org_other", name: "外部" });
|
||||
const typst = await makeSkill(root, "typst");
|
||||
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
|
||||
await expect(configuration.setSkillFolder({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "typst",
|
||||
folderId: otherFolder.id,
|
||||
})).rejects.toThrow("folder not found in organization");
|
||||
await expect(configuration.setRoleFolder({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
roleId: "draft",
|
||||
folderId: otherFolder.id,
|
||||
})).rejects.toThrow("folder not found in organization");
|
||||
});
|
||||
|
||||
it("treats folder assignment as a label-class change (no session archival)", async () => {
|
||||
await prisma.project.create({
|
||||
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
|
||||
});
|
||||
await prisma.agentSession.create({
|
||||
data: {
|
||||
id: "session-folder-assignment",
|
||||
projectId: "project-a",
|
||||
provider: "openrouter",
|
||||
roleId: "draft",
|
||||
model: "anthropic/claude-sonnet-5",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "分组" });
|
||||
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: folder.id });
|
||||
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-folder-assignment" } }))
|
||||
.resolves.toMatchObject({ archivedAt: null });
|
||||
});
|
||||
|
||||
async function makeSkill(parent: string, name: string): Promise<string> {
|
||||
const source = join(parent, "sources", name);
|
||||
await mkdir(source, { recursive: true });
|
||||
|
||||
@@ -157,9 +157,10 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
|
||||
).toBe("completed");
|
||||
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
|
||||
expect(new Set(result.initializedSkillIds)).toEqual(new Set([
|
||||
"cph-runtime:outline",
|
||||
]));
|
||||
const skillIds = new Set(result.initializedSkillIds ?? []);
|
||||
expect(skillIds.has("cph-runtime:outline")).toBe(true);
|
||||
// Workspace-local untrusted skills must never load (ADR-0018).
|
||||
expect([...skillIds].some((id) => id.includes("untrusted"))).toBe(false);
|
||||
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
|
||||
expect(toolResults).toHaveLength(2);
|
||||
const rejectedOptOut = toolResults[0];
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { prisma, resetDb, seedTestOrganization, testSecretEnvelope, DEFAULT_ORG_ID } from "./helpers.js";
|
||||
import { createPbankService } from "../../src/capability/pbank.js";
|
||||
import type { PbankClient, PbankLoginResult } from "../../src/capability/pbankClient.js";
|
||||
import type { PbankCapabilitySecretPayload } from "../../src/capability/types.js";
|
||||
import { CapabilityConnectionUnavailable } from "../../src/capability/types.js";
|
||||
|
||||
const CAPABILITY_ID = "pbank";
|
||||
const PROBLEM_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||
|
||||
describe("pbank capability service (ADR-0027)", () => {
|
||||
let workspaceRoot: string;
|
||||
let runId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
await seedTestOrganization();
|
||||
workspaceRoot = await mkdtemp(join(tmpdir(), "cph-pbank-"));
|
||||
runId = "run-pbank-test";
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: "proj-pbank",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "PBank Test",
|
||||
workspaceDir: workspaceRoot,
|
||||
},
|
||||
});
|
||||
await prisma.agentRun.create({
|
||||
data: {
|
||||
id: runId,
|
||||
projectId: "proj-pbank",
|
||||
entrypoint: "FEISHU",
|
||||
provider: "openrouter",
|
||||
model: "mock-model",
|
||||
status: "ACTIVE",
|
||||
prompt: "search pbank",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspaceRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedActiveConnection(): Promise<void> {
|
||||
const payload: PbankCapabilitySecretPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: "pbank",
|
||||
baseUrl: "https://pbank.example/api",
|
||||
username: "teacher",
|
||||
password: "secret-never-log",
|
||||
rightsStatus: "owned",
|
||||
rightsHolder: "Paradigm Education",
|
||||
};
|
||||
const connection = await prisma.organizationCapabilityConnection.create({
|
||||
data: {
|
||||
id: "pbank-conn-1",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
capabilityId: CAPABILITY_ID,
|
||||
status: "ACTIVE",
|
||||
activatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const envelope = testSecretEnvelope.encryptJson(
|
||||
{
|
||||
purpose: "capability",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
connectionId: connection.id,
|
||||
secretVersionId: "pbank-sv-1",
|
||||
},
|
||||
payload,
|
||||
);
|
||||
await prisma.capabilityCredentialVersion.create({
|
||||
data: {
|
||||
id: "pbank-sv-1",
|
||||
connectionId: connection.id,
|
||||
version: 1,
|
||||
envelope: envelope as object,
|
||||
keyId: envelope.keyId,
|
||||
},
|
||||
});
|
||||
await prisma.organizationCapabilityConnection.update({
|
||||
where: { id: connection.id },
|
||||
data: { activeSecretVersionId: "pbank-sv-1" },
|
||||
});
|
||||
}
|
||||
|
||||
function mockClient(): PbankClient {
|
||||
const login: PbankLoginResult = { token: "tok-1", expiresAt: Date.now() + 60_000 };
|
||||
return {
|
||||
login: vi.fn(async () => login),
|
||||
searchProblems: vi.fn(async () => ({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
total: 1,
|
||||
items: [{ id: PROBLEM_ID, title: "示例题" }],
|
||||
})),
|
||||
getProblem: vi.fn(async () => ({ id: PROBLEM_ID, title: "示例题" })),
|
||||
getOccurrences: vi.fn(async () => ({ items: [] })),
|
||||
downloadProject: vi.fn(async () => ({
|
||||
buffer: Buffer.from("# problem\n"),
|
||||
contentType: "text/plain",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
it("fails closed when no ACTIVE connection exists", async () => {
|
||||
const service = createPbankService({
|
||||
prisma,
|
||||
secrets: testSecretEnvelope,
|
||||
client: mockClient(),
|
||||
});
|
||||
await expect(
|
||||
service.searchProblems(
|
||||
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
|
||||
{ q: "函数" },
|
||||
),
|
||||
).rejects.toBeInstanceOf(CapabilityConnectionUnavailable);
|
||||
});
|
||||
|
||||
it("searches via org credential and writes UsageFact without leaking password", async () => {
|
||||
await seedActiveConnection();
|
||||
const client = mockClient();
|
||||
const service = createPbankService({
|
||||
prisma,
|
||||
secrets: testSecretEnvelope,
|
||||
client,
|
||||
});
|
||||
const result = await service.searchProblems(
|
||||
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
|
||||
{ q: "函数", pageNum: 1, pageSize: 10 },
|
||||
);
|
||||
|
||||
expect(client.login).toHaveBeenCalledTimes(1);
|
||||
const loginArg = (client.login as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as PbankCapabilitySecretPayload;
|
||||
expect(loginArg.username).toBe("teacher");
|
||||
expect(loginArg.password).toBe("secret-never-log");
|
||||
expect(loginArg.kind).toBe("pbank");
|
||||
|
||||
expect(result.data).toMatchObject({
|
||||
rights: { derivativeUseAllowed: true, status: "owned" },
|
||||
items: [{ id: PROBLEM_ID }],
|
||||
});
|
||||
expect(JSON.stringify(result.data)).not.toContain("secret-never-log");
|
||||
|
||||
const facts = await prisma.usageFact.findMany({ where: { runId } });
|
||||
expect(facts).toHaveLength(1);
|
||||
expect(facts[0]).toMatchObject({
|
||||
kind: "external_capability",
|
||||
capabilityId: CAPABILITY_ID,
|
||||
provider: "paradigm_pbank",
|
||||
unit: "requests",
|
||||
quantity: expect.anything(),
|
||||
costUsd: null,
|
||||
costSource: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches one problem and attaches rights", async () => {
|
||||
await seedActiveConnection();
|
||||
const client = mockClient();
|
||||
const service = createPbankService({
|
||||
prisma,
|
||||
secrets: testSecretEnvelope,
|
||||
client,
|
||||
});
|
||||
const result = await service.getProblem(
|
||||
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
|
||||
{ urlOrId: PROBLEM_ID, includeProjects: true, materializeProjects: false },
|
||||
);
|
||||
expect(result.data).toMatchObject({
|
||||
id: PROBLEM_ID,
|
||||
rights: { derivativeUseAllowed: true },
|
||||
problem: { id: PROBLEM_ID },
|
||||
projects: {
|
||||
problem: { status: "text" },
|
||||
answer: { status: "text" },
|
||||
},
|
||||
});
|
||||
expect(client.getProblem).toHaveBeenCalled();
|
||||
expect(client.downloadProject).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
|
||||
async function seedActiveCapabilityConnection(): Promise<void> {
|
||||
const payload: CapabilitySecretPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: "docmind",
|
||||
accessKeyId: "LTAI-test-key-id",
|
||||
accessKeySecret: "test-secret-never-log",
|
||||
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
|
||||
|
||||
@@ -16,7 +16,9 @@ import { ProviderConnectionService } from "../../src/connections/providerConnect
|
||||
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
const TEST_DATABASE_URL =
|
||||
process.env.DATABASE_URL?.trim() ||
|
||||
"postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
|
||||
describe("deployment preflight CLI", { timeout: 20_000 }, () => {
|
||||
let root: string;
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
|
||||
* AI SDK model factory (doGenerate() returns canned responses - no network).
|
||||
*/
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type {
|
||||
@@ -19,7 +21,21 @@ 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";
|
||||
|
||||
// Admin routes and agent-config tests need a skill store root; seed once for
|
||||
// the whole vitest process when CI/dev didn't set one.
|
||||
if (
|
||||
(process.env.HUB_SKILL_STORE_ROOT === undefined || process.env.HUB_SKILL_STORE_ROOT.trim() === "") &&
|
||||
(process.env.XDG_STATE_HOME === undefined || process.env.XDG_STATE_HOME.trim() === "")
|
||||
) {
|
||||
process.env.HUB_SKILL_STORE_ROOT = `${tmpdir()}/cph-test-skills`;
|
||||
}
|
||||
if (process.env.HUB_SKILL_STORE_ROOT !== undefined && process.env.HUB_SKILL_STORE_ROOT.trim() !== "") {
|
||||
mkdirSync(process.env.HUB_SKILL_STORE_ROOT, { recursive: true });
|
||||
}
|
||||
export const TEST_DATABASE_URL =
|
||||
process.env.DATABASE_URL?.trim() ||
|
||||
"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");
|
||||
@@ -34,26 +50,29 @@ export const prisma = new PrismaClient({
|
||||
|
||||
/** 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(),
|
||||
// MemberGroup is global (ADR-0028): no FK to the org/user roots, so the
|
||||
// cascade above never reaches it. Clear explicitly — closure/membership
|
||||
// first (they FK into MemberGroup), groups last.
|
||||
prisma.memberGroupClosure.deleteMany(),
|
||||
prisma.memberGroupMembership.deleteMany(),
|
||||
prisma.memberGroup.deleteMany(),
|
||||
prisma.user.deleteMany(),
|
||||
prisma.organization.deleteMany(),
|
||||
]);
|
||||
// Hard reset via TRUNCATE CASCADE. Parent RESTRICT edges and leftover
|
||||
// folder trees made deleteMany-based cleanup race Prisma upserts
|
||||
// ("Unique constraint failed on id" while the where-branch saw no row).
|
||||
await prisma.$executeRawUnsafe(`
|
||||
DO $$
|
||||
DECLARE
|
||||
stmt text;
|
||||
BEGIN
|
||||
SELECT 'TRUNCATE TABLE ' || string_agg(format('%I.%I', schemaname, tablename), ', ')
|
||||
|| ' RESTART IDENTITY CASCADE'
|
||||
INTO stmt
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename <> '_prisma_migrations';
|
||||
IF stmt IS NOT NULL THEN
|
||||
EXECUTE stmt;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
const leftover = await prisma.organization.count();
|
||||
if (leftover !== 0) {
|
||||
throw new Error(`resetDb truncate left ${leftover} organization row(s)`);
|
||||
}
|
||||
await seedTestOrganization();
|
||||
}
|
||||
|
||||
@@ -61,49 +80,51 @@ export async function seedTestOrganization(
|
||||
id: string = DEFAULT_ORG_ID,
|
||||
slug: string = "test-default",
|
||||
): Promise<void> {
|
||||
// Serialise generate+inbox against concurrent callers in the same process.
|
||||
// Integration tests share one DB and some files call seed without resetDb.
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.organization.upsert({
|
||||
const existing = await tx.organization.findUnique({
|
||||
where: { id },
|
||||
update: {},
|
||||
create: { id, slug, name: "Test Default Organization" },
|
||||
select: { id: true },
|
||||
});
|
||||
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 },
|
||||
if (existing === null) {
|
||||
await tx.organization.create({
|
||||
data: {
|
||||
id,
|
||||
slug,
|
||||
name: "Test Default Organization",
|
||||
projectSettings: {
|
||||
create: { membersCanCreateProjects: true },
|
||||
},
|
||||
agentRoles: {
|
||||
create: {
|
||||
id: `agent_role_draft_${id}`,
|
||||
roleId: "draft",
|
||||
label: "草稿",
|
||||
sortOrder: 10,
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const inbox = await tx.folder.findFirst({
|
||||
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (inbox === null) {
|
||||
await tx.folder.create({
|
||||
data: {
|
||||
id: `folder_inbox_${id}`,
|
||||
organizationId: id,
|
||||
name: "Inbox",
|
||||
kind: "SYSTEM_INBOX",
|
||||
sortKey: "000000",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
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). */
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
seedProject,
|
||||
seedTestOrganization,
|
||||
silentLogger,
|
||||
testSecretEnvelope,
|
||||
} from "./helpers.js";
|
||||
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
|
||||
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
|
||||
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
|
||||
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
|
||||
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
|
||||
import type { RuntimeSettings } from "../../src/settings/runtime.js";
|
||||
|
||||
@@ -34,6 +37,7 @@ function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProduc
|
||||
publicBaseUrl: "https://educraft.example.test",
|
||||
siloOrganizationId: DEFAULT_ORG_ID,
|
||||
allowLegacyFeishuIdentity: true,
|
||||
secretEnvelope: testSecretEnvelope,
|
||||
...deps,
|
||||
});
|
||||
}
|
||||
@@ -190,13 +194,14 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
where: { id: "proj-post-image" },
|
||||
data: { workspaceDir },
|
||||
});
|
||||
const messageResourceGet = vi.fn(async () => ({
|
||||
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
|
||||
}));
|
||||
const imV1 = (rt.client as unknown as {
|
||||
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
|
||||
}).im.v1;
|
||||
imV1.messageResource = { get: messageResourceGet };
|
||||
const downloadResource = vi.fn(async (request) => writeNewWorkspaceFileNoFollow(
|
||||
request.workspaceRoot,
|
||||
request.workspaceDir,
|
||||
request.workspaceRelativePath,
|
||||
Readable.from([Buffer.from("image bytes")]),
|
||||
request.maxBytes,
|
||||
));
|
||||
const feishuBotCli: FeishuBotCli = { downloadResource };
|
||||
const baseEvent = makeEvent("chat-post-image", "@_user_1 看看这张图");
|
||||
const event: MessageReceiveEvent = {
|
||||
...baseEvent,
|
||||
@@ -220,6 +225,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
runAgent,
|
||||
projectWorkspaceRoot: workspaceRoot,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
feishuBotCli,
|
||||
});
|
||||
|
||||
await trigger(event, rt);
|
||||
@@ -228,10 +234,11 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
});
|
||||
expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox"));
|
||||
expect(messageResourceGet).toHaveBeenCalledWith({
|
||||
params: { type: "image" },
|
||||
path: { message_id: event.message.message_id, file_key: "img-key-1" },
|
||||
});
|
||||
expect(downloadResource).toHaveBeenCalledWith(expect.objectContaining({
|
||||
messageId: event.message.message_id,
|
||||
fileKey: "img-key-1",
|
||||
resourceType: "image",
|
||||
}));
|
||||
const inboxFiles = await readdir(join(workspaceDir, ".cph", "inbox"));
|
||||
expect(inboxFiles).toHaveLength(1);
|
||||
await expect(readFile(join(workspaceDir, ".cph", "inbox", inboxFiles[0]!))).resolves.toEqual(Buffer.from("image bytes"));
|
||||
@@ -1109,15 +1116,18 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
});
|
||||
const resourceEntered = deferred<void>();
|
||||
const releaseResource = deferred<void>();
|
||||
const messageResourceGet = vi.fn(async () => {
|
||||
const downloadResource = vi.fn(async (request) => {
|
||||
resourceEntered.resolve();
|
||||
await releaseResource.promise;
|
||||
return { getReadableStream: () => Readable.from([Buffer.from("staged image bytes")]) };
|
||||
return writeNewWorkspaceFileNoFollow(
|
||||
request.workspaceRoot,
|
||||
request.workspaceDir,
|
||||
request.workspaceRelativePath,
|
||||
Readable.from([Buffer.from("staged image bytes")]),
|
||||
request.maxBytes,
|
||||
);
|
||||
});
|
||||
const imV1 = (rt.client as unknown as {
|
||||
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
|
||||
}).im.v1;
|
||||
imV1.messageResource = { get: messageResourceGet };
|
||||
const feishuBotCli: FeishuBotCli = { downloadResource };
|
||||
const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态");
|
||||
const event: MessageReceiveEvent = {
|
||||
...baseEvent,
|
||||
@@ -1141,6 +1151,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
runAgent,
|
||||
projectWorkspaceRoot: workspaceRoot,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
feishuBotCli,
|
||||
});
|
||||
|
||||
const pendingTrigger = trigger(event, rt);
|
||||
@@ -1600,7 +1611,9 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(runs[0]?.status).toBe("CANCELED");
|
||||
});
|
||||
expect(patch).toHaveBeenCalled();
|
||||
expect(rt.sentTexts).toContain("已中断当前运行。");
|
||||
await vi.waitFor(() => {
|
||||
expect(rt.sentTexts).toContain("已中断当前运行。");
|
||||
});
|
||||
});
|
||||
|
||||
it("denies interrupt when the operator lacks agent.cancel permission", async () => {
|
||||
|
||||
Reference in New Issue
Block a user