187 lines
11 KiB
JavaScript
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]);
|
|
}
|