Integrate starter pack timing, reactivation, purchase analytics, payment routing and atomic user IDs. Resolve iOS conflicts by preserving both battle pass delivery and starter pack snapshots. Add coexistence regression checks and record remaining compatibility risks. Validation: 118/125 tests passed; 7 existing failures remain, plus a separately reproduced pre-existing V2 task login failure.
136 lines
8.6 KiB
JavaScript
136 lines
8.6 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { registerHooks } from 'node:module';
|
|
import { createHmac } from 'node:crypto';
|
|
|
|
const state = { orders: [], user: null, reads: 0, writes: [], requests: [], reply: null };
|
|
const matches = (item, query) => item && Object.entries(query).every(([k, v]) => (v === null ? item[k] == null : item[k] === v));
|
|
globalThis.__routingCloud = {
|
|
database: () => ({
|
|
collection(name) {
|
|
return {
|
|
async add(data) { state.writes.push({ name, data }); if (name === 'order') state.orders.push(data); return { ok: true }; },
|
|
where(query) {
|
|
return {
|
|
async getOne() { state.reads++; return { data: name === 'order' ? state.orders.find(o => matches(o, query)) : state.user }; },
|
|
async update(data) {
|
|
state.writes.push({ name, data });
|
|
const target = name === 'order' ? state.orders.find(o => matches(o, query)) : state.user;
|
|
if (target) Object.assign(target, data);
|
|
return { ok: true };
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
}),
|
|
async fetch(request) { state.requests.push(request); if (state.reply instanceof Error) throw state.reply; return state.reply; },
|
|
};
|
|
globalThis.require = () => ({ initWithBatchMode: () => ({ track() { }, trackFirst() { }, close() { } }) });
|
|
registerHooks({
|
|
resolve(specifier, context, next) {
|
|
if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__routingCloud', shortCircuit: true };
|
|
if (specifier === '@/limitedTimeEvent') return { url: new URL('../functions/limitedTimeEvent.ts', import.meta.url).href, shortCircuit: true };
|
|
if (specifier === '@/passCheckUpgrade') return { url: new URL('../functions/passCheckUpgrade.ts', import.meta.url).href, shortCircuit: true };
|
|
if (specifier === '@/Utils') return { url: new URL('../functions/Utils.ts', import.meta.url).href, shortCircuit: true };
|
|
if (specifier === '@/jungleConfig') return { url: new URL('../functions/jungleConfig.ts', import.meta.url).href, shortCircuit: true };
|
|
return next(specifier, context);
|
|
}
|
|
});
|
|
const { default: callback } = await import('../functions/wx/payCallBack.ts');
|
|
const { default: androidOrder } = await import('../functions/wx/orderPaySig.ts');
|
|
const { default: iosOrder } = await import('../functions/wx/iosorderPaySig.ts');
|
|
const key = 'synthetic-payment-test-key';
|
|
function reset(env = 'production') {
|
|
process.env.PAYMENT_APP_ENV = env; process.env.WX_MIDAS_PAY_SIGN_KEY = key;
|
|
Object.assign(state, { orders: [], user: { _id: 'u', openid: 'o', session_key: 'session' }, reads: 0, writes: [], requests: [], reply: { status: 200, data: { ErrCode: 0 } } });
|
|
}
|
|
function order(id = 'wct_123', extra = {}) {
|
|
const result = { outTradeNo: id, openid: 'o', itemid: 'starter_pack', itemCount: 1, goodsPrice: 300, state: 0, paymentAppEnv: 'test', ...extra };
|
|
state.orders.push(result); return result;
|
|
}
|
|
function notification(id = 'wct_123', extra = {}) {
|
|
const Payload = JSON.stringify({ OutTradeNo: id, OpenId: 'o', Env: 0, GoodsInfo: { ProductId: 'starter_pack', Quantity: 1, OrigPrice: 300 }, ...extra }, null, 2);
|
|
const Event = 'minigame_game_pay_goods_deliver_notify';
|
|
return { body: { Event, MiniGame: { Payload, PayEventSig: createHmac('sha256', key).update(Event + '&' + Payload).digest('hex'), IsMock: false } }, headers: {}, socket: { remoteAddress: '127.0.0.1' } };
|
|
}
|
|
test('both platforms use server environment, record it and sign the same routed order id', async () => {
|
|
for (const create of [androidOrder, iosOrder]) for (const env of ['production', 'test']) {
|
|
reset(env);
|
|
const result = await create({ body: { uid: 'u', itemid: 'starter_pack', itemCount: 1, itemPrice: 300, isDebug: env !== 'test', paymentAppEnv: env === 'test' ? 'production' : 'test' } });
|
|
assert.equal(result.code, 1);
|
|
const data = JSON.parse(result.data.signData);
|
|
assert.ok(data.outTradeNo.startsWith(env === 'test' ? 'wct_' : 'wcx_'));
|
|
assert.ok(data.outTradeNo.length <= 32); assert.equal(data.env, 0);
|
|
assert.equal(state.orders[0].paymentAppEnv, env);
|
|
assert.equal(data.outTradeNo, state.orders[0].outTradeNo);
|
|
assert.equal(result.data.paySig, createHmac('sha256', key).update('requestMidasPaymentGameItem&' + result.data.signData).digest('hex'));
|
|
}
|
|
});
|
|
test('missing test key or invalid deployment environment rejects creation before database access', async () => {
|
|
for (const create of [androidOrder, iosOrder]) {
|
|
reset('test'); delete process.env.WX_MIDAS_PAY_SIGN_KEY;
|
|
assert.equal((await create({ body: {} })).code, 0); assert.equal(state.reads, 0);
|
|
reset('typo'); assert.equal((await create({ body: {} })).code, 0); assert.equal(state.writes.length, 0);
|
|
}
|
|
});
|
|
test('production forwards signed test notification unchanged without reading or writing production DB', async () => {
|
|
reset(); const ctx = notification(); order();
|
|
assert.equal((await callback(ctx)).ErrCode, 0);
|
|
assert.equal(state.reads, 0); assert.equal(state.writes.length, 0);
|
|
const request = state.requests[0];
|
|
assert.equal(request.url, 'https://sor779u2w8.sealoshzh.site/wx/payCallBack');
|
|
assert.equal(request.data, ctx.body); assert.equal(request.timeout, 3000); assert.equal(request.maxRedirects, 0);
|
|
});
|
|
test('tampered/missing signatures, wrong event, sandbox and mock notifications never forward or update', async () => {
|
|
for (const env of ['production', 'test']) for (const change of [
|
|
ctx => { ctx.body.MiniGame.PayEventSig = '0'.repeat(64); },
|
|
ctx => { delete ctx.body.MiniGame.PayEventSig; },
|
|
ctx => { ctx.body.MiniGame.Payload = ctx.body.MiniGame.Payload.replace('300', '301'); },
|
|
ctx => { ctx.body.Event = 'other_event'; },
|
|
ctx => { ctx.body.MiniGame.IsMock = true; },
|
|
ctx => { Object.assign(ctx, notification('wct_123', { Env: 1 })); },
|
|
]) {
|
|
reset(env); order(); const ctx = notification(); change(ctx);
|
|
assert.notEqual((await callback(ctx)).ErrCode, 0);
|
|
assert.equal(state.requests.length, 0); assert.equal(state.writes.length, 0); assert.equal(state.reads, 0);
|
|
}
|
|
});
|
|
test('missing verifier key fails closed in both deployments', async () => {
|
|
for (const env of ['production', 'test']) {
|
|
reset(env); delete process.env.WX_MIDAS_PAY_SIGN_KEY;
|
|
assert.notEqual((await callback(notification())).ErrCode, 0); assert.equal(state.requests.length, 0);
|
|
}
|
|
});
|
|
test('forward error, timeout, malformed response and redirect do not acknowledge payment', async () => {
|
|
for (const reply of [new Error('timeout'), { status: 500, data: { ErrCode: 0 } }, { status: 302, data: { ErrCode: 0 } }, { status: 200, data: { ErrCode: -1 } }, { status: 200, data: 'html' }, null]) {
|
|
reset(); state.reply = reply;
|
|
assert.notEqual((await callback(notification())).ErrCode, 0); assert.equal(state.writes.length, 0);
|
|
}
|
|
reset(); const ctx = notification(); ctx.headers['x-payment-forwarded'] = '1';
|
|
assert.notEqual((await callback(ctx)).ErrCode, 0); assert.equal(state.requests.length, 0);
|
|
});
|
|
test('test deployment confirms only matching local test order and repeated callbacks preserve completed state', async () => {
|
|
reset('test'); const row = order(); const ctx = notification(); ctx.headers['x-payment-forwarded'] = '1';
|
|
assert.equal((await callback(ctx)).ErrCode, 0); assert.equal(row.state, 1);
|
|
assert.equal(state.user.starter_packState, 1); assert.equal(state.requests.length, 0);
|
|
row.state = 2; const analytics = state.writes.filter(w => w.name === 'susu').length;
|
|
assert.equal((await callback(ctx)).ErrCode, 0); assert.equal(row.state, 2);
|
|
assert.equal(state.writes.filter(w => w.name === 'susu').length, analytics);
|
|
});
|
|
test('test environment rejects missing or mismatched orders and production order ids', async () => {
|
|
for (const mismatch of [null, { openid: 'wrong' }, { itemid: 'other' }, { itemCount: 2 }, { goodsPrice: 301 }, { paymentAppEnv: 'production' }]) {
|
|
reset('test'); if (mismatch) order('wct_123', mismatch);
|
|
assert.notEqual((await callback(notification())).ErrCode, 0); assert.equal(state.writes.length, 0);
|
|
}
|
|
reset('test'); order('wcx_old');
|
|
assert.notEqual((await callback(notification('wcx_old'))).ErrCode, 0); assert.equal(state.writes.length, 0);
|
|
});
|
|
test('unconfigured production keeps legacy orders local, including missing-order failures', async () => {
|
|
reset(); delete process.env.PAYMENT_APP_ENV; delete process.env.WX_MIDAS_PAY_SIGN_KEY;
|
|
const row = order('wcx_old', { paymentAppEnv: undefined });
|
|
const ctx = notification('wcx_old'); delete ctx.body.MiniGame.PayEventSig;
|
|
assert.equal((await callback(ctx)).ErrCode, 0); assert.equal(row.state, 1); assert.equal(state.requests.length, 0);
|
|
assert.notEqual((await callback(notification('wcx_missing'))).ErrCode, 0); assert.equal(state.requests.length, 0);
|
|
});
|