Files
wxserver/server/test/reminder.test.js
yuming e47a399ad5 余额为 0 时改为发探针,不再无条件全部跳过
balance 只有前端上报这一条上升通道,没升级小程序的老用户在微信侧仍在
真实累积额度却永远不上报,记账会系统性低估。低估到 0 就一条不发,比
改造前更糟——改造前至少还会试。

改为:余额为 0 时仍发出优先级最高的那一条当探针。成功则证实微信侧有
额度,本轮继续发;43101 则立即中止(与原行为一致);非额度错误记 failed
且不再重复探。每用户每轮最多探一次。

探针成功仍走 quota.consume:内部 MAX(0, balance-1) 扣不出负数,净效果是
balance 保持 0、sentTotal +1。不趁机把 balance 调高——探针只证明「至少
还有 1 条」,凭空补猜出来的数字会让首页风险预告变成乐观的假话。

注:三个原有用例断言的是「余额 0 就一个请求都不发」这一被本次刻意推翻的
旧契约,已按新行为改写;改写后仍用变异测试确认能抓到探针被关掉。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:23:11 +08:00

316 lines
12 KiB
JavaScript
Raw Permalink 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.
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 attempts = [] // 所有真正打出去的请求
const sent = [] // 其中发送成功的
const restore = wx.sendSubscribeMessage
// 第一条放行、之后一律 43101。
// Why 要让微信亲口说 43101:记账余额只有 1,但第二条会被当作「探针」发出去(见探针机制),
// 只有微信侧确认没额度,「额度不足时的取舍」才真正成立。
wx.sendSubscribeMessage = async (p) => {
attempts.push(p.data.name1.value)
if (attempts.length > 1) {
const e = new Error('发送订阅消息失败')
e.errcode = 43101
throw e
}
sent.push(p.data.name1.value)
return { errcode: 0 }
}
try {
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)
assert.strictEqual(r.ok, 1)
assert.strictEqual(r.skipped, 2)
assert.deepStrictEqual(sent, ['当天高'], '应当只发出当天且最重要的那条')
assert.deepStrictEqual(attempts, ['当天高', '当天低'], '探针也要按优先级挑下一条,且被拒后不再打第三个请求')
} finally {
wx.sendSubscribeMessage = restore
}
})
test('记账余额为 0 但微信侧仍有额度:探针成功后本轮继续发送', async () => {
resetLogs()
// uP1 从未 grant 过,记账余额为 0——模拟「没升级小程序、授权从不上报」的老用户
assert.strictEqual(quota.getBalance('uP1'), 0)
const sent = []
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async (p) => { sent.push(p.data.name1.value); return { errcode: 0 } }
try {
const items = [
makeAnniv('P1', 'uP1', '甲', 'high', 0, 3),
makeAnniv('P2', 'uP1', '乙', 'medium', 0, 3),
makeAnniv('P3', 'uP1', '丙', 'low', 0, 3)
]
const r = await reminder.runForUser('uP1', items, TODAY)
assert.strictEqual(r.ok, 3, '探针证实微信侧有额度后,剩下两条也应照常发出')
assert.strictEqual(r.skipped, 0)
assert.deepStrictEqual(sent, ['甲', '乙', '丙'])
assert.strictEqual(quota.getBalance('uP1'), 0, '探针成功不凭空补余额,账面保持保守的 0')
const row = db.prepare('SELECT sentTotal FROM subscribe_quota WHERE openid = ?').get('uP1')
assert.strictEqual(row.sentTotal, 3, 'sentTotal 要如实记录每一次成功发送,便于排查')
} finally {
wx.sendSubscribeMessage = restore
}
})
test('记账余额为 0 且微信侧确实没额度:探针被 43101 拒绝后立即中止', async () => {
resetLogs()
const WX_ERROR_MESSAGE = '发送订阅消息失败: {"errcode":43101}'
let called = 0
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async () => {
called++
const e = new Error(WX_ERROR_MESSAGE)
e.errcode = 43101
throw e
}
try {
const items = [
makeAnniv('Q1', 'uP2', '甲', 'high', 0, 3),
makeAnniv('Q2', 'uP2', '乙', 'high', 0, 3),
makeAnniv('Q3', 'uP2', '丙', 'high', 0, 3)
]
const r = await reminder.runForUser('uP2', items, TODAY)
assert.strictEqual(called, 1, '只探一次,被拒后不该继续打请求')
assert.strictEqual(r.ok, 0)
assert.strictEqual(r.skipped, 3, '含探针那条在内全部计为 skipped')
assert.strictEqual(quota.getBalance('uP2'), 0)
const q1 = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'Q1'").get()
const q2 = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'Q2'").get()
assert.strictEqual(q1.error, WX_ERROR_MESSAGE, '探针那条真打了请求,应保留微信原始错误信息')
assert.strictEqual(q2.error, 'quota_exhausted', '后续条目根本没发请求,记兜底文案')
} finally {
wx.sendSubscribeMessage = restore
}
})
test('探针遇非额度错误:记 failed、不动余额、本轮不再重复探', async () => {
resetLogs()
let called = 0
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async () => {
called++
const e = new Error('网络炸了')
e.errcode = 40003
throw e
}
try {
const items = [
makeAnniv('R1', 'uP3', '甲', 'high', 0, 3),
makeAnniv('R2', 'uP3', '乙', 'high', 0, 3),
makeAnniv('R3', 'uP3', '丙', 'high', 0, 3)
]
const r = await reminder.runForUser('uP3', items, TODAY)
// 网络/配置错误什么都没证明,既不能当作「有额度」继续发,也不该反复赌请求
assert.strictEqual(called, 1, '探针机会用掉就没了,不该对同一个用户反复重试')
assert.strictEqual(r.fail, 1, '探针那条记 failed,不是 skipped')
assert.strictEqual(r.skipped, 2)
assert.strictEqual(quota.getBalance('uP3'), 0, '非额度错误不动余额')
const r1 = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'R1'").get()
assert.strictEqual(r1.status, 'failed')
assert.strictEqual(r1.error, '网络炸了')
} finally {
wx.sendSubscribeMessage = restore
}
})
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()
// 余额为 0 时第一条会被当探针发出去,所以要放两条,第二条才是「压根没发请求」的 skipped
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async () => {
const e = new Error('发送订阅消息失败')
e.errcode = 43101
throw e
}
try {
const items = [
makeAnniv('F1', 'uF', '甲', 'high', 0, 3),
makeAnniv('F2', 'uF', '乙', 'high', 0, 3)
]
await reminder.runForUser('uF', items, TODAY)
const row = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'F2'").get()
assert.ok(row, '应写入 skipped 日志')
assert.strictEqual(row.status, 'skipped')
assert.strictEqual(row.error, 'quota_exhausted')
} finally {
wx.sendSubscribeMessage = restore
}
})
// 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,且微信侧也确实没额度(下面 mock 里对 uH 回 43101
insertAnniv('G1', 'uG', '甲', 0, 3) // 今天到期
insertAnniv('H1', 'uH', '乙', 0, 3) // 今天到期
const attempts = []
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async (p) => {
attempts.push(p.touser)
if (p.touser === 'uH') {
const e = new Error('发送订阅消息失败')
e.errcode = 43101
throw e
}
return { errcode: 0 }
}
try {
const r = await reminder.runOnce()
assert.strictEqual(r.total, 2)
assert.strictEqual(r.ok, 1, '有额度的 uG 应正常发出')
assert.strictEqual(r.skipped, 1, '微信侧确认没额度的 uH 应被跳过')
assert.strictEqual(r.fail, 0)
assert.deepStrictEqual(attempts, ['uG', 'uH'], 'uH 只有一次探针请求,不能借用 uG 的额度')
assert.strictEqual(quota.getBalance('uG'), 4, 'uG 消费后余额正确减少')
assert.strictEqual(quota.getBalance('uH'), 0, 'uH 的余额不受 uG 影响')
} finally {
wx.sendSubscribeMessage = restore
}
})