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); });