1311 lines
95 KiB
JavaScript
1311 lines
95 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { registerHooks } from 'node:module';
|
|
import { createHmac, createSign, createCipheriv, generateKeyPairSync } from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
const START = Date.parse('2026-09-17T00:00:00+08:00'), END = START + 4 * 86400000, NEXT = START + 7 * 86400000;
|
|
const originalNow = Date.now, originalFetch = globalThis.fetch;
|
|
let now = START + 1000, tables = {}, counter = 0, fault = null, beforeWrite = null;
|
|
Date.now = () => now;
|
|
globalThis.fetch = async () => { throw new Error('unexpected external request'); };
|
|
test.after(() => { Date.now = originalNow; globalThis.fetch = originalFetch; });
|
|
const copy = value => value == null ? value : structuredClone(value);
|
|
const table = name => tables[name] ||= [];
|
|
const field = (row, path) => path.split('.').reduce((v,k) => v?.[k], row);
|
|
function put(row, path, value) {
|
|
const parts = path.split('.'), last = parts.pop();
|
|
for (const p of parts) row = row[p] ||= {};
|
|
row[last] = copy(value);
|
|
}
|
|
function expression(row, value, variables = {}) {
|
|
if (typeof value === 'string' && value.startsWith('$$')) return field(variables, value.slice(2));
|
|
if (typeof value === 'string' && value.startsWith('$')) return field(row, value.slice(1));
|
|
if (value === null || typeof value !== 'object' || value instanceof Date) return value;
|
|
if (Array.isArray(value)) return value.map(v => expression(row, v, variables));
|
|
const [[op, args]] = Object.entries(value);
|
|
if (op === '$filter') return expression(row, args.input, variables).filter(task =>
|
|
expression(row, args.cond, {...variables, [args.as]: task}));
|
|
if (op === '$size') return expression(row, args, variables).length;
|
|
const values = expression(row, args, variables);
|
|
if (op === '$ifNull') return values[0] ?? values[1];
|
|
if (op === '$and') return values.every(Boolean);
|
|
if (op === '$gt') return values[0] > values[1];
|
|
if (op === '$lte') return values[0] <= values[1];
|
|
if (op === '$ne') return values[0] !== values[1];
|
|
throw Error('unsupported expression ' + op);
|
|
}
|
|
function matches(row, query) {
|
|
return Object.entries(query).every(([k,v]) => {
|
|
if (k === '$expr') return v.$lt ? now < v.$lt[1].getTime() : expression(row, v);
|
|
if (k === '$or') return v.some(q => matches(row,q));
|
|
const actual = field(row,k);
|
|
if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) return Object.entries(v).every(([op,x]) => {
|
|
if (op === '$lt') return actual < x;
|
|
if (op === '$lte') return actual <= x;
|
|
if (op === '$gt') return actual > x;
|
|
if (op === '$gte') return actual >= x;
|
|
if (op === '$ne') return actual !== x;
|
|
if (op === '$in') return x.includes(actual);
|
|
if (op === '$nin') return !x.includes(actual);
|
|
if (op === '$exists') return (actual !== undefined) === x;
|
|
if (op === '$not' && x instanceof RegExp) return !x.test(actual);
|
|
throw Error('unsupported query ' + op);
|
|
});
|
|
return v === null ? actual == null : actual === v;
|
|
});
|
|
}
|
|
const mongo = { collection(name) { return {
|
|
async findOne(query) { return copy(table(name).find(r => matches(r,query)) || null); },
|
|
find(query) {
|
|
let values = table(name).filter(r=>matches(r,query));
|
|
return {
|
|
sort(spec) { values.sort((a,b)=> { for (const [k,d] of Object.entries(spec)) { if (field(a,k)!==field(b,k)) return (field(a,k)<field(b,k)?-1:1)*d; } return 0; }); return this; },
|
|
limit(n) { values=values.slice(0,n); return this; },
|
|
async toArray() { return copy(values); },
|
|
};
|
|
},
|
|
async insertOne(data) {
|
|
if (table(name).some(r=>r._id===data._id)) throw Object.assign(Error('duplicate'),{code:11000});
|
|
table(name).push(copy(data)); return { insertedId:data._id };
|
|
},
|
|
async updateOne(query, update, options={}) {
|
|
if (beforeWrite) beforeWrite(name,query,update);
|
|
if (fault && fault(name,query,update)) throw Error('injected storage failure');
|
|
let row=table(name).find(r=>matches(r,query));
|
|
if (!row && options.upsert) {
|
|
if (table(name).some(r=>r._id===query._id)) throw Object.assign(Error('duplicate'),{code:11000});
|
|
row={_id:query._id || 'mock-'+(++counter),...copy(update.$setOnInsert || {})}; table(name).push(row);
|
|
for (const [k,v] of Object.entries(update.$set || {})) put(row,k,v);
|
|
return { modifiedCount:0, matchedCount:0, upsertedCount:1 };
|
|
}
|
|
if (!row) return { modifiedCount:0,matchedCount:0 };
|
|
for (const [k,v] of Object.entries(update.$set || {})) put(row,k,v);
|
|
for (const [k,v] of Object.entries(update.$inc || {})) put(row,k,(field(row,k)||0)+v);
|
|
return {modifiedCount:1,matchedCount:1};
|
|
},
|
|
async deleteOne(query) { const i=table(name).findIndex(r=>matches(r,query)); if(i>=0)table(name).splice(i,1); return {deletedCount:i>=0?1:0}; },
|
|
async createIndex() { return 'index'; },
|
|
}; } };
|
|
globalThis.__goldCloud = {
|
|
mongo:{db:mongo}, shared:{get(){return 'test-access-token';},set(){}},
|
|
storage:{bucket(){return {async readFile(){return {Body:{async transformToString(){return merchantKeys.privateKey;}}};}};}},
|
|
database:()=>({collection(name) { return {
|
|
async add(data) { return mongo.collection(name).insertOne({_id:'mock-'+(++counter),...data}); },
|
|
where(query) { return {
|
|
async getOne() { return {data:await mongo.collection(name).findOne(query)}; },
|
|
async get() { return {data:await mongo.collection(name).find(query).toArray()}; },
|
|
async update(data) { const r=await mongo.collection(name).updateOne(query,{$set:data}); return {updated:r.modifiedCount}; },
|
|
}; },
|
|
}; }}),
|
|
};
|
|
globalThis.require = () => ({initWithBatchMode:()=>({track(){},trackFirst(){},close(){}})});
|
|
registerHooks({resolve(specifier,context,next) {
|
|
if (specifier==='@lafjs/cloud') return {url:'data:text/javascript,export default globalThis.__goldCloud',shortCircuit:true};
|
|
if (specifier==='ip2region') return {url:'data:text/javascript,export default class { search(){return null;} }',shortCircuit:true};
|
|
if (specifier.startsWith('@/')) return {url:new URL('../../'+specifier.slice(2)+'.ts',import.meta.url).href,shortCircuit:true};
|
|
return next(specifier,context);
|
|
}});
|
|
const R = await import('../config.ts');
|
|
const S = await import('../service.ts');
|
|
const P = await import('../payment.ts');
|
|
const C = await import('../../activityConfig/store.ts');
|
|
const {default:api} = await import('../index.ts');
|
|
const {default:jobs} = await import('../jobs.ts');
|
|
const {default:admin} = await import('../admin.ts');
|
|
const {default:android} = await import('../../wx/orderPaySig.ts');
|
|
const {default:ios} = await import('../../wx/iosorderPaySig.ts');
|
|
const {default:callback} = await import('../../wx/payCallBack.ts');
|
|
const {default:queryPayment} = await import('../../wx/getPayInfo.ts');
|
|
const {default:getOrderReward} = await import('../../wx/getOrderReward.ts');
|
|
const {default:legacyQuery} = await import('../../wx/iosgetPayInfo.ts');
|
|
const {default:customerService} = await import('../../wx/KeFuInfo.ts');
|
|
const L = await import('../legacyPayment.ts');
|
|
const M = await import('../merchant.ts');
|
|
const {default:merchantNotify} = await import('../merchantNotify.ts');
|
|
const {default:checkIos} = await import('../../wx/checkIos.ts');
|
|
const {default:saveLevel} = await import('../../userLevel.ts');
|
|
|
|
const config = {configVersion:'v1',status:'published',effectiveFromPeriodId:'goldMiner:2026-09-17',publishedAt:START-1000,
|
|
unlockPassedLevel:41,timezone:'Asia/Shanghai',productId:'gold_miner',priceFen:600,currency:'CNY',
|
|
tasks:[1,2,3].map((targetWins,i)=>({taskId:'t'+(i+1),sequence:i+1,targetWins,items:[{type:'coin',count:100*(i+1)}]}))};
|
|
const version = c => { const {configVersion,publishedAt,status,...data}=copy(c); return {_id:C.configId('goldMiner',configVersion),activityId:'goldMiner',recordType:'version',configVersion,publishedAt,status,effectiveFrom:R.periodDates(c.effectiveFromPeriodId).startsAt,config:data}; };
|
|
const entitlements=()=>table(R.TABLES.players).filter(p=>p.entitlement).map(p=>p.entitlement);
|
|
const embeddedGrants=()=>table(R.TABLES.players).flatMap(p=>(p.tasks||[]).filter(t=>t.grantId));
|
|
const control=()=>{let c=table(R.TABLES.configs).find(c=>c.recordType==='control');if(!c){c={_id:S.controlId('goldMiner:2026-09-17'),activityId:'goldMiner',recordType:'control',purchaseEnabled:true};table(R.TABLES.configs).push(c);}return c;};
|
|
function reset(overrides={}) {
|
|
now=START+1000;tables={};counter=0;fault=null;beforeWrite=null;
|
|
table('users').push({_id:'u',token:'token',openid:'openid',session_key:'session',levelAmount:41,endLevelNum:1,coinAmount:500,isWhite:true,...overrides});
|
|
table(R.TABLES.configs).push(version(config));
|
|
process.env.PAYMENT_APP_ENV='production'; process.env.WX_MIDAS_PAY_SIGN_KEY='synthetic-key';
|
|
process.env.GOLD_MINER_ADMIN_TOKEN='admin-test';
|
|
process.env.ADMIN_TOKEN='admin-test';
|
|
delete process.env.GOLD_MINER_TEST_PERIODS_ENABLED;
|
|
delete process.env.GOLD_MINER_LEGACY_ENABLED;
|
|
globalThis.fetch = async () => { throw new Error('unexpected external request'); };
|
|
}
|
|
const user=()=>copy(table('users')[0]);
|
|
const ctx=body=>({body,headers:{},socket:{remoteAddress:'127.0.0.1'}});
|
|
const call=(action,more={})=>api(ctx({action,uid:'u',token:'token',...more}));
|
|
const event=(i,mode='main',more={})=>({periodId:R.calendar(now).periodId,eventId:'event'+i,mode,outcome:'win',
|
|
...(mode==='main'?{clearedMainLevel:41+i}:{endlessSequence:i}),...more});
|
|
async function win(i,mode='main',more={}) {
|
|
if (mode==='main') table('users')[0].levelAmount=41+i;
|
|
return S.recordProgress('u',user(),event(i,mode,more));
|
|
}
|
|
const player=periodId=>table(R.TABLES.players).find(p=>p.periodId===(periodId||'goldMiner:2026-09-17'));
|
|
async function buy(create=android,more={}) {
|
|
const result=await create(ctx({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'buy1',itemCount:99,itemPrice:1,...more}));
|
|
assert.equal(result.code,1,JSON.stringify(result)); return table('order').find(o=>o.outTradeNo===result.data.outTradeNo);
|
|
}
|
|
function notification(order,changes={}) {
|
|
const Event='minigame_game_pay_goods_deliver_notify';
|
|
const Payload=JSON.stringify({OutTradeNo:order.outTradeNo,OpenId:order.openid,Env:0,
|
|
GoodsInfo:{ProductId:order.itemid,Quantity:order.itemCount,OrigPrice:order.goodsPrice},...changes});
|
|
return ctx({Event,MiniGame:{Payload,PayEventSig:createHmac('sha256',process.env.WX_MIDAS_PAY_SIGN_KEY).update(Event+'&'+Payload).digest('hex'),IsMock:false}});
|
|
}
|
|
async function pay(order) { const r=await callback(notification(order)); assert.equal(r.ErrCode,0,JSON.stringify(r)); }
|
|
const claim=taskId=>call('claim',{periodId:'goldMiner:2026-09-17',taskId,requestId:'claim-'+taskId});
|
|
async function deliver(taskId,periodId='goldMiner:2026-09-17') {
|
|
const g=await call('claim',{periodId,taskId,requestId:'claim-'+taskId}); assert.equal(g.code,1,JSON.stringify(g));
|
|
const r=await call('confirm_delivery',{periodId,taskId,grantId:g.data.grantId}); assert.equal(r.code,1,JSON.stringify(r)); return g.data.grantId;
|
|
}
|
|
|
|
test('calendar has exact Beijing Thursday/Monday boundaries and validates period IDs',()=>{
|
|
assert.equal(R.calendar(START).periodId,'goldMiner:2026-09-17');
|
|
assert.equal(R.open(R.calendar(START),START),true);assert.equal(R.open(R.calendar(END-1),END-1),true);
|
|
assert.equal(R.open(R.calendar(END),END),false);assert.equal(R.calendar(NEXT).periodId,'goldMiner:2026-09-24');
|
|
assert.throws(()=>R.periodDates('goldMiner:2026-09-18'));assert.throws(()=>R.periodDates('goldMiner:2026-02-30'));
|
|
assert.equal(R.periodDates('goldMiner:2026-12-31').nextPeriodId,'goldMiner:2027-01-07');
|
|
});
|
|
test('configuration rejects unsupported rewards, gaps, duplicate goals, invalid price and unbounded dedup size',()=>{
|
|
assert.equal(R.validateConfig(config).priceFen,600);
|
|
for (const change of [c=>c.priceFen=0,c=>c.tasks[1].targetWins=1,c=>c.tasks[1].sequence=3,
|
|
c=>c.tasks[1].taskId='t1',c=>c.tasks[0].items[0].type='hammer',c=>c.tasks[2].targetWins=1001,c=>c.timezone='UTC']) {
|
|
const c=copy(config);change(c);assert.throws(()=>R.validateConfig(c));
|
|
}
|
|
});
|
|
test('authentication fails closed, account scope is fixed, and level 40 is locked',async()=>{
|
|
reset({levelAmount:40});assert.equal((await call('info')).data.status,'locked');
|
|
for(const extra of [{token:''},{token:'bad'},{uid:'other'},{gameName:'iaa'}]) assert.equal((await call('info',extra)).errorCode,'UNAUTHORIZED');
|
|
delete table('users')[0].token;assert.equal((await call('info')).errorCode,'UNAUTHORIZED');
|
|
});
|
|
test('level 41 only unlocks; first main win creates a record without info or payment',async()=>{
|
|
reset();const zero=await S.recordProgress('u',user(),event(0));assert.equal(zero.activityCounted,false);
|
|
assert.equal((await win(1)).progressWins,1);assert.equal(player().entitlementStatus,'none');
|
|
assert.equal(player().configSnapshot.tasks.length,3);assert.equal(table('users')[0].coinAmount,500);
|
|
});
|
|
test('main and endless share progress; repeated template is allowed; request and business keys dedup',async()=>{
|
|
reset();await win(1);assert.equal((await win(1)).duplicate,true);
|
|
assert.equal((await win(1,'main',{eventId:'new-request'})).duplicate,true);
|
|
assert.equal((await win(2,'endless',{templateId:100})).progressWins,2);
|
|
assert.equal((await win(3,'endless',{templateId:100})).progressWins,3);
|
|
assert.equal((await win(4,'endless')).activityCounted,false);assert.equal(player().progressDedup.entries.length,3);
|
|
await assert.rejects(()=>win(8,'endless',{eventId:'event1'}),e=>e.errorCode==='EVENT_PAYLOAD_CONFLICT');
|
|
});
|
|
test('parallel distinct and duplicate events update dedup and progress atomically',async()=>{
|
|
reset();table(R.TABLES.configs)[0].config.tasks=[{taskId:'all',sequence:1,targetWins:50,items:[{type:'coin',count:100}]}];
|
|
await Promise.all(Array.from({length:8},(_,i)=>win(i+1,'endless')));
|
|
await Promise.all(Array.from({length:8},()=>win(9,'endless')));
|
|
assert.equal(player().progressWins,9);assert.equal(player().progressDedup.entries.length,9);
|
|
});
|
|
test('cutoff rejects new events, preserves previous success on retry, and never moves old events into next period',async()=>{
|
|
reset();const original=event(1);await win(1);now=END;
|
|
assert.equal((await S.recordProgress('u',user(),original)).duplicate,true);
|
|
await assert.rejects(()=>win(2),e=>e.errorCode==='PERIOD_ENDED');now=NEXT;
|
|
await assert.rejects(()=>S.recordProgress('u',user(),{...original,eventId:'late',clearedMainLevel:43}),e=>e.errorCode==='PERIOD_ENDED');
|
|
assert.equal(player().progressWins,1);
|
|
});
|
|
test('database time prevents an in-flight write after cutoff; no recovery record is created',async()=>{
|
|
reset();await call('info');now=END-1;
|
|
beforeWrite=(name,q)=>{if(name===R.TABLES.players&&q.$expr) now=END;};
|
|
await assert.rejects(()=>win(1),e=>e.errorCode==='PERIOD_ENDED');
|
|
assert.equal(player().progressWins,0);assert.deepEqual(player().progressDedup.entries,[]);
|
|
});
|
|
test('failed progress write is not counted and has no late replay queue',async()=>{
|
|
reset();await call('info');fault=(name,q)=>name===R.TABLES.players&&q.revision!==undefined;
|
|
await assert.rejects(()=>win(1));fault=null;now=END;
|
|
await assert.rejects(()=>win(1),e=>e.errorCode==='PERIOD_ENDED');assert.equal(player().progressWins,0);
|
|
assert.equal(Object.keys(tables).some(n=>n.includes('clear_events')),false);
|
|
});
|
|
test('period snapshot stays unchanged after config edits and next period uses the new version',async()=>{
|
|
reset();await call('info');table(R.TABLES.configs).push(version({...copy(config),configVersion:'vLater',effectiveFromPeriodId:'goldMiner:2026-09-24',publishedAt:START+5000,tasks:[{taskId:'x',sequence:1,targetWins:1,items:[{type:'coin',count:999}]}]}));
|
|
assert.equal((await call('info')).data.tasks[0].itemsSnapshot[0].count,100);
|
|
table(R.TABLES.configs).push(version({...copy(config),configVersion:'v2',effectiveFromPeriodId:'goldMiner:2026-09-24',publishedAt:START+10000,priceFen:900}));
|
|
now=NEXT;assert.equal((await call('info')).data.priceFen,900);assert.equal(player().configSnapshot.priceFen,600);
|
|
});
|
|
test('both native order routes use server price and one order per period, with a single business JSON field',async()=>{
|
|
for(const creator of [android,ios]) {
|
|
reset();const result=await Promise.all([buy(creator),buy(creator,{createRequestId:'other'})]);
|
|
assert.equal(table('order').length,1);assert.equal(result[0].goodsPrice,600);assert.equal(result[0].itemCount,1);
|
|
assert.equal(result[0].goldMiner.periodId,'goldMiner:2026-09-17');assert.equal(result[0].goldMiner.fulfillmentRoute,null);
|
|
assert.equal(result[0].goldMinerPeriodId,undefined);assert.equal(result[0].uid,undefined);
|
|
}
|
|
});
|
|
test('invalid auth, wrong reserved product, missing signing key, stopped and closed periods cannot create orders',async()=>{
|
|
reset();for(const extra of [{token:'bad'},{itemid:'gold_miner_other'},{createRequestId:''}]) {
|
|
assert.equal((await android(ctx({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'buy',...extra}))).code,0);
|
|
}
|
|
delete process.env.WX_MIDAS_PAY_SIGN_KEY;assert.equal((await android(ctx({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'buy'}))).code,0);
|
|
reset();await call('info');control().purchaseEnabled=false;
|
|
assert.equal((await P.createNativeOrder({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'b'},'and')).errorCode,'PURCHASE_DISABLED');
|
|
now=END;assert.equal((await P.createNativeOrder({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'b'},'and')).errorCode,'PERIOD_ENDED');
|
|
});
|
|
test('production callback verifies signature, environment, user, product, amount and quantity',async()=>{
|
|
reset();const o=await buy();
|
|
for(const change of [c=>c.body.MiniGame.PayEventSig='0'.repeat(64),c=>c.body.MiniGame.IsMock=true,
|
|
c=>Object.assign(c,notification(o,{OpenId:'wrong'})),c=>Object.assign(c,notification(o,{Env:1})),
|
|
c=>Object.assign(c,notification(o,{GoodsInfo:{ProductId:o.itemid,Quantity:2,OrigPrice:600}}))]) {
|
|
const c=notification(o);change(c);assert.notEqual((await callback(c)).ErrCode,0);assert.equal(o.state,0);
|
|
}
|
|
await pay(o);assert.equal(o.state,2);assert.equal(player().entitlementStatus,'unlocked');assert.equal(table('users')[0].coinAmount,500);
|
|
});
|
|
test('payment and fulfillment retries do not grant twice or turn delivered original rewards into carry',async()=>{
|
|
reset();await win(1);const o=await buy();now=END;await pay(o);
|
|
assert.equal(o.goldMiner.fulfillmentRoute,'settle_original');
|
|
const receipt=(await call('settlements')).data.items[0];
|
|
assert.equal((await call('confirm_settlement_delivery',{settlementId:receipt.settlementId,grantIds:receipt.rewards.map(r=>r.grantId)})).code,1);
|
|
await pay(o);await jobs();assert.equal(entitlements().length,1);
|
|
assert.equal(o.goldMiner.targetPeriodId,'goldMiner:2026-09-17');assert.equal(table('users')[0].coinAmount,500);
|
|
});
|
|
test('earned tasks can be claimed and confirmed in any order without duplicating grants',async()=>{
|
|
reset();await win(1);await win(2);await win(3);
|
|
assert.equal((await claim('t1')).errorCode,'NOT_PAID');const o=await buy();await pay(o);
|
|
assert.equal((await claim('t2')).code,1);
|
|
const grants=await Promise.all([claim('t1'),claim('t1')]);assert.equal(grants[0].data.grantId,grants[1].data.grantId);
|
|
assert.equal(player().claimedThrough,0);assert.equal((await claim('t2')).code,1);
|
|
assert.equal(table('users')[0].coinAmount,500);
|
|
await deliver('t2');await deliver('t2');assert.equal(player().claimedThrough,0);await deliver('t1');
|
|
assert.equal(embeddedGrants().length,2);assert.equal(player().claimedThrough,2);
|
|
});
|
|
test('only owning player may confirm and a fake grant cannot mark rewards delivered',async()=>{
|
|
reset();await win(1);const o=await buy();await pay(o);const g=await claim('t1');
|
|
assert.equal((await call('confirm_delivery',{periodId:o.goldMiner.periodId,taskId:'t1',grantId:'fake'})).errorCode,'INVALID_GRANT');
|
|
table('users').push({_id:'other',token:'other-token',levelAmount:50});
|
|
assert.equal((await call('confirm_delivery',{uid:'other',token:'other-token',periodId:o.goldMiner.periodId,taskId:'t1',grantId:g.data.grantId})).code,0);
|
|
});
|
|
test('expiry closes claim, server prepares only unpaid deliveries and preserves in-flight grant',async()=>{
|
|
reset();await win(1);await win(2);await pay(await buy());const g=await claim('t1');now=END;
|
|
assert.equal((await claim('t1')).errorCode,'PERIOD_SETTLEMENT_REQUIRED');
|
|
assert.equal((await call('settlements')).data.status,'settling');
|
|
assert.equal(player().progressClosed,false); // Query must not settle on behalf of the job.
|
|
await jobs();assert.equal(player().progressClosed,true);assert.equal(table('users')[0].coinAmount,500);
|
|
const list=(await call('settlements')).data;assert.equal(list.status,'pending_delivery');
|
|
const s=list.items[0];assert.deepEqual(s.rewards.map(r=>r.taskId),['t1','t2']);
|
|
assert.equal(s.rewards[0].grantId,g.data.grantId);
|
|
assert.equal((await call('ack_settlement',{settlementId:s.settlementId})).errorCode,'DELIVERY_PENDING');
|
|
assert.equal((await call('confirm_settlement_delivery',{settlementId:s.settlementId,grantIds:[s.rewards[1].grantId]})).data.deliveryStatus,'partial');
|
|
assert.equal(player().claimedThrough,0);
|
|
// The original confirmation remains valid after cutoff and uses the same state as batch confirmation.
|
|
assert.equal((await call('confirm_delivery',{periodId:s.periodId,taskId:'t1',grantId:g.data.grantId})).code,1);
|
|
assert.deepEqual((await call('settlements')).data.items,[]);
|
|
const body={settlementId:s.settlementId,grantIds:s.rewards.map(r=>r.grantId)};
|
|
assert.equal((await call('confirm_settlement_delivery',body)).data.deliveryStatus,'client_saved');
|
|
assert.equal((await call('confirm_settlement_delivery',body)).code,1);
|
|
assert.deepEqual((await call('settlements')).data,{status:'no_pending_rewards',items:[],nextCursor:null});
|
|
assert.equal((await claim('t1')).errorCode,'PERIOD_SETTLEMENT_REQUIRED');
|
|
assert.equal((await call('ack_settlement',{settlementId:s.settlementId})).code,1);
|
|
assert.equal(player().claimedThrough,2);assert.equal(table('users')[0].coinAmount,500);
|
|
});
|
|
test('zero-reward delayed payment reserves next period; opens automatically and cannot be bought again',async()=>{
|
|
reset();const o=await buy();now=END;await pay(o);
|
|
assert.equal(o.goldMiner.fulfillmentRoute,'carry_next');assert.equal(entitlements()[0].state,'reserved');
|
|
now=NEXT;await jobs();const info=await call('info');assert.equal(info.data.status,'unlocked');assert.equal(player('goldMiner:2026-09-24').entitlementSource,'delayed_payment_carry');
|
|
assert.equal(info.data.progressWins,0);
|
|
assert.equal((await android(ctx({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'next'}))).errorCode,'ALREADY_UNLOCKED');
|
|
now=NEXT+4*86400000;await jobs();assert.equal(entitlements().length,1);
|
|
assert.equal(player('goldMiner:2026-09-24').settlementState,'no_reward');
|
|
});
|
|
test('carry activation preserves already-earned next-period unpaid progress',async()=>{
|
|
reset();const o=await buy();now=NEXT;await win(1);assert.equal(player('goldMiner:2026-09-24').progressWins,1);
|
|
await pay(o);assert.equal(player('goldMiner:2026-09-24').progressWins,1);assert.equal((await call('info')).data.status,'unlocked');
|
|
});
|
|
test('normal in-period payment with zero rewards does not carry',async()=>{
|
|
reset();const o=await buy();await pay(o);now=END;await jobs();assert.equal(o.goldMiner.fulfillmentRoute,'current_period');
|
|
now=NEXT;assert.equal((await call('info')).data.status,'purchasable');assert.equal(entitlements().length,1);
|
|
});
|
|
test('route and embedded entitlement survive storage outages without a new reward',async()=>{
|
|
reset();await win(1);const o=await buy();
|
|
fault=(name,q,u)=>name===R.TABLES.players&&!!u.$set?.entitlement;
|
|
assert.notEqual((await callback(notification(o))).ErrCode,0);assert.equal(o.goldMiner.fulfillmentRoute,'current_period');
|
|
now=END;fault=null;await pay(o);
|
|
const receipt=(await call('settlements')).data.items[0], id=receipt.rewards[0].grantId;
|
|
await pay(o);assert.equal((await call('settlements')).data.items[0].rewards[0].grantId,id);
|
|
assert.equal(embeddedGrants().length,1);assert.equal(entitlements().length,1);
|
|
});
|
|
test('generic reward endpoint cannot fake payment; query retries confirmed fulfillment and requires auth',async()=>{
|
|
reset();const o=await buy();
|
|
assert.equal((await getOrderReward(ctx({uid:'u',token:'token',outTradeNo:o.outTradeNo}))).errorCode,'PAYMENT_PENDING');assert.equal(o.state,0);
|
|
await pay(o);
|
|
assert.equal((await queryPayment(ctx({uid:'u',token:'bad',outTradeNo:o.outTradeNo}))).errorCode,'UNAUTHORIZED');
|
|
const r=await legacyQuery(ctx({uid:'u',token:'token',outTradeNo:o.outTradeNo}));assert.equal(r.code,1);assert.equal(r.data.rewardDelivery,'goldMiner.claim');
|
|
});
|
|
test('legacy customer-service payment rejects calls without an authenticated pre-order',async()=>{
|
|
reset();const r=await customerService(ctx({FromUserName:'openid',SessionFrom:JSON.stringify({tpye:'ios',propName:'gold_miner'})}));
|
|
assert.equal(r.errorCode,'INVALID_INPUT');assert.equal(table('order').length,0);
|
|
});
|
|
test('userLevel integration accepts form-encoded events and keeps ordinary save success on activity failure',async()=>{
|
|
reset();const e=event(1);const r=await saveLevel(ctx({action:'save',uid:'u',token:'token',levelAmount:'42',goldMiner:JSON.stringify(e)}));
|
|
assert.equal(r.code,1);assert.equal(r.data.goldMiner.progressWins,1);assert.equal(table('users')[0].levelAmount,42);
|
|
now=END;const late=await saveLevel(ctx({action:'save',uid:'u',token:'token',levelAmount:43,goldMiner:JSON.stringify(event(2))}));
|
|
assert.equal(late.code,1);assert.equal(late.data.goldMiner.errorCode,'PERIOD_ENDED');assert.equal(table('users')[0].levelAmount,43);
|
|
});
|
|
test('admin is protected and can only publish immutable future configurations',async()=>{
|
|
reset();assert.equal((await admin(ctx({action:'setup_indexes'}))).errorCode,'UNAUTHORIZED');
|
|
assert.equal((await admin(ctx({action:'publish',adminToken:'admin-test',config}))).errorCode,'CONFIG_UNAVAILABLE');
|
|
const future={...config,configVersion:'v2',effectiveFromPeriodId:'goldMiner:2026-09-24'};
|
|
assert.equal((await admin(ctx({action:'publish',adminToken:'admin-test',config:future}))).code,1);
|
|
assert.equal((await admin(ctx({action:'publish',adminToken:'admin-test',config:future}))).code,1);
|
|
assert.equal((await admin(ctx({action:'setup_indexes',adminToken:'admin-test'}))).code,1);
|
|
});
|
|
|
|
test('an issuing task is displayed as delivery pending instead of a fresh claim button',async()=>{
|
|
reset();await win(1);await pay(await buy());await claim('t1');
|
|
const t=(await call('info')).data.tasks[0];assert.equal(t.claimStatus,'issuing');assert.equal(t.claimable,undefined);assert.equal(t.issuing,undefined);assert.equal(t.blockReason,undefined);
|
|
});
|
|
test('ordinary abandoned pending order does not permanently block next-week purchases; late conflict is flagged',async()=>{
|
|
reset();const old=await buy();now=NEXT;const next=await buy();await pay(next);
|
|
assert.notEqual((await callback(notification(old))).ErrCode,0);
|
|
assert.equal(old.goldMiner.fulfillmentStatus,'manual_review');assert.equal(next.goldMiner.fulfillmentStatus,'fulfilled');
|
|
assert.equal(entitlements().length,1);assert.equal(player('goldMiner:2026-09-24').sourceOrderNo,next.outTradeNo);
|
|
});
|
|
test('very late zero-progress payment never silently carries to a third period',async()=>{
|
|
reset();const o=await buy();now=NEXT+4*86400000;
|
|
assert.notEqual((await callback(notification(o))).ErrCode,0);assert.equal(o.goldMiner.fulfillmentStatus,'manual_review');
|
|
assert.equal(o.goldMiner.targetPeriodId,'goldMiner:2026-09-24');assert.equal(entitlements().length,0);
|
|
});
|
|
test('test deployment verifies and fulfills test orders with the same business rules',async()=>{
|
|
reset();process.env.PAYMENT_APP_ENV='test';const o=await buy(ios);assert.ok(o.outTradeNo.startsWith('wct_'));
|
|
await pay(o);assert.equal(o.state,2);assert.equal(player().entitlementStatus,'unlocked');
|
|
o.paymentAppEnv='production';assert.notEqual((await callback(notification(o))).ErrCode,0);
|
|
});
|
|
test('draft or absent commercial configuration disables sales without inventing price or rewards',async()=>{
|
|
reset();table(R.TABLES.configs)[0].status='draft';assert.equal((await call('info')).data.status,'unavailable');
|
|
assert.equal((await P.createNativeOrder({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'b'},'and')).code,0);
|
|
assert.equal(table('order').length,0);
|
|
});
|
|
test('existing entitlement is activated through a win report without first opening the activity',async()=>{
|
|
reset();const o=await buy();now=END;await pay(o);now=NEXT;
|
|
await win(1);assert.equal(player('goldMiner:2026-09-24').entitlementStatus,'unlocked');
|
|
assert.equal(player('goldMiner:2026-09-24').progressWins,1);
|
|
});
|
|
test('same event key is isolated per user and unrelated confirmations cannot change another player',async()=>{
|
|
reset();await win(1);const u={...user(),_id:'second',token:'second-token',openid:'second-openid'};table('users').push(u);
|
|
await S.recordProgress('second',u,event(1));assert.equal(table(R.TABLES.players).length,2);
|
|
assert.ok(table(R.TABLES.players).every(p=>p.progressWins===1));
|
|
});
|
|
test('settlement atomic write retries keep original grant and acknowledgement in player record',async()=>{
|
|
reset();await win(1);await pay(await buy());const g=await claim('t1');now=END;
|
|
fault=(name,q,u)=>name===R.TABLES.players&&!!u.$set?.settlementPreparedAt;
|
|
assert.ok((await jobs()).failures);assert.equal((await call('settlements')).data.status,'settling');
|
|
fault=null;now+=60000;await jobs();const receipt=(await call('settlements')).data.items[0];
|
|
assert.equal(receipt.rewards[0].grantId,g.data.grantId);
|
|
await call('confirm_settlement_delivery',{settlementId:receipt.settlementId,grantIds:[g.data.grantId]});
|
|
await call('ack_settlement',{settlementId:receipt.settlementId});await jobs();
|
|
assert.ok(player().settlementAcknowledgedAt);assert.equal(table('users')[0].coinAmount,500);
|
|
});
|
|
test('confirmation storage failure leaves the authoritative grant pending for retry',async()=>{
|
|
reset();await win(1);await pay(await buy());const g=(await claim('t1')).data;
|
|
fault=(name,q,u)=>name===R.TABLES.players&&u.$set?.claimedThrough===1;
|
|
const body={periodId:'goldMiner:2026-09-17',taskId:'t1',grantId:g.grantId};
|
|
assert.equal((await call('confirm_delivery',body)).code,0);assert.equal(player().claimedThrough,0);fault=null;
|
|
assert.equal((await call('confirm_delivery',body)).code,1);assert.equal(embeddedGrants()[0].claimStatus,'claimed');
|
|
});
|
|
test('timer drains pending states in bounded batches and malformed current config does not stop old closures',async()=>{
|
|
reset();await call('info');const seed=copy(player());table(R.TABLES.players).length=0;
|
|
for(let i=0;i<101;i++) table(R.TABLES.players).push({...copy(seed),_id:String(i).padStart(4,'0'),uid:'user'+i});
|
|
now=NEXT;table(R.TABLES.configs)[0].config.priceFen=0;
|
|
const first=await jobs();assert.equal(first.players,100);assert.equal(first.failures,1);
|
|
const second=await jobs();assert.equal(second.players,1);assert.ok(table(R.TABLES.players).every(p=>p.progressClosed));
|
|
});
|
|
test('main event cannot claim historical completed levels or exceed the current save',async()=>{
|
|
reset({levelAmount:100});await assert.rejects(()=>S.recordProgress('u',user(),event(1)),e=>e.errorCode==='INVALID_INPUT');
|
|
await assert.rejects(()=>S.recordProgress('u',user(),event(100)),e=>e.errorCode==='INVALID_INPUT');
|
|
});
|
|
|
|
|
|
// Merchant-channel tests use generated RSA keys and AES-GCM; no live provider is contacted.
|
|
const merchantKeys = generateKeyPairSync('rsa', {modulusLength:2048,publicKeyEncoding:{type:'spki',format:'pem'},privateKeyEncoding:{type:'pkcs8',format:'pem'}});
|
|
let provider, requests, sentLinks;
|
|
function signedResponse(data, status=200, changes={}) {
|
|
const raw = typeof data==='string' ? data : JSON.stringify(data);
|
|
const headers = {'wechatpay-timestamp':String(Math.floor(now/1000)),'wechatpay-nonce':'nonce','wechatpay-serial':'test-public-key'};
|
|
headers['wechatpay-signature']=createSign('RSA-SHA256').update(`${headers['wechatpay-timestamp']}\nnonce\n${raw}\n`).sign(merchantKeys.privateKey,'base64');
|
|
Object.assign(headers,changes);
|
|
return {ok:status>=200&&status<300,status,headers:{get:k=>headers[k]},async text(){return raw;}};
|
|
}
|
|
function legacyReset() {
|
|
reset();requests=[];sentLinks=[];provider={tradeState:'NOT_EXIST',badSignature:false,prepayCalls:0,closeCalls:0};
|
|
Object.assign(process.env,{
|
|
GOLD_MINER_LEGACY_ENABLED:'true',WX_MCH_ID:'merchant',WX_MINIGAME_APP_ID:'app',WX_MCH_CERT_SERIAL_NO:'cert',WX_PAY_PRIVATE_KEY_BUCKET:'bucket',
|
|
GOLD_MINER_ORDER_TICKET_SECRET:'0123456789abcdef0123456789abcdef',GOLD_MINER_MERCHANT_NOTIFY_URL:'https://test.invalid/goldMiner/merchantNotify',
|
|
GOLD_MINER_PAY_PAGE_URL:'https://pay.invalid/order3.html',GOLD_MINER_CHECK_IOS_URL:'https://test.invalid/wx/checkIos',
|
|
GOLD_MINER_WECHATPAY_PUBLIC_KEYS:JSON.stringify({'test-public-key':merchantKeys.publicKey}),GOLD_MINER_WECHATPAY_API_V3_KEY:'0123456789abcdef0123456789abcdef',
|
|
});
|
|
globalThis.fetch=async (url,options)=>{
|
|
requests.push({url,options});
|
|
if(url.startsWith('https://api.weixin.qq.com/')) {sentLinks.push(JSON.parse(options.body));return {ok:true,async json(){return {errcode:0};}};}
|
|
if(provider.throwNetwork) throw Error('network interrupted');
|
|
const order=table('order').find(o=>o.goldMiner?.paymentChannel==='legacy_ios');
|
|
const bad=provider.badSignature?{'wechatpay-signature':'invalid'}:{};
|
|
if(url.endsWith('/v3/pay/transactions/jsapi')) {
|
|
provider.prepayCalls++;provider.tradeState='NOTPAY';
|
|
if(provider.afterPrepay) await provider.afterPrepay(order);
|
|
return signedResponse({prepay_id:'wx-prepay-'+provider.prepayCalls},200,bad);
|
|
}
|
|
if(url.endsWith('/close')) {
|
|
provider.closeCalls++;
|
|
if(provider.closePaid) {provider.tradeState='SUCCESS';return signedResponse({code:'ORDERPAID'},400);}
|
|
provider.tradeState='CLOSED'; return signedResponse('',204,bad);
|
|
}
|
|
if(provider.tradeState==='NOT_EXIST')return signedResponse({code:'ORDER_NOT_EXIST'},404,bad);
|
|
return signedResponse({...merchantTransaction(order),trade_state:provider.tradeState,...provider.queryChanges},200,bad);
|
|
};
|
|
}
|
|
async function legacyBuy(more={}) {
|
|
const r=await call('create_order',{channel:'legacy_ios',itemid:'gold_miner',createRequestId:'legacy-buy',...more});
|
|
assert.equal(r.code,1,JSON.stringify(r));return r.data;
|
|
}
|
|
const legacyOrder=()=>table('order').find(o=>o.goldMiner?.paymentChannel==='legacy_ios');
|
|
async function sendLink(data) {return customerService(ctx({FromUserName:'openid',SessionFrom:data.sessionFrom}));}
|
|
function linkParam() {return JSON.parse(decodeURIComponent(sentLinks.at(-1).link.url.split('#data=')[1]));}
|
|
function merchantTransaction(order,changes={}) {
|
|
return {out_trade_no:order.outTradeNo,appid:'app',mchid:'merchant',payer:{openid:order.openid},trade_type:'JSAPI',trade_state:'SUCCESS',
|
|
amount:{total:order.goodsPrice,currency:'CNY'},transaction_id:'transaction-'+order.outTradeNo,success_time:new Date(now).toISOString(),attach:order.goldMiner.periodId,...changes};
|
|
}
|
|
function merchantNotice(order,changes={}) {
|
|
const cipher=createCipheriv('aes-256-gcm',Buffer.from(process.env.GOLD_MINER_WECHATPAY_API_V3_KEY),Buffer.from('123456789012'));
|
|
cipher.setAAD(Buffer.from('transaction'));
|
|
const ciphertext=Buffer.concat([cipher.update(JSON.stringify(merchantTransaction(order,changes))),cipher.final(),cipher.getAuthTag()]).toString('base64');
|
|
const raw=JSON.stringify({event_type:'TRANSACTION.SUCCESS',resource:{algorithm:'AEAD_AES_256_GCM',original_type:'transaction',nonce:'123456789012',associated_data:'transaction',ciphertext}});
|
|
const response=signedResponse(raw);let status;
|
|
return {body:JSON.parse(raw),rawBody:raw,headers:Object.fromEntries(['timestamp','nonce','serial','signature'].map(k=>['wechatpay-'+k,response.headers.get('wechatpay-'+k)])),
|
|
response:{status(value){status=value;}},get status(){return status;}};
|
|
}
|
|
|
|
test('legacy pre-order is authenticated, server-priced, canonical and channel-exclusive',async()=>{
|
|
legacyReset();const first=await legacyBuy({itemPrice:1,itemCount:99});const o=legacyOrder();
|
|
assert.equal(first.priceFen,600);assert.equal(first.quantity,1);assert.equal(o.goldMiner.periodId,R.calendar(now).periodId);
|
|
assert.equal(o.goldMiner.merchantSnapshot.mchid,'merchant');assert.equal(table('iosOrder').length,0);assert.equal(requests.length,0);
|
|
assert.equal((await legacyBuy({createRequestId:'another'})).outTradeNo,first.outTradeNo);assert.equal(table('order').length,1);
|
|
assert.equal((await android(ctx({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'native'}))).errorCode,'PAYMENT_CHANNEL_CONFLICT');
|
|
legacyReset();await buy();assert.equal((await call('create_order',{channel:'legacy_ios',itemid:'gold_miner',createRequestId:'legacy'})).errorCode,'PAYMENT_CHANNEL_CONFLICT');
|
|
});
|
|
|
|
test('legacy feature gate, auth and purchase cutoff prevent pre-orders and payment links',async()=>{
|
|
legacyReset();process.env.GOLD_MINER_LEGACY_ENABLED='false';assert.equal((await call('create_order',{channel:'legacy_ios'})).errorCode,'PAYMENT_CHANNEL_DISABLED');
|
|
legacyReset();assert.equal((await call('create_order',{token:'bad',channel:'legacy_ios'})).errorCode,'UNAUTHORIZED');
|
|
const d=await legacyBuy();control().purchaseEnabled=false;assert.equal((await sendLink(d)).errorCode,'PURCHASE_DISABLED');
|
|
control().purchaseEnabled=true;now=END;assert.equal((await sendLink(d)).code,0);assert.equal(provider.prepayCalls,0);
|
|
});
|
|
|
|
test('customer link ignores client price and uses scoped tickets and environment-specific check URL',async()=>{
|
|
legacyReset();const d=await legacyBuy();let session=JSON.parse(d.sessionFrom);session.price=1;session.count=99;
|
|
assert.equal((await sendLink({...d,sessionFrom:JSON.stringify(session)})).code,1);
|
|
const o=legacyOrder(), param=linkParam();
|
|
assert.equal(param.checkUrl,'https://test.invalid/wx/checkIos');assert.equal(param.quantity,1);assert.equal(param.price,600);
|
|
assert.equal(table('iosOrder')[0].goldMiner.configHash,o.goldMiner.configHash);
|
|
assert.equal(table('iosOrder')[0].goldMiner.paymentChannel,'legacy_ios');
|
|
const posted=JSON.parse(requests.find(r=>r.url.endsWith('/transactions/jsapi')).options.body);
|
|
assert.equal(posted.time_expire,new Date(END).toISOString());assert.equal(posted.amount.total,600);
|
|
assert.equal((await sendLink(d)).code,1);assert.equal(provider.prepayCalls,1);
|
|
assert.equal((await customerService(ctx({FromUserName:'another',SessionFrom:d.sessionFrom}))).errorCode,'UNAUTHORIZED');
|
|
session.goldMinerTicket=session.goldMinerTicket.slice(0,-1)+(session.goldMinerTicket.endsWith('0')?'1':'0');
|
|
assert.equal((await sendLink({...d,sessionFrom:JSON.stringify(session)})).errorCode,'UNAUTHORIZED');
|
|
});
|
|
|
|
test('checkIos preserves full snapshots, is idempotent, and never turns association into payment',async()=>{
|
|
legacyReset();const d=await legacyBuy();await sendLink(d);const param=linkParam();const o=legacyOrder();
|
|
assert.equal(await checkIos(ctx({openid:param.openid,time:param.time,outTradeNo:param.outTradeNo})),true);
|
|
assert.equal(o.state,0);assert.equal(o.goldMiner.confirmedAt,0);assert.equal(table('iosOrder').length,0);
|
|
assert.equal(await checkIos(ctx({openid:param.openid,time:param.time,outTradeNo:param.outTradeNo})),true);
|
|
assert.equal(await checkIos(ctx({openid:'another',time:param.time,outTradeNo:param.outTradeNo})),false);
|
|
assert.equal(await checkIos(ctx({openid:param.openid,time:'12345',outTradeNo:param.outTradeNo})),false);
|
|
});
|
|
|
|
test('payment before transfer and stale compatibility copy cannot roll back paid order',async()=>{
|
|
legacyReset();const d=await legacyBuy();await sendLink(d);const param=linkParam(),o=legacyOrder();
|
|
assert.equal((await merchantNotify(merchantNotice(o))).code,'SUCCESS');assert.equal(o.state,2);
|
|
assert.equal(table('iosOrder')[0].state,0);
|
|
assert.equal(await checkIos(ctx({openid:param.openid,time:param.time,outTradeNo:param.outTradeNo})),true);
|
|
assert.equal(o.state,2);assert.equal(o.goldMiner.fulfillmentStatus,'fulfilled');assert.equal(entitlements().length,1);
|
|
});
|
|
|
|
test('compatibility repair copies all activity fields and refuses a conflicting snapshot',async()=>{
|
|
legacyReset();const d=await legacyBuy();await sendLink(d);const param=linkParam(),saved=copy(legacyOrder());
|
|
table('order').length=0;
|
|
assert.equal(await checkIos(ctx({openid:param.openid,time:param.time,outTradeNo:param.outTradeNo})),true);
|
|
assert.deepEqual(legacyOrder().goldMiner,saved.goldMiner);
|
|
await sendLink(d);table('iosOrder')[0].goldMiner.periodId='goldMiner:2026-09-24';
|
|
assert.equal(await checkIos(ctx({uid:'u',token:'token',outTradeNo:param.outTradeNo})),false);
|
|
assert.equal(table('iosOrder').length,1);
|
|
});
|
|
|
|
test('merchant notification verifies signature, timestamp, decryption and exact payment fields',async()=>{
|
|
legacyReset();await legacyBuy();const o=legacyOrder();
|
|
for(const patch of [{mchid:'wrong'},{appid:'wrong'},{payer:{openid:'other'}},{amount:{total:1,currency:'CNY'}},{trade_state:'NOTPAY'},{trade_type:'NATIVE'}]) {
|
|
const c=merchantNotice(o,patch);assert.equal((await merchantNotify(c)).code,'FAIL');assert.equal(c.status,500);assert.equal(o.state,0);
|
|
}
|
|
for(const patch of [{'wechatpay-signature':'fake'},{'wechatpay-timestamp':'1'},{'wechatpay-serial':'unknown'}]) {
|
|
const c=merchantNotice(o);Object.assign(c.headers,patch);assert.equal((await merchantNotify(c)).code,'FAIL');
|
|
}
|
|
const c=merchantNotice(o);c.rawBody=c.rawBody.replace('TRANSACTION.SUCCESS','TRANSACTION.FAIL');assert.equal((await merchantNotify(c)).code,'FAIL');
|
|
const valid=merchantNotice(o);assert.equal((await merchantNotify(valid)).code,'SUCCESS');assert.equal(valid.status,200);
|
|
assert.equal(o.goldMiner.confirmationSource,'merchant_notify');assert.ok(o.goldMiner.transactionId);assert.equal(o.state,2);
|
|
assert.equal((await merchantNotify(merchantNotice(o))).code,'SUCCESS');assert.equal(entitlements().length,1);
|
|
});
|
|
|
|
test('merchant confirmation is durable before acknowledgement and fulfillment failures are retried',async()=>{
|
|
legacyReset();await legacyBuy();const o=legacyOrder();fault=(name,q,u)=>name===R.TABLES.players&&!!u.$set?.entitlement;
|
|
const c=merchantNotice(o);assert.equal((await merchantNotify(c)).code,'SUCCESS');assert.equal(o.state,1);assert.ok(o.goldMiner.confirmedAt);
|
|
fault=null;now+=60000;await jobs();assert.equal(o.state,2);assert.equal(entitlements().length,1);
|
|
legacyReset();await legacyBuy();fault=(name,q,u)=>name==='order'&&u.$set?.['goldMiner.confirmedAt'];
|
|
assert.equal((await merchantNotify(merchantNotice(legacyOrder()))).code,'FAIL');assert.equal(legacyOrder().state,0);
|
|
});
|
|
|
|
test('merchant notifications cannot confirm native orders and native notifications cannot confirm merchant orders',async()=>{
|
|
legacyReset();const native=await buy();assert.equal((await merchantNotify(merchantNotice(native))).code,'FAIL');assert.equal(native.state,0);
|
|
legacyReset();await legacyBuy();const o=legacyOrder();assert.notEqual((await callback(notification(o))).ErrCode,0);assert.equal(o.state,0);
|
|
});
|
|
|
|
test('all authenticated query endpoints recover missing merchant notifications without client coin delivery',async()=>{
|
|
for(const fn of [queryPayment,legacyQuery,getOrderReward]) {
|
|
legacyReset();const d=await legacyBuy();provider.tradeState='SUCCESS';
|
|
assert.equal((await fn(ctx({uid:'u',token:'bad',outTradeNo:d.outTradeNo}))).code,0);assert.equal(requests.length,0);
|
|
const result=await fn(ctx({uid:'u',token:'token',outTradeNo:d.outTradeNo}));
|
|
assert.equal(result.code,1,JSON.stringify(result));assert.equal(result.data.rewardDelivery,'goldMiner.claim');
|
|
assert.equal(legacyOrder().goldMiner.confirmationSource,'merchant_query');assert.equal(table('users')[0].coinAmount,500);
|
|
}
|
|
});
|
|
|
|
test('unverified query responses and network failures retain unpaid orders with bounded retry',async()=>{
|
|
legacyReset();const d=await legacyBuy();provider.tradeState='SUCCESS';provider.badSignature=true;
|
|
let result=await legacyQuery(ctx({uid:'u',token:'token',outTradeNo:d.outTradeNo}));assert.equal(result.errorCode,'INVALID_PAYMENT_SIGNATURE');
|
|
const o=legacyOrder();assert.equal(o.state,0);assert.ok(o.goldMiner.nextQueryAt>now);const count=requests.length;
|
|
await legacyQuery(ctx({uid:'u',token:'token',outTradeNo:d.outTradeNo}));assert.equal(requests.length,count);
|
|
now=o.goldMiner.nextQueryAt;provider.badSignature=false;provider.throwNetwork=true;
|
|
result=await legacyQuery(ctx({uid:'u',token:'token',outTradeNo:d.outTradeNo}));assert.equal(result.code,0);assert.equal(table('order').length,1);assert.equal(o.state,0);
|
|
});
|
|
|
|
test('concurrent polling takes one query lease and repeats return the same entitlement',async()=>{
|
|
legacyReset();const d=await legacyBuy();provider.tradeState='SUCCESS';
|
|
await Promise.all(Array.from({length:6},()=>legacyQuery(ctx({uid:'u',token:'token',outTradeNo:d.outTradeNo}))));
|
|
assert.equal(requests.filter(r=>r.options.method==='GET').length,1);assert.equal(entitlements().length,1);
|
|
});
|
|
|
|
test('prepay timeout reuses the same order number and regenerates only the prepay session',async()=>{
|
|
legacyReset();const d=await legacyBuy();provider.afterPrepay=()=>{throw Error('lost prepay response');};
|
|
assert.equal((await sendLink(d)).code,0);const o=legacyOrder();assert.equal(o.goldMiner.prepayState,'creating');
|
|
provider.afterPrepay=null;assert.equal((await sendLink(d)).code,1);assert.equal(legacyOrder().outTradeNo,d.outTradeNo);assert.equal(table('order').length,1);
|
|
assert.equal(provider.prepayCalls,2);assert.equal(requests.filter(r=>r.options.method==='GET').length,1);
|
|
});
|
|
|
|
test('period expiry closes unpaid orders and close-versus-payment race still grants correctly',async()=>{
|
|
legacyReset();const d=await legacyBuy();await sendLink(d);const o=legacyOrder();now=END;await jobs();
|
|
assert.equal(o.goldMiner.prepayState,'closed');assert.equal(provider.closeCalls,1);assert.equal(o.state,0);assert.equal(table('order').length,1);
|
|
assert.equal((await legacyQuery(ctx({uid:'u',token:'token',outTradeNo:d.outTradeNo}))).errorCode,'ORDER_CLOSED');
|
|
legacyReset();await legacyBuy();provider.tradeState='NOTPAY';provider.closePaid=true;now=END;await jobs();
|
|
assert.equal(legacyOrder().state,2);assert.equal(legacyOrder().goldMiner.fulfillmentRoute,'carry_next');
|
|
});
|
|
|
|
test('delayed merchant payment settles original rewards or carries exactly once',async()=>{
|
|
for(const earned of [false,true]) {
|
|
legacyReset();const d=await legacyBuy();if(earned)await win(1);now=END;
|
|
assert.equal((await merchantNotify(merchantNotice(legacyOrder()))).code,'SUCCESS');
|
|
assert.equal(legacyOrder().goldMiner.fulfillmentRoute,earned?'settle_original':'carry_next');
|
|
if(earned)assert.equal((await call('settlements')).data.items[0].rewards[0].taskId,'t1');
|
|
else {now=NEXT;assert.equal((await call('info')).data.status,'unlocked');assert.equal(player('goldMiner:2026-09-24').entitlementSource,'delayed_payment_carry');}
|
|
assert.equal((await legacyQuery(ctx({uid:'u',token:'token',outTradeNo:d.outTradeNo}))).code,1);
|
|
assert.equal(entitlements().length,1);assert.equal(table('users')[0].coinAmount,500);
|
|
}
|
|
});
|
|
|
|
test('login recovery and timer find payments without checkIos ever being called',async()=>{
|
|
legacyReset();await legacyBuy();provider.tradeState='SUCCESS';await L.recoverLegacyOrders('other');assert.equal(requests.length,0);
|
|
await L.recoverLegacyOrders('u');assert.equal(legacyOrder().state,2);
|
|
legacyReset();await legacyBuy();provider.tradeState='SUCCESS';await jobs();assert.equal(legacyOrder().state,2);assert.equal(table('iosOrder').length,0);
|
|
});
|
|
|
|
test('test/production order and ticket boundaries cannot be bypassed',async()=>{
|
|
legacyReset();process.env.PAYMENT_APP_ENV='test';const d=await legacyBuy();assert.ok(d.outTradeNo.startsWith('wct_'));
|
|
process.env.PAYMENT_APP_ENV='production';assert.equal((await sendLink(d)).errorCode,'ORDER_ENVIRONMENT_MISMATCH');
|
|
assert.equal((await merchantNotify(merchantNotice(legacyOrder()))).code,'FAIL');assert.equal(legacyOrder().state,0);
|
|
});
|
|
|
|
|
|
test('legacy preorder refuses incomplete merchant verification and page configuration',async()=>{
|
|
for (const [name,value] of [['GOLD_MINER_WECHATPAY_API_V3_KEY',''],['GOLD_MINER_WECHATPAY_PUBLIC_KEYS','{}'],['GOLD_MINER_CHECK_IOS_URL','https://test.invalid/wrong'],['GOLD_MINER_PAY_PAGE_URL','https://pay.invalid/order3.html#wrong']]) {
|
|
legacyReset();process.env[name]=value;
|
|
const r=await call('create_order',{channel:'legacy_ios',itemid:'gold_miner',createRequestId:'config-check'});
|
|
assert.equal(r.errorCode,'PAYMENT_CONFIG_UNAVAILABLE');assert.equal(table('order').length,0);
|
|
}
|
|
});
|
|
|
|
|
|
test('B group career counter excludes the first 15 main levels without changing main saves',async()=>{
|
|
const b={layer_1:{name:'new1_50',enabled:true,group:'B'}};
|
|
for(const [from,to,abTests,expected] of [
|
|
[14,15,b,0], [14,17,b,2], [15,16,b,1],
|
|
[14,17,JSON.stringify(b),2],
|
|
[14,15,{layer_1:{name:'new1_50',group:'2'}},0],
|
|
[14,15,{layer_1:{name:'new1_50',group:'A'}},1],
|
|
[14,15,{layer_1:{name:'new1_50',group:'B',enabled:false}},1],
|
|
[14,15,{layer_1:{name:'other',group:'B'}},1],
|
|
[14,15,undefined,1], [14,15,'invalid-json',1],
|
|
]) {
|
|
reset({levelAmount:from,addLevel:7,abTests});
|
|
const r=await saveLevel(ctx({action:'save',uid:'u',token:'token',levelAmount:to}));
|
|
assert.equal(r.code,1);assert.equal(user().levelAmount,to);assert.equal(user().addLevel,7+expected);
|
|
}
|
|
});
|
|
|
|
test('B group career rule preserves independent main and endless gold miner progress',async()=>{
|
|
reset({addLevel:12,abTests:{layer_1:{name:'new1_50',enabled:true,group:'B'}}});
|
|
const main={action:'save',uid:'u',token:'token',levelAmount:42,goldMiner:event(1)};
|
|
let r=await saveLevel(ctx(main));
|
|
assert.equal(r.data.goldMiner.progressWins,1);assert.equal(user().addLevel,13);
|
|
r=await saveLevel(ctx(main));
|
|
assert.equal(r.data.goldMiner.duplicate,true);assert.equal(player().progressWins,1);assert.equal(user().addLevel,13);
|
|
r=await saveLevel(ctx({...main,isWuXian:'true',goldMiner:event(2,'endless')}));
|
|
assert.equal(r.data.goldMiner.progressWins,2);assert.equal(user().addLevel,14);assert.equal(user().levelAmount,42);
|
|
});
|
|
|
|
|
|
test('info combines activity states and removes legacy flags from every response shape',async()=>{
|
|
const removed=['availability','qualified','entitlement','purchasable','currency','nextStartsAt','nextPeriodId','claimedThrough'];
|
|
async function expectState(status) {
|
|
const r=await call('info');assert.equal(r.code,1);assert.equal(r.data.status,status);
|
|
for(const field of removed) assert.equal(Object.hasOwn(r.data,field),false,field);
|
|
return r.data;
|
|
}
|
|
reset();table(R.TABLES.configs)[0].status='draft';assert.deepEqual((await expectState('unavailable')).tasks,[]);
|
|
reset();now=END;assert.deepEqual((await expectState('unavailable')).tasks,[]);
|
|
reset({levelAmount:40});assert.deepEqual((await expectState('locked')).tasks,[]);
|
|
reset();await win(1);await expectState('purchasable');
|
|
control().purchaseEnabled=false;
|
|
let info=await expectState('purchase_disabled');assert.equal(info.progressWins,1);assert.equal(info.tasks[0].claimStatus,'pending_unlock');
|
|
await win(2);assert.equal((await expectState('purchase_disabled')).progressWins,2);
|
|
assert.equal((await android(ctx({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'disabled-test'}))).errorCode,'PURCHASE_DISABLED');
|
|
control().purchaseEnabled=true;const order=await buy();await pay(order);
|
|
control().purchaseEnabled=false;
|
|
info=await expectState('unlocked');assert.equal(info.tasks[0].claimStatus,'claimable');
|
|
assert.equal((await claim('t1')).code,1);
|
|
});
|
|
|
|
test('info uses an explicit task projection through locked, claimable, issuing and claimed states',async()=>{
|
|
reset();await win(1);await win(2);await win(3);
|
|
const read=async()=> (await call('info')).data;
|
|
let info=await read();assert.deepEqual(info.tasks.map(t=>t.claimStatus),['pending_unlock','pending_unlock','pending_unlock']);
|
|
assert.ok(info.tasks.every(t=>t.completed));
|
|
const order=await buy();await pay(order);
|
|
info=await read();assert.deepEqual(info.tasks.map(t=>t.claimStatus),['claimable','claimable','claimable']);
|
|
const grant=await claim('t1');assert.equal(grant.code,1);
|
|
info=await read();assert.deepEqual(info.tasks.map(t=>t.claimStatus),['issuing','claimable','claimable']);
|
|
assert.equal((await claim('t1')).data.grantId,grant.data.grantId);
|
|
assert.equal((await claim('t2')).code,1);
|
|
await deliver('t1');
|
|
info=await read();assert.deepEqual(info.tasks.map(t=>t.claimStatus),['claimed','issuing','claimable']);
|
|
assert.deepEqual(info.tasks.map(t=>t.taskId),['t1','t2','t3']);
|
|
const keys=['taskId','targetWins','itemsSnapshot','claimStatus','completed','progress'].sort();
|
|
for(const t of info.tasks) assert.deepEqual(Object.keys(t).sort(),keys);
|
|
assert.ok(player().tasks[0].grantId);assert.ok(player().tasks[0].clientSavedAt);
|
|
assert.equal(player().claimedThrough,1);assert.equal(player().tasks[0].sequence,1);
|
|
});
|
|
|
|
|
|
test('manual claim crossing the database cutoff is rejected without authorizing a grant',async()=>{
|
|
reset();await win(1);await pay(await buy());now=END-1;
|
|
beforeWrite=(name,q,u)=>{if(name===R.TABLES.players&&u.$set?.tasks){now=END;beforeWrite=null;}};
|
|
assert.equal((await claim('t1')).errorCode,'PERIOD_SETTLEMENT_REQUIRED');
|
|
assert.equal(player().tasks[0].claimStatus,'unclaimed');assert.equal(embeddedGrants().length,0);
|
|
});
|
|
|
|
test('settlements return ready periods even when another period still needs settlement',async()=>{
|
|
reset();await win(1);await win(2);await pay(await buy());await deliver('t1');now=END;await jobs();
|
|
now=NEXT+1000;await win(1);await pay(await buy());now=NEXT+4*86400000;await jobs();
|
|
const all=(await call('settlements')).data;assert.equal(all.items.length,2);
|
|
assert.deepEqual(all.items.flatMap(s=>s.rewards.map(r=>r.taskId)).sort(),['t1','t2']);
|
|
const row=table(R.TABLES.players).sort((a,b)=>a._id.localeCompare(b._id))[0];delete row.settlementPreparedAt;
|
|
const mixed=(await call('settlements')).data;
|
|
assert.equal(mixed.status,'pending_delivery');assert.equal(mixed.items.length,1);
|
|
assert.notEqual(mixed.items[0].periodId,row.periodId);
|
|
await call('confirm_settlement_delivery',{settlementId:mixed.items[0].settlementId,grantIds:mixed.items[0].rewards.map(r=>r.grantId)});
|
|
assert.equal((await call('settlements')).data.status,'settling');
|
|
await jobs();assert.equal((await call('settlements')).data.status,'pending_delivery');
|
|
reset();await win(1);now=END;await jobs();assert.deepEqual((await call('settlements')).data.items,[]);
|
|
reset();assert.deepEqual((await call('settlements')).data,{status:'no_pending_rewards',items:[],nextCursor:null});
|
|
});
|
|
|
|
test('userLevel never opens CloudRise when progress reaches the unlock level',async()=>{
|
|
reset({onlyId:1001,levelAmount:99});
|
|
table('activityConfigs').push({_id:'cloud-rise-unlock',activityId:'cloudRise',recordType:'version',configVersion:'v1',status:'published',enabled:true,effectiveFrom:START,publishedAt:START,config:{unlockLevel:100,durationHours:1,pools:[10000,15000,20000]}});
|
|
const result=await saveLevel(ctx({action:'save',uid:'u',token:'token',levelAmount:100}));
|
|
assert.equal(result.code,1);assert.equal(result.data.cloudRisePeriod,undefined);assert.equal(user().cloudRisePeriod,undefined);
|
|
});
|
|
|
|
|
|
test('server-sized settlement pages continue past settling periods and check the whole account before reporting no rewards',async()=>{
|
|
reset();await win(1);await pay(await buy());now=END;await jobs();
|
|
const earned=copy(player());tables[R.TABLES.players]=[];
|
|
for(let i=0;i<21;i++) table(R.TABLES.players).push({...copy(earned),_id:'history-'+String(i).padStart(2,'0')});
|
|
const rows=table(R.TABLES.players);
|
|
for(const row of rows.slice(0,20)) { row.tasks.forEach(t=>t.claimStatus='claimed');row.settlementState='client_saved'; }
|
|
const first=(await call('settlements')).data;
|
|
assert.deepEqual(first,{status:'pending_delivery',items:[],nextCursor:'history-19'});
|
|
// Old client values cannot alter the server's page size.
|
|
for(const limit of [1,100,0,'invalid']) assert.deepEqual((await call('settlements',{limit})).data,first);
|
|
const second=(await call('settlements',{afterId:first.nextCursor})).data;
|
|
assert.equal(second.items.length,1);assert.equal(second.nextCursor,null);
|
|
rows[0].settlementPreparedAt=null;
|
|
assert.deepEqual((await call('settlements')).data,{status:'settling',items:[],nextCursor:'history-19'});
|
|
assert.equal((await call('settlements',{afterId:first.nextCursor})).data.items.length,1);
|
|
rows[0].settlementPreparedAt=now;
|
|
// A ready reward before the cursor must also prevent a false global empty result.
|
|
rows[0].tasks=copy(earned.tasks);rows[20].tasks.forEach(t=>t.claimStatus='claimed');
|
|
assert.equal((await call('settlements',{afterId:first.nextCursor})).data.status,'pending_delivery');
|
|
rows[0].settlementPreparedAt=null;
|
|
const settling=(await call('settlements')).data;
|
|
assert.deepEqual(settling,{status:'settling',items:[],nextCursor:'history-19'});
|
|
assert.equal((await call('settlements',{afterId:first.nextCursor})).data.status,'settling');
|
|
rows[0].settlementPreparedAt=now;rows[0].tasks.forEach(t=>t.claimStatus='claimed');
|
|
assert.deepEqual((await call('settlements')).data,{status:'no_pending_rewards',items:[],nextCursor:null});
|
|
assert.deepEqual((await call('settlements',{afterId:first.nextCursor})).data,{status:'no_pending_rewards',items:[],nextCursor:null});
|
|
assert.equal((await call('settlements',{afterId:42})).errorCode,'INVALID_INPUT');
|
|
});
|
|
|
|
test('global empty settlement check scopes account, expiry and test environment and detects reserved or unprepared rewards',async()=>{
|
|
reset();await win(1);await pay(await buy());now=END;await jobs();
|
|
const original=copy(player());tables[R.TABLES.players]=[];
|
|
table(R.TABLES.players).push({...copy(original),_id:'other-user',uid:'other'},
|
|
{...copy(original),_id:'future',endsAt:now+1000},
|
|
{...copy(original),_id:'test-only',periodId:'goldMiner:test:disabled'});
|
|
const empty={status:'no_pending_rewards',items:[],nextCursor:null};
|
|
assert.deepEqual((await call('settlements')).data,empty);
|
|
const overdue={...copy(original),_id:'history'};table(R.TABLES.players).push(overdue);
|
|
// Probe outside the requested page, including data with incorrect cached settlementState.
|
|
overdue.settlementState='client_saved';
|
|
assert.equal((await call('settlements',{afterId:'zzzz'})).data.status,'pending_delivery');
|
|
delete overdue.tasks[0].grantId;
|
|
assert.equal((await call('settlements',{afterId:'zzzz'})).data.status,'settling');
|
|
overdue.entitlementStatus='none';overdue.progressClosed=false;
|
|
assert.equal((await call('settlements',{afterId:'zzzz'})).data.status,'settling');
|
|
overdue.progressClosed=true;
|
|
assert.deepEqual((await call('settlements',{afterId:'zzzz'})).data,empty);
|
|
delete overdue.tasks;overdue.entitlement={state:'reserved'};
|
|
assert.equal((await call('settlements',{afterId:'zzzz'})).data.status,'settling');
|
|
});
|
|
|
|
test('settlement confirmation is atomic, owned, idempotent and shares state with original confirmations',async()=>{
|
|
reset();await win(1);await win(2);await pay(await buy());now=END;await jobs();
|
|
const s=(await call('settlements')).data.items[0], ids=s.rewards.map(r=>r.grantId);
|
|
const body={settlementId:s.settlementId,grantIds:ids};
|
|
assert.equal((await call('confirm_settlement_delivery',{...body,grantIds:[ids[0],'fake']})).errorCode,'INVALID_GRANT');
|
|
assert.equal(player().claimedThrough,0);
|
|
assert.equal((await call('confirm_settlement_delivery',{...body,grantIds:[ids[0],ids[0]]})).errorCode,'INVALID_INPUT');
|
|
table('users').push({_id:'other',token:'other-token'});
|
|
assert.equal((await call('confirm_settlement_delivery',{...body,uid:'other',token:'other-token'})).errorCode,'NOT_FOUND');
|
|
fault=(name,q,u)=>name===R.TABLES.players&&u.$set?.claimedThrough===2;
|
|
assert.equal((await call('confirm_settlement_delivery',body)).code,0);assert.equal(player().claimedThrough,0);
|
|
fault=null;const results=await Promise.all([call('confirm_settlement_delivery',body),call('confirm_settlement_delivery',body)]);
|
|
assert.ok(results.every(r=>r.code===1));assert.equal(player().claimedThrough,2);
|
|
assert.equal((await call('confirm_delivery',{periodId:s.periodId,taskId:'t1',grantId:ids[0]})).code,1);
|
|
assert.ok(embeddedGrants().every(g=>g.claimStatus==='claimed'));
|
|
assert.deepEqual((await call('settlements')).data.items,[]);
|
|
});
|
|
|
|
test('late payment adds settlement rewards after an unpaid period was already closed',async()=>{
|
|
reset();await win(1);const o=await buy();now=END;await jobs();
|
|
assert.deepEqual((await call('settlements')).data.items,[]);
|
|
await pay(o);const s=(await call('settlements')).data.items[0];
|
|
assert.equal(s.periodId,o.goldMiner.periodId);assert.equal(s.rewards.length,1);
|
|
assert.equal((await claim('t1')).errorCode,'PERIOD_SETTLEMENT_REQUIRED');
|
|
});
|
|
|
|
|
|
test('old and new confirmation racing advance each task only once',async()=>{
|
|
reset();await win(1);await win(2);await pay(await buy());const original=(await claim('t1')).data;
|
|
now=END;await jobs();const receipt=(await call('settlements')).data.items[0];
|
|
const results=await Promise.all([
|
|
call('confirm_delivery',{periodId:receipt.periodId,taskId:'t1',grantId:original.grantId}),
|
|
call('confirm_settlement_delivery',{settlementId:receipt.settlementId,grantIds:receipt.rewards.map(r=>r.grantId)})
|
|
]);
|
|
assert.ok(results.every(r=>r.code===1));assert.equal(player().claimedThrough,2);
|
|
assert.equal(embeddedGrants().length,2);assert.deepEqual((await call('settlements')).data.items,[]);
|
|
});
|
|
|
|
test('batch confirmation accepts gaps and later fills the legacy contiguous prefix',async()=>{
|
|
reset();await win(1);await win(2);await win(3);await pay(await buy());now=END;await jobs();
|
|
const s=(await call('settlements')).data.items[0];
|
|
assert.equal((await call('confirm_settlement_delivery',{settlementId:s.settlementId,grantIds:[s.rewards[2].grantId,s.rewards[0].grantId]})).data.deliveryStatus,'partial');
|
|
assert.equal(player().claimedThrough,1);assert.deepEqual(player().tasks.map(t=>t.claimStatus),['claimed','issuing','claimed']);
|
|
assert.equal((await call('confirm_settlement_delivery',{settlementId:s.settlementId,grantIds:[s.rewards[1].grantId]})).data.deliveryStatus,'client_saved');
|
|
assert.equal(player().claimedThrough,3);
|
|
});
|
|
|
|
test('background scan upgrades an old settlement without replacing original grants',async()=>{
|
|
reset();await win(1);await win(2);await pay(await buy());const original=(await claim('t1')).data;
|
|
now=END;
|
|
Object.assign(player(),{progressClosed:true,progressAtClose:2,progressFrozenAt:END,settlementTaskIds:['t1','t2'],settlementIds:[R.key('settlement',player()._id)],settlementState:'pending_delivery'});
|
|
assert.equal((await call('settlements')).data.status,'settling');
|
|
await jobs();const s=(await call('settlements')).data.items[0];
|
|
assert.equal(s.rewards[0].grantId,original.grantId);assert.ok(s.rewards[1].grantId);
|
|
assert.equal(embeddedGrants().length,2);
|
|
});
|
|
|
|
|
|
test('shared configs isolate activity versions, reject edits and choose only versions published before opening',async()=>{
|
|
reset();const future={...copy(config),configVersion:'sharedV1',effectiveFromPeriodId:'goldMiner:2026-09-24'};
|
|
assert.equal((await admin(ctx({action:'publish',adminToken:'admin-test',config:future}))).code,1);
|
|
await C.publish('cloudRise','sharedV1',NEXT,{periodId:'race',startsAt:NEXT,endsAt:NEXT+1000});
|
|
assert.equal(table(R.TABLES.configs).filter(c=>c.configVersion==='sharedV1').length,2);
|
|
assert.equal((await admin(ctx({action:'publish',adminToken:'admin-test',config:{...future,priceFen:999}}))).code,0);
|
|
assert.equal((await call('info')).data.configVersion,'v1');
|
|
now=NEXT+1;assert.equal((await call('info')).data.configVersion,'sharedV1');
|
|
table(R.TABLES.configs).push(version({...future,configVersion:'tooLate',priceFen:999,publishedAt:NEXT+1}));
|
|
assert.equal((await S.periodById('goldMiner:2026-09-24')).configVersion,'sharedV1');
|
|
});
|
|
|
|
test('reserved future player shell has no reward snapshot until next period opens',async()=>{
|
|
reset();const order=await buy();now=END;await pay(order);
|
|
const shell=player('goldMiner:2026-09-24');assert.equal(shell.tasks,undefined);assert.equal(shell.configSnapshot,undefined);
|
|
assert.equal(shell.entitlement.state,'reserved');
|
|
const future={...copy(config),configVersion:'future',effectiveFromPeriodId:'goldMiner:2026-09-24',publishedAt:now,priceFen:999};
|
|
table(R.TABLES.configs).push(version(future));now=NEXT;await jobs();
|
|
assert.equal(player('goldMiner:2026-09-24').configVersion,'future');assert.equal((await call('info')).data.priceFen,999);
|
|
assert.equal((await call('info')).data.status,'unlocked');
|
|
});
|
|
|
|
test('pending-state batches back off failures and never rescan completed settlements',async()=>{
|
|
reset();await call('info');const seed=copy(player());table(R.TABLES.players).length=0;
|
|
for(let i=0;i<101;i++)table(R.TABLES.players).push({...copy(seed),_id:String(i).padStart(4,'0'),uid:'user'+i});
|
|
now=END;fault=(name,q,u)=>name===R.TABLES.players&&q._id==='0000'&&!!u.$set?.settlementPreparedAt;
|
|
assert.equal((await jobs()).failures,1);
|
|
assert.equal((await jobs()).players,1);assert.equal((await jobs()).players,0);
|
|
fault=null;now+=60000;assert.equal((await jobs()).players,1);assert.equal((await jobs()).players,0);
|
|
assert.ok(Object.keys(tables).filter(k=>tables[k].length).every(k=>['users','activityConfigs','goldMinerPlayerPeriods'].includes(k)));
|
|
});
|
|
|
|
test('offline migration previews, preserves IDs and receipts, and safely repeats without deleting old data',async()=>{
|
|
const {migrate}=await import('../../activityConfig/migrate.cjs');
|
|
reset();await win(1);await pay(await buy());const grant=(await claim('t1')).data;now=END;await jobs();
|
|
const before=copy(player()), receipt=(await call('settlements')).data.items[0];
|
|
await call('confirm_settlement_delivery',{settlementId:receipt.settlementId,grantIds:[grant.grantId]});
|
|
await call('ack_settlement',{settlementId:receipt.settlementId});const paid=copy(player());
|
|
table('goldMinerConfigs').push(copy(config));table(R.TABLES.configs).length=0;
|
|
table('goldMinerPeriods').push({...await S.periodById('goldMiner:2026-09-17'),periodId:paid.periodId,configSnapshot:paid.configSnapshot,purchaseEnabled:false});
|
|
table('goldMinerEntitlements').push(copy(paid.entitlement));
|
|
table('goldMinerRewardGrants').push({_id:grant.grantId,uid:'u',periodId:paid.periodId,taskId:'t1',itemsSnapshot:grant.items,status:'client_saved'});
|
|
table('goldMinerSettlements').push({_id:receipt.settlementId,uid:'u',periodId:paid.periodId,taskIds:['t1'],acknowledgedAt:now});
|
|
delete player().entitlement;delete player().settlementId;delete player().settlementAcknowledgedAt;
|
|
const snapshot=JSON.stringify(table(R.TABLES.players));
|
|
const preview=await migrate(mongo);assert.equal(preview.dryRun,true);assert.equal(JSON.stringify(table(R.TABLES.players)),snapshot);
|
|
await migrate(mongo,{apply:true});assert.equal(player().tasks[0].grantId,grant.grantId);
|
|
assert.equal(player().tasks[0].claimStatus,'claimed');assert.equal(player().settlementAcknowledgedAt,now);
|
|
assert.equal((await S.periodById(paid.periodId)).purchaseEnabled,false);
|
|
assert.equal((await migrate(mongo,{apply:true})).writes,0);assert.equal(table('goldMinerRewardGrants').length,1);
|
|
assert.deepEqual((await call('settlements')).data.items,[]);assert.equal(before.tasks[0].grantId,grant.grantId);
|
|
});
|
|
|
|
test('migration fails before writing anything if an old grant disagrees with player state',async()=>{
|
|
const {migrate}=await import('../../activityConfig/migrate.cjs');reset();
|
|
table('goldMinerConfigs').push(copy(config));table(R.TABLES.configs).length=0;
|
|
table('goldMinerRewardGrants').push({_id:'bad',uid:'u',periodId:config.effectiveFromPeriodId,taskId:'missing'});
|
|
await assert.rejects(()=>migrate(mongo,{apply:true}),/Reward\/player mismatch/);
|
|
assert.equal(table(R.TABLES.configs).length,0);
|
|
});
|
|
|
|
|
|
test('mongosh adapter migrates cloudRise without altering users and permits same version across activities',async()=>{
|
|
const {migrate}=await import('../../activityConfig/migrate.cjs');reset();const before=copy(user());
|
|
table('cloudRiseConfig').push({_id:'old',periodId:'race1',startsAt:START,endsAt:END,unlockLevel:10,enabled:false});
|
|
const shell={getCollection:name=>mongo.collection(name)};
|
|
await migrate(shell,{apply:true});const row=table(R.TABLES.configs).find(c=>c.activityId==='cloudRise');
|
|
assert.equal(row.config.durationHours,24);assert.deepEqual(row.config.pools,[10000,15000,20000]);
|
|
assert.equal(row.config.enabled,undefined);assert.equal(row.publishedAt,START-1);assert.deepEqual(user(),before);
|
|
assert.equal((await migrate(shell,{apply:true})).writes,0);
|
|
});
|
|
|
|
test('gold miner published example passes real admin validation and immutable shared storage',async()=>{
|
|
const {readFile}=await import('node:fs/promises');reset();
|
|
const body=JSON.parse(await readFile(new URL('../config.publish.example.json',import.meta.url),'utf8'));
|
|
body.adminToken='admin-test';const result=await admin(ctx(body));assert.equal(result.code,1,JSON.stringify(result));
|
|
const row=table(R.TABLES.configs).find(c=>c.configVersion===body.config.configVersion);
|
|
assert.equal(row.activityId,'goldMiner');assert.equal(row.config.tasks.length,5);assert.equal(row.config.priceFen,100);
|
|
now=NEXT;assert.equal((await call('info')).data.configVersion,body.config.configVersion);
|
|
});
|
|
|
|
// Temporary periods exercise the same live handlers, with a Monday clock and synthetic payment signatures.
|
|
const TEMP = 'goldMiner:test:monday';
|
|
const TEMP_START = END + 3600000, TEMP_END = TEMP_START + 3600000;
|
|
function enableTemporary() {
|
|
process.env.PAYMENT_APP_ENV='test';process.env.GOLD_MINER_TEST_PERIODS_ENABLED='true';now=TEMP_START;
|
|
}
|
|
const temporaryBody = (extra={}) => ({action:'publish_test_period',adminToken:'admin-test',periodId:TEMP,
|
|
startsAt:TEMP_START,endsAt:TEMP_END,config:{...copy(config),configVersion:'tempV1'},...extra});
|
|
async function publishTemporary(extra={}) {
|
|
const result=await admin(ctx(temporaryBody(extra)));assert.equal(result.code,1,JSON.stringify(result));return result.data;
|
|
}
|
|
|
|
test('temporary admin requires authentication and both server environment gates',async()=>{
|
|
for(const [env,flag] of [['production','true'],['test','false'],['','true'],['test','']]) {
|
|
reset();process.env.PAYMENT_APP_ENV=env;process.env.GOLD_MINER_TEST_PERIODS_ENABLED=flag;
|
|
const r=await admin(ctx(temporaryBody({PAYMENT_APP_ENV:'test',GOLD_MINER_TEST_PERIODS_ENABLED:'true'})));
|
|
assert.equal(r.errorCode,'TEST_PERIOD_DISABLED');assert.equal(table(R.TABLES.configs).length,1);
|
|
}
|
|
reset();enableTemporary();assert.equal((await admin(ctx(temporaryBody({adminToken:'wrong'})))).errorCode,'UNAUTHORIZED');
|
|
await publishTemporary();process.env.PAYMENT_APP_ENV='production';
|
|
assert.equal((await call('info')).data.status,'unavailable');
|
|
await assert.rejects(()=>S.periodById(TEMP),e=>e.errorCode==='TEST_PERIOD_DISABLED');
|
|
});
|
|
|
|
test('Monday temporary period uses its exact boundaries for info, main/endless progress and native payment',async()=>{
|
|
reset();enableTemporary();now=TEMP_START-1;await publishTemporary();
|
|
assert.equal((await call('info')).data.status,'unavailable');
|
|
now=TEMP_START;const info=(await call('info')).data;
|
|
assert.equal(info.periodId,TEMP);assert.equal(info.startsAt,TEMP_START);assert.equal(info.endsAt,TEMP_END);
|
|
assert.equal(info.status,'purchasable');
|
|
const main=await saveLevel(ctx({action:'save',uid:'u',token:'token',levelAmount:42,goldMiner:event(1,'main',{periodId:TEMP})}));
|
|
assert.equal(main.data.goldMiner.activityCounted,true);
|
|
const endless=await saveLevel(ctx({action:'save',uid:'u',token:'token',levelAmount:42,isWuXian:'true',goldMiner:event(2,'endless',{periodId:TEMP})}));
|
|
assert.equal(endless.data.goldMiner.activityCounted,true);assert.equal(player(TEMP).progressWins,2);
|
|
const order=await buy();assert.equal(order.goldMiner.periodId,TEMP);assert.equal(order.goldMiner.endsAt,TEMP_END);
|
|
await pay(order);assert.equal((await call('info')).data.status,'unlocked');
|
|
now=TEMP_END-1;const grant=await deliver('t1',TEMP);assert.ok(grant);
|
|
now=TEMP_END;
|
|
assert.equal((await call('info')).data.status,'unavailable');
|
|
assert.equal((await call('claim',{periodId:TEMP,taskId:'t2',requestId:'expired'})).errorCode,'PERIOD_SETTLEMENT_REQUIRED');
|
|
await assert.rejects(()=>win(3,'endless',{periodId:TEMP}),e=>e.errorCode==='PERIOD_ENDED');
|
|
assert.equal((await android(ctx({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'late'}))).errorCode,'PERIOD_ENDED');
|
|
assert.equal((await call('settlements')).data.status,'settling');
|
|
await jobs();const receipt=(await call('settlements')).data.items[0];
|
|
assert.equal(receipt.periodId,TEMP);assert.deepEqual(receipt.rewards.map(t=>t.taskId),['t2']);
|
|
const confirmed=await call('confirm_settlement_delivery',{settlementId:receipt.settlementId,grantIds:receipt.rewards.map(t=>t.grantId)});
|
|
assert.equal(confirmed.data.deliveryStatus,'client_saved');assert.equal(player(TEMP).claimedThrough,2);
|
|
assert.deepEqual((await call('settlements')).data.items,[]);assert.equal(table('users')[0].coinAmount,500);
|
|
});
|
|
|
|
test('temporary periods can start immediately, are immutable and cannot overlap even under concurrent publishing',async()=>{
|
|
reset();enableTemporary();now+=1000;const first=await publishTemporary();
|
|
assert.equal(first.configSnapshot.publishedAt,now);assert.equal((await call('info')).data.periodId,TEMP);
|
|
now+=1000;assert.deepEqual(await publishTemporary(),first);
|
|
assert.equal((await admin(ctx(temporaryBody({endsAt:TEMP_END+1})))).errorCode,'CONFIG_UNAVAILABLE');
|
|
assert.equal((await admin(ctx(temporaryBody({config:{...config,priceFen:1}})))).errorCode,'CONFIG_UNAVAILABLE');
|
|
const common={startsAt:TEMP_END,endsAt:TEMP_END+60000};
|
|
const results=await Promise.all(['a','b'].map(id=>admin(ctx(temporaryBody({...common,periodId:'goldMiner:test:'+id})))));
|
|
assert.equal(results.filter(r=>r.code===1).length,1);assert.equal(results.filter(r=>r.errorCode==='CONFIG_UNAVAILABLE').length,1);
|
|
for(const extra of [{periodId:'goldMiner:2026-09-17'},{startsAt:TEMP_END},{startsAt:String(TEMP_START)},
|
|
{periodId:'goldMiner:test:expired',startsAt:TEMP_START-60000,endsAt:TEMP_START},
|
|
{nextPeriodId:'goldMiner:2026-09-17'},{nextPeriodId:'goldMiner:test:missing'}]) {
|
|
assert.equal((await admin(ctx(temporaryBody(extra)))).code,0,JSON.stringify(extra));
|
|
}
|
|
});
|
|
|
|
test('active temporary period takes precedence over weekly activity and retains its own purchase control',async()=>{
|
|
reset();enableTemporary();now=START+1000;
|
|
await publishTemporary({startsAt:START,endsAt:START+60000});assert.equal((await call('info')).data.periodId,TEMP);
|
|
assert.equal((await admin(ctx({action:'set_purchasable',adminToken:'admin-test',periodId:TEMP,purchaseEnabled:false}))).code,1);
|
|
assert.equal((await call('info')).data.status,'purchase_disabled');
|
|
now=START+60000;assert.equal((await call('info')).data.periodId,'goldMiner:2026-09-17');
|
|
assert.equal((await call('info')).data.status,'purchasable');assert.equal(player(TEMP).progressWins,0);
|
|
});
|
|
|
|
test('late temporary payment with earned rewards settles the original period and keeps grant identity',async()=>{
|
|
reset();enableTemporary();await publishTemporary();await win(1,'endless',{periodId:TEMP});const order=await buy();
|
|
now=TEMP_END;await pay(order);assert.equal(table('order')[0].goldMiner.fulfillmentRoute,'settle_original');
|
|
const receipt=(await call('settlements')).data.items[0];assert.equal(receipt.periodId,TEMP);
|
|
assert.equal(receipt.rewards[0].grantId,R.key('grant',player(TEMP)._id,'t1'));await pay(order);
|
|
assert.deepEqual((await call('settlements')).data.items[0],receipt);
|
|
});
|
|
|
|
test('late temporary payment carries to a configured next test period and activates at its start',async()=>{
|
|
reset();enableTemporary();const nextId='goldMiner:test:next',startsAt=TEMP_END+60000;
|
|
await publishTemporary({periodId:nextId,startsAt,endsAt:startsAt+60000});
|
|
await publishTemporary({nextPeriodId:nextId});const order=await buy();now=TEMP_END;await pay(order);
|
|
assert.equal(table('order')[0].goldMiner.targetPeriodId,nextId);assert.equal(player(nextId).tasks,undefined);
|
|
assert.equal(player(nextId).entitlement.state,'reserved');now=startsAt;await jobs();
|
|
const info=(await call('info')).data;assert.equal(info.periodId,nextId);assert.equal(info.status,'unlocked');
|
|
assert.equal(info.progressWins,0);assert.equal(entitlements().length,1);
|
|
});
|
|
|
|
test('temporary carry defaults to the first weekly start at or after its end',async()=>{
|
|
reset();enableTemporary();const temp=await publishTemporary();assert.equal(temp.nextPeriodId,'goldMiner:2026-09-24');
|
|
const order=await buy();now=TEMP_END;await pay(order);now=NEXT;
|
|
assert.equal((await call('info')).data.status,'unlocked');assert.equal(player('goldMiner:2026-09-24').progressWins,0);
|
|
});
|
|
|
|
test('legacy temporary orders preserve custom cutoff through customer service and iOS transfer',async()=>{
|
|
legacyReset();enableTemporary();await publishTemporary();const data=await legacyBuy();
|
|
assert.equal(data.periodId,TEMP);const order=legacyOrder();assert.equal(order.goldMiner.endsAt,TEMP_END);
|
|
assert.equal((await sendLink(data)).code,1);const param=linkParam();
|
|
assert.equal(table('iosOrder')[0].goldMiner.endsAt,TEMP_END);
|
|
assert.equal(await checkIos(ctx({openid:param.openid,time:param.time,outTradeNo:param.outTradeNo})),true);
|
|
assert.equal(legacyOrder().goldMiner.endsAt,TEMP_END);
|
|
await win(1,'endless',{periodId:TEMP});now=TEMP_END;
|
|
assert.equal((await merchantNotify(merchantNotice(order))).code,'SUCCESS');
|
|
assert.equal(legacyOrder().goldMiner.fulfillmentRoute,'settle_original');assert.equal((await call('settlements')).data.items[0].periodId,TEMP);
|
|
assert.equal(table('iosOrder').length,0);
|
|
});
|
|
|
|
test('disabled test periods cannot deliver rewards or recover test orders in production',async()=>{
|
|
reset();enableTemporary();await publishTemporary();await win(1,'endless',{periodId:TEMP});const order=await buy();await pay(order);
|
|
const grant=(await call('claim',{periodId:TEMP,taskId:'t1',requestId:'g'})).data;
|
|
now=TEMP_END;await jobs();const receipt=(await call('settlements')).data.items[0];process.env.PAYMENT_APP_ENV='production';
|
|
assert.equal((await call('claim',{periodId:TEMP,taskId:'t1',requestId:'g'})).errorCode,'TEST_PERIOD_DISABLED');
|
|
assert.equal((await call('confirm_delivery',{periodId:TEMP,taskId:'t1',grantId:grant.grantId})).errorCode,'TEST_PERIOD_DISABLED');
|
|
assert.equal((await call('confirm_settlement_delivery',{settlementId:receipt.settlementId,grantIds:[grant.grantId]})).errorCode,'TEST_PERIOD_DISABLED');
|
|
await assert.rejects(()=>P.fulfillOrder(order.outTradeNo),e=>e.errorCode==='TEST_PERIOD_DISABLED');
|
|
assert.deepEqual((await call('settlements')).data.items,[]);assert.equal(player(TEMP).claimedThrough,0);
|
|
});
|
|
|
|
test('legacy customer payment checks its original period purchase switch during a temporary override',async()=>{
|
|
legacyReset();process.env.PAYMENT_APP_ENV='test';const data=await legacyBuy();control().purchaseEnabled=false;
|
|
process.env.GOLD_MINER_TEST_PERIODS_ENABLED='true';await publishTemporary({startsAt:START,endsAt:START+60000});
|
|
assert.equal((await call('info')).data.periodId,TEMP);
|
|
assert.equal((await sendLink(data)).errorCode,'PURCHASE_DISABLED');assert.equal(provider.prepayCalls,0);
|
|
});
|
|
|
|
test('turning off the temporary gate rejects both payment callbacks before confirming an unpaid order',async()=>{
|
|
reset();enableTemporary();await publishTemporary();let order=await buy();process.env.GOLD_MINER_TEST_PERIODS_ENABLED='false';
|
|
assert.notEqual((await callback(notification(order))).ErrCode,0);assert.equal(table('order')[0].state,0);
|
|
legacyReset();enableTemporary();await publishTemporary();await legacyBuy();order=legacyOrder();
|
|
process.env.GOLD_MINER_TEST_PERIODS_ENABLED='false';
|
|
assert.equal((await merchantNotify(merchantNotice(order))).code,'FAIL');assert.equal(order.state,0);
|
|
});
|
|
|
|
test('temporary JSON example is accepted by admin and needs no weekly config',async()=>{
|
|
reset();enableTemporary();table(R.TABLES.configs).length=0;
|
|
const {readFile}=await import('node:fs/promises');
|
|
const body=JSON.parse(await readFile(new URL('../test-period.publish.example.json',import.meta.url),'utf8'));
|
|
body.adminToken='admin-test';now=body.startsAt;
|
|
const result=await admin(ctx(body));assert.equal(result.code,1,JSON.stringify(result));
|
|
const info=(await call('info')).data;assert.equal(info.periodId,body.periodId);assert.equal(info.startsAt,body.startsAt);
|
|
assert.equal(info.endsAt,body.endsAt);assert.equal(info.tasks.length,body.config.tasks.length);
|
|
});
|
|
|
|
|
|
test('shared enabled defaults and latest selection apply to arbitrary future activities without fallback',async()=>{
|
|
reset();
|
|
const first=await C.publish('futureActivity','v1',now+1000,{value:1});
|
|
assert.equal(first.enabled,true);
|
|
const stopped=await C.publish('futureActivity','v2',now+2000,{value:2},false);
|
|
assert.equal(stopped.enabled,false);
|
|
assert.equal(await C.latestEnabled('futureActivity',now+3000),null);
|
|
assert.equal((await C.latest('futureActivity',now+3000)).configVersion,'v2');
|
|
await assert.rejects(C.publish('futureActivity','v2',now+2000,{value:2}));
|
|
await assert.rejects(C.publish('futureActivity','v3',now+3000,{value:3},'false'));
|
|
assert.equal(C.isEnabled({}),true);assert.equal(C.isEnabled(null),false);
|
|
const legacy=table(R.TABLES.configs).find(r=>r.activityId==='futureActivity'&&r.configVersion==='v1');delete legacy.enabled;
|
|
assert.equal((await C.publish('futureActivity','v1',now+1000,{value:1})).configVersion,'v1');
|
|
assert.equal((await C.latestEnabled('futureActivity',now+1500)).configVersion,'v1');
|
|
});
|
|
|
|
test('gold miner admin publishes outer enabled and a disabled latest version never falls back',async()=>{
|
|
reset();const body={action:'publish',adminToken:'admin-test',enabled:false,
|
|
config:{...copy(config),configVersion:'disabled-v2',effectiveFromPeriodId:'goldMiner:2026-09-24'}};
|
|
const result=await admin(ctx(body));assert.equal(result.code,1);assert.equal(result.data.enabled,false);
|
|
const row=table(R.TABLES.configs).find(r=>r.configVersion==='disabled-v2');
|
|
assert.equal(row.enabled,false);assert.equal(row.config.enabled,undefined);
|
|
assert.equal((await admin(ctx(body))).code,1);
|
|
assert.equal((await admin(ctx({...body,enabled:true}))).code,0);
|
|
assert.equal((await admin(ctx({...body,enabled:'false'}))).code,0);
|
|
now=NEXT;assert.equal(await S.currentPeriod(),null);
|
|
assert.equal((await call('info')).data.status,'unavailable');assert.equal(table(R.TABLES.players).length,0);
|
|
const raw=await S.periodById(R.calendar(now).periodId);assert.equal(raw.configVersion,'disabled-v2');
|
|
await assert.rejects(S.ensurePlayer('u',user(),raw),e=>e.errorCode==='PERIOD_DISABLED');
|
|
await assert.rejects(win(1),e=>e.errorCode==='PERIOD_ENDED');
|
|
assert.equal((await android(ctx({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'stopped'}))).code,0);
|
|
assert.equal(table('order').length,0);assert.equal(table(R.TABLES.players).length,0);
|
|
row.enabled=true;assert.equal((await call('info')).data.status,'purchasable');
|
|
});
|
|
|
|
test('gold miner enabled closure preserves joined players progress payment and settlement',async()=>{
|
|
reset();await call('info');const snapshot=copy(player().configSnapshot);
|
|
table(R.TABLES.configs)[0].enabled=false;
|
|
assert.equal(await S.currentPeriod(),null);
|
|
assert.equal((await call('info')).data.status,'purchasable');
|
|
assert.deepEqual(player().configSnapshot,snapshot);
|
|
await win(1);const order=await buy();await pay(order);
|
|
assert.equal((await call('info')).data.status,'unlocked');
|
|
await deliver('t1');await win(2);
|
|
now=END;await jobs();const pending=await call('settlements');
|
|
assert.equal(pending.code,1);assert.equal(pending.data.items.length,1);
|
|
assert.equal(pending.data.items[0].rewards[0].taskId,'t2');
|
|
});
|
|
|
|
test('gold miner disabled next period still honors a previously assigned paid entitlement',async()=>{
|
|
reset();const order=await buy();now=END;await pay(order);
|
|
assert.equal(player('goldMiner:2026-09-24').entitlement.state,'reserved');
|
|
table(R.TABLES.configs)[0].enabled=false;now=NEXT;
|
|
await jobs();assert.equal((await call('info')).data.status,'unlocked');
|
|
assert.equal(player('goldMiner:2026-09-24').entitlementStatus,'unlocked');
|
|
});
|
|
|
|
test('gold miner temporary enabled closure blocks new players but preserves participants',async()=>{
|
|
reset();enableTemporary();await publishTemporary({enabled:false});
|
|
assert.equal((await call('info')).data.status,'unavailable');assert.equal(table(R.TABLES.players).length,0);
|
|
const period=table(R.TABLES.configs).find(r=>r.recordType==='testSchedule').periods[0];period.enabled=true;
|
|
assert.equal((await call('info')).data.status,'purchasable');period.enabled=false;
|
|
assert.equal((await call('info')).data.status,'purchasable');
|
|
assert.equal((await win(1,'endless',{periodId:TEMP})).activityCounted,true);
|
|
assert.equal((await admin(ctx(temporaryBody({enabled:'false'})))).code,0);
|
|
});
|
|
|
|
test('configuration validation reports exact field paths without changing error codes',async()=>{
|
|
const cases=[
|
|
['config',()=>null],
|
|
['config.configVersion',c=>({...c,configVersion:''})],
|
|
['config.productId',c=>({...c,productId:'wrong'})],
|
|
['config.timezone',c=>({...c,timezone:'UTC'})],
|
|
['config.currency',c=>({...c,currency:'USD'})],
|
|
['config.unlockPassedLevel',c=>({...c,unlockPassedLevel:'41'})],
|
|
['config.priceFen',c=>({...c,priceFen:'100'})],
|
|
['config.publishedAt',c=>({...c,publishedAt:0})],
|
|
['config.tasks',c=>({...c,tasks:[]})],
|
|
['config.tasks[0]',c=>({...c,tasks:[null]})],
|
|
['config.tasks[0].taskId',c=>({...c,tasks:[{...c.tasks[0],taskId:''}]})],
|
|
['config.tasks[1].taskId',c=>({...c,tasks:[c.tasks[0],{...c.tasks[1],taskId:c.tasks[0].taskId}]})],
|
|
['config.tasks[0].sequence',c=>({...c,tasks:[{...c.tasks[0],sequence:2}]})],
|
|
['config.tasks[0].targetWins',c=>({...c,tasks:[{...c.tasks[0],targetWins:1001}]})],
|
|
['config.tasks[1].targetWins',c=>({...c,tasks:[c.tasks[0],{...c.tasks[1],targetWins:1}]})],
|
|
['config.tasks[0].items',c=>({...c,tasks:[{...c.tasks[0],items:[]}]})],
|
|
['config.tasks[0].items[0]',c=>({...c,tasks:[{...c.tasks[0],items:[null]}]})],
|
|
['config.tasks[0].items[0].type',c=>({...c,tasks:[{...c.tasks[0],items:[{type:'other',count:1}]}]})],
|
|
['config.tasks[0].items[0].count',c=>({...c,tasks:[{...c.tasks[0],items:[{type:'coin',count:-1}]}]})],
|
|
['config.tasks',c=>({...c,tasks:c.tasks.map(t=>({...t,items:[{type:'coin',count:Number.MAX_SAFE_INTEGER}]}))})],
|
|
];
|
|
for(const [path,change] of cases){
|
|
reset();assert.throws(()=>R.validateConfig(change(copy(config))),error=>{
|
|
const r=R.failure(error);assert.equal(r.code,0);assert.equal(r.data,null);assert.equal(r.errorCode,'CONFIG_UNAVAILABLE');
|
|
assert(r.msg.startsWith(path+' '),r.msg);assert.notEqual(r.msg,r.errorCode);return true;
|
|
});
|
|
}
|
|
assert.throws(()=>R.validateConfig({...config,effectiveFromPeriodId:'goldMiner:2026-09-18'}),error=>error.errorCode==='INVALID_PERIOD'&&error.message.includes('config.effectiveFromPeriodId')&&error.message.includes('周四'));
|
|
});
|
|
|
|
test('both publish endpoints return actionable configuration details and reject malformed task objects',async()=>{
|
|
reset();const future={...copy(config),effectiveFromPeriodId:'goldMiner:2026-09-24',priceFen:0};
|
|
let result=await admin(ctx({action:'publish',adminToken:'admin-test',config:future}));
|
|
assert.equal(result.errorCode,'CONFIG_UNAVAILABLE');assert.match(result.msg,/config\.priceFen.*大于 0/);
|
|
for(const cfg of [undefined,null,[],JSON.stringify(future)]){
|
|
result=await admin(ctx({action:'publish',adminToken:'admin-test',config:cfg}));assert.match(result.msg,/config 必须为 JSON 对象/);
|
|
}
|
|
enableTemporary();
|
|
result=await admin(ctx(temporaryBody({config:{...config,tasks:[null]}})));
|
|
assert.equal(result.errorCode,'CONFIG_UNAVAILABLE');assert.equal(result.msg,'config.tasks[0] 必须为任务对象');
|
|
assert.equal(table(R.TABLES.configs).filter(r=>r.recordType==='testSchedule').length,0);
|
|
result=await admin(ctx(temporaryBody({startsAt:'2026-09-21'})));assert.match(result.msg,/startsAt.*毫秒/);
|
|
result=await admin(ctx(temporaryBody({endsAt:TEMP_START})));assert.equal(result.msg,'endsAt 必须严格晚于 startsAt');
|
|
await publishTemporary();result=await admin(ctx(temporaryBody({periodId:'goldMiner:test:overlap'})));
|
|
assert.equal(result.errorCode,'CONFIG_UNAVAILABLE');assert(result.msg.includes(TEMP));assert(result.msg.includes(String(TEMP_END)));
|
|
});
|
|
|
|
test('business errors have readable defaults and explicit messages remain intact',async()=>{
|
|
reset();await win(1);const denied=await claim('t1');
|
|
assert.equal(denied.errorCode,'NOT_PAID');assert.match(denied.msg,/尚未解锁/);
|
|
assert.match((await call('unsupported')).msg,/action/);
|
|
assert.equal(R.failure(new R.ActivityError('INVALID_INPUT','field-specific explanation')).msg,'field-specific explanation');
|
|
assert.equal(R.failure(new Error('secret connection string')).msg,'黄金矿工处理失败,请使用原请求重试');
|
|
assert.equal(R.failure(new Error('secret connection string')).errorCode,'RETRYABLE');
|
|
});
|
|
|
|
test('payment configuration reports the failing variable without exposing supplied secrets',async()=>{
|
|
for(const [name,value] of [['WX_MCH_ID',''],['GOLD_MINER_WECHATPAY_API_V3_KEY','private-secret-short'],
|
|
['GOLD_MINER_WECHATPAY_PUBLIC_KEYS','{private-json'],['GOLD_MINER_CHECK_IOS_URL','https://test.invalid/wrong'],
|
|
['GOLD_MINER_PAY_PAGE_URL','https://secret:password@test.invalid/order3.html']]){
|
|
legacyReset();process.env[name]=value;
|
|
const result=await call('create_order',{channel:'legacy_ios',itemid:'gold_miner',createRequestId:'diagnostic'});
|
|
assert.equal(result.errorCode,'PAYMENT_CONFIG_UNAVAILABLE');assert(result.msg.includes(name),result.msg);
|
|
if(value)assert(!result.msg.includes(value));assert.equal(table('order').length,0);
|
|
}
|
|
reset();delete process.env.WX_MIDAS_PAY_SIGN_KEY;
|
|
let result=await android(ctx({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'diagnostic'}));assert.match(result.msg,/WX_MIDAS_PAY_SIGN_KEY/);
|
|
reset();delete table('users')[0].session_key;
|
|
result=await android(ctx({uid:'u',token:'token',itemid:'gold_miner',createRequestId:'diagnostic'}));assert.match(result.msg,/session_key/);
|
|
});
|
|
|
|
|
|
const vipExample=JSON.parse(readFileSync(new URL('../vip-tiers.example.json',import.meta.url),'utf8'));
|
|
const vipConfig=()=>({...copy(vipExample.config),status:'published',effectiveFromPeriodId:config.effectiveFromPeriodId,publishedAt:START-1000});
|
|
function useVipConfig(level) {
|
|
table('users')[0].vip_level=level;
|
|
tables[R.TABLES.configs]=[version(vipConfig())];
|
|
}
|
|
|
|
test('VIP example validates all six exact totals and publishes via normal and test period admin routes',async()=>{
|
|
reset();const c=R.validateConfig(vipConfig());
|
|
assert.deepEqual(c.vipTiers.map(t=>t.priceFen),[300,600,1200,3000,6800,12800]);
|
|
assert.deepEqual(c.vipTiers.map(t=>t.tasks.reduce((sum,t)=>sum+t.items[0].count,0)),[6000,6000,8000,15000,28000,45000]);
|
|
assert.deepEqual(c.vipTiers.map(t=>t.tasks.at(-1).targetWins),[50,60,80,100,150,200]);
|
|
const published=await admin(ctx({...copy(vipExample),adminToken:'admin-test'}));assert.equal(published.code,1,JSON.stringify(published));
|
|
enableTemporary();const temp=await admin(ctx(temporaryBody({config:copy(vipExample.config)})));
|
|
assert.equal(temp.code,1,JSON.stringify(temp));assert.equal(temp.data.configSnapshot.vipTiers.length,6);
|
|
for(const edit of [c=>c.vipTiers.pop(),c=>c.vipTiers[1].vipLevel=0,c=>c.vipTiers[1].productId=c.vipTiers[0].productId,
|
|
c=>c.vipTiers[1].tasks[0].targetWins=1,c=>c.vipTiers[2].tasks[9].targetWins=60,c=>c.vipTiers[5].tasks[0].items[0].count=0]) {
|
|
const bad=vipConfig();edit(bad);assert.throws(()=>R.validateConfig(bad),/vipTiers/);
|
|
}
|
|
});
|
|
|
|
test('VIP info and both native payment routes use server-selected tier price and reward snapshots',async()=>{
|
|
for(let level=0;level<6;level++) {
|
|
reset();useVipConfig(level);const tier=vipConfig().vipTiers[level];
|
|
const info=(await call('info',{vip_level:5})).data;
|
|
assert.equal(info.vipLevel,level);assert.equal(info.priceFen,tier.priceFen);assert.equal(info.productId,tier.productId);
|
|
assert.deepEqual(info.tasks.map(t=>t.targetWins),tier.tasks.map(t=>t.targetWins));
|
|
assert.deepEqual(info.tasks.map(t=>t.itemsSnapshot),tier.tasks.map(t=>t.items));
|
|
assert.equal(info.endsAt,END);assert.equal(info.startsAt,START);
|
|
const order=await buy(level%2?ios:android,{itemid:tier.productId});assert.equal(order.goodsPrice,tier.priceFen);
|
|
assert.equal(order.goldMiner.vipLevel,level);assert.equal(order.goldMiner.configHash,player().configHash);
|
|
table('users')[0].vip_level=(level+1)%6;
|
|
await pay(order);const paid=(await call('info')).data;
|
|
assert.equal(paid.vipLevel,level);assert.equal(paid.priceFen,tier.priceFen);assert.equal(paid.endsAt,END);
|
|
assert.deepEqual(paid.tasks.map(t=>t.itemsSnapshot),info.tasks.map(t=>t.itemsSnapshot));
|
|
}
|
|
});
|
|
|
|
test('missing or invalid VIP and previously paid VIP0 still receive the VIP0 package',async()=>{
|
|
for(const level of [undefined,null,-1,6,'invalid',0]) {
|
|
reset({pay_user:true,vip_profile:{has_ever_paid:true,data_valid:false}});useVipConfig(level);
|
|
const info=(await call('info')).data;assert.equal(info.vipLevel,0);assert.equal(info.priceFen,300);
|
|
const order=await buy(android,{itemid:info.productId});await pay(order);assert.equal((await call('info')).data.status,'unlocked');
|
|
}
|
|
});
|
|
|
|
test('unpaid VIP changes refresh the offer, preserve progress, reject stale products and pin at order creation',async()=>{
|
|
reset();useVipConfig(0);await win(1);
|
|
let info=(await call('info')).data;assert.equal(info.tasks[0].claimStatus,'pending_unlock');assert.equal(info.progressWins,1);
|
|
assert.equal((await claim('task_1')).errorCode,'NOT_PAID');
|
|
table('users')[0].vip_level=5;
|
|
const stale=await android(ctx({uid:'u',token:'token',itemid:info.productId,createRequestId:'stale'}));
|
|
assert.equal(stale.errorCode,'INVALID_PRODUCT');assert.equal(table('order').length,0);assert.equal(player().offerLockedAt,undefined);
|
|
info=(await call('info')).data;assert.equal(info.vipLevel,5);assert.equal(info.progressWins,1);assert.equal(info.tasks[0].claimStatus,'locked');
|
|
await win(2);await win(3);assert.equal((await call('info')).data.tasks[0].claimStatus,'pending_unlock');
|
|
const order=await buy(android,{itemid:info.productId});table('users')[0].vip_level=0;
|
|
assert.equal((await call('info')).data.vipLevel,5);await pay(order);
|
|
assert.equal((await call('info')).data.tasks[0].claimStatus,'claimable');
|
|
assert.equal(order.goodsPrice,12800);assert.equal(player().progressWins,3);
|
|
});
|
|
|
|
test('unpaid wins beyond a lower VIP final goal remain available if VIP rises before ordering',async()=>{
|
|
reset();useVipConfig(0);
|
|
for(let i=1;i<=61;i++) await win(i);
|
|
assert.equal((await call('info')).data.progressWins,61);
|
|
table('users')[0].vip_level=1;const info=(await call('info')).data;
|
|
assert.equal(info.maxTarget,60);assert.ok(info.tasks.every(t=>t.claimStatus==='pending_unlock'));
|
|
await pay(await buy(android,{itemid:info.productId}));
|
|
const last=await call('claim',{periodId:info.periodId,taskId:'task_10',requestId:'last-first'});assert.equal(last.code,1);
|
|
assert.equal((await call('confirm_delivery',{periodId:info.periodId,taskId:'task_10',grantId:last.data.grantId})).code,1);
|
|
assert.equal(player().claimedThrough,0);assert.equal((await call('info')).data.tasks[0].claimStatus,'claimable');
|
|
now=END;await jobs();const s=(await call('settlements')).data.items[0];assert.equal(s.rewards.length,9);
|
|
assert.ok(s.rewards.every(r=>r.taskId!=='task_10'));
|
|
});
|
|
|
|
test('legacy VIP order and delayed carry preserve the originally purchased tier through VIP changes',async()=>{
|
|
legacyReset();useVipConfig(4);const data=await legacyBuy({itemid:'gold_miner_vip4'});
|
|
assert.equal(data.priceFen,6800);assert.equal(legacyOrder().goodsPrice,6800);
|
|
reset();useVipConfig(5);const order=await buy(android,{itemid:'gold_miner_vip5'});
|
|
table('users')[0].vip_level=0;now=END;await pay(order);assert.equal(order.goldMiner.fulfillmentRoute,'carry_next');
|
|
now=NEXT;await jobs();const info=(await call('info')).data;
|
|
assert.equal(info.vipLevel,5);assert.equal(info.priceFen,12800);assert.equal(info.maxTarget,200);
|
|
assert.equal(info.tasks.reduce((sum,t)=>sum+t.itemsSnapshot[0].count,0),45000);
|
|
});
|