Files
web_hgcg_h5/tests/payment.test.mjs
T
谢宇宁andClaude Opus 5.5 8cdbfee08f Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:23:48 +07:00

191 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import ts from 'typescript';
async function moduleUrl(file,replace=code=>code){
const filename=new URL(file,import.meta.url);
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)));
const {paymentDialogMarkup,renderWallet}=await import(accountUrl);
const coinPackage={id:31,name:'金币套餐',packageType:2,price:10000,baseCoin:100,giftCoin:999,totalCoin:1099,payMethods:[{type:'alipay',typeName:'支付宝'},{type:'wechat',typeName:'微信'}]};
const flush=()=>new Promise(resolve=>setImmediate(resolve));
test('confirmation displays actual base/gift/total coins and the price in yuan with original payment icons',()=>{
const html=paymentDialogMarkup(coinPackage,false);
assert.ok(html.includes('<strong>100 金币</strong>'));assert.ok(html.includes('赠送 999 金币 · 合计 1099 金币'));
assert.ok(html.includes('<b>¥100</b>'));assert.ok(html.includes('5652fcead17188e8.svg'));assert.ok(html.includes('2bead9ea5c435c98.svg'));
assert.ok(!html.includes('7848816d05aa8591.svg'));assert.ok(!html.includes('type="radio"'));
assert.ok(html.includes('name="payMode" value="alipay"'));assert.ok(html.includes('class="wallet-payment-title"'));
const derived=paymentDialogMarkup({...coinPackage,baseCoin:undefined,price:12345},false);
assert.ok(derived.includes('<strong>100 金币</strong>'));assert.ok(derived.includes('<b>¥123.45</b>'));
});
test('VIP and missing payment data never introduce demo bonuses or unconfigured channels',()=>{
const vip=paymentDialogMarkup({id:7,name:'月卡 <script>',packageType:1,price:3000},true);
assert.ok(vip.includes('会员畅享'));assert.ok(vip.includes('月卡 &lt;script&gt;'));assert.ok(!vip.includes('赠送 999'));
assert.ok(vip.includes('暂无可用支付方式'));assert.match(vip,/<button class="wallet-submit wallet-payment-go" type="submit" disabled>/);
assert.ok(!vip.includes('data-wallet-method='));
const unknown=paymentDialogMarkup({...coinPackage,payMethods:[null,{type:'',typeName:'空'},{type:'union',typeName:'银联 <img>'},{type:'union',typeName:'重复'}]},false);
assert.equal((unknown.match(/data-wallet-method=/g)||[]).length,1);assert.ok(unknown.includes('银联 &lt;img&gt;'));assert.ok(unknown.includes('wallet-pay-logo--other'));
});
class Element extends EventTarget {
dataset={};attrs=new Map();nodes=new Map();lists=new Map();innerHTML='';textContent='';disabled=false;isConnected=true;value='';
querySelector(selector){return this.nodes.get(selector)||null;}
querySelectorAll(selector){return this.lists.get(selector)||[];}
setAttribute(key,value){this.attrs.set(key,String(value));}
getAttribute(key){return this.attrs.get(key)||null;}
closest(){return this;}
reportValidity(){return true;}
}
function emit(element,type,target=element){const event=new Event(type,{cancelable:true});Object.defineProperty(event,'target',{value:target});element.dispatchEvent(event);}
async function fixture({vip=false,post=async()=>({payUrl:'https://pay.example/checkout',tradeNo:'order-1'}),methods=coinPackage.payMethods,packages,transactions={list:[],totalCount:0},transactionError=false,balance=90,openDialog=true,popupBlocked=false}={}){
const oldDocument=globalThis.document,OldFormData=globalThis.FormData,oldWindow=globalThis.window;
const controller=new AbortController(),root=new Element(),dialog=new Element(),form=new Element(),hidden=new Element(),confirm=new Element(),error=new Element(),refresh=new Element();
const item={...coinPackage,payMethods:methods};hidden.value=methods[0]?.type||'';
const items=packages??[item],pay=new Element(),selection=new Element();pay.dataset.walletAction='pay';
const packageButtons=items.map((item,index)=>{const button=new Element();button.dataset.buyPackage=String(item.id);button.setAttribute('aria-pressed',String(index===0));return button;});
root.nodes.set('[data-wallet-action="pay"]',pay);root.nodes.set('#wallet-selection',selection);root.lists.set('[data-buy-package]',packageButtons);
const choices=methods.map(method=>{const button=new Element();button.dataset.walletMethod=method.type;button.nodes.set('.wallet-method-check',new Element());return button;});
form.nodes.set('[name="payMode"]',hidden);form.nodes.set('[type="submit"]',confirm);form.nodes.set('.api-error',error);form.nodes.set('[data-payment-refresh]',refresh);form.lists.set('[data-wallet-method]',choices);
dialog.nodes.set('form',form);dialog.open=false;dialog.showModal=()=>dialog.open=true;dialog.close=()=>dialog.open=false;
const posts=[],popups=[],redirects=[];let refreshes=0,routes=0;
const app={signal:controller.signal,api:{user:{recharge:balance},get:async path=>{if(path==='recharge/package/list')return {coinList:items,vipList:items};if(transactionError)throw new Error('offline');return transactions;},post:async(path,body)=>{posts.push({path,body});return post();},refreshUser:async()=>refreshes++},notify(){},async route(){routes++;}};
globalThis.document={getElementById:id=>id==='info-content'?root:dialog};
globalThis.window={open(url,target){
if(popupBlocked)return null;
const popup={url,target,closed:false,opener:{},document:{title:'',body:{textContent:''}},location:{replace(value){popup.url=value;}},close(){popup.closed=true;}};
popups.push(popup);return popup;
},location:{assign(url){redirects.push(url);}}};
globalThis.FormData=class extends OldFormData{constructor(form){super();this.append('payMode',form.querySelector('[name="payMode"]').value);}};
await renderWallet(app,vip,1,()=>'/info/deposit/');
if(openDialog)emit(root,'click',pay);
return {app,root,dialog,form,hidden,confirm,error,refresh,choices,posts,pay,selection,packageButtons,popups,redirects,controller,get refreshes(){return refreshes;},get routes(){return routes;},dispose(){controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;globalThis.window=oldWindow;}};
}
test('recharge page uses the HTML balance, package and transaction layout with actual API values',async()=>{
const f=await fixture({openDialog:false,balance:2400,packages:[{...coinPackage,baseCoin:250,giftCoin:25,totalCoin:275,price:12345}],transactions:{list:[
{title:'解锁 <短剧>',createdAt:'2026-10-20T13:24:00+08:00',flowDir:-1,amount:30,changeAmount:0},
{tranTypeName:'充值到账',createdAt:'2026-10-20 12:00:00',flowDir:1,amount:100,changeAmount:125},
{remark:'余额校验',flowDir:1,amount:0,coin:999},
],totalCount:3}});
try{
const html=f.root.innerHTML;
for(const className of ['wallet-balance-card','wallet-balance-summary','wallet-usage','wallet-pack-panel','wallet-pack-heading','wallet-package-coin','wallet-double-badge','wallet-mobile-selected','wallet-pay-area','wallet-transaction-list'])assert.ok(html.includes(className),className);
assert.ok(html.includes('<strong>2,400</strong><b>金币</b>'));assert.ok(html.includes('>250金币</span>'));assert.ok(html.includes('>赠送 25金币</span>'));assert.ok(html.includes('立即支付 ¥123.45'));
assert.ok(html.includes('已选择 250金币 + 赠送25金币'));assert.ok(html.includes('aria-pressed="true"'));
assert.ok(html.includes('解锁 &lt;短剧&gt;'));assert.ok(html.includes('2026/10/20 13:24'));assert.ok(html.includes('datetime="2026-10-20T05:24:00.000Z"'));
assert.ok(html.includes('expense">−30</span>'));assert.ok(html.includes('income">+125</span>'));assert.ok(html.includes('income">0</span>'));
assert.ok(html.includes('href="/info/feedback/"'));assert.ok(!html.includes('模拟'));assert.ok(!html.includes('首次充值赠送10%'));assert.ok(!html.includes('1,280'));
assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
}finally{f.dispose();}
});
test('package click only updates selection and the pay action confirms the selected package ID',async()=>{
const second={...coinPackage,id:84,baseCoin:300,giftCoin:0,totalCoin:300,price:4599};
const f=await fixture({openDialog:false,packages:[coinPackage,second]});
try{
emit(f.root,'click',f.packageButtons[1]);
assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
assert.equal(f.packageButtons[0].getAttribute('aria-pressed'),'false');assert.equal(f.packageButtons[1].getAttribute('aria-pressed'),'true');
assert.equal(f.pay.textContent,'立即支付 ¥45.99');assert.equal(f.selection.textContent,'已选择 300金币 · 点击后选择支付方式');
emit(f.root,'click',f.pay);assert.equal(f.dialog.open,true);assert.ok(f.dialog.innerHTML.includes('<strong>300 金币</strong>'));assert.ok(f.dialog.innerHTML.includes('<b>¥45.99</b>'));
emit(f.form,'submit');await flush();assert.deepEqual(f.posts,[{path:'coin/buy',body:{packageId:84,payMode:'alipay'}}]);
}finally{f.dispose();}
});
test('empty packages and unavailable transactions show empty states without sample data or an order',async()=>{
const f=await fixture({openDialog:false,packages:[],transactionError:true,balance:0});
try{
assert.ok(f.root.innerHTML.includes('<strong>0</strong><b>金币</b>'));assert.ok(f.root.innerHTML.includes('暂无可购买套餐'));assert.ok(f.root.innerHTML.includes('余额明细暂不可用,请稍后刷新'));
assert.match(f.root.innerHTML, /data-wallet-action="pay" disabled/);assert.ok(!f.root.innerHTML.includes('data-buy-package='));assert.ok(!f.root.innerHTML.includes('wallet-transaction-row'));
emit(f.root,'click',f.pay);assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
}finally{f.dispose();}
});
test('selecting a styled method updates the actual order payload and duplicate submission is blocked',async()=>{
let finish;const f=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
try{
assert.equal(f.dialog.open,true);assert.equal(f.posts.length,0);
emit(f.form,'click',f.choices[1]);assert.equal(f.hidden.value,'wechat');assert.equal(f.choices[1].getAttribute('aria-pressed'),'true');assert.equal(f.choices[1].querySelector('.wallet-method-check').textContent,'✓');
emit(f.form,'submit');emit(f.form,'submit');assert.deepEqual(f.posts,[{path:'coin/buy',body:{packageId:31,payMode:'wechat'}}]);
assert.equal(f.popups.length,1);assert.equal(f.popups[0].url,'about:blank');assert.equal(f.popups[0].target,'_blank');assert.equal(f.popups[0].opener,null);
assert.equal(f.confirm.disabled,true);emit(f.form,'click',f.choices[0]);assert.equal(f.hidden.value,'wechat');
finish({payUrl:'https://pay.example/checkout',tradeNo:'order-1'});await flush();
assert.equal(f.popups[0].url,'https://pay.example/checkout');assert.equal(f.popups[0].closed,false);assert.deepEqual(f.redirects,[]);
assert.match(f.form.innerHTML,/<a class="primary wallet-payment-link" href="https:\/\/pay\.example\/checkout" target="_blank" rel="sponsored nofollow noopener noreferrer">打开支付页面<\/a>/);assert.ok(f.form.innerHTML.includes('我已完成支付'));assert.equal(f.refreshes,0);assert.equal(f.app.api.user.recharge,90);
emit(f.refresh,'click');await flush();assert.equal(f.refreshes,1);assert.equal(f.dialog.open,false);assert.equal(f.routes,1);
f.controller.abort();assert.equal(f.popups[0].closed,false,'completed payment tabs remain open when leaving the wallet');
}finally{f.dispose();}
});
test('VIP uses its existing endpoint and an invalid payment URL leaves the form available to retry',async()=>{
const f=await fixture({vip:true,post:async()=>({payUrl:'javascript:alert(1)'})});
try{
emit(f.form,'submit');await flush();assert.deepEqual(f.posts,[{path:'vip/pay',body:{packageId:31,payMode:'alipay'}}]);
assert.match(f.error.textContent,/有效地址/);assert.equal(f.confirm.disabled,false);assert.equal(f.choices[0].disabled,false);assert.equal(f.refreshes,0);
assert.equal(f.popups[0].closed,true);assert.equal(f.popups[0].url,'about:blank');assert.deepEqual(f.redirects,[]);
assert.ok(!f.form.innerHTML.includes('javascript:'));
}finally{f.dispose();}
});
test('missing methods cannot create an order and a replaced form ignores late order responses',async()=>{
const empty=await fixture({methods:[]});
try{emit(empty.form,'submit');assert.equal(empty.posts.length,0);assert.equal(empty.popups.length,0);}finally{empty.dispose();}
let finish;const late=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
try{
emit(late.form,'submit');late.form.isConnected=false;finish({payUrl:'https://pay.example',tradeNo:'old-order'});await flush();
assert.equal(late.form.innerHTML,'');assert.equal(late.refreshes,0);
assert.equal(late.popups[0].closed,true);assert.equal(late.popups[0].url,'about:blank');assert.deepEqual(late.redirects,[]);
}finally{late.dispose();}
});
test('VIP payment opens the returned URL automatically and keeps a link to reopen the order',async()=>{
const f=await fixture({vip:true});
try{
emit(f.form,'submit');await flush();
assert.deepEqual(f.posts,[{path:'vip/pay',body:{packageId:31,payMode:'alipay'}}]);
assert.equal(f.popups.length,1);assert.equal(f.popups[0].url,'https://pay.example/checkout');
assert.ok(f.form.innerHTML.includes('打开支付页面'));assert.ok(f.form.innerHTML.includes('href="https://pay.example/checkout"'));assert.equal(f.refreshes,0);
}finally{f.dispose();}
});
test('a blocked payment popup automatically continues in the current page after order creation',async()=>{
let finish;const f=await fixture({popupBlocked:true,post:()=>new Promise(resolve=>finish=resolve)});
try{
emit(f.form,'submit');assert.deepEqual(f.redirects,[]);
finish({payUrl:'https://pay.example/checkout',tradeNo:'order-1'});await flush();
assert.deepEqual(f.redirects,['https://pay.example/checkout']);assert.equal(f.posts.length,1);
assert.ok(f.form.innerHTML.includes('打开支付页面'));
}finally{f.dispose();}
});
test('failed orders close their reserved tab and can retry with a fresh tab',async()=>{
let attempts=0;const f=await fixture({post:async()=>{
if(!attempts++)throw new Error('下单失败');
return {payUrl:'https://pay.example/retry',tradeNo:'order-2'};
}});
try{
emit(f.form,'submit');await flush();
assert.equal(f.popups[0].closed,true);assert.equal(f.error.textContent,'下单失败');assert.equal(f.confirm.disabled,false);
emit(f.form,'submit');await flush();
assert.equal(f.posts.length,2);assert.equal(f.popups.length,2);assert.equal(f.popups[1].url,'https://pay.example/retry');assert.equal(f.popups[1].closed,false);
}finally{f.dispose();}
});
test('leaving the wallet closes its pending tab and ignores a late payment URL',async()=>{
let finish;const f=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
try{
emit(f.form,'submit');f.controller.abort();assert.equal(f.popups[0].closed,true);
finish({payUrl:'https://pay.example/late',tradeNo:'old-order'});await flush();
assert.equal(f.popups[0].url,'about:blank');assert.deepEqual(f.redirects,[]);assert.equal(f.form.innerHTML,'');
}finally{f.dispose();}
});