236 lines
13 KiB
JavaScript
236 lines
13 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { registerHooks } from 'node:module';
|
|
import { generateKeyPairSync } from 'node:crypto';
|
|
|
|
const NOW = 1800000000000, DAY = 86400000;
|
|
let now = NOW, sdkError = false, provider = {}, dbError = false;
|
|
const tables = { users: [], order: [], iosOrder: [], susu: [] }, events = [], shared = new Map();
|
|
const realNow = Date.now, realFetch = globalThis.fetch;
|
|
Date.now = () => now;
|
|
test.after(() => { Date.now = realNow; globalThis.fetch = realFetch; });
|
|
const clone = value => value == null ? value : structuredClone(value);
|
|
const matches = (row, query) => Object.entries(query).every(([k, v]) => v === null ? row[k] == null : row[k] === v);
|
|
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
|
|
globalThis.__rookieCloud = {
|
|
database: () => ({
|
|
collection: name => ({
|
|
async add(data) { tables[name].push(clone(data)); return { id: 'mock' }; },
|
|
where(query) {
|
|
return {
|
|
async getOne() { return { data: clone(tables[name].find(r => matches(r, query))) }; },
|
|
async update(data) {
|
|
if (dbError && data.rookieGiftPaidAt) throw new Error('synthetic snapshot error');
|
|
const rows = tables[name].filter(r => matches(r, query));
|
|
rows.forEach(r => Object.assign(r, clone(data))); return { updated: rows.length };
|
|
},
|
|
async remove() { tables[name] = tables[name].filter(r => !matches(r, query)); },
|
|
};
|
|
},
|
|
})
|
|
}),
|
|
shared: { get: k => shared.get(k), set: (k, v) => shared.set(k, v) },
|
|
storage: { bucket: () => ({ readFile: async () => ({ Body: { transformToString: async () => privateKey } }) }) },
|
|
};
|
|
globalThis.fetch = async () => ({ ok: true, json: async () => provider });
|
|
globalThis.require = () => ({
|
|
initWithBatchMode: appId => ({
|
|
track() { },
|
|
trackFirst(event) {
|
|
if (sdkError) throw new Error('synthetic analytics error');
|
|
events.push({ appId, ...event });
|
|
},
|
|
close() { },
|
|
})
|
|
});
|
|
registerHooks({
|
|
resolve(specifier, context, next) {
|
|
if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__rookieCloud', shortCircuit: true };
|
|
if (specifier.startsWith('@/')) return { url: new URL('../functions/' + specifier.slice(2) + '.ts', import.meta.url).href, shortCircuit: true };
|
|
return next(specifier, context);
|
|
}
|
|
});
|
|
const { reportRookieGift, starterPackOrderSnapshot } = await import('../functions/limitedTimeEvent.ts');
|
|
const { default: callback } = await import('../functions/wx/payCallBack.ts');
|
|
const { default: query } = await import('../functions/wx/getPayInfo.ts');
|
|
const { default: iosQuery } = await import('../functions/wx/iosgetPayInfo.ts');
|
|
const { default: androidOrder } = await import('../functions/wx/orderPaySig.ts');
|
|
const { default: nativeIosOrder } = await import('../functions/wx/iosorderPaySig.ts');
|
|
const { default: customerService } = await import('../functions/wx/KeFuInfo.ts');
|
|
const { default: transfer } = await import('../functions/wx/checkIos.ts');
|
|
const { default: claimOrder } = await import('../functions/wx/getOrderReward.ts');
|
|
|
|
function reset() {
|
|
now = NOW; sdkError = false; dbError = false; provider = {};
|
|
events.length = 0; shared.clear();
|
|
for (const key of Object.keys(tables)) tables[key] = [];
|
|
process.env.PAYMENT_APP_ENV = 'production'; process.env.WX_MIDAS_PAY_SIGN_KEY = 'synthetic';
|
|
const user = { _id: 'u', openid: 'o', distinctId: 'd', session_key: 's', starter_pack: NOW + 3599999, starterPackRound: 3, starterPackVersion: 2 };
|
|
tables.users.push(user); return user;
|
|
}
|
|
function order(user, extra = {}) {
|
|
const row = { outTradeNo: 'wcx_test', openid: 'o', itemid: 'starter_pack', itemCount: 1, goodsPrice: 300, state: 0, time: NOW - 10000, ...starterPackOrderSnapshot(user, 'starter_pack'), ...extra };
|
|
tables.order.push(row); return row;
|
|
}
|
|
const ctx = body => ({ body, headers: {}, socket: { remoteAddress: '127.0.0.1' } });
|
|
const notification = () => ctx({ MiniGame: { Payload: JSON.stringify({ OutTradeNo: 'wcx_test' }) } });
|
|
|
|
test('native Android and iOS orders snapshot the server cycle, ignoring client analytics values', async () => {
|
|
for (const create of [androidOrder, nativeIosOrder]) {
|
|
const user = reset();
|
|
const result = await create(ctx({ uid: 'u', itemid: 'starter_pack', itemCount: 1, itemPrice: 300, starterPackBuyRound: 99, starterPackExpiresAt: 1 }));
|
|
assert.equal(result.code, 1);
|
|
assert.equal(tables.order[0].starterPackBuyRound, 3);
|
|
assert.equal(tables.order[0].starterPackExpiresAt, user.starter_pack);
|
|
assert.equal(events.length, 0);
|
|
}
|
|
});
|
|
|
|
test('native callback reports the paid order cycle and integer seconds, not a later reactivation', async () => {
|
|
const user = reset(), row = order(user);
|
|
user.starter_pack = NOW + 2 * DAY; user.starterPackRound = 4;
|
|
assert.equal((await callback(notification())).ErrCode, 0);
|
|
assert.equal(row.state, 1); assert.equal(user.starter_packState, 1);
|
|
assert.deepEqual(events[0].properties, { order_id: 'wcx_test', buy_round: 3, time_left: 3599 });
|
|
assert.equal(events[0].event, 'rookie_gift'); assert.equal(events[0].accountId, 'o');
|
|
assert.equal(events[0].distinctId, 'd'); assert.equal(events[0].firstCheckId, 'wcx_test');
|
|
});
|
|
|
|
test('concurrent and repeated callbacks and paid queries keep a single dedup key and frozen values', async () => {
|
|
const user = reset(), row = order(user);
|
|
await Promise.all([callback(notification()), callback(notification())]);
|
|
now += DAY; row.state = 2;
|
|
await callback(notification()); await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo }));
|
|
assert.equal(row.state, 2);
|
|
assert.equal(events.length, 4);
|
|
assert.equal(new Set(events.map(e => e.firstCheckId)).size, 1);
|
|
for (const event of events) assert.deepEqual(event.properties, events[0].properties);
|
|
assert.equal(row.rookieGiftPaidAt, NOW);
|
|
});
|
|
|
|
test('payment at or beyond expiry still succeeds and reports zero remaining seconds', async () => {
|
|
for (const expiry of [NOW, NOW - 1000]) {
|
|
const user = reset(); order(user, { starterPackExpiresAt: expiry });
|
|
assert.equal((await callback(notification())).ErrCode, 0);
|
|
assert.equal(events[0].properties.time_left, 0);
|
|
}
|
|
});
|
|
|
|
test('unpaid queries and other products never send rookie_gift', async () => {
|
|
const user = reset(), row = order(user);
|
|
assert.equal((await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo }))).code, 0);
|
|
assert.equal(events.length, 0);
|
|
row.itemid = 'gold_1'; await callback(notification()); assert.equal(events.length, 0);
|
|
assert.deepEqual(starterPackOrderSnapshot(user, 'gold_1'), {});
|
|
});
|
|
|
|
test('SDK failure cannot block payment; a later query retries the original snapshot', async () => {
|
|
const user = reset(), row = order(user); sdkError = true;
|
|
assert.equal((await callback(notification())).ErrCode, 0); assert.equal(row.state, 1);
|
|
assert.equal(events.length, 0); assert.equal(row.rookieGiftTimeLeft, 3599);
|
|
now += DAY; sdkError = false;
|
|
assert.equal((await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo }))).data.pay_state, 2);
|
|
assert.equal(events[0].properties.time_left, 3599);
|
|
});
|
|
|
|
test('analytics snapshot DB failure cannot block payment acknowledgement', async () => {
|
|
const user = reset(), row = order(user); dbError = true;
|
|
assert.equal((await callback(notification())).ErrCode, 0);
|
|
assert.equal(row.state, 1); assert.equal(events.length, 0);
|
|
});
|
|
|
|
test('existing ThinkingData debug routing is retained and account ID works without distinctId', async () => {
|
|
const user = reset(); delete user.distinctId; user.isDebug = 'true'; const row = order(user);
|
|
await reportRookieGift(row, user, NOW);
|
|
assert.equal(events[0].appId, '95993f9ab6f1402a87abe5147827e5e0');
|
|
assert.equal(events[0].accountId, 'o'); assert.equal('distinctId' in events[0], false);
|
|
});
|
|
|
|
test('legacy pending orders use cycle one; missing historical deadline falls back to zero', async () => {
|
|
const user = reset(); delete user.starterPackRound;
|
|
const row = order(user); delete row.starterPackBuyRound; delete row.starterPackExpiresAt;
|
|
await reportRookieGift(row, user, NOW);
|
|
assert.equal(events[0].properties.buy_round, 1); assert.equal(events[0].properties.time_left, 3599);
|
|
reset(); const older = order(user, { time: NOW - 5 * DAY });
|
|
delete older.starterPackBuyRound; delete older.starterPackExpiresAt;
|
|
await reportRookieGift(older, user, NOW);
|
|
assert.equal(events[0].properties.time_left, 0);
|
|
});
|
|
|
|
test('legacy iOS creation and transfer preserve cycle snapshot even if the user later changes', async () => {
|
|
const user = reset(); shared.set('wxaccess_token', 'synthetic'); provider = { prepay_id: 'mock-prepay' };
|
|
await customerService(ctx({ FromUserName: 'o', SessionFrom: JSON.stringify({ tpye: 'ios', outTradeNo: 'old-ios', propName: 'starter_pack', count: 1, price: 300 }) }));
|
|
assert.equal(tables.iosOrder.length, 1);
|
|
assert.equal(tables.iosOrder[0].starterPackBuyRound, 3);
|
|
const expiry = user.starter_pack; user.starter_pack = NOW + 2 * DAY; user.starterPackRound = 4;
|
|
assert.equal(await transfer(ctx({ openid: 'o', time: user.iosTime, outTradeNo: 'old-ios' })), true);
|
|
assert.equal(tables.order[0].starterPackBuyRound, 3); assert.equal(tables.order[0].starterPackExpiresAt, expiry);
|
|
assert.equal(events.length, 0);
|
|
});
|
|
|
|
test('legacy iOS uses provider success_time, not the later query or reward time', async () => {
|
|
const user = reset(), row = order(user);
|
|
now += DAY; provider = { trade_state: 'SUCCESS', success_time: new Date(NOW).toISOString() };
|
|
assert.equal((await iosQuery(ctx({ uid: 'u', outTradeNo: row.outTradeNo }))).code, 1);
|
|
assert.equal(row.state, 2); assert.equal(row.rookieGiftPaidAt, NOW);
|
|
assert.equal(events[0].properties.time_left, 3599);
|
|
await iosQuery(ctx({ uid: 'u', outTradeNo: row.outTradeNo }));
|
|
assert.equal(events[1].firstCheckId, events[0].firstCheckId);
|
|
assert.deepEqual(events[1].properties, events[0].properties);
|
|
});
|
|
|
|
test('legacy iOS unpaid query sends no event', async () => {
|
|
const user = reset(), row = order(user); provider = { trade_state: 'NOTPAY' };
|
|
assert.equal((await iosQuery(ctx({ uid: 'u', outTradeNo: row.outTradeNo }))).code, 0);
|
|
assert.equal(events.length, 0);
|
|
});
|
|
|
|
test('merged native payment keeps battle pass cycle validation and authoritative price', async () => {
|
|
for (const create of [androidOrder, nativeIosOrder]) {
|
|
const user = reset(), passEnd = String(NOW + DAY);
|
|
user.passCheck = JSON.stringify({ 2: { time: passEnd, activate: false } });
|
|
const body = { uid: 'u', itemid: 'battlepass_30', itemCount: 9, itemPrice: 1, passVersion: 2, passEnd };
|
|
assert.equal((await create(ctx({ ...body, passEnd: 'expired-cycle' }))).code, 0);
|
|
assert.equal(tables.order.length, 0);
|
|
assert.equal((await create(ctx(body))).code, 1);
|
|
const row = tables.order[0];
|
|
assert.equal(row.passVersion, 2); assert.equal(row.passEnd, passEnd);
|
|
assert.equal(row.goodsPrice, 3000); assert.equal(row.itemCount, 1);
|
|
assert.equal(row.starterPackBuyRound, undefined);
|
|
assert.equal(events.length, 0);
|
|
}
|
|
});
|
|
|
|
test('merged legacy iOS creation and transfer retain battle pass metadata', async () => {
|
|
const user = reset(), passEnd = String(NOW + DAY);
|
|
user.passCheck = JSON.stringify({ 2: { time: passEnd, activate: false } });
|
|
shared.set('wxaccess_token', 'synthetic'); provider = { prepay_id: 'mock-prepay' };
|
|
await customerService(ctx({
|
|
FromUserName: 'o', SessionFrom: JSON.stringify({
|
|
tpye: 'ios', outTradeNo: 'pass-ios', propName: 'battlepass_30', count: 9, price: 1, passVersion: 2, passEnd,
|
|
})
|
|
}));
|
|
assert.equal(tables.iosOrder[0].passVersion, 2);
|
|
assert.equal(tables.iosOrder[0].passEnd, passEnd);
|
|
assert.equal(tables.iosOrder[0].goodsPrice, 3000);
|
|
assert.equal(await transfer(ctx({ openid: 'o', time: user.iosTime, outTradeNo: 'pass-ios' })), true);
|
|
assert.equal(tables.order[0].passVersion, 2); assert.equal(tables.order[0].passEnd, passEnd);
|
|
assert.equal(events.length, 0);
|
|
});
|
|
|
|
test('merged generic reward endpoint cannot consume an upgraded battle pass order', async () => {
|
|
const user = reset(), row = order(user, { itemid: 'battlepass_30', passVersion: 2, state: 1 });
|
|
assert.equal((await claimOrder(ctx({ outTradeNo: row.outTradeNo }))).code, 0);
|
|
assert.equal(row.state, 1); assert.equal(row.getTime, undefined);
|
|
});
|
|
|
|
test('merged legacy iOS paid battle pass stays pending specialized delivery', async () => {
|
|
const user = reset(), row = order(user, { itemid: 'battlepass_30', passVersion: 2 });
|
|
provider = { trade_state: 'SUCCESS', success_time: new Date(NOW).toISOString() };
|
|
assert.equal((await iosQuery(ctx({ uid: 'u', outTradeNo: row.outTradeNo }))).code, 1);
|
|
assert.equal(row.state, 1); assert.equal(row.getTime, undefined);
|
|
row.state = 2;
|
|
assert.equal((await iosQuery(ctx({ uid: 'u', outTradeNo: row.outTradeNo }))).code, 1);
|
|
assert.equal(row.state, 2); assert.equal(events.length, 0);
|
|
});
|