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]); }