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

187 lines
11 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;
}
async function createApprovedLockAsset(headers, { projectId, shotId, kind, name, usageRole }) {
const upload = expectOk(await request("/api/assets/upload", {
method: "POST",
headers,
body: JSON.stringify({
data: Buffer.from(JSON.stringify({ projectId, kind, name, generatedBy: "smoke-project-isolation" })).toString("base64"),
fileName: `${kind}-lock.json`,
kind,
name,
mimeType: "application/json",
metadata: {
detail: `${name} 是隔离验收项目的原创 ${kind} 锁资产。`,
lock: "仅绑定当前项目首个镜头,用于验证连续性治理和项目隔离。"
},
provenance: {
sourceType: "original",
sourceName: projectId,
sourceRef: `smoke://${projectId}/${kind}`,
creator: "local-smoke"
},
tags: ["original", "continuity-lock", kind]
})
}), `upload ${kind} lock asset`);
const assetId = upload.asset.id;
const rights = expectOk(await request(`/api/assets/${encodeURIComponent(assetId)}/rights`, {
method: "POST",
headers,
body: JSON.stringify({
rightsStatus: "approved",
evidence: { reference: `local-evidence/${projectId}/${kind}`, notes: "隔离 smoke 本地原创资产证据" },
provenance: {
sourceType: "original",
sourceName: projectId,
sourceRef: `smoke://${projectId}/${kind}`,
creator: "local-smoke"
},
licenseScope: "commercial-test-local"
})
}), `approve ${kind} lock asset`);
assert.equal(rights.asset.currentVersion.rights_status, "approved", `${kind} asset rights must be approved`);
const locked = expectOk(await request(`/api/assets/${encodeURIComponent(assetId)}/lock`, {
method: "POST",
headers,
body: JSON.stringify({ lockStatus: "locked" })
}), `lock ${kind} asset`);
assert.equal(locked.asset.lock_status, "locked", `${kind} asset must be locked`);
const bound = expectOk(await request(`/api/assets/${encodeURIComponent(assetId)}/bindings`, {
method: "POST",
headers,
body: JSON.stringify({ shotId, usageRole })
}), `bind ${kind} asset`);
assert.ok(bound.asset.bindings.some((binding) => binding.shot_id === shotId), `${kind} asset must bind to isolated shot`);
return bound.asset;
}
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 isolatedShotId = scoped.project.shots[0].id;
await createApprovedLockAsset(projectHeaders, { projectId, shotId: isolatedShotId, kind: "character", name: "隔离项目角色锁", usageRole: "character" });
await createApprovedLockAsset(projectHeaders, { projectId, shotId: isolatedShotId, kind: "location", name: "隔离项目场景锁", usageRole: "location" });
await createApprovedLockAsset(projectHeaders, { projectId, shotId: isolatedShotId, kind: "prop", name: "隔离项目道具锁", usageRole: "prop" });
const job = expectOk(await request("/api/jobs", {
method: "POST",
headers: projectHeaders,
body: JSON.stringify({ adapter: "owned-image", kind: "隔离验收关键帧", shotId: isolatedShotId, 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]);
}