Files
BYW-VoiceHub/test/wechatSubscribeFields.test.mjs
T

186 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* 微信订阅消息字段裁剪规则的单元测试(`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())}`
)
})