MatchMaster/server/laf-cloud/tests/coin-madness.test.mjs

166 lines
11 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createHmac } from 'node:crypto';
import { createState, validConfig, win, reward } from '../functions/coinMadnessModel.ts';
const NOW = 1800000000000;
let now = NOW;
const realNow = Date.now;
Date.now = () => now;
test.after(() => { Date.now = realNow; delete process.env.COIN_MADNESS_CONFIG; });
let users = [], orders = [], beforeUpdate = null;
const copy = v => v == null ? v : structuredClone(v);
const matches = (row, query) => Object.entries(query).every(([key, value]) => value?.exists === false
? row[key] === undefined : row[key] === value);
globalThis.__coinCloud = { database: () => ({
command: { exists: value => ({ exists: value }) },
collection: name => ({
async add(data) { const rows = name === 'users' ? users : orders;
if (data._id && rows.some(r => r._id === data._id)) throw new Error('duplicate key');
rows.push(copy(data)); return { ok: true }; },
where(query) { return {
async getOne() { return { data: copy((name === 'users' ? users : orders).find(r => matches(r, query))) }; },
async update(data) {
if (beforeUpdate) { const f = beforeUpdate; beforeUpdate = null; f(); }
const rows = (name === 'users' ? users : orders).filter(r => matches(r, query));
rows.forEach(row => Object.assign(row, copy(data))); return { ok: true, updated: rows.length };
},
}; },
}),
}) };
globalThis.require = () => ({ initWithBatchMode: () => ({ track() {}, close() {} }) });
registerHooks({ resolve(specifier, context, next) {
if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__coinCloud', shortCircuit: true };
if (specifier === '@/Utils') return { url: 'data:text/javascript,export default {checkToken:(a,b)=>a===b}', 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 { default: endpoint, prepareCoinMadnessOrder, confirmCoinMadnessPayment, recordCoinMadnessWin } = await import('../functions/coinMadness.ts');
const { default: userCoin } = await import('../functions/userCoin.ts');
const { default: order } = await import('../functions/wx/orderPaySig.ts');
const { default: callback } = await import('../functions/wx/payCallBack.ts');
const config = () => ({ enabled: true, approved: true, periodId: 'test-1', startsAt: NOW - 1000, endsAt: NOW + 100000,
minLevel: 10, priceCents: 99, title: '金币狂热', stages: [{ wins: 2, coins: 100 }, { wins: 3, coins: 150 }] });
const state = () => JSON.parse(users[0].coinMadnessState);
const api = (action, extra = {}) => endpoint({ body: { uid: 'u', token: 'token', action, ...extra } });
function reset() {
now = NOW; beforeUpdate = null; orders = [];
users = [{ _id: 'u', openid: 'openid', token: 'token', levelAmount: 10, coinAmount: 1000, session_key: 'session' }];
process.env.COIN_MADNESS_CONFIG = JSON.stringify(config());
process.env.PAYMENT_APP_ENV = 'test'; process.env.WX_MIDAS_PAY_SIGN_KEY = 'secret';
}
async function paid() {
await api('read');
await prepareCoinMadnessOrder(users[0], { token: 'token', coinMadnessLevel: 10 }, 'order1');
await confirmCoinMadnessPayment({ openid: 'openid', outTradeNo: 'order1', coinMadnessPeriod: 'test-1' });
}
async function earned() { await paid(); await recordCoinMadnessWin('u', 11, true, false, now); await recordCoinMadnessWin('u', 12, true, false, now); }
test('invalid or unapproved configuration stays closed', async () => {
reset(); assert.equal(validConfig(config()), true);
for (const delta of [{approved:false}, {priceCents:0}, {endsAt:1}, {stages:[]}]) assert.equal(validConfig({...config(),...delta}), false);
delete process.env.COIN_MADNESS_CONFIG; assert.equal((await api('read')).data.available, false);
});
test('no paid progress, sequential independent stages, duplicates ignored', () => {
let s = createState(config(), 10); s = win(s, 11, now, true, false); assert.equal(s.progress, 0);
s.paidAt = now; s = win(s, 12, now, true, false); s = win(s, 12, now, true, false); assert.equal(s.progress, 1);
s = win(s, 13, now, true, false); s = win(s, 14, now, true, false); assert.equal(s.progress, 2);
const r = reward(s, now, false); assert.equal(r.coins, 100); assert.equal(r.state.progress, 0); assert.equal(r.state.stage, 1);
assert.equal(win(r.state, 14, now, true, false).progress, 0);
assert.equal(win(r.state, 15, now, true, false).progress, 1);
});
test('sync, infinite, duplicate, skipped and expired wins never earn progress', () => {
const s = createState(config(), 10); s.paidAt = now;
for (const [level,t,v,i] of [[11,now,false,false],[11,now,true,true],[10,now,true,false],[12,now,true,false],[11,config().endsAt,true,false]])
assert.equal(win(s, level, t, v, i).progress, 0);
});
test('authentication and opening level enforced', async () => {
reset(); assert.equal((await endpoint({body:{uid:'u',token:'bad',action:'read'}})).code, 0);
users[0].levelAmount = 9; assert.equal((await api('read')).data.available, false);
});
test('one reused native order, price always server owned; repeat purchase denied', async () => {
reset(); await api('read');
const ctx = {body:{uid:'u',token:'token',itemid:'coin_madness',itemPrice:1,itemCount:999,coinMadnessLevel:10}};
const a = await order(ctx), b = await order(ctx);
assert.equal(a.code,1); assert.equal(a.data.outTradeNo,b.data.outTradeNo); assert.equal(orders.length,1);
assert.equal(orders[0].goodsPrice,99); assert.equal(orders[0].itemCount,1);
await confirmCoinMadnessPayment(orders[0]); assert.equal((await order(ctx)).code,0);
});
test('signed callback unlocks once, never grants purchase coins', async () => {
reset(); await api('read');
await order({body:{uid:'u',token:'token',itemid:'coin_madness',coinMadnessLevel:10}});
const data = {OutTradeNo:orders[0].outTradeNo,OpenId:'openid',Env:0,GoodsInfo:{ProductId:'coin_madness',Quantity:1,OrigPrice:99}};
const event = 'minigame_game_pay_goods_deliver_notify', payload = JSON.stringify(data);
const ctx = {body:{Event:event,MiniGame:{Payload:payload,IsMock:false,PayEventSig:createHmac('sha256','secret').update(event+'&'+payload).digest('hex')}}};
assert.equal((await callback(ctx)).ErrCode,0); const paidAt = state().paidAt; now++;
assert.equal((await callback(ctx)).ErrCode,0); assert.equal(state().paidAt,paidAt); assert.equal(users[0].coinAmount,1000);
ctx.body.MiniGame.PayEventSig = '0'.repeat(64); assert.notEqual((await callback(ctx)).ErrCode,0);
});
test('waiting wins are not banked; manual claim advances from zero', async () => {
reset(); await earned(); await recordCoinMadnessWin('u',13,true,false,now);
const r = await api('claim',{periodId:'test-1',stage:0,coinAmount:1000}); assert.equal(r.code,1);
assert.equal(state().stage,1); assert.equal(state().progress,0); assert.equal(users[0].coinAmount,1100);
await recordCoinMadnessWin('u',13,true,false,now); assert.equal(state().progress,0);
});
test('lost claim response replays receipt; ack and duplicate claim cannot double grant', async () => {
reset(); await earned();
const body={periodId:'test-1',stage:0,coinAmount:1000};
const a=await api('claim',body), b=await api('claim',body);
assert.equal(a.data.pending.id,b.data.pending.id); assert.equal(users[0].coinAmount,1100);
assert.equal((await api('ack',{receiptId:a.data.pending.id,coinAmount:1100})).code,1);
assert.equal((await api('claim',body)).code,0); assert.equal(users[0].coinAmount,1100);
});
test('concurrent claims: only one wallet grant', async () => {
reset(); await earned();
const results=await Promise.all([api('claim',{periodId:'test-1',stage:0,coinAmount:1000}),api('claim',{periodId:'test-1',stage:0,coinAmount:1000})]);
assert.equal(results.filter(r=>r.code===1).length,1); assert.equal(users[0].coinAmount,1100);
});
test('expired paid earned reward compensated once; unfinished and unpaid expire', async () => {
reset(); await earned(); now=config().endsAt;
const r=await api('settle',{periodId:'test-1',coinAmount:1000}); assert.equal(r.data.pending.coins,100); assert.equal(state().settled,true);
await api('ack',{receiptId:r.data.pending.id,coinAmount:1100});
await api('settle',{periodId:'test-1',coinAmount:1100}); assert.equal(users[0].coinAmount,1100);
reset(); await paid(); now=config().endsAt; assert.equal((await api('settle',{periodId:'test-1'})).data.pending,null);
reset(); await api('read'); now=config().endsAt; assert.equal((await api('settle',{periodId:'test-1'})).data.pending,null);
});
test('late payment flagged for review, cannot unlock a new period', async () => {
reset(); await api('read'); await order({body:{uid:'u',token:'token',itemid:'coin_madness',coinMadnessLevel:10}});
now=config().endsAt; await assert.rejects(confirmCoinMadnessPayment(orders[0]),/截止/);
assert.equal(state().paidAt,0); assert.equal(orders[0].coinMadnessReviewReason,'confirmed_after_deadline');
});
test('wallet pending blocks old saves; race guard stops pre-grant upload', async () => {
reset(); await earned();
await api('claim',{periodId:'test-1',stage:0,coinAmount:1000});
assert.equal((await userCoin({body:{uid:'u',token:'token',action:'save',coinAmount:1000}})).code,0);
reset(); beforeUpdate=()=>{users[0].coinMadnessWalletRevision=1;users[0].coinAmount=1100;};
assert.equal((await userCoin({body:{uid:'u',token:'token',action:'save',coinAmount:1000}})).code,0);
assert.equal(users[0].coinAmount,1100);
});
test('concurrent reservation cannot produce two payment orders', async () => {
reset(); await api('read'); const original=copy(users[0]);
const results=await Promise.allSettled([
prepareCoinMadnessOrder(original,{token:'token',coinMadnessLevel:10},'a'),
prepareCoinMadnessOrder(original,{token:'token',coinMadnessLevel:10},'b'),
]);
assert.equal(results.filter(r=>r.status==='fulfilled').length,1); assert.equal(state().reservedOrder,'a');
});
test('paid configuration snapshot survives config changes and closing the sale', async () => {
reset(); await paid(); const changed={...config(),priceCents:199,stages:[{wins:99,coins:999}],enabled:false};
process.env.COIN_MADNESS_CONFIG=JSON.stringify(changed);
const r=await api('read');assert.equal(r.data.available,true);assert.equal(r.data.state.config.priceCents,99);
assert.equal(r.data.state.config.stages[0].wins,2);
});
test('next period resets purchase and progress only after old settlement receipt is acknowledged', async () => {
reset(); await earned(); now=config().endsAt;
const next={...config(),periodId:'test-2',startsAt:now,endsAt:now+100000};process.env.COIN_MADNESS_CONFIG=JSON.stringify(next);
assert.equal((await api('read')).data.state.config.periodId,'test-1');
const r=await api('settle',{periodId:'test-1',coinAmount:1000});
assert.equal((await api('read')).data.state.config.periodId,'test-1');
await api('ack',{receiptId:r.data.pending.id,coinAmount:1100});
const fresh=(await api('read')).data.state; assert.equal(fresh.config.periodId,'test-2');assert.equal(fresh.paidAt,0);assert.equal(fresh.progress,0);
});
test('wrong receipt and stale period never advance tasks or alter coins', async () => {
reset(); await earned();assert.equal((await api('claim',{periodId:'old',stage:0,coinAmount:1000})).code,0);
await api('claim',{periodId:'test-1',stage:0,coinAmount:1000});
assert.equal((await api('ack',{receiptId:'wrong',coinAmount:0})).code,0);assert.equal(users[0].coinAmount,1100);
});