import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { rm } from "node:fs/promises"; import { resolve } from "node:path"; import { dbAll, dbRun, withTransaction } from "../server/db.mjs"; const api = "http://127.0.0.1:8787"; const runId = Date.now(); let createdAssetId = ""; let uploadedAssetId = ""; let jobId = ""; let originalSeries = null; let originalEpisode = null; async function cleanup() { const assetIds = [createdAssetId, uploadedAssetId].filter(Boolean); const assetPaths = assetIds.length ? dbAll(`SELECT storage_path FROM asset_versions WHERE asset_id IN (${assetIds.map(() => "?").join(",")})`, assetIds) : []; withTransaction(() => { if (assetIds.length) { const placeholders = assetIds.map(() => "?").join(","); dbRun(`DELETE FROM asset_bindings WHERE asset_id IN (${placeholders})`, assetIds); dbRun(`DELETE FROM asset_versions WHERE asset_id IN (${placeholders})`, assetIds); dbRun(`DELETE FROM assets WHERE id IN (${placeholders})`, assetIds); } if (jobId) { dbRun("DELETE FROM media_artifacts WHERE job_id = ?", [jobId]); dbRun("DELETE FROM job_dependencies WHERE job_id = ? OR depends_on_job_id = ?", [jobId, jobId]); dbRun("DELETE FROM job_attempts WHERE job_id = ?", [jobId]); dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]); dbRun("DELETE FROM audit_logs WHERE target_id = ?", [jobId]); dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]); } if (originalSeries?.id) { dbRun("UPDATE series SET title = ?, logline = ?, format = ?, visual_style = ?, continuity_rule = ?, show_engine = ?, updated_at = ? WHERE id = ?", [originalSeries.title, originalSeries.logline, originalSeries.format, originalSeries.visual_style, originalSeries.continuity_rule, originalSeries.show_engine, originalSeries.updated_at, originalSeries.id]); } if (originalEpisode?.id) { dbRun("UPDATE episodes SET title = ?, status = ?, target_duration_sec = ?, hook = ?, cliffhanger = ?, updated_at = ? WHERE id = ?", [originalEpisode.title, originalEpisode.status, originalEpisode.target_duration_sec, originalEpisode.hook, originalEpisode.cliffhanger, originalEpisode.updated_at, originalEpisode.id]); } }); await Promise.all(assetPaths.map((asset) => rm(resolve(import.meta.dirname, "..", asset.storage_path), { force: true }).catch(() => {}))); for (const assetId of assetIds) { await rm(resolve(import.meta.dirname, "..", "storage", "assets", "thunder-mouth", assetId), { recursive: true, force: true }); } } async function request(path, options = {}) { const response = await fetch(`${api}${path}`, { ...options, headers: { "content-type": "application/json", ...(options.headers || {}) } }); const payload = await response.json(); assert.equal(response.ok, true, `${options.method || "GET"} ${path}: ${response.status} ${payload.detail || payload.error || ""}`); return payload; } async function rawRequest(path, options = {}) { const response = await fetch(`${api}${path}`, { ...options, headers: { "content-type": "application/json", ...(options.headers || {}) } }); return { response, payload: await response.json().catch(() => ({})) }; } try { const login = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) }); const headers = { authorization: `Bearer ${login.session.token}`, "x-organization-id": "org-studio-lab", "x-workspace-id": "ws-local-aidrama", "x-project-id": "thunder-mouth" }; const reviewerLogin = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email: "review@local.test", password: "Demo@123456" }) }); const reviewerHeaders = { authorization: `Bearer ${reviewerLogin.session.token}`, "x-organization-id": "org-studio-lab", "x-workspace-id": "ws-local-aidrama", "x-project-id": "thunder-mouth" }; const reviewerAssets = await rawRequest("/api/assets", { headers: reviewerHeaders }); assert.equal(reviewerAssets.response.status, 403, "没有资产权限的审片用户不能读取资产库"); const reviewerUpload = await rawRequest("/api/assets/upload", { method: "POST", headers: reviewerHeaders, body: JSON.stringify({ data: Buffer.from("reviewer-must-be-blocked").toString("base64"), fileName: "blocked.txt" }) }); assert.equal(reviewerUpload.response.status, 403, "没有资产权限的审片用户不能上传资产"); const assets = await request("/api/assets", { headers }); assert.ok(Array.isArray(assets.assets), "资产列表必须是数组"); assert.ok(assets.assets.length > 0, "示例项目必须有资产"); const invalidUpload = await rawRequest("/api/assets/upload", { method: "POST", headers, body: JSON.stringify({ data: "not-base64@@", fileName: "invalid.txt" }) }); assert.equal(invalidUpload.response.status, 400, "非法 Base64 必须被服务端拒绝"); assert.equal(invalidUpload.payload.error, "asset_data_invalid", "非法 Base64 必须返回明确错误码"); const created = await request("/api/assets", { method: "POST", headers, body: JSON.stringify({ name: `smoke-asset-${Date.now()}`, kind: "reference", subtitle: "smoke" }) }); assert.ok(created.asset?.id, "创建资产必须返回 id"); createdAssetId = created.asset.id; const bound = await request(`/api/assets/${encodeURIComponent(created.asset.id)}/bindings`, { method: "POST", headers, body: JSON.stringify({ shotId: "shot-01", usageRole: "reference" }) }); assert.equal(bound.asset.bindings.some((item) => item.shot_id === "shot-01"), true, "资产绑定必须写入镜头"); const uploaded = await request("/api/assets/upload", { method: "POST", headers, body: JSON.stringify({ data: Buffer.from("creator-suite-smoke").toString("base64"), fileName: "smoke-reference.txt", kind: "reference" }) }); assert.ok(uploaded.asset?.currentVersion?.storage_path?.includes("storage/assets/"), "本地上传必须写入 storage/assets"); uploadedAssetId = uploaded.asset.id; const firstHash = createHash("sha256").update("creator-suite-smoke").digest("hex"); assert.equal(uploaded.asset.currentVersion.contentSha256, firstHash, "上传资产必须登记 SHA-256"); const uploadedVersion = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/versions/upload`, { method: "POST", headers, body: JSON.stringify({ data: Buffer.from("creator-suite-smoke-v2").toString("base64"), fileName: "smoke-reference-v2.txt", mimeType: "text/plain", versionNote: "smoke file version" }) }); const secondHash = createHash("sha256").update("creator-suite-smoke-v2").digest("hex"); assert.equal(uploadedVersion.asset.currentVersion.version_number, 2, "文件上传版本必须递增"); assert.equal(uploadedVersion.asset.currentVersion.contentSha256, secondHash, "文件版本必须登记新的 SHA-256"); const verification = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/verify`, { method: "POST", headers, body: JSON.stringify({}) }); assert.equal(verification.verification.verified, true, "当前资产文件完整性校验必须通过"); const contentResponse = await fetch(`${api}/api/assets/${encodeURIComponent(uploaded.asset.id)}/content`, { headers }); assert.equal(contentResponse.status, 200, "资产内容读取必须成功"); assert.equal(contentResponse.headers.get("etag"), `"${secondHash}"`, "资产内容响应必须返回 ETag"); assert.equal(await contentResponse.text(), "creator-suite-smoke-v2", "资产内容必须与当前文件版本一致"); const versioned = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/versions`, { method: "POST", headers, body: JSON.stringify({ versionNote: "smoke version" }) }); assert.equal(versioned.asset.currentVersion.version_number, 3, "资产版本必须递增"); const restored = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/versions/${encodeURIComponent(versioned.asset.versions.find((version) => version.version_number === 1).id)}/restore`, { method: "POST", headers, body: JSON.stringify({}) }); assert.equal(restored.asset.currentVersion.version_number, 1, "资产版本必须支持恢复到历史版本"); const locked = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/lock`, { method: "POST", headers, body: JSON.stringify({ lockStatus: "locked" }) }); assert.equal(locked.asset.lockStatus, "locked", "资产锁定状态必须持久化"); const assistant = await request("/api/assistant/query", { method: "POST", headers, body: JSON.stringify({ question: "检查连续性", actionId: "continuity" }) }); assert.ok(assistant.answer?.length > 10, "项目助手必须返回真实答案"); const originalGraph = await request("/api/production/graph", { headers }); originalSeries = originalGraph.graph.series; originalEpisode = originalGraph.graph.episode; const bibleUpdate = await request("/api/production/bible", { method: "PATCH", headers, body: JSON.stringify({ logline: "smoke test:雷暴来临前的原创避险选择。", continuityRule: "smoke test:角色、服装、道具、天气、机位和实际末帧必须连续。" }) }); assert.equal(bibleUpdate.bible.series.logline, "smoke test:雷暴来临前的原创避险选择。", "系列 Bible 更新必须写入数据库"); assert.equal(bibleUpdate.bible.series.continuity_rule, "smoke test:角色、服装、道具、天气、机位和实际末帧必须连续。", "连续性规则必须写入数据库"); const graphAfterBibleUpdate = await request("/api/production/graph", { headers }); assert.equal(graphAfterBibleUpdate.graph.series.logline, "smoke test:雷暴来临前的原创避险选择。", "生产图谱必须读回最新系列 Bible"); const usageBeforeJob = await request("/api/usage", { headers }); const job = await request("/api/jobs", { method: "POST", headers, body: JSON.stringify({ adapter: "local-tts", kind: "单句 TTS 试听", shotId: "shot-01", output: `voices/auditions/smoke-${runId}-line.wav` }) }); jobId = job.job?.id || ""; assert.ok(["queued", "blocked"].includes(job.job?.status), "单句试听必须写入可追踪任务"); if (job.job?.status === "blocked") assert.match(job.job.errorMessage || "", /连接器|planned|not-connected/i, "连接器未就绪时必须记录阻塞原因"); assert.ok(job.jobs.some((item) => item.output === `voices/auditions/smoke-${runId}-line.wav`), "任务输出路径必须可追踪"); const usageAfterJob = await request("/api/usage", { headers }); const beforeClip = usageBeforeJob.usage?.quotas?.find((quota) => quota.metric === "clip")?.used_value || 0; const afterClip = usageAfterJob.usage?.quotas?.find((quota) => quota.metric === "clip")?.used_value || 0; assert.ok(afterClip > beforeClip, "生成任务写入后必须递增片段额度用量"); console.log("creator-suite smoke passed"); } finally { await cleanup(); }