Files
opendance-web/web-server/test/smoke.mjs
T
zijunchiang f3b97fec53 fix(scenes): 场景表不可用不再拖垮整站(生产事故修复)+ 诊断脚本
现场(真实部署 v0.7.2):数据库缺 scenes 表,于是
  /api/scenes、/api/scenes/:id、/api/catalog/export、/api/catalog/version、
  /api/reviews?status=pending 全部 500,而 /api/songs、/api/motions、/api/models
  正常。后果不是"场景不可用",而是客户端拉不到任何目录(Windows/Android 全废)、
管理端待审列表打不开。`/health` 却显示 database.connected=true、
migrationsPending=0 —— 因为 DataSource.showMigrations() 只比较**代码里加载到的**
迁移;部署的 dist 若缺 AddScenes.js,该迁移不在比较集合里,于是"没有待执行迁移"
但表不存在(最隐蔽的一种不一致)。

修复思路:契约与文档都写明「场景是可选资源」(没有可用场景时客户端回退内置空
场景),所以场景表的问题不允许影响与场景无关的功能 —— 改为降级:

- ScenesService 新增 queryScenes() 包装:数据库层面失败时返回空结果,**只记一次
  日志**(避免刷屏),原因写入 diagnostics();下一次查询成功即自动复位;
- ReviewsService.listByStatus() 对场景查询单独 catch,待审列表照常返回其它资源;
- /health 新增 scenes: { available, error } —— 降级但绝不静默;
- HealthModule 增加 imports: [ScenesModule](HealthController 注入了 ScenesService,
  Nest 的 DI 按模块解析,漏了会直接 UnknownDependenciesException;
  scripts/check-bootstrap.mjs 已能拦住这一类)。

新增 web-server/scripts/diagnose-scenes.mjs(默认只读):打印运行期真实连的库、
表清单、typeorm_migrations 记录、**代码实际加载的迁移**、scenes 实际列 vs 实体
期望列的逐列 diff;`--fix` 仅在缺表时用迁移里同一份 DDL 幂等补表。

顺带修正 models.controller.ts 的误导性注释:模型上传**不接受** isPrimary/status
(契约:上传一律 pending,默认模型由审核通过时带 setPrimary=true 或
POST /api/models/:id/primary 设定)。test/smoke.mjs 此前正是按错误预期断言
"isPrimary 已置位",已改为:上传断言 pending + isPrimary=false → 审核通过时带
setPrimary=true → 再断言 isPrimary=true;同时给目录导出/指纹等检查补上失败细节
(HTTP 状态 + 响应体),并把待审列表查询的 pageSize 放大到 200(避免库里待审项多
时新条目被挤出第一页)。

文档:README.md/.txt 增加 v0.7.3 修订说明;tasks/015-scene-management.md 增
「缺陷修复记录二」;开发文档 §4.12 补充硬性要求(场景不可用必须降级 + /health 暴露
+ showMigrations 的陷阱说明)。

测试:新增 test/scenes-degradation.spec.ts(6 项);npm run verify 通过
(契约六项一致、35 条路由、21 文件 / 276 项测试、nest build、启动冒烟);
发布产物解压后 npm ci --omit=dev + node dist/main.js 实测打印「应用版本 v0.7.3」。

注意:Gitea 附件上传通道仍故障(1 KB 也瞬时 500),因此本版**尚未上传 Release**,
产物先在本地交付(sha256 见 SHA256SUMS)。
2026-09-17 03:24:56 +08:00

463 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OpenDance Studio Web 服务端 —— 端到端冒烟验收脚本。
*
* 覆盖 T-WEB-003 ~ T-WEB-009 的主要接口:
* 歌曲 CRUD → 动作上传 → 关键点预处理 → 关键点下载
* → 模型上传(VRM 优先)→ 审核流转 → 目录导出 → 负向用例
*
* 用法:
* API_BASE=http://127.0.0.1:3000 node test/smoke.mjs
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const API = process.env.API_BASE ?? 'http://127.0.0.1:3000';
let passed = 0;
let failed = 0;
const failures = [];
function check(name, condition, detail = '') {
if (condition) {
passed += 1;
console.log(` ✓ ${name}`);
} else {
failed += 1;
failures.push(`${name}${detail ? ` — ${detail}` : ''}`);
console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`);
}
}
async function request(method, path, { json, form } = {}) {
const init = { method };
if (json !== undefined) {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify(json);
}
if (form !== undefined) {
init.body = form;
}
const res = await fetch(`${API}${path}`, init);
const text = await res.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
return { status: res.status, body, headers: res.headers };
}
const SOURCE = {
sourceType: 'original',
sourceName: '冒烟测试来源',
sourceUrl: 'https://example.com/source',
license: 'MIT',
author: 'OpenDance QA',
};
function fileForm(fields, filename, content, mime = 'application/octet-stream') {
const form = new FormData();
for (const [key, value] of Object.entries(fields)) form.append(key, String(value));
form.append('file', new Blob([content], { type: mime }), filename);
return form;
}
/** 构造合法的 GLB(magic + version + length + JSON chunk) */
function buildGlb(json) {
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
const padded = Buffer.concat([jsonBuf, Buffer.alloc((4 - (jsonBuf.length % 4)) % 4, 0x20)]);
const header = Buffer.alloc(12);
header.write('glTF', 0, 'ascii');
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + padded.length, 8);
const chunkHeader = Buffer.alloc(8);
chunkHeader.writeUInt32LE(padded.length, 0);
chunkHeader.writeUInt32LE(0x4e4f534a, 4); // 'JSON'
return Buffer.concat([header, chunkHeader, padded]);
}
async function main() {
console.log(`\n== OpenDance Studio Web 服务端冒烟验收 @ ${API} ==\n`);
// ---------- 0. 健康检查 ----------
console.log('[0] 健康检查');
const health = await request('GET', '/health');
check('GET /health 返回 200', health.status === 200, `实际 ${health.status}`);
check('数据库已连接', health.body?.database?.connected === true, JSON.stringify(health.body?.database));
check('迁移无待执行项', health.body?.database?.migrationsPending === 0);
check('契约版本已暴露', Boolean(health.body?.contractVersion?.['rest-api']));
// ---------- 1. T-WEB-003 歌曲 CRUD ----------
console.log('\n[1] T-WEB-003 歌曲 CRUD');
const created = await request('POST', '/api/songs', {
json: {
title: `冒烟曲目 ${Date.now()}`,
artist: 'QA 歌手',
album: 'QA 专辑',
language: 'zh',
durationMs: 180000,
bpm: 128.5,
difficulty: 3,
tags: ['测试', 'dance'],
...SOURCE,
},
});
check('POST /api/songs 返回 201', created.status === 201, `实际 ${created.status} ${JSON.stringify(created.body)}`);
const songId = created.body?.id;
check('返回 id 为数字', typeof songId === 'number', `实际 ${typeof songId}`);
check('来源五元组回显完整', created.body?.sourceName === SOURCE.sourceName && created.body?.license === 'MIT');
check('tags 正确往返', Array.isArray(created.body?.tags) && created.body.tags.length === 2);
check('bpm 为数字而非字符串', typeof created.body?.bpm === 'number', `实际 ${typeof created.body?.bpm}`);
const list = await request('GET', '/api/songs?q=冒烟&page=1&pageSize=5');
check('GET /api/songs 分页结构正确', list.status === 200 && Array.isArray(list.body?.items) && typeof list.body?.total === 'number');
check('模糊查询命中新建曲目', list.body?.items?.some((s) => s.id === songId));
const audioForm = new FormData();
audioForm.append('file', new Blob([Buffer.from('ID3fake-audio-bytes')], { type: 'audio/mpeg' }), 'test.mp3');
const audio = await request('POST', `/api/songs/${songId}/audio`, { form: audioForm });
check('POST /api/songs/:id/audio 上传成功', audio.status === 201, `实际 ${audio.status}`);
check('audioUrl 已生成', typeof audio.body?.audioUrl === 'string' && audio.body.audioUrl.startsWith('/static/'), String(audio.body?.audioUrl));
const patched = await request('PATCH', `/api/songs/${songId}`, { json: { difficulty: 5 } });
check('PATCH /api/songs/:id 更新成功', patched.status === 200 && patched.body?.difficulty === 5);
const badSource = await request('PATCH', `/api/songs/${songId}`, { json: { license: 'GPL' } });
check('PATCH 只给部分来源字段 → 400', badSource.status === 400, `实际 ${badSource.status}`);
// ---------- 2. T-WEB-004 动作上传 ----------
console.log('\n[2] T-WEB-004 动作上传 + 来源字段');
const bvh = readFileSync(join(here, 'fixtures', 'sample.bvh'));
const motionRes = await request('POST', '/api/motions', {
form: fileForm(
{ name: '冒烟动作-挥手深蹲', songId, autoPreprocess: false, ...SOURCE },
'sample.bvh',
bvh,
'text/plain',
),
});
check('POST /api/motions 返回 201', motionRes.status === 201, `实际 ${motionRes.status} ${JSON.stringify(motionRes.body)}`);
const motionId = motionRes.body?.id;
check('自动识别格式为 bvh', motionRes.body?.format === 'bvh', String(motionRes.body?.format));
check('fileSize 已记录', typeof motionRes.body?.fileSize === 'number' && motionRes.body.fileSize > 0);
check('预处理初始状态为 none', motionRes.body?.preprocessState === 'none');
const missingSource = await request('POST', '/api/motions', {
form: fileForm({ name: '缺少来源' }, 'sample.bvh', bvh),
});
check('缺少来源五元组 → 400', missingSource.status === 400, `实际 ${missingSource.status}`);
const badExt = await request('POST', '/api/motions', {
form: fileForm({ name: '非法格式', ...SOURCE }, 'evil.exe', bvh),
});
check('非法扩展名 → 400', badExt.status === 400, `实际 ${badExt.status}`);
// ---------- 3. T-WEB-006 关键点预处理 ----------
console.log('\n[3] T-WEB-006 BVH → MediaPipe33 预处理');
const pre = await request('POST', `/api/motions/${motionId}/preprocess`);
check('POST /api/motions/:id/preprocess 成功', pre.status === 200, `实际 ${pre.status} ${JSON.stringify(pre.body)}`);
check('preprocessState = done', pre.body?.preprocessState === 'done', String(pre.body?.preprocessError));
check('keypointFormat 为 mediapipe33@0.1.0', pre.body?.keypointFormat === 'mediapipe33@0.1.0');
check('keypointFrames = 60', pre.body?.keypointFrames === 60, String(pre.body?.keypointFrames));
check('fps ≈ 30', Math.abs((pre.body?.fps ?? 0) - 30) < 0.01, String(pre.body?.fps));
const kp = await fetch(`${API}/api/motions/${motionId}/keypoints`);
const kpText = await kp.text();
const lines = kpText.trim().split('\n');
check('GET /api/motions/:id/keypoints 返回 200', kp.status === 200, `实际 ${kp.status}`);
check('NDJSON 共 60 行', lines.length === 60, `实际 ${lines.length}`);
const firstFrame = JSON.parse(lines[0]);
check('每帧 33 个关键点', firstFrame.points?.length === 33, `实际 ${firstFrame.points?.length}`);
check('每帧 9 个关节角', firstFrame.angles?.length === 9, `实际 ${firstFrame.angles?.length}`);
const xs = firstFrame.points.map((p) => p[0]);
const ys = firstFrame.points.map((p) => p[1]);
check('关键点 x 归一化在 [0,1]', xs.every((v) => v >= 0 && v <= 1), `min=${Math.min(...xs)} max=${Math.max(...xs)}`);
check('关键点 y 归一化在 [0,1]', ys.every((v) => v >= 0 && v <= 1), `min=${Math.min(...ys)} max=${Math.max(...ys)}`);
const confs = new Set(firstFrame.points.map((p) => p[3]));
check('置信度分层(直接映射 1 / 推算 0.5)', confs.has(1) || confs.has(0.5), [...confs].join(','));
check('关节角余弦落在 [-1,1]', firstFrame.angles.every((v) => v >= -1.0001 && v <= 1.0001));
// 关键点随时间变化(确认不是静止帧)
const lastFrame = JSON.parse(lines[lines.length - 1]);
const moved = firstFrame.points.some((p, i) => Math.abs(p[0] - lastFrame.points[i][0]) > 0.001);
check('关键点序列随时间变化', moved);
// ---------- 4. T-WEB-005 模型上传(VRM 优先) ----------
console.log('\n[4] T-WEB-005 模型上传 + VRM 优先 + 来源字段');
const vrmFile = buildGlb({
asset: { version: '2.0', generator: 'OpenDance smoke test' },
extensions: { VRMC_vrm: { specVersion: '1.0' } },
});
const modelRes = await request('POST', '/api/models', {
form: fileForm({ name: '冒烟 VRM 模型', ...SOURCE }, 'avatar.vrm', vrmFile, 'model/gltf-binary'),
});
check('POST /api/models 返回 201', modelRes.status === 201, `实际 ${modelRes.status} ${JSON.stringify(modelRes.body)}`);
const modelId = modelRes.body?.id;
check('识别 VRM 版本为 1.0', modelRes.body?.vrmVersion === '1.0', String(modelRes.body?.vrmVersion));
check('上传后状态为待审核', modelRes.body?.status === 'pending', String(modelRes.body?.status));
// 契约 data-models §4 / rest-api POST /api/models:**上传一律 pending,且不设默认模型**;
// 默认模型只能由「审核通过时带 setPrimary=true」或 POST /api/models/{id}/primary 设定。
check('上传阶段不置默认模型(isPrimary=false)', modelRes.body?.isPrimary === false, String(modelRes.body?.isPrimary));
const modelMissingSource = await request('POST', '/api/models', {
form: fileForm({ name: '缺少来源' }, 'x.vrm', vrmFile),
});
check('模型缺少来源五元组 → 400', modelMissingSource.status === 400, `实际 ${modelMissingSource.status}`);
const modelsList = await request('GET', '/api/models?format=vrm');
check('按格式过滤模型列表', modelsList.status === 200 && modelsList.body?.items?.every((m) => m.format === 'vrm'));
check('列表含来源信息(管理端展示用)', Boolean(modelsList.body?.items?.[0]?.sourceUrl && modelsList.body?.items?.[0]?.author));
// ---------- 5. T-WEB-007 审核流程 ----------
console.log('\n[5] T-WEB-007 上传审核流程');
const submit = await request('POST', `/api/reviews/song/${songId}/submit`, { json: { reviewer: 'qa' } });
check('提交审核 → pending', submit.status === 200 && submit.body?.status === 'pending', `实际 ${submit.status} ${JSON.stringify(submit.body)}`);
// 待审列表默认分页:显式放大 pageSize,避免库里已有大量待审项时新条目被挤出第一页
const pending = await request('GET', '/api/reviews?status=pending&pageSize=200');
check(
'待审列表包含该曲目',
pending.status === 200 && pending.body?.items?.some((i) => i.targetType === 'song' && i.targetId === songId),
`HTTP ${pending.status},total=${pending.body?.total},条数=${pending.body?.items?.length},${JSON.stringify(pending.body).slice(0, 200)}`,
);
const badReject = await request('POST', `/api/reviews/song/${songId}/reject`, { json: { reviewer: 'qa' } });
check('驳回未填理由 → 400', badReject.status === 400, `实际 ${badReject.status}`);
const approve = await request('POST', `/api/reviews/song/${songId}/approve`, { json: { reviewer: 'qa', comment: '通过' } });
check('审核通过 → published', approve.status === 200 && approve.body?.status === 'published');
const again = await request('POST', `/api/reviews/song/${songId}/approve`, { json: { reviewer: 'qa' } });
check('重复审核 → 409', again.status === 409, `实际 ${again.status}`);
const history = await request('GET', `/api/reviews/song/${songId}/history`);
check('审核流水已记录 2 条', Array.isArray(history.body) && history.body.length === 2, `实际 ${history.body?.length}`);
await request('POST', `/api/reviews/motion/${motionId}/submit`, { json: { reviewer: 'qa' } });
await request('POST', `/api/reviews/motion/${motionId}/approve`, { json: { reviewer: 'qa' } });
await request('POST', `/api/reviews/model/${modelId}/submit`, { json: { reviewer: 'qa' } });
// 默认模型按契约在此设定:审核通过时带 setPrimary=true(等价于 POST /api/models/:id/primary)
const modelApprove = await request('POST', `/api/reviews/model/${modelId}/approve`, {
json: { reviewer: 'qa', setPrimary: true },
});
check(
'模型审核通过并设为默认(setPrimary=true)',
modelApprove.status === 200 && modelApprove.body?.status === 'published',
`HTTP ${modelApprove.status} ${JSON.stringify(modelApprove.body).slice(0, 160)}`,
);
const modelAfter = await request('GET', `/api/models/${modelId}`);
check('审核通过后 isPrimary 已置位', modelAfter.body?.isPrimary === true, String(modelAfter.body?.isPrimary));
// ---------- 6. 目录导出 ----------
console.log('\n[6] 客户端目录导出');
const bundle = await request('GET', '/api/catalog/export');
check(
'GET /api/catalog/export 返回 200',
bundle.status === 200,
`HTTP ${bundle.status} ${JSON.stringify(bundle.body).slice(0, 200)}`,
);
check('仅含已发布曲目', bundle.body?.songs?.some((s) => s.id === songId));
check('defaultModelId 解析为 VRM 模型', bundle.body?.defaultModelId === modelId, String(bundle.body?.defaultModelId));
check('bundle 含契约版本', Boolean(bundle.body?.contractVersion?.['data-models']));
const version = await request('GET', '/api/catalog/version');
check(
'GET /api/catalog/version 返回目录指纹',
/^[0-9a-f]{64}$/.test(version.body?.revision ?? ''),
`HTTP ${version.status} ${JSON.stringify(version.body).slice(0, 200)}`,
);
// ---------- 7. T-WEB-015 场景管理(GLB + 物理参数) ----------
console.log('\n[7] T-WEB-015 场景管理(GLB + 物理参数)');
const sceneConfig = {
physics: {
gravity: { x: 0, y: -3.5, z: 0 },
wind: {
base: { x: 2, y: 0, z: -1 },
gustAmplitude: 4,
gustFrequency: 0.5,
gustDirection: { x: 1, y: 0, z: 0 },
space: 'world',
},
ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 },
colliders: 'auto',
},
environment: { ambientColor: '#112233', ambientIntensity: 2, fogDensity: 0.2 },
spawnPoints: [{ x: 1, y: 0, z: -2, yaw: 90 }],
camera: { mode: 'orbit', distance: 6, height: 2 },
};
// 场景 GLB:一个 mesh(POSITION=3)+ 一个按客户端 `colliders: auto` 命名约定
// 命名的节点(*_col)。分析/缩略图失败是非致命的,下面的断言只依赖入库结果。
const sceneGlb = buildGlb({
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ name: 'Floor_col' }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
accessors: [{ count: 3 }],
});
const sceneRes = await request('POST', '/api/scenes', {
form: fileForm(
{ name: `冒烟场景 ${Date.now()}`, config: JSON.stringify(sceneConfig), ...SOURCE },
'stage.glb',
sceneGlb,
'model/gltf-binary',
),
});
check('POST /api/scenes 返回 201', sceneRes.status === 201, `实际 ${sceneRes.status} ${JSON.stringify(sceneRes.body)}`);
const sceneId = sceneRes.body?.id;
check('上传场景一律待审核', sceneRes.body?.status === 'pending', String(sceneRes.body?.status));
check(
'GLB 直传不重编码(conversionNote 说明)',
typeof sceneRes.body?.conversionNote === 'string' && sceneRes.body.conversionNote.includes('直接导入'),
String(sceneRes.body?.conversionNote),
);
check(
'glbUrl 已生成',
typeof sceneRes.body?.glbUrl === 'string' && sceneRes.body.glbUrl.startsWith('/static/'),
String(sceneRes.body?.glbUrl),
);
// 物理参数入库即收敛:合法值原样保留
const sceneCfg = sceneRes.body?.config;
check('重力按配置入库', sceneCfg?.physics?.gravity?.y === -3.5, JSON.stringify(sceneCfg?.physics?.gravity));
check('风基础向量入库', sceneCfg?.physics?.wind?.base?.x === 2, JSON.stringify(sceneCfg?.physics?.wind));
check(
'地面摩擦/坡度入库',
sceneCfg?.physics?.ground?.friction === 0.8 && sceneCfg?.physics?.ground?.slopeLimit === 30,
JSON.stringify(sceneCfg?.physics?.ground),
);
check(
'出生点与相机模式入库',
sceneCfg?.spawnPoints?.[0]?.yaw === 90 && sceneCfg?.camera?.mode === 'orbit',
JSON.stringify({ spawn: sceneCfg?.spawnPoints, camera: sceneCfg?.camera }),
);
// 物理参数入库即收敛:越界夹紧、非法枚举回退
const clampRes = await request('POST', '/api/scenes', {
form: fileForm(
{
name: `冒烟越界场景 ${Date.now()}`,
config: JSON.stringify({
physics: { gravity: { y: -9999 }, ground: { friction: 5 }, colliders: '不存在的枚举' },
camera: { mode: '无此模式' },
}),
...SOURCE,
},
'stage-clamped.glb',
sceneGlb,
'model/gltf-binary',
),
});
const clampId = clampRes.body?.id;
check(
'越界参数被夹紧(不会把非法物理下发到客户端)',
clampRes.body?.config?.physics?.gravity?.y === -200 && clampRes.body?.config?.physics?.ground?.friction === 1,
JSON.stringify(clampRes.body?.config?.physics),
);
check(
'非法枚举回退默认值',
clampRes.body?.config?.physics?.colliders === 'auto' && clampRes.body?.config?.camera?.mode === 'follow',
JSON.stringify({ colliders: clampRes.body?.config?.physics?.colliders, camera: clampRes.body?.config?.camera?.mode }),
);
const sceneMissingSource = await request('POST', '/api/scenes', {
form: fileForm({ name: '缺来源' }, 'x.glb', sceneGlb, 'model/gltf-binary'),
});
check('场景缺少来源五元组 → 400', sceneMissingSource.status === 400, `实际 ${sceneMissingSource.status}`);
// 未审核前不得进入客户端目录
const versionBeforeScene = await request('GET', '/api/catalog/version');
const bundleBeforeScene = await request('GET', '/api/catalog/export');
check(
'待审场景不出现在客户端目录',
!bundleBeforeScene.body?.scenes?.some((s) => s.id === sceneId),
JSON.stringify(bundleBeforeScene.body?.scenes?.map((s) => s.id)),
);
await request('POST', `/api/reviews/scene/${sceneId}/submit`, { json: { reviewer: 'qa' } });
const sceneApprove = await request('POST', `/api/reviews/scene/${sceneId}/approve`, {
json: { reviewer: 'qa', comment: '通过' },
});
check(
'场景审核通过 → published',
sceneApprove.status === 200 && sceneApprove.body?.status === 'published',
`实际 ${sceneApprove.status} ${JSON.stringify(sceneApprove.body)}`,
);
const bundleAfterScene = await request('GET', '/api/catalog/export');
const publishedScene = bundleAfterScene.body?.scenes?.find((s) => s.id === sceneId);
check(
'发布后场景进入客户端目录 scenes[]',
Boolean(publishedScene),
JSON.stringify(bundleAfterScene.body?.scenes?.map((s) => s.id)),
);
check(
'目录里的场景带完整物理参数',
publishedScene?.config?.physics?.gravity?.y === -3.5 && publishedScene?.config?.physics?.wind?.gustAmplitude === 4,
JSON.stringify(publishedScene?.config?.physics),
);
check(
'目录里的场景带 GLB 与缩略图字段',
typeof publishedScene?.glbUrl === 'string' && 'thumbnailUrl' in (publishedScene ?? {}),
JSON.stringify({ glbUrl: publishedScene?.glbUrl, thumbnailUrl: publishedScene?.thumbnailUrl }),
);
const versionAfterScene = await request('GET', '/api/catalog/version');
check(
'目录指纹纳入场景变更(客户端会因此重新拉取)',
versionAfterScene.body?.revision !== versionBeforeScene.body?.revision,
`${String(versionBeforeScene.body?.revision).slice(0, 12)} -> ${String(versionAfterScene.body?.revision).slice(0, 12)}`,
);
const sceneDetail = await request('GET', `/api/scenes/${sceneId}`);
check('GET /api/scenes/:id 返回详情', sceneDetail.status === 200 && sceneDetail.body?.id === sceneId);
const sceneList = await request('GET', '/api/scenes?status=published');
check(
'场景列表可按状态过滤',
sceneList.status === 200 && sceneList.body?.items?.some((s) => s.id === sceneId),
`实际 ${sceneList.status}`,
);
// ---------- 8. 负向用例 ----------
console.log('\n[8] 负向用例');
const notFound = await request('GET', '/api/songs/99999999');
check('不存在的曲目 → 404', notFound.status === 404);
const badEnum = await request('POST', '/api/songs', { json: { title: 'x', artist: 'y', ...SOURCE, sourceType: 'nope' } });
check('非法 sourceType → 400', badEnum.status === 400, `实际 ${badEnum.status}`);
const badUrl = await request('POST', '/api/songs', { json: { title: 'x', artist: 'y', ...SOURCE, sourceUrl: 'not-a-url' } });
check('非法 sourceUrl → 400', badUrl.status === 400, `实际 ${badUrl.status}`);
// ---------- 9. 清理 ----------
console.log('\n[9] 清理');
const del = await request('DELETE', `/api/songs/${songId}`);
check('DELETE /api/songs/:id → 204', del.status === 204, `实际 ${del.status}`);
const afterDelete = await request('GET', '/api/songs?q=冒烟');
check('软删除后列表不再包含', !afterDelete.body?.items?.some((s) => s.id === songId));
await request('DELETE', `/api/motions/${motionId}`);
await request('DELETE', `/api/models/${modelId}`);
if (sceneId) {
const delScene = await request('DELETE', `/api/scenes/${sceneId}`);
check('DELETE /api/scenes/:id → 204', delScene.status === 204, `实际 ${delScene.status}`);
}
if (clampId) await request('DELETE', `/api/scenes/${clampId}`);
console.log(`\n== 结果:${passed} 通过 / ${failed} 失败 ==`);
if (failed > 0) {
console.log('\n失败项:');
for (const f of failures) console.log(` - ${f}`);
process.exit(1);
}
console.log('全部通过 ✅\n');
}
main().catch((error) => {
console.error('\n冒烟测试异常终止:', error);
process.exit(1);
});