/** * 微信订阅消息字段裁剪规则的单元测试(`npm test` 跑)。 * * 为什么值得单独测:微信对订阅消息的字段校验是**整条消息级别**的 —— 一个字段超长、 * 格式不对或为空,整条消息就被拒(47003),而且报错里点名的字段不一定是真凶。 * 这些规则一旦写错,线上表现就是「通知发不出去」,却看不出哪里错。 */ import { test } from 'node:test' import assert from 'node:assert/strict' import { beijingNow, buildWechatTimeValue, clampField } from '../server/utils/wechatSubscribeFields.ts' const len = (s) => Array.from(s).length test('thing 型:20 字以内原样,超了截 19 字 + 省略号(正好 20)', () => { assert.equal(clampField('thing2', '晴天'), '晴天') const twenty = '一二三四五六七八九十一二三四五六七八九十' assert.equal(clampField('thing2', twenty), twenty) assert.equal(len(clampField('thing2', twenty)), 20) const long = twenty + '崩坏的世界' const clamped = clampField('thing2', long) assert.equal(len(clamped), 20) assert.ok(clamped.endsWith('…')) assert.ok(long.startsWith(clamped.slice(0, 19))) }) test('phrase 型:5 个以内纯汉字——“已排期”会被补成全角 5 字,不是原样发出去', () => { const padded = clampField('phrase5', '已排期') assert.equal(len(padded), 5) assert.ok(padded.startsWith('已排期')) // 刚好 5 个汉字原样保留 assert.equal(clampField('phrase5', '已排期播放'), '已排期播放') // 超了截到 5 个 assert.equal(len(clampField('phrase5', '已排期播放中啦啦啦')), 5) }) test('time 型:必须是 YYYY-MM-DD HH:mm 且不超过 24 字', () => { const w = (v) => ({ value: clampField('time1', v) }) assert.deepEqual(w(buildWechatTimeValue(new Date('2026-02-14T00:00:00Z'), '12:30')), { value: '2026-02-14 12:30' }) assert.match(clampField('time1', '2026-02-14 12:30'), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/) assert.equal(len(clampField('time1', '2026-02-14 12:30')), 16) }) test('buildWechatTimeValue:取时段开始时间,兼容各种写法,兜底当天 00:00', () => { const day = new Date('2026-02-14T00:00:00Z') assert.equal(buildWechatTimeValue(day, '12:30-13:00'), '2026-02-14 12:30') assert.equal(buildWechatTimeValue(day, '12:30~13:00'), '2026-02-14 12:30') assert.equal(buildWechatTimeValue(day, '12:30'), '2026-02-14 12:30') assert.equal(buildWechatTimeValue(day, '9:05'), '2026-02-14 09:05') assert.equal(buildWechatTimeValue(day, '13:00'), '2026-02-14 13:00') // time 型字段不能为空,解析不出来也要给个合法值 assert.equal(buildWechatTimeValue(day, ''), '2026-02-14 00:00') assert.equal(buildWechatTimeValue(day, null), '2026-02-14 00:00') assert.equal(buildWechatTimeValue(day, '午间'), '2026-02-14 00:00') }) test('buildWechatTimeValue:以北京时间取日期(UTC 16:00 已是次日)', () => { assert.equal(buildWechatTimeValue(new Date('2026-02-13T16:00:00Z'), '12:30'), '2026-02-14 12:30') assert.equal(buildWechatTimeValue(new Date('2026-02-13T15:59:00Z'), '12:30'), '2026-02-13 12:30') }) test('character_string 型:序号原样发(不补零、不加字)', () => { assert.equal(clampField('character_string3', 1), '1') assert.equal(clampField('character_string3', '12'), '12') assert.equal(clampField('character_string3', 0), '0') }) test('date 型:YYYY-MM-DD 原样,脏值截到 10 字', () => { assert.equal(clampField('date4', '2026-02-14'), '2026-02-14') assert.equal(clampField('date4', '2026-02-14 12:30:00'), '2026-02-14') }) test('空白与换行会被压成一行(微信不接受换行)', () => { assert.equal(clampField('thing1', ' 晴天\n 周杰伦 '), '晴天 周杰伦') assert.equal(clampField('thing1', null), '') assert.equal(clampField('thing1', undefined), '') }) test('SONG_SELECTED 的内置字段映射:每个字段名后缀都要跟发出去的值类型匹配', () => { // 字段映射的唯一来源是 server/utils/wechatSubscribeTemplates.ts(那里有更完整的测试) const fields = { thing2: 'songTitle', date4: 'playDate', time1: 'playTime', character_string3: 'sequence', phrase5: 'status' } const vars = { songTitle: '晴天', playDate: '2026-02-14', playTime: '2026-02-14 12:30', sequence: '1', status: '已排期' } const out = {} for (const key of Object.keys(fields)) out[key] = clampField(key, vars[fields[key]]) assert.equal(out.thing2, '晴天') assert.equal(out.date4, '2026-02-14') assert.match(out.time1, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/) assert.equal(out.character_string3, '1') assert.equal(len(out.phrase5), 5) // “已排期” 3 字 → 补齐到 5 字,避开 47003 // 不能有任何字段是空值:空值同样会被微信拒 for (const key of Object.keys(out)) assert.ok(out[key].length > 0, key + ' 不能为空') }) test('SONG_REJECTED 的内置字段映射:长驳回原因必须裁进 thing 的 20 字内', () => { /* * 模板字段(站长在公众平台选好的): * 审核内容 = thing6 → songTitle * 驳回原因 = thing4 → reason * 驳回原因是管理员手填的自由文本,经常远超 20 字;thing 超长是整条消息 47003 的头号原因, * 所以这里专门盯住「长原因不会被原样发出去」。 */ const fields = { thing6: 'songTitle', thing4: 'reason' } const vars = { songTitle: '晴天 - 周杰伦', reason: '已有同名单曲,暂不重复收录;另外上传的音频码率过低,建议重新投稿 320kbps 版本后再试一次' } const out = {} for (const key of Object.keys(fields)) out[key] = clampField(key, vars[fields[key]]) assert.equal(out.thing6, '晴天 - 周杰伦') assert.ok(len(out.thing4) <= 20) assert.ok(out.thing4.endsWith('…')) for (const key of Object.keys(out)) assert.ok(out[key].length > 0, key + ' 不能为空') }) test('内置模板与线上代码一致(直接读源码,防止映射被改坏没人发现)', async () => { /* * 模板定义现在的唯一来源是 server/utils/wechatSubscribeTemplates.ts(纯 .ts,无 Nuxt 别名), * 但这里仍然用「读源码 + 正则」而不是 import:这个测试文件只依赖 wechatSubscribeFields.ts, * 不想因为模板文件偶尔引入别名而整个挂掉。详细的解析行为在 * test/wechatSubscribeTemplates.test.mjs 里测。 */ const { readFileSync } = await import('node:fs') const { fileURLToPath } = await import('node:url') const src = readFileSync( fileURLToPath(new URL('../server/utils/wechatSubscribeTemplates.ts', import.meta.url)), 'utf8' ) const block = (kind) => { const m = src.match(new RegExp(kind + ':\\s*\\{([\\s\\S]*?)\\n \\}')) assert.ok(m, '源码里找不到 ' + kind + ' 的内置模板') return m[1] } const rejected = block('SONG_REJECTED') assert.match(rejected, /erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c/) assert.match(rejected, /thing6:\s*'songTitle'/) assert.match(rejected, /thing4:\s*'reason'/) const selected = block('SONG_SELECTED') assert.match(selected, /oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk/) for (const f of ['thing2', 'date4', 'time1', 'character_string3', 'phrase5']) { assert.match(selected, new RegExp(f + ":\\s*'"), f + ' 的映射丢了') } }) test('beijingNow 是合法的 time 型值,且与北京时间一致', () => { assert.match(beijingNow(), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/) // beijingNow() 内部按 UTC+8 取值,用同一算法反推对比 const t = new Date(Date.now() + 8 * 3600 * 1000) const p = (n) => (n < 10 ? '0' + n : String(n)) assert.equal( beijingNow(), `${t.getUTCFullYear()}-${p(t.getUTCMonth() + 1)}-${p(t.getUTCDate())} ${p(t.getUTCHours())}:${p(t.getUTCMinutes())}` ) })