diff --git a/server/src/reminder.js b/server/src/reminder.js index c3f64f4..ef387f0 100644 --- a/server/src/reminder.js +++ b/server/src/reminder.js @@ -84,14 +84,16 @@ async function runForUser(openid, items, today = new Date()) { let halted = false let ok = 0, fail = 0, skipped = 0 - const logSkip = (item) => insertLog.run({ + // errorMsg 默认是「根本没发请求」的兜底文案;真正打了请求并被 43101 拒绝的那一条 + // 会传入微信返回的原始错误信息,两者都记为 skipped,但 error 字段要能区分开 + const logSkip = (item, errorMsg = 'quota_exhausted') => insertLog.run({ anniversaryId: item.anniv.id, personName: item.anniv.personName, typeName: getTypeName(item.anniv.type, item.anniv.customTypeName), daysUntil: item.daysUntil, sendDate: Date.now(), status: 'skipped', - error: 'quota_exhausted' + error: errorMsg }) for (const item of due) { @@ -129,8 +131,8 @@ async function runForUser(openid, items, today = new Date()) { quota.reset(openid) balance = 0 halted = true - logSkip(item); skipped++ - console.warn(`[reminder] ${openid} 额度已耗尽,本轮剩余全部跳过`) + logSkip(item, err.message); skipped++ + console.warn(`[reminder] ${openid} 额度已耗尽,本轮剩余全部跳过: ${err.message}`) } else { insertLog.run({ anniversaryId: anniv.id, personName: anniv.personName, typeName: null, diff --git a/server/test/reminder.test.js b/server/test/reminder.test.js index f089f44..8b99163 100644 --- a/server/test/reminder.test.js +++ b/server/test/reminder.test.js @@ -103,21 +103,29 @@ test('遇到 43101 立即归零并中止本用户剩余发送', async () => { assert.strictEqual(r.skipped, 3, '含失败那条在内全部计为 skipped') }) -test('非额度类错误只记 failed,不动余额', async () => { +test('非额度类错误只记 failed,不中止后续发送,也不动余额', async () => { resetLogs() quota.grant('uE', 4) + let called = 0 const restore = wx.sendSubscribeMessage wx.sendSubscribeMessage = async () => { + called++ const e = new Error('网络炸了') e.errcode = 40003 throw e } - const items = [makeAnniv('E1', 'uE', '甲', 'high', 0, 3)] + // 至少 2 条数据:如果有人误把 halted = true 加进非额度错误分支, + // 第二条就不会被尝试,called 会停在 1,这条断言就能抓到 + const items = [ + makeAnniv('E1', 'uE', '甲', 'high', 0, 3), + makeAnniv('E2', 'uE', '乙', 'high', 0, 3) + ] const r = await reminder.runForUser('uE', items, TODAY) wx.sendSubscribeMessage = restore - assert.strictEqual(r.fail, 1) + assert.strictEqual(called, 2, '第二条也应被尝试,证明非额度错误不会误中止后续发送') + assert.strictEqual(r.fail, 2) assert.strictEqual(quota.getBalance('uE'), 4, '配置/网络错误不该动余额') }) @@ -129,3 +137,42 @@ test('skipped 会写入 remind_logs 便于排查', async () => { assert.ok(row, '应写入 skipped 日志') assert.strictEqual(row.error, 'quota_exhausted') }) + +// runOnce 是从数据库读数据的(SELECT * FROM anniversaries WHERE remindEnabled = 1), +// 不能像 runForUser 那样直接传数组,必须真的往临时库插行 +function insertAnniv(id, openid, personName, offsetDays, remindDays) { + const d = inDays(offsetDays) + db.prepare(` + INSERT INTO anniversaries + (id, openid, personId, personName, type, isLunar, solarYear, solarMonth, solarDay, + importance, remindEnabled, remindDays, createTime, updateTime) + VALUES (?, ?, 'p', ?, 'birthday', 0, ?, ?, ?, 'high', 1, ?, 0, 0) + `).run(id, openid, personName, d.getFullYear(), d.getMonth() + 1, d.getDate(), remindDays) +} + +test('runOnce 按 openid 分组结算,各用户额度互不影响', async () => { + resetLogs() + // 会读到库里所有 remindEnabled=1 的行,先清空避免受其它用例残留数据干扰 + db.prepare('DELETE FROM anniversaries').run() + + quota.grant('uG', 5) // uG 有额度 + // uH 不 grant,余额保持 0 + + insertAnniv('G1', 'uG', '甲', 0, 3) // 今天到期 + insertAnniv('H1', 'uH', '乙', 0, 3) // 今天到期 + + let called = 0 + const restore = wx.sendSubscribeMessage + wx.sendSubscribeMessage = async () => { called++; return { errcode: 0 } } + + const r = await reminder.runOnce() + wx.sendSubscribeMessage = restore + + assert.strictEqual(r.total, 2) + assert.strictEqual(r.ok, 1, '有额度的 uG 应正常发出') + assert.strictEqual(r.skipped, 1, '没额度的 uH 应被跳过') + assert.strictEqual(r.fail, 0) + assert.strictEqual(called, 1, '没额度的用户不该真的发起请求,两用户不能互相借额度') + assert.strictEqual(quota.getBalance('uG'), 4, 'uG 消费后余额正确减少') + assert.strictEqual(quota.getBalance('uH'), 0, 'uH 的余额不受 uG 影响') +}) diff --git a/server/test/wx.test.js b/server/test/wx.test.js new file mode 100644 index 0000000..24b39a7 --- /dev/null +++ b/server/test/wx.test.js @@ -0,0 +1,35 @@ +const test = require('node:test') +const assert = require('node:assert') +const { useTempDb } = require('./helper') + +// wx.js 本身不连库,但按项目约定统一在 require 业务模块前处理好临时库, +// 避免以后 wx.js 引入了连库逻辑时这份测试悄悄读写到真实数据库。 +useTempDb() + +const axios = require('axios') +const wx = require('../src/wx') + +test('微信返回非 0 errcode 时,抛出的 Error 对象应带上原始 errcode', async () => { + const restoreGet = axios.get + const restorePost = axios.post + + // getAccessToken 走 axios.get,sendSubscribeMessage 走 axios.post,两个都要 mock + axios.get = async () => ({ data: { access_token: 'fake_token', expires_in: 7200 } }) + axios.post = async () => ({ data: { errcode: 43101, errmsg: 'user refuse to accept the msg' } }) + + await assert.rejects( + () => wx.sendSubscribeMessage({ + touser: 'oTest', + page: 'pages/index/index', + templateId: 'tplTest', + data: {} + }), + (err) => { + assert.strictEqual(err.errcode, 43101, 'Error 对象应带上微信返回的原始 errcode,而不是靠字符串匹配') + return true + } + ) + + axios.get = restoreGet + axios.post = restorePost +})