324 lines
29 KiB
JavaScript
324 lines
29 KiB
JavaScript
import './pass-check-loader.mjs';
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { isDeepStrictEqual } from 'node:util';
|
|
import { createHmac } from 'node:crypto';
|
|
const { handlePassRequest, preparePassOrder, acceptPassPayment, reconcilePassOrder, verifyPassCallback, PERIOD, config } = await import('../functions/passCheckService.ts');
|
|
const A = Date.UTC(2026, 8, 1), realNow = Date.now;
|
|
let now, tables, beforeUpdate, failFinalize, failAdd;
|
|
const copy = x => x == null ? x : structuredClone(x);
|
|
function matches(row, query) { return Object.entries(query).every(([k,v]) => v === null ? row[k] == null : isDeepStrictEqual(row[k], v)); }
|
|
const db = { collection(name) { return {
|
|
async add(value) { if (failAdd) { failAdd=false; throw Error('add interrupted'); } if (tables[name].some(r=>r._id===value._id)) throw Error('duplicate id'); tables[name].push(copy(value)); return {id:value._id}; },
|
|
where(query) { return {
|
|
async getOne() { return {data:copy(tables[name].find(r=>matches(r,query))||null)}; },
|
|
async get() { return {data:copy(tables[name].filter(r=>matches(r,query)))}; },
|
|
async update(update) {
|
|
if (beforeUpdate) { const hook=beforeUpdate; beforeUpdate=null; await hook(name,update); }
|
|
if(name==='order' && update.passGranted && failFinalize) { failFinalize=false; throw Error('finalize interrupted'); }
|
|
let found=tables[name].filter(r=>matches(r,query)); found.forEach(r=>Object.assign(r,copy(update))); return {updated:found.length};
|
|
}
|
|
}; }
|
|
}; }};
|
|
const fixture = { version:'test', premiumProduct:'premium-test', upgradeProduct:'upgrade-test', loopPoints:3, loopCoins:10, loopPowerSeconds:60, gift:[{field:'coinAmount',amount:7}] };
|
|
function reset(extra={}) { now=A+1000; Date.now=()=>now; process.env.PASSCHECKTIME_ID='anchor'; process.env.PASSCHECK_PAY_CALLBACK_KEY='test-key'; process.env.PASSCHECK_V2_ENABLED='true'; delete process.env.PASSCHECK_V2_USERS; process.env.PASSCHECK_V2_CONFIG=JSON.stringify(fixture); tables={users:[{_id:'u',openid:'o',token:'t',coinAmount:0,userPowerTime:0,...extra}],order:[],idcount:[{_id:'anchor',passcheckTime:A}]};beforeUpdate=null;failFinalize=false;failAdd=false; }
|
|
test.beforeEach(()=>reset()); test.after(()=>{Date.now=realNow;});
|
|
const request = (action, extra={})=>handlePassRequest(db,{uid:'u',token:'t',action,...extra});
|
|
const state = ()=>JSON.parse(tables.users[0].passCheckV2);
|
|
function change(fn) { const s=state();fn(s);tables.users[0].passCheckV2=JSON.stringify(s); }
|
|
async function win() { const r=(await request('begin_round')).data.round; return request('complete_round',{roundId:r.id,roundSeq:r.seq,won:true}); }
|
|
async function buy(product='premium-test', number='order-1') { await preparePassOrder(db,{uid:'u',token:'t',itemid:product,passChannel:'and'},number); const o=tables.order.find(o=>o.outTradeNo===number); return acceptPassPayment(db,o,{outTradeNo:number,openid:'o',price:o.goodsPrice,quantity:1,productId:product}); }
|
|
const rejects = (fn,reason)=>assert.rejects(fn,e=>e.reason===reason);
|
|
test('disabled, authentication and configuration fail closed',async()=>{
|
|
process.env.PASSCHECK_V2_ENABLED='false';await rejects(()=>request('read'),'FEATURE_DISABLED');
|
|
process.env.PASSCHECK_V2_ENABLED='true';await rejects(()=>request('read',{token:'bad'}),'TOKEN_INVALID');
|
|
process.env.PASSCHECK_V2_CONFIG='bad';await rejects(()=>request('read'),'CONFIG_INVALID');
|
|
});
|
|
test('missing gift blocks purchase until gift configuration is filled',async()=>{
|
|
process.env.PASSCHECK_V2_CONFIG='{}';const r=await request('read');assert.deepEqual(r.data.purchaseOptions,[]);
|
|
await rejects(()=>preparePassOrder(db,{uid:'u',token:'t',itemid:'battlepass',passChannel:'and'},'missing-gift'),'CONFIG_INCOMPLETE');
|
|
process.env.PASSCHECK_V2_CONFIG=JSON.stringify(fixture);const next=await request('read');assert.equal(next.data.purchaseOptions.length,2);
|
|
});
|
|
test('free and paid gain one point, premium gains two without retroactive credit',async()=>{
|
|
await request('read');await win();assert.equal(state().seasons[0].points,1);await buy('battlepass');await win();assert.equal(state().seasons[0].points,2);
|
|
await buy('upgrade-test','order-2');await win();assert.equal(state().seasons[0].points,4);assert.equal(tables.users[0].coinAmount,7);
|
|
assert.deepEqual(tables.order.map(o=>o.goodsPrice),[1800,1200]);
|
|
});
|
|
test('direct premium equals upgrade and callback retries do not duplicate gift',async()=>{
|
|
await request('read');await buy();await reconcilePassOrder(db,'order-1','u');assert.equal(state().seasons[0].tier,3);assert.equal(tables.users[0].coinAmount,7);
|
|
assert.equal(tables.order[0].goodsPrice,3000);await rejects(()=>preparePassOrder(db,{uid:'u',token:'t',itemid:'battlepass',passChannel:'and'},'x'),'TIER_CONFLICT');
|
|
});
|
|
test('illegal upgrade, tampered amounts and owner mismatch rejected',async()=>{
|
|
await request('read');await rejects(()=>preparePassOrder(db,{uid:'u',token:'t',itemid:'upgrade-test',passChannel:'and'},'x'),'TIER_CONFLICT');
|
|
const p=await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',itemPrice:1,itemCount:20,passChannel:'and'},'x');assert.equal(p.price,3000);
|
|
await rejects(()=>acceptPassPayment(db,tables.order[0],{outTradeNo:'x',openid:'o',price:1,productId:'premium-test',quantity:1}),'PAYMENT_MISMATCH');
|
|
await rejects(()=>reconcilePassOrder(db,'x','someone'),'ORDER_OWNER_MISMATCH');
|
|
});
|
|
test('concurrent purchases reserve one order and retry returns original number',async()=>{
|
|
await request('read');const bodies={uid:'u',token:'t',itemid:'premium-test',passChannel:'and'};
|
|
const r=await Promise.all([preparePassOrder(db,bodies,'a'),preparePassOrder(db,bodies,'b')]);assert.equal(r[0].outTradeNo,r[1].outTradeNo);assert.equal(tables.order.length,1);
|
|
await rejects(()=>preparePassOrder(db,{...bodies,itemid:'battlepass'},'c'),'ORDER_PENDING');
|
|
});
|
|
test('reservation survives interrupted order insert',async()=>{
|
|
await request('read');failAdd=true;await assert.rejects(()=>preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'and'},'a'));
|
|
const p=await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'and'},'b');assert.equal(p.outTradeNo,'a');assert.equal(tables.order.length,1);
|
|
});
|
|
test('only provider-confirmed closure permits changing product',async()=>{
|
|
await request('read');await preparePassOrder(db,{uid:'u',token:'t',itemid:'battlepass',passChannel:'and'},'a');tables.order[0].state=3;
|
|
await rejects(()=>preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'and'},'b'),'ORDER_PENDING');
|
|
tables.order[0].passClosedVerified=true;await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'and'},'b');assert.equal(tables.order.length,2);
|
|
});
|
|
test('duplicate round and concurrent settlement count one win',async()=>{
|
|
await request('read');const r=(await request('begin_round')).data.round;const r2=(await request('begin_round')).data.round;assert.equal(r.id,r2.id);
|
|
await Promise.all([request('complete_round',{roundId:r.id,roundSeq:r.seq,won:true}),request('complete_round',{roundId:r.id,roundSeq:r.seq,won:true})]);assert.equal(state().seasons[0].points,1);
|
|
await rejects(()=>request('complete_round',{roundId:'fake',roundSeq:r.seq,won:true}),'ROUND_INVALID');
|
|
});
|
|
test('two-point win crosses threshold and preserves overflow at cap',async()=>{
|
|
await request('read');await buy();const total=config().levels.reduce((n,r)=>n+r.token,0);change(s=>s.seasons[0].points=total-1);const r=await win();assert.equal(r.data.current.unlocked,30);assert.equal(r.data.current.overflow,1);
|
|
});
|
|
test('lower tiers bank overflow; upgrade unlocks alternating batch and keeps remainder',async()=>{
|
|
await request('read');const total=config().levels.reduce((n,r)=>n+r.token,0);change(s=>s.seasons[0].points=total+10);
|
|
await rejects(()=>request('claim_loop',{seasonId:String(A),expectedLoopClaimed:0}),'TIER_REQUIRED');await buy();
|
|
const r=await request('claim_loop',{seasonId:String(A),expectedLoopClaimed:0});assert.equal(r.data.current.loopClaimed,3);assert.equal(r.data.current.loopRemainder,1);assert.equal(tables.users[0].coinAmount,27);assert.equal(tables.users[0].userPowerTime,Math.floor(now/1000)+60);
|
|
await request('claim_loop',{seasonId:String(A),expectedLoopClaimed:0});assert.equal(tables.users[0].coinAmount,27);
|
|
await win();await request('claim_loop',{seasonId:String(A),expectedLoopClaimed:3});assert.equal(tables.users[0].userPowerTime,Math.floor(now/1000)+120);
|
|
});
|
|
test('concurrent level claims grant once, reject locked track and level',async()=>{
|
|
await request('read');await rejects(()=>request('claim_level',{seasonId:String(A),track:'paid',level:1}),'TIER_REQUIRED');
|
|
await rejects(()=>request('claim_level',{seasonId:String(A),track:'free',level:30}),'LEVEL_LOCKED');
|
|
const b={seasonId:String(A),track:'free',level:1};await Promise.all([request('claim_level',b),request('claim_level',b)]);assert.equal(tables.users[0].userPowerTime,Math.floor(now/1000)+900);
|
|
});
|
|
test('concurrent external resource grant is retained by CAS retry',async()=>{
|
|
await request('read');await buy();beforeUpdate=async(name)=>{if(name==='users')tables.users[0].coinAmount+=100;};
|
|
await request('claim_level',{seasonId:String(A),track:'paid',level:1});assert.equal(tables.users[0].coinAmount,1607);
|
|
});
|
|
test('old rewards block display and purchase while current accumulates at free speed',async()=>{
|
|
await request('read');await buy();now=A+PERIOD+1000;const r=await request('read');assert.equal(r.data.displaySeasonId,String(A));assert.equal(r.data.current.tier,1);
|
|
await win();assert.equal(state().seasons.find(p=>p.id===String(A+PERIOD)).points,1);
|
|
await rejects(()=>preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'and'},'b'),'OLD_REWARDS_PENDING');
|
|
await request('claim_level',{seasonId:String(A),track:'free',level:1});await request('claim_level',{seasonId:String(A),track:'paid',level:1});assert.equal((await request('read')).data.displaySeasonId,String(A+PERIOD));
|
|
});
|
|
test('expired period discards remainder and rejects old round and rewards',async()=>{
|
|
await request('read');const r=(await request('begin_round')).data.round;now=A+PERIOD;
|
|
await rejects(()=>request('complete_round',{roundId:r.id,roundSeq:r.seq,won:true}),'ROUND_EXPIRED');
|
|
now=A+PERIOD*2;await request('read');await rejects(()=>request('claim_level',{seasonId:String(A),track:'free',level:1}),'SEASON_EXPIRED');
|
|
});
|
|
test('late paid order grants original season only; too late is retained for review',async()=>{
|
|
await request('read');await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'and'},'a');now=A+PERIOD+1;
|
|
await acceptPassPayment(db,tables.order[0],{outTradeNo:'a',openid:'o',price:3000,quantity:1,productId:'premium-test'});assert.equal(state().seasons.find(p=>p.id===String(A)).tier,3);assert.equal(state().seasons.find(p=>p.id===String(A+PERIOD)).tier,1);
|
|
reset();await request('read');await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'and'},'a');now=A+PERIOD*2;
|
|
await rejects(()=>acceptPassPayment(db,tables.order[0],{outTradeNo:'a',openid:'o',price:3000,quantity:1,productId:'premium-test'}),'SEASON_EXPIRED');assert.equal(tables.order[0].passReviewReason,'SEASON_EXPIRED');assert.equal(tables.order[0].passGranted,undefined);
|
|
});
|
|
test('interrupted order completion retries without gifting twice',async()=>{
|
|
await request('read');await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'and'},'a');failFinalize=true;
|
|
await assert.rejects(()=>acceptPassPayment(db,tables.order[0],{outTradeNo:'a',openid:'o',price:3000,quantity:1,productId:'premium-test'}));assert.equal(tables.users[0].coinAmount,7);
|
|
await reconcilePassOrder(db,'a','u');assert.equal(tables.users[0].coinAmount,7);assert.equal(tables.order[0].passGranted,true);
|
|
});
|
|
test('legacy migration preserves claimed rewards and snapshot, clips unknown overflow',async()=>{
|
|
const legacy=JSON.stringify({1:{time:'0'},2:{time:String(A+PERIOD),progressLevel:31,progress:999,activate:true,free:[0,1],passCheck:[1,1]}});tables.users[0].passCheck=legacy;
|
|
const r=await request('read');assert.equal(r.data.current.tier,2);assert.equal(r.data.current.overflow,0);assert.deepEqual(r.data.current.freeClaimed,[1]);assert.equal(state().legacySnapshot,legacy);
|
|
});
|
|
test('malformed legacy and unresolved legacy payments block migration',async()=>{
|
|
tables.users[0].passCheck='invalid';await rejects(()=>request('read'),'LEGACY_STATE_INVALID');tables.users[0].passCheck=null;
|
|
tables.order.push({openid:'o',itemid:'battlepass',state:1});await rejects(()=>request('read'),'LEGACY_ORDER_PENDING');
|
|
});
|
|
test('immutable anchor survives changes by legacy time API',async()=>{
|
|
await request('read');tables.idcount[0].passcheckTime=A+10*PERIOD;now=A+PERIOD+1;assert.equal((await request('read')).data.current.startsAt,A+PERIOD);
|
|
});
|
|
test('signed callback authenticates event plus payload and rejects mock/tamper',()=>{
|
|
process.env.PASSCHECK_PAY_CALLBACK_KEY='test-key';const body={Event:'minigame_game_pay_goods_deliver_notify',MiniGame:{Payload:'{"Env":0}',IsMock:false}};
|
|
body.MiniGame.PayEventSig=createHmac('sha256','test-key').update(body.Event+'&'+body.MiniGame.Payload).digest('hex');verifyPassCallback(body);
|
|
body.MiniGame.Payload='{"changed":true}';assert.throws(()=>verifyPassCallback(body),e=>e.reason==='PAYMENT_SIGNATURE_INVALID');
|
|
});
|
|
|
|
// Exercise the real cloud-function adapters, with provider transport and storage stubbed.
|
|
const { registerHooks } = await import('node:module');
|
|
const { generateKeyPairSync } = await import('node:crypto');
|
|
const { privateKey } = generateKeyPairSync('rsa',{modulusLength:2048,privateKeyEncoding:{type:'pkcs8',format:'pem'},publicKeyEncoding:{type:'spki',format:'pem'}});
|
|
globalThis.__passCloud = { database:()=>db, shared:{get:()=>null,set(){}}, storage:{bucket:()=>({readFile:async()=>({Body:{transformToString:async()=>privateKey}})})} };
|
|
globalThis.require=()=>({initWithBatchMode:()=>({track(){},close(){}})});
|
|
registerHooks({ resolve(specifier,context,next) {
|
|
if(specifier==='@lafjs/cloud')return {url:'data:text/javascript,export default globalThis.__passCloud',shortCircuit:true};
|
|
if(specifier==='@/jungleConfig')return {url:'data:text/javascript,export function getJunglePaidTierByProductId(){return null}',shortCircuit:true};
|
|
if(specifier==='@/Utils')return {url:'data:text/javascript,export default {checkToken:(a,b)=>a===b}',shortCircuit:true};
|
|
return next(specifier,context);
|
|
}});
|
|
const {default: androidSign}=await import('../functions/wx/orderPaySig.ts');
|
|
const {default: iosSign}=await import('../functions/wx/iosorderPaySig.ts');
|
|
const {default: callback}=await import('../functions/wx/payCallBack.ts');
|
|
const {default: iosPoll}=await import('../functions/wx/iosgetPayInfo.ts');
|
|
const {default: legacyPass}=await import('../functions/passCheck.ts');
|
|
const {default: legacyLv}=await import('../functions/passCheckLv.ts');
|
|
const {default: acknowledge}=await import('../functions/wx/getOrderReward.ts');
|
|
const {default: api}=await import('../functions/passCheckV2.ts');
|
|
const originalFetch=globalThis.fetch;
|
|
test.after(()=>{globalThis.fetch=originalFetch;});
|
|
test('Android and iOS adapters sign server price and create exactly one bound order',async()=>{
|
|
for(const sign of [androidSign,iosSign]) {
|
|
reset();tables.users[0].session_key='test-session';await request('read');
|
|
const ctx={body:{uid:'u',token:'t',itemid:'premium-test',itemCount:999,itemPrice:1,passCheckVersion:2}};
|
|
const first=await sign(ctx);assert.equal(first.code,1);const data=JSON.parse(first.data.signData);assert.equal(data.goodsPrice,3000);assert.equal(data.buyQuantity,1);
|
|
const again=await sign(ctx);assert.equal(again.data.outTradeNo,first.data.outTradeNo);assert.equal(tables.order.length,1);assert.equal(tables.order[0].passOrder.seasonId,String(A));
|
|
}
|
|
});
|
|
test('callback rejects forged signatures then grants a valid signed payment once',async()=>{
|
|
await request('read');await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'and'},'a');
|
|
const data={OutTradeNo:'a',OpenId:'o',Env:0,GoodsInfo:{ProductId:'premium-test',Quantity:1,OrigPrice:3000}};
|
|
const body={Event:'minigame_game_pay_goods_deliver_notify',MiniGame:{Payload:JSON.stringify(data),PayEventSig:'0'.repeat(64),IsMock:false}};
|
|
process.env.PASSCHECK_PAY_CALLBACK_KEY='test-key';assert.equal((await callback({body})).ErrCode,-1);assert.equal(state().seasons[0].tier,1);
|
|
body.MiniGame.PayEventSig=createHmac('sha256','test-key').update(body.Event+'&'+body.MiniGame.Payload).digest('hex');
|
|
assert.equal((await callback({body})).ErrCode,0);assert.equal((await callback({body})).ErrCode,0);assert.equal(tables.users[0].coinAmount,7);
|
|
});
|
|
test('iOS provider query validates payer and amount, including customer service orders',async()=>{
|
|
await request('read');await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'ios'},'a');
|
|
let payer='other';globalThis.fetch=async()=>({json:async()=>({trade_state:'SUCCESS',out_trade_no:'a',payer:{openid:payer},amount:{total:3000}})});
|
|
const ctx={body:{uid:'u',token:'t',outTradeNo:'a'}};assert.equal((await iosPoll(ctx)).data.reason,'PAYMENT_MISMATCH');
|
|
payer='o';assert.equal((await iosPoll(ctx)).code,1);assert.equal(state().seasons[0].tier,3);
|
|
});
|
|
test('legacy saves and generic delivery cannot overwrite migrated users or unpaid orders',async()=>{
|
|
await request('read');const body={uid:'u',token:'t',action:'save',passCheck:'{}',passCheckLv:'{}'};
|
|
assert.equal((await legacyPass({body})).data.reason,'CLIENT_UPGRADE_REQUIRED');assert.equal((await legacyLv({body})).data.reason,'CLIENT_UPGRADE_REQUIRED');
|
|
await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'and'},'a');
|
|
assert.equal((await acknowledge({body:{uid:'u',token:'t',outTradeNo:'a'}})).data.reason,'ORDER_UNPAID');assert.equal(tables.order[0].state,0);
|
|
});
|
|
test('API returns stable error envelope for malformed requests',async()=>{
|
|
const r=await api({body:{uid:'u',token:'wrong',action:'read'}});assert.equal(r.code,0);assert.equal(r.data.reason,'TOKEN_INVALID');
|
|
});
|
|
test('grant receipt survives period cleanup when order finalize was interrupted',async()=>{
|
|
await request('read');await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'and'},'a');failFinalize=true;
|
|
await assert.rejects(()=>acceptPassPayment(db,tables.order[0],{outTradeNo:'a',openid:'o',price:3000,quantity:1,productId:'premium-test'}));
|
|
now=A+PERIOD*3;await reconcilePassOrder(db,'a','u');assert.equal(tables.users[0].coinAmount,7);assert.equal(tables.order[0].passGranted,true);
|
|
});
|
|
|
|
test('provider-confirmed iOS closure releases a pending purchase without granting',async()=>{
|
|
await request('read');await preparePassOrder(db,{uid:'u',token:'t',itemid:'battlepass',passChannel:'ios'},'a');
|
|
globalThis.fetch=async()=>({json:async()=>({trade_state:'CLOSED',out_trade_no:'a'})});
|
|
assert.equal((await iosPoll({body:{uid:'u',token:'t',outTradeNo:'a'}})).data.reason,'ORDER_CLOSED');
|
|
await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'ios'},'b');assert.equal(state().seasons[0].tier,1);
|
|
});
|
|
test('failed gift leaves paid order recoverable and no partial balance update',async()=>{
|
|
process.env.PASSCHECK_V2_CONFIG=JSON.stringify({...fixture,gift:[{field:'coinAmount',amount:7},{field:'userPowerTime',amount:60}]});
|
|
await request('read');await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'and'},'a');tables.users[0].userPowerTime=Number.MAX_SAFE_INTEGER;
|
|
await rejects(()=>acceptPassPayment(db,tables.order[0],{outTradeNo:'a',openid:'o',price:3000,quantity:1,productId:'premium-test'}),'RESOURCE_OVERFLOW');
|
|
assert.equal(tables.users[0].coinAmount,0);assert.equal(state().seasons[0].tier,1);assert.equal(tables.order[0].passPaymentVerified,true);
|
|
tables.users[0].userPowerTime=0;await reconcilePassOrder(db,'a','u');assert.equal(tables.users[0].coinAmount,7);assert.equal(state().seasons[0].tier,3);
|
|
});
|
|
test('old lower-tier overflow and locked rewards never hide the new period',async()=>{
|
|
await request('read');change(s=>{const p=s.seasons[0];p.points=p.config.levels.reduce((a,r)=>a+r.token,0)+100;p.freeClaimed=Array.from({length:30},(_,i)=>i+1);});
|
|
now=A+PERIOD+1;const r=await request('read');assert.equal(r.data.displaySeasonId,String(A+PERIOD));assert.equal(r.data.previous,null);
|
|
});
|
|
test('concurrent loop claims only consume one cursor even when both arrive together',async()=>{
|
|
await request('read');await buy();change(s=>s.seasons[0].points=s.seasons[0].config.levels.reduce((a,r)=>a+r.token,0)+6);
|
|
await Promise.all([request('claim_loop',{seasonId:String(A),expectedLoopClaimed:0}),request('claim_loop',{seasonId:String(A),expectedLoopClaimed:0})]);
|
|
assert.equal(state().seasons[0].loopClaimed,2);assert.equal(tables.users[0].coinAmount,17);
|
|
});
|
|
|
|
test('customer service payment uses the reserved price and does not create a second order',async()=>{
|
|
const {default: kefu}=await import('../functions/wx/KeFuInfo.ts');
|
|
await request('read');await preparePassOrder(db,{uid:'u',token:'t',itemid:'premium-test',passChannel:'ios'},'a');
|
|
const sent=[];globalThis.__passCloud.shared.get=()=> 'test-access-token';
|
|
globalThis.fetch=async(url,options)=>{sent.push({url,body:JSON.parse(options.body)});return {ok:true,json:async()=>({prepay_id:'test-prepay'})};};
|
|
await kefu({body:{FromUserName:'o',SessionFrom:JSON.stringify({tpye:'ios',outTradeNo:'a',propName:'premium-test',count:50,price:1})}});
|
|
const charge=sent.find(r=>r.url.includes('/pay/transactions/jsapi'));assert.equal(charge.body.amount.total,3000);assert.equal(tables.order.length,1);
|
|
assert.equal(sent.filter(r=>r.body.msgtype==='link').length,1);globalThis.__passCloud.shared.get=()=>null;
|
|
});
|
|
|
|
test('missing callback verifier disables purchase before a user can pay',async()=>{
|
|
delete process.env.PASSCHECK_PAY_CALLBACK_KEY;assert.equal((await request('read')).data.purchaseOptions.length,0);
|
|
await rejects(()=>preparePassOrder(db,{uid:'u',token:'t',itemid:'battlepass',passChannel:'and'},'a'),'PAYMENT_VERIFIER_UNCONFIGURED');assert.equal(tables.order.length,0);
|
|
});
|
|
|
|
test('level display preserves initial level and exact-boundary progress',async()=>{
|
|
const fresh=(await request('read')).data.current;assert.equal(fresh.level,1);assert.equal(fresh.levelProgress,0);assert.equal(fresh.unlocked,1);
|
|
let p=(await win()).data.current;assert.equal(p.level,1);assert.equal(p.levelProgress,1);
|
|
p=(await win()).data.current;assert.equal(p.level,2);assert.equal(p.levelProgress,1);
|
|
});
|
|
|
|
test('second tier receives the shared gift once and never unlocks loops or double progress',async()=>{
|
|
await request('read');await buy('battlepass','base');
|
|
assert.equal(tables.users[0].coinAmount,7);assert.equal(state().seasons[0].giftGranted,true);
|
|
await reconcilePassOrder(db,'base','u');assert.equal(tables.users[0].coinAmount,7);
|
|
const p=(await win()).data.current;assert.equal(p.multiplier,1);assert.equal(p.points,1);
|
|
change(s=>s.seasons[0].points=s.seasons[0].config.levels.reduce((sum,row)=>sum+row.token,0)+9);
|
|
assert.equal((await request('read')).data.current.loopAvailable,0);
|
|
await rejects(()=>request('claim_loop',{seasonId:String(A),expectedLoopClaimed:0}),'TIER_REQUIRED');
|
|
await buy('upgrade-test','upgrade');assert.equal(tables.users[0].coinAmount,7);
|
|
const upgraded=(await win()).data.current;assert.equal(upgraded.multiplier,2);assert.equal(upgraded.overflow,11);assert.equal(upgraded.loopAvailable,3);
|
|
});
|
|
test('base gift can be enabled before premium config, and stays fixed when premium opens',async()=>{
|
|
process.env.PASSCHECK_V2_CONFIG=JSON.stringify({gift:fixture.gift});
|
|
const r=await request('read');assert.deepEqual(r.data.purchaseOptions,[{productId:'battlepass',price:1800}]);
|
|
await buy('battlepass','base');assert.equal(tables.users[0].coinAmount,7);
|
|
process.env.PASSCHECK_V2_CONFIG=JSON.stringify({...fixture,gift:[{field:'coinAmount',amount:999}]});
|
|
await request('read');assert.deepEqual(state().seasons[0].config.gift,fixture.gift);
|
|
await buy('upgrade-test','upgrade');assert.equal(tables.users[0].coinAmount,7);
|
|
});
|
|
test('second-tier payment recovers after interrupted finalization without repeating the gift',async()=>{
|
|
await request('read');await preparePassOrder(db,{uid:'u',token:'t',itemid:'battlepass',passChannel:'and'},'base');failFinalize=true;
|
|
await assert.rejects(()=>acceptPassPayment(db,tables.order[0],{outTradeNo:'base',openid:'o',price:1800,quantity:1,productId:'battlepass'}));
|
|
assert.equal(tables.users[0].coinAmount,7);await reconcilePassOrder(db,'base','u');assert.equal(tables.users[0].coinAmount,7);assert.equal(state().seasons[0].tier,2);
|
|
});
|
|
test('migration preserves explicit legacy gift receipt on premium upgrade',async()=>{
|
|
tables.users[0].passCheck=JSON.stringify({2:{time:String(A+PERIOD),progressLevel:1,progress:0,activate:true,free:[1],passCheck:[1],giftGranted:true}});
|
|
await request('read');await buy('upgrade-test','upgrade');assert.equal(tables.users[0].coinAmount,0);assert.equal(state().seasons[0].giftGranted,true);
|
|
});
|
|
|
|
const oldPeriod = (end, overrides={}) => ({time:String(end),progressLevel:1,progress:1,activate:true,free:[1],passCheck:[1],...overrides});
|
|
test('migration keeps both old periods and all unpaid rewards claimable exactly once',async()=>{
|
|
const legacy=JSON.stringify({1:oldPeriod(A,{free:[0],passCheck:[1]}),2:oldPeriod(A+PERIOD)});
|
|
tables.users[0].passCheck=legacy;
|
|
const r=await request('read');assert.equal(r.data.displaySeasonId,String(A-PERIOD));assert.equal(r.data.current.tier,2);
|
|
assert.deepEqual(r.data.previous.freeClaimed,[1]);assert.deepEqual(r.data.previous.paidClaimed,[]);
|
|
const claim={seasonId:r.data.previous.id,level:1,track:'paid'};
|
|
await Promise.all([request('claim_level',claim),request('claim_level',claim)]);
|
|
assert.equal(tables.users[0].coinAmount,1500);
|
|
const next=await request('read');assert.equal(next.data.displaySeasonId,String(A));
|
|
// Consuming the initial token also unlocks level 1 only; no unclaimed old rewards remain.
|
|
assert.equal(next.data.previous,null);
|
|
assert.equal(state().legacySnapshot,legacy);assert.equal(tables.users[0].passCheck,legacy);
|
|
await request('claim_level',{seasonId:r.data.current.id,level:1,track:'paid'});
|
|
assert.equal(tables.users[0].coinAmount,3000);
|
|
await request('claim_level',{seasonId:r.data.current.id,level:1,track:'free'});
|
|
assert.equal(tables.users[0].userPowerTime,Math.floor(now/1000)+900);
|
|
});
|
|
test('expired legacy unpaid rewards block migration without changing the user or resources',async()=>{
|
|
for(const track of ['free','passCheck']) {
|
|
reset();tables.users[0].passCheck=JSON.stringify({1:oldPeriod(A-PERIOD,{free:[0],passCheck:[0],[track]:[1]}),2:oldPeriod(A+PERIOD)});
|
|
const before=copy(tables.users[0]);await rejects(()=>request('read'),'LEGACY_REWARDS_PENDING');assert.deepEqual(tables.users[0],before);
|
|
}
|
|
});
|
|
test('expired fully claimed or unpurchased paid rewards do not block migration',async()=>{
|
|
for(const activate of [true,false]) {
|
|
reset();tables.users[0].passCheck={1:oldPeriod(A-PERIOD,{activate,free:[0],passCheck:activate?[0]:[1]}),2:oldPeriod(A+PERIOD)};
|
|
const r=await request('read');assert.equal(r.data.previous,null);assert.equal(state().seasons.length,1);
|
|
}
|
|
});
|
|
test('legacy reward amounts and progress use the original table despite V2 overrides',async()=>{
|
|
const levels=copy(config().levels);levels[0]={token:100,free:[{field:'coinAmount',amount:1}],paid:[{field:'coinAmount',amount:1}]};
|
|
process.env.PASSCHECK_V2_CONFIG=JSON.stringify({...fixture,levels});
|
|
tables.users[0].passCheck={2:oldPeriod(A+PERIOD,{progressLevel:2,progress:1,free:[0,1],passCheck:[1,1]})};
|
|
const r=await request('read');assert.equal(r.data.current.points,2);assert.equal(r.data.current.level,2);
|
|
await request('claim_level',{seasonId:r.data.current.id,level:1,track:'paid'});assert.equal(tables.users[0].coinAmount,1500);
|
|
});
|
|
test('drifting periods and ambiguous entitlement flags cannot silently lose old rewards',async()=>{
|
|
for(const overrides of [{time:String(A+PERIOD+3600000)},{activate:'true'},{time:'0'}]) {
|
|
reset();tables.users[0].passCheck={2:oldPeriod(A+PERIOD,overrides)};const before=copy(tables.users[0]);
|
|
await assert.rejects(()=>request('read'),e=>['LEGACY_PERIOD_INVALID','LEGACY_STATE_INVALID'].includes(e.reason));assert.deepEqual(tables.users[0],before);
|
|
}
|
|
});
|
|
test('legacy concurrent claim during migration is re-read instead of granting it twice',async()=>{
|
|
tables.users[0].passCheck=JSON.stringify({2:oldPeriod(A+PERIOD)});
|
|
// Initialize only the shared anchor so the hook targets the user CAS.
|
|
tables.idcount[0].passcheckV2Anchor=A;
|
|
beforeUpdate=async(name)=>{assert.equal(name,'users');tables.users[0].passCheck=JSON.stringify({2:oldPeriod(A+PERIOD,{passCheck:[0]})});tables.users[0].coinAmount=1500;};
|
|
const r=await request('read');assert.deepEqual(r.data.current.paidClaimed,[1]);
|
|
await request('claim_level',{seasonId:r.data.current.id,level:1,track:'paid'});assert.equal(tables.users[0].coinAmount,1500);
|
|
});
|
|
|
|
test('unmigrated users keep the legacy interface while rollout is disabled',async()=>{
|
|
const legacy=JSON.stringify({2:oldPeriod(A+PERIOD)});tables.users[0].passCheck=legacy;
|
|
process.env.PASSCHECK_V2_ENABLED='false';await rejects(()=>request('read'),'FEATURE_DISABLED');
|
|
const r=await legacyPass({body:{uid:'u',token:'t',action:'read'}});
|
|
assert.equal(r.code,1);assert.equal(r.data.passCheck,legacy);assert.equal(tables.users[0].passCheckV2,undefined);
|
|
});
|