Files
ai-drama-platform/scripts/smoke-release-workflow.mjs
T
2026-09-01 14:38:42 +08:00

247 lines
15 KiB
JavaScript

import assert from "node:assert/strict";
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { dbAll, dbGet, 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 runId = Date.now();
const projectId = `smoke-release-project-${runId}`;
const sourceRoot = `storage/smoke-release-${runId}`;
const deliveryLabel = `smoke-release-delivery-${runId}`;
const batchId = `smoke-release-batch-${runId}`;
const channelName = `smoke-release-channel-${runId}`;
const version = `smoke-${runId}`;
const sourcePath = `${sourceRoot}/clip.mp4`;
const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`;
const manifestPath = `${sourceRoot}/manifest.json`;
const customerId = `cust-release-${runId}`;
const contractId = `contract-release-${runId}`;
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 expectStatus(result, status, label) {
assert.equal(result.response.status, status, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
function expectOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
const producerLogin = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
}), "producer login");
const reviewerLogin = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "review@local.test", password: "Demo@123456" })
}), "reviewer login");
const producerSetupHeaders = {
authorization: `Bearer ${producerLogin.session.token}`,
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": "thunder-mouth"
};
const producerHeaders = {
authorization: `Bearer ${producerLogin.session.token}`,
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": projectId
};
const reviewerHeaders = {
authorization: `Bearer ${reviewerLogin.session.token}`,
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": projectId
};
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
assert.ok(producerUser?.id, "producer user must exist");
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', 'release@example.local', '', 'release workflow smoke customer', '[\"smoke\",\"release\"]', '{}', ?, ?, ?)",
[customerId, organizationId, `发布烟测客户 ${runId}`, `发布烟测客户 ${runId} 有限公司`, `REL-${runId}`, producerUser.id, 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 release contract', ?, ?, ?, ?)",
[
contractId,
organizationId,
customerId,
`REL-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://release-contract/${runId}`, rightsEvidenceRef: `smoke://release-rights/${runId}`, signedCopyPath: `storage/contracts/smoke-release/${runId}.json` }),
JSON.stringify({ paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" }),
producerUser.id,
producerUser.id,
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', 'release workflow smoke binding', ?, ?, ?)",
[`contract-binding-release-${runId}`, organizationId, workspaceId, projectId, contractId, customerId, producerUser.id, timestamp, timestamp]
);
}
const channelPath = `/api/production/delivery-channels`;
let channelId = "";
let releaseId = "";
let createdDeliveryId = "";
let seeded = false;
let projectCreated = false;
try {
const project = expectStatus(await request("/api/projects", {
method: "POST",
headers: producerSetupHeaders,
body: JSON.stringify({ id: projectId, name: `发布流程烟测 ${runId}`, type: "AI 漫剧" })
}), 201, "producer creates isolated release project");
projectCreated = true;
assert.equal(project.project.id, projectId, "isolated project must be created");
const graph = expectOk(await request("/api/production/graph", { headers: producerHeaders }), "read isolated production graph");
const projectShot = { id: graph.graph.shots[0]?.id };
assert.ok(projectShot.id, "smoke project must have a starter shot");
const channels = expectOk(await request(channelPath, { headers: reviewerHeaders }), "reviewer can view channels");
assert.ok(channels.channels.some((channel) => channel.kind === "local-file"), "workspace must expose the default local-file channel");
expectStatus(await request(channelPath, {
method: "POST",
headers: reviewerHeaders,
body: JSON.stringify({ name: "reviewer must not create", kind: "local-file" })
}), 403, "reviewer cannot create a channel");
expectStatus(await request(channelPath, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ name: "public webhook must be rejected", kind: "local-webhook", endpoint: "https://example.com/release" })
}), 400, "public webhook is rejected");
const channel = expectStatus(await request(channelPath, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ name: channelName, kind: "local-file", endpoint: "storage/releases", requireApproval: true })
}), 201, "producer creates local channel");
channelId = channel.channel.id;
const delivery = expectStatus(await request("/api/production/deliveries", {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ version, channel: deliveryLabel })
}), 201, "producer creates delivery draft");
createdDeliveryId = delivery.delivery.id;
assert.equal(delivery.delivery.status, "draft", "new delivery must start as draft");
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-manifest", deliveryId: createdDeliveryId }, null, 2));
const timestamp = new Date().toISOString();
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, 'smoke active batch', 'active', ?, '{}', ?, ?, ?, ?)", [batchId, organizationId, workspaceId, projectId, delivery.delivery.id, 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, ?, ?, ?, ?, ?)", [`smoke-release-item-${runId}`, batchId, projectShot.id, sourcePath, lastFramePath, "a".repeat(64), JSON.stringify({ artifactStatus: "inspected" }), timestamp]);
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
expectOk(await request("/api/production/reviews", { headers: producerHeaders }), "ensure isolated QA reviews");
dbRun("UPDATE reviews SET status = 'approved', score = 100, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE project_id = ?", [producerUser.id, timestamp, JSON.stringify({ source: "smoke-release-workflow", checkedAt: timestamp }), timestamp, projectId]);
seedContractClearance();
seeded = true;
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ channelId, submit: true, idempotencyKey: `release-${runId}` })
}), 201, "producer submits release");
releaseId = release.release.id;
assert.equal(release.release.status, "submitted", "new release request must be submitted");
const reviewerReleases = expectOk(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, { headers: reviewerHeaders }), "reviewer can view release records");
assert.equal(reviewerReleases.releases.length, 1, "reviewer must see the submitted release");
expectStatus(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/decision`, {
method: "POST",
headers: reviewerHeaders,
body: JSON.stringify({ status: "approved" })
}), 403, "reviewer cannot approve release");
const approved = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/decision`, {
method: "POST",
headers: producerHeaders,
body: JSON.stringify({ status: "approved", note: "smoke approval" })
}), "producer approves release");
assert.equal(approved.release.status, "approved", "release must become approved");
const published = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/publish`, {
method: "POST",
headers: producerHeaders,
body: "{}"
}), "producer publishes release");
assert.equal(published.release.status, "published", "release must become published");
assert.ok(published.release.output_path, "published release must return an output path");
await access(resolve(projectRoot, published.release.output_path));
const publishedDocument = await readFile(resolve(projectRoot, published.release.output_path), "utf8");
assert.match(publishedDocument, /ai-drama-platform\.delivery-release\.v1/, "release.json must contain the release schema");
const repeated = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/publish`, {
method: "POST",
headers: producerHeaders,
body: "{}"
}), "repeated publish is idempotent");
assert.equal(repeated.idempotent, true, "published release retry must be idempotent");
const crossOrganization = await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
headers: { ...producerHeaders, "x-organization-id": "org-northstar", "x-workspace-id": "ws-northstar-main", "x-project-id": "northstar-pilot" }
});
assert.ok([403, 404].includes(crossOrganization.response.status), `cross organization release access must be hidden or denied: ${crossOrganization.response.status}`);
const audit = dbGet("SELECT COUNT(*) AS count FROM audit_logs WHERE target_id = ? AND action IN ('delivery.release.submitted', 'delivery.release.approved', 'delivery.release.published')", [releaseId]);
assert.equal(Number(audit?.count || 0), 3, "release lifecycle must write three audit records");
console.log(`release workflow smoke passed: ${releaseId}`);
} finally {
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]);
if (releaseId) dbRun("DELETE FROM audit_logs WHERE target_id = ?", [releaseId]);
if (seeded) {
dbRun("DELETE FROM delivery_releases WHERE delivery_id = ?", [createdDeliveryId]);
dbRun("DELETE FROM delivery_batch_items WHERE batch_id = ?", [batchId]);
dbRun("DELETE FROM delivery_batches WHERE id = ?", [batchId]);
dbRun("DELETE FROM deliveries WHERE id = ?", [createdDeliveryId]);
}
if (channelId) dbRun("DELETE FROM delivery_channels WHERE id = ?", [channelId]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [createdDeliveryId]);
dbRun("DELETE FROM delivery_clearance_reports WHERE project_id = ?", [projectId]);
dbRun("DELETE FROM reviews WHERE project_id = ?", [projectId]);
if (projectCreated) {
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 projects WHERE id = ?", [projectId]);
}
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 });
}