Files
ql-scripts/test_common_notify.js
zhuhjay 23d35cd942 refactor(common): 通知实现搬进 common.js,所有脚本一行 require 即可用
上一版把 notify 内联在 qqmusic 脚本里,只解决了单个脚本。这次搬到仓库根的
common.js,后续任何脚本 require('../common') 就能发通知。

common.js 新增导出:
  notify(title, content, opts)   webhook + pushplus 双通道,任一成功即 ok,永不抛错
  notifyConfig(env)              读 env 并给默认值(可注入替身,方便测试)
  httpPost(url, headers, body)   通用 POST,http/https 自适应
  parseHookHeaders(raw)          一行一个 Key: Value
  buildHookBody(t, c, tpl, ct)   拼 body,JSON 不合法就重建
Env 保持原样未动(jd 与 xiequ 在用)。

顺带删掉第 1 行的 require('node-fetch') —— 它是死代码:Env 内部所有 HTTP 走的是
require('got') + require('tough-cookie'),fetch 只以 $task.fetch(...) 的形式出现在
QuantumultX 分支(宿主 API,不是 node-fetch)。留着它反而会在容器缺 node-fetch 时
让整个文件 require 抛错,连带 jd / xiequ 一起挂。

qqmusic 脚本从 125 行内联实现缩成一行 require('../common'),删掉重复的
httpPost / parseHookHeaders / buildHookBody / notify 四份副本。

测试从 qqmusic/test_notify.js 挪到仓库根 test_common_notify.js,断言从 38 加到 69:
  - parseHookHeaders 九种输入(新增重复 key 覆盖)
  - buildHookBody 五种(新增自定义模板 + urlencoded + text/plain)
  - notifyConfig 十项默认值与 trim
  - notify 八种状态(200/204/201/401/403/400/429/连不上)
  - 无配置、双通道互不牵连
  - Env 兼容性(确认 node-fetch 已移除、老接口都在)

端到端已验证:common.js 的 notify 发出的 content 与原始 desp 逐字相同(10 行完整),
title 正确,header 带 token,HTTP 204 判为成功。

README(根 + qqmusic)新增 common.js 公共库章节,列出 notify 支持的八个环境变量
与「为什么不能放 sendNotify.js」的 deps 覆盖原因。
2026-11-07 02:10:23 +08:00

211 lines
9.3 KiB
JavaScript
Raw Permalink 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.

#!/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)
})()