const test = require('node:test') const assert = require('node:assert') const { useTempDb } = require('./helper') useTempDb() const db = require('../src/db') const wx = require('../src/wx') const quota = require('../src/quota') const reminder = require('../src/reminder') // 用「今天」为基准造数据,保证 daysUntil 落在期望值上 const TODAY = new Date() const inDays = (n) => new Date(TODAY.getTime() + n * 86400000) function makeAnniv(id, openid, personName, importance, offsetDays, remindDays) { const d = inDays(offsetDays) return { id, openid, personId: 'p', personName, type: 'birthday', isLunar: 0, solarYear: d.getFullYear(), solarMonth: d.getMonth() + 1, solarDay: d.getDate(), importance, remindEnabled: 1, remindDays } } function resetLogs() { db.prepare('DELETE FROM remind_logs').run() } test('额度够时全部发出', async () => { resetLogs() quota.grant('uA', 5) const sent = [] const restore = wx.sendSubscribeMessage wx.sendSubscribeMessage = async (p) => { sent.push(p.data.name1.value); return { errcode: 0 } } const items = [ makeAnniv('A1', 'uA', '甲', 'low', 0, 3), makeAnniv('A2', 'uA', '乙', 'high', 0, 3) ] const r = await reminder.runForUser('uA', items, TODAY) wx.sendSubscribeMessage = restore assert.strictEqual(r.ok, 2) assert.strictEqual(r.skipped, 0) assert.strictEqual(quota.getBalance('uA'), 3) }) test('额度不足时当天优先、同级按重要程度', async () => { resetLogs() quota.grant('uB', 1) const sent = [] const restore = wx.sendSubscribeMessage wx.sendSubscribeMessage = async (p) => { sent.push(p.data.name1.value); return { errcode: 0 } } const items = [ makeAnniv('B1', 'uB', '提前的', 'high', 3, 3), // 提前事件(daysUntil=3=remindDays) makeAnniv('B2', 'uB', '当天低', 'low', 0, 3), // 当天事件 makeAnniv('B3', 'uB', '当天高', 'high', 0, 3) // 当天事件,重要程度更高 ] const r = await reminder.runForUser('uB', items, TODAY) wx.sendSubscribeMessage = restore assert.strictEqual(r.ok, 1) assert.strictEqual(r.skipped, 2) assert.deepStrictEqual(sent, ['当天高'], '应当只发出当天且最重要的那条') }) test('余额为 0 时不发任何请求', async () => { resetLogs() let called = 0 const restore = wx.sendSubscribeMessage wx.sendSubscribeMessage = async () => { called++; return { errcode: 0 } } const items = [makeAnniv('C1', 'uC', '丙', 'high', 0, 3)] const r = await reminder.runForUser('uC', items, TODAY) wx.sendSubscribeMessage = restore assert.strictEqual(called, 0, '没额度就不该打请求') assert.strictEqual(r.skipped, 1) }) test('遇到 43101 立即归零并中止本用户剩余发送', async () => { resetLogs() quota.grant('uD', 9) let called = 0 const WX_ERROR_MESSAGE = '发送订阅消息失败' const restore = wx.sendSubscribeMessage wx.sendSubscribeMessage = async () => { called++ const e = new Error(WX_ERROR_MESSAGE) e.errcode = 43101 throw e } const items = [ makeAnniv('D1', 'uD', '甲', 'high', 0, 3), makeAnniv('D2', 'uD', '乙', 'high', 0, 3), makeAnniv('D3', 'uD', '丙', 'high', 0, 3) ] const r = await reminder.runForUser('uD', items, TODAY) wx.sendSubscribeMessage = restore assert.strictEqual(called, 1, '失败一次就该停,不该继续打请求') assert.strictEqual(quota.getBalance('uD'), 0, '余额应被归零校正') assert.strictEqual(r.skipped, 3, '含失败那条在内全部计为 skipped') // D1 是真正打了请求、被微信 43101 拒绝的那一条:error 字段应保留微信原始错误信息, // 而不是「根本没发请求」时用的兜底文案 'quota_exhausted'。 const d1Log = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'D1'").get() assert.ok(d1Log, 'D1 应写入 remind_logs') assert.strictEqual(d1Log.status, 'skipped') assert.strictEqual(d1Log.error, WX_ERROR_MESSAGE, 'D1 应记录微信原始错误信息,而不是 quota_exhausted 兜底文案') // D2、D3 是因为 halted 而被跳过、根本没发起请求的,error 字段应保持默认兜底文案。 const d2Log = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'D2'").get() const d3Log = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'D3'").get() assert.strictEqual(d2Log.error, 'quota_exhausted', 'D2 从未真正发起请求,应为兜底文案') assert.strictEqual(d3Log.error, 'quota_exhausted', 'D3 从未真正发起请求,应为兜底文案') }) 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 } // 至少 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(called, 2, '第二条也应被尝试,证明非额度错误不会误中止后续发送') assert.strictEqual(r.fail, 2) assert.strictEqual(quota.getBalance('uE'), 4, '配置/网络错误不该动余额') }) test('skipped 会写入 remind_logs 便于排查', async () => { resetLogs() const items = [makeAnniv('F1', 'uF', '甲', 'high', 0, 3)] await reminder.runForUser('uF', items, TODAY) const row = db.prepare("SELECT * FROM remind_logs WHERE status = 'skipped'").get() 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 影响') })