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({ load(url, context, next) { return next(url, url.endsWith('.ts') ? { ...context, format: 'module-typescript' } : context); }, 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', levelAmount: 15, pay_user: false, 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' }) } }); function setOffer(user, productId, priceFen) { user.starterPackVersion = 3; user.pay_user = productId !== 'starter_pack'; user.starterPackOffer = { version: 3, round: user.starterPackRound, productId, priceFen, startsAt: NOW - 10000, expiresAt: user.starter_pack, rewardVersion: 'starter_pack_v3_20260930', selectionBasis: 'profile' }; return user.starterPackOffer; } test('each lucky tier uses the server offer price, quantity and tier snapshot on native payment', async () => { for (const create of [androidOrder, nativeIosOrder]) { for (const [productId, priceFen] of [['starter_pack', 300], ['starter_pack_2', 600], ['starter_pack_3', 1200], ['starter_pack_4', 1800], ['starter_pack_5', 3000]]) { const user = reset(), offer = setOffer(user, productId, priceFen); const result = await create(ctx({ uid: 'u', itemid: productId, itemPrice: 1, itemCount: 9 })); assert.equal(result.code, 1); const saved = tables.order[0]; assert.equal(saved.goodsPrice, priceFen); assert.equal(saved.itemCount, 1); assert.deepEqual(saved.starterPackOffer, offer); assert.equal(saved.starterPackOffer.reward, undefined); assert.equal(saved.activityType, 'lucky'); assert.equal(saved.rewardVersion, offer.rewardVersion); const signed = JSON.parse(result.data.signData); assert.equal(signed.productId, productId); assert.equal(signed.goodsPrice, priceFen); assert.equal(signed.buyQuantity, 1); } } }); test('lucky native creation rejects a mismatched tier, invalid suffix, expired or purchased offer', async () => { for (const create of [androidOrder, nativeIosOrder]) for (const scenario of ['mismatch', 'suffix', 'expired', 'purchased']) { const user = reset(); setOffer(user, 'starter_pack_3', 1200); if (scenario === 'expired') { user.starter_pack = NOW; user.starterPackOffer.expiresAt = NOW; } if (scenario === 'purchased') user.starter_packState = 1; const itemid = scenario === 'mismatch' ? 'starter_pack_2' : scenario === 'suffix' ? 'starter_pack_1' : 'starter_pack_3'; assert.equal((await create(ctx({ uid: 'u', itemid, itemPrice: 1200, itemCount: 1 }))).code, 0); assert.equal(tables.order.length, 0); } }); test('lucky query and original reward acknowledgement return the purchased snapshot after expiry', async () => { const user = reset(), offer = setOffer(user, 'starter_pack_4', 1800); const row = { outTradeNo: 'wcx_test', openid: user.openid, itemid: offer.productId, goodsPrice: offer.priceFen, itemCount: 1, state: 1, ...starterPackOrderSnapshot(user, offer.productId) }; tables.order.push(row); now += 2 * DAY; const result = await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo })); assert.equal(result.data.pay_state, 2); assert.equal(result.data.itemid, offer.productId); assert.deepEqual(result.data.starterPackOffer, offer); const claimed = await claimOrder(ctx({ uid: 'u', outTradeNo: row.outTradeNo })); assert.equal(claimed.data, 'ok'); assert.deepEqual(claimed.starterPackOffer, offer); assert.equal(row.state, 2); const done = await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo })); assert.equal(done.data.pay_state, 1); assert.deepEqual(done.data.starterPackOffer, offer); }); test('lucky callback marks only its own round, while historical orders still confirm payment', async () => { for (const older of [false, true]) { const user = reset(), offer = setOffer(user, 'starter_pack_5', 3000); const row = { outTradeNo: 'wcx_test', openid: 'o', itemid: offer.productId, goodsPrice: 3000, itemCount: 1, time: NOW, state: 0, ...starterPackOrderSnapshot(user, offer.productId) }; tables.order.push(row); if (older) { user.starter_pack += DAY; user.starterPackRound++; setOffer(user, 'starter_pack_2', 600); } assert.equal((await callback(notification())).ErrCode, 0); assert.equal(row.state, 1); assert.equal(user.starter_packState === 1, !older); assert.equal(user.pay_user, true); } const user = reset(), row = order(user); delete row.starterPackOffer; delete row.starterPackExpiresAt; delete row.starterPackBuyRound; user.starterPackRound++; user.starter_pack += DAY; setOffer(user, 'starter_pack_2', 600); assert.equal((await callback(notification())).ErrCode, 0); assert.notEqual(user.starter_packState, 1); assert.equal(row.state, 1); const result = await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo })); assert.equal(result.data.starterPackOffer, undefined); }); test('legacy iOS lucky tiers preserve the locked offer through creation, transfer and paid query', async () => { const user = reset(), offer = setOffer(user, 'starter_pack_3', 1200); shared.set('wxaccess_token', 'synthetic'); provider = { prepay_id: 'mock-prepay' }; await customerService(ctx({ FromUserName: 'o', SessionFrom: JSON.stringify({ tpye: 'ios', outTradeNo: 'tier-ios', propName: offer.productId, count: 9, price: 1 }) })); assert.equal(tables.iosOrder[0].goodsPrice, 1200); assert.equal(tables.iosOrder[0].itemCount, 1); assert.deepEqual(tables.iosOrder[0].starterPackOffer, offer); assert.equal(await transfer(ctx({ openid: 'o', time: user.iosTime, outTradeNo: 'tier-ios' })), true); assert.deepEqual(tables.order[0].starterPackOffer, offer); user.starter_pack += DAY; user.starterPackRound++; setOffer(user, 'starter_pack_5', 3000); provider = { trade_state: 'SUCCESS', success_time: new Date(NOW).toISOString() }; const paid = await iosQuery(ctx({ uid: 'u', outTradeNo: 'tier-ios' })); assert.equal(paid.code, 1); assert.deepEqual(paid.data.starterPackOffer, offer); assert.notEqual(user.starter_packState, 1); assert.equal(tables.order[0].state, 2); const done = await iosQuery(ctx({ uid: 'u', outTradeNo: 'tier-ios' })); assert.equal(done.msg, '已经获取到奖励'); assert.deepEqual(done.data.starterPackOffer, offer); }); test('old orders without snapshots close only a matching migrated legacy offer', async () => { for (const payment of ['native', 'ios']) { const user = reset(), row = order(user); delete row.starterPackOffer; delete row.starterPackExpiresAt; delete row.starterPackBuyRound; user.starterPackOffer = { legacy: true, round: user.starterPackRound, expiresAt: user.starter_pack }; if (payment === 'native') assert.equal((await callback(notification())).ErrCode, 0); else { provider = { trade_state: 'SUCCESS', success_time: new Date(NOW).toISOString() }; assert.equal((await iosQuery(ctx({ uid: 'u', outTradeNo: row.outTradeNo }))).code, 1); } assert.equal(user.starter_packState, 1); assert.equal(user.pay_user, true); assert.equal(row.starterPackOffer, undefined); } }); 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.notEqual(user.starter_packState, 1); assert.equal(user.pay_user, true); 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); });