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

233 lines
9.2 KiB
JavaScript
Raw 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.
/**
* 微信订阅消息**模板登记表**的单元测试(`npm test` 跑)。
*
* 为什么值得测:模板 ID 或字段名写错,线上表现只有一句 `47003 argument invalid`,
* 整条通知发不出去且看不出哪里错。这里把「认哪些模板、字段怎么映射、环境变量覆盖
* 怎么合并」全部钉死,改坏了在 CI 就红。
*
* 可以直接 `import` 这个 .ts 文件,是因为 Node 22+ 会剥离类型标注;本文件刻意不依赖
* Nuxt 别名(`~~/server/...`),所以纯 `node --test` 就能跑。
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
BUILTIN_TEMPLATES,
TEMPLATES_ENV_KEY,
buildTemplateData,
explainWechatErrcode,
findEmptyDataFields,
findEmptyFields,
listTemplateIds,
parseSubscribeTemplates
} from '../server/utils/wechatSubscribeTemplates.ts'
test('内置模板:两个模板的 ID 与字段映射和公众平台一致', () => {
assert.deepEqual(Object.keys(BUILTIN_TEMPLATES).sort(), ['SONG_REJECTED', 'SONG_SELECTED'])
const rejected = BUILTIN_TEMPLATES.SONG_REJECTED
assert.equal(rejected.id, 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c')
assert.deepEqual(rejected.fields, { thing6: 'songTitle', thing4: 'reason' })
const selected = BUILTIN_TEMPLATES.SONG_SELECTED
assert.equal(selected.id, 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk')
assert.deepEqual(selected.fields, {
thing2: 'songTitle',
date4: 'playDate',
time1: 'playTime',
character_string3: 'sequence',
phrase5: 'status'
})
// 每个字段名都要带类型后缀(thing2/date4/…),否则 clampField 认不出类型、不会裁剪
for (const kind of Object.keys(BUILTIN_TEMPLATES)) {
for (const field of Object.keys(BUILTIN_TEMPLATES[kind].fields)) {
assert.match(
field,
/^(thing|phrase|time|date|character_string|number|letter|symbol|amount)\d+$/,
`${kind}.${field} 字段名没有类型后缀`
)
assert.ok(BUILTIN_TEMPLATES[kind].title, `${kind} 缺 title(体检报告里要显示)`)
}
}
assert.equal(listTemplateIds(BUILTIN_TEMPLATES).length, 2)
assert.equal(TEMPLATES_ENV_KEY, 'WECHAT_MINI_SUBSCRIBE_TEMPLATES')
})
test('没有环境变量时:原样用内置模板,且不产生任何告警项', () => {
const r = parseSubscribeTemplates(undefined)
assert.equal(r.envJsonValid, true)
assert.equal(r.envRaw, null)
assert.deepEqual(r.overriddenKeys, [])
assert.deepEqual(r.unknownKeys, [])
assert.deepEqual(r.newAdditionsKeys, [])
assert.deepEqual(listTemplateIds(r.templates), [
BUILTIN_TEMPLATES.SONG_REJECTED.id,
BUILTIN_TEMPLATES.SONG_SELECTED.id
])
})
test('环境变量坏 JSON:只报告不抛异常,仍然能用内置模板发消息', () => {
// 线上真的出现过:Gitea secret 里填了一个光秃秃的模板 ID
const r = parseSubscribeTemplates('erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c')
assert.equal(r.envJsonValid, false)
assert.equal(r.templates.SONG_REJECTED.id, BUILTIN_TEMPLATES.SONG_REJECTED.id)
assert.equal(listTemplateIds(r.templates).length, 2)
assert.ok(String(r.envRaw).includes('erAK99'))
})
test('只给 id:沿用内置的字段映射(避免退化成微信必拒的空映射)', () => {
const r = parseSubscribeTemplates(JSON.stringify({ SONG_SELECTED: { id: 'brand-new-id' } }))
assert.equal(r.envJsonValid, true)
assert.deepEqual(r.overriddenKeys, ['SONG_SELECTED'])
assert.deepEqual(r.inheritedFieldsKeys, ['SONG_SELECTED'])
assert.deepEqual(r.unknownKeys, [])
assert.equal(r.templates.SONG_SELECTED.id, 'brand-new-id')
assert.deepEqual(r.templates.SONG_SELECTED.fields, BUILTIN_TEMPLATES.SONG_SELECTED.fields)
// 另一张模板不受影响
assert.equal(r.templates.SONG_REJECTED.id, BUILTIN_TEMPLATES.SONG_REJECTED.id)
})
test('给 fields:换字段映射,title 沿用内置的', () => {
const r = parseSubscribeTemplates(
JSON.stringify({ SONG_REJECTED: { id: 'x', fields: { thing1: 'reason' } } })
)
assert.deepEqual(r.templates.SONG_REJECTED.fields, { thing1: 'reason' })
assert.equal(r.templates.SONG_REJECTED.title, BUILTIN_TEMPLATES.SONG_REJECTED.title)
assert.deepEqual(r.inheritedFieldsKeys, [])
})
test('新增一个内置表里没有的模板 key 是合法的(只要给全 id/fields)', () => {
const r = parseSubscribeTemplates(
JSON.stringify({
SONG_PLAYED: { id: 'played-id', title: '已播放', fields: { thing1: 'songTitle' } }
})
)
assert.deepEqual(r.newAdditionsKeys, ['SONG_PLAYED'])
assert.deepEqual(r.unknownKeys, [])
assert.equal(r.templates.SONG_PLAYED.id, 'played-id')
assert.equal(listTemplateIds(r.templates).length, 3)
})
test('用不了的项:缺 id / 类型不对 → 记进 unknownKeys 并忽略,其余配置照常生效', () => {
const r = parseSubscribeTemplates(
JSON.stringify({
NO_ID: { fields: { thing1: 'songTitle' } },
WRONG_TYPE: 'not-an-object',
NULL_ITEM: null,
SONG_SELECTED: { id: 'kept-id' }
})
)
assert.deepEqual(r.unknownKeys.sort(), ['NO_ID', 'NULL_ITEM', 'WRONG_TYPE'])
assert.deepEqual(r.overriddenKeys, ['SONG_SELECTED'])
// 坏项没有污染模板表:仍然只有两个内置模板,且好的那项生效了
assert.equal(listTemplateIds(r.templates).length, 2)
assert.equal(r.templates.SONG_SELECTED.id, 'kept-id')
})
test('环境变量是数组或标量时也算坏 JSON(不会把模板表搞乱)', () => {
for (const bad of ['[]', '123', '"abc"', 'null']) {
const r = parseSubscribeTemplates(bad)
assert.equal(r.envJsonValid, false, bad + ' 应该被判定为不可用')
assert.equal(listTemplateIds(r.templates).length, 2)
}
})
test('findEmptyFields:变量没给或全是空白 → 点名到字段和变量', () => {
const conf = BUILTIN_TEMPLATES.SONG_SELECTED
assert.deepEqual(
findEmptyFields(conf, {})
.map((f) => f.field)
.sort(),
['character_string3', 'date4', 'phrase5', 'thing2', 'time1']
)
const partial = findEmptyFields(conf, {
songTitle: '晴天',
playDate: ' ',
playTime: '2026-02-14 12:30',
sequence: '1',
status: '已排期'
})
assert.deepEqual(partial, [{ field: 'date4', variable: 'playDate' }])
})
test('findEmptyDataFields:拼完 data 再兜一层空值检查', () => {
assert.deepEqual(findEmptyDataFields({ a: { value: ' ' }, b: { value: 'ok' } }), [
{ field: 'a', value: ' ' }
])
assert.deepEqual(findEmptyDataFields({ b: { value: 'ok' } }), [])
})
test('buildTemplateData:按映射取值,并交给 clamp 处理(time 有兜底值)', async () => {
const { clampField } = await import('../server/utils/wechatSubscribeFields.ts')
const conf = BUILTIN_TEMPLATES.SONG_SELECTED
const data = buildTemplateData(
conf,
{
songTitle: '晴天',
playDate: '2026-02-14',
playTime: '2026-02-14 12:30',
sequence: 3,
status: '已排期'
},
clampField
)
assert.deepEqual(Object.keys(data), ['thing2', 'date4', 'time1', 'character_string3', 'phrase5'])
assert.equal(data.thing2.value, '晴天')
assert.equal(data.character_string3.value, '3')
assert.equal(data.phrase5.value, '已排期')
assert.deepEqual(findEmptyDataFields(data), [], '有完整变量时不该出现空字段')
})
test('explainWechatErrcode:把会遇到的错误码翻成人话', () => {
assert.match(explainWechatErrcode(0), /成功/)
assert.match(explainWechatErrcode(43101), /没有可用授权/)
assert.match(explainWechatErrcode(47003), /字段/)
assert.match(explainWechatErrcode(40003), /openid/)
assert.match(explainWechatErrcode(40037), /模板 ID/)
assert.match(explainWechatErrcode(40001), /access_token/)
assert.match(explainWechatErrcode(45009), /上限/)
assert.match(explainWechatErrcode(undefined), /未知/)
assert.match(explainWechatErrcode(99999), /99999/)
})
test('服务层不再自己写死模板(模板定义只应有一处)', async () => {
/*
* 以前 `DEFAULT_TEMPLATES` 直接写在 wechatSubscribeService.ts 里,
* 于是「模板 ID」和「字段映射」在源码里有两份容易走样的副本。
* 现在唯一来源是 server/utils/wechatSubscribeTemplates.ts,这里防止回退。
*/
const { readFileSync } = await import('node:fs')
const { fileURLToPath } = await import('node:url')
const src = readFileSync(
fileURLToPath(new URL('../server/services/wechatSubscribeService.ts', import.meta.url)),
'utf8'
)
assert.ok(!/DEFAULT_TEMPLATES/.test(src), '服务层不应再定义 DEFAULT_TEMPLATES')
assert.ok(
!/erAK99M38ZeD/.test(src),
'模板 ID 不应硬编码在服务层(应来自 wechatSubscribeTemplates.ts)'
)
assert.ok(
!/oLTAefO92xYYlGRY/.test(src),
'模板 ID 不应硬编码在服务层(应来自 wechatSubscribeTemplates.ts)'
)
// 旧调用点的函数名必须保留,否则 notificationService 会编译失败
for (const fn of [
'sendWechatSubscribeToUser',
'sendWechatSubscribe',
'buildTemplateData',
'getSubscribeTemplates',
'getSubscribeTemplateIds',
'isWechatSubscribeEnabled',
'getMiniAccessToken',
'getWechatOpenid',
'sendRawSubscribe',
'inspectWechatSubscribe',
'grantWechatSubscribe'
]) {
assert.match(src, new RegExp('export (async )?function ' + fn + '\\b'), `缺少导出 ${fn}`)
}
})