forked from bai/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;
|
||||
}
|
||||
Reference in New Issue
Block a user