import test from 'node:test' import assert from 'node:assert/strict' import {createHash} from 'node:crypto' import {readFileSync} from 'node:fs' import {prepareTaxCustoms,verifyTaxDocumentsAfterCompletion,taxDocumentMatches} from './phase5-root-tax-customs.mjs' const loading={tenantId:'90',orderId:'100',inboundId:'200',loadingPlanId:'300',containerBookingId:'400',inboundCode:'IN-TAX',containerNo:'TAX-BOX'} function fixture(){ const calls=[],files={},evidence=[],view={version:0,status:'READY_TO_START',loadingPlanId:'300',containerBookingId:'400', inbounds:[{inboundId:'200',taxMode:'TAX_REFUND'}],documents:[]} const wrap=data=>({http:200,body:{code:'200',data:structuredClone(data)}}) const context={marker:'OFFLINE-TAX',token:'platform',createdResources:{},context:{f40TaxMerchantToken:'customer',f40TaxEvidence:evidence}, assert:(_m,name,_r,passed)=>{assert.equal(Boolean(passed),true,name)},apiSuccess:(_m,_n,r)=>r.body?.data, request:async(method,path,body,token)=>{ calls.push({method,path,body,token}) if(path==='/api/merchant/v1/orders/100')return wrap({summary:{orderId:'100',mainStatus:'COMPLETED',taxMode:'TAX_REFUND'}}) if(method==='GET')return wrap(view) if(path.endsWith('/start')){view.status='DECLARING';view.documents[0].frozen=true} if(path.endsWith('/receipt/confirm'))view.documents[0].receiptStatus='CONFIRMED' if(path.endsWith('/declaration-batches'))return wrap({id:'500',version:0,status:'READY'}) if(path.endsWith('/500/submit'))return wrap({version:1,status:'SUBMITTED'}) if(path.endsWith('/500/results'))return wrap({resultType:'RELEASED',resultStatus:'RESOLVED'}) view.version++;return wrap(view) }, requestMultipart:async(_method,path,file,token)=>{ calls.push({method:'UPLOAD',path,token}) const kind=path.includes('/receipt/')?'receipt':path.includes('/final/')?'final':'source';files[kind]=file.content const metadata={fileHash:createHash('sha256').update(file.content).digest('hex'),version:1} if(kind==='source')view.documents.push({documentId:'600',tenantId:'90',inboundIds:['200'],source:metadata,frozen:false}) else view.documents[0][kind==='final'?'finalDocument':kind]=metadata if(kind==='final')view.status='COMPLETED' return wrap(view.documents[0]) }, async requestBinary(_method,path){assert.equal(this.token,'customer');const kind=path.includes('/receipt/')?'receipt':path.includes('/final/')?'final':'source';return {http:200,bytes:files[kind]}}, } return {context,calls,files,evidence,view} } test('tax F40 uses actual upload confirm declaration release and final before post-COMPLETED customer downloads',async()=>{ const f=fixture();const proof=await prepareTaxCustoms(f.context,loading) assert.equal(proof.status,'CUSTOMS_COMPLETED') await verifyTaxDocumentsAfterCompletion(f.context,f.evidence,'customer') assert.equal(proof.status,'ORDER_COMPLETED_CUSTOMS_READ_DOWNLOAD_VERIFIED') assert.deepEqual(proof.downloads.map(x=>x.kind),['source','receipt','final']) const paths=f.calls.filter(c=>c.method!=='GET').map(c=>c.path) let last=-1 for(const suffix of ['/documents/upload?inboundIds=200','/start','/send-to-broker','/receipt/upload?expectedVersion=0', '/receipt/confirm','/submit-to-broker','/declaration-batches','/500/submit','/500/results','/final/upload?expectedVersion=0']){ const index=paths.findIndex((p,i)=>i>last&&p.endsWith(suffix));assert.ok(index>last,suffix);last=index } assert.equal(f.calls.find(c=>c.path.endsWith('/receipt/confirm')).token,'customer') assert.equal(JSON.stringify(proof).includes('customer'),true) // customerId evidence, never session token field assert.equal(Object.hasOwn(proof,'token'),false) assert.doesNotMatch(JSON.stringify(proof),/"(?:bytes|password|accessToken|refreshToken|authorization)"\s*:/i) }) test('customer tax evidence rejects changed customer, source, plan, file bytes and still-uncompleted order',async()=>{ const f=fixture(),proof=await prepareTaxCustoms(f.context,loading) for(const mutate of [v=>v.status='DECLARING',v=>v.loadingPlanId='999',v=>v.documents[0].tenantId='91', v=>v.documents[0].inboundIds=['201'],v=>v.documents[0].finalDocument.fileHash='changed']){ const v=structuredClone(f.view);mutate(v);assert.equal(taxDocumentMatches(v,proof),false) } f.files.final=new TextEncoder().encode('incorrect bytes') await assert.rejects(verifyTaxDocumentsAfterCompletion(f.context,[proof],'customer'),/final字节/) }) test('buy remains the default; tax path is explicit and final verification occurs after Outbox proof',()=>{ const source=readFileSync(new URL('./phase5-root-fulfillment.mjs',import.meta.url),'utf8') assert.match(source,/f40TaxMode\|\|'BUY_DECLARATION'/) assert.match(source,/f40TaxMode==='TAX_REFUND'\) await prepareTaxCustoms/) assert.match(source,/else await stage\(context,'phase3-customs'\)/) assert.ok(source.indexOf('await verifyTaxDocumentsAfterCompletion')>source.indexOf('proof.outboxProof =')) })