server/laf-cloud/tests/rookie-gift.test.mjs
guanchao e2a5c3d2f6 Squash local into main while preserving battle pass compatibility
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.
2026-09-10 16:31:14 +08:00

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