- 移除 bd/mg 两平台及磁盘残留产物;清理 api-test 死脚本、musicSdk/message+options 死模块、infSign vendor - musicSdk/ 改名 sdk/;src/common、src/auth、src/apis 整体移入 sdk/,依赖方向定为 src → sdk 严格单向 - src/ 手写 JS 全量转 .ts,删除 JS 类型垫片 modules.d.ts;源码 import 统一 .ts 后缀 - Node 原生类型剥离直接运行 .ts(engines >=22.18),取消 tsc 发射产物,tsc 仅类型检查(pnpm typecheck) - 删除 kwdes 备用实现与 src/index.ts 转发垫片;测试移至 test/smoke-test.ts - common/request.ts 补显式导出类型;修复 set-cookie 单值形态、ApiError 类字段等隐患 验证:tsc 零错误,smoke-test 41/41
413 lines
15 KiB
TypeScript
413 lines
15 KiB
TypeScript
// 冒烟测试 —— 验证服务器各接口与各平台功能
|
||
// 运行:pnpm test(Node 22.18+ 直接以类型剥离方式运行 .ts)
|
||
//
|
||
// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词、封面、榜单详情、热搜、歌单、
|
||
// 跨平台匹配 /find、歌手/专辑、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、
|
||
// 文档页、旧 query 路由移除回归
|
||
|
||
import http from 'node:http'
|
||
import { spawn } from 'node:child_process'
|
||
|
||
// 本地默认 16688(避开 1024~15000 的动态端口冲突区),可用 PORT/BASE_URL 覆盖
|
||
const PORT = process.env.PORT || 16688
|
||
const BASE = process.env.BASE_URL || `http://localhost:${PORT}`
|
||
const results: { name: string; ok: boolean; detail?: any }[] = []
|
||
|
||
const get = (path: string): Promise<any> => new Promise((resolve, reject) => {
|
||
http.get(`${BASE}${path}`, res => {
|
||
let data = ''
|
||
res.on('data', c => { data += c })
|
||
res.on('end', () => {
|
||
try { resolve(JSON.parse(data)) } catch { reject(new Error(`JSON parse failed: ${data.slice(0, 100)}`)) }
|
||
})
|
||
}).on('error', reject)
|
||
})
|
||
|
||
// 原始响应(不解析 JSON,用于 HTML 页与状态码断言)
|
||
const getRaw = (path: string): Promise<any> => new Promise((resolve, reject) => {
|
||
http.get(`${BASE}${path}`, res => {
|
||
let data = ''
|
||
res.on('data', c => { data += c })
|
||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }))
|
||
}).on('error', reject)
|
||
})
|
||
|
||
const postRaw = (path: string): Promise<any> => new Promise((resolve, reject) => {
|
||
const req = http.request(`${BASE}${path}`, { method: 'POST' }, res => {
|
||
let data = ''
|
||
res.on('data', c => { data += c })
|
||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }))
|
||
})
|
||
req.on('error', reject)
|
||
req.end()
|
||
})
|
||
|
||
// JSON POST(登录等写入接口)
|
||
const postJson = (path: string, body: any): Promise<any> => new Promise((resolve, reject) => {
|
||
const payload = JSON.stringify(body)
|
||
const req = http.request(`${BASE}${path}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
||
}, res => {
|
||
let data = ''
|
||
res.on('data', c => { data += c })
|
||
res.on('end', () => resolve({
|
||
status: res.statusCode,
|
||
body: (() => { try { return JSON.parse(data) } catch { return null } })(),
|
||
}))
|
||
})
|
||
req.on('error', reject)
|
||
req.write(payload)
|
||
req.end()
|
||
})
|
||
|
||
const test = async(name: string, fn: () => Promise<any>) => {
|
||
try {
|
||
const result = await fn()
|
||
results.push({ name, ok: true, detail: result })
|
||
console.log(`✅ ${name}`)
|
||
} catch (err: any) {
|
||
results.push({ name, ok: false, detail: err.message })
|
||
console.log(`❌ ${name}: ${err.message}`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查本地服务器是否已在运行
|
||
*/
|
||
const isServerRunning = () => new Promise(resolve => {
|
||
if (!BASE.startsWith('http://localhost:') && !BASE.startsWith('http://127.0.0.1:')) return resolve(true)
|
||
const req = http.get(`${BASE}/health`, res => {
|
||
res.resume()
|
||
resolve(res.statusCode === 200)
|
||
})
|
||
req.on('error', () => resolve(false))
|
||
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
|
||
})
|
||
|
||
/**
|
||
* 启动本地服务器并返回关闭句柄
|
||
*/
|
||
const startLocalServer = () => new Promise((resolve, reject) => {
|
||
const proc = spawn(process.execPath, ['src/server.ts'], {
|
||
cwd: process.cwd(),
|
||
env: { ...process.env, PORT: String(PORT) },
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
})
|
||
let stdout = ''
|
||
proc.stdout.on('data', d => { stdout += d })
|
||
proc.stderr.on('data', d => { stdout += d })
|
||
|
||
const timer = setTimeout(() => {
|
||
proc.kill()
|
||
reject(new Error('启动本地服务器超时'))
|
||
}, 10000)
|
||
|
||
const check = () => {
|
||
http.get(`${BASE}/health`, res => {
|
||
res.resume()
|
||
if (res.statusCode === 200) {
|
||
clearTimeout(timer)
|
||
resolve(proc)
|
||
} else {
|
||
setTimeout(check, 200)
|
||
}
|
||
}).on('error', () => setTimeout(check, 200))
|
||
}
|
||
setTimeout(check, 300)
|
||
})
|
||
|
||
let localServer: any = null
|
||
const running = await isServerRunning()
|
||
if (!running) {
|
||
console.log('本地服务器未运行,正在自动启动...')
|
||
localServer = await startLocalServer()
|
||
console.log('本地服务器已启动\n')
|
||
} else {
|
||
console.log('使用已运行的服务器\n')
|
||
}
|
||
|
||
// 搜索
|
||
for (const source of ['kw', 'kg', 'tx', 'wy']) {
|
||
await test(`搜索 ${source}`, async() => {
|
||
const r = await get(`/search?source=${source}&name=${encodeURIComponent('稻香')}&limit=2`)
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.list?.length) throw new Error('空列表')
|
||
return `${r.data.list[0].name} / ${r.data.list[0].singer}`
|
||
})
|
||
}
|
||
|
||
await test('聚合搜索', async() => {
|
||
const r = await get(`/search?name=${encodeURIComponent('稻香')}&limit=2`)
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.list?.length) throw new Error('空列表')
|
||
return `${r.data.list.length} 条结果`
|
||
})
|
||
|
||
// URL(用已知歌曲 id,路径参数风格)
|
||
await test('URL kw 320k', async() => {
|
||
const r = await get('/url/kw/28423286?quality=320k')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.url) throw new Error('无 url')
|
||
return r.data.url.slice(0, 60)
|
||
})
|
||
|
||
await test('URL wy 128k', async() => {
|
||
const r = await get('/url/wy/3357698666?quality=128k')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.url) throw new Error('无 url')
|
||
return r.data.url.slice(0, 60)
|
||
})
|
||
|
||
await test('URL tx 320k', async() => {
|
||
const r = await get('/url/tx/003C9fBv2K4x8b?quality=320k')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.url) throw new Error('无 url')
|
||
return r.data.url.slice(0, 60)
|
||
})
|
||
|
||
// 歌词
|
||
await test('歌词 kw', async() => {
|
||
const r = await get('/lyric/kw/28423286')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.lyric) throw new Error('无歌词')
|
||
return `${r.data.lyric.slice(0, 30)}...`
|
||
})
|
||
|
||
// 封面
|
||
await test('封面 kw', async() => {
|
||
const r = await get('/pic/kw/28423286')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.url) throw new Error('无 url')
|
||
return r.data.url.slice(0, 60)
|
||
})
|
||
|
||
// 榜单 & 热搜
|
||
for (const source of ['kw', 'kg', 'tx', 'wy']) {
|
||
await test(`榜单 ${source}`, async() => {
|
||
const r = await get(`/boards/${source}`)
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.list?.length) throw new Error('空列表')
|
||
return `${r.data.list.length} 个榜单`
|
||
})
|
||
}
|
||
|
||
await test('热搜 kw', async() => {
|
||
const r = await get('/hot-search/kw')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.list?.length) throw new Error('空列表')
|
||
return r.data.list[0]
|
||
})
|
||
|
||
// ---- 标准 HTTP 语义与文档 ----
|
||
|
||
await test('平台列表 /sources', async() => {
|
||
const r = await get('/sources')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.platforms?.length) throw new Error('空平台列表')
|
||
const kw = r.data.platforms.find(p => p.id === 'kw')
|
||
if (!kw?.capabilities?.search) throw new Error('kw 缺少 search 能力')
|
||
return `${r.data.platforms.length} 个平台`
|
||
})
|
||
|
||
await test('榜单详情 路径风格 /boards/:source/:boardId', async() => {
|
||
// 用列表返回的带前缀 id(回归:详情接口应自动归一化为裸 bangid)
|
||
const r = await get('/boards/kw/kw__93?page=1')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.list?.length) throw new Error('空列表')
|
||
return `${r.data.list.length} 首`
|
||
})
|
||
|
||
await test('跨平台匹配 /find', async() => {
|
||
const r = await get(`/find?name=${encodeURIComponent('稻香')}&singer=${encodeURIComponent('周杰伦')}`)
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.list?.length) throw new Error('空列表')
|
||
return `${r.data.list[0].name} / ${r.data.list[0].singer}`
|
||
})
|
||
|
||
await test('歌单 路径风格 /song-list/:source', async() => {
|
||
const r = await get('/song-list/kg?page=1')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.list?.length) throw new Error('空列表')
|
||
return `${r.data.list.length} 个歌单`
|
||
})
|
||
|
||
await test('歌单 默认排序 /song-list/kw', async() => {
|
||
const r = await get('/song-list/kw?page=1')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.list?.length) throw new Error('空列表(sortId 默认值未适配平台)')
|
||
return `${r.data.list.length} 个歌单`
|
||
})
|
||
|
||
// ---- 歌手 & 专辑 ----
|
||
|
||
await test('歌手信息 /singer/kg', async() => {
|
||
const r = await get('/singer/kg/3062')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.info?.name) throw new Error('无歌手名')
|
||
return r.data.info.name
|
||
})
|
||
|
||
await test('歌手信息 /singer/wy', async() => {
|
||
const r = await get('/singer/wy/3684')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.info?.name) throw new Error('无歌手名')
|
||
return r.data.info.name
|
||
})
|
||
|
||
await test('歌手信息 /singer/tx', async() => {
|
||
const r = await get('/singer/tx/0025NhlN2yWrP4')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.info?.name) throw new Error('无歌手名')
|
||
return r.data.info.name
|
||
})
|
||
|
||
await test('歌手歌曲 /singer/tx/:id/songs', async() => {
|
||
const r = await get('/singer/tx/0025NhlN2yWrP4/songs?page=1&limit=3')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.list?.length) throw new Error('空列表')
|
||
return `${r.data.list.length} 首 / 共 ${r.data.total}`
|
||
})
|
||
|
||
await test('歌手专辑 /singer/kg/:id/albums', async() => {
|
||
const r = await get('/singer/kg/3062/albums?page=1&limit=3')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.list?.length) throw new Error('空列表')
|
||
return `${r.data.list.length} 张`
|
||
})
|
||
|
||
await test('专辑详情 /album/kg', async() => {
|
||
const r = await get('/album/kg/171089481')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.info?.name || !r.data?.list) throw new Error('响应不完整')
|
||
return `${r.data.info.name}(${r.data.list.length} 首)`
|
||
})
|
||
|
||
await test('专辑详情 /album/kw', async() => {
|
||
const r = await get('/album/kw/29218')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.info?.name || !r.data?.list?.length) throw new Error('响应不完整')
|
||
return `${r.data.info.name}(${r.data.list.length} 首)`
|
||
})
|
||
|
||
await test('专辑详情 /album/tx', async() => {
|
||
const r = await get('/album/tx/000MkMni19ClKG')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (!r.data?.info?.name || !r.data?.list?.length) throw new Error('响应不完整')
|
||
return `${r.data.info.name}(${r.data.list.length} 首)`
|
||
})
|
||
|
||
await test('参数校验 422(缺少 name)', async() => {
|
||
const r = await getRaw('/search')
|
||
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
|
||
const body = JSON.parse(r.body)
|
||
if (body.code !== 422 || !body.msg) throw new Error('响应体格式错误')
|
||
return body.msg
|
||
})
|
||
|
||
await test('参数校验 422(非法音质)', async() => {
|
||
const r = await getRaw('/url/kw/28423286?quality=999k')
|
||
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
|
||
return JSON.parse(r.body).msg
|
||
})
|
||
|
||
await test('404 未知接口', async() => {
|
||
const r = await getRaw('/not-exist')
|
||
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
|
||
return JSON.parse(r.body).msg
|
||
})
|
||
|
||
await test('旧 query 路由已移除(404)', async() => {
|
||
const r = await getRaw('/url?source=kw&id=28423286')
|
||
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
|
||
const body = JSON.parse(r.body)
|
||
if (!/接口不存在/.test(body.msg)) throw new Error(`响应异常: ${body.msg}`)
|
||
return body.msg
|
||
})
|
||
|
||
await test('405 方法不允许(静态路由)', async() => {
|
||
const r = await postRaw('/search')
|
||
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)
|
||
if (!r.headers.allow) throw new Error('缺少 Allow 头')
|
||
return `Allow: ${r.headers.allow}`
|
||
})
|
||
|
||
await test('405 方法不允许(参数路由)', async() => {
|
||
const r = await postRaw('/url/kw/28423286')
|
||
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)
|
||
if (!r.headers.allow || !r.headers.allow.includes('GET')) throw new Error(`Allow 头异常: ${r.headers.allow}`)
|
||
return `Allow: ${r.headers.allow}`
|
||
})
|
||
|
||
await test('根路径 302 → /docs', async() => {
|
||
const r = await getRaw('/')
|
||
if (r.status !== 302) throw new Error(`期望 302,实际 ${r.status}`)
|
||
if (r.headers.location !== '/docs') throw new Error(`期望 /docs,实际 ${r.headers.location}`)
|
||
return r.headers.location
|
||
})
|
||
|
||
await test('Swagger UI 文档页 /docs', async() => {
|
||
const r = await getRaw('/docs')
|
||
if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`)
|
||
if (!r.body.includes('swagger-ui')) throw new Error('页面内容异常')
|
||
return 'Swagger UI'
|
||
})
|
||
|
||
await test('OpenAPI 规范 /openapi.json', async() => {
|
||
const r = await getRaw('/openapi.json')
|
||
if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`)
|
||
const spec = JSON.parse(r.body)
|
||
if (!spec.openapi?.startsWith('3.')) throw new Error(`openapi 版本异常: ${spec.openapi}`)
|
||
if (!spec.paths?.['/search']) throw new Error('缺少 /search 定义')
|
||
if (!spec.paths?.['/url/{source}/{id}']) throw new Error('缺少路径参数路由定义')
|
||
return `${Object.keys(spec.paths).length} 个路径`
|
||
})
|
||
|
||
await test('Swagger UI 规范端点 /docs/json(不可包信封)', async() => {
|
||
const r = await getRaw('/docs/json')
|
||
if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`)
|
||
const spec = JSON.parse(r.body)
|
||
// Swagger UI 强制从该地址加载规范:若被全局信封钩子包成 { code, data },顶层无 openapi 字段,文档页直接报错
|
||
if (!spec.openapi?.startsWith('3.')) throw new Error(`顶层缺少 openapi 字段(疑似被信封包裹): ${r.body.slice(0, 80)}`)
|
||
return spec.openapi
|
||
})
|
||
|
||
// ---- 登录 ----
|
||
|
||
await test('登录状态 /login/status', async() => {
|
||
const r = await get('/login/status')
|
||
if (r.code != 0) throw new Error(r.msg)
|
||
if (typeof r.data.wy?.logged !== 'boolean' || typeof r.data.kg?.logged !== 'boolean') throw new Error('响应不完整')
|
||
return `wy:${r.data.wy.logged} kg:${r.data.kg.logged}`
|
||
})
|
||
|
||
await test('扫码二维码创建 POST /login/wy/qr', async() => {
|
||
const r = await postJson('/login/wy/qr', {})
|
||
if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`)
|
||
if (!r.body.data?.key || !r.body.data?.qrurl || !r.body.data?.qrimg) throw new Error('响应不完整')
|
||
return r.body.data.key.slice(0, 18) + '…'
|
||
})
|
||
|
||
await test('kg 假 token 拒绝 POST /login/kg', async() => {
|
||
const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 })
|
||
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)
|
||
return r.body?.msg
|
||
})
|
||
|
||
// 汇总
|
||
const passed = results.filter(r => r.ok).length
|
||
const failed = results.filter(r => !r.ok)
|
||
console.log(`\n===== 结果: ${passed}/${results.length} 通过 =====`)
|
||
if (failed.length) {
|
||
console.log('失败项:')
|
||
failed.forEach(f => console.log(` - ${f.name}: ${f.detail}`))
|
||
}
|
||
|
||
if (localServer) {
|
||
console.log('\n正在关闭本地服务器...')
|
||
localServer.kill()
|
||
await new Promise(resolve => { localServer.on('exit', resolve) })
|
||
}
|
||
|
||
if (failed.length) process.exit(1)
|