server/laf-cloud/tests/pass-check-v2.test.mjs

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