#!/usr/bin/env node /** * test_notify.js —— 验证脚本自带的 notify() 实现(不依赖青龙 sendNotify) * * 覆盖: * 1. parseHookHeaders —— 多行 / 空行 / 无冒号 / 重复 key * 2. buildHookBody —— JSON 内容含引号换行时的转义、urlencoded、纯文本 * 3. notify —— mock 服务测 200/204/401/403/400/429 六种状态码 * 4. 无配置 —— 两路都没配时的行为 * * 用法: node qqmusic/test_notify.js * 退出码 0 = 全过 */ 'use strict' const http = require('http') let pass = 0, fail = 0 const ok = (name, cond, extra = '') => { if (cond) { pass++; console.log(` ✓ ${name}`) } else { fail++; console.log(` ✗ ${name}${extra ? ' — ' + extra : ''}`) } } const eq = (name, got, want) => ok(name, got === want, `got=${JSON.stringify(got)} want=${JSON.stringify(want)}`) /* ══════════ 1-2. 纯函数(不需要 env) ══════════ */ const { buildHookBody, parseHookHeaders } = require('./scrip_qqmusic_checkin.js') console.log('\n── 1. parseHookHeaders ──') eq('单行', JSON.stringify(parseHookHeaders('X-A: 1')), '{"X-A":"1"}') eq('两行', JSON.stringify(parseHookHeaders('X-A: 1\nX-B: 2')), '{"X-A":"1","X-B":"2"}') eq('空行忽略', JSON.stringify(parseHookHeaders('X-A: 1\n\n\nX-B: 2')), '{"X-A":"1","X-B":"2"}') eq('无冒号忽略', JSON.stringify(parseHookHeaders('garbage\nX-A: 1')), '{"X-A":"1"}') eq('值含冒号', JSON.stringify(parseHookHeaders('X-A: a:b:c')), '{"X-A":"a:b:c"}') eq('空串', JSON.stringify(parseHookHeaders('')), '{}') eq('值前后空格被 trim', JSON.stringify(parseHookHeaders(' X-A : v ')), '{"X-A":"v"}') // 字面量 \n(两个字符)不该被切开 —— 这正是青龙 sendNotify 的坑 eq('字面量 \\n 不切开', Object.keys(parseHookHeaders('X-A: 1\\nX-B: 2')).length, 1) console.log('\n── 2. buildHookBody(默认 JSON 模板)──') { const b = buildHookBody('标题', '第一行\n第二行') let j = null try { j = JSON.parse(b) } catch { /* 下面判 */ } ok('产出是合法 JSON', j !== null, b) eq('title 正确', j && j.title, '标题') eq('content 换行保留', j && j.content, '第一行\n第二行') } { // 内容里带引号 —— 占位替换后必须仍然合法 const b = buildHookBody('T', '他说"你好"然后走了') let j = null try { j = JSON.parse(b) } catch { /* */ } ok('内容含双引号仍是合法 JSON', j !== null, b) eq('引号原样保留', j && j.content, '他说"你好"然后走了') } { // 内容里带反斜杠与制表符 const b = buildHookBody('T', 'C:\\path\\to\tfile') let j = null try { j = JSON.parse(b) } catch { /* */ } ok('含反斜杠/制表符仍合法', j !== null, b) eq('反斜杠保留', j && j.content, 'C:\\path\\to\tfile') } /* ══════════ 3. notify —— mock 服务 ══════════ */ console.log('\n── 3. notify(mock 服务,六种状态码)──') // 注意:WEBHOOK_URL 等是模块加载时读的常量,所以要改 env 后重新 require const MOD = require.resolve('./scrip_qqmusic_checkin.js') function reloadWith(env) { for (const k of ['WEBHOOK_URL', 'WEBHOOK_BODY', 'WEBHOOK_METHOD', 'WEBHOOK_CONTENT_TYPE', 'WEBHOOK_HEADERS', 'PUSH_PLUS_TOKEN', 'PUSH_PLUS_USER', 'PUSH_PLUS_TEMPLATE']) delete process.env[k] Object.assign(process.env, env) delete require.cache[MOD] return require(MOD) } function startServer(status, body = '') { return new Promise((resolve) => { const seen = [] const srv = http.createServer((req, res) => { const chunks = [] req.on('data', c => chunks.push(c)) req.on('end', () => { seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('utf8') }) res.writeHead(status, { 'Content-Type': 'application/json' }) res.end(body) }) }) srv.listen(0, '127.0.0.1', () => resolve({ srv, port: srv.address().port, seen })) }) } ;(async () => { const cases = [ { status: 204, wantOk: true, label: '204 平台正常返回(★ 旧版误判为失败的就是这个)' }, { status: 200, wantOk: true, label: '200 普通成功' }, { status: 201, wantOk: true, label: '201 也是 2xx' }, { status: 401, wantOk: false, label: '401 token 错' }, { status: 403, wantOk: false, label: '403 IP 不在白名单' }, { status: 400, wantOk: false, label: '400 body 畸形' }, { status: 429, wantOk: false, label: '429 超频' }, ] for (const c of cases) { const { srv, port, seen } = await startServer(c.status) const m = reloadWith({ WEBHOOK_URL: `http://127.0.0.1:${port}/api/hooks/ql`, WEBHOOK_METHOD: 'POST', WEBHOOK_CONTENT_TYPE: 'application/json', WEBHOOK_BODY: 'title:$title\ncontent:$content', WEBHOOK_HEADERS: 'X-Sh-Hook-Token: tok123\nX-Sh-Hook-Id: qinglong', }) // 静音 notify 内部的 log const origLog = console.log console.log = () => {} const r = await m.notify('QQ音乐签到', '✅ 全部完成\n ✓ 任务中心签到 +15') console.log = origLog const wh = r.channels.find(x => x.name === 'webhook') ok(`${c.status} → ${c.wantOk ? 'ok' : 'not ok'}(${c.label})`, wh.ok === c.wantOk, JSON.stringify(wh)) if (c.status === 204 || c.status === 200) { ok(` 请求确实发出去了`, seen.length === 1, `seen=${seen.length}`) const sent = seen[0] ok(` method = POST`, sent.method === 'POST', sent.method) ok(` header 带 token`, sent.headers['x-sh-hook-token'] === 'tok123', sent.headers['x-sh-hook-token']) let j = null try { j = JSON.parse(sent.body) } catch { /* */ } ok(` body 是合法 JSON`, j !== null, sent.body.slice(0, 120)) eq(` title`, j && j.title, 'QQ音乐签到') ok(` content 含明细`, !!(j && j.content && j.content.includes('任务中心签到')), j && j.content) } srv.close() } /* 4. 无配置 */ console.log('\n── 4. 两路都没配 ──') { const m = reloadWith({}) const origLog = console.log const lines = [] console.log = (...a) => lines.push(a.join(' ')) const r = await m.notify('T', 'C') console.log = origLog ok('ok = false', r.ok === false) ok('提示「未配置任何通道」', lines.some(l => l.includes('未配置任何通道')), lines.join(' | ')) } /* 5. 只有 webhook 挂、pushplus 通 —— 不应因一路失败而整体失败 */ console.log('\n── 5. 双通道:webhook 失败但 pushplus 成功 ──') { const { srv, port } = await startServer(500) const m = reloadWith({ WEBHOOK_URL: `http://127.0.0.1:${port}/x`, WEBHOOK_METHOD: 'POST', PUSH_PLUS_TOKEN: 'faketoken_for_shape_only', }) // pushplus 会真的发出去 —— 这里只校验 webhook 那一半,避免污染真实账号 const origLog = console.log console.log = () => {} const r = await m.notify('T', 'C') console.log = origLog const wh = r.channels.find(x => x.name === 'webhook') ok('webhook 记为失败', wh.ok === false, JSON.stringify(wh)) ok('channels 里有两路', r.channels.length === 2, JSON.stringify(r.channels.map(c => c.name))) srv.close() } console.log(`\nRESULT: ${fail === 0 ? 'PASS' : `FAIL(${fail})`} (${pass} passed, ${fail} failed)`) process.exit(fail === 0 ? 0 : 1) })()