MatchMaster/server/laf-cloud/tests/rookie-gift.test.mjs
2026-09-30 21:49:05 +08:00

342 lines
20 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({
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);
});