458 lines
22 KiB
JavaScript
458 lines
22 KiB
JavaScript
// 用法:node server/test.js
|
|
// 1) 纯函数断言(HMAC 官方向量、价目表、signData 字段、单号格式、激活码格式)
|
|
// 2) 起一个假的微信接口 + 真的 server/index.js,走一遍下单、发货、我的
|
|
const assert = require('assert')
|
|
const crypto = require('crypto')
|
|
const fs = require('fs')
|
|
const http = require('http')
|
|
const os = require('os')
|
|
const path = require('path')
|
|
const { spawn } = require('child_process')
|
|
const { GOODS, hmacSha256, newOutTradeNo, buildSignData, paySig } = require('./sign.js')
|
|
const { newActivationCode } = require('./code.js')
|
|
|
|
const APP_KEY = 'test-appkey'
|
|
const SANDBOX_APP_KEY = 'test-sandbox-appkey'
|
|
const OFFER_ID = '1450663371'
|
|
const SESSION_KEY = 'sk-test'
|
|
const OPENID = 'oTestUser'
|
|
const PUSH_TOKEN = 'test-push-token'
|
|
|
|
// ---------- 1. 纯函数 ----------
|
|
assert.strictEqual(
|
|
hmacSha256(Buffer.alloc(20, 0x0b), 'Hi There'), // RFC 4231 官方向量
|
|
'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7'
|
|
)
|
|
assert.deepStrictEqual(
|
|
{ 90001: GOODS[90001].price, 10001: GOODS[10001].price, 10002: GOODS[10002].price, 10000: GOODS[10000].price },
|
|
{ 90001: 10, 10001: 3900, 10002: 6900, 10000: 9900 }
|
|
)
|
|
|
|
const sample = buildSignData({ offerId: OFFER_ID, productId: 10000, price: 9900, outTradeNo: 'T1700ABCDEF', attach: 'test-10000' })
|
|
assert.deepStrictEqual(JSON.parse(sample), {
|
|
offerId: OFFER_ID,
|
|
buyQuantity: 1,
|
|
env: 0,
|
|
currencyType: 'CNY',
|
|
productId: '10000',
|
|
goodsPrice: 9900,
|
|
outTradeNo: 'T1700ABCDEF',
|
|
attach: 'test-10000',
|
|
})
|
|
assert.notStrictEqual(
|
|
paySig(APP_KEY, 'requestVirtualPayment', sample),
|
|
paySig(APP_KEY, 'requestVirtualPayment', sample + ' '),
|
|
'签名必须认原文'
|
|
)
|
|
for (let i = 0; i < 500; i++) {
|
|
const no = newOutTradeNo(1700000000000 + i)
|
|
assert.ok(no.length >= 8 && no.length <= 32, '单号长度越界: ' + no)
|
|
assert.ok(/^[0-9A-Za-z_\-|*@]+$/.test(no), '单号含非法字符: ' + no)
|
|
assert.ok(!no.startsWith('_'), '单号不能以下划线开头: ' + no)
|
|
}
|
|
|
|
// 激活码:4 组 4 位、不含容易看错的 I/L/O/U、不重复
|
|
const codes = new Set()
|
|
for (let i = 0; i < 1000; i++) {
|
|
const made = newActivationCode()
|
|
assert.ok(/^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){3}$/.test(made), '激活码格式不对: ' + made)
|
|
codes.add(made)
|
|
}
|
|
assert.strictEqual(codes.size, 1000, '激活码不应重复')
|
|
|
|
// ---------- 2. 起假微信接口 ----------
|
|
const seen = { notifyCalls: 0, unpaid: null, failNotify: false }
|
|
const fakeWx = http.createServer((req, res) => {
|
|
const [urlPath, query] = req.url.split('?')
|
|
let raw = ''
|
|
req.on('data', (chunk) => (raw += chunk))
|
|
req.on('end', () => {
|
|
res.setHeader('Content-Type', 'application/json')
|
|
const reply = (obj) => res.end(JSON.stringify(obj))
|
|
if (urlPath === '/sns/jscode2session') {
|
|
const jsCode = new URLSearchParams(query).get('js_code')
|
|
seen.code = jsCode
|
|
return reply({ openid: jsCode === 'code-other' ? 'oOtherUser' : OPENID, session_key: SESSION_KEY })
|
|
}
|
|
if (urlPath === '/cgi-bin/token') return reply({ access_token: 'tok-test', expires_in: 7200 })
|
|
if (urlPath === '/xpay/query_order') {
|
|
seen.queryBody = raw
|
|
seen.querySig = new URLSearchParams(query).get('pay_sig')
|
|
seen.queryToken = new URLSearchParams(query).get('access_token')
|
|
const { order_id: orderId, env } = JSON.parse(raw)
|
|
const status = orderId === seen.unpaid ? 1 : 2 // 1 = 已创建未支付
|
|
return reply({
|
|
errcode: 0,
|
|
errmsg: 'ok',
|
|
order: { order_id: orderId, status, paid_fee: 10, left_fee: 10, wx_order_id: `wx-${env}-${orderId}` },
|
|
})
|
|
}
|
|
if (urlPath === '/xpay/notify_provide_goods') {
|
|
seen.notifyCalls++
|
|
seen.notifyBody = raw
|
|
seen.notifyToken = new URLSearchParams(query).get('access_token')
|
|
seen.notifySig = new URLSearchParams(query).get('pay_sig')
|
|
if (seen.failNotify) return reply({ errcode: 44002, errmsg: 'empty post data' })
|
|
return reply({ errcode: 0, errmsg: 'ok' })
|
|
}
|
|
if (urlPath === '/xpay/refund_order') {
|
|
seen.refundBody = raw
|
|
seen.refundSig = new URLSearchParams(query).get('pay_sig')
|
|
return reply({ errcode: 0, errmsg: 'ok', refund_order_id: JSON.parse(raw).refund_order_id })
|
|
}
|
|
res.statusCode = 404
|
|
reply({ errcode: -1, errmsg: 'no such path: ' + urlPath })
|
|
})
|
|
})
|
|
|
|
function listen(server) {
|
|
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)))
|
|
}
|
|
|
|
function fetchJson(port, urlPath, payload) {
|
|
return new Promise((resolve, reject) => {
|
|
const body = payload === undefined ? null : JSON.stringify(payload)
|
|
const req = http.request(
|
|
{ host: '127.0.0.1', port, path: urlPath, method: body ? 'POST' : 'GET', headers: body ? { 'Content-Type': 'application/json' } : {} },
|
|
(res) => {
|
|
let raw = ''
|
|
res.on('data', (chunk) => (raw += chunk))
|
|
res.on('end', () => resolve(JSON.parse(raw)))
|
|
}
|
|
)
|
|
req.on('error', reject)
|
|
req.end(body || undefined)
|
|
})
|
|
}
|
|
|
|
// 需要看原始响应的场合(推送接口回的是 XML / 纯文本)
|
|
function rawRequest(port, urlPath, options) {
|
|
const opts = options || {}
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request(
|
|
{ host: '127.0.0.1', port, path: urlPath, method: opts.method || (opts.body ? 'POST' : 'GET') },
|
|
(res) => {
|
|
let raw = ''
|
|
res.on('data', (chunk) => (raw += chunk))
|
|
res.on('end', () => resolve({ status: res.statusCode, body: raw }))
|
|
}
|
|
)
|
|
req.on('error', reject)
|
|
req.end(opts.body || undefined)
|
|
})
|
|
}
|
|
|
|
// 消息推送的签名:sha1(sort([token, timestamp, nonce]).join(''))
|
|
function pushQuery(token) {
|
|
const timestamp = '1700000000'
|
|
const nonce = 'abc123'
|
|
const signature = crypto.createHash('sha1').update([token || PUSH_TOKEN, timestamp, nonce].sort().join('')).digest('hex')
|
|
return `?signature=${signature}×tamp=${timestamp}&nonce=${nonce}`
|
|
}
|
|
|
|
function startServer(env) {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [path.join(__dirname, 'index.js')], {
|
|
env: Object.assign({}, process.env, env),
|
|
stdio: ['ignore', 'pipe', 'inherit'],
|
|
})
|
|
let out = ''
|
|
child.stdout.on('data', (chunk) => {
|
|
out += chunk
|
|
const port = (out.match(/http:\/\/localhost:(\d+)/) || [])[1]
|
|
if (port) resolve({ port: Number(port), child })
|
|
})
|
|
})
|
|
}
|
|
|
|
// 轮询等某个单号出现激活码(定时补发货是异步的)
|
|
async function waitForCode(port, outTradeNo, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
const mine = await fetchJson(port, '/pay/my', { code: 'code-1' })
|
|
const hit = mine.list.find((item) => item.outTradeNo === outTradeNo)
|
|
if (hit) return hit
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function main() {
|
|
const wxPort = await listen(fakeWx)
|
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xpay-data-'))
|
|
// 先放一条管理员记录:沙箱、补单道具、退款这些管理员功能要靠它
|
|
fs.writeFileSync(
|
|
path.join(dataDir, 'users.jsonl'),
|
|
JSON.stringify({ openid: OPENID, createdAt: 1700000000000, lastSeenAt: 1700000000000, isAdmin: true }) + '\n'
|
|
)
|
|
// 文案配置:只覆盖标题,其它项应该退回代码里的默认值
|
|
fs.writeFileSync(path.join(dataDir, 'settings.json'), JSON.stringify({ 'buy.title': '限时特惠', 'buy.button': '立即开通' }))
|
|
const serverEnv = {
|
|
PORT: '0',
|
|
PAY_POLL_MS: '0', // 主流程把定时任务关掉,免得跟断言的时序打架
|
|
PAY_STORE: 'jsonl', // 自测一律用文件后端,绝不碰你的数据库
|
|
WX_API_ORIGIN: `http://127.0.0.1:${wxPort}`,
|
|
DATA_DIR: dataDir,
|
|
PAY_APP_ID: 'wxtest',
|
|
PAY_APP_SECRET: 'secret-test',
|
|
PAY_OFFER_ID: OFFER_ID,
|
|
PAY_APP_KEY: APP_KEY,
|
|
PAY_SANDBOX_APP_KEY: SANDBOX_APP_KEY,
|
|
PAY_PUSH_TOKEN: PUSH_TOKEN,
|
|
}
|
|
const { port, child } = await startServer(serverEnv)
|
|
|
|
try {
|
|
assert.deepStrictEqual(await fetchJson(port, '/health'), {
|
|
ok: true,
|
|
missing: [],
|
|
sandbox: true,
|
|
goods: ['90001', '10001', '10002', '10000'], // 价格从低到高
|
|
store: 'jsonl',
|
|
routes: ['/pay/order', '/pay/deliver', '/pay/my', '/pay/pending', '/pay/refund', '/pay/me'],
|
|
})
|
|
|
|
// 下单:签名要能被独立重算出来
|
|
const paid = await fetchJson(port, '/pay/order', { productId: '10000', code: 'code-1' })
|
|
assert.strictEqual(paid.ok, true, JSON.stringify(paid))
|
|
assert.strictEqual(seen.code, 'code-1')
|
|
const { signData, paySig: gotPaySig, signature, mode } = paid.payData
|
|
assert.strictEqual(mode, 'short_series_goods')
|
|
assert.strictEqual(gotPaySig, paySig(APP_KEY, 'requestVirtualPayment', signData))
|
|
assert.strictEqual(signature, hmacSha256(SESSION_KEY, signData))
|
|
const parsed = JSON.parse(signData)
|
|
assert.strictEqual(parsed.offerId, OFFER_ID)
|
|
assert.strictEqual(parsed.productId, '10000')
|
|
assert.strictEqual(parsed.goodsPrice, 9900)
|
|
assert.strictEqual(parsed.env, 0)
|
|
assert.strictEqual(gotPaySig, paySig(APP_KEY, 'requestVirtualPayment', signData))
|
|
assert.strictEqual(paid.outTradeNo, parsed.outTradeNo)
|
|
|
|
// 页面重进后能找回还没发货的单(补发货用)
|
|
const pending = await fetchJson(port, '/pay/pending', { code: 'code-1' })
|
|
assert.strictEqual(pending.outTradeNo, paid.outTradeNo)
|
|
// 半路放弃、根本没付款的单,不该被当成「待发货」提示
|
|
seen.unpaid = paid.outTradeNo
|
|
assert.deepStrictEqual(await fetchJson(port, '/pay/pending', { code: 'code-1' }), { ok: true, outTradeNo: '' })
|
|
seen.unpaid = null
|
|
assert.deepStrictEqual(await fetchJson(port, '/pay/pending', { code: 'code-other' }), { ok: true, outTradeNo: '' })
|
|
|
|
// 沙箱单:env=1、用沙箱 AppKey 签名,和现网签名不能一样
|
|
const sandbox = await fetchJson(port, '/pay/order', { productId: '90001', code: 'code-1', env: 1 })
|
|
assert.strictEqual(sandbox.ok, true, JSON.stringify(sandbox))
|
|
const sandboxSignData = sandbox.payData.signData
|
|
assert.strictEqual(JSON.parse(sandboxSignData).env, 1)
|
|
assert.strictEqual(JSON.parse(sandboxSignData).goodsPrice, 10)
|
|
assert.strictEqual(sandbox.payData.paySig, paySig(SANDBOX_APP_KEY, 'requestVirtualPayment', sandboxSignData))
|
|
assert.notStrictEqual(sandbox.payData.paySig, paySig(APP_KEY, 'requestVirtualPayment', sandboxSignData))
|
|
|
|
// 前端万一把 env 传成字符串 '1',也必须按沙箱签,绝不能悄悄走现网真扣钱
|
|
const stringEnv = await fetchJson(port, '/pay/order', { productId: '90001', code: 'code-1', env: '1' })
|
|
assert.strictEqual(JSON.parse(stringEnv.payData.signData).env, 1)
|
|
assert.strictEqual(stringEnv.payData.paySig, paySig(SANDBOX_APP_KEY, 'requestVirtualPayment', stringEnv.payData.signData))
|
|
|
|
// 未发布的道具 ID 不该能下单
|
|
const bad = await fetchJson(port, '/pay/order', { productId: '12345', code: 'code-1' })
|
|
assert.strictEqual(bad.ok, false)
|
|
assert.ok(/未知道具/.test(bad.error), bad.error)
|
|
|
|
// 我是谁 + 我能看到哪些道具:管理员才多看到「补单专用」(0.1 元测试价)
|
|
const me = await fetchJson(port, '/pay/me', { code: 'code-1' })
|
|
assert.strictEqual(me.isAdmin, true)
|
|
assert.deepStrictEqual(
|
|
me.goods.map((item) => [item.productId, item.price]),
|
|
[
|
|
['90001', 10],
|
|
['10001', 3900],
|
|
['10002', 6900],
|
|
['10000', 9900],
|
|
]
|
|
)
|
|
assert.ok(me.goods.find((item) => item.productId === '90001').note, '备注文案要能带回小程序')
|
|
// 页面文案:库里有就用库里的,没有就退回默认值
|
|
assert.strictEqual(me.copy['buy.title'], '限时特惠')
|
|
assert.strictEqual(me.copy['buy.button'], '立即开通') // 库里的覆盖生效
|
|
assert.strictEqual(me.copy['tab.buy'], '开通')
|
|
const normalMe = await fetchJson(port, '/pay/me', { code: 'code-other' })
|
|
assert.strictEqual(normalMe.isAdmin, false)
|
|
assert.ok(!normalMe.goods.some((item) => item.productId === '90001'), '普通用户不该看到补单专用')
|
|
// 普通用户直接调接口也绕不过去
|
|
const sneakyGoods = await fetchJson(port, '/pay/order', { productId: '90001', code: 'code-other' })
|
|
assert.strictEqual(sneakyGoods.ok, false)
|
|
assert.ok(/管理员/.test(sneakyGoods.error), sneakyGoods.error)
|
|
const sneakySandbox = await fetchJson(port, '/pay/order', { productId: '10001', code: 'code-other', env: 1 })
|
|
assert.strictEqual(sneakySandbox.ok, false)
|
|
assert.ok(/沙箱/.test(sneakySandbox.error), sneakySandbox.error)
|
|
|
|
// 查单该用 pay_sig + access_token
|
|
seen.unpaid = paid.outTradeNo
|
|
const unpaid = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-1' })
|
|
assert.strictEqual(unpaid.ok, true)
|
|
assert.strictEqual(unpaid.paid, false, '没付款就不能发货')
|
|
assert.strictEqual(unpaid.code, undefined)
|
|
assert.strictEqual(seen.notifyCalls, 0, '没付款不该通知平台发货')
|
|
assert.deepStrictEqual(JSON.parse(seen.queryBody), { openid: OPENID, env: 0, order_id: paid.outTradeNo })
|
|
assert.strictEqual(seen.querySig, paySig(APP_KEY, '/xpay/query_order', seen.queryBody))
|
|
assert.strictEqual(seen.queryToken, 'tok-test')
|
|
|
|
// 付款后发货:发一张码 + 通知平台
|
|
seen.unpaid = null
|
|
const delivered = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-1' })
|
|
assert.strictEqual(delivered.paid, true)
|
|
assert.ok(/^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){3}$/.test(delivered.code), delivered.code)
|
|
assert.strictEqual(delivered.productName, '永久激活码')
|
|
assert.strictEqual(delivered.notified, true)
|
|
assert.deepStrictEqual(JSON.parse(seen.notifyBody), { order_id: paid.outTradeNo, wx_order_id: 'wx-0-' + paid.outTradeNo, env: 0 })
|
|
assert.strictEqual(seen.notifyToken, 'tok-test')
|
|
assert.strictEqual(seen.notifyCalls, 1)
|
|
// 平台要求 pay_sig,缺了会回 268490002
|
|
assert.strictEqual(seen.notifySig, paySig(APP_KEY, '/xpay/notify_provide_goods', seen.notifyBody))
|
|
|
|
// 幂等:同一单再点一次,还是同一张码,也不再重复通知平台
|
|
const again = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-1' })
|
|
assert.strictEqual(again.code, delivered.code)
|
|
assert.strictEqual(seen.notifyCalls, 1)
|
|
|
|
// 别人的单不能领
|
|
const stolen = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-other' })
|
|
assert.strictEqual(stolen.ok, false)
|
|
assert.ok(/不属于当前用户/.test(stolen.error), stolen.error)
|
|
|
|
// 我的:本人能看到,别人看不到
|
|
const sandboxDelivered = await fetchJson(port, '/pay/deliver', { outTradeNo: sandbox.outTradeNo, code: 'code-1' })
|
|
assert.strictEqual(sandboxDelivered.paid, true, JSON.stringify(sandboxDelivered))
|
|
assert.deepStrictEqual(JSON.parse(seen.queryBody), { openid: OPENID, env: 1, order_id: sandbox.outTradeNo })
|
|
assert.strictEqual(seen.querySig, paySig(SANDBOX_APP_KEY, '/xpay/query_order', seen.queryBody))
|
|
// 沙箱单不通知平台:通知次数还停在前面的现网单上
|
|
assert.strictEqual(seen.notifyCalls, 1, '沙箱单不该调 notify_provide_goods')
|
|
assert.strictEqual(sandboxDelivered.notified, true)
|
|
assert.strictEqual(sandboxDelivered.productName, '补单专用')
|
|
|
|
const mine = await fetchJson(port, '/pay/my', { code: 'code-1' })
|
|
assert.deepStrictEqual(
|
|
mine.list.map((item) => [item.code, item.productName]).sort(),
|
|
[
|
|
[delivered.code, '永久激活码'],
|
|
[sandboxDelivered.code, '补单专用'],
|
|
].sort()
|
|
)
|
|
const others = await fetchJson(port, '/pay/my', { code: 'code-other' })
|
|
assert.deepStrictEqual(others.list, [])
|
|
|
|
// 发过货的单不再出现在待发货里
|
|
const pendingAfter = await fetchJson(port, '/pay/pending', { code: 'code-1' })
|
|
assert.notStrictEqual(pendingAfter.outTradeNo, paid.outTradeNo)
|
|
assert.notStrictEqual(pendingAfter.outTradeNo, sandbox.outTradeNo)
|
|
|
|
// 平台拒绝通知时:码照样给用户,但要把平台原话带回去,方便直接在手机上看到原因
|
|
// 先把试探 env 的那笔也发掉,保证后面只剩一笔未完成单,断言不被时序搅浑
|
|
await fetchJson(port, '/pay/deliver', { outTradeNo: stringEnv.outTradeNo, code: 'code-1' })
|
|
const failOrder = await fetchJson(port, '/pay/order', { productId: '10001', code: 'code-1' })
|
|
seen.failNotify = true
|
|
const failed = await fetchJson(port, '/pay/deliver', { outTradeNo: failOrder.outTradeNo, code: 'code-1' })
|
|
assert.strictEqual(failed.paid, true)
|
|
assert.ok(failed.code, '平台通知失败也要把激活码给用户')
|
|
assert.strictEqual(failed.notified, false)
|
|
assert.ok(/44002/.test(failed.notifyError), failed.notifyError)
|
|
seen.failNotify = false
|
|
|
|
// 码发了但平台没确认的单,要能找回来继续重试通知
|
|
const pendingRetry = await fetchJson(port, '/pay/pending', { code: 'code-1' })
|
|
assert.strictEqual(pendingRetry.outTradeNo, failOrder.outTradeNo)
|
|
const retried = await fetchJson(port, '/pay/deliver', { outTradeNo: failOrder.outTradeNo, code: 'code-1' })
|
|
assert.strictEqual(retried.code, failed.code, '重试要给回同一张码')
|
|
assert.strictEqual(retried.notified, true)
|
|
// 通知成功后就不再提示了
|
|
assert.deepStrictEqual(await fetchJson(port, '/pay/pending', { code: 'code-1' }), { ok: true, outTradeNo: '' })
|
|
|
|
// ---------- 发货推送 ----------
|
|
// 配置 URL 时的 GET 校验:签名对就原样回 echostr,签名错要 403
|
|
assert.strictEqual((await rawRequest(port, '/pay/notify' + pushQuery() + '&echostr=hello-123')).body, 'hello-123')
|
|
assert.strictEqual((await rawRequest(port, '/pay/notify' + pushQuery('wrong-token') + '&echostr=hello')).status, 403)
|
|
|
|
const pushOrder = await fetchJson(port, '/pay/order', { productId: '10002', code: 'code-1' })
|
|
const pushXml =
|
|
'<xml>' +
|
|
'<ToUserName><![CDATA[gh_test]]></ToUserName>' +
|
|
'<MsgType><![CDATA[event]]></MsgType>' +
|
|
'<Event><![CDATA[xpay_goods_deliver_notify]]></Event>' +
|
|
`<OpenId><![CDATA[${OPENID}]]></OpenId>` +
|
|
`<OutTradeNo><![CDATA[${pushOrder.outTradeNo}]]></OutTradeNo>` +
|
|
'<Env>0</Env>' +
|
|
'<WeChatPayInfo><MchOrderNo><![CDATA[wx-push-1]]></MchOrderNo></WeChatPayInfo>' +
|
|
'<GoodsInfo><ProductId><![CDATA[10002]]></ProductId><Quantity>1</Quantity></GoodsInfo>' +
|
|
'</xml>'
|
|
const notifyCallsBeforePush = seen.notifyCalls
|
|
const pushRes = await rawRequest(port, '/pay/notify' + pushQuery(), { body: pushXml, method: 'POST' })
|
|
assert.ok(/<ErrCode>0<\/ErrCode>/.test(pushRes.body), pushRes.body)
|
|
const afterPush = await fetchJson(port, '/pay/my', { code: 'code-1' })
|
|
const pushCode = afterPush.list.find((item) => item.outTradeNo === pushOrder.outTradeNo)
|
|
assert.ok(pushCode && pushCode.productName === '高级激活码', '推送应该把货发出来')
|
|
// 推送已经回了 0,就不该再去调 notify_provide_goods
|
|
assert.strictEqual(seen.notifyCalls, notifyCallsBeforePush, '推送场景不用再手动通知平台')
|
|
// 同一个单再推一次:幂等,码不变
|
|
assert.ok(/<ErrCode>0<\/ErrCode>/.test((await rawRequest(port, '/pay/notify' + pushQuery(), { body: pushXml, method: 'POST' })).body))
|
|
const afterSecondPush = await fetchJson(port, '/pay/my', { code: 'code-1' })
|
|
assert.strictEqual(afterSecondPush.list.find((item) => item.outTradeNo === pushOrder.outTradeNo).code, pushCode.code)
|
|
|
|
// ---------- 退款 ----------
|
|
const refunded = await fetchJson(port, '/pay/refund', { outTradeNo: pushOrder.outTradeNo, code: 'code-1' })
|
|
assert.strictEqual(refunded.ok, true, JSON.stringify(refunded))
|
|
const refundBody = JSON.parse(seen.refundBody)
|
|
assert.strictEqual(refundBody.order_id, pushOrder.outTradeNo)
|
|
assert.strictEqual(refundBody.left_fee, 10)
|
|
assert.strictEqual(refundBody.refund_fee, 10)
|
|
assert.strictEqual(refundBody.env, 0)
|
|
assert.ok(/^[\w-]{8,32}$/.test(refundBody.refund_order_id), refundBody.refund_order_id)
|
|
assert.strictEqual(seen.refundSig, paySig(APP_KEY, '/xpay/refund_order', seen.refundBody))
|
|
// 退完就不在「我的」里了,也不能重复退
|
|
const afterRefund = await fetchJson(port, '/pay/my', { code: 'code-1' })
|
|
assert.ok(!afterRefund.list.some((item) => item.outTradeNo === pushOrder.outTradeNo), '退款后激活码要作废')
|
|
assert.strictEqual((await fetchJson(port, '/pay/refund', { outTradeNo: pushOrder.outTradeNo, code: 'code-1' })).ok, false)
|
|
|
|
// 普通用户能正常买单,但退款是管理员功能
|
|
const normalOrder = await fetchJson(port, '/pay/order', { productId: '10001', code: 'code-other' })
|
|
assert.strictEqual(normalOrder.ok, true, JSON.stringify(normalOrder))
|
|
await fetchJson(port, '/pay/deliver', { outTradeNo: normalOrder.outTradeNo, code: 'code-other' })
|
|
const normalRefund = await fetchJson(port, '/pay/refund', { outTradeNo: normalOrder.outTradeNo, code: 'code-other' })
|
|
assert.strictEqual(normalRefund.ok, false)
|
|
assert.ok(/管理员/.test(normalRefund.error), normalRefund.error)
|
|
|
|
// ---------- 定时查单补发货 ----------
|
|
const cronDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xpay-cron-'))
|
|
const cron = await startServer(Object.assign({}, serverEnv, { DATA_DIR: cronDataDir, PAY_POLL_MS: '200' }))
|
|
try {
|
|
const cronOrder = await fetchJson(cron.port, '/pay/order', { productId: '10001', code: 'code-1' })
|
|
const autoCode = await waitForCode(cron.port, cronOrder.outTradeNo, 5000)
|
|
assert.ok(autoCode, '付了钱但没进页面,定时任务也应该自动发货')
|
|
} finally {
|
|
cron.child.kill()
|
|
fs.rmSync(cronDataDir, { recursive: true, force: true })
|
|
}
|
|
|
|
// 落盘了:重启也能恢复
|
|
assert.ok(fs.existsSync(path.join(dataDir, 'orders.jsonl')), '订单文件没写出来')
|
|
|
|
// 用户表:首次使用会建行;这条是我们预置的管理员,标志要保持 true 不被覆盖
|
|
const userRows = fs
|
|
.readFileSync(path.join(dataDir, 'users.jsonl'), 'utf8')
|
|
.trim()
|
|
.split('\n')
|
|
.map((line) => JSON.parse(line))
|
|
assert.strictEqual(userRows[0].openid, OPENID)
|
|
assert.strictEqual(userRows[0].isAdmin, true)
|
|
assert.ok(userRows[0].createdAt > 0, '首次使用时间没记上')
|
|
|
|
console.log('服务端测试通过:下单签名、查单、发货幂等、归属校验、我的列表、发货推送、退款、定时补发货')
|
|
} finally {
|
|
child.kill()
|
|
fakeWx.close()
|
|
fs.rmSync(dataDir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('测试失败:', err.message)
|
|
process.exit(1)
|
|
})
|