forked from EduCraft/curriculum-project-hub
feat: migrate legacy projects through binding search
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { importLegacyProjects } from "../../src/deployment/legacyProjectImport.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
describe("legacy teaching-material project import", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: "legacy-import-owner",
|
||||
feishuOpenId: "ou_legacy_owner",
|
||||
displayName: "Legacy Import Owner",
|
||||
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "OWNER" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (temporaryRoots.length > 0) {
|
||||
const root = temporaryRoots.pop();
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => prisma.$disconnect());
|
||||
|
||||
it("imports each legacy project as an unbound resumable project under its old folder path", async () => {
|
||||
const sourceRoot = await temporaryRoot("cph-legacy-source-");
|
||||
const workspaceRoot = await temporaryRoot("cph-legacy-target-");
|
||||
const projectSource = join(sourceRoot, "物理", "M-243-牛顿力学");
|
||||
await mkdir(join(projectSource, "workspace", "chapters"), { recursive: true });
|
||||
await mkdir(join(projectSource, "workspace", ".claude"), { recursive: true });
|
||||
await mkdir(join(projectSource, "workspace", "chapters", ".cph"), { recursive: true });
|
||||
await mkdir(join(projectSource, "_raw"), { recursive: true });
|
||||
await writeFile(join(projectSource, "workspace", "project.toml"), "title = \"牛顿力学\"\n");
|
||||
await writeFile(join(projectSource, "workspace", "chapters", "lesson.typ"), "= 牛顿第二定律\n");
|
||||
await writeFile(join(projectSource, "workspace", ".claude", "session.json"), "{}\n");
|
||||
await writeFile(join(projectSource, "workspace", "chapters", ".cph", "runtime.json"), "{}\n");
|
||||
await writeFile(join(projectSource, "project.json"), "{\"id\":\"M-243\"}\n");
|
||||
await writeFile(join(projectSource, "_raw", "source.txt"), "legacy source\n");
|
||||
const stateFile = join(workspaceRoot, "migration-state", "state.json");
|
||||
const manifest = [{
|
||||
legacyId: "M-243",
|
||||
name: "牛顿力学",
|
||||
folderPath: ["物理"],
|
||||
sourceRelativePath: "物理/M-243-牛顿力学",
|
||||
}];
|
||||
|
||||
const first = await importLegacyProjects({
|
||||
prisma,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorFeishuOpenId: "ou_legacy_owner",
|
||||
workspaceRoot,
|
||||
sourceRoot,
|
||||
stateFile,
|
||||
projects: manifest,
|
||||
});
|
||||
const imported = first.projects["M-243"];
|
||||
expect(imported).toBeDefined();
|
||||
if (imported === undefined) throw new Error("missing imported project state");
|
||||
|
||||
const project = await prisma.project.findUniqueOrThrow({
|
||||
where: { id: imported.projectId },
|
||||
include: { folder: { include: { parent: true } }, groupBindings: true },
|
||||
});
|
||||
expect(project.name).toBe("牛顿力学");
|
||||
expect(project.folder?.name).toBe("物理");
|
||||
expect(project.folder?.parent?.name).toBe("旧教学资产");
|
||||
expect(project.groupBindings).toEqual([]);
|
||||
await expect(readFile(join(imported.workspaceDir, "chapters", "lesson.typ"), "utf8"))
|
||||
.resolves.toBe("= 牛顿第二定律\n");
|
||||
await expect(readFile(join(imported.workspaceDir, ".claude", "session.json"), "utf8"))
|
||||
.rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readFile(join(imported.workspaceDir, "chapters", ".cph", "runtime.json"), "utf8"))
|
||||
.rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readFile(join(imported.workspaceDir, ".legacy-source", "project.json"), "utf8"))
|
||||
.resolves.toContain("M-243");
|
||||
await expect(readFile(join(imported.workspaceDir, ".legacy-source", "raw", "source.txt"), "utf8"))
|
||||
.resolves.toBe("legacy source\n");
|
||||
|
||||
await unlink(stateFile);
|
||||
const second = await importLegacyProjects({
|
||||
prisma,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorFeishuOpenId: "ou_legacy_owner",
|
||||
workspaceRoot,
|
||||
sourceRoot,
|
||||
stateFile,
|
||||
projects: manifest,
|
||||
});
|
||||
expect(second.projects["M-243"]?.projectId).toBe(imported.projectId);
|
||||
await expect(prisma.project.count({ where: { organizationId: DEFAULT_ORG_ID } })).resolves.toBe(1);
|
||||
expect(JSON.parse(await readFile(stateFile, "utf8"))).toMatchObject({
|
||||
version: 1,
|
||||
projects: { "M-243": { projectId: imported.projectId } },
|
||||
});
|
||||
await expect(prisma.auditEntry.count({
|
||||
where: { projectId: imported.projectId, action: "legacy_project.imported" },
|
||||
})).resolves.toBe(1);
|
||||
|
||||
await writeFile(join(imported.workspaceDir, ".legacy-source", "migration.json"), "not json\n");
|
||||
await expect(importLegacyProjects({
|
||||
prisma,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorFeishuOpenId: "ou_legacy_owner",
|
||||
workspaceRoot,
|
||||
sourceRoot,
|
||||
stateFile,
|
||||
projects: manifest,
|
||||
})).rejects.toThrow(/invalid legacy completion marker/);
|
||||
await expect(prisma.project.count({ where: { id: imported.projectId } })).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("rejects symlinks instead of importing paths outside the staged project", async () => {
|
||||
const sourceRoot = await temporaryRoot("cph-legacy-symlink-");
|
||||
const workspaceRoot = await temporaryRoot("cph-legacy-target-");
|
||||
const projectSource = join(sourceRoot, "legacy");
|
||||
await mkdir(join(projectSource, "workspace"), { recursive: true });
|
||||
await writeFile(join(projectSource, "project.json"), "{}\n");
|
||||
await symlink("/etc/passwd", join(projectSource, "workspace", "outside"));
|
||||
|
||||
await expect(importLegacyProjects({
|
||||
prisma,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorFeishuOpenId: "ou_legacy_owner",
|
||||
workspaceRoot,
|
||||
sourceRoot,
|
||||
stateFile: join(workspaceRoot, "state.json"),
|
||||
projects: [{ legacyId: "symlink", name: "Symlink", folderPath: [], sourceRelativePath: "legacy" }],
|
||||
})).rejects.toThrow(/rejects symbolic link/);
|
||||
await expect(prisma.project.count()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a manifest path that escapes the staged source root", async () => {
|
||||
const parent = await temporaryRoot("cph-legacy-escape-");
|
||||
const sourceRoot = join(parent, "source");
|
||||
const outside = join(parent, "outside");
|
||||
await mkdir(sourceRoot);
|
||||
await mkdir(outside);
|
||||
|
||||
await expect(importLegacyProjects({
|
||||
prisma,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorFeishuOpenId: "ou_legacy_owner",
|
||||
workspaceRoot: await temporaryRoot("cph-legacy-target-"),
|
||||
sourceRoot,
|
||||
stateFile: join(parent, "state.json"),
|
||||
projects: [{
|
||||
legacyId: "escape",
|
||||
name: "Escape",
|
||||
folderPath: [],
|
||||
sourceRelativePath: "../outside",
|
||||
}],
|
||||
})).rejects.toThrow(/escapes source root/);
|
||||
});
|
||||
|
||||
it("rejects malformed resume state before creating a project", async () => {
|
||||
const sourceRoot = await temporaryRoot("cph-legacy-state-source-");
|
||||
const workspaceRoot = await temporaryRoot("cph-legacy-state-target-");
|
||||
const stateFile = join(workspaceRoot, "state.json");
|
||||
await writeFile(stateFile, JSON.stringify({ version: 1, projects: { broken: { status: "MAYBE" } } }));
|
||||
|
||||
await expect(importLegacyProjects({
|
||||
prisma,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorFeishuOpenId: "ou_legacy_owner",
|
||||
workspaceRoot,
|
||||
sourceRoot,
|
||||
stateFile,
|
||||
projects: [],
|
||||
})).rejects.toThrow(/invalid legacy import state fields/);
|
||||
await expect(prisma.project.count()).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function temporaryRoot(prefix: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), prefix));
|
||||
temporaryRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
@@ -361,6 +361,100 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目");
|
||||
});
|
||||
|
||||
it("uses unbound-chat mention text to search bindable projects", async () => {
|
||||
await seedOnboardingUser("u-onboard-search", "ou_onboard_search", "OWNER");
|
||||
const folder = await prisma.folder.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, name: "物理竞赛" },
|
||||
});
|
||||
await prisma.project.createMany({
|
||||
data: [
|
||||
{
|
||||
id: "p-search-newton",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
folderId: folder.id,
|
||||
name: "牛顿力学专题",
|
||||
workspaceDir: join(await tempWorkspaceRoot(), "p-search-newton"),
|
||||
},
|
||||
{
|
||||
id: "p-search-optics",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
folderId: folder.id,
|
||||
name: "几何光学专题",
|
||||
workspaceDir: join(await tempWorkspaceRoot(), "p-search-optics"),
|
||||
},
|
||||
],
|
||||
});
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
projectWorkspaceRoot: await tempWorkspaceRoot(),
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-onboard-search", "@_user_1 牛顿", "ou_onboard_search"), rt);
|
||||
|
||||
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
|
||||
project_onboarding: {
|
||||
action: "bind_project",
|
||||
organization_id: DEFAULT_ORG_ID,
|
||||
project_id: "p-search-newton",
|
||||
},
|
||||
});
|
||||
expect(cardActionValues(rt.sentCards[0])).not.toContainEqual(expect.objectContaining({
|
||||
project_onboarding: expect.objectContaining({ project_id: "p-search-optics" }),
|
||||
}));
|
||||
expect(JSON.stringify(rt.sentCards[0])).toContain("牛顿");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("continues searching past unauthorized matches for a manageable project", async () => {
|
||||
await seedOnboardingUser("u-onboard-page", "ou_onboard_page", "MEMBER");
|
||||
const workspaceRoot = await tempWorkspaceRoot();
|
||||
await prisma.project.createMany({
|
||||
data: [
|
||||
...Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `z-search-denied-${String(index).padStart(2, "0")}`,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: `迁移项目 ${index}`,
|
||||
workspaceDir: join(workspaceRoot, `denied-${index}`),
|
||||
})),
|
||||
{
|
||||
id: "a-search-allowed",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "迁移项目 可管理",
|
||||
workspaceDir: join(workspaceRoot, "allowed"),
|
||||
},
|
||||
],
|
||||
});
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "a-search-allowed",
|
||||
principalType: "USER",
|
||||
principalId: "ou_onboard_page",
|
||||
role: "MANAGE",
|
||||
},
|
||||
});
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
projectWorkspaceRoot: workspaceRoot,
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-onboard-page", "@_user_1 迁移", "ou_onboard_page"), rt);
|
||||
|
||||
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
|
||||
project_onboarding: {
|
||||
action: "bind_project",
|
||||
organization_id: DEFAULT_ORG_ID,
|
||||
project_id: "a-search-allowed",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("batches quick text messages from the same chat and sender into one run", async () => {
|
||||
await seedProject("proj-1b", "chat-1b");
|
||||
const trigger = makeTriggerHandler({
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
describe("legacy project manifest builder", () => {
|
||||
afterEach(async () => {
|
||||
while (temporaryRoots.length > 0) {
|
||||
const root = temporaryRoots.pop();
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("stops at a project root and excludes trash projects", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-legacy-manifest-"));
|
||||
temporaryRoots.push(root);
|
||||
const projectRoot = join(root, "物理", "legacy__牛顿力学");
|
||||
await mkdir(join(projectRoot, "workspace", "nested"), { recursive: true });
|
||||
await mkdir(join(projectRoot, "_raw"), { recursive: true });
|
||||
await mkdir(join(root, ".trash", "deleted"), { recursive: true });
|
||||
await writeFile(join(projectRoot, "project.json"), JSON.stringify({
|
||||
id: "legacy",
|
||||
name: "牛顿力学",
|
||||
folderPath: ["物理"],
|
||||
}));
|
||||
await writeFile(join(projectRoot, "workspace", "nested", "project.json"), "not metadata");
|
||||
await writeFile(join(projectRoot, "_raw", "project.json"), "not metadata");
|
||||
await writeFile(join(root, ".trash", "deleted", "project.json"), JSON.stringify({
|
||||
id: "deleted",
|
||||
name: "Deleted",
|
||||
}));
|
||||
|
||||
const { stdout } = await execute(process.execPath, [
|
||||
resolve("deploy/build_legacy_project_manifest.mjs"),
|
||||
root,
|
||||
]);
|
||||
|
||||
expect(JSON.parse(stdout)).toEqual([{
|
||||
legacyId: "legacy",
|
||||
name: "牛顿力学",
|
||||
folderPath: ["物理"],
|
||||
sourceRelativePath: "物理/legacy__牛顿力学",
|
||||
}]);
|
||||
});
|
||||
|
||||
it("rejects symlinked entries instead of silently omitting them", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-legacy-manifest-link-"));
|
||||
temporaryRoots.push(root);
|
||||
await symlink("/tmp", join(root, "linked-project"));
|
||||
|
||||
await expect(execute(process.execPath, [
|
||||
resolve("deploy/build_legacy_project_manifest.mjs"),
|
||||
root,
|
||||
])).rejects.toMatchObject({ stderr: expect.stringContaining("rejects symbolic link") });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user