#!/usr/bin/env node /** * test_common_notify.js —— 验证 common.js 的 notify 实现 * * 覆盖: * 1. parseHookHeaders —— 多行 / 空行 / 无冒号 / 重复 key / 字面量 \n * 2. buildHookBody —— JSON 转义(引号/反斜杠/制表符)、urlencoded、纯文本、自定义模板 * 3. notify —— mock 服务测 200/204/201/401/403/400/429/0 * 4. notifyConfig —— env 读取与默认值 * 5. 无配置 / 双通道部分失败 * 6. Env 兼容性 —— 老接口(jd / xiequ 在用)没被破坏 * * 用法: node test_common_notify.js * 退出码 0 = 全过 */ 'use strict' const http = require('http') const m = require('./common.js') 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. parseHookHeaders ══════════ */ console.log('\n── 1. parseHookHeaders ──') eq('单行', JSON.stringify(m.parseHookHeaders('X-A: 1')), '{"X-A":"1"}') eq('两行', JSON.stringify(m.parseHookHeaders('X-A: 1\nX-B: 2')), '{"X-A":"1","X-B":"2"}') eq('空行忽略', JSON.stringify(m.parseHookHeaders('X-A: 1\n\n\nX-B: 2')), '{"X-A":"1","X-B":"2"}') eq('无冒号忽略', JSON.stringify(m.parseHookHeaders('garbage\nX-A: 1')), '{"X-A":"1"}') eq('值含冒号', JSON.stringify(m.parseHookHeaders('X-A: a:b:c')), '{"X-A":"a:b:c"}') eq('空串', JSON.stringify(m.parseHookHeaders('')), '{}') eq('值前后空格 trim', JSON.stringify(m.parseHookHeaders(' X-A : v ')), '{"X-A":"v"}') eq('字面量 \\n 不切开', Object.keys(m.parseHookHeaders('X-A: 1\\nX-B: 2')).length, 1) eq('重复 key 后者覆盖', m.parseHookHeaders('X-A: 1\nX-A: 2')['X-A'], '2') /* ══════════ 2. buildHookBody ══════════ */ console.log('\n── 2. buildHookBody ──') { const b = m.buildHookBody('标题', '第一行\n第二行', '', 'application/json') let j = null; try { j = JSON.parse(b) } catch { /* */ } ok('默认模板产出合法 JSON', j !== null, b) eq('title', j && j.title, '标题') eq('换行保留', j && j.content, '第一行\n第二行') } { const b = m.buildHookBody('T', '他说"你好"然后走了', 'title:$title\ncontent:$content', 'application/json') let j = null; try { j = JSON.parse(b) } catch { /* */ } ok('内容含双引号仍合法', j !== null, b) eq('引号原样保留', j && j.content, '他说"你好"然后走了') } { const b = m.buildHookBody('T', 'C:\\path\\to\tfile', '', 'application/json') let j = null; try { j = JSON.parse(b) } catch { /* */ } ok('含反斜杠/制表符仍合法', j !== null, b) eq('反斜杠保留', j && j.content, 'C:\\path\\to\tfile') } { const b = m.buildHookBody('T', 'a\nb', 'title:$title\ncontent:$content', 'application/json') let j = null; try { j = JSON.parse(b) } catch { /* */ } ok('多行模板 + 多行内容 仍合法', j !== null, b) eq('多行内容完整', j && j.content, 'a\nb') } { const b = m.buildHookBody('标题', 'a&b=c', '', 'application/x-www-form-urlencoded') eq('urlencoded 编码', b, 'title=%E6%A0%87%E9%A2%98&content=a%26b%3Dc') } { const b = m.buildHookBody('T', 'C', 'x=$title&y=$content', 'text/plain') eq('纯文本替换', b, 'x=T&y=C') } /* ══════════ 3. notifyConfig ══════════ */ console.log('\n── 3. notifyConfig ──') { const c = m.notifyConfig({}) eq('默认 method', c.webhookMethod, 'POST') eq('默认 content-type', c.webhookCt, 'application/json') eq('默认 pushplus 模板', c.pushPlusTemplate, 'txt') eq('空 URL', c.webhookUrl, '') eq('空 token', c.pushPlusToken, '') } { const c = m.notifyConfig({ WEBHOOK_URL: ' http://x/ ', WEBHOOK_METHOD: 'post', PUSH_PLUS_TOKEN: ' tk ', PUSH_PLUS_USER: 'grp', PUSH_PLUS_TEMPLATE: 'html', }) eq('URL 被 trim', c.webhookUrl, 'http://x/') eq('method 大写', c.webhookMethod, 'POST') eq('token 被 trim', c.pushPlusToken, 'tk') eq('topic', c.pushPlusTopic, 'grp') eq('template 可改', c.pushPlusTemplate, 'html') } /* ══════════ 4. notify(mock 服务) ══════════ */ console.log('\n── 4. notify(mock 服务)──') 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 平台正常返回(★ 旧 sendNotify 误判为失败的就是这个)' }, { 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 r = await m.notify('QQ音乐签到', '✅ 全部完成\n ✓ 任务中心签到 +15', { silent: true, env: { 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', }, }) 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.wantOk) { ok(' 请求发出去了', seen.length === 1, `seen=${seen.length}`) const s = seen[0] ok(' method POST', s.method === 'POST', s.method) ok(' header 带 token', s.headers['x-sh-hook-token'] === 'tok123', s.headers['x-sh-hook-token']) let j = null; try { j = JSON.parse(s.body) } catch { /* */ } ok(' body 合法 JSON', j !== null, s.body.slice(0, 120)) eq(' title', j && j.title, 'QQ音乐签到') ok(' content 含明细', !!(j && j.content && j.content.includes('任务中心签到')), j && j.content) } srv.close() } /* 连不上 */ console.log('\n── 4b. 连不上 ──') { const r = await m.notify('T', 'C', { silent: true, env: { WEBHOOK_URL: 'http://127.0.0.1:1/nope', WEBHOOK_METHOD: 'POST' }, }) const wh = r.channels.find(x => x.name === 'webhook') ok('连接失败记为 not ok', wh.ok === false, JSON.stringify(wh)) ok('detail 标 ERR', wh.detail.includes('ERR'), wh.detail) } /* ══════════ 5. 无配置 / 双通道 ══════════ */ console.log('\n── 5. 无配置 ──') { const lines = [] const orig = console.log console.log = (...a) => lines.push(a.join(' ')) const r = await m.notify('T', 'C', { env: {} }) console.log = orig ok('ok = false', r.ok === false) ok('提示「未配置任何通道」', lines.some(l => l.includes('未配置任何通道')), lines.join(' | ')) ok('channels 有两路', r.channels.length === 2, JSON.stringify(r.channels.map(c => c.name))) } console.log('\n── 5b. webhook 失败但 pushplus 成功 ──') { const { srv, port } = await startServer(500) // pushplus 那半会真发 —— 用假 token,pushplus 返回非 200 code,同样能验证「互不牵连」 const r = await m.notify('T', 'C', { silent: true, env: { WEBHOOK_URL: `http://127.0.0.1:${port}/x`, WEBHOOK_METHOD: 'POST', PUSH_PLUS_TOKEN: 'fake_token_for_shape' }, }) const wh = r.channels.find(x => x.name === 'webhook') const pp = r.channels.find(x => x.name === 'pushplus') ok('webhook 记为失败', wh.ok === false, JSON.stringify(wh)) ok('pushplus 那路也跑了', pp.detail !== 'PUSH_PLUS_TOKEN 未设', JSON.stringify(pp)) srv.close() } /* ══════════ 6. Env 兼容性 ══════════ */ console.log('\n── 6. Env 兼容性(jd / xiequ 在用)──') { ok('Env 是函数', typeof m.Env === 'function') const e = new m.Env('测试脚本') eq('name', e.name, '测试脚本') ok('isNode()', e.isNode() === true) ok('有 get/post', typeof e.get === 'function' && typeof e.post === 'function') ok('有 getdata/setdata', typeof e.getdata === 'function' && typeof e.setdata === 'function') ok('有 log/msg/wait/done', ['log', 'msg', 'wait', 'done'].every(k => typeof e[k] === 'function')) // 关键:node-fetch 移除后,require 整个文件不该抛错 ok('不再 require node-fetch', !require('fs').readFileSync('./common.js', 'utf8').includes("require('node-fetch')")) } console.log(`\nRESULT: ${fail === 0 ? 'PASS' : `FAIL(${fail})`} (${pass} passed, ${fail} failed)`) process.exit(fail === 0 ? 0 : 1) })()