/** * 微信订阅消息**模板登记表**的单元测试(`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(Array.from(data.phrase5.value).length, 5) 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}`) } })