diff --git a/laf-cloud/functions/login.ts b/laf-cloud/functions/login.ts index b46abe8..f85860c 100644 --- a/laf-cloud/functions/login.ts +++ b/laf-cloud/functions/login.ts @@ -488,6 +488,8 @@ async function getIosOrder(openid, ctx) { let list = []; if (res.data.length > 0) { for (let i = 0; i < res.data.length; i++) { + // 金字塔使用专用支付确认及订单级去重埋点,不走旧通用补单/普通 payment 上报。 + if (res.data[i].pyramid || res.data[i].activityType === "pyramid") continue; // 原生黄金矿工订单不走旧商户查单,也不能被三小时清单逻辑删除。 if (res.data[i].goldMiner || String(res.data[i].itemid || "").startsWith("gold_miner")) continue; let ret = await getOrder(res.data[i].outTradeNo); diff --git a/laf-cloud/functions/pyramid/README.md b/laf-cloud/functions/pyramid/README.md index 6a55303..b296ac3 100644 --- a/laf-cloud/functions/pyramid/README.md +++ b/laf-cloud/functions/pyramid/README.md @@ -136,3 +136,12 @@ 前端领取成功后同步绝对库存、关闭补发弹窗、播放战令奖励动画。领取失败保留弹窗供重试;关闭弹窗不入账、不播放奖励动画、不消耗服务端清单,下次首页/前台检查仍可领取。动画只展示,不修改库存。 部署须同步更新 pyramid/api、客户端及金字塔分包,旧客户端的查询即入账协议不再使用。现有每分钟 jobs 触发器不变,无需新增云函数或集合。 + + +### 2026-10-13 payment 埋点补齐 + +金字塔在统一 fulfill 流程确认 paymentStatus=succeeded 且 firstVerifiedAt 有效后上报 payment,覆盖原生支付、iOS 客服支付、查询恢复和 jobs 发货恢复。按服务器订单填写金额(分)、商品、数量和已验证支付时间;沿用现有用户/AB 属性与 Android、ios、ios_old 渠道口径。测试环境订单进入测试数数项目。未支付订单不报;已付款但次数过期的订单仍报。 + +使用项目已有 trackFirst,firstCheckId=outTradeNo,重复请求由数数按订单去重;不新增云函数或集合。SDK 异常不阻断发货,后续回调/查单可再次尝试;没有新增独立的埋点补偿定时任务。首次事件可能延迟入库,不能以查单调用次数统计支付笔数。没有 distinctId 时记录警告并等待后续确认重试。 + +部署更新 pyramid/api 与 login。login 的旧商户补单跳过金字塔,避免通用普通 payment 上报与专用去重事件混用;金字塔仍由专用支付回调、客户端支付恢复及 jobs 处理。无需更新前端。本次不批量回填历史订单。 diff --git a/laf-cloud/functions/pyramid/api.ts b/laf-cloud/functions/pyramid/api.ts index 641730a..938cc88 100644 --- a/laf-cloud/functions/pyramid/api.ts +++ b/laf-cloud/functions/pyramid/api.ts @@ -454,9 +454,47 @@ export async function createOrder(body: any, platform: "and" | "ios") { }; } catch (error) { return failure(error); } } +// All verified payment channels converge here. The analytics first-event key is +// the order ID, so callback/query/job retries cannot count the same payment twice. +async function reportPayment(order: any) { + try { + const user = await collection("users").findOne({ _id: order.pyramid.uid }); + if (!user?.distinctId || user.openid !== order.openid) { + console.warn("[PyramidPayment] payment 上报缺少匹配的用户标识", order.outTradeNo.slice(-6)); + return; + } + let abTests = user.abTests; + if (typeof abTests === "string") { try { abTests = JSON.parse(abTests); } catch { abTests = null; } } + const group = (key: string) => ({ A: 1, B: 2, C: 3, D: 4, E: 5 }[String(abTests?.[key]?.group || "").toUpperCase()] || 0); + const ThinkingData = require('thinkingdata-node'); + const appId = order.paymentAppEnv === "test" || user.isDebug === "true" + ? '95993f9ab6f1402a87abe5147827e5e0' : '40e3d5c5f2af49a4a074205564ce5dbb'; + const sdk = ThinkingData.initWithBatchMode(appId, 'https://data.nika4fun.com/sync_data', { dryRun: false, deviceId: '123456789' }); + try { + sdk.trackFirst({ + accountId: order.openid, distinctId: user.distinctId, event: 'payment', firstCheckId: order.outTradeNo, + time: new Date(order.pyramid.firstVerifiedAt + 8 * 3600000), + properties: { + order_id: order.outTradeNo, pay_amount: order.goodsPrice, payment_name: order.itemid, payment_num: order.itemCount, + payment_type: order.pyramid.paymentChannel === "legacy_ios" ? "ios_old" : order.type === "ios" ? "ios" : "Android", + is_on_table: Number(user.addLevel) >= 5, current_level: user.levelAmount, current_health: user.healthAmount, + tmp_coin: user.coinAmount, version: "1.6", user_id: user._id, pay_user: user.pay_user, register_time: user.register_time, + abTest_layer_one: group("layer_1"), abTest_layer_two: group("layer_2"), abTest_layer_three: group("layer_3") + }, + callback(error: any) { + if (error) console.error("[PyramidPayment] payment 上报失败,后续支付确认可重试", order.outTradeNo.slice(-6)); + } + }); + } finally { sdk.close(); } + } catch { + // Analytics failure must never block payment acknowledgement or draw credits. + console.error("[PyramidPayment] payment 上报失败,后续支付确认可重试", order.outTradeNo.slice(-6)); + } +} export async function fulfill(orderId: string) { const order = await orders().findOne({ outTradeNo: orderId }), g = order?.pyramid; if (!g || g.paymentStatus !== "succeeded" || !g.firstVerifiedAt) fail("PAYMENT_PENDING"); + await reportPayment(order); let saved; try { saved = await mutate(g.uid, g.periodId, (p, now) => { @@ -661,9 +699,11 @@ export async function settleExpiredPeriod(uid: string, periodId: string) { export async function readSettlement(uid: string) { const rows = await collection(TABLES.players).find({ uid, endAt: { $lte: Date.now() }, "settlementNotice.readAt": null }) .sort({ endAt: 1 }).toArray(); - return { status: rows.some((p: any) => p.settlementPreparedAt == null) ? "settling" : "ready", + return { + status: rows.some((p: any) => p.settlementPreparedAt == null) ? "settling" : "ready", notices: rows.filter((p: any) => p.settlementNotice && p.settlementPreparedAt != null).map((p: any) => clone(p.settlementNotice)), - serverTime: Date.now() }; + serverTime: Date.now() + }; } /** Only an explicit claim deposits this player's notice. Retries return the same receipt. */ diff --git a/laf-cloud/functions/pyramid/tests/pyramid.test.mjs b/laf-cloud/functions/pyramid/tests/pyramid.test.mjs index db198dc..611a009 100644 --- a/laf-cloud/functions/pyramid/tests/pyramid.test.mjs +++ b/laf-cloud/functions/pyramid/tests/pyramid.test.mjs @@ -2,941 +2,1065 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { registerHooks } from 'node:module'; import { createHmac } from 'node:crypto'; -const START=Date.parse('2026-09-28T00:00:00+08:00'), END=START+86400000; -let now=START+1000,tables={},beforeWrite=null; -const realNow=Date.now;Date.now=()=>now;test.after(()=>Date.now=realNow); -const copy=x=>x==null?x:structuredClone(x),table=n=>tables[n]||=([]); -function values(row,path){if(!path.length)return [row];if(Array.isArray(row))return row.flatMap(r=>values(r,path));return values(row?.[path[0]],path.slice(1));} -function get(row,path){return values(row,path.split('.'))[0];} -function put(row,path,v){const a=path.split('.'),last=a.pop();for(const p of a)row=row[p]||=( {} );row[last]=copy(v);} -function matches(row,q){return Object.entries(q).every(([k,v])=>{ - if(k==='$expr')return nowObject.entries(v).every(([op,x])=>{ - if(op==='$eq')return JSON.stringify(a)===JSON.stringify(x);if(op==='$exists')return (a!==undefined)===x;if(op==='$gt')return a>x;if(op==='$gte')return a>=x; - if(op==='$lt')return av===null?a==null:a===v); -});} -const db={collection(name){return { - async findOne(q){return copy(table(name).find(r=>matches(r,q))||null);}, - find(q){let a=table(name).filter(r=>matches(r,q));return {sort(s){a.sort((x,y)=>{for(const[k,d]of Object.entries(s)){if(get(x,k)!==get(y,k))return(get(x,k)matches(r,q)); - if(!r&&o.upsert){if(table(name).some(r=>r._id===q._id))throw Error('duplicate');r={_id:q._id,...copy(u.$setOnInsert||{})};table(name).push(r);return {modifiedCount:0,upsertedCount:1};} - if(!r)return {modifiedCount:0};for(const[k,v]of Object.entries(u.$set||{}))put(r,k,v);return {modifiedCount:1};}, - async insertOne(r){if(table(name).some(x=>x._id===r._id))throw Error('duplicate');table(name).push(copy(r));},async createIndex(){} -};}}; -globalThis.__pyramidCloud={mongo:{db}}; -registerHooks({resolve(s,c,next){if(s==='@lafjs/cloud')return{url:'data:text/javascript,export default globalThis.__pyramidCloud',shortCircuit:true}; - if(s==='@/Utils')return{url:'data:text/javascript,export default {checkToken:(a,b)=>a===b}',shortCircuit:true}; - // Pyramid integration tests isolate the unrelated gold miner provider. - if(s==='@/goldMiner/config')return{url:'data:text/javascript,export const calendar=()=>({startsAt:Number.MAX_SAFE_INTEGER})',shortCircuit:true}; - if(s==='@/goldMiner/service')return{url:'data:text/javascript,export const periodById=async()=>null,periodForPlayer=async()=>null',shortCircuit:true}; - if(s==='@/goldMiner/testPeriods')return{url:'data:text/javascript,export const testPeriods=async()=>[]',shortCircuit:true}; - if(s.startsWith('@/'))return{url:new URL('../../'+s.slice(2)+'.ts',import.meta.url).href,shortCircuit:true};return next(s,c);}}); -const M=await import('../api.ts'),S=M,P=M; -const store=await import('../../activityConfig/store.ts'); -const {default:unifiedList}=await import('../../activityConfig/list.ts'); -const {migrate}=await import('../migrate-config.cjs'); -const {readFileSync}=await import('node:fs'); -const {createHash}=await import('node:crypto'); -const {Exact,buildVerifiedPaymentFeatures}=await import('../../paymentProfile.ts'); -const draft=JSON.parse(readFileSync(new URL('../config/v3-campaign-draft.json',import.meta.url))); -const publishedDraft=copy(draft); -// Keep the original numerical regression fixture stable when an operator edits the draft. -for(const kind of ['catalog','policy']){ - draft.offer[kind+'Json']=readFileSync(new URL('../config/v3-'+kind+'.json',import.meta.url),'utf8'); - draft.offer[kind+'Hash']=createHash('sha256').update(draft.offer[kind+'Json']).digest('hex'); +const START = Date.parse('2026-09-28T00:00:00+08:00'), END = START + 86400000; +let now = START + 1000, tables = {}, beforeWrite = null; +let paymentEvents = [], analyticsFailure = false, analyticsCallbackFailure = false; +globalThis.require = name => { + assert.equal(name, 'thinkingdata-node'); + return { + initWithBatchMode(appId) { + return { + trackFirst(event) { + if (analyticsFailure) throw Error('analytics offline'); + if (analyticsCallbackFailure) { event.callback(Error('analytics rejected')); return; } + paymentEvents.push({ appId, ...event }); event.callback(null); + }, close() { } + }; + } + }; +}; +const realNow = Date.now; Date.now = () => now; test.after(() => Date.now = realNow); +const copy = x => x == null ? x : structuredClone(x), table = n => tables[n] ||= ([]); +function values(row, path) { if (!path.length) return [row]; if (Array.isArray(row)) return row.flatMap(r => values(r, path)); return values(row?.[path[0]], path.slice(1)); } +function get(row, path) { return values(row, path.split('.'))[0]; } +function put(row, path, v) { const a = path.split('.'), last = a.pop(); for (const p of a) row = row[p] ||= ({}); row[last] = copy(v); } +function matches(row, q) { + return Object.entries(q).every(([k, v]) => { + if (k === '$expr') return now < v.$lt[1].getTime(); + const vals = values(row, k.split('.')); + if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) return vals.some(a => Object.entries(v).every(([op, x]) => { + if (op === '$eq') return JSON.stringify(a) === JSON.stringify(x); if (op === '$exists') return (a !== undefined) === x; if (op === '$gt') return a > x; if (op === '$gte') return a >= x; + if (op === '$lt') return a < x; if (op === '$lte') return a <= x; if (op === '$ne') return a !== x; if (op === '$in') return x.includes(a); throw Error(op); + })); return vals.some(a => v === null ? a == null : a === v); + }); } -const DAY=86400000; -const offerConfig=()=>({...copy(draft),periodId:'pyramid:2026-09-28',startAt:START,endAt:START+7*DAY}); -const policy=JSON.parse(draft.offer.policyJson); -function facts(orders=[],extra={}) { - return {feature_schema_version:'verified_payment_features_1',evidence_version:'weighted_order_days_1',sku_classification_version:draft.offer.skuClassificationVersion, - snapshot_id:'verified-test',revision:1,identity_revision:1,watermark:1,has_ever_paid:true,as_of:now, - registration_at:now-90*DAY,coverage_start:now-35*DAY,coverage_complete:true,full_history_verified:true,net_payment_verified:true, - last_positive_net_paid_at:orders.length?Math.max(...orders.filter(o=>o.paid_fen>o.refunded_fen).map(o=>o.paid_at)):null,orders,...extra}; -} -function evidence(amount,days=1,category='normal',extra={}) {return {order_id:`o-${days}-${amount}`,paid_at:now-days*DAY, - paid_fen:amount,refunded_fen:0,refund_verified:true,sku:'verified-sku',sku_version:'v1',evidence_class:category,...extra};} -const features=f=>buildVerifiedPaymentFeatures(f,now,policy.evidence.weights,draft.offer.skuClassificationVersion); -const select=f=>M.selectOffer(features(f),policy,7*DAY,[],[],now); -test('v3 catalog validates all 80 slots, source hashes, exact cost ceilings and totals',()=>{ - const c=offerConfig();M.validPeriod(c);const {catalog}=M.validateOffer(c.offer); - assert.deepEqual(catalog.tiers.map(t=>t.total_coin_equivalent),[80600,128600,211800,322400]); - for(let tier=1;tier<=4;tier++){ - const levels=M.offerLevels(c.offer,tier);assert.equal(levels[0].itemPrice,0);assert.equal(levels[5].rewards.length,0); - assert.equal(levels.flatMap(l=>l.rewards).length,20); - assert.equal(levels[1].rewards[4].icon,'chest-1');assert.equal(levels[2].rewards[3].icon,'chest-2'); - assert.deepEqual(levels[0],M.offerLevels(c.offer,1)[0]); - const totals={};for(const l of levels)for(const r of l.rewards)for(const g of r.goods)totals[g.id]=(totals[g.id]||0)+g.count; - assert.deepEqual(totals,{1001:[21100,30900,55800,82800][tier-1],2001:[62,100,162,262][tier-1], - 2002:[34,51,91,137][tier-1],2003:[35,59,84,132][tier-1],2005:[405,735,1095,1635][tier-1]*60}); - } - for(const edit of [o=>o.catalogJson+=' ',o=>o.resourceMappingVersion='unknown',o=>{ - const p=JSON.parse(o.policyJson);p.expected_completion_cost_fen_ceil[1]--;o.policyJson=JSON.stringify(p);o.policyHash=createHash('sha256').update(o.policyJson).digest('hex'); - }]){const bad=copy(c.offer);edit(bad);assert.throws(()=>M.validateOffer(bad));} -}); - -test('updated rewards allow ordinary slots up to 800 equivalent per yuan and retain strict validation',()=>{ - const offer=copy(draft.offer),catalog=JSON.parse(offer.catalogJson),policy=JSON.parse(offer.policyJson); - policy.reward_limits.non_chest_paid_max_equivalent_per_yuan=800; - const first=catalog.tiers[0],second=catalog.tiers[1]; - Object.assign(first.slots[8],{reward:{hammer:4},coin_equivalent:2400}); - Object.assign(first.slots[9],{reward:{wand:8},coin_equivalent:2400}); - Object.assign(first.slots[12],{reward:{freeze:25},coin_equivalent:5000}); - Object.assign(first.slots[15],{reward:{coins:8000},coin_equivalent:8000}); - Object.assign(second.slots[11],{reward:{wand:25},coin_equivalent:7500}); - first.total_coin_equivalent=81500;second.total_coin_equivalent=128300; - const pack=(c,p)=>{ - const result=copy(offer);result.catalogJson=JSON.stringify(c);result.policyJson=JSON.stringify(p); - for(const kind of ['catalog','policy'])result[kind+'Hash']=createHash('sha256').update(result[kind+'Json']).digest('hex'); - return result; - }; - assert.doesNotThrow(()=>M.validateOffer(pack(catalog,policy))); - const oldLimit=copy(policy);oldLimit.reward_limits.non_chest_paid_max_equivalent_per_yuan=700; - assert.throws(()=>M.validateOffer(pack(catalog,oldLimit)),e=>e.errorCode==='CONFIG_INVALID'); - for(const key of ['non_chest_paid_max_equivalent_per_yuan','chest_paid_max_equivalent_per_yuan']){ - const invalid=copy(policy);invalid.reward_limits[key]=801; - assert.throws(()=>M.validateOffer(pack(catalog,invalid)),e=>e.errorCode==='CONFIG_INVALID'); - } - const excessive=copy(catalog);excessive.tiers[0].slots[8].reward.hammer=5; - excessive.tiers[0].slots[8].coin_equivalent=3000;excessive.tiers[0].total_coin_equivalent+=600; - assert.throws(()=>M.validateOffer(pack(excessive,policy)),e=>e.errorCode==='CONFIG_INVALID'); -}); - -test('current operator draft validates with its own catalog, policy and original byte hashes',()=>{ - M.validPeriod({...copy(publishedDraft),startAt:START,endAt:START+7*DAY}); -}); - -test('enumerates all 76800 complete paths: costs, no duplicate draws, top fills exactly twenty slots',()=>{ - for(let tier=1;tier<=4;tier++){ - const levels=M.offerLevels(draft.offer,tier);let paths=0,prob=0,costSum=0,min=Infinity,max=0; - function walk(layer,drawn,chance,cost){ - if(layer===5){paths++;prob+=chance;costSum+=chance*cost;min=Math.min(min,cost);max=Math.max(max,cost); - const missing=levels.slice(0,5).flatMap(l=>l.rewards).filter(r=>!drawn.includes(r.rewardId)); - assert.equal(new Set(drawn).size,drawn.length);assert.equal(drawn.length+missing.length,20);return;} - const l=levels[layer],pool=l.rewards.filter(r=>!drawn.includes(r.rewardId)),sum=pool.reduce((n,r)=>n+r.weight,0); - for(const r of pool)walk(r.up?layer+1:layer,[...drawn,r.rewardId],chance*r.weight/sum,cost+l.itemPrice); +const db = { + collection(name) { + return { + async findOne(q) { return copy(table(name).find(r => matches(r, q)) || null); }, + find(q) { let a = table(name).filter(r => matches(r, q)); return { sort(s) { a.sort((x, y) => { for (const [k, d] of Object.entries(s)) { if (get(x, k) !== get(y, k)) return (get(x, k) < get(y, k) ? -1 : 1) * d; } return 0; }); return this; }, limit(n) { a = a.slice(0, n); return this; }, async toArray() { return copy(a); } }; }, + async updateOne(q, u, o = {}) { + if (beforeWrite) beforeWrite(name, q, u); let r = table(name).find(r => matches(r, q)); + if (!r && o.upsert) { if (table(name).some(r => r._id === q._id)) throw Error('duplicate'); r = { _id: q._id, ...copy(u.$setOnInsert || {}) }; table(name).push(r); return { modifiedCount: 0, upsertedCount: 1 }; } + if (!r) return { modifiedCount: 0 }; for (const [k, v] of Object.entries(u.$set || {})) put(r, k, v); return { modifiedCount: 1 }; + }, + async insertOne(r) { if (table(name).some(x => x._id === r._id)) throw Error('duplicate'); table(name).push(copy(r)); }, async createIndex() { } + }; } - walk(0,[],1,0);assert.equal(paths,19200);assert.ok(Math.abs(prob-1)<1e-10); - assert.equal(Math.ceil(costSum),policy.expected_completion_cost_fen_ceil[tier-1]); - assert.equal(min,levels.slice(1,5).reduce((n,l)=>n+l.itemPrice,0)); - assert.equal(max,levels.slice(1,5).reduce((n,l,i)=>n+l.itemPrice*[4,3,3,2][i],0)); - } +}; +globalThis.__pyramidCloud = { mongo: { db } }; +registerHooks({ + resolve(s, c, next) { + if (s === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__pyramidCloud', shortCircuit: true }; + if (s === '@/Utils') return { url: 'data:text/javascript,export default {checkToken:(a,b)=>a===b}', shortCircuit: true }; + // Pyramid integration tests isolate the unrelated gold miner provider. + if (s === '@/goldMiner/config') return { url: 'data:text/javascript,export const UNLOCK_PASSED_LEVEL=50;export const calendar=()=>({startsAt:Number.MAX_SAFE_INTEGER})', shortCircuit: true }; + if (s === '@/goldMiner/service') return { url: 'data:text/javascript,export const periodById=async()=>null,periodForPlayer=async()=>null', shortCircuit: true }; + if (s === '@/goldMiner/testPeriods') return { url: 'data:text/javascript,export const testPeriods=async()=>[]', shortCircuit: true }; + if (s.startsWith('@/')) return { url: new URL('../../' + s.slice(2) + '.ts', import.meta.url).href, shortCircuit: true }; return next(s, c); + } +}); +const M = await import('../api.ts'), S = M, P = M; +const store = await import('../../activityConfig/store.ts'); +const { default: unifiedList } = await import('../../activityConfig/list.ts'); +const { migrate } = await import('../migrate-config.cjs'); +const { readFileSync } = await import('node:fs'); +const { createHash } = await import('node:crypto'); +const { Exact, buildVerifiedPaymentFeatures } = await import('../../paymentProfile.ts'); +const draft = JSON.parse(readFileSync(new URL('../config/v3-campaign-draft.json', import.meta.url))); +const publishedDraft = copy(draft); +// Keep the original numerical regression fixture stable when an operator edits the draft. +for (const kind of ['catalog', 'policy']) { + draft.offer[kind + 'Json'] = readFileSync(new URL('../config/v3-' + kind + '.json', import.meta.url), 'utf8'); + draft.offer[kind + 'Hash'] = createHash('sha256').update(draft.offer[kind + 'Json']).digest('hex'); +} +const DAY = 86400000; +const offerConfig = () => ({ ...copy(draft), periodId: 'pyramid:2026-09-28', startAt: START, endAt: START + 7 * DAY }); +const policy = JSON.parse(draft.offer.policyJson); +function facts(orders = [], extra = {}) { + return { + feature_schema_version: 'verified_payment_features_1', evidence_version: 'weighted_order_days_1', sku_classification_version: draft.offer.skuClassificationVersion, + snapshot_id: 'verified-test', revision: 1, identity_revision: 1, watermark: 1, has_ever_paid: true, as_of: now, + registration_at: now - 90 * DAY, coverage_start: now - 35 * DAY, coverage_complete: true, full_history_verified: true, net_payment_verified: true, + last_positive_net_paid_at: orders.length ? Math.max(...orders.filter(o => o.paid_fen > o.refunded_fen).map(o => o.paid_at)) : null, orders, ...extra + }; +} +function evidence(amount, days = 1, category = 'normal', extra = {}) { + return { + order_id: `o-${days}-${amount}`, paid_at: now - days * DAY, + paid_fen: amount, refunded_fen: 0, refund_verified: true, sku: 'verified-sku', sku_version: 'v1', evidence_class: category, ...extra + }; +} +const features = f => buildVerifiedPaymentFeatures(f, now, policy.evidence.weights, draft.offer.skuClassificationVersion); +const select = f => M.selectOffer(features(f), policy, 7 * DAY, [], [], now); +test('v3 catalog validates all 80 slots, source hashes, exact cost ceilings and totals', () => { + const c = offerConfig(); M.validPeriod(c); const { catalog } = M.validateOffer(c.offer); + assert.deepEqual(catalog.tiers.map(t => t.total_coin_equivalent), [80600, 128600, 211800, 322400]); + for (let tier = 1; tier <= 4; tier++) { + const levels = M.offerLevels(c.offer, tier); assert.equal(levels[0].itemPrice, 0); assert.equal(levels[5].rewards.length, 0); + assert.equal(levels.flatMap(l => l.rewards).length, 20); + assert.equal(levels[1].rewards[4].icon, 'chest-1'); assert.equal(levels[2].rewards[3].icon, 'chest-2'); + assert.deepEqual(levels[0], M.offerLevels(c.offer, 1)[0]); + const totals = {}; for (const l of levels) for (const r of l.rewards) for (const g of r.goods) totals[g.id] = (totals[g.id] || 0) + g.count; + assert.deepEqual(totals, { + 1001: [21100, 30900, 55800, 82800][tier - 1], 2001: [62, 100, 162, 262][tier - 1], + 2002: [34, 51, 91, 137][tier - 1], 2003: [35, 59, 84, 132][tier - 1], 2005: [405, 735, 1095, 1635][tier - 1] * 60 + }); + } + for (const edit of [o => o.catalogJson += ' ', o => o.resourceMappingVersion = 'unknown', o => { + const p = JSON.parse(o.policyJson); p.expected_completion_cost_fen_ceil[1]--; o.policyJson = JSON.stringify(p); o.policyHash = createHash('sha256').update(o.policyJson).digest('hex'); + }]) { const bad = copy(c.offer); edit(bad); assert.throws(() => M.validateOffer(bad)); } }); -test('v3 profile examples use actual weighted prices and never multiply VIP',()=>{ - for(const [amount,count,category,tier] of [[600,30,'normal',1],[3000,20,'legacy_dominant_30',2], - [3000,30,'legacy_dominant_30',3],[3000,40,'legacy_dominant_30',3],[3000,50,'legacy_dominant_30',4]]){ - // Balanced exact 30/15-day spending, and at least four effective evidence days. - const orders=Array.from({length:count},(_,i)=>evidence(amount,((i%Math.ceil(count/2))+1)/(Math.ceil(count/2))*14+(io.paid_at=Math.floor(o.paid_at)); - const result=select(facts(orders,{vip_level:99}));assert.equal(result.offerTier,tier);assert.deepEqual(result.Q50,new Exact(amount).toJSON()); - } - const single=select(facts([evidence(12800)]));assert.equal(single.offerTier,2);assert.deepEqual(single.q,new Exact('0.25').toJSON()); - assert.equal(select(facts([],{has_ever_paid:false})).offerTier,1); - assert.equal(select(facts([evidence(3000,30)])).reason,'returning'); +test('updated rewards allow ordinary slots up to 800 equivalent per yuan and retain strict validation', () => { + const offer = copy(draft.offer), catalog = JSON.parse(offer.catalogJson), policy = JSON.parse(offer.policyJson); + policy.reward_limits.non_chest_paid_max_equivalent_per_yuan = 800; + const first = catalog.tiers[0], second = catalog.tiers[1]; + Object.assign(first.slots[8], { reward: { hammer: 4 }, coin_equivalent: 2400 }); + Object.assign(first.slots[9], { reward: { wand: 8 }, coin_equivalent: 2400 }); + Object.assign(first.slots[12], { reward: { freeze: 25 }, coin_equivalent: 5000 }); + Object.assign(first.slots[15], { reward: { coins: 8000 }, coin_equivalent: 8000 }); + Object.assign(second.slots[11], { reward: { wand: 25 }, coin_equivalent: 7500 }); + first.total_coin_equivalent = 81500; second.total_coin_equivalent = 128300; + const pack = (c, p) => { + const result = copy(offer); result.catalogJson = JSON.stringify(c); result.policyJson = JSON.stringify(p); + for (const kind of ['catalog', 'policy']) result[kind + 'Hash'] = createHash('sha256').update(result[kind + 'Json']).digest('hex'); + return result; + }; + assert.doesNotThrow(() => M.validateOffer(pack(catalog, policy))); + const oldLimit = copy(policy); oldLimit.reward_limits.non_chest_paid_max_equivalent_per_yuan = 700; + assert.throws(() => M.validateOffer(pack(catalog, oldLimit)), e => e.errorCode === 'CONFIG_INVALID'); + for (const key of ['non_chest_paid_max_equivalent_per_yuan', 'chest_paid_max_equivalent_per_yuan']) { + const invalid = copy(policy); invalid.reward_limits[key] = 801; + assert.throws(() => M.validateOffer(pack(catalog, invalid)), e => e.errorCode === 'CONFIG_INVALID'); + } + const excessive = copy(catalog); excessive.tiers[0].slots[8].reward.hammer = 5; + excessive.tiers[0].slots[8].coin_equivalent = 3000; excessive.tiers[0].total_coin_equivalent += 600; + assert.throws(() => M.validateOffer(pack(excessive, policy)), e => e.errorCode === 'CONFIG_INVALID'); }); -test('weighted median lower tie, day support, refunds, exact windows and prior',()=>{ - const rows=[evidence(600,1),evidence(3000,2)];assert.deepEqual(select(facts(rows)).Q50,new Exact(600).toJSON()); - const same=Array.from({length:40},(_,i)=>evidence(3000,1,'normal',{order_id:`s${i}`})); - assert.deepEqual(select(facts(same)).q,new Exact('0.25').toJSON()); - const partial=evidence(3000,1,'normal',{refunded_fen:1000});const f=features(facts([partial])); - assert.equal(f.M30_fen,2000);assert.equal(f.F30,1);const r=M.selectOffer(f,policy,7*DAY); - assert.equal(r.used_prior,true);assert.deepEqual(r.q,new Exact(0).toJSON());assert.deepEqual(r.A,new Exact(600).toJSON()); - const boundary=features(facts([evidence(100,30),evidence(200,15),evidence(300,30.0001)])); - assert.equal(boundary.M30_fen,300);assert.equal(boundary.M15_fen,200); +test('current operator draft validates with its own catalog, policy and original byte hashes', () => { + M.validPeriod({ ...copy(publishedDraft), startAt: START, endAt: START + 7 * DAY }); }); -test('unknown, stale, dirty, unclassified or contradictory payment facts hold new instances',()=>{ - for(const mutate of [f=>f.has_ever_paid=null,f=>f.as_of=now-31*3600000,f=>f.dirty=true,f=>f.coverage_complete=false, - f=>f.net_payment_verified=false,f=>f.registration_at=now+1,f=>f.last_positive_net_paid_at=now+1, - f=>f.orders[0].evidence_class='unknown',f=>f.orders[0].refund_verified=false,f=>f.has_ever_paid=false]){ - const f=facts([evidence(3000)]);mutate(f);assert.throws(()=>features(f),e=>e.errorCode==='PROFILE_UNAVAILABLE'); - } - const duplicate=evidence(3000);assert.equal(features(facts([duplicate,copy(duplicate)])).N30,1); - assert.throws(()=>features(facts([duplicate,{...duplicate,paid_fen:5000}]))); +test('enumerates all 76800 complete paths: costs, no duplicate draws, top fills exactly twenty slots', () => { + for (let tier = 1; tier <= 4; tier++) { + const levels = M.offerLevels(draft.offer, tier); let paths = 0, prob = 0, costSum = 0, min = Infinity, max = 0; + function walk(layer, drawn, chance, cost) { + if (layer === 5) { + paths++; prob += chance; costSum += chance * cost; min = Math.min(min, cost); max = Math.max(max, cost); + const missing = levels.slice(0, 5).flatMap(l => l.rewards).filter(r => !drawn.includes(r.rewardId)); + assert.equal(new Set(drawn).size, drawn.length); assert.equal(drawn.length + missing.length, 20); return; + } + const l = levels[layer], pool = l.rewards.filter(r => !drawn.includes(r.rewardId)), sum = pool.reduce((n, r) => n + r.weight, 0); + for (const r of pool) walk(r.up ? layer + 1 : layer, [...drawn, r.rewardId], chance * r.weight / sum, cost + l.itemPrice); + } + walk(0, [], 1, 0); assert.equal(paths, 19200); assert.ok(Math.abs(prob - 1) < 1e-10); + assert.equal(Math.ceil(costSum), policy.expected_completion_cost_fen_ceil[tier - 1]); + assert.equal(min, levels.slice(1, 5).reduce((n, l) => n + l.itemPrice, 0)); + assert.equal(max, levels.slice(1, 5).reduce((n, l, i) => n + l.itemPrice * [4, 3, 3, 2][i], 0)); + } }); -test('exact tier thresholds require both caps and recency at 15/30 days',()=>{ - const profile=features(facts([evidence(3000)])); - // Completion anchor tests exact inclusive E/U without rounding display amounts. - const h={id:'top',epoch:policy.feedback_epoch,tier:4,paid:true,paymentVerified:true,refunded:false,pending:false, - completed:true,deliveredSlots:20,completedAt:now-1,endAt:now-1,paidAt:now-1}; - assert.equal(M.selectOffer(profile,policy,7*DAY,[h],[],now).offerTier,4); - h.deliveredSlots=19;assert.equal(M.selectOffer(profile,policy,7*DAY,[h],[],now).offerTier,1); - const soft=M.selectOffer(features(facts([evidence(3000,15)])),policy,7*DAY,[],[],now);assert.deepEqual(soft.g,new Exact('0.75').toJSON()); - assert.equal(select(facts([evidence(3000,30)])).offerTier,1); - const p={...copy(policy),budget_share:0,total_ticket_base:1,total_ticket_confidence_gain:0, - peak_ticket_base:1,peak_ticket_confidence_gain:0}; - for(const [amount,tier] of [[12330,1],[12331,2],[20445,2],[20446,3],[31275,3],[31276,4]]) - assert.equal(M.selectOffer(features(facts([evidence(amount)])),p,7*DAY,[],[],now).offerTier,tier); - assert.equal(M.selectOffer(features(facts([evidence(31276)])),{...p,peak_ticket_base:'0.01'},7*DAY,[],[],now).offerTier,1); - assert.equal(M.selectOffer(features(facts([evidence(12331)])),{...p,total_ticket_base:'0.999999'},7*DAY,[],[],now).offerTier,1); +test('v3 profile examples use actual weighted prices and never multiply VIP', () => { + for (const [amount, count, category, tier] of [[600, 30, 'normal', 1], [3000, 20, 'legacy_dominant_30', 2], + [3000, 30, 'legacy_dominant_30', 3], [3000, 40, 'legacy_dominant_30', 3], [3000, 50, 'legacy_dominant_30', 4]]) { + // Balanced exact 30/15-day spending, and at least four effective evidence days. + const orders = Array.from({ length: count }, (_, i) => evidence(amount, ((i % Math.ceil(count / 2)) + 1) / (Math.ceil(count / 2)) * 14 + (i < Math.ceil(count / 2) ? 0 : 15), category, { order_id: `o${i}` })); + orders.forEach(o => o.paid_at = Math.floor(o.paid_at)); + const result = select(facts(orders, { vip_level: 99 })); assert.equal(result.offerTier, tier); assert.deepEqual(result.Q50, new Exact(amount).toJSON()); + } + const single = select(facts([evidence(12800)])); assert.equal(single.offerTier, 2); assert.deepEqual(single.q, new Exact('0.25').toJSON()); + assert.equal(select(facts([], { has_ever_paid: false })).offerTier, 1); + assert.equal(select(facts([evidence(3000, 30)])).reason, 'returning'); }); -test('failure caps pair once, neutral exposures do not count, paid/pending break, and late payment clears',()=>{ - const h=(id,endAt,tier=4)=>({id,endAt,tier,epoch:policy.feedback_epoch,qualified:true,paymentVerified:true,paid:false,pending:false,refunded:false}); - const a=h('a',now-4*DAY),b=h('b',now-3*DAY),c=h('c',now-2*DAY); - const caps=M.feedbackCaps([a,b,c],policy,now);assert.equal(caps.length,1);assert.deepEqual(caps[0].source_instance_ids,['a','b']); - assert.equal(caps[0].max_tier,3);assert.equal(caps[0].expires_at,b.endAt+30*DAY); - assert.equal(M.feedbackCaps([a,{...h('neutral',now-3.5*DAY),qualified:false},b],policy,now).length,1); - for(const middle of [{paid:true,qualified:false},{pending:true},{refunded:true,paid:true}]) - assert.equal(M.feedbackCaps([a,{...h('x',now-3.5*DAY),...middle},b],policy,now).length,0); - assert.equal(M.feedbackCaps([a,b,{...c,paid:true,paidAt:now-1}],policy,now).length,0); - assert.equal(M.feedbackCaps([b,c],policy,now,['a','b']).length,0); - const profile=features(facts([evidence(12800)]));const selected=M.selectOffer(profile,policy,7*DAY,[a,b],[],now); - assert.equal(selected.offerTier,2);assert.equal(selected.capIds.length,1); // consumed even when base tier was lower +test('weighted median lower tie, day support, refunds, exact windows and prior', () => { + const rows = [evidence(600, 1), evidence(3000, 2)]; assert.deepEqual(select(facts(rows)).Q50, new Exact(600).toJSON()); + const same = Array.from({ length: 40 }, (_, i) => evidence(3000, 1, 'normal', { order_id: `s${i}` })); + assert.deepEqual(select(facts(same)).q, new Exact('0.25').toJSON()); + const partial = evidence(3000, 1, 'normal', { refunded_fen: 1000 }); const f = features(facts([partial])); + assert.equal(f.M30_fen, 2000); assert.equal(f.F30, 1); const r = M.selectOffer(f, policy, 7 * DAY); + assert.equal(r.used_prior, true); assert.deepEqual(r.q, new Exact(0).toJSON()); assert.deepEqual(r.A, new Exact(600).toJSON()); + const boundary = features(facts([evidence(100, 30), evidence(200, 15), evidence(300, 30.0001)])); + assert.equal(boundary.M30_fen, 300); assert.equal(boundary.M15_fen, 200); }); -test('test tier overrides validate integer tiers 1 to 4 and reject malformed mappings',()=>{ - for(const testTierOverrides of [{},{u:1},{u:2},{u:3},{u:4}]) - assert.doesNotThrow(()=>M.validPeriod({...offerConfig(),testTierOverrides})); - for(const testTierOverrides of [null,[],4,'4',{u:'4'},{u:0},{u:5},{u:1.5},{u:null},{' ':4}]) - assert.throws(()=>M.validPeriod({...offerConfig(),testTierOverrides}),e=>e.errorCode==='CONFIG_INVALID'); +test('unknown, stale, dirty, unclassified or contradictory payment facts hold new instances', () => { + for (const mutate of [f => f.has_ever_paid = null, f => f.as_of = now - 31 * 3600000, f => f.dirty = true, f => f.coverage_complete = false, + f => f.net_payment_verified = false, f => f.registration_at = now + 1, f => f.last_positive_net_paid_at = now + 1, + f => f.orders[0].evidence_class = 'unknown', f => f.orders[0].refund_verified = false, f => f.has_ever_paid = false]) { + const f = facts([evidence(3000)]); mutate(f); assert.throws(() => features(f), e => e.errorCode === 'PROFILE_UNAVAILABLE'); + } + const duplicate = evidence(3000); assert.equal(features(facts([duplicate, copy(duplicate)])).N30, 1); + assert.throws(() => features(facts([duplicate, { ...duplicate, paid_fen: 5000 }]))); }); -test('configured test account locks tier four with matching prices and rewards without payment history',async()=>{ - const cfg={...offerConfig(),testTierOverrides:{u:4}}; - table(M.TABLES.periods).splice(0,1,version(cfg));delete table('users')[0].pay_user; - const replies=await Promise.all([api('info'),api('info')]); - for(const reply of replies){assert.equal(reply.code,1,JSON.stringify(reply));assert.equal(reply.data.offerTier,4);} - assert.deepEqual(player().config.levels,M.offerLevels(cfg.offer,4)); - assert.equal(player().config.levels[1].itemId,'pyramid_t4_l2');assert.equal(player().config.levels[1].itemPrice,1800); - assert.equal(player().selection.reason,'test_tier_override');assert.deepEqual(player().selection.capIds,[]); - assert.equal(table(M.TABLES.players).length,1);assert.equal(table('pyramid_assignments')[0].revision,1); - cfg.testTierOverrides.u=1;table(M.TABLES.periods)[0]=version(cfg); - assert.equal((await api('info')).data.offerTier,4); +test('exact tier thresholds require both caps and recency at 15/30 days', () => { + const profile = features(facts([evidence(3000)])); + // Completion anchor tests exact inclusive E/U without rounding display amounts. + const h = { + id: 'top', epoch: policy.feedback_epoch, tier: 4, paid: true, paymentVerified: true, refunded: false, pending: false, + completed: true, deliveredSlots: 20, completedAt: now - 1, endAt: now - 1, paidAt: now - 1 + }; + assert.equal(M.selectOffer(profile, policy, 7 * DAY, [h], [], now).offerTier, 4); + h.deliveredSlots = 19; assert.equal(M.selectOffer(profile, policy, 7 * DAY, [h], [], now).offerTier, 1); + const soft = M.selectOffer(features(facts([evidence(3000, 15)])), policy, 7 * DAY, [], [], now); assert.deepEqual(soft.g, new Exact('0.75').toJSON()); + assert.equal(select(facts([evidence(3000, 30)])).offerTier, 1); + const p = { + ...copy(policy), budget_share: 0, total_ticket_base: 1, total_ticket_confidence_gain: 0, + peak_ticket_base: 1, peak_ticket_confidence_gain: 0 + }; + for (const [amount, tier] of [[12330, 1], [12331, 2], [20445, 2], [20446, 3], [31275, 3], [31276, 4]]) + assert.equal(M.selectOffer(features(facts([evidence(amount)])), p, 7 * DAY, [], [], now).offerTier, tier); + assert.equal(M.selectOffer(features(facts([evidence(31276)])), { ...p, peak_ticket_base: '0.01' }, 7 * DAY, [], [], now).offerTier, 1); + assert.equal(M.selectOffer(features(facts([evidence(12331)])), { ...p, total_ticket_base: '0.999999' }, 7 * DAY, [], [], now).offerTier, 1); }); -test('nonlisted players and client supplied overrides still use automatic selection',async()=>{ - const cfg={...offerConfig(),testTierOverrides:{another:4}};table(M.TABLES.periods).splice(0,1,version(cfg)); - const reply=await api('info',{testTierOverrides:{u:4},offerTier:4,force_tier:4}); - assert.equal(reply.code,1);assert.equal(reply.data.offerTier,1);assert.equal(player().selection.reason,'never_paid'); +test('failure caps pair once, neutral exposures do not count, paid/pending break, and late payment clears', () => { + const h = (id, endAt, tier = 4) => ({ id, endAt, tier, epoch: policy.feedback_epoch, qualified: true, paymentVerified: true, paid: false, pending: false, refunded: false }); + const a = h('a', now - 4 * DAY), b = h('b', now - 3 * DAY), c = h('c', now - 2 * DAY); + const caps = M.feedbackCaps([a, b, c], policy, now); assert.equal(caps.length, 1); assert.deepEqual(caps[0].source_instance_ids, ['a', 'b']); + assert.equal(caps[0].max_tier, 3); assert.equal(caps[0].expires_at, b.endAt + 30 * DAY); + assert.equal(M.feedbackCaps([a, { ...h('neutral', now - 3.5 * DAY), qualified: false }, b], policy, now).length, 1); + for (const middle of [{ paid: true, qualified: false }, { pending: true }, { refunded: true, paid: true }]) + assert.equal(M.feedbackCaps([a, { ...h('x', now - 3.5 * DAY), ...middle }, b], policy, now).length, 0); + assert.equal(M.feedbackCaps([a, b, { ...c, paid: true, paidAt: now - 1 }], policy, now).length, 0); + assert.equal(M.feedbackCaps([b, c], policy, now, ['a', 'b']).length, 0); + const profile = features(facts([evidence(12800)])); const selected = M.selectOffer(profile, policy, 7 * DAY, [a, b], [], now); + assert.equal(selected.offerTier, 2); assert.equal(selected.capIds.length, 1); // consumed even when base tier was lower }); -test('test tier override requires both test deployment and test campaign',async()=>{ - for(const [environment,deployment] of [['production','production'],['test','production'],['production','test']]){ - reset();process.env.PAYMENT_APP_ENV=deployment; - const row=await S.ensure('u',M.validPeriod({...offerConfig(),environment,testTierOverrides:{u:4}})); - assert.equal(row.config.offerTier,1);assert.equal(row.selection.reason,'never_paid'); - } +test('test tier overrides validate integer tiers 1 to 4 and reject malformed mappings', () => { + for (const testTierOverrides of [{}, { u: 1 }, { u: 2 }, { u: 3 }, { u: 4 }]) + assert.doesNotThrow(() => M.validPeriod({ ...offerConfig(), testTierOverrides })); + for (const testTierOverrides of [null, [], 4, '4', { u: '4' }, { u: 0 }, { u: 5 }, { u: 1.5 }, { u: null }, { ' ': 4 }]) + assert.throws(() => M.validPeriod({ ...offerConfig(), testTierOverrides }), e => e.errorCode === 'CONFIG_INVALID'); }); -test('test tier overrides retain audience, level and time requirements',async()=>{ - const cfg={...offerConfig(),testTierOverrides:{u:4},audience:['another']};table(M.TABLES.periods).splice(0,1,version(cfg)); - assert.equal((await api('info')).errorCode,'AUDIENCE_EXCLUDED'); - cfg.audience=[];table(M.TABLES.periods)[0]=version(cfg);table('users')[0].levelAmount=49; - assert.equal((await api('info')).errorCode,'LEVEL_LOCKED'); - table('users')[0].levelAmount=50;now=cfg.endAt-3600000; - const result=await api('info');assert.equal(result.code,1);assert.equal(result.data.offerTier,4); - assert.equal(table(M.TABLES.players).length,1); +test('configured test account locks tier four with matching prices and rewards without payment history', async () => { + const cfg = { ...offerConfig(), testTierOverrides: { u: 4 } }; + table(M.TABLES.periods).splice(0, 1, version(cfg)); delete table('users')[0].pay_user; + const replies = await Promise.all([api('info'), api('info')]); + for (const reply of replies) { assert.equal(reply.code, 1, JSON.stringify(reply)); assert.equal(reply.data.offerTier, 4); } + assert.deepEqual(player().config.levels, M.offerLevels(cfg.offer, 4)); + assert.equal(player().config.levels[1].itemId, 'pyramid_t4_l2'); assert.equal(player().config.levels[1].itemPrice, 1800); + assert.equal(player().selection.reason, 'test_tier_override'); assert.deepEqual(player().selection.capIds, []); + assert.equal(table(M.TABLES.players).length, 1); assert.equal(table('pyramid_assignments')[0].revision, 1); + cfg.testTierOverrides.u = 1; table(M.TABLES.periods)[0] = version(cfg); + assert.equal((await api('info')).data.offerTier, 4); }); -test('v3 first free draw races safely, locks assignment, and top waits for the free final claim',async()=>{ - const cfg=offerConfig();cfg.audience=['u'];table(M.TABLES.periods).splice(0,1,version(cfg)); - const first=await Promise.all([api('info'),api('info')]);first.forEach(r=>assert.equal(r.code,1,JSON.stringify(r))); - assert.equal(table(M.TABLES.players).length,1);assert.equal(player().config.offerTier,1);assert.equal(first[0].data.canPay,false);assert.equal(first[0].data.canDraw,true); - table('users')[0].vip_level=5; - assert.equal((await api('info')).data.offerTier,1); - const draws=await Promise.all(Array.from({length:20},(_,i)=>api('draw',{level:1,requestId:`free${i}`}))); - assert.equal(draws.filter(r=>r.code===1).length,1);assert.equal(player().rewards.filter(r=>r.gain).length,1); - await deliver(player().grants[0]);const p=player();p.currentLevel=5;p.credits.push({orderId:'top',level:5,status:'available'}); - M.draw(p,body({level:5,requestId:'top'}),now,()=>1); - assert.equal(p.rewards.filter(r=>r.gain).length,20);assert.equal(p.rewards.filter(r=>r.deliveryStatus==='unclaimed').length,18); - assert.equal(p.grants.filter(g=>g.source==='final_claim').length,0);assert.equal(M.snapshot(p,now).canClaimFinal,false); - await deliver(p.grants.find(g=>g.status==='pending')); - const ready=(await api('info')).data;assert.equal(ready.currentLevel,6);assert.equal(ready.completed,false);assert.equal(ready.canClaimFinal,true); - const claimed=await api('claim',{requestId:'final'});assert.equal(claimed.code,1,JSON.stringify(claimed));assert.equal(claimed.data.grant.rewardIds.length,18); - assert.equal((await api('info')).data.completed,false);await deliver(claimed.data.grant);assert.equal(player().completed,true); +test('nonlisted players and client supplied overrides still use automatic selection', async () => { + const cfg = { ...offerConfig(), testTierOverrides: { another: 4 } }; table(M.TABLES.periods).splice(0, 1, version(cfg)); + const reply = await api('info', { testTierOverrides: { u: 4 }, offerTier: 4, force_tier: 4 }); + assert.equal(reply.code, 1); assert.equal(reply.data.offerTier, 1); assert.equal(player().selection.reason, 'never_paid'); }); -test('v3 enforces audience, order-profile availability and server-only tier even in the last two hours',async()=>{ - const cfg=offerConfig();cfg.audience=['another-user'];table(M.TABLES.periods).splice(0,1,version(cfg)); - assert.equal((await api('info')).errorCode,'AUDIENCE_EXCLUDED');cfg.audience=['u'];table(M.TABLES.periods)[0]=version(cfg); - delete table('users')[0].pay_user;assert.equal((await api('info')).errorCode,'PROFILE_UNAVAILABLE'); - table('users')[0].pay_user=true;const noOrders=await api('info');assert.equal(noOrders.code,1,JSON.stringify(noOrders));assert.equal(noOrders.data.offerTier,1); - tables.pyramid_players=[];tables.pyramid_assignments=[]; - table('order').push({openid:'openid',outTradeNo:'real',state:2,paymentAppEnv:'production',goodsPrice:6400,itemCount:2, - itemid:'normal_pack',time:now-DAY}); - const selected=await api('info',{force_tier:4,offerTier:4});assert.equal(selected.data.offerTier,2); - assert.equal(selected.data.levels[1].itemPrice,600); - assert.equal(player().selection.profile.M30_fen,12800);assert.equal(player().selection.profile.profileSource,'confirmed_orders_goods_price'); - tables.pyramid_players=[];tables.pyramid_assignments=[];table('users')[0].pay_user=false;table('order').splice(0);now=cfg.endAt-2*3600000+1; - assert.equal((await api('info')).code,1); +test('test tier override requires both test deployment and test campaign', async () => { + for (const [environment, deployment] of [['production', 'production'], ['test', 'production'], ['production', 'test']]) { + reset(); process.env.PAYMENT_APP_ENV = deployment; + const row = await S.ensure('u', M.validPeriod({ ...offerConfig(), environment, testTierOverrides: { u: 4 } })); + assert.equal(row.config.offerTier, 1); assert.equal(row.selection.reason, 'never_paid'); + } }); -test('runtime offer selection uses goodsPrice times itemCount and weighted payment days',async()=>{ - const cfg=offerConfig();cfg.audience=['u'];table(M.TABLES.periods).splice(0,1,version(cfg));table('users')[0].pay_user=true; - for(let i=0;i<20;i++)table('order').push({openid:'openid',outTradeNo:`legacy-${i}`,state:2,paymentAppEnv:'production', - goodsPrice:1500,itemCount:2,itemid:'unlimited_health_bundle_30',time:now-(i+7)*DAY}); - const result=await api('info');assert.equal(result.code,1,JSON.stringify(result));assert.equal(result.data.offerTier,2); - assert.equal(player().selection.profile.M30_fen,60000);assert.deepEqual(player().selection.q,new Exact(1).toJSON()); - assert.deepEqual(player().selection.Q50,new Exact(3000).toJSON()); +test('test tier overrides retain audience, level and time requirements', async () => { + const cfg = { ...offerConfig(), testTierOverrides: { u: 4 }, audience: ['another'] }; table(M.TABLES.periods).splice(0, 1, version(cfg)); + assert.equal((await api('info')).errorCode, 'AUDIENCE_EXCLUDED'); + cfg.audience = []; table(M.TABLES.periods)[0] = version(cfg); table('users')[0].levelAmount = 49; + assert.equal((await api('info')).errorCode, 'LEVEL_LOCKED'); + table('users')[0].levelAmount = 50; now = cfg.endAt - 3600000; + const result = await api('info'); assert.equal(result.code, 1); assert.equal(result.data.offerTier, 4); + assert.equal(table(M.TABLES.players).length, 1); }); -test('v3 info ignores pending orders whose old player record is missing',async()=>{ - const cfg=offerConfig();cfg.audience=['u'];table(M.TABLES.periods).splice(0,1,version(cfg));table('users')[0].pay_user=false; - const orphan={outTradeNo:'wct_orphan',state:0,paymentAppEnv:'test',pyramid:{uid:'u',periodId:'deleted-test-period',level:2,paymentStatus:'pending'}}; - table('order').push(copy(orphan)); - const response=await api('info',{periodId:undefined}); - assert.equal(response.code,1,JSON.stringify(response));assert.equal(response.data.periodId,cfg.periodId); - assert.equal(response.data.status,'active');assert.equal(response.data.canDraw,true); - assert.deepEqual(table('order')[0],orphan); +test('v3 first free draw races safely, locks assignment, and top waits for the free final claim', async () => { + const cfg = offerConfig(); cfg.audience = ['u']; table(M.TABLES.periods).splice(0, 1, version(cfg)); + const first = await Promise.all([api('info'), api('info')]); first.forEach(r => assert.equal(r.code, 1, JSON.stringify(r))); + assert.equal(table(M.TABLES.players).length, 1); assert.equal(player().config.offerTier, 1); assert.equal(first[0].data.canPay, false); assert.equal(first[0].data.canDraw, true); + table('users')[0].vip_level = 5; + assert.equal((await api('info')).data.offerTier, 1); + const draws = await Promise.all(Array.from({ length: 20 }, (_, i) => api('draw', { level: 1, requestId: `free${i}` }))); + assert.equal(draws.filter(r => r.code === 1).length, 1); assert.equal(player().rewards.filter(r => r.gain).length, 1); + await deliver(player().grants[0]); const p = player(); p.currentLevel = 5; p.credits.push({ orderId: 'top', level: 5, status: 'available' }); + M.draw(p, body({ level: 5, requestId: 'top' }), now, () => 1); + assert.equal(p.rewards.filter(r => r.gain).length, 20); assert.equal(p.rewards.filter(r => r.deliveryStatus === 'unclaimed').length, 18); + assert.equal(p.grants.filter(g => g.source === 'final_claim').length, 0); assert.equal(M.snapshot(p, now).canClaimFinal, false); + await deliver(p.grants.find(g => g.status === 'pending')); + const ready = (await api('info')).data; assert.equal(ready.currentLevel, 6); assert.equal(ready.completed, false); assert.equal(ready.canClaimFinal, true); + const claimed = await api('claim', { requestId: 'final' }); assert.equal(claimed.code, 1, JSON.stringify(claimed)); assert.equal(claimed.data.grant.rewardIds.length, 18); + assert.equal((await api('info')).data.completed, false); await deliver(claimed.data.grant); assert.equal(player().completed, true); }); -test('v3 info starts a new period despite old unpaid orders and late payment stays in the old period',async()=>{ - const order=await buy('old-unpaid'),oldPeriod=config().periodId; - now=END+1000; - const cfg={...offerConfig(),periodId:'pyramid:new-period',startAt:END,endAt:END+7*DAY,audience:['u']}; - table(M.TABLES.periods).splice(0,1,version(cfg));table('users')[0].pay_user=false; - const response=await api('info',{periodId:undefined}); - assert.equal(response.code,1,JSON.stringify(response));assert.equal(response.data.periodId,cfg.periodId); - const current=()=>table(M.TABLES.players).find(p=>p.periodId===cfg.periodId); - const before=copy(current());assert.equal(response.data.canDraw,true); - assert.equal(table('order')[0].pyramid.paymentStatus,'pending'); - await pay(order); - assert.deepEqual(current(),before); - assert.equal(table('order')[0].pyramid.fulfillmentStatus,'expired'); - assert.equal(table(M.TABLES.players).find(p=>p.periodId===oldPeriod).credits[0].status,'expired'); +test('v3 enforces audience, order-profile availability and server-only tier even in the last two hours', async () => { + const cfg = offerConfig(); cfg.audience = ['another-user']; table(M.TABLES.periods).splice(0, 1, version(cfg)); + assert.equal((await api('info')).errorCode, 'AUDIENCE_EXCLUDED'); cfg.audience = ['u']; table(M.TABLES.periods)[0] = version(cfg); + delete table('users')[0].pay_user; assert.equal((await api('info')).errorCode, 'PROFILE_UNAVAILABLE'); + table('users')[0].pay_user = true; const noOrders = await api('info'); assert.equal(noOrders.code, 1, JSON.stringify(noOrders)); assert.equal(noOrders.data.offerTier, 1); + tables.pyramid_players = []; tables.pyramid_assignments = []; + table('order').push({ + openid: 'openid', outTradeNo: 'real', state: 2, paymentAppEnv: 'production', goodsPrice: 6400, itemCount: 2, + itemid: 'normal_pack', time: now - DAY + }); + const selected = await api('info', { force_tier: 4, offerTier: 4 }); assert.equal(selected.data.offerTier, 2); + assert.equal(selected.data.levels[1].itemPrice, 600); + assert.equal(player().selection.profile.M30_fen, 12800); assert.equal(player().selection.profile.profileSource, 'confirmed_orders_goods_price'); + tables.pyramid_players = []; tables.pyramid_assignments = []; table('users')[0].pay_user = false; table('order').splice(0); now = cfg.endAt - 2 * 3600000 + 1; + assert.equal((await api('info')).code, 1); }); -test('player and reservation keep selected levels but omit catalog and unused offer metadata',async()=>{ - const cfg={...offerConfig(),testTierOverrides:{u:4}};table(M.TABLES.periods).splice(0,1,version(cfg)); - assert.equal((await api('info')).data.offerTier,4); - for(const row of [player(),table('pyramid_assignments')[0].reservation]){ - assert.deepEqual(row.config.levels,M.offerLevels(cfg.offer,4)); - assert.deepEqual(row.config.offer,{manifestVersion:cfg.offer.manifestVersion,policyJson:cfg.offer.policyJson}); - assert.equal(row.config.testTierOverrides,undefined);assert.equal(row.config.offerTier,4); - } - assert.deepEqual(table(M.TABLES.periods)[0].config.offer,cfg.offer); - assert.deepEqual(table(M.TABLES.periods)[0].config.testTierOverrides,{u:4}); - const revision=player().revision; - await api('info');assert.equal(player().revision,revision,'already compact snapshots do not cause extra writes'); +test('runtime offer selection uses goodsPrice times itemCount and weighted payment days', async () => { + const cfg = offerConfig(); cfg.audience = ['u']; table(M.TABLES.periods).splice(0, 1, version(cfg)); table('users')[0].pay_user = true; + for (let i = 0; i < 20; i++)table('order').push({ + openid: 'openid', outTradeNo: `legacy-${i}`, state: 2, paymentAppEnv: 'production', + goodsPrice: 1500, itemCount: 2, itemid: 'unlimited_health_bundle_30', time: now - (i + 7) * DAY + }); + const result = await api('info'); assert.equal(result.code, 1, JSON.stringify(result)); assert.equal(result.data.offerTier, 2); + assert.equal(player().selection.profile.M30_fen, 60000); assert.deepEqual(player().selection.q, new Exact(1).toJSON()); + assert.deepEqual(player().selection.Q50, new Exact(3000).toJSON()); }); -test('legacy player snapshots compact on use without changing rewards or delivery eligibility',async()=>{ - const cfg=offerConfig();table(M.TABLES.periods).splice(0,1,version(cfg));await api('info'); - const p=player();p.config.offer=copy(cfg.offer);p.config.testTierOverrides={u:4}; - const before=copy(p),snapshot=M.snapshot(before,now); - const response=await api('info');assert.equal(response.code,1); - assert.deepEqual({...response.data,revision:before.revision},snapshot); - assert.deepEqual(player().config.levels,before.config.levels); - assert.deepEqual(player().rewards,before.rewards);assert.deepEqual(player().grants,before.grants); - assert.equal(player().config.offer.catalogJson,undefined);assert.equal(player().revision,before.revision+1); +test('v3 info ignores pending orders whose old player record is missing', async () => { + const cfg = offerConfig(); cfg.audience = ['u']; table(M.TABLES.periods).splice(0, 1, version(cfg)); table('users')[0].pay_user = false; + const orphan = { outTradeNo: 'wct_orphan', state: 0, paymentAppEnv: 'test', pyramid: { uid: 'u', periodId: 'deleted-test-period', level: 2, paymentStatus: 'pending' } }; + table('order').push(copy(orphan)); + const response = await api('info', { periodId: undefined }); + assert.equal(response.code, 1, JSON.stringify(response)); assert.equal(response.data.periodId, cfg.periodId); + assert.equal(response.data.status, 'active'); assert.equal(response.data.canDraw, true); + assert.deepEqual(table('order')[0], orphan); }); -test('legacy reservation recovery strips metadata without reselecting or consuming caps again',async()=>{ - const cfg=offerConfig();table(M.TABLES.periods).splice(0,1,version(cfg)); - let once=true;beforeWrite=name=>{if(name===M.TABLES.players&&once){once=false;throw Error('interrupted projection');}}; - assert.equal((await api('info')).code,0);beforeWrite=null; - const assignment=table('pyramid_assignments')[0];assignment.reservation.config.offer=copy(cfg.offer); - assignment.reservation.config.testTierOverrides={u:4}; - const reservation=copy(assignment.reservation),caps=copy(assignment.consumedCaps); - const responses=await Promise.all([api('info'),api('info')]);responses.forEach(r=>assert.equal(r.data.offerTier,1)); - assert.equal(table(M.TABLES.players).length,1); - assert.deepEqual(player().config.levels,reservation.config.levels); - assert.equal(player().config.offer.catalogJson,undefined); - assert.equal(assignment.reservation.config.offer.catalogJson,undefined);assert.deepEqual(assignment.consumedCaps,caps); +test('v3 info starts a new period despite old unpaid orders and late payment stays in the old period', async () => { + const order = await buy('old-unpaid'), oldPeriod = config().periodId; + now = END + 1000; + const cfg = { ...offerConfig(), periodId: 'pyramid:new-period', startAt: END, endAt: END + 7 * DAY, audience: ['u'] }; + table(M.TABLES.periods).splice(0, 1, version(cfg)); table('users')[0].pay_user = false; + const response = await api('info', { periodId: undefined }); + assert.equal(response.code, 1, JSON.stringify(response)); assert.equal(response.data.periodId, cfg.periodId); + const current = () => table(M.TABLES.players).find(p => p.periodId === cfg.periodId); + const before = copy(current()); assert.equal(response.data.canDraw, true); + assert.equal(table('order')[0].pyramid.paymentStatus, 'pending'); + await pay(order); + assert.deepEqual(current(), before); + assert.equal(table('order')[0].pyramid.fulfillmentStatus, 'expired'); + assert.equal(table(M.TABLES.players).find(p => p.periodId === oldPeriod).credits[0].status, 'expired'); }); -test('v3 assignment recovers materialization failure without repricing or consuming twice',async()=>{ - const cfg=offerConfig();cfg.audience=['u'];table(M.TABLES.periods).splice(0,1,version(cfg)); - let once=true;beforeWrite=(name)=>{if(name===M.TABLES.players&&once){once=false;throw Error('interrupted projection');}}; - assert.equal((await api('info')).code,0);assert.equal(table('pyramid_assignments')[0].reservation.config.offerTier,1); - table('users')[0].vip_level=5;beforeWrite=null;const retried=await api('info');assert.equal(retried.data.offerTier,1);assert.equal(table('pyramid_assignments')[0].revision,1); +test('player and reservation keep selected levels but omit catalog and unused offer metadata', async () => { + const cfg = { ...offerConfig(), testTierOverrides: { u: 4 } }; table(M.TABLES.periods).splice(0, 1, version(cfg)); + assert.equal((await api('info')).data.offerTier, 4); + for (const row of [player(), table('pyramid_assignments')[0].reservation]) { + assert.deepEqual(row.config.levels, M.offerLevels(cfg.offer, 4)); + assert.deepEqual(row.config.offer, { manifestVersion: cfg.offer.manifestVersion, policyJson: cfg.offer.policyJson }); + assert.equal(row.config.testTierOverrides, undefined); assert.equal(row.config.offerTier, 4); + } + assert.deepEqual(table(M.TABLES.periods)[0].config.offer, cfg.offer); + assert.deepEqual(table(M.TABLES.periods)[0].config.testTierOverrides, { u: 4 }); + const revision = player().revision; + await api('info'); assert.equal(player().revision, revision, 'already compact snapshots do not cause extra writes'); }); -test('price exposure uses server time and requires two spaced followups',async()=>{ - const cfg=offerConfig();cfg.audience=['u'];table(M.TABLES.periods).splice(0,1,version(cfg));table('users')[0].pay_user=false;await api('info'); - const view=()=>api('price_detail_view',{manifestVersion:cfg.offer.manifestVersion,viewAt:0,followupSessions:[0,1,2]}); - await view();await view();assert.equal(player().priceDetailViewAt,now);assert.equal(player().followupSessions.length,0); - now+=30*60000;await view();await view();now+=30*60000;await view();assert.equal(player().followupSessions.length,2); +test('legacy player snapshots compact on use without changing rewards or delivery eligibility', async () => { + const cfg = offerConfig(); table(M.TABLES.periods).splice(0, 1, version(cfg)); await api('info'); + const p = player(); p.config.offer = copy(cfg.offer); p.config.testTierOverrides = { u: 4 }; + const before = copy(p), snapshot = M.snapshot(before, now); + const response = await api('info'); assert.equal(response.code, 1); + assert.deepEqual({ ...response.data, revision: before.revision }, snapshot); + assert.deepEqual(player().config.levels, before.config.levels); + assert.deepEqual(player().rewards, before.rewards); assert.deepEqual(player().grants, before.grants); + assert.equal(player().config.offer.catalogJson, undefined); assert.equal(player().revision, before.revision + 1); }); -test('a failure pair cannot shift to its second member when the first leaves the lookback',()=>{ - const h=(id,days)=>({id,endAt:now-days*DAY,tier:4,epoch:policy.feedback_epoch,qualified:true,paymentVerified:true,paid:false}); - const caps=M.feedbackCaps([h('a',31),h('b',29),h('c',1)],policy,now); - assert.equal(caps.length,1);assert.deepEqual(caps[0].source_instance_ids,['a','b']); +test('legacy reservation recovery strips metadata without reselecting or consuming caps again', async () => { + const cfg = offerConfig(); table(M.TABLES.periods).splice(0, 1, version(cfg)); + let once = true; beforeWrite = name => { if (name === M.TABLES.players && once) { once = false; throw Error('interrupted projection'); } }; + assert.equal((await api('info')).code, 0); beforeWrite = null; + const assignment = table('pyramid_assignments')[0]; assignment.reservation.config.offer = copy(cfg.offer); + assignment.reservation.config.testTierOverrides = { u: 4 }; + const reservation = copy(assignment.reservation), caps = copy(assignment.consumedCaps); + const responses = await Promise.all([api('info'), api('info')]); responses.forEach(r => assert.equal(r.data.offerTier, 1)); + assert.equal(table(M.TABLES.players).length, 1); + assert.deepEqual(player().config.levels, reservation.config.levels); + assert.equal(player().config.offer.catalogJson, undefined); + assert.equal(assignment.reservation.config.offer.catalogJson, undefined); assert.deepEqual(assignment.consumedCaps, caps); }); -test('new v3 instance atomically consumes failure cap even if its normal tier is lower',async()=>{ - const cfg=offerConfig();cfg.audience=['u'];table(M.TABLES.periods).splice(0,1,version(cfg)); - for(let i=1;i<=2;i++){ - const oldCfg={...copy(cfg),periodId:`old${i}`,environment:'production',offerTier:4,levels:M.offerLevels(cfg.offer,4),startAt:now-(i+3)*DAY,endAt:now-i*DAY}; - const old={...M.initial('u',oldCfg),selection:{feedbackEpoch:policy.feedback_epoch},priceDetailViewAt:oldCfg.startAt, - followupSessions:[oldCfg.startAt+3600000,oldCfg.startAt+7200000]};table(M.TABLES.players).push(old); - } - await Promise.all([api('info'),api('info')]);const state=table('pyramid_assignments')[0]; - assert.equal(state.consumedCaps.length,1);assert.equal(state.consumedFailures.length,2);assert.equal(state.reservation.config.offerTier,1); - await api('info');assert.equal(state.revision,1); +test('v3 assignment recovers materialization failure without repricing or consuming twice', async () => { + const cfg = offerConfig(); cfg.audience = ['u']; table(M.TABLES.periods).splice(0, 1, version(cfg)); + let once = true; beforeWrite = (name) => { if (name === M.TABLES.players && once) { once = false; throw Error('interrupted projection'); } }; + assert.equal((await api('info')).code, 0); assert.equal(table('pyramid_assignments')[0].reservation.config.offerTier, 1); + table('users')[0].vip_level = 5; beforeWrite = null; const retried = await api('info'); assert.equal(retried.data.offerTier, 1); assert.equal(table('pyramid_assignments')[0].revision, 1); }); -test('v3 participation stays open until the actual end with legacy cutoff configuration',async()=>{ - for(const remaining of [2*3600000,2*3600000-1,3600000,1]){ - reset();const cfg=offerConfig();table(M.TABLES.periods).splice(0,1,version(cfg));now=cfg.endAt-remaining; - const results=await Promise.all([api('info'),api('info')]); - results.forEach(r=>{assert.equal(r.code,1,JSON.stringify(r));assert.equal(r.data.canDraw,true);}); - assert.equal(table(M.TABLES.players).length,1);assert.equal(table('pyramid_assignments')[0].revision,1); - const drawn=await api('draw',{level:1,requestId:'last-moment'});assert.equal(drawn.code,1); - now=cfg.endAt;assert.equal((await api('draw',{level:2,requestId:'after-end'})).errorCode,'ACTIVITY_ENDED'); - } - reset();const cfg=offerConfig();now=cfg.endAt; - await assert.rejects(()=>S.ensure('u',M.validPeriod(cfg)),e=>e.errorCode==='ACTIVITY_ENDED'); - assert.equal(table(M.TABLES.players).length,0); +test('price exposure uses server time and requires two spaced followups', async () => { + const cfg = offerConfig(); cfg.audience = ['u']; table(M.TABLES.periods).splice(0, 1, version(cfg)); table('users')[0].pay_user = false; await api('info'); + const view = () => api('price_detail_view', { manifestVersion: cfg.offer.manifestVersion, viewAt: 0, followupSessions: [0, 1, 2] }); + await view(); await view(); assert.equal(player().priceDetailViewAt, now); assert.equal(player().followupSessions.length, 0); + now += 30 * 60000; await view(); await view(); now += 30 * 60000; await view(); assert.equal(player().followupSessions.length, 2); }); -test('deprecated minimum remaining hours is optional and does not affect offer validation',()=>{ - for(const value of [undefined,0,2]){ - const offer=copy(draft.offer),policy=JSON.parse(offer.policyJson); - if(value===undefined)delete policy.min_remaining_hours_for_new_instance; - else policy.min_remaining_hours_for_new_instance=value; - offer.policyJson=JSON.stringify(policy);offer.policyHash=createHash('sha256').update(offer.policyJson).digest('hex'); - assert.doesNotThrow(()=>M.validateOffer(offer)); - } +test('a failure pair cannot shift to its second member when the first leaves the lookback', () => { + const h = (id, days) => ({ id, endAt: now - days * DAY, tier: 4, epoch: policy.feedback_epoch, qualified: true, paymentVerified: true, paid: false }); + const caps = M.feedbackCaps([h('a', 31), h('b', 29), h('c', 1)], policy, now); + assert.equal(caps.length, 1); assert.deepEqual(caps[0].source_instance_ids, ['a', 'b']); }); -test('v3 actual activity end is enforced at the assignment CAS',async()=>{ - const cfg=offerConfig();cfg.audience=['u'];table(M.TABLES.periods).splice(0,1,version(cfg));table('users')[0].pay_user=false; - beforeWrite=(name,q)=>{if(name==='pyramid_assignments'&&q.$expr)now=cfg.endAt;}; - const result=await api('info');assert.equal(result.errorCode,'ACTIVITY_ENDED'); - assert.equal(table(M.TABLES.players).length,0);assert.equal(table('pyramid_assignments')[0].revision,0); +test('new v3 instance atomically consumes failure cap even if its normal tier is lower', async () => { + const cfg = offerConfig(); cfg.audience = ['u']; table(M.TABLES.periods).splice(0, 1, version(cfg)); + for (let i = 1; i <= 2; i++) { + const oldCfg = { ...copy(cfg), periodId: `old${i}`, environment: 'production', offerTier: 4, levels: M.offerLevels(cfg.offer, 4), startAt: now - (i + 3) * DAY, endAt: now - i * DAY }; + const old = { + ...M.initial('u', oldCfg), selection: { feedbackEpoch: policy.feedback_epoch }, priceDetailViewAt: oldCfg.startAt, + followupSessions: [oldCfg.startAt + 3600000, oldCfg.startAt + 7200000] + }; table(M.TABLES.players).push(old); + } + await Promise.all([api('info'), api('info')]); const state = table('pyramid_assignments')[0]; + assert.equal(state.consumedCaps.length, 1); assert.equal(state.consumedFailures.length, 2); assert.equal(state.reservation.config.offerTier, 1); + await api('info'); assert.equal(state.revision, 1); }); -const admin=ctx=>M.default({body:{...ctx.body,operation:ctx.body.action,action:'admin'}}); -const config=()=>({periodId:'pyramid:2026-09-28',configVersion:'test-v1',environment:'test',startAt:START,endAt:END,published:true, - levels:Array.from({length:6},(_,i)=>({level:i+1,itemId:i<5?'pay_test':null,itemPrice:i<5?100:null, - rewards:i<5?[{rewardId:`l${i+1}a`,weight:5,up:false,goods:[{id:1001,count:20}]},{rewardId:`l${i+1}b`,weight:3,up:true,goods:[{id:2002,count:2}]},{rewardId:`l${i+1}c`,weight:2,up:true,goods:[{id:2001,count:3}]}] - :[{rewardId:'final',up:false,goods:[{id:1001,count:200},{id:2005,count:900}]}]}))}); -function version(p=config(), extra={}) {const {published,...parameters}=p;return {_id:M.key('pyramid',p.configVersion),activityId:'pyramid',recordType:'version', - configVersion:p.configVersion,status:'published',enabled:true,effectiveFrom:p.startAt,publishedAt:START-1,config:parameters,...extra};} -function reset(){now=START+1000;tables={};beforeWrite=null;const p=config();table(M.TABLES.periods).push(version(p)); - table('users').push({_id:'u',onlyId:123,levelAmount:50,token:'t',openid:'openid',session_key:'session',pay_user:false,vip_level:0,register_time:START-90*DAY, - coinAmount:100,hammerAmount:0,freezeAmount:0,magicAmount:0,userPowerTime:0}); - process.env.PAYMENT_APP_ENV='test';process.env.WX_MIDAS_PAY_SIGN_KEY='synthetic-test-secret'; - process.env.WX_MCH_ID='merchant';process.env.WX_MINIGAME_APP_ID='app';return p;} + +test('v3 participation stays open until the actual end with legacy cutoff configuration', async () => { + for (const remaining of [2 * 3600000, 2 * 3600000 - 1, 3600000, 1]) { + reset(); const cfg = offerConfig(); table(M.TABLES.periods).splice(0, 1, version(cfg)); now = cfg.endAt - remaining; + const results = await Promise.all([api('info'), api('info')]); + results.forEach(r => { assert.equal(r.code, 1, JSON.stringify(r)); assert.equal(r.data.canDraw, true); }); + assert.equal(table(M.TABLES.players).length, 1); assert.equal(table('pyramid_assignments')[0].revision, 1); + const drawn = await api('draw', { level: 1, requestId: 'last-moment' }); assert.equal(drawn.code, 1); + now = cfg.endAt; assert.equal((await api('draw', { level: 2, requestId: 'after-end' })).errorCode, 'ACTIVITY_ENDED'); + } + reset(); const cfg = offerConfig(); now = cfg.endAt; + await assert.rejects(() => S.ensure('u', M.validPeriod(cfg)), e => e.errorCode === 'ACTIVITY_ENDED'); + assert.equal(table(M.TABLES.players).length, 0); +}); + +test('deprecated minimum remaining hours is optional and does not affect offer validation', () => { + for (const value of [undefined, 0, 2]) { + const offer = copy(draft.offer), policy = JSON.parse(offer.policyJson); + if (value === undefined) delete policy.min_remaining_hours_for_new_instance; + else policy.min_remaining_hours_for_new_instance = value; + offer.policyJson = JSON.stringify(policy); offer.policyHash = createHash('sha256').update(offer.policyJson).digest('hex'); + assert.doesNotThrow(() => M.validateOffer(offer)); + } +}); + +test('v3 actual activity end is enforced at the assignment CAS', async () => { + const cfg = offerConfig(); cfg.audience = ['u']; table(M.TABLES.periods).splice(0, 1, version(cfg)); table('users')[0].pay_user = false; + beforeWrite = (name, q) => { if (name === 'pyramid_assignments' && q.$expr) now = cfg.endAt; }; + const result = await api('info'); assert.equal(result.errorCode, 'ACTIVITY_ENDED'); + assert.equal(table(M.TABLES.players).length, 0); assert.equal(table('pyramid_assignments')[0].revision, 0); +}); +const admin = ctx => M.default({ body: { ...ctx.body, operation: ctx.body.action, action: 'admin' } }); +const config = () => ({ + periodId: 'pyramid:2026-09-28', configVersion: 'test-v1', environment: 'test', startAt: START, endAt: END, published: true, + levels: Array.from({ length: 6 }, (_, i) => ({ + level: i + 1, itemId: i < 5 ? 'pay_test' : null, itemPrice: i < 5 ? 100 : null, + rewards: i < 5 ? [{ rewardId: `l${i + 1}a`, weight: 5, up: false, goods: [{ id: 1001, count: 20 }] }, { rewardId: `l${i + 1}b`, weight: 3, up: true, goods: [{ id: 2002, count: 2 }] }, { rewardId: `l${i + 1}c`, weight: 2, up: true, goods: [{ id: 2001, count: 3 }] }] + : [{ rewardId: 'final', up: false, goods: [{ id: 1001, count: 200 }, { id: 2005, count: 900 }] }] + })) +}); +function version(p = config(), extra = {}) { + const { published, ...parameters } = p; return { + _id: M.key('pyramid', p.configVersion), activityId: 'pyramid', recordType: 'version', + configVersion: p.configVersion, status: 'published', enabled: true, effectiveFrom: p.startAt, publishedAt: START - 1, config: parameters, ...extra + }; +} +function reset() { + now = START + 1000; tables = {}; beforeWrite = null; paymentEvents = []; analyticsFailure = false; analyticsCallbackFailure = false; const p = config(); table(M.TABLES.periods).push(version(p)); + table('users').push({ + _id: 'u', onlyId: 123, levelAmount: 50, token: 't', openid: 'openid', session_key: 'session', pay_user: false, vip_level: 0, register_time: START - 90 * DAY, + coinAmount: 100, hammerAmount: 0, freezeAmount: 0, magicAmount: 0, userPowerTime: 0 + }); + process.env.PAYMENT_APP_ENV = 'test'; process.env.WX_MIDAS_PAY_SIGN_KEY = 'synthetic-test-secret'; + process.env.WX_MCH_ID = 'merchant'; process.env.WX_MINIGAME_APP_ID = 'app'; return p; +} test.beforeEach(reset); -const claimNotice=(p=player(),extra={})=>api('settlement_claim',{periodId:p.periodId,noticeId:p.settlementNotice.noticeId,...extra}); -test('expiry job and repeated reads do not grant; explicit claim deposits all eligible rewards once',async()=>{ - await S.ensure('u',config());const p=player();p.currentLevel=6;p.config.offerTier=2; - p.rewards.forEach(r=>{r.gain=true;r.deliveryStatus='unclaimed';});p.credits.push({status:'available'});now=END; - const job=await S.runJobs();assert.equal(job.settled,1);assert.equal(job.errors,0); - assert.equal(player().credits[0].status,'expired');assert.equal(player().completed,false); - assert.deepEqual(player().settlementNotice.goods,[{id:1001,count:300},{id:2001,count:15},{id:2002,count:10},{id:2005,count:900}]); - for(let i=0;i<2;i++){ - const read=await api('settlement_read');assert.equal(read.data.notices.length,1);assert.equal(read.data.inventory,undefined); - assert.equal(read.data.notices[0].readAt,null);assert.equal(read.data.notices[0].depositedAt,null); - assert.equal(table('users')[0].coinAmount,100);assert.equal(table('users')[0].userPowerTime,0); - } - now=END+3600000;const first=await claimNotice();assert.equal(first.code,1); - assert.equal(first.data.notice.readAt,now);assert.equal(first.data.notice.depositedAt,now); - assert.equal(first.data.inventory.coinAmount,400);assert.equal(player().completed,true); - assert.equal(table('users')[0].userPowerTime,now/1000+900); - assert.equal((await api('settlement_read')).data.notices.length,0); - now+=60000;const retry=await claimNotice();assert.equal(retry.code,1);assert.equal(retry.data.inventory.coinAmount,400); - assert.equal(retry.data.inventory.userPowerTime,first.data.inventory.userPowerTime);assert.deepEqual(retry.data.notice,first.data.notice); -}); -test('expiry job does not pay unearned slots or issue empty notices',async()=>{ - await S.ensure('u',config());now=END-1;assert.equal((await S.runJobs()).settled,0); - now=END;await S.runJobs();assert.equal(table('users')[0].coinAmount,100);assert.equal(player().settlementNotice,null); - assert.equal((await api('settlement_read')).data.notices.length,0); -}); -test('claim resumes after deposit but before confirmation without double inventory',async()=>{ - await S.ensure('u',config());M.grant(player(),['l1a'],'draw',now);now=END;await S.runJobs(); - beforeWrite=(name,q,u)=>{if(name===M.TABLES.players&&u.$set?.grants?.some(g=>g.status==='delivered'))throw Error('lost confirmation');}; - assert.equal((await claimNotice()).code,0);assert.equal(table('users')[0].coinAmount,120); - assert.equal(player().settlementNotice.readAt,null);assert.equal(player().settlementNotice.depositedAt,null); - beforeWrite=null;assert.equal((await api('settlement_read')).data.notices.length,1); - const response=await claimNotice();assert.equal(response.code,1);assert.equal(table('users')[0].coinAmount,120); - assert.deepEqual(response.data.notice.goods,[{id:1001,count:20}]); -}); -test('client-saved rewards are confirmed but do not appear as a second settlement award',async()=>{ - await S.ensure('u',config());const g=M.grant(player(),['l1a'],'draw',now); - const r=await S.saveDelivery('u',{grantId:g.grantId,phase:'prepare'}); - await S.saveDelivery('u',{grantId:g.grantId,phase:'save',base:r.inventory,inventory:{...r.inventory,coinAmount:120},calculatedAt:now}); - now=END;await S.runJobs();assert.equal(player().grants[0].status,'delivered');assert.equal(table('users')[0].coinAmount,120); - assert.equal(player().settlementNotice,null); -}); -test('claim only deposits the selected period and another failed claim remains unread',async()=>{ - await S.ensure('u',config());M.grant(player(),['l1a'],'draw',now); - await S.ensure('u',{...config(),periodId:'second-period'});const second=table(M.TABLES.players).find(p=>p.periodId==='second-period'); - M.grant(second,['l1a'],'draw',now);now=END;await S.runJobs(); - assert.equal((await claimNotice()).code,1);assert.equal(table('users')[0].coinAmount,120); - beforeWrite=(name,q,u)=>{if(name==='users'&&u.$set?.coinAmount===140)throw Error('second deposit unavailable');}; - assert.equal((await claimNotice(second)).code,0); - assert.deepEqual((await api('settlement_read')).data.notices.map(n=>n.periodId),['second-period']); - beforeWrite=null;const result=await claimNotice(second);assert.equal(result.code,1);assert.equal(result.data.inventory.coinAmount,140); - assert.equal((await api('settlement_read')).data.notices.length,0); -}); -test('concurrent reads are read-only and concurrent claims deposit once',async()=>{ - await S.ensure('u',config());M.grant(player(),['l1a'],'draw',now);now=END; - await Promise.all([S.runJobs(),S.runJobs()]); - const reads=await Promise.all([api('settlement_read'),api('settlement_read')]); - assert.ok(reads.every(r=>r.data.notices.length===1));assert.equal(table('users')[0].coinAmount,100); - const claims=await Promise.all([claimNotice(),claimNotice()]);assert.ok(claims.every(r=>r.code===1)); - assert.equal(table('users')[0].coinAmount,120);assert.equal((await api('settlement_read')).data.notices.length,0); -}); -test('settlement reads and claims require authentication and match the owner, period and notice',async()=>{ - await S.ensure('u',config());M.grant(player(),['l1a'],'draw',now);now=END;await S.runJobs(); - assert.equal((await api('settlement_read',{token:'bad'})).errorCode,'AUTH_INVALID'); - assert.equal((await claimNotice(player(),{token:'bad'})).errorCode,'AUTH_INVALID'); - table('users').push({_id:'other',token:'other-token',levelAmount:50}); - assert.equal((await api('settlement_read',{uid:'other',token:'other-token'})).data.notices.length,0); - for(const extra of [{uid:'other',token:'other-token'},{periodId:'wrong'},{noticeId:'wrong'}])assert.equal((await claimNotice(player(),extra)).errorCode,'INVALID_INPUT'); - assert.equal(player().settlementNotice.readAt,null);assert.equal(table('users')[0].coinAmount,100); -}); -test('a previously deposited unread notice can be claimed without paying it again',async()=>{ - await S.ensure('u',config());M.grant(player(),['l1a'],'draw',now);now=END;await S.runJobs(); - await S.settleExpiredPeriod('u',config().periodId);assert.equal(table('users')[0].coinAmount,120); - assert.equal((await claimNotice()).code,1);assert.equal(table('users')[0].coinAmount,120); -}); -test('job prepares a period already settled by info, and only explicit claim deposits it',async()=>{ - await S.ensure('u',config());const p=player();p.rewards[0].gain=true;p.rewards[0].deliveryStatus='unclaimed';now=END; - await api('info');assert.equal(player().settledAt,END);assert.equal(player().settlementCompletedAt,null); - assert.equal((await S.runJobs()).settled,1);await api('settlement_read');assert.equal(table('users')[0].coinAmount,100); - assert.equal((await claimNotice()).code,1);assert.equal(table('users')[0].coinAmount,120); -}); -const body=extra=>({uid:'u',token:'t',periodId:config().periodId,...extra}); -const orderBody=extra=>body({outTradeNo:(process.env.PAYMENT_APP_ENV==='test'?'wct_':'wcx_')+extra.requestId,...extra}); -const pyramidEntry=result=>result.data.activities.find(a=>a.activityId==='pyramid'); -const visibleActivities=result=>result.data.activities.filter(a=>a.startsAt<=now&&nowM.default({body:body({...b,action})}); -const player=()=>table(M.TABLES.players)[0]; -async function buy(id='buy',extra={}){const r=await P.createOrder(orderBody({activityType:'pyramid',itemid:'pay_test',itemCount:1,itemPrice:1,level:1,requestId:id,...extra}),'and');assert.equal(r.code,1,JSON.stringify(r));return table('order').find(o=>o.outTradeNo===r.data.outTradeNo);} -async function pay(order){const payload={OutTradeNo:order.outTradeNo,OpenId:order.openid,Env:0,GoodsInfo:{ProductId:order.itemid,Quantity:1,OrigPrice:order.goodsPrice}}; - const event={Event:'minigame_game_pay_goods_deliver_notify',MiniGame:{Payload:JSON.stringify(payload),IsMock:false}}; - event.MiniGame.PayEventSig=createHmac('sha256',process.env.WX_MIDAS_PAY_SIGN_KEY).update(event.Event+'&'+event.MiniGame.Payload).digest('hex');return P.notify(event,payload,order);} -async function deliver(g){let r=await S.saveDelivery('u',{grantId:g.grantId,phase:'prepare'});const next={...r.inventory}; - for(const item of g.goods){const f=M.FIELDS[item.id];next[f]=f==='userPowerTime'?Math.max(next[f],Math.floor(now/1000))+item.count:next[f]+item.count;} - await S.saveDelivery('u',{grantId:g.grantId,phase:'save',base:r.inventory,inventory:next,calculatedAt:now});return S.confirm('u',{grantId:g.grantId});} -test('config validates targets, weights, prices and test-only pay_test',()=>{assert.equal(M.validPeriod(config()).levels.length,6);for(const change of [p=>p.levels[0].rewards.forEach(r=>r.up=false),p=>p.levels[0].rewards[0].weight=0,p=>p.environment='production']){const p=config();change(p);assert.throws(()=>M.validPeriod(p));}}); - -test('info exposes immutable player weights for local probability calculation',async()=>{ - const first=await api('info');assert.equal(first.code,1); - assert.deepEqual(first.data.levels[0].rewards.map(r=>r.weight),[5,3,2]); - assert.equal(first.data.levels[5].rewards[0].weight,null); - table(M.TABLES.periods)[0].config.levels[0].rewards[0].weight=999; - const next=await api('info');assert.equal(next.data.levels[0].rewards[0].weight,5); -}); - -test('test-v2 configured weight intervals select the advertised slot',async()=>{ - const {readFileSync}=await import('node:fs');const cfg=JSON.parse(readFileSync(new URL('../config/2026-09-28-test.json',import.meta.url))); - M.validPeriod(cfg); - const expected=[[49,5,4946,4946,5,49],[49,4949,4,4949,49],[97,9797,9,97],[1,1,1],[1,1]]; - cfg.levels.slice(0,5).forEach((l,index)=>{ - assert.deepEqual(l.rewards.map(r=>r.weight),expected[index]);let start=0; - for(const reward of l.rewards){for(const ticket of [start,start+reward.weight-1]){ - const p=M.initial('u',cfg);p.currentLevel=l.level;p.credits.push({orderId:'weight-test',level:l.level,status:'available'}); - const result=M.draw(p,{periodId:cfg.periodId,level:l.level,requestId:'weight-test'},now,()=>ticket); - assert.equal(result.rewardId,reward.rewardId); - }start+=reward.weight;} - }); -}); -test('weighted intervals renormalize after removal and either target advances',()=>{for(const ticket of [5,8]){const p=M.initial('u',config());p.credits.push({orderId:'x',level:1,status:'available'});const r=M.draw(p,body({level:1,requestId:'d'}),now,()=>ticket);assert.equal(r.up,true);assert.equal(p.currentLevel,2);} - const p=M.initial('u',config());p.credits.push({orderId:'x',level:1,status:'available'});M.draw(p,body({level:1,requestId:'a'}),now,()=>0);p.grants[0].status='delivered';p.credits.push({orderId:'y',level:1,status:'available'}); - M.draw(p,body({level:1,requestId:'b'}),now,total=>{assert.equal(total,5);return 0;});assert.equal(p.currentLevel,2);}); -test('distinct purchase clicks create distinct orders at server price without a lock',async()=>{const [a,b]=await Promise.all([buy('a'),buy('b')]);assert.notEqual(a.outTradeNo,b.outTradeNo);assert.equal(table('order').length,2);assert.equal(a.goodsPrice,100);assert.equal(player().pendingOrder,null);assert.equal((await api('info')).data.canPay,true);}); -test('retrying the same request remains idempotent',async()=>{const [a,b]=await Promise.all([buy('a'),buy('a')]);assert.equal(a.outTradeNo,b.outTradeNo);assert.equal(table('order').length,1);}); -test('signing preserves the client order number and server price',async()=>{ - const input=orderBody({itemid:'pay_test',itemCount:1,itemPrice:1,level:1,requestId:'client',outTradeNo:'wct_client_selected_123'}); - const r=await P.createOrder(input,'and');assert.equal(r.code,1);assert.equal(r.data.outTradeNo,input.outTradeNo); - const signed=JSON.parse(r.data.signData);assert.equal(signed.outTradeNo,input.outTradeNo);assert.equal(signed.goodsPrice,100); - assert.equal(table('order')[0].outTradeNo,input.outTradeNo); -}); -test('missing malformed and wrong-environment client order numbers are rejected',async()=>{ - for(const outTradeNo of [undefined,'','wct_bad.space','wct_'+ 'x'.repeat(29)]){ - const r=await P.createOrder(orderBody({itemid:'pay_test',itemCount:1,level:1,requestId:'invalid',outTradeNo}),'and'); - assert.equal(r.errorCode,'INVALID_INPUT'); - } - const r=await P.createOrder(orderBody({itemid:'pay_test',itemCount:1,level:1,requestId:'invalid',outTradeNo:'wcx_production'}),'and'); - assert.equal(r.errorCode,'PAYMENT_ENV_MISMATCH');assert.equal(table('order').length,0); -}); -test('a request cannot change its order number and another request cannot reuse it',async()=>{ - const order=await buy('original'); - for(const extra of [{requestId:'original',outTradeNo:'wct_changed'},{requestId:'different',outTradeNo:order.outTradeNo}]){ - const r=await P.createOrder(orderBody({itemid:'pay_test',itemCount:1,level:1,...extra}),'and'); - assert.equal(r.errorCode,'IDEMPOTENCY_CONFLICT'); - } - assert.equal(table('order').length,1); -}); -test('another player cannot obtain a signature for an existing client order number',async()=>{ - const order=await buy('owner');const before=copy(order); - table('users').push({...copy(table('users')[0]),_id:'other',openid:'other-openid'}); - const r=await P.createOrder(orderBody({uid:'other',itemid:'pay_test',itemCount:1,level:1,requestId:'foreign',outTradeNo:order.outTradeNo}),'ios'); - assert.equal(r.errorCode,'IDEMPOTENCY_CONFLICT');assert.equal(r.data,null);assert.deepEqual(table('order'),[before]); -}); -test('concurrent players using the same order number cannot both obtain signatures',async()=>{ - table('users').push({...copy(table('users')[0]),_id:'other',openid:'other-openid'}); - const r=await Promise.all(['u','other'].map(uid=>P.createOrder(orderBody({uid,itemid:'pay_test',itemCount:1,level:1,requestId:'race',outTradeNo:'wct_shared'}),'and'))); - assert.equal(r.filter(x=>x.code===1).length,1);assert.equal(r.filter(x=>x.errorCode==='IDEMPOTENCY_CONFLICT').length,1);assert.equal(table('order').length,1); -}); -test('a new click on either platform creates its own order',async()=>{const order=await buy();const r=await P.createOrder(orderBody({activityType:'pyramid',itemid:'pay_test',itemCount:1,level:1,requestId:'ios'}),'ios');assert.notEqual(r.data.outTradeNo,order.outTradeNo);assert.ok(r.data.signData);}); -test('verified iOS customer payment reuses the server-priced order and credits once',async()=>{ - const r=await P.createOrder(orderBody({activityType:'pyramid',itemid:'pay_test',itemCount:1,itemPrice:1,level:1,requestId:'ios-customer'}),'ios'); - const order=table('order').find(o=>o.outTradeNo===r.data.outTradeNo),transaction={trade_state:'SUCCESS',trade_type:'JSAPI', - out_trade_no:order.outTradeNo,mchid:'merchant',appid:'app',payer:{openid:'openid'},amount:{total:100,currency:'CNY'}, - transaction_id:'wx-transaction',success_time:new Date(now).toISOString()}; - await P.confirmLegacyPayment(body({}),transaction,order);await P.confirmLegacyPayment(body({}),transaction,table('order')[0]); - assert.equal(table('order')[0].pyramid.paymentChannel,'legacy_ios');assert.equal(table('order')[0].pyramid.transactionId,'wx-transaction'); - assert.equal(M.count(player()),1);assert.equal(player().credits.length,1); -}); -test('iOS customer payment rejects client identity, amount and merchant mismatches',async()=>{ - const r=await P.createOrder(orderBody({activityType:'pyramid',itemid:'pay_test',itemCount:1,level:1,requestId:'ios-invalid'}),'ios'); - const order=table('order')[0],base={trade_state:'SUCCESS',trade_type:'JSAPI',out_trade_no:r.data.outTradeNo,mchid:'merchant',appid:'app', - payer:{openid:'openid'},amount:{total:100,currency:'CNY'},transaction_id:'wx-invalid',success_time:new Date(now).toISOString()}; - for(const change of [t=>t.amount.total=1,t=>t.mchid='other',t=>t.payer.openid='other']){ - const transaction=copy(base);change(transaction);await assert.rejects(()=>P.confirmLegacyPayment(body({}),transaction,order),e=>e.errorCode==='INVALID_PAYMENT'); - } - assert.equal(player().credits.length,0);assert.equal(order.pyramid.paymentStatus,'pending'); -}); -test('reservation interrupted before order insert repairs original number',async()=>{await S.ensure('u',config());const p=player();p.pendingOrder={orderId:'wct_repair',level:1,itemId:'pay_test',itemPrice:100,openid:'openid',platform:'and',environment:'test',createdAt:now};const result=await P.query('u',{orderId:'wct_repair'});assert.equal(result.paymentStatus,'pending');assert.equal(table('order').length,1);}); -test('unconfirmed polling never produces failure or a chance or a purchase lock',async()=>{const order=await buy();now+=200000;const r=await P.query('u',{orderId:order.outTradeNo});assert.equal(r.paymentStatus,'pending');assert.equal(r.drawCount,0);assert.equal(player().pendingOrder,null);}); - -test('provider closure recovery requires admin evidence and repairs an interrupted unlock',async()=>{ - const o=await buy();process.env.PYRAMID_ADMIN_TOKEN='local-admin-test'; - assert.equal((await admin({body:{action:'record_closed_order',orderId:o.outTradeNo}})).errorCode,'AUTH_INVALID'); - assert.equal((await admin({body:{adminToken:'local-admin-test',action:'record_closed_order',orderId:o.outTradeNo,providerStatus:'CLOSED'}})).errorCode,'INVALID_INPUT'); - const result=await admin({body:{adminToken:'local-admin-test',action:'record_closed_order',orderId:o.outTradeNo,providerStatus:'CLOSED',evidenceReference:'provider-audit-123'}}); - assert.equal(result.code,1);assert.equal(player().pendingOrder,null);assert.equal(player().credits.length,0); - player().pendingOrder={orderId:o.outTradeNo};assert.equal((await P.query('u',{orderId:o.outTradeNo})).paymentStatus,'closed');assert.equal(player().pendingOrder,null); -}); - -test('a previous order request replays its own order while a newer order is pending',async()=>{ - const old=await buy('old');old.pyramid.paymentStatus='closed';player().pendingOrder=null;const current=await buy('new'); - const result=await P.createOrder(orderBody({activityType:'pyramid',itemid:'pay_test',itemCount:1,level:1,requestId:'old'}),'and'); - assert.equal(result.data.outTradeNo,old.outTradeNo);assert.equal(result.data.resumeOnly,true);assert.notEqual(current.outTradeNo,old.outTradeNo);assert.equal(player().pendingOrder,null); -}); - -test('a request interrupted before order insert can recover after another click',async()=>{ - const first=await buy('first');table('order').splice(0,1);await buy('second'); - const retry=await buy('first');assert.equal(retry.outTradeNo,first.outTradeNo);assert.equal(table('order').length,2); -}); -test('legacy unpaid pointer neither blocks buying nor creates a payment confirmation state',async()=>{ - await S.ensure('u',config());player().pendingOrder={orderId:'unpaid-legacy'}; - const state=(await api('info')).data;assert.equal(state.canPay,true);assert.equal(state.pendingOrder,null); - await buy('fresh');assert.equal(player().pendingOrder,null); -}); -test('delayed payments for the same level each credit once and unused credits expire on upgrade',async()=>{ - const a=await buy('a'),b=await buy('b');await pay(a);await pay(b);await pay(a);assert.equal(M.count(player()),2); - M.draw(player(),body({level:1,requestId:'upgrade'}),now,()=>5); - assert.equal(player().currentLevel,2);assert.equal(player().credits.filter(c=>c.status==='expired').length,1);assert.equal(M.count(player()),0); -}); -test('payment from an earlier level cannot credit the new level',async()=>{ - const late=await buy('late'),paid=await buy('paid');await pay(paid); - M.draw(player(),body({level:1,requestId:'upgrade'}),now,()=>5);await pay(late); - assert.equal(player().currentLevel,2);assert.equal(M.count(player()),0); - assert.equal(player().credits.find(c=>c.orderId===late.outTradeNo).status,'expired'); -}); -test('duplicate signed callbacks credit once; generic stale price is not trusted',async()=>{const order=await buy();await Promise.all([pay(order),pay(order)]);assert.equal(player().credits.length,1);assert.equal(M.count(player()),1);assert.equal((await P.createOrder(orderBody({itemid:'pay_test',itemCount:1,level:1,requestId:'next'}),'and')).errorCode,'DRAW_CHANCE_EXISTS');}); -test('invalid signatures cannot confirm payment',async()=>{const order=await buy();await assert.rejects(()=>P.notify({MiniGame:{}},{},order),e=>e.errorCode==='INVALID_PAYMENT');assert.equal(player().credits.length,0);}); -test('two draws race for one credit and replay returns original after deadline',async()=>{const order=await buy();await pay(order);const [a,b]=await Promise.all([api('draw',{level:1,requestId:'a'}),api('draw',{level:1,requestId:'b'})]);assert.equal([a,b].filter(r=>r.code===1).length,1);const winner=a.code===1?a:b;now=END;const retry=await api('draw',{level:1,requestId:winner.data.requestId});assert.equal(retry.data.rewardId,winner.data.rewardId);assert.equal(player().grants.length,1);}); -test('same draw request sent concurrently consumes once',async()=>{await pay(await buy());const r=await Promise.all([api('draw',{level:1,requestId:'a'}),api('draw',{level:1,requestId:'a'})]);assert.equal(r[0].data.grant.grantId,r[1].data.grant.grantId);assert.equal(player().credits.filter(c=>c.status==='consumed').length,1);}); -test('database cutoff rejects a write that crosses endAt',async()=>{await pay(await buy());beforeWrite=(name,q)=>{if(name===M.TABLES.players&&q.$expr)now=END;};const r=await api('draw',{level:1,requestId:'late'});assert.equal(r.errorCode,'ACTIVITY_ENDED');assert.equal(player().grants.length,0);}); -test('paid callback after cutoff is succeeded but expired',async()=>{const o=await buy();now=END;await pay(o);const r=await P.query('u',{orderId:o.outTradeNo});assert.equal(r.paymentStatus,'succeeded');assert.equal(r.fulfillmentStatus,'expired');assert.equal(r.drawCount,0);}); -test('client delivery requires atomic saved receipt; replay cannot overwrite newer balance',async()=>{await pay(await buy());const r=await api('draw',{level:1,requestId:'d'}),g=r.data.grant;await assert.rejects(()=>S.confirm('u',{grantId:g.grantId}),e=>e.errorCode==='DELIVERY_NOT_SAVED');await deliver(g);const user=table('users')[0];user.coinAmount+=17;const balance=user.coinAmount; - const retry=await S.saveDelivery('u',{grantId:g.grantId,phase:'save'});assert.equal(retry.saved,true);assert.equal(user.coinAmount,balance);await S.confirm('u',{grantId:g.grantId});assert.equal(player().grants[0].status,'delivered');}); -test('client cannot save arbitrary reward amount or foreign grant',async()=>{await pay(await buy());const r=await api('draw',{level:1,requestId:'d'}),g=r.data.grant;const base=S.inventory(table('users')[0]);const next={...base};for(const x of g.goods)next[M.FIELDS[x.id]]+=x.count+1; - await assert.rejects(()=>S.saveDelivery('u',{grantId:g.grantId,phase:'save',base,inventory:next,calculatedAt:now}),e=>e.errorCode==='INVALID_REWARD_SAVE');await assert.rejects(()=>S.findGrant('other',g.grantId),e=>e.errorCode==='GRANT_NOT_FOUND');}); -test('final claim and end settlement share every reward only once',async()=>{await S.ensure('u',config());const p=player();p.currentLevel=6;p.rewards.forEach(r=>{r.gain=true;r.deliveryStatus='unclaimed';});const result=await api('claim',{requestId:'final'});assert.equal(result.code,1);now=END;assert.equal((await S.pending('u',{})).grants.length,0);await S.runJobs();await claimNotice();assert.equal(player().grants.length,1);assert.equal(player().grants[0].grantId,result.data.grant.grantId);assert.equal(player().completed,true);assert.equal((await S.pending('u',{})).grants.length,0);}); -test('non-final expiry grants only previously won rewards and expires unused credits',async()=>{await pay(await buy());const r=await api('draw',{level:1,requestId:'d'});now=END;assert.equal((await S.pending('u',{})).grants.length,0);await S.runJobs();assert.equal(player().grants.length,1);assert.deepEqual(player().grants[0].rewardIds,[r.data.rewardId]);assert.equal(player().rewards.filter(r=>r.gain).length,1);assert.deepEqual(player().settlementNotice.goods,r.data.goods);}); -test('sixth-level eligibility persists even if target response was lost',async()=>{await S.ensure('u',config());const p=player();p.currentLevel=5;p.credits.push({level:5,status:'available',orderId:'o'});M.draw(p,body({level:5,requestId:'target'}),now,()=>5);now=END;await S.runJobs();await claimNotice();assert.equal(player().grants.length,2);const ids=player().grants.flatMap(g=>g.rewardIds);assert.equal(new Set(ids).size,p.rewards.length);assert.equal(player().completed,true);assert.ok(player().settlementNotice);}); -test('every public action requires player authentication',async()=>{ - for(const action of ['info','draw','claim','query','delivery_list','delivery_save','delivery_confirm']) - for(const token of [undefined,'bad'])assert.equal((await M.default({body:{uid:'u',token,action}})).errorCode,'AUTH_INVALID'); -}); - -test('HTTP dispatcher rejects missing, internal and unknown actions',async()=>{ - for(const action of [undefined,'list','jobs','runJobs','fulfill','notify','createOrder','constructor','__proto__']) - assert.equal((await api(action)).errorCode,'INVALID_ACTION'); - assert.equal(table('order').length,0);assert.equal(table(M.TABLES.players).length,0); -}); - -test('admin branch cannot use player credentials and validates its own token',async()=>{ - process.env.PYRAMID_ADMIN_TOKEN='test-admin'; - assert.equal((await api('admin',{operation:'setup_indexes'})).errorCode,'AUTH_INVALID'); - assert.equal((await api('admin',{operation:'setup_indexes',adminToken:'bad'})).errorCode,'AUTH_INVALID'); - assert.equal((await M.default({body:{action:'admin',operation:'setup_indexes',adminToken:'test-admin'}})).code,1); -}); - -test('unified endpoint delivers using phase without overwriting the routing action',async()=>{ - await pay(await buy());const result=await api('draw',{level:1,requestId:'draw'}),grant=result.data.grant; - const prepared=await api('delivery_save',{phase:'prepare',grantId:grant.grantId});assert.equal(prepared.code,1); - const next={...prepared.data.inventory};for(const g of grant.goods)next[M.FIELDS[g.id]]+=g.count; - assert.equal((await api('delivery_save',{phase:'save',grantId:grant.grantId,base:prepared.data.inventory,inventory:next,calculatedAt:now})).code,1); - assert.equal((await api('delivery_confirm',{grantId:grant.grantId})).code,1); - assert.equal((await api('delivery_list')).data.grants.length,0); -}); - -test('only the public API and HTTP-disabled timer remain as deployable functions',async()=>{ - const fs=await import('node:fs');const dir=new URL('../',import.meta.url); - const manifests=fs.readdirSync(dir,{recursive:true}).filter(p=>p.endsWith('.yaml')).map(p=>p.replaceAll('\\','/')).sort(); - assert.deepEqual(manifests,['api.yaml','jobs.yaml']);assert.match(fs.readFileSync(new URL('../api.yaml',import.meta.url),'utf8'),/methods: \[POST\]/); - assert.match(fs.readFileSync(new URL('../jobs.yaml',import.meta.url),'utf8'),/methods: \[\]/); - const {default:job}=await import('../jobs.ts');await S.ensure('u',config());now=END;assert.equal((await job()).settled,1); -}); - -test('configuration comes only from activityConfigs and isolates activity and environment',async()=>{ - assert.equal(M.TABLES.periods,'activityConfigs');tables.activityConfigs=[]; - table('pyramid_periods').push({...config(),_id:config().periodId}); - table('activityConfigs').push(version(config(),{activityId:'cloudRise'})); - table('activityConfigs').push(version({...config(),environment:'production'})); - assert.equal(await S.info('u',{}),null);assert.equal(table(M.TABLES.players).length,0); - table('activityConfigs').push(version());assert.equal((await S.info('u',{})).periodId,config().periodId); -}); - -test('disabled latest version blocks new participation and orders without hiding existing progress',async()=>{ - await S.info('u',{});const next=version({...config(),configVersion:'test-v2'},{enabled:false,effectiveFrom:now,publishedAt:now}); - table('activityConfigs').push(next); - assert.equal(await S.period(config().periodId),null);assert.equal(await S.info('new',{}),null); - assert.equal(await S.info('new',{periodId:config().periodId}),null); - table('users').push({...table('users')[0],_id:'new'}); - assert.equal((await P.createOrder(orderBody({uid:'new',itemid:'pay_test',itemCount:1,level:1,requestId:'new'}),'and')).errorCode,'PERIOD_NOT_FOUND'); - assert.equal((await S.info('u',{})).periodId,config().periodId);await buy(); - assert.equal((await M.pyramidActivities(now,END,null)).activities.length,0); - const own=await M.pyramidActivities(now,END,table('users')[0]); - assert.equal(own.activities[0].configVersion,'test-v1');assert.equal(table(M.TABLES.players).length,1); -}); - -test('public schedule is read only, previews future publications and respects exact period boundaries',async()=>{ - tables.activityConfigs=[version({...config(),startAt:now+5000,endAt:END},{effectiveFrom:now+5000})]; - assert.equal(await S.info('u',{}),null);assert.equal(await S.period(config().periodId),null); - const upcoming=await M.pyramidActivities(now,END,null); - assert.equal(upcoming.activities[0].phase,'upcoming');assert.equal(upcoming.activities[0].startsAt,now+5000); - assert.equal(table(M.TABLES.players).length,0);now+=5000; - assert.equal((await M.pyramidActivities(now,END,null)).activities[0].phase,'active'); - assert.equal(table(M.TABLES.players).length,0);assert.ok(await S.period()); - now=END;assert.equal(await S.period(),null);assert.equal((await M.pyramidActivities(now,END+1000,null)).activities.length,0); - assert.equal(await S.info('new',{periodId:config().periodId}),null); -}); - -test('new versions change only new participants and cannot publish into the past in production',async()=>{ - process.env.PYRAMID_ADMIN_TOKEN='admin-test';await S.info('u',{}); - const publish=(p,extra={})=>admin({body:{action:'publish',adminToken:'admin-test',period:p,...extra}}); - const p={...config(),configVersion:'test-v2'};p.levels[0].rewards[0].weight=900; - const result=await publish(p);assert.equal(result.code,1,JSON.stringify(result)); - assert.equal(result.data.activityId,'pyramid');assert.equal(result.data.recordType,'version'); - assert.equal(result.data.effectiveFrom,START);assert.equal(result.data.publishedAt,now); - assert.equal(result.data.config.published,undefined);assert.equal(result.data.enabled,true); - assert.equal((await publish(p)).code,1,'identical retry reuses immutable version'); - assert.equal((await S.info('u',{})).levels[0].rewards[0].weight,5); - table('users').push({...table('users')[0],_id:'new'});assert.equal((await S.info('new',{})).levels[0].rewards[0].weight,900); - const conflict=copy(p);conflict.levels[0].rewards[0].weight=901; - assert.equal((await publish(conflict)).code,0);assert.equal((await S.period()).levels[0].rewards[0].weight,900); - assert.equal((await publish({...p,periodId:'other',configVersion:'other'})).errorCode,'PERIOD_OVERLAP'); - assert.equal((await publish({...p,configVersion:'changed-time',endAt:END+1000})).errorCode,'PERIOD_TIME_IMMUTABLE'); - assert.equal((await publish({...p,configVersion:'bad-flag'},{enabled:'false'})).errorCode,'CONFIG_INVALID'); - process.env.PAYMENT_APP_ENV='production';const prod=copy(p);prod.environment='production';prod.configVersion='prod'; - prod.levels.slice(0,5).forEach(l=>l.itemId='pyramid_test'); - assert.equal((await publish(prod)).errorCode,'CONFIG_INVALID'); - assert.equal((await publish(prod,{effectiveFrom:now+1000})).code,1); - assert.equal(await S.period(),null);now+=1000;assert.ok(await S.period()); -}); - -test('shared publishing exception is restricted to active pyramid test periods',async()=>{ - const p=config(),opt={allowActiveTestPeriod:true}; - await assert.rejects(()=>store.publish('cloudRise','v',START,p,true,opt)); - await assert.rejects(()=>store.publish('pyramid','v',START,p)); - await assert.rejects(()=>store.publish('pyramid','v',START,{...p,environment:'production'},true,opt)); - process.env.PAYMENT_APP_ENV='production';await assert.rejects(()=>store.publish('pyramid','v',START,p,true,opt)); - process.env.PAYMENT_APP_ENV='test';now=END;await assert.rejects(()=>store.publish('pyramid','v',START,p,true,opt)); -}); - -test('upcoming versions use the earliest visible effective time regardless of insertion order',async()=>{ - const first={...config(),startAt:now+5000},second={...first,configVersion:'test-v2'}; - tables.activityConfigs=[version(second,{effectiveFrom:now+9000}),version(first), - version({...config(),periodId:'unpublished',configVersion:'unpublished'},{effectiveFrom:now+2000,publishedAt:now+1})]; - const result=await M.pyramidActivities(now,END,null); - assert.equal(result.activities.length,1);assert.equal(result.activities[0].startsAt,now+5000); - assert.equal(result.activities[0].configVersion,'test-v1');assert.equal(table(M.TABLES.players).length,0); -}); - -test('migration defaults to dry run, preserves snapshots and is repeatable',async()=>{ - await S.info('u',{});const before=copy(player());tables.activityConfigs=[]; - const old={...config(),_id:config().periodId,publishedAt:START-1};table('pyramid_periods').push(old); - const preview=await migrate(db);assert.equal(preview.writes,1);assert.equal(preview.dryRun,true); - assert.equal(table('activityConfigs').length,0);assert.deepEqual(player(),before); - assert.equal((await migrate(db,{apply:true})).writes,1);assert.deepEqual(table('activityConfigs')[0],version()); - assert.equal((await migrate(db,{apply:true})).writes,0);assert.deepEqual(table('pyramid_periods'),[old]); - assert.deepEqual(player(),before);table('users').push({...table('users')[0],_id:'new'});assert.equal((await S.info('new',{})).periodId,config().periodId); -}); - -test('migration validates all version conflicts before making any writes',async()=>{ - tables.activityConfigs=[];table('pyramid_periods').push({...config(),_id:'one'}, {...config(),periodId:'different',_id:'two'}); - await assert.rejects(()=>migrate(db,{apply:true}),/Conflicting pyramid configVersion/); - assert.equal(table('activityConfigs').length,0); - tables.pyramid_periods=[{...config(),_id:'one'}];tables.activityConfigs=[version(config(),{enabled:false})]; - await assert.rejects(()=>migrate(db,{apply:true}),/Conflicting pyramid configVersion/); - assert.equal(table('activityConfigs')[0].enabled,false); - tables.activityConfigs=[];tables.pyramid_periods=[{...config(),configVersion:undefined}]; - await assert.rejects(()=>migrate(db,{apply:true}),/Invalid legacy period/);assert.equal(table('activityConfigs').length,0); -}); - -test('delivery ledger is JSON text and duplicate concurrent saves add inventory once',async()=>{ - await pay(await buy());const {data:{grant:g}}=await api('draw',{level:1,requestId:'json'}); - const prepared=await S.saveDelivery('u',{grantId:g.grantId,phase:'prepare'}),next={...prepared.inventory}; - for(const item of g.goods)next[M.FIELDS[item.id]]+=item.count; - const request={grantId:g.grantId,phase:'save',base:prepared.inventory,inventory:next,calculatedAt:now}; - const results=await Promise.all([S.saveDelivery('u',request),S.saveDelivery('u',request)]); - assert.ok(results.every(r=>r.saved));const user=table('users')[0];assert.equal(typeof user.pyramidDeliveries,'string'); - assert.ok(JSON.parse(user.pyramidDeliveries)[g.grantId]);assert.deepEqual(M.inventory(user),next); - assert.equal((await S.saveDelivery('u',{grantId:g.grantId,phase:'prepare'})).saved,true); - await S.confirm('u',{grantId:g.grantId}); -}); - -test('legacy object receipts remain readable and are preserved on the next JSON write',async()=>{ - await pay(await buy());const {data:{grant:g}}=await api('draw',{level:1,requestId:'legacy'}); - await deliver(g);const user=table('users')[0],existing=JSON.parse(user.pyramidDeliveries),balance=M.inventory(user); - user.pyramidDeliveries=copy(existing); - assert.equal((await S.saveDelivery('u',{grantId:g.grantId,phase:'prepare'})).saved,true); - await S.confirm('u',{grantId:g.grantId});assert.deepEqual(M.inventory(user),balance); - const p=player(),reward=p.rewards.find(r=>!r.gain),next=M.grant(p,[reward.rewardId],'draw',now); - await deliver(next);assert.equal(typeof user.pyramidDeliveries,'string'); - const stored=JSON.parse(user.pyramidDeliveries);assert.deepEqual(stored[g.grantId],existing[g.grantId]);assert.ok(stored[next.grantId]); -}); - -test('another receipt written concurrently forces retry even when touched inventory is unchanged',async()=>{ - await pay(await buy());const {data:{grant:g}}=await api('draw',{level:1,requestId:'race'}); - const user=table('users')[0],other={periodId:'old',savedAt:now,inventory:{freezeAmount:1}}; - const before=M.inventory(user); - beforeWrite=(name)=>{if(name==='users'){beforeWrite=null;user.pyramidDeliveries=JSON.stringify({other});}}; - await assert.rejects(()=>deliver(g),e=>e.errorCode==='INVENTORY_CHANGED');assert.deepEqual(M.inventory(user),before); - await deliver(g);const stored=JSON.parse(user.pyramidDeliveries);assert.deepEqual(stored.other,other);assert.ok(stored[g.grantId]); -}); - -test('invalid receipt JSON blocks saving and confirmation without changing inventory',async()=>{ - await pay(await buy());const {data:{grant:g}}=await api('draw',{level:1,requestId:'invalid-json'}); - const user=table('users')[0],before=M.inventory(user); - for(const raw of ['{bad','[]','null']){ - user.pyramidDeliveries=raw; - await assert.rejects(()=>S.saveDelivery('u',{grantId:g.grantId,phase:'prepare'}),e=>e.errorCode==='INVALID_DELIVERY_RECEIPTS'); - await assert.rejects(()=>S.confirm('u',{grantId:g.grantId}),e=>e.errorCode==='INVALID_DELIVERY_RECEIPTS'); - assert.deepEqual(M.inventory(user),before);assert.equal(user.pyramidDeliveries,raw); - } -}); - -test('schedule is read only, unlocks after 50, and details create exactly one participation',async()=>{ - const user=table('users')[0];user.levelAmount=49; - let r=await unifiedList({body:{uid:123,token:'t'}});assert.deepEqual(r.data.activities,[]); - assert.equal((await api('info')).errorCode,'LEVEL_LOCKED'); - user.levelAmount=50; - beforeWrite=()=>{throw Error('schedule must not write');}; - r=await unifiedList({body:{uid:123,token:'t'}});assert.equal(r.code,1); - assert.deepEqual(Object.keys(pyramidEntry(r)).sort(),['activityId','periodId','configVersion','startsAt','endsAt','unlockLevel','phase'].sort()); - assert.equal(table(M.TABLES.players).length,0);assert.equal(table('pyramid_assignments').length,0); - beforeWrite=null; - const detail=await api('info');assert.equal(detail.code,1);assert.equal(detail.data.currentLevel,1);assert.ok(detail.data.levels.length); - await api('info');assert.equal(table(M.TABLES.players).length,1); - player().completed=true; - r=await unifiedList({body:{uid:123,token:'t'}});assert.equal(visibleActivities(r).length,0); - assert.equal((await api('info')).data.completed,true); -}); -test('eligibility errors are Chinese detail responses and never leak into the activity list',async()=>{ - const cfg=offerConfig();cfg.audience=['another-user'];tables.activityConfigs=[version(cfg)]; - const user=table('users')[0];user.cloudRisePeriod={periodId:'cloud-active',configVersion:'v',startsAt:START,endsAt:cfg.endAt,unlockLevel:50}; - assert.equal((await unifiedList({body:{uid:123,token:'bad'}})).code,0); - let r=await unifiedList({body:{uid:123,token:'t'}});assert.equal(r.code,1); - assert.deepEqual(visibleActivities(r).map(a=>a.activityId),['cloudRise']); - let detail=await api('info');assert.equal(detail.errorCode,'AUDIENCE_EXCLUDED');assert.match(detail.msg,/本期活动/); - cfg.audience=[];tables.activityConfigs=[version(cfg)];delete user.pay_user; - r=await unifiedList({body:{uid:123,token:'t'}});assert.ok(pyramidEntry(r)); - detail=await api('info');assert.equal(detail.errorCode,'PROFILE_UNAVAILABLE');assert.match(detail.msg,/[\u4e00-\u9fff]/); - now=cfg.endAt-2*3600000+1; - r=await unifiedList({body:{uid:123,token:'t'}});assert.ok(pyramidEntry(r)); - detail=await api('info');assert.equal(detail.errorCode,'PROFILE_UNAVAILABLE');assert.match(detail.msg,/[\u4e00-\u9fff]/); - for(const key of ['pyramid','pyramidError','pyramidErrorMessage'])assert.equal(key in pyramidEntry(r),false); - assert.equal(table(M.TABLES.players).length,0); -}); -test('only the authenticated owner can query historical details; schedule queries never settle',async()=>{ - await api('info');now=END; - beforeWrite=()=>{throw Error('schedule must not settle');}; - const r=await unifiedList({body:{uid:123,token:'t',pyramidPeriodId:config().periodId}}); - assert.equal(r.code,1);assert.equal(r.data.activities.length,0);assert.equal(player().settledAt,null); - beforeWrite=null; - assert.equal((await api('info')).data.status,'ended');assert.equal(player().settledAt,now); - table('users').push({_id:'other',onlyId:124,token:'other-token',levelAmount:50}); - const other=await api('info',{uid:'other',token:'other-token'});assert.equal(other.code,1);assert.equal(other.data,null); - assert.equal(table(M.TABLES.players).length,1); -}); - -test('empty audience opens active offers to new and old accounts; nonempty audience restricts both list and orders',async()=>{ - for(const audience of [[],['u'],['someone-else']]){ - for(const age of [1,90]){ - reset();const cfg=offerConfig();cfg.audience=audience;tables.activityConfigs=[version(cfg)]; - table('users')[0].register_time=now-age*DAY; - const included=audience.length===0||audience.includes('u'); - const listing=await unifiedList({body:{uid:123,token:'t'}}); - assert.equal(listing.code,1); - assert.equal(visibleActivities(listing).some(a=>a.activityId==='pyramid'),included); - assert.equal(table(M.TABLES.players).length,0); - const detail=await api('info'); - if(included)assert.equal(detail.data.offerTier,1);else assert.equal(detail.errorCode,'AUDIENCE_EXCLUDED'); - tables.pyramid_players=[];tables.pyramid_assignments=[]; - const level=M.offerLevels(cfg.offer,1)[1]; - const order=await P.createOrder(orderBody({itemid:level.itemId,itemCount:1,level:2,requestId:'audience-order'}),'and'); - // Included accounts reach normal level validation; excluded accounts cannot even create a player. - assert.equal(order.errorCode,included?'LEVEL_MISMATCH':'AUDIENCE_EXCLUDED'); - assert.equal(table(M.TABLES.players).length,included?1:0); +const claimNotice = (p = player(), extra = {}) => api('settlement_claim', { periodId: p.periodId, noticeId: p.settlementNotice.noticeId, ...extra }); +test('expiry job and repeated reads do not grant; explicit claim deposits all eligible rewards once', async () => { + await S.ensure('u', config()); const p = player(); p.currentLevel = 6; p.config.offerTier = 2; + p.rewards.forEach(r => { r.gain = true; r.deliveryStatus = 'unclaimed'; }); p.credits.push({ status: 'available' }); now = END; + const job = await S.runJobs(); assert.equal(job.settled, 1); assert.equal(job.errors, 0); + assert.equal(player().credits[0].status, 'expired'); assert.equal(player().completed, false); + assert.deepEqual(player().settlementNotice.goods, [{ id: 1001, count: 300 }, { id: 2001, count: 15 }, { id: 2002, count: 10 }, { id: 2005, count: 900 }]); + for (let i = 0; i < 2; i++) { + const read = await api('settlement_read'); assert.equal(read.data.notices.length, 1); assert.equal(read.data.inventory, undefined); + assert.equal(read.data.notices[0].readAt, null); assert.equal(read.data.notices[0].depositedAt, null); + assert.equal(table('users')[0].coinAmount, 100); assert.equal(table('users')[0].userPowerTime, 0); } - } + now = END + 3600000; const first = await claimNotice(); assert.equal(first.code, 1); + assert.equal(first.data.notice.readAt, now); assert.equal(first.data.notice.depositedAt, now); + assert.equal(first.data.inventory.coinAmount, 400); assert.equal(player().completed, true); + assert.equal(table('users')[0].userPowerTime, now / 1000 + 900); + assert.equal((await api('settlement_read')).data.notices.length, 0); + now += 60000; const retry = await claimNotice(); assert.equal(retry.code, 1); assert.equal(retry.data.inventory.coinAmount, 400); + assert.equal(retry.data.inventory.userPowerTime, first.data.inventory.userPowerTime); assert.deepEqual(retry.data.notice, first.data.notice); +}); +test('expiry job does not pay unearned slots or issue empty notices', async () => { + await S.ensure('u', config()); now = END - 1; assert.equal((await S.runJobs()).settled, 0); + now = END; await S.runJobs(); assert.equal(table('users')[0].coinAmount, 100); assert.equal(player().settlementNotice, null); + assert.equal((await api('settlement_read')).data.notices.length, 0); +}); +test('claim resumes after deposit but before confirmation without double inventory', async () => { + await S.ensure('u', config()); M.grant(player(), ['l1a'], 'draw', now); now = END; await S.runJobs(); + beforeWrite = (name, q, u) => { if (name === M.TABLES.players && u.$set?.grants?.some(g => g.status === 'delivered')) throw Error('lost confirmation'); }; + assert.equal((await claimNotice()).code, 0); assert.equal(table('users')[0].coinAmount, 120); + assert.equal(player().settlementNotice.readAt, null); assert.equal(player().settlementNotice.depositedAt, null); + beforeWrite = null; assert.equal((await api('settlement_read')).data.notices.length, 1); + const response = await claimNotice(); assert.equal(response.code, 1); assert.equal(table('users')[0].coinAmount, 120); + assert.deepEqual(response.data.notice.goods, [{ id: 1001, count: 20 }]); +}); +test('client-saved rewards are confirmed but do not appear as a second settlement award', async () => { + await S.ensure('u', config()); const g = M.grant(player(), ['l1a'], 'draw', now); + const r = await S.saveDelivery('u', { grantId: g.grantId, phase: 'prepare' }); + await S.saveDelivery('u', { grantId: g.grantId, phase: 'save', base: r.inventory, inventory: { ...r.inventory, coinAmount: 120 }, calculatedAt: now }); + now = END; await S.runJobs(); assert.equal(player().grants[0].status, 'delivered'); assert.equal(table('users')[0].coinAmount, 120); + assert.equal(player().settlementNotice, null); +}); +test('claim only deposits the selected period and another failed claim remains unread', async () => { + await S.ensure('u', config()); M.grant(player(), ['l1a'], 'draw', now); + await S.ensure('u', { ...config(), periodId: 'second-period' }); const second = table(M.TABLES.players).find(p => p.periodId === 'second-period'); + M.grant(second, ['l1a'], 'draw', now); now = END; await S.runJobs(); + assert.equal((await claimNotice()).code, 1); assert.equal(table('users')[0].coinAmount, 120); + beforeWrite = (name, q, u) => { if (name === 'users' && u.$set?.coinAmount === 140) throw Error('second deposit unavailable'); }; + assert.equal((await claimNotice(second)).code, 0); + assert.deepEqual((await api('settlement_read')).data.notices.map(n => n.periodId), ['second-period']); + beforeWrite = null; const result = await claimNotice(second); assert.equal(result.code, 1); assert.equal(result.data.inventory.coinAmount, 140); + assert.equal((await api('settlement_read')).data.notices.length, 0); +}); +test('concurrent reads are read-only and concurrent claims deposit once', async () => { + await S.ensure('u', config()); M.grant(player(), ['l1a'], 'draw', now); now = END; + await Promise.all([S.runJobs(), S.runJobs()]); + const reads = await Promise.all([api('settlement_read'), api('settlement_read')]); + assert.ok(reads.every(r => r.data.notices.length === 1)); assert.equal(table('users')[0].coinAmount, 100); + const claims = await Promise.all([claimNotice(), claimNotice()]); assert.ok(claims.every(r => r.code === 1)); + assert.equal(table('users')[0].coinAmount, 120); assert.equal((await api('settlement_read')).data.notices.length, 0); +}); +test('settlement reads and claims require authentication and match the owner, period and notice', async () => { + await S.ensure('u', config()); M.grant(player(), ['l1a'], 'draw', now); now = END; await S.runJobs(); + assert.equal((await api('settlement_read', { token: 'bad' })).errorCode, 'AUTH_INVALID'); + assert.equal((await claimNotice(player(), { token: 'bad' })).errorCode, 'AUTH_INVALID'); + table('users').push({ _id: 'other', token: 'other-token', levelAmount: 50 }); + assert.equal((await api('settlement_read', { uid: 'other', token: 'other-token' })).data.notices.length, 0); + for (const extra of [{ uid: 'other', token: 'other-token' }, { periodId: 'wrong' }, { noticeId: 'wrong' }]) assert.equal((await claimNotice(player(), extra)).errorCode, 'INVALID_INPUT'); + assert.equal(player().settlementNotice.readAt, null); assert.equal(table('users')[0].coinAmount, 100); +}); +test('a previously deposited unread notice can be claimed without paying it again', async () => { + await S.ensure('u', config()); M.grant(player(), ['l1a'], 'draw', now); now = END; await S.runJobs(); + await S.settleExpiredPeriod('u', config().periodId); assert.equal(table('users')[0].coinAmount, 120); + assert.equal((await claimNotice()).code, 1); assert.equal(table('users')[0].coinAmount, 120); +}); +test('job prepares a period already settled by info, and only explicit claim deposits it', async () => { + await S.ensure('u', config()); const p = player(); p.rewards[0].gain = true; p.rewards[0].deliveryStatus = 'unclaimed'; now = END; + await api('info'); assert.equal(player().settledAt, END); assert.equal(player().settlementCompletedAt, null); + assert.equal((await S.runJobs()).settled, 1); await api('settlement_read'); assert.equal(table('users')[0].coinAmount, 100); + assert.equal((await claimNotice()).code, 1); assert.equal(table('users')[0].coinAmount, 120); +}); +const body = extra => ({ uid: 'u', token: 't', periodId: config().periodId, ...extra }); +const orderBody = extra => body({ outTradeNo: (process.env.PAYMENT_APP_ENV === 'test' ? 'wct_' : 'wcx_') + extra.requestId, ...extra }); +const pyramidEntry = result => result.data.activities.find(a => a.activityId === 'pyramid'); +const visibleActivities = result => result.data.activities.filter(a => a.startsAt <= now && now < a.endsAt); +const api = async (action, b = {}) => M.default({ body: body({ ...b, action }) }); +const player = () => table(M.TABLES.players)[0]; +async function buy(id = 'buy', extra = {}) { const r = await P.createOrder(orderBody({ activityType: 'pyramid', itemid: 'pay_test', itemCount: 1, itemPrice: 1, level: 1, requestId: id, ...extra }), 'and'); assert.equal(r.code, 1, JSON.stringify(r)); return table('order').find(o => o.outTradeNo === r.data.outTradeNo); } +async function pay(order) { + const payload = { OutTradeNo: order.outTradeNo, OpenId: order.openid, Env: 0, GoodsInfo: { ProductId: order.itemid, Quantity: 1, OrigPrice: order.goodsPrice } }; + const event = { Event: 'minigame_game_pay_goods_deliver_notify', MiniGame: { Payload: JSON.stringify(payload), IsMock: false } }; + event.MiniGame.PayEventSig = createHmac('sha256', process.env.WX_MIDAS_PAY_SIGN_KEY).update(event.Event + '&' + event.MiniGame.Payload).digest('hex'); return P.notify(event, payload, order); +} +async function deliver(g) { + let r = await S.saveDelivery('u', { grantId: g.grantId, phase: 'prepare' }); const next = { ...r.inventory }; + for (const item of g.goods) { const f = M.FIELDS[item.id]; next[f] = f === 'userPowerTime' ? Math.max(next[f], Math.floor(now / 1000)) + item.count : next[f] + item.count; } + await S.saveDelivery('u', { grantId: g.grantId, phase: 'save', base: r.inventory, inventory: next, calculatedAt: now }); return S.confirm('u', { grantId: g.grantId }); +} +test('config validates targets, weights, prices and test-only pay_test', () => { assert.equal(M.validPeriod(config()).levels.length, 6); for (const change of [p => p.levels[0].rewards.forEach(r => r.up = false), p => p.levels[0].rewards[0].weight = 0, p => p.environment = 'production']) { const p = config(); change(p); assert.throws(() => M.validPeriod(p)); } }); + +test('info exposes immutable player weights for local probability calculation', async () => { + const first = await api('info'); assert.equal(first.code, 1); + assert.deepEqual(first.data.levels[0].rewards.map(r => r.weight), [5, 3, 2]); + assert.equal(first.data.levels[5].rewards[0].weight, null); + table(M.TABLES.periods)[0].config.levels[0].rewards[0].weight = 999; + const next = await api('info'); assert.equal(next.data.levels[0].rewards[0].weight, 5); }); -test('empty audience exposes upcoming schedules without creating players and preserves other gates',async()=>{ - const cfg=offerConfig();cfg.audience=[];cfg.startAt=now+60000;tables.activityConfigs=[version(cfg,{effectiveFrom:cfg.startAt})]; - for(const user of [null,table('users')[0]]){ - const result=await M.pyramidActivities(now,now+7*DAY,user); - assert.equal(result.activities[0].phase,'upcoming');assert.equal('pyramid' in result.activities[0],false); - } - assert.equal(table(M.TABLES.players).length,0); - cfg.audience=['someone-else'];tables.activityConfigs=[version(cfg,{effectiveFrom:cfg.startAt})]; - assert.equal((await M.pyramidActivities(now,now+7*DAY,table('users')[0])).activities.length,0); - cfg.audience=[];cfg.startAt=START;tables.activityConfigs=[version(cfg,{enabled:false})]; - assert.equal((await unifiedList({body:{uid:123,token:'t'}})).data.activities.length,0); - tables.activityConfigs=[version(cfg)];table('users')[0].levelAmount=49; - assert.equal((await unifiedList({body:{uid:123,token:'t'}})).data.activities.length,0); - assert.equal(table(M.TABLES.players).length,0); -}); - -test('all pyramid business error codes have Chinese messages and keep their codes',()=>{ - const source=readFileSync(new URL('../api.ts',import.meta.url),'utf8'); - const codes=new Set([...source.matchAll(/fail\([^;\n]+/g)].flatMap(m=>[...m[0].matchAll(/"([A-Z][A-Z_]+)"/g)].map(x=>x[1]))); - for(const code of codes){ - assert.ok(Object.hasOwn(M.ERROR_MESSAGES,code),code); - assert.throws(()=>M.fail(code),error=>{ - const result=M.failure(error); - assert.equal(result.errorCode,code);assert.equal(result.code,0);assert.equal(result.data,null); - assert.match(result.msg,/[\u4e00-\u9fff]/);assert.notEqual(result.msg,code);return true; +test('test-v2 configured weight intervals select the advertised slot', async () => { + const { readFileSync } = await import('node:fs'); const cfg = JSON.parse(readFileSync(new URL('../config/2026-09-28-test.json', import.meta.url))); + M.validPeriod(cfg); + const expected = [[49, 5, 4946, 4946, 5, 49], [49, 4949, 4, 4949, 49], [97, 9797, 9, 97], [1, 1, 1], [1, 1]]; + cfg.levels.slice(0, 5).forEach((l, index) => { + assert.deepEqual(l.rewards.map(r => r.weight), expected[index]); let start = 0; + for (const reward of l.rewards) { + for (const ticket of [start, start + reward.weight - 1]) { + const p = M.initial('u', cfg); p.currentLevel = l.level; p.credits.push({ orderId: 'weight-test', level: l.level, status: 'available' }); + const result = M.draw(p, { periodId: cfg.periodId, level: l.level, requestId: 'weight-test' }, now, () => ticket); + assert.equal(result.rewardId, reward.rewardId); + } start += reward.weight; + } }); - assert.equal(M.failure({errorCode:code,message:code}).msg,M.ERROR_MESSAGES[code]); - } - assert.ok(codes.size>25); - assert.equal(M.failure({errorCode:'PROFILE_UNAVAILABLE',message:'支付记录存在冲突'}).msg,'支付记录存在冲突'); - for(const error of [null,Error('database connection failed'),{errorCode:'UNKNOWN',message:'UNKNOWN'}]) - assert.equal(M.failure(error).msg,M.ERROR_MESSAGES.RETRYABLE); }); -test('public actions, admin and both payment platforms return Chinese errors',async()=>{ - for(const action of ['info','draw','claim','query','delivery_list','delivery_save','delivery_confirm','price_detail_view','admin','unknown']){ - const result=await M.default({body:{uid:'u',token:'invalid',action}}); - assert.equal(result.code,0);assert.match(result.msg,/[\u4e00-\u9fff]/);assert.ok(result.errorCode); - } - for(const platform of ['and','ios']){ - const result=await M.createOrder({uid:'u',token:'invalid'},platform); - assert.equal(result.errorCode,'AUTH_INVALID');assert.equal(result.msg,M.ERROR_MESSAGES.AUTH_INVALID); - } +test('weighted intervals renormalize after removal and either target advances', () => { + for (const ticket of [5, 8]) { const p = M.initial('u', config()); p.credits.push({ orderId: 'x', level: 1, status: 'available' }); const r = M.draw(p, body({ level: 1, requestId: 'd' }), now, () => ticket); assert.equal(r.up, true); assert.equal(p.currentLevel, 2); } + const p = M.initial('u', config()); p.credits.push({ orderId: 'x', level: 1, status: 'available' }); M.draw(p, body({ level: 1, requestId: 'a' }), now, () => 0); p.grants[0].status = 'delivered'; p.credits.push({ orderId: 'y', level: 1, status: 'available' }); + M.draw(p, body({ level: 1, requestId: 'b' }), now, total => { assert.equal(total, 5); return 0; }); assert.equal(p.currentLevel, 2); +}); +test('distinct purchase clicks create distinct orders at server price without a lock', async () => { const [a, b] = await Promise.all([buy('a'), buy('b')]); assert.notEqual(a.outTradeNo, b.outTradeNo); assert.equal(table('order').length, 2); assert.equal(a.goodsPrice, 100); assert.equal(player().pendingOrder, null); assert.equal((await api('info')).data.canPay, true); }); +test('retrying the same request remains idempotent', async () => { const [a, b] = await Promise.all([buy('a'), buy('a')]); assert.equal(a.outTradeNo, b.outTradeNo); assert.equal(table('order').length, 1); }); +test('signing preserves the client order number and server price', async () => { + const input = orderBody({ itemid: 'pay_test', itemCount: 1, itemPrice: 1, level: 1, requestId: 'client', outTradeNo: 'wct_client_selected_123' }); + const r = await P.createOrder(input, 'and'); assert.equal(r.code, 1); assert.equal(r.data.outTradeNo, input.outTradeNo); + const signed = JSON.parse(r.data.signData); assert.equal(signed.outTradeNo, input.outTradeNo); assert.equal(signed.goodsPrice, 100); + assert.equal(table('order')[0].outTradeNo, input.outTradeNo); +}); +test('missing malformed and wrong-environment client order numbers are rejected', async () => { + for (const outTradeNo of [undefined, '', 'wct_bad.space', 'wct_' + 'x'.repeat(29)]) { + const r = await P.createOrder(orderBody({ itemid: 'pay_test', itemCount: 1, level: 1, requestId: 'invalid', outTradeNo }), 'and'); + assert.equal(r.errorCode, 'INVALID_INPUT'); + } + const r = await P.createOrder(orderBody({ itemid: 'pay_test', itemCount: 1, level: 1, requestId: 'invalid', outTradeNo: 'wcx_production' }), 'and'); + assert.equal(r.errorCode, 'PAYMENT_ENV_MISMATCH'); assert.equal(table('order').length, 0); +}); +test('a request cannot change its order number and another request cannot reuse it', async () => { + const order = await buy('original'); + for (const extra of [{ requestId: 'original', outTradeNo: 'wct_changed' }, { requestId: 'different', outTradeNo: order.outTradeNo }]) { + const r = await P.createOrder(orderBody({ itemid: 'pay_test', itemCount: 1, level: 1, ...extra }), 'and'); + assert.equal(r.errorCode, 'IDEMPOTENCY_CONFLICT'); + } + assert.equal(table('order').length, 1); +}); +test('another player cannot obtain a signature for an existing client order number', async () => { + const order = await buy('owner'); const before = copy(order); + table('users').push({ ...copy(table('users')[0]), _id: 'other', openid: 'other-openid' }); + const r = await P.createOrder(orderBody({ uid: 'other', itemid: 'pay_test', itemCount: 1, level: 1, requestId: 'foreign', outTradeNo: order.outTradeNo }), 'ios'); + assert.equal(r.errorCode, 'IDEMPOTENCY_CONFLICT'); assert.equal(r.data, null); assert.deepEqual(table('order'), [before]); +}); +test('concurrent players using the same order number cannot both obtain signatures', async () => { + table('users').push({ ...copy(table('users')[0]), _id: 'other', openid: 'other-openid' }); + const r = await Promise.all(['u', 'other'].map(uid => P.createOrder(orderBody({ uid, itemid: 'pay_test', itemCount: 1, level: 1, requestId: 'race', outTradeNo: 'wct_shared' }), 'and'))); + assert.equal(r.filter(x => x.code === 1).length, 1); assert.equal(r.filter(x => x.errorCode === 'IDEMPOTENCY_CONFLICT').length, 1); assert.equal(table('order').length, 1); +}); +test('a new click on either platform creates its own order', async () => { const order = await buy(); const r = await P.createOrder(orderBody({ activityType: 'pyramid', itemid: 'pay_test', itemCount: 1, level: 1, requestId: 'ios' }), 'ios'); assert.notEqual(r.data.outTradeNo, order.outTradeNo); assert.ok(r.data.signData); }); + +test('pyramid payment uses verified order amount/time and the same dedup key on callbacks and queries', async () => { + const user = table('users')[0]; Object.assign(user, { distinctId: 'device', addLevel: 6, healthAmount: 4, abTests: JSON.stringify({ layer_1: { group: 'B' } }) }); + const order = await buy(); assert.equal(paymentEvents.length, 0); + await pay(order); const paidAt = table('order')[0].pyramid.firstVerifiedAt; now += 1000; + await Promise.all([pay(order), P.query('u', { orderId: order.outTradeNo })]); + assert.ok(paymentEvents.length >= 3); + assert.equal(new Set(paymentEvents.map(e => e.appId + ':' + e.event + ':' + e.firstCheckId)).size, 1); + for (const e of paymentEvents) { + assert.equal(e.event, 'payment'); assert.equal(e.firstCheckId, order.outTradeNo); + assert.equal(e.properties.order_id, order.outTradeNo); assert.equal(e.properties.pay_amount, 100); + assert.equal(e.properties.payment_name, 'pay_test'); assert.equal(e.properties.payment_num, 1); + assert.equal(e.properties.payment_type, 'Android'); assert.equal(e.properties.abTest_layer_one, 2); + assert.equal(e.time.getTime(), paidAt + 8 * 3600000); assert.equal(e.accountId, 'openid'); assert.equal(e.distinctId, 'device'); + assert.equal(e.appId, '95993f9ab6f1402a87abe5147827e5e0'); + } + assert.equal(player().credits.length, 1); +}); +test('native iOS and production payment keep the existing analytics channel and app', async () => { + table('users')[0].distinctId = 'device'; const order = await buy(); order.type = 'ios'; order.paymentAppEnv = 'production'; + process.env.PAYMENT_APP_ENV = 'production'; await pay(order); + assert.equal(paymentEvents[0].properties.payment_type, 'ios'); + assert.equal(paymentEvents[0].appId, '40e3d5c5f2af49a4a074205564ce5dbb'); +}); +test('analytics exceptions and callback failures do not block credits; subsequent confirmation retries', async () => { + table('users')[0].distinctId = 'device'; const order = await buy(); analyticsFailure = true; + await pay(order); assert.equal(player().credits.length, 1); assert.equal(paymentEvents.length, 0); + analyticsFailure = false; analyticsCallbackFailure = true; await P.query('u', { orderId: order.outTradeNo }); + assert.equal(paymentEvents.length, 0); analyticsCallbackFailure = false; await P.query('u', { orderId: order.outTradeNo }); + assert.equal(paymentEvents.length, 1); assert.equal(player().credits.length, 1); +}); +test('missing analytics identity can retry and an unpaid order never reports payment', async () => { + const order = await buy(); await P.query('u', { orderId: order.outTradeNo }); assert.equal(paymentEvents.length, 0); + await assert.rejects(() => P.fulfill(order.outTradeNo)); await pay(order); assert.equal(paymentEvents.length, 0); + table('users')[0].distinctId = 'device'; await P.query('u', { orderId: order.outTradeNo }); assert.equal(paymentEvents.length, 1); +}); +test('verified late payment is reported even when its draw credit has expired', async () => { + table('users')[0].distinctId = 'device'; const order = await buy(); now = END; await pay(order); + assert.equal(paymentEvents.length, 1); assert.equal(table('order')[0].pyramid.fulfillmentStatus, 'expired'); +}); +test('legacy login recovery excludes pyramid from ordinary non-deduplicated payment reporting', () => { + const source = readFileSync(new URL('../../login.ts', import.meta.url), 'utf8'); + const recovery = source.slice(source.indexOf('async function getIosOrder')); + const skip = recovery.indexOf('res.data[i].pyramid || res.data[i].activityType === "pyramid"'), report = recovery.indexOf('sead(res.data[i]'); + assert.ok(skip >= 0 && report > skip); }); -test('settlement reads report pending preparation and become ready after the job without relogin',async()=>{ - await S.ensure('u',config());M.grant(player(),['l1a'],'draw',now); - assert.equal((await api('settlement_read')).data.status,'ready');now=END; - const pending=await api('settlement_read');assert.equal(pending.data.status,'settling');assert.deepEqual(pending.data.notices,[]); - assert.equal(table('users')[0].coinAmount,100);assert.equal(player().settlementPreparedAt,null); - await S.runJobs();const ready=await api('settlement_read');assert.equal(ready.data.status,'ready');assert.equal(ready.data.notices.length,1); - assert.equal(table('users')[0].coinAmount,100); - await claimNotice(); - const again=await api('settlement_read');assert.equal(again.data.status,'ready');assert.deepEqual(again.data.notices,[]); - assert.equal(table('users')[0].coinAmount,120); +test('verified iOS customer payment reuses the server-priced order and credits once', async () => { + table('users')[0].distinctId = 'device'; + const r = await P.createOrder(orderBody({ activityType: 'pyramid', itemid: 'pay_test', itemCount: 1, itemPrice: 1, level: 1, requestId: 'ios-customer' }), 'ios'); + const order = table('order').find(o => o.outTradeNo === r.data.outTradeNo), transaction = { + trade_state: 'SUCCESS', trade_type: 'JSAPI', + out_trade_no: order.outTradeNo, mchid: 'merchant', appid: 'app', payer: { openid: 'openid' }, amount: { total: 100, currency: 'CNY' }, + transaction_id: 'wx-transaction', success_time: new Date(now).toISOString() + }; + await P.confirmLegacyPayment(body({}), transaction, order); await P.confirmLegacyPayment(body({}), transaction, table('order')[0]); + assert.equal(table('order')[0].pyramid.paymentChannel, 'legacy_ios'); assert.equal(table('order')[0].pyramid.transactionId, 'wx-transaction'); + assert.equal(paymentEvents.length, 2); assert.ok(paymentEvents.every(e => e.properties.payment_type === 'ios_old' && e.firstCheckId === order.outTradeNo)); + assert.equal(M.count(player()), 1); assert.equal(player().credits.length, 1); }); -test('empty expired participation stops settlement retries after the job prepares it',async()=>{ - await S.ensure('u',config());now=END;assert.equal((await api('settlement_read')).data.status,'settling'); - await S.runJobs();const result=await api('settlement_read');assert.equal(result.data.status,'ready');assert.deepEqual(result.data.notices,[]); +test('iOS customer payment rejects client identity, amount and merchant mismatches', async () => { + const r = await P.createOrder(orderBody({ activityType: 'pyramid', itemid: 'pay_test', itemCount: 1, level: 1, requestId: 'ios-invalid' }), 'ios'); + const order = table('order')[0], base = { + trade_state: 'SUCCESS', trade_type: 'JSAPI', out_trade_no: r.data.outTradeNo, mchid: 'merchant', appid: 'app', + payer: { openid: 'openid' }, amount: { total: 100, currency: 'CNY' }, transaction_id: 'wx-invalid', success_time: new Date(now).toISOString() + }; + for (const change of [t => t.amount.total = 1, t => t.mchid = 'other', t => t.payer.openid = 'other']) { + const transaction = copy(base); change(transaction); await assert.rejects(() => P.confirmLegacyPayment(body({}), transaction, order), e => e.errorCode === 'INVALID_PAYMENT'); + } + assert.equal(player().credits.length, 0); assert.equal(order.pyramid.paymentStatus, 'pending'); +}); +test('reservation interrupted before order insert repairs original number', async () => { await S.ensure('u', config()); const p = player(); p.pendingOrder = { orderId: 'wct_repair', level: 1, itemId: 'pay_test', itemPrice: 100, openid: 'openid', platform: 'and', environment: 'test', createdAt: now }; const result = await P.query('u', { orderId: 'wct_repair' }); assert.equal(result.paymentStatus, 'pending'); assert.equal(table('order').length, 1); }); +test('unconfirmed polling never produces failure or a chance or a purchase lock', async () => { const order = await buy(); now += 200000; const r = await P.query('u', { orderId: order.outTradeNo }); assert.equal(r.paymentStatus, 'pending'); assert.equal(r.drawCount, 0); assert.equal(player().pendingOrder, null); }); + +test('provider closure recovery requires admin evidence and repairs an interrupted unlock', async () => { + const o = await buy(); process.env.PYRAMID_ADMIN_TOKEN = 'local-admin-test'; + assert.equal((await admin({ body: { action: 'record_closed_order', orderId: o.outTradeNo } })).errorCode, 'AUTH_INVALID'); + assert.equal((await admin({ body: { adminToken: 'local-admin-test', action: 'record_closed_order', orderId: o.outTradeNo, providerStatus: 'CLOSED' } })).errorCode, 'INVALID_INPUT'); + const result = await admin({ body: { adminToken: 'local-admin-test', action: 'record_closed_order', orderId: o.outTradeNo, providerStatus: 'CLOSED', evidenceReference: 'provider-audit-123' } }); + assert.equal(result.code, 1); assert.equal(player().pendingOrder, null); assert.equal(player().credits.length, 0); + player().pendingOrder = { orderId: o.outTradeNo }; assert.equal((await P.query('u', { orderId: o.outTradeNo })).paymentStatus, 'closed'); assert.equal(player().pendingOrder, null); +}); + +test('a previous order request replays its own order while a newer order is pending', async () => { + const old = await buy('old'); old.pyramid.paymentStatus = 'closed'; player().pendingOrder = null; const current = await buy('new'); + const result = await P.createOrder(orderBody({ activityType: 'pyramid', itemid: 'pay_test', itemCount: 1, level: 1, requestId: 'old' }), 'and'); + assert.equal(result.data.outTradeNo, old.outTradeNo); assert.equal(result.data.resumeOnly, true); assert.notEqual(current.outTradeNo, old.outTradeNo); assert.equal(player().pendingOrder, null); +}); + +test('a request interrupted before order insert can recover after another click', async () => { + const first = await buy('first'); table('order').splice(0, 1); await buy('second'); + const retry = await buy('first'); assert.equal(retry.outTradeNo, first.outTradeNo); assert.equal(table('order').length, 2); +}); +test('legacy unpaid pointer neither blocks buying nor creates a payment confirmation state', async () => { + await S.ensure('u', config()); player().pendingOrder = { orderId: 'unpaid-legacy' }; + const state = (await api('info')).data; assert.equal(state.canPay, true); assert.equal(state.pendingOrder, null); + await buy('fresh'); assert.equal(player().pendingOrder, null); +}); +test('delayed payments for the same level each credit once and unused credits expire on upgrade', async () => { + const a = await buy('a'), b = await buy('b'); await pay(a); await pay(b); await pay(a); assert.equal(M.count(player()), 2); + M.draw(player(), body({ level: 1, requestId: 'upgrade' }), now, () => 5); + assert.equal(player().currentLevel, 2); assert.equal(player().credits.filter(c => c.status === 'expired').length, 1); assert.equal(M.count(player()), 0); +}); +test('payment from an earlier level cannot credit the new level', async () => { + const late = await buy('late'), paid = await buy('paid'); await pay(paid); + M.draw(player(), body({ level: 1, requestId: 'upgrade' }), now, () => 5); await pay(late); + assert.equal(player().currentLevel, 2); assert.equal(M.count(player()), 0); + assert.equal(player().credits.find(c => c.orderId === late.outTradeNo).status, 'expired'); +}); +test('duplicate signed callbacks credit once; generic stale price is not trusted', async () => { const order = await buy(); await Promise.all([pay(order), pay(order)]); assert.equal(player().credits.length, 1); assert.equal(M.count(player()), 1); assert.equal((await P.createOrder(orderBody({ itemid: 'pay_test', itemCount: 1, level: 1, requestId: 'next' }), 'and')).errorCode, 'DRAW_CHANCE_EXISTS'); }); +test('invalid signatures cannot confirm payment', async () => { const order = await buy(); await assert.rejects(() => P.notify({ MiniGame: {} }, {}, order), e => e.errorCode === 'INVALID_PAYMENT'); assert.equal(player().credits.length, 0); }); +test('two draws race for one credit and replay returns original after deadline', async () => { const order = await buy(); await pay(order); const [a, b] = await Promise.all([api('draw', { level: 1, requestId: 'a' }), api('draw', { level: 1, requestId: 'b' })]); assert.equal([a, b].filter(r => r.code === 1).length, 1); const winner = a.code === 1 ? a : b; now = END; const retry = await api('draw', { level: 1, requestId: winner.data.requestId }); assert.equal(retry.data.rewardId, winner.data.rewardId); assert.equal(player().grants.length, 1); }); +test('same draw request sent concurrently consumes once', async () => { await pay(await buy()); const r = await Promise.all([api('draw', { level: 1, requestId: 'a' }), api('draw', { level: 1, requestId: 'a' })]); assert.equal(r[0].data.grant.grantId, r[1].data.grant.grantId); assert.equal(player().credits.filter(c => c.status === 'consumed').length, 1); }); +test('database cutoff rejects a write that crosses endAt', async () => { await pay(await buy()); beforeWrite = (name, q) => { if (name === M.TABLES.players && q.$expr) now = END; }; const r = await api('draw', { level: 1, requestId: 'late' }); assert.equal(r.errorCode, 'ACTIVITY_ENDED'); assert.equal(player().grants.length, 0); }); +test('paid callback after cutoff is succeeded but expired', async () => { const o = await buy(); now = END; await pay(o); const r = await P.query('u', { orderId: o.outTradeNo }); assert.equal(r.paymentStatus, 'succeeded'); assert.equal(r.fulfillmentStatus, 'expired'); assert.equal(r.drawCount, 0); }); +test('client delivery requires atomic saved receipt; replay cannot overwrite newer balance', async () => { + await pay(await buy()); const r = await api('draw', { level: 1, requestId: 'd' }), g = r.data.grant; await assert.rejects(() => S.confirm('u', { grantId: g.grantId }), e => e.errorCode === 'DELIVERY_NOT_SAVED'); await deliver(g); const user = table('users')[0]; user.coinAmount += 17; const balance = user.coinAmount; + const retry = await S.saveDelivery('u', { grantId: g.grantId, phase: 'save' }); assert.equal(retry.saved, true); assert.equal(user.coinAmount, balance); await S.confirm('u', { grantId: g.grantId }); assert.equal(player().grants[0].status, 'delivered'); +}); +test('client cannot save arbitrary reward amount or foreign grant', async () => { + await pay(await buy()); const r = await api('draw', { level: 1, requestId: 'd' }), g = r.data.grant; const base = S.inventory(table('users')[0]); const next = { ...base }; for (const x of g.goods) next[M.FIELDS[x.id]] += x.count + 1; + await assert.rejects(() => S.saveDelivery('u', { grantId: g.grantId, phase: 'save', base, inventory: next, calculatedAt: now }), e => e.errorCode === 'INVALID_REWARD_SAVE'); await assert.rejects(() => S.findGrant('other', g.grantId), e => e.errorCode === 'GRANT_NOT_FOUND'); +}); +test('final claim and end settlement share every reward only once', async () => { await S.ensure('u', config()); const p = player(); p.currentLevel = 6; p.rewards.forEach(r => { r.gain = true; r.deliveryStatus = 'unclaimed'; }); const result = await api('claim', { requestId: 'final' }); assert.equal(result.code, 1); now = END; assert.equal((await S.pending('u', {})).grants.length, 0); await S.runJobs(); await claimNotice(); assert.equal(player().grants.length, 1); assert.equal(player().grants[0].grantId, result.data.grant.grantId); assert.equal(player().completed, true); assert.equal((await S.pending('u', {})).grants.length, 0); }); +test('non-final expiry grants only previously won rewards and expires unused credits', async () => { await pay(await buy()); const r = await api('draw', { level: 1, requestId: 'd' }); now = END; assert.equal((await S.pending('u', {})).grants.length, 0); await S.runJobs(); assert.equal(player().grants.length, 1); assert.deepEqual(player().grants[0].rewardIds, [r.data.rewardId]); assert.equal(player().rewards.filter(r => r.gain).length, 1); assert.deepEqual(player().settlementNotice.goods, r.data.goods); }); +test('sixth-level eligibility persists even if target response was lost', async () => { await S.ensure('u', config()); const p = player(); p.currentLevel = 5; p.credits.push({ level: 5, status: 'available', orderId: 'o' }); M.draw(p, body({ level: 5, requestId: 'target' }), now, () => 5); now = END; await S.runJobs(); await claimNotice(); assert.equal(player().grants.length, 2); const ids = player().grants.flatMap(g => g.rewardIds); assert.equal(new Set(ids).size, p.rewards.length); assert.equal(player().completed, true); assert.ok(player().settlementNotice); }); +test('every public action requires player authentication', async () => { + for (const action of ['info', 'draw', 'claim', 'query', 'delivery_list', 'delivery_save', 'delivery_confirm']) + for (const token of [undefined, 'bad']) assert.equal((await M.default({ body: { uid: 'u', token, action } })).errorCode, 'AUTH_INVALID'); +}); + +test('HTTP dispatcher rejects missing, internal and unknown actions', async () => { + for (const action of [undefined, 'list', 'jobs', 'runJobs', 'fulfill', 'notify', 'createOrder', 'constructor', '__proto__']) + assert.equal((await api(action)).errorCode, 'INVALID_ACTION'); + assert.equal(table('order').length, 0); assert.equal(table(M.TABLES.players).length, 0); +}); + +test('admin branch cannot use player credentials and validates its own token', async () => { + process.env.PYRAMID_ADMIN_TOKEN = 'test-admin'; + assert.equal((await api('admin', { operation: 'setup_indexes' })).errorCode, 'AUTH_INVALID'); + assert.equal((await api('admin', { operation: 'setup_indexes', adminToken: 'bad' })).errorCode, 'AUTH_INVALID'); + assert.equal((await M.default({ body: { action: 'admin', operation: 'setup_indexes', adminToken: 'test-admin' } })).code, 1); +}); + +test('unified endpoint delivers using phase without overwriting the routing action', async () => { + await pay(await buy()); const result = await api('draw', { level: 1, requestId: 'draw' }), grant = result.data.grant; + const prepared = await api('delivery_save', { phase: 'prepare', grantId: grant.grantId }); assert.equal(prepared.code, 1); + const next = { ...prepared.data.inventory }; for (const g of grant.goods) next[M.FIELDS[g.id]] += g.count; + assert.equal((await api('delivery_save', { phase: 'save', grantId: grant.grantId, base: prepared.data.inventory, inventory: next, calculatedAt: now })).code, 1); + assert.equal((await api('delivery_confirm', { grantId: grant.grantId })).code, 1); + assert.equal((await api('delivery_list')).data.grants.length, 0); +}); + +test('only the public API and HTTP-disabled timer remain as deployable functions', async () => { + const fs = await import('node:fs'); const dir = new URL('../', import.meta.url); + const manifests = fs.readdirSync(dir, { recursive: true }).filter(p => p.endsWith('.yaml')).map(p => p.replaceAll('\\', '/')).sort(); + assert.deepEqual(manifests, ['api.yaml', 'jobs.yaml']); assert.match(fs.readFileSync(new URL('../api.yaml', import.meta.url), 'utf8'), /methods: \[POST\]/); + assert.match(fs.readFileSync(new URL('../jobs.yaml', import.meta.url), 'utf8'), /methods: \[\]/); + const { default: job } = await import('../jobs.ts'); await S.ensure('u', config()); now = END; assert.equal((await job()).settled, 1); +}); + +test('configuration comes only from activityConfigs and isolates activity and environment', async () => { + assert.equal(M.TABLES.periods, 'activityConfigs'); tables.activityConfigs = []; + table('pyramid_periods').push({ ...config(), _id: config().periodId }); + table('activityConfigs').push(version(config(), { activityId: 'cloudRise' })); + table('activityConfigs').push(version({ ...config(), environment: 'production' })); + assert.equal(await S.info('u', {}), null); assert.equal(table(M.TABLES.players).length, 0); + table('activityConfigs').push(version()); assert.equal((await S.info('u', {})).periodId, config().periodId); +}); + +test('disabled latest version blocks new participation and orders without hiding existing progress', async () => { + await S.info('u', {}); const next = version({ ...config(), configVersion: 'test-v2' }, { enabled: false, effectiveFrom: now, publishedAt: now }); + table('activityConfigs').push(next); + assert.equal(await S.period(config().periodId), null); assert.equal(await S.info('new', {}), null); + assert.equal(await S.info('new', { periodId: config().periodId }), null); + table('users').push({ ...table('users')[0], _id: 'new' }); + assert.equal((await P.createOrder(orderBody({ uid: 'new', itemid: 'pay_test', itemCount: 1, level: 1, requestId: 'new' }), 'and')).errorCode, 'PERIOD_NOT_FOUND'); + assert.equal((await S.info('u', {})).periodId, config().periodId); await buy(); + assert.equal((await M.pyramidActivities(now, END, null)).activities.length, 0); + const own = await M.pyramidActivities(now, END, table('users')[0]); + assert.equal(own.activities[0].configVersion, 'test-v1'); assert.equal(table(M.TABLES.players).length, 1); +}); + +test('public schedule is read only, previews future publications and respects exact period boundaries', async () => { + tables.activityConfigs = [version({ ...config(), startAt: now + 5000, endAt: END }, { effectiveFrom: now + 5000 })]; + assert.equal(await S.info('u', {}), null); assert.equal(await S.period(config().periodId), null); + const upcoming = await M.pyramidActivities(now, END, null); + assert.equal(upcoming.activities[0].phase, 'upcoming'); assert.equal(upcoming.activities[0].startsAt, now + 5000); + assert.equal(table(M.TABLES.players).length, 0); now += 5000; + assert.equal((await M.pyramidActivities(now, END, null)).activities[0].phase, 'active'); + assert.equal(table(M.TABLES.players).length, 0); assert.ok(await S.period()); + now = END; assert.equal(await S.period(), null); assert.equal((await M.pyramidActivities(now, END + 1000, null)).activities.length, 0); + assert.equal(await S.info('new', { periodId: config().periodId }), null); +}); + +test('new versions change only new participants and cannot publish into the past in production', async () => { + process.env.PYRAMID_ADMIN_TOKEN = 'admin-test'; await S.info('u', {}); + const publish = (p, extra = {}) => admin({ body: { action: 'publish', adminToken: 'admin-test', period: p, ...extra } }); + const p = { ...config(), configVersion: 'test-v2' }; p.levels[0].rewards[0].weight = 900; + const result = await publish(p); assert.equal(result.code, 1, JSON.stringify(result)); + assert.equal(result.data.activityId, 'pyramid'); assert.equal(result.data.recordType, 'version'); + assert.equal(result.data.effectiveFrom, START); assert.equal(result.data.publishedAt, now); + assert.equal(result.data.config.published, undefined); assert.equal(result.data.enabled, true); + assert.equal((await publish(p)).code, 1, 'identical retry reuses immutable version'); + assert.equal((await S.info('u', {})).levels[0].rewards[0].weight, 5); + table('users').push({ ...table('users')[0], _id: 'new' }); assert.equal((await S.info('new', {})).levels[0].rewards[0].weight, 900); + const conflict = copy(p); conflict.levels[0].rewards[0].weight = 901; + assert.equal((await publish(conflict)).code, 0); assert.equal((await S.period()).levels[0].rewards[0].weight, 900); + assert.equal((await publish({ ...p, periodId: 'other', configVersion: 'other' })).errorCode, 'PERIOD_OVERLAP'); + assert.equal((await publish({ ...p, configVersion: 'changed-time', endAt: END + 1000 })).errorCode, 'PERIOD_TIME_IMMUTABLE'); + assert.equal((await publish({ ...p, configVersion: 'bad-flag' }, { enabled: 'false' })).errorCode, 'CONFIG_INVALID'); + process.env.PAYMENT_APP_ENV = 'production'; const prod = copy(p); prod.environment = 'production'; prod.configVersion = 'prod'; + prod.levels.slice(0, 5).forEach(l => l.itemId = 'pyramid_test'); + assert.equal((await publish(prod)).errorCode, 'CONFIG_INVALID'); + assert.equal((await publish(prod, { effectiveFrom: now + 1000 })).code, 1); + assert.equal(await S.period(), null); now += 1000; assert.ok(await S.period()); +}); + +test('shared publishing exception is restricted to active pyramid test periods', async () => { + const p = config(), opt = { allowActiveTestPeriod: true }; + await assert.rejects(() => store.publish('cloudRise', 'v', START, p, true, opt)); + await assert.rejects(() => store.publish('pyramid', 'v', START, p)); + await assert.rejects(() => store.publish('pyramid', 'v', START, { ...p, environment: 'production' }, true, opt)); + process.env.PAYMENT_APP_ENV = 'production'; await assert.rejects(() => store.publish('pyramid', 'v', START, p, true, opt)); + process.env.PAYMENT_APP_ENV = 'test'; now = END; await assert.rejects(() => store.publish('pyramid', 'v', START, p, true, opt)); +}); + +test('upcoming versions use the earliest visible effective time regardless of insertion order', async () => { + const first = { ...config(), startAt: now + 5000 }, second = { ...first, configVersion: 'test-v2' }; + tables.activityConfigs = [version(second, { effectiveFrom: now + 9000 }), version(first), + version({ ...config(), periodId: 'unpublished', configVersion: 'unpublished' }, { effectiveFrom: now + 2000, publishedAt: now + 1 })]; + const result = await M.pyramidActivities(now, END, null); + assert.equal(result.activities.length, 1); assert.equal(result.activities[0].startsAt, now + 5000); + assert.equal(result.activities[0].configVersion, 'test-v1'); assert.equal(table(M.TABLES.players).length, 0); +}); + +test('migration defaults to dry run, preserves snapshots and is repeatable', async () => { + await S.info('u', {}); const before = copy(player()); tables.activityConfigs = []; + const old = { ...config(), _id: config().periodId, publishedAt: START - 1 }; table('pyramid_periods').push(old); + const preview = await migrate(db); assert.equal(preview.writes, 1); assert.equal(preview.dryRun, true); + assert.equal(table('activityConfigs').length, 0); assert.deepEqual(player(), before); + assert.equal((await migrate(db, { apply: true })).writes, 1); assert.deepEqual(table('activityConfigs')[0], version()); + assert.equal((await migrate(db, { apply: true })).writes, 0); assert.deepEqual(table('pyramid_periods'), [old]); + assert.deepEqual(player(), before); table('users').push({ ...table('users')[0], _id: 'new' }); assert.equal((await S.info('new', {})).periodId, config().periodId); +}); + +test('migration validates all version conflicts before making any writes', async () => { + tables.activityConfigs = []; table('pyramid_periods').push({ ...config(), _id: 'one' }, { ...config(), periodId: 'different', _id: 'two' }); + await assert.rejects(() => migrate(db, { apply: true }), /Conflicting pyramid configVersion/); + assert.equal(table('activityConfigs').length, 0); + tables.pyramid_periods = [{ ...config(), _id: 'one' }]; tables.activityConfigs = [version(config(), { enabled: false })]; + await assert.rejects(() => migrate(db, { apply: true }), /Conflicting pyramid configVersion/); + assert.equal(table('activityConfigs')[0].enabled, false); + tables.activityConfigs = []; tables.pyramid_periods = [{ ...config(), configVersion: undefined }]; + await assert.rejects(() => migrate(db, { apply: true }), /Invalid legacy period/); assert.equal(table('activityConfigs').length, 0); +}); + +test('delivery ledger is JSON text and duplicate concurrent saves add inventory once', async () => { + await pay(await buy()); const { data: { grant: g } } = await api('draw', { level: 1, requestId: 'json' }); + const prepared = await S.saveDelivery('u', { grantId: g.grantId, phase: 'prepare' }), next = { ...prepared.inventory }; + for (const item of g.goods) next[M.FIELDS[item.id]] += item.count; + const request = { grantId: g.grantId, phase: 'save', base: prepared.inventory, inventory: next, calculatedAt: now }; + const results = await Promise.all([S.saveDelivery('u', request), S.saveDelivery('u', request)]); + assert.ok(results.every(r => r.saved)); const user = table('users')[0]; assert.equal(typeof user.pyramidDeliveries, 'string'); + assert.ok(JSON.parse(user.pyramidDeliveries)[g.grantId]); assert.deepEqual(M.inventory(user), next); + assert.equal((await S.saveDelivery('u', { grantId: g.grantId, phase: 'prepare' })).saved, true); + await S.confirm('u', { grantId: g.grantId }); +}); + +test('legacy object receipts remain readable and are preserved on the next JSON write', async () => { + await pay(await buy()); const { data: { grant: g } } = await api('draw', { level: 1, requestId: 'legacy' }); + await deliver(g); const user = table('users')[0], existing = JSON.parse(user.pyramidDeliveries), balance = M.inventory(user); + user.pyramidDeliveries = copy(existing); + assert.equal((await S.saveDelivery('u', { grantId: g.grantId, phase: 'prepare' })).saved, true); + await S.confirm('u', { grantId: g.grantId }); assert.deepEqual(M.inventory(user), balance); + const p = player(), reward = p.rewards.find(r => !r.gain), next = M.grant(p, [reward.rewardId], 'draw', now); + await deliver(next); assert.equal(typeof user.pyramidDeliveries, 'string'); + const stored = JSON.parse(user.pyramidDeliveries); assert.deepEqual(stored[g.grantId], existing[g.grantId]); assert.ok(stored[next.grantId]); +}); + +test('another receipt written concurrently forces retry even when touched inventory is unchanged', async () => { + await pay(await buy()); const { data: { grant: g } } = await api('draw', { level: 1, requestId: 'race' }); + const user = table('users')[0], other = { periodId: 'old', savedAt: now, inventory: { freezeAmount: 1 } }; + const before = M.inventory(user); + beforeWrite = (name) => { if (name === 'users') { beforeWrite = null; user.pyramidDeliveries = JSON.stringify({ other }); } }; + await assert.rejects(() => deliver(g), e => e.errorCode === 'INVENTORY_CHANGED'); assert.deepEqual(M.inventory(user), before); + await deliver(g); const stored = JSON.parse(user.pyramidDeliveries); assert.deepEqual(stored.other, other); assert.ok(stored[g.grantId]); +}); + +test('invalid receipt JSON blocks saving and confirmation without changing inventory', async () => { + await pay(await buy()); const { data: { grant: g } } = await api('draw', { level: 1, requestId: 'invalid-json' }); + const user = table('users')[0], before = M.inventory(user); + for (const raw of ['{bad', '[]', 'null']) { + user.pyramidDeliveries = raw; + await assert.rejects(() => S.saveDelivery('u', { grantId: g.grantId, phase: 'prepare' }), e => e.errorCode === 'INVALID_DELIVERY_RECEIPTS'); + await assert.rejects(() => S.confirm('u', { grantId: g.grantId }), e => e.errorCode === 'INVALID_DELIVERY_RECEIPTS'); + assert.deepEqual(M.inventory(user), before); assert.equal(user.pyramidDeliveries, raw); + } +}); + +test('schedule is read only, unlocks after 50, and details create exactly one participation', async () => { + const user = table('users')[0]; user.levelAmount = 49; + let r = await unifiedList({ body: { uid: 123, token: 't' } }); assert.deepEqual(r.data.activities, []); + assert.equal((await api('info')).errorCode, 'LEVEL_LOCKED'); + user.levelAmount = 50; + beforeWrite = () => { throw Error('schedule must not write'); }; + r = await unifiedList({ body: { uid: 123, token: 't' } }); assert.equal(r.code, 1); + assert.deepEqual(Object.keys(pyramidEntry(r)).sort(), ['activityId', 'periodId', 'configVersion', 'startsAt', 'endsAt', 'unlockLevel', 'phase'].sort()); + assert.equal(table(M.TABLES.players).length, 0); assert.equal(table('pyramid_assignments').length, 0); + beforeWrite = null; + const detail = await api('info'); assert.equal(detail.code, 1); assert.equal(detail.data.currentLevel, 1); assert.ok(detail.data.levels.length); + await api('info'); assert.equal(table(M.TABLES.players).length, 1); + player().completed = true; + r = await unifiedList({ body: { uid: 123, token: 't' } }); assert.equal(visibleActivities(r).length, 0); + assert.equal((await api('info')).data.completed, true); +}); +test('eligibility errors are Chinese detail responses and never leak into the activity list', async () => { + const cfg = offerConfig(); cfg.audience = ['another-user']; tables.activityConfigs = [version(cfg)]; + const user = table('users')[0]; user.cloudRisePeriod = { periodId: 'cloud-active', configVersion: 'v', startsAt: START, endsAt: cfg.endAt, unlockLevel: 50 }; + assert.equal((await unifiedList({ body: { uid: 123, token: 'bad' } })).code, 0); + let r = await unifiedList({ body: { uid: 123, token: 't' } }); assert.equal(r.code, 1); + assert.deepEqual(visibleActivities(r).map(a => a.activityId), ['cloudRise']); + let detail = await api('info'); assert.equal(detail.errorCode, 'AUDIENCE_EXCLUDED'); assert.match(detail.msg, /本期活动/); + cfg.audience = []; tables.activityConfigs = [version(cfg)]; delete user.pay_user; + r = await unifiedList({ body: { uid: 123, token: 't' } }); assert.ok(pyramidEntry(r)); + detail = await api('info'); assert.equal(detail.errorCode, 'PROFILE_UNAVAILABLE'); assert.match(detail.msg, /[\u4e00-\u9fff]/); + now = cfg.endAt - 2 * 3600000 + 1; + r = await unifiedList({ body: { uid: 123, token: 't' } }); assert.ok(pyramidEntry(r)); + detail = await api('info'); assert.equal(detail.errorCode, 'PROFILE_UNAVAILABLE'); assert.match(detail.msg, /[\u4e00-\u9fff]/); + for (const key of ['pyramid', 'pyramidError', 'pyramidErrorMessage']) assert.equal(key in pyramidEntry(r), false); + assert.equal(table(M.TABLES.players).length, 0); +}); +test('only the authenticated owner can query historical details; schedule queries never settle', async () => { + await api('info'); now = END; + beforeWrite = () => { throw Error('schedule must not settle'); }; + const r = await unifiedList({ body: { uid: 123, token: 't', pyramidPeriodId: config().periodId } }); + assert.equal(r.code, 1); assert.equal(r.data.activities.length, 0); assert.equal(player().settledAt, null); + beforeWrite = null; + assert.equal((await api('info')).data.status, 'ended'); assert.equal(player().settledAt, now); + table('users').push({ _id: 'other', onlyId: 124, token: 'other-token', levelAmount: 50 }); + const other = await api('info', { uid: 'other', token: 'other-token' }); assert.equal(other.code, 1); assert.equal(other.data, null); + assert.equal(table(M.TABLES.players).length, 1); +}); + +test('empty audience opens active offers to new and old accounts; nonempty audience restricts both list and orders', async () => { + for (const audience of [[], ['u'], ['someone-else']]) { + for (const age of [1, 90]) { + reset(); const cfg = offerConfig(); cfg.audience = audience; tables.activityConfigs = [version(cfg)]; + table('users')[0].register_time = now - age * DAY; + const included = audience.length === 0 || audience.includes('u'); + const listing = await unifiedList({ body: { uid: 123, token: 't' } }); + assert.equal(listing.code, 1); + assert.equal(visibleActivities(listing).some(a => a.activityId === 'pyramid'), included); + assert.equal(table(M.TABLES.players).length, 0); + const detail = await api('info'); + if (included) assert.equal(detail.data.offerTier, 1); else assert.equal(detail.errorCode, 'AUDIENCE_EXCLUDED'); + tables.pyramid_players = []; tables.pyramid_assignments = []; + const level = M.offerLevels(cfg.offer, 1)[1]; + const order = await P.createOrder(orderBody({ itemid: level.itemId, itemCount: 1, level: 2, requestId: 'audience-order' }), 'and'); + // Included accounts reach normal level validation; excluded accounts cannot even create a player. + assert.equal(order.errorCode, included ? 'LEVEL_MISMATCH' : 'AUDIENCE_EXCLUDED'); + assert.equal(table(M.TABLES.players).length, included ? 1 : 0); + } + } +}); + +test('empty audience exposes upcoming schedules without creating players and preserves other gates', async () => { + const cfg = offerConfig(); cfg.audience = []; cfg.startAt = now + 60000; tables.activityConfigs = [version(cfg, { effectiveFrom: cfg.startAt })]; + for (const user of [null, table('users')[0]]) { + const result = await M.pyramidActivities(now, now + 7 * DAY, user); + assert.equal(result.activities[0].phase, 'upcoming'); assert.equal('pyramid' in result.activities[0], false); + } + assert.equal(table(M.TABLES.players).length, 0); + cfg.audience = ['someone-else']; tables.activityConfigs = [version(cfg, { effectiveFrom: cfg.startAt })]; + assert.equal((await M.pyramidActivities(now, now + 7 * DAY, table('users')[0])).activities.length, 0); + cfg.audience = []; cfg.startAt = START; tables.activityConfigs = [version(cfg, { enabled: false })]; + assert.equal((await unifiedList({ body: { uid: 123, token: 't' } })).data.activities.length, 0); + tables.activityConfigs = [version(cfg)]; table('users')[0].levelAmount = 49; + assert.equal((await unifiedList({ body: { uid: 123, token: 't' } })).data.activities.length, 0); + assert.equal(table(M.TABLES.players).length, 0); +}); + +test('all pyramid business error codes have Chinese messages and keep their codes', () => { + const source = readFileSync(new URL('../api.ts', import.meta.url), 'utf8'); + const codes = new Set([...source.matchAll(/fail\([^;\n]+/g)].flatMap(m => [...m[0].matchAll(/"([A-Z][A-Z_]+)"/g)].map(x => x[1]))); + for (const code of codes) { + assert.ok(Object.hasOwn(M.ERROR_MESSAGES, code), code); + assert.throws(() => M.fail(code), error => { + const result = M.failure(error); + assert.equal(result.errorCode, code); assert.equal(result.code, 0); assert.equal(result.data, null); + assert.match(result.msg, /[\u4e00-\u9fff]/); assert.notEqual(result.msg, code); return true; + }); + assert.equal(M.failure({ errorCode: code, message: code }).msg, M.ERROR_MESSAGES[code]); + } + assert.ok(codes.size > 25); + assert.equal(M.failure({ errorCode: 'PROFILE_UNAVAILABLE', message: '支付记录存在冲突' }).msg, '支付记录存在冲突'); + for (const error of [null, Error('database connection failed'), { errorCode: 'UNKNOWN', message: 'UNKNOWN' }]) + assert.equal(M.failure(error).msg, M.ERROR_MESSAGES.RETRYABLE); +}); +test('public actions, admin and both payment platforms return Chinese errors', async () => { + for (const action of ['info', 'draw', 'claim', 'query', 'delivery_list', 'delivery_save', 'delivery_confirm', 'price_detail_view', 'admin', 'unknown']) { + const result = await M.default({ body: { uid: 'u', token: 'invalid', action } }); + assert.equal(result.code, 0); assert.match(result.msg, /[\u4e00-\u9fff]/); assert.ok(result.errorCode); + } + for (const platform of ['and', 'ios']) { + const result = await M.createOrder({ uid: 'u', token: 'invalid' }, platform); + assert.equal(result.errorCode, 'AUTH_INVALID'); assert.equal(result.msg, M.ERROR_MESSAGES.AUTH_INVALID); + } +}); + +test('settlement reads report pending preparation and become ready after the job without relogin', async () => { + await S.ensure('u', config()); M.grant(player(), ['l1a'], 'draw', now); + assert.equal((await api('settlement_read')).data.status, 'ready'); now = END; + const pending = await api('settlement_read'); assert.equal(pending.data.status, 'settling'); assert.deepEqual(pending.data.notices, []); + assert.equal(table('users')[0].coinAmount, 100); assert.equal(player().settlementPreparedAt, null); + await S.runJobs(); const ready = await api('settlement_read'); assert.equal(ready.data.status, 'ready'); assert.equal(ready.data.notices.length, 1); + assert.equal(table('users')[0].coinAmount, 100); + await claimNotice(); + const again = await api('settlement_read'); assert.equal(again.data.status, 'ready'); assert.deepEqual(again.data.notices, []); + assert.equal(table('users')[0].coinAmount, 120); +}); +test('empty expired participation stops settlement retries after the job prepares it', async () => { + await S.ensure('u', config()); now = END; assert.equal((await api('settlement_read')).data.status, 'settling'); + await S.runJobs(); const result = await api('settlement_read'); assert.equal(result.data.status, 'ready'); assert.deepEqual(result.data.notices, []); });