forked from EduCraft/curriculum-project-hub
d94cc787b2
This reverts commit 4f8df12fb0.
186 lines
7.7 KiB
TypeScript
186 lines
7.7 KiB
TypeScript
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;
|
|
}
|