Files
opendance-web/web-server/test/smoke.mjs
T

280 lines
14 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 模型', isPrimary: true, ...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('isPrimary 已置位', modelRes.body?.isPrimary === true);
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)}`);
const pending = await request('GET', '/api/reviews?status=pending');
check('待审列表包含该曲目', pending.body?.items?.some((i) => i.targetType === 'song' && i.targetId === songId));
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' } });
await request('POST', `/api/reviews/model/${modelId}/approve`, { json: { reviewer: 'qa' } });
// ---------- 6. 目录导出 ----------
console.log('\n[6] 客户端目录导出');
const bundle = await request('GET', '/api/catalog/export');
check('GET /api/catalog/export 返回 200', bundle.status === 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 ?? ''), String(version.body?.revision).slice(0, 16));
// ---------- 7. 负向用例 ----------
console.log('\n[7] 负向用例');
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}`);
// ---------- 8. 清理 ----------
console.log('\n[8] 清理');
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}`);
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);
});