173 lines
8.4 KiB
JavaScript
173 lines
8.4 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { rm } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
import { 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 scope = {
|
|
"x-organization-id": "org-studio-lab",
|
|
"x-workspace-id": "ws-local-aidrama",
|
|
"x-project-id": "thunder-mouth"
|
|
};
|
|
|
|
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 };
|
|
}
|
|
|
|
async function login(email = "producer@local.test") {
|
|
const result = await request("/api/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email, password: "Demo@123456" })
|
|
});
|
|
assert.equal(result.response.ok, true, `模型路由 smoke 登录失败:${result.payload.detail || result.payload.error || ""}`);
|
|
return { authorization: `Bearer ${result.payload.session.token}`, ...scope };
|
|
}
|
|
|
|
const headers = await login();
|
|
const writerHeaders = await login("writer@local.test");
|
|
const createdJobIds = [];
|
|
let workflowRunId = "";
|
|
|
|
async function preview(body) {
|
|
const result = await request("/api/jobs/preview", { method: "POST", headers, body: JSON.stringify(body) });
|
|
assert.equal(result.response.ok, true, `任务预览失败:${result.payload.detail || result.payload.error || ""}`);
|
|
return result.payload;
|
|
}
|
|
|
|
function cleanupGeneratedRecords() {
|
|
withTransaction(() => {
|
|
for (const jobId of createdJobIds) {
|
|
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_type = 'generation_job' AND target_id = ?", [jobId]);
|
|
dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
|
|
}
|
|
if (workflowRunId) {
|
|
dbRun("DELETE FROM audit_logs WHERE target_type = 'workflow_run' AND target_id = ?", [workflowRunId]);
|
|
dbRun("DELETE FROM workflow_runs WHERE id = ?", [workflowRunId]);
|
|
}
|
|
});
|
|
}
|
|
|
|
try {
|
|
const imageRouteResolution = await request("/api/platform/model-routes/resolve", {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
workflowKey: "ai-manhua-drama",
|
|
operationKey: "image-keyframe",
|
|
kind: "单画面关键帧",
|
|
adapter: "comfyui-optional",
|
|
localOnly: true
|
|
})
|
|
});
|
|
assert.equal(imageRouteResolution.response.status, 200, `模型路由试算失败:${imageRouteResolution.payload.detail || imageRouteResolution.payload.error || ""}`);
|
|
assert.equal(imageRouteResolution.payload.resolution.source, "routing-policy", "模型路由试算必须优先命中业务路由");
|
|
assert.equal(imageRouteResolution.payload.resolution.route.routeId, "route-image-keyframe", "关键帧试算没有命中单画面路由");
|
|
assert.equal(imageRouteResolution.payload.resolution.connector.id, "owned-image", "试算不应被前端连接器选择绕过");
|
|
assert.equal(imageRouteResolution.payload.resolution.guard.status, "blocked", "未探活的本地关键帧连接器应被准入预检阻断");
|
|
assert.ok(imageRouteResolution.payload.resolution.guard.reasons.some((reason) => reason.code === "connector_not_ready"), "试算结果必须说明连接器尚未就绪");
|
|
|
|
const mixedRouteResolution = await request("/api/platform/model-routes/resolve", {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
adapter: "newapi-audio-production",
|
|
kind: "固定 TTS 配音",
|
|
localOnly: true
|
|
})
|
|
});
|
|
assert.equal(mixedRouteResolution.response.status, 200, "混合连接器试算接口失败");
|
|
assert.equal(mixedRouteResolution.payload.resolution.guard.status, "blocked", "local-only 预检必须阻断混合/外部连接器");
|
|
assert.ok(mixedRouteResolution.payload.resolution.guard.reasons.some((reason) => reason.code === "local_only_violation"), "试算结果必须说明 local-only 冲突");
|
|
|
|
const deniedResolution = await request("/api/platform/model-routes/resolve", {
|
|
method: "POST",
|
|
headers: writerHeaders,
|
|
body: JSON.stringify({ workflowKey: "ai-manhua-drama", operationKey: "image-keyframe" })
|
|
});
|
|
assert.equal(deniedResolution.response.status, 403, "普通编剧不应访问模型路由试算器");
|
|
|
|
const imagePreview = await preview({
|
|
kind: "单画面关键帧",
|
|
shotId: "shot-01",
|
|
adapter: "comfyui-optional"
|
|
});
|
|
assert.equal(imagePreview.resolution.source, "routing-policy", "关键帧任务必须由路由策略解析");
|
|
assert.equal(imagePreview.resolution.route.routeId, "route-image-keyframe", "关键帧没有命中单画面路由");
|
|
assert.equal(imagePreview.resolution.route.modelKey, "qwen-image-2d-lock", "关键帧没有解析到 Qwen 单画面模型");
|
|
assert.equal(imagePreview.resolution.adapter.id, "owned-image", "路由策略不应被前端连接器选择绕过");
|
|
assert.equal(imagePreview.preview.job.modelEntryId, "catalog-qwen-image-2d", "请求合同必须记录模型目录条目");
|
|
|
|
const auditionPreview = await preview({
|
|
kind: "单句 TTS 试听",
|
|
shotId: "shot-01",
|
|
adapter: "local-tts"
|
|
});
|
|
assert.equal(auditionPreview.resolution.source, "legacy-adapter", "单句试听应保留本地试听连接器,不进入正式配音路由");
|
|
assert.equal(auditionPreview.resolution.adapter.id, "local-tts", "单句试听没有使用本地 TTS 连接器");
|
|
|
|
const formalTtsPreview = await preview({
|
|
kind: "固定 TTS 配音",
|
|
shotId: "shot-01",
|
|
adapter: "owned-model-platform"
|
|
});
|
|
assert.equal(formalTtsPreview.resolution.route.routeId, "route-voice-tts", "固定配音没有命中声音路由");
|
|
assert.equal(formalTtsPreview.resolution.route.requiresApproval, true, "混合固定配音必须要求审批");
|
|
|
|
const blockedVoice = await request("/api/jobs", {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
kind: "固定 TTS 配音",
|
|
shotId: "shot-01",
|
|
adapter: "owned-model-platform",
|
|
approveExternal: true
|
|
})
|
|
});
|
|
assert.equal(blockedVoice.response.status, 422, "未授权角色声线必须阻断固定 TTS 任务");
|
|
assert.equal(blockedVoice.payload.error, "voice_reference_not_approved", "固定 TTS 的声音授权错误码不正确");
|
|
|
|
const createdImage = await request("/api/jobs", {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ kind: "单画面关键帧", shotId: "shot-01", adapter: "owned-model-platform" })
|
|
});
|
|
assert.equal(createdImage.response.status, 201, `关键帧任务创建失败:${createdImage.payload.detail || createdImage.payload.error || ""}`);
|
|
createdJobIds.push(createdImage.payload.job.id);
|
|
assert.equal(createdImage.payload.job.routing.routeId, "route-image-keyframe", "已创建任务没有保存路由证据");
|
|
assert.equal(createdImage.payload.job.request.routing.modelKey, "qwen-image-2d-lock", "请求合同没有保存实际模型 Key");
|
|
assert.equal(createdImage.payload.job.cost_policy, "local", "路由成本策略没有写入任务");
|
|
|
|
const workflow = await request("/api/workflows/templates/workflow-global-ai-manhua-drama-v1/instantiate", {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ mode: "queue", shotId: "shot-01", adapter: "comfyui-optional", approveExternal: true })
|
|
});
|
|
assert.equal(workflow.response.status, 201, `工作流入队失败:${workflow.payload.detail || workflow.payload.error || ""}`);
|
|
workflowRunId = workflow.payload.run.id;
|
|
createdJobIds.push(...workflow.payload.jobs);
|
|
assert.equal(workflow.payload.run.status, "blocked", "存在未授权固定声线时,工作流必须保持阻塞");
|
|
assert.equal(workflow.payload.jobs.length, 2, "未授权固定 TTS 步骤不得创建任务,前序图像和视频任务仍应保留");
|
|
|
|
for (const jobId of workflow.payload.jobs) {
|
|
const detail = await request(`/api/jobs/${encodeURIComponent(jobId)}`, { headers });
|
|
assert.equal(detail.response.ok, true, "工作流任务详情读取失败");
|
|
assert.ok(detail.payload.job.routing?.routeId, "工作流创建的任务必须记录模型路由");
|
|
}
|
|
|
|
console.log("model routing smoke passed: route resolution, local audition, voice-rights gate, workflow queue");
|
|
} finally {
|
|
cleanupGeneratedRecords();
|
|
for (const jobId of createdJobIds) {
|
|
await rm(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true, force: true });
|
|
}
|
|
}
|