号2素材实测踩到:mitm/Reqable 导出的请求头是 'Cookie'(大写),
parse_curl 的 headers.get('cookie') 大小写敏感 → 自检误报缺 cookie 头。
改用小写化视图做自检判定,原始头键仍原样保留(服务端/request 都大小写不敏感)。
244 lines
10 KiB
JavaScript
244 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* test_coin.js —— 金币中心签到逻辑的离线验证(mock https,不联网)
|
||
*
|
||
* 覆盖 coinCenter() 的六条分支:
|
||
* ① 未签 → SignIn 成功 → AwardPrize 成功
|
||
* ② 未签 → SignIn 成功 → 无 State=2 的 task(只签不领)
|
||
* ③ 已签 → 有 State=2 的 task(补领)
|
||
* ④ 已签 → 无 State=2 的 task(skip)
|
||
* ⑤ 状态查询失败(ActID 过期)→ warn,不 fail
|
||
* ⑥ QM_COIN_SIGN=off → skip
|
||
* ⑦ 领奖返回 100004(幂等)→ 算成功
|
||
* ⑧ QmDead 透传(code 2000)
|
||
*
|
||
* 用法: node qqmusic/test_coin.js
|
||
*/
|
||
'use strict'
|
||
const Module = require('module')
|
||
const path = require('path')
|
||
|
||
const FILE = path.join(__dirname, 'scrip_qqmusic_checkin.js')
|
||
|
||
let pass = 0, fail = 0
|
||
const ok = (n, c, e = '') => {
|
||
if (c) { pass++; console.log(` ✓ ${n}`) }
|
||
else { fail++; console.log(` ✗ ${n}${e ? ' — ' + e : ''}`) }
|
||
}
|
||
|
||
/* ── mock https.request ── */
|
||
let respond = () => ({ status: 200, text: '{}' })
|
||
const origRequest = require('https').request
|
||
require('https').request = function (opts, cb) {
|
||
const chunks = []
|
||
const req = {
|
||
on() { return req },
|
||
write(d) { chunks.push(Buffer.from(d)) },
|
||
end() {
|
||
const body = Buffer.concat(chunks).toString('utf8')
|
||
const r = respond(opts, body)
|
||
const res = {
|
||
statusCode: r.status,
|
||
on(ev, fn) {
|
||
if (ev === 'data') fn(Buffer.from(r.text))
|
||
if (ev === 'end') setImmediate(fn)
|
||
return res
|
||
},
|
||
}
|
||
setImmediate(() => cb(res))
|
||
},
|
||
destroy() {},
|
||
}
|
||
return req
|
||
}
|
||
|
||
const { QMClient } = require(FILE)
|
||
|
||
/* 构造一个直接可用(跳过 refresh/缓存)的 client */
|
||
function mkClient() {
|
||
const c = new QMClient('1006764347', 'Q_H_L_faketoken_for_shape', '/tmp/__nonexistent_qm_cache.json')
|
||
c.key = 'Q_H_L_faketoken_for_shape'
|
||
return c
|
||
}
|
||
|
||
const STATE_OK = (isSignIn, day, tasks) => JSON.stringify({
|
||
code: 0,
|
||
req_0: { code: 0, data: { retCode: 0, Info: { IsSignIn: isSignIn } } },
|
||
req_1: {
|
||
code: 0,
|
||
data: {
|
||
retCode: 0,
|
||
Info: { IsSignIn: isSignIn, ContinueSignInCount: day },
|
||
TaskListInfo: { TaskList: { ContinueTaskList: tasks } },
|
||
},
|
||
},
|
||
})
|
||
const SIGN_OK = JSON.stringify({
|
||
code: 0,
|
||
req_0: { code: 0, data: { retCode: 0, Info: { IsSignIn: true } } },
|
||
})
|
||
const AWARD_OK = JSON.stringify({ code: 0, req_0: { code: 0, data: { retCode: 0 } } })
|
||
const AWARD_DUP = JSON.stringify({ code: 0, req_0: { code: 0, data: { retCode: 100004 } } })
|
||
|
||
/* 按 method 分派的 mock:金币主活动与抽奖活动用不同 ActID 区分 */
|
||
function route(table) {
|
||
return (opts, body) => {
|
||
let j = {}
|
||
try { j = JSON.parse(body) } catch { /* */ }
|
||
const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID)
|
||
const act = acts[0]
|
||
const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method)
|
||
const key = `${act}:${methods.join('+')}`
|
||
const hit = table[key]
|
||
return { status: 200, text: hit !== undefined ? hit : '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' }
|
||
}
|
||
}
|
||
|
||
const COIN = 'Z25hHGi'
|
||
const LOT = 'Z156KEu'
|
||
|
||
const cases = [
|
||
{
|
||
name: '① 未签 → 签到成功 → 领奖成功',
|
||
table: {
|
||
[`${COIN}:GetSignInSummary+GetSignInTaskList`]: STATE_OK(false, 0, {}),
|
||
[`${COIN}:SignIn`]: SIGN_OK,
|
||
[`${COIN}:AwardPrize`]: AWARD_OK,
|
||
[`${LOT}:GetSignInSummary+GetSignInTaskList`]: STATE_OK(true, 5, {}),
|
||
},
|
||
// 第二次查询(签完后复查)也要返回带 task 的状态 → 用序列化响应
|
||
seq: true,
|
||
want: { status: 'done', has: '已签' },
|
||
},
|
||
]
|
||
|
||
/* 由于「签完复查」需要同一 key 返回不同响应,用计数器实现 */
|
||
async function runCase(name, script, want) {
|
||
const calls = []
|
||
respond = (opts, body) => {
|
||
let j = {}
|
||
try { j = JSON.parse(body) } catch { /* */ }
|
||
calls.push({
|
||
url: opts.host + opts.path,
|
||
reqs: [j.req_0, j.req_1].filter(Boolean).map(r => `${(r.param || {}).ActID}:${r.method}`),
|
||
comm: j.comm,
|
||
})
|
||
return script(j, calls.length)
|
||
}
|
||
const c = mkClient()
|
||
const t = await c.coinCenter()
|
||
console.log(`\n${name}`)
|
||
ok(`返回 0(金币不计入成长值)`, t === 0, String(t))
|
||
for (const [k, v] of Object.entries(want)) {
|
||
if (k === 'status') {
|
||
const s = c.steps.find(x => x.name === '金币签到')
|
||
ok(`step 存在且 status=${v}`, s && s.status === v, JSON.stringify(c.steps.map(x => `${x.name}:${x.status}`)))
|
||
} else if (k === 'has') {
|
||
const s = c.steps.find(x => x.name === '金币签到')
|
||
ok(`detail 含「${v}」`, s && s.detail.includes(v), s && s.detail)
|
||
} else if (k === 'calls') {
|
||
ok(`请求次数 = ${v}`, calls.length === v, String(calls.length))
|
||
} else if (k === 'comm') {
|
||
const c0 = calls[0] && calls[0].comm
|
||
ok(`comm 是小程序形态(mina=1 appid=…)`, c0 && c0.mina === 1 && String(c0.appid).startsWith('wx'), JSON.stringify(c0))
|
||
ok(`comm.authst = key`, c0 && c0.authst === 'Q_H_L_faketoken_for_shape')
|
||
ok(`comm 无 g_tk(金币体系不签名)`, c0 && c0.g_tk === undefined, JSON.stringify(c0))
|
||
}
|
||
}
|
||
return calls
|
||
}
|
||
|
||
;(async () => {
|
||
/* ① 未签 → 签 → 领 */
|
||
await runCase('── ① 未签 → 签到 → 领奖 ──', (j, n) => {
|
||
const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID)
|
||
const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method)
|
||
const act = acts[0]
|
||
if (act === LOT) return { status: 200, text: STATE_OK(true, 3, {}) }
|
||
if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') {
|
||
// 第一次查询:未签;第二次(签后复查):已签 + 有可领 task
|
||
return { status: 200, text: n === 1
|
||
? STATE_OK(false, 0, {})
|
||
: STATE_OK(true, 1, { '1': { ID: 'T1', State: 2, PrizeList: [{ Value: 30 }] } }) }
|
||
}
|
||
if (methods[0] === 'SignIn') return { status: 200, text: SIGN_OK }
|
||
if (methods[0] === 'AwardPrize') return { status: 200, text: AWARD_OK }
|
||
return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' }
|
||
}, { status: 'done', has: '已签', comm: true })
|
||
|
||
/* ② 已签 + 有 task → 补领 */
|
||
await runCase('── ② 已签 + 有可领 task → 补领 ──', (j) => {
|
||
const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID)
|
||
const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method)
|
||
if (acts[0] === LOT) return { status: 200, text: STATE_OK(true, 3, {}) }
|
||
if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') {
|
||
return { status: 200, text: STATE_OK(true, 2, { '2': { ID: 'T2', State: 2, PrizeList: [{ Name: '10金币' }] } }) }
|
||
}
|
||
if (methods[0] === 'AwardPrize') return { status: 200, text: AWARD_OK }
|
||
return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' }
|
||
}, { status: 'done', has: '10金币' })
|
||
|
||
/* ③ 已签 + 无可领 → skip */
|
||
await runCase('── ③ 已签 + 无可领 → skip ──', (j) => {
|
||
const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID)
|
||
return { status: 200, text: STATE_OK(true, 4, { '1': { ID: 'T1', State: 1 } }) }
|
||
}, { status: 'skip', has: '连续第 4 天' })
|
||
|
||
/* ④ 状态查询失败 → warn(不是 fail) */
|
||
await runCase('── ④ ActID 过期(查询失败)→ warn ──', () => ({
|
||
status: 200,
|
||
text: JSON.stringify({ code: 0, req_0: { code: -1, data: { retCode: 100001 } }, req_1: { code: -1, data: {} } }),
|
||
}), { status: 'warn', has: 'ActID' })
|
||
|
||
/* ⑤ 领奖返回 100004(幂等)→ 算成功 */
|
||
await runCase('── ⑤ 领奖 retCode=100004(已领过)→ done ──', (j) => {
|
||
const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID)
|
||
const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method)
|
||
if (acts[0] === LOT) return { status: 200, text: STATE_OK(true, 3, {}) }
|
||
if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') {
|
||
return { status: 200, text: STATE_OK(true, 2, { '2': { ID: 'T2', State: 2, PrizeList: [{ Value: 20 }] } }) }
|
||
}
|
||
if (methods[0] === 'AwardPrize') return { status: 200, text: AWARD_DUP }
|
||
return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' }
|
||
}, { status: 'done' })
|
||
|
||
/* ⑥ 金币抽奖签到(独立 ActID) */
|
||
{
|
||
console.log('\n── ⑥ 金币抽奖签到(独立 ActID)──')
|
||
respond = (opts, body) => {
|
||
const j = JSON.parse(body)
|
||
const acts = [j.req_0, j.req_1].filter(Boolean).map(r => (r.param || {}).ActID)
|
||
const methods = [j.req_0, j.req_1].filter(Boolean).map(r => r.method)
|
||
if (acts[0] === COIN) {
|
||
if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') return { status: 200, text: STATE_OK(true, 5, {}) }
|
||
return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' }
|
||
}
|
||
// 抽奖活动:未签 → 签 → 有 task 可领
|
||
if (methods.join('+') === 'GetSignInSummary+GetSignInTaskList') {
|
||
return { status: 200, text: STATE_OK(false, 0, {}) }
|
||
}
|
||
if (methods[0] === 'SignIn') return { status: 200, text: SIGN_OK }
|
||
if (methods[0] === 'AwardPrize') return { status: 200, text: AWARD_OK }
|
||
return { status: 200, text: '{"code":0,"req_0":{"code":0,"data":{"retCode":0}}}' }
|
||
}
|
||
const c = mkClient()
|
||
await c.coinCenter()
|
||
const s = c.steps.find(x => x.name === '金币抽奖签到')
|
||
ok('金币抽奖签到有独立 step', !!s, JSON.stringify(c.steps.map(x => x.name)))
|
||
}
|
||
|
||
/* ⑦ QmDead 透传 */
|
||
{
|
||
console.log('\n── ⑦ 未登录(code 2000)→ 抛 QmDead ──')
|
||
respond = () => ({ status: 200, text: JSON.stringify({ code: 0, req_0: { code: 2000, data: {} } }) })
|
||
const c = mkClient()
|
||
let threw = null
|
||
try { await c.coinCenter() } catch (e) { threw = e }
|
||
ok('抛了 QmDead', threw && threw.constructor.name === 'QmDead', threw && threw.constructor.name)
|
||
}
|
||
|
||
require('https').request = origRequest
|
||
console.log(`\nRESULT: ${fail === 0 ? 'PASS' : `FAIL(${fail})`} (${pass} passed, ${fail} failed)`)
|
||
process.exit(fail === 0 ? 0 : 1)
|
||
})()
|