330 lines
20 KiB
JavaScript
330 lines
20 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
import { dbAll, dbGet, dbRun, withTransaction } 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 runId = Date.now();
|
|
const projectId = `smoke-clearance-${runId}`;
|
|
const sourceRoot = `storage/smoke-clearance-${runId}`;
|
|
const sourcePath = `${sourceRoot}/clip.mp4`;
|
|
const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`;
|
|
const manifestPath = `${sourceRoot}/manifest.json`;
|
|
const batchId = `delivery-clearance-batch-${runId}`;
|
|
const voiceId = `voice-clearance-${runId}`;
|
|
const customerId = `cust-clearance-${runId}`;
|
|
const contractId = `contract-clearance-${runId}`;
|
|
|
|
const createdAssetIds = [];
|
|
let deliveryId = "";
|
|
let releaseId = "";
|
|
let knowledgeDocumentId = "";
|
|
let scriptId = "";
|
|
|
|
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;
|
|
}
|
|
|
|
function expectStatus(result, status, label) {
|
|
assert.equal(result.response.status, status, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
|
|
return result.payload;
|
|
}
|
|
|
|
async function login(email) {
|
|
const result = expectOk(await request("/api/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email, password: "Demo@123456" })
|
|
}), `${email} login`);
|
|
return { authorization: `Bearer ${result.session.token}`, "x-organization-id": organizationId, "x-workspace-id": workspaceId };
|
|
}
|
|
|
|
async function createApprovedAsset(headers, body) {
|
|
const created = expectOk(await request("/api/assets", {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
lockStatus: "locked",
|
|
contentSha256: "b".repeat(64),
|
|
provenance: { sourceType: "original", sourceName: `${body.name} 原创设定`, sourceRef: `smoke://${runId}/${body.kind}` },
|
|
tags: ["smoke", "clearance"],
|
|
...body
|
|
})
|
|
}), `create ${body.kind} asset`);
|
|
createdAssetIds.push(created.asset.id);
|
|
const approved = expectOk(await request(`/api/assets/${encodeURIComponent(created.asset.id)}/rights`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
rightsStatus: "approved",
|
|
provenance: { sourceType: "original", sourceName: `${body.name} 原创设定`, sourceRef: `smoke://${runId}/${body.kind}` },
|
|
evidence: { reference: `smoke-evidence://${runId}/${body.kind}` },
|
|
licenseScope: "commercial-platform-test"
|
|
})
|
|
}), `approve ${body.kind} asset`);
|
|
return approved.asset;
|
|
}
|
|
|
|
async function bind(headers, assetId, shotId, usageRole) {
|
|
return expectOk(await request(`/api/assets/${encodeURIComponent(assetId)}/bindings`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ shotId, usageRole })
|
|
}), `bind ${assetId}`);
|
|
}
|
|
|
|
function cleanup() {
|
|
withTransaction(() => {
|
|
dbRun("DELETE FROM contract_license_events WHERE contract_id = ? OR customer_id = ? OR project_id = ?", [contractId, customerId, projectId]);
|
|
dbRun("DELETE FROM contract_project_bindings WHERE contract_id = ? OR project_id = ?", [contractId, projectId]);
|
|
dbRun("DELETE FROM production_contracts WHERE id = ?", [contractId]);
|
|
dbRun("DELETE FROM customer_contacts WHERE customer_id = ?", [customerId]);
|
|
dbRun("DELETE FROM customers WHERE id = ?", [customerId]);
|
|
dbRun("DELETE FROM delivery_clearance_reports WHERE project_id = ?", [projectId]);
|
|
dbRun("DELETE FROM delivery_releases WHERE project_id = ?", [projectId]);
|
|
dbRun("DELETE FROM delivery_batch_items WHERE batch_id = ?", [batchId]);
|
|
dbRun("DELETE FROM delivery_batches WHERE project_id = ?", [projectId]);
|
|
dbRun("DELETE FROM deliveries WHERE project_id = ?", [projectId]);
|
|
for (const assetId of createdAssetIds) {
|
|
dbRun("DELETE FROM asset_governance_reviews WHERE asset_id = ?", [assetId]);
|
|
dbRun("DELETE FROM asset_bindings WHERE asset_id = ?", [assetId]);
|
|
dbRun("DELETE FROM asset_versions WHERE asset_id = ?", [assetId]);
|
|
dbRun("DELETE FROM assets WHERE id = ?", [assetId]);
|
|
}
|
|
if (knowledgeDocumentId) {
|
|
dbRun("DELETE FROM knowledge_governance_reviews WHERE document_id = ?", [knowledgeDocumentId]);
|
|
dbRun("DELETE FROM knowledge_chunks WHERE document_id = ?", [knowledgeDocumentId]);
|
|
dbRun("DELETE FROM knowledge_document_versions WHERE document_id = ?", [knowledgeDocumentId]);
|
|
dbRun("DELETE FROM knowledge_documents WHERE id = ?", [knowledgeDocumentId]);
|
|
}
|
|
if (scriptId) dbRun("DELETE FROM script_documents WHERE id = ?", [scriptId]);
|
|
dbRun("DELETE FROM voice_lines WHERE shot_id IN (SELECT id FROM shots WHERE episode_id IN (SELECT id FROM episodes WHERE season_id IN (SELECT id FROM seasons WHERE series_id = ?)))", [`series-${projectId}`]);
|
|
dbRun("DELETE FROM reviews WHERE project_id = ?", [projectId]);
|
|
dbRun("DELETE FROM shots WHERE episode_id IN (SELECT id FROM episodes WHERE season_id IN (SELECT id FROM seasons WHERE series_id = ?))", [`series-${projectId}`]);
|
|
dbRun("DELETE FROM episodes WHERE season_id IN (SELECT id FROM seasons WHERE series_id = ?)", [`series-${projectId}`]);
|
|
dbRun("DELETE FROM seasons WHERE series_id = ?", [`series-${projectId}`]);
|
|
dbRun("DELETE FROM series WHERE id = ?", [`series-${projectId}`]);
|
|
dbRun("DELETE FROM audit_logs WHERE project_id = ? OR metadata_json LIKE ?", [projectId, `%${projectId}%`]);
|
|
dbRun("DELETE FROM usage_events WHERE project_id = ?", [projectId]);
|
|
dbRun("DELETE FROM projects WHERE id = ?", [projectId]);
|
|
});
|
|
}
|
|
|
|
function seedContractClearance() {
|
|
const timestamp = new Date().toISOString();
|
|
dbRun(
|
|
"INSERT OR IGNORE INTO customers(id, organization_id, name, legal_name, code, customer_type, status, industry, region, billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'internal', 'active', 'AI 短剧', 'CN', 'clearance@example.local', '', 'delivery clearance smoke customer', '[\"smoke\",\"clearance\"]', '{}', ?, ?, ?)",
|
|
[customerId, organizationId, `清算烟测客户 ${runId}`, `清算烟测客户 ${runId} 有限公司`, `CLR-${runId}`, producerUserId(), timestamp, timestamp]
|
|
);
|
|
dbRun(
|
|
"INSERT OR IGNORE INTO production_contracts(id, organization_id, customer_id, contract_number, title, contract_type, status, approval_status, effective_at, expires_at, signed_at, currency, amount, license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json, approval_note, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'production_license', 'active', 'approved', ?, ?, ?, 'CNY', 0, ?, ?, ?, ?, ?, 'smoke clearance contract', ?, ?, ?, ?)",
|
|
[
|
|
contractId,
|
|
organizationId,
|
|
customerId,
|
|
`CLR-CTR-${runId}`,
|
|
`清算烟测授权 ${runId}`,
|
|
new Date(Date.now() - 86400000).toISOString(),
|
|
new Date(Date.now() + 365 * 86400000).toISOString(),
|
|
timestamp,
|
|
JSON.stringify({ channels: ["local-file", "private-delivery-portal"], territories: ["CN"], deliverables: ["vertical-video", "delivery-manifest"], platforms: ["internal-review"], exclusive: false, commercialUse: true, language: ["zh-CN"] }),
|
|
JSON.stringify({ originalStory: true, derivativeProduction: true, aiGeneratedAssetsAllowed: true, voiceCloneAllowed: false, fixedVoiceRequired: true, singleFramePolicyRequired: true }),
|
|
JSON.stringify({ customerPortalAllowed: true, requireClearanceCertificate: true }),
|
|
JSON.stringify({ contractRef: `smoke://contract/${runId}`, rightsEvidenceRef: `smoke://rights/${runId}`, signedCopyPath: `storage/contracts/smoke/${runId}.json` }),
|
|
JSON.stringify({ paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" }),
|
|
producerUserId(),
|
|
producerUserId(),
|
|
timestamp,
|
|
timestamp
|
|
]
|
|
);
|
|
dbRun(
|
|
"INSERT OR IGNORE INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'primary-license', 'active', 'delivery clearance smoke binding', ?, ?, ?)",
|
|
[`contract-binding-clearance-${runId}`, organizationId, workspaceId, projectId, contractId, customerId, producerUserId(), timestamp, timestamp]
|
|
);
|
|
}
|
|
|
|
function producerUserId() {
|
|
return dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"])?.id || "u-owner";
|
|
}
|
|
|
|
try {
|
|
cleanup();
|
|
const ownerBase = await login("producer@local.test");
|
|
const ownerHeaders = { ...ownerBase, "x-project-id": "thunder-mouth" };
|
|
const project = expectOk(await request("/api/projects", {
|
|
method: "POST",
|
|
headers: ownerHeaders,
|
|
body: JSON.stringify({ id: projectId, name: `清算烟测项目 ${runId}`, type: "AI 漫剧" })
|
|
}), "create clearance project");
|
|
assert.equal(project.project.id, projectId);
|
|
const scopedHeaders = { ...ownerBase, "x-project-id": projectId };
|
|
const graph = expectOk(await request("/api/production/graph", { headers: scopedHeaders }), "read project graph");
|
|
const shotId = graph.graph.shots[0]?.id;
|
|
assert.ok(shotId, "new project must have a starter shot");
|
|
|
|
const character = await createApprovedAsset(scopedHeaders, { name: `清算角色 ${runId}`, kind: "character", detail: "原创角色单画面设定", lock: "服装和发型锁定" });
|
|
const location = await createApprovedAsset(scopedHeaders, { name: `清算场景 ${runId}`, kind: "location", detail: "原创地铁口内景", lock: "固定中景机位" });
|
|
const prop = await createApprovedAsset(scopedHeaders, { name: `清算道具 ${runId}`, kind: "prop", detail: "原创蓝色工作证", lock: "挂在胸前" });
|
|
const voice = await createApprovedAsset(scopedHeaders, {
|
|
name: `清算固定声线 ${runId}`,
|
|
kind: "voice",
|
|
storagePath: `assets/${projectId}/voices/ref.wav`,
|
|
mimeType: "audio/wav",
|
|
detail: "原创授权参考音频",
|
|
lock: "只用于本项目固定 TTS",
|
|
metadata: { voiceId, ttsModel: "IndexTTS-2.5", referencePolicy: "fixed-local-reference" }
|
|
});
|
|
const unapproved = expectOk(await request("/api/assets", {
|
|
method: "POST",
|
|
headers: scopedHeaders,
|
|
body: JSON.stringify({
|
|
name: `未授权道具 ${runId}`,
|
|
kind: "prop",
|
|
lockStatus: "locked",
|
|
detail: "原创但未授权的临时道具",
|
|
lock: "用于清算阻断测试",
|
|
contentSha256: "c".repeat(64),
|
|
provenance: { sourceType: "original", sourceName: "清算阻断样本", sourceRef: `smoke://${runId}/blocked-prop` },
|
|
tags: ["smoke", "blocked"]
|
|
})
|
|
}), "create unapproved asset");
|
|
createdAssetIds.push(unapproved.asset.id);
|
|
|
|
await bind(scopedHeaders, character.id, shotId, "character");
|
|
await bind(scopedHeaders, location.id, shotId, "location");
|
|
await bind(scopedHeaders, prop.id, shotId, "prop");
|
|
await bind(scopedHeaders, voice.id, shotId, "voice");
|
|
await bind(scopedHeaders, unapproved.asset.id, shotId, "prop");
|
|
|
|
knowledgeDocumentId = `knowledge-clearance-${runId}`;
|
|
const knowledge = expectOk(await request("/api/knowledge/library/import", {
|
|
method: "POST",
|
|
headers: scopedHeaders,
|
|
body: JSON.stringify({
|
|
id: knowledgeDocumentId,
|
|
title: `清算测试小说 ${runId}`,
|
|
content: "第一章 雨夜任务\n陈宇来到地铁口,发现蓝色工作证背面写着新的线索。\n\n第二章 反应镜头\n角色以侧脸对白推进冲突,避免正脸大段说话。",
|
|
sourceType: "novel",
|
|
scopeMode: "project",
|
|
rightsStatus: "needs-evidence",
|
|
provenance: { sourceLabel: "清算测试小说", author: "本地原创", rightsOwner: "测试组织", evidenceRef: "" },
|
|
tags: ["smoke", "knowledge"]
|
|
})
|
|
}), "import knowledge document");
|
|
assert.equal(knowledge.document.rightsStatus, "needs-evidence");
|
|
const materialized = expectOk(await request(`/api/knowledge/library/${encodeURIComponent(knowledgeDocumentId)}/materialize`, {
|
|
method: "POST",
|
|
headers: scopedHeaders,
|
|
body: JSON.stringify({ title: `清算剧本 ${runId}`, episodeId: graph.graph.episode.id })
|
|
}), "materialize knowledge document");
|
|
scriptId = materialized.importedScript.id;
|
|
|
|
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
|
|
const timestamp = new Date().toISOString();
|
|
dbRun("INSERT INTO voice_lines(id, shot_id, line_number, character_key, text, emotion, mouth_plan, target_duration_sec, voice_id, audio_path, status, created_by, created_at, updated_at) VALUES (?, ?, 1, 'smoke-role', '我们只交付已经清算的版本。', 'calm', '侧脸/反应镜头', 2.2, ?, ?, 'locked', ?, ?, ?)", [`voice-line-clearance-${runId}`, shotId, voiceId, `voices/${projectId}/line-001.wav`, producerUser.id, timestamp, timestamp]);
|
|
await mkdir(resolve(projectRoot, sourceRoot), { recursive: true });
|
|
await writeFile(resolve(projectRoot, sourcePath), Buffer.from("fake local video evidence"));
|
|
await writeFile(resolve(projectRoot, lastFramePath), Buffer.from("fake actual last frame evidence"));
|
|
await writeFile(resolve(projectRoot, manifestPath), JSON.stringify({ schema: "smoke-clearance-manifest", shotId }, null, 2));
|
|
|
|
const delivery = expectOk(await request("/api/production/deliveries", {
|
|
method: "POST",
|
|
headers: scopedHeaders,
|
|
body: JSON.stringify({ version: `clearance-${runId}`, channel: "internal" })
|
|
}), "create delivery");
|
|
deliveryId = delivery.delivery.id;
|
|
dbRun("INSERT INTO delivery_batches(id, organization_id, workspace_id, project_id, delivery_id, batch_number, label, status, manifest_path, source_json, result_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, 'clearance smoke active batch', 'active', ?, '{}', ?, ?, ?, ?)", [batchId, organizationId, workspaceId, projectId, deliveryId, manifestPath, JSON.stringify({ blockers: [], manifestWritten: true }), producerUser.id, timestamp, timestamp]);
|
|
dbRun("INSERT INTO delivery_batch_items(id, batch_id, shot_id, sequence_number, source_path, actual_last_frame_path, source_sha256, metadata_json, created_at) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)", [`delivery-clearance-item-${runId}`, batchId, shotId, sourcePath, lastFramePath, "d".repeat(64), JSON.stringify({ artifactStatus: "inspected" }), timestamp]);
|
|
dbRun("UPDATE deliveries SET active_batch_id = ?, updated_at = ? WHERE id = ?", [batchId, timestamp, deliveryId]);
|
|
|
|
expectOk(await request("/api/production/qa/run", { method: "POST", headers: scopedHeaders, body: "{}" }), "run contract QA");
|
|
const blockedClearance = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/clearance`, { method: "POST", headers: scopedHeaders, body: "{}" }), 201, "run blocked clearance");
|
|
assert.equal(blockedClearance.clearance.status, "blocked", "unapproved asset and knowledge source must block clearance");
|
|
assert.ok(blockedClearance.clearance.blockers.some((item) => item.type === "asset"), "clearance must include asset blockers");
|
|
assert.ok(blockedClearance.clearance.blockers.some((item) => item.type === "knowledge_document"), "clearance must include knowledge blockers");
|
|
|
|
const blockedApproval = await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/approve`, { method: "POST", headers: scopedHeaders, body: "{}" });
|
|
assert.equal(blockedApproval.response.status, 409, "delivery approval must be blocked by clearance");
|
|
assert.equal(blockedApproval.payload.error, "delivery_blocked");
|
|
|
|
expectOk(await request(`/api/assets/${encodeURIComponent(unapproved.asset.id)}/rights`, {
|
|
method: "POST",
|
|
headers: scopedHeaders,
|
|
body: JSON.stringify({
|
|
rightsStatus: "approved",
|
|
provenance: { sourceType: "original", sourceName: "清算阻断样本已补证", sourceRef: `smoke://${runId}/blocked-prop` },
|
|
evidence: { reference: `smoke-evidence://${runId}/blocked-prop` },
|
|
licenseScope: "commercial-platform-test"
|
|
})
|
|
}), "approve formerly blocked asset");
|
|
expectOk(await request(`/api/knowledge/library/${encodeURIComponent(knowledgeDocumentId)}/review`, {
|
|
method: "POST",
|
|
headers: scopedHeaders,
|
|
body: JSON.stringify({
|
|
decision: "approved",
|
|
rightsStatus: "approved",
|
|
evidenceRef: `smoke-evidence://${runId}/knowledge`,
|
|
provenance: { sourceLabel: "清算测试小说", author: "本地原创", rightsOwner: "测试组织", evidenceRef: `smoke-evidence://${runId}/knowledge` },
|
|
notes: "清算烟测批准"
|
|
})
|
|
}), "approve knowledge source");
|
|
seedContractClearance();
|
|
|
|
const passedClearance = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/clearance`, { method: "POST", headers: scopedHeaders, body: "{}" }), 201, "run passed clearance");
|
|
assert.equal(passedClearance.clearance.status, "pass", "all commercial gates should pass after evidence is fixed");
|
|
assert.ok(passedClearance.latest.certificatePath, "clearance must persist a certificate path");
|
|
|
|
const approvedDelivery = expectOk(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/approve`, { method: "POST", headers: scopedHeaders, body: "{}" }), "approve delivery after clearance");
|
|
assert.equal(approvedDelivery.delivery.status, "approved");
|
|
|
|
const channels = expectOk(await request("/api/production/delivery-channels", { headers: scopedHeaders }), "list channels");
|
|
const channel = channels.channels.find((item) => item.kind === "local-file" && item.enabled);
|
|
assert.ok(channel?.id, "local-file channel must exist");
|
|
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/releases`, {
|
|
method: "POST",
|
|
headers: scopedHeaders,
|
|
body: JSON.stringify({ channelId: channel.id, submit: true, idempotencyKey: `clearance-release-${runId}` })
|
|
}), 201, "submit release after clearance");
|
|
releaseId = release.release.id;
|
|
assert.equal(release.release.status, "submitted");
|
|
assert.equal(release.release.preflight.clearance.status, "pass");
|
|
|
|
const approvedRelease = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/decision`, {
|
|
method: "POST",
|
|
headers: scopedHeaders,
|
|
body: JSON.stringify({ status: "approved", note: "clearance smoke approved" })
|
|
}), "approve release with clearance");
|
|
assert.equal(approvedRelease.release.status, "approved");
|
|
const published = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/publish`, {
|
|
method: "POST",
|
|
headers: scopedHeaders,
|
|
body: "{}"
|
|
}), "publish release with clearance");
|
|
assert.equal(published.release.status, "published");
|
|
const releaseDocument = await readFile(resolve(projectRoot, published.release.output_path), "utf8");
|
|
assert.match(releaseDocument, /delivery-clearance-certificate/, "release document must include clearance certificate summary");
|
|
const residue = dbAll("SELECT id FROM delivery_clearance_reports WHERE project_id = ?", [projectId]);
|
|
assert.ok(residue.length >= 4, "approval, release approval and publish should persist clearance reports");
|
|
|
|
console.log(`delivery clearance smoke passed: ${projectId}, ${releaseId}`);
|
|
} finally {
|
|
cleanup();
|
|
await rm(resolve(projectRoot, sourceRoot), { recursive: true, force: true });
|
|
await rm(resolve(projectRoot, "storage", "deliveries", projectId), { recursive: true, force: true });
|
|
await rm(resolve(projectRoot, "storage", "releases", projectId), { recursive: true, force: true });
|
|
await rm(resolve(projectRoot, "exports", projectId), { recursive: true, force: true });
|
|
}
|