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