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 { const root = await mkdtemp(join(tmpdir(), prefix)); temporaryRoots.push(root); return root; }