238 lines
9.5 KiB
JavaScript
238 lines
9.5 KiB
JavaScript
/**
|
||
* 微信订阅消息**模板登记表**的单元测试(`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')
|
||
/*
|
||
* 这四个键是微信侧真实分配的编号(模板原文:
|
||
* 歌曲名称:{{thing1.DATA}} 排期状态:{{phrase2.DATA}}
|
||
* 播放时间:{{time3.DATA}} 排期序号:{{character_string4.DATA}}
|
||
* )。这张模板没有日期关键词 —— 曾经在这里写过一个不存在的 date4,
|
||
* 微信就回 `47003 data.thing1.value is empty`。
|
||
*/
|
||
assert.deepEqual(selected.fields, {
|
||
thing1: 'songTitle',
|
||
phrase2: 'status',
|
||
time3: 'playTime',
|
||
character_string4: 'sequence'
|
||
})
|
||
|
||
// 每个字段名都要带类型后缀(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_string4', 'phrase2', 'thing1', 'time3']
|
||
)
|
||
|
||
const partial = findEmptyFields(conf, {
|
||
songTitle: '晴天',
|
||
playTime: ' ',
|
||
sequence: '1',
|
||
status: '已排期'
|
||
})
|
||
assert.deepEqual(partial, [{ field: 'time3', variable: 'playTime' }])
|
||
})
|
||
|
||
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), ['thing1', 'phrase2', 'time3', 'character_string4'])
|
||
assert.equal(data.thing1.value, '晴天')
|
||
assert.equal(data.character_string4.value, '3')
|
||
assert.equal(data.phrase2.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}`)
|
||
}
|
||
})
|