166 lines
11 KiB
JavaScript
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);
|
|
});
|