Files
ai-drama-platform/scripts/smoke-project-isolation.mjs
T

128 lines
7.9 KiB
JavaScript

import assert from "node:assert/strict";
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
import { dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const projectRoot = resolve(import.meta.dirname, "..");
const organizationId = "org-studio-lab";
const workspaceId = "ws-local-aidrama";
const projectId = `smoke-isolation-${Date.now()}`;
const projectName = `隔离验收项目 ${projectId.slice(-6)}`;
const emptyOrganizationId = `smoke-empty-org-${Date.now()}`;
async function request(path, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
function expectOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
let created = false;
let emptyOrganizationCreated = false;
let isolatedAssetPath = "";
try {
const login = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
}), "owner login");
const headers = {
authorization: `Bearer ${login.session.token}`,
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": "thunder-mouth"
};
const original = expectOk(await request("/api/project", { headers }), "read original project");
const originalShotIds = new Set((original.project.shots || []).map((shot) => shot.id));
const originalCharacterIds = new Set((original.project.characters || []).map((asset) => asset.id));
const creation = expectOk(await request("/api/projects", {
method: "POST",
headers,
body: JSON.stringify({ id: projectId, name: projectName, type: "AI 漫剧" })
}), "create isolated project");
assert.equal(creation.project.id, projectId, "project factory must return the created project");
created = true;
const projectHeaders = { ...headers, "x-project-id": projectId };
const scoped = expectOk(await request("/api/project", { headers: projectHeaders }), "read isolated project");
assert.equal(scoped.project.id, projectId, "project response must stay inside requested project");
assert.equal(scoped.project.series.title, projectName, "series title must come from the new project");
assert.notEqual(scoped.project.series.title, original.project.series.title, "new project must not inherit the seeded series title");
assert.equal(scoped.project.characters.length, 0, "new project must not inherit character locks");
assert.equal(scoped.project.locations.length, 0, "new project must not inherit location locks");
assert.equal(scoped.project.props.length, 0, "new project must not inherit prop locks");
assert.equal(scoped.project.productionJobs.length, 0, "new project must start with an empty job list");
assert.ok(scoped.project.episode.id, "new project must initialize a first episode");
assert.equal(scoped.project.shots.length, 1, "new project must initialize exactly one starter shot");
assert.ok(!scoped.project.shots.some((shot) => originalShotIds.has(shot.id)), "starter shot must not reuse another project's shot");
assert.ok(!scoped.project.characters.some((asset) => originalCharacterIds.has(asset.id)), "assets must not cross project boundaries");
const isolatedAsset = expectOk(await request("/api/assets/upload", {
method: "POST",
headers: projectHeaders,
body: JSON.stringify({ data: Buffer.from(`isolated-asset-${projectId}`).toString("base64"), fileName: "isolated.txt", kind: "reference", mimeType: "text/plain" })
}), "upload isolated asset");
isolatedAssetPath = isolatedAsset.asset.currentVersion.storage_path;
const isolatedAssets = expectOk(await request("/api/assets", { headers: projectHeaders }), "read isolated assets");
assert.equal(isolatedAssets.assets.length, 1, "新项目资产列表只能包含本项目刚上传的资产");
const originalAssetContent = await request(`/api/assets/${encodeURIComponent([...originalCharacterIds][0])}/content`, { headers: projectHeaders });
assert.equal(originalAssetContent.response.status, 404, "跨项目读取资产内容必须返回 404");
const graph = expectOk(await request("/api/production/graph", { headers: projectHeaders }), "read isolated production graph");
assert.equal(graph.graph.series.title, projectName, "production graph must use the new series");
assert.equal(graph.graph.shots.length, 1, "production graph must contain the new starter shot only");
assert.equal(graph.graph.characters.length, 0, "production graph must not include seeded characters");
const job = expectOk(await request("/api/jobs", {
method: "POST",
headers: projectHeaders,
body: JSON.stringify({ adapter: "owned-image", kind: "隔离验收关键帧", shotId: scoped.project.shots[0].id, output: `qa/${projectId}/frame.json` })
}), "create isolated job");
const afterJob = expectOk(await request("/api/project", { headers: projectHeaders }), "read isolated project after job");
assert.equal(afterJob.jobs.length, 1, "new project job list must contain only its own job");
assert.equal(afterJob.jobs[0].id, job.job.id, "job must be scoped to the new project");
const exports = expectOk(await request("/api/exports/write", { method: "POST", headers: projectHeaders, body: "{}" }), "write isolated exports");
assert.ok(exports.exportRoot.endsWith(`/exports/${projectId}`), "exports must use a project-specific root");
assert.ok(exports.files.every((file) => file.startsWith(exports.exportRoot)), "every export file must stay in the project root");
const originalAfter = expectOk(await request("/api/project", { headers }), "read original project after isolation test");
assert.ok(originalAfter.project.characters.length > 0, "original project's assets must remain intact");
assert.ok(originalAfter.project.productionJobs.length >= original.project.productionJobs.length, "original project jobs must remain separate");
assert.ok(originalAfter.project.shots.some((shot) => originalShotIds.has(shot.id)), "original project's shots must remain intact");
const emptyOrganization = expectOk(await request("/api/organizations", {
method: "POST",
headers,
body: JSON.stringify({ id: emptyOrganizationId, name: `空租户 ${emptyOrganizationId.slice(-6)}`, workspaceName: "空生产空间" })
}), "create empty organization");
emptyOrganizationCreated = true;
const emptyScope = { authorization: headers.authorization, "x-organization-id": emptyOrganization.organization.id };
const emptyContext = expectOk(await request("/api/context", { headers: emptyScope }), "read empty organization context");
assert.equal(emptyContext.context.currentProject, null, "new organization must not invent a current project");
const emptyProject = expectOk(await request("/api/project", { headers: emptyScope }), "read empty organization project shell");
assert.equal(emptyProject.project.id, "", "empty organization must return an empty project shell");
assert.equal(emptyProject.project.episode.id, "", "empty organization must not invent an episode");
assert.equal(emptyProject.project.shots.length, 0, "empty organization must not inherit a starter shot");
assert.equal(emptyProject.project.characters.length, 0, "empty organization must not inherit character locks");
console.log(`project isolation smoke passed: ${projectId}`);
} finally {
if (created) {
dbRun("DELETE FROM projects WHERE id = ?", [projectId]);
await rm(resolve(projectRoot, "exports", projectId), { recursive: true, force: true });
if (isolatedAssetPath) await rm(resolve(projectRoot, isolatedAssetPath), { force: true });
await rm(resolve(projectRoot, "storage", "assets", projectId), { recursive: true, force: true });
}
if (emptyOrganizationCreated) dbRun("DELETE FROM organizations WHERE id = ?", [emptyOrganizationId]);
}