186 lines
8.5 KiB
JavaScript
186 lines
8.5 KiB
JavaScript
/**
|
||
* 微信订阅消息字段裁剪规则的单元测试(`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', () => {
|
||
// 「已排期」3 个汉字本来就合规,绝不能补成 "已排期 "(U+3000 不是汉字,会 47003)
|
||
assert.equal(clampField('phrase5', '已排期'), '已排期')
|
||
assert.equal(clampField('phrase5', '已排期').includes(' '), false)
|
||
|
||
// 刚好 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(那里有更完整的测试)。
|
||
* 这里的重点不是"是不是这几个编号",而是**每个键的后缀(类型)和值匹配**:
|
||
* thing1 / phrase2 / time3 / character_string4
|
||
* 真实模板内容(微信 /wxaapi/newtmpl/gettemplate 读出来):
|
||
* 歌曲名称:{{thing1.DATA}} 排期状态:{{phrase2.DATA}}
|
||
* 播放时间:{{time3.DATA}} 排期序号:{{character_string4.DATA}}
|
||
* ⚠️ 这张模板**没有日期字段**,日期含在 time3 的 'YYYY-MM-DD HH:mm' 里。
|
||
*/
|
||
const fields = {
|
||
thing1: 'songTitle',
|
||
phrase2: 'status',
|
||
time3: 'playTime',
|
||
character_string4: 'sequence'
|
||
}
|
||
const vars = {
|
||
songTitle: '晴天',
|
||
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.thing1, '晴天')
|
||
assert.match(out.time3, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
|
||
assert.equal(out.character_string4, '1')
|
||
assert.equal(out.phrase2, '已排期') // 3 个汉字本来就合规,不补全角空格
|
||
// 不能有任何字段是空值:空值同样会被微信拒
|
||
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 ['thing1', 'phrase2', 'time3', 'character_string4']) {
|
||
assert.match(selected, new RegExp(f + ":\\s*'"), f + ' 的映射丢了')
|
||
}
|
||
// 这张模板没有日期字段,配置里不该出现 date 型键
|
||
assert.doesNotMatch(selected, /date\d+:/, 'SONG_SELECTED 的模板里没有日期关键词')
|
||
})
|
||
|
||
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())}`
|
||
)
|
||
})
|