2026-08-25 00:24:43 +08:00
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
2026-10-22 14:37:59 +08:00
import { verifyDiscardApproval , submitClientTransfer } from './phase4-overseas-client-services.mjs'
2026-09-07 21:26:03 +08:00
import { parseArguments } from '../admin-test-env-acceptance.mjs'
2026-08-25 00:24:43 +08:00
const source = await readFile ( fileURLToPath ( new URL ( './phase4-overseas-client-services.mjs' , import . meta . url )), 'utf8' )
2026-10-22 14:37:59 +08:00
for ( const fault of [ '' , 'network' , 'server' , 'rejected' , 'wrong order' , 'wrong status' , 'throws' ]) {
test ( `transfer submit sends once and stops on uncertain result: ${ fault || 'success' } ` , async () => {
const calls = []
const context = { marker : 'batch' , createdResources : { phase4OverseasClientServices : {} },
apiSuccess ( _m , _n , response ) { return response ? . body ? . data },
async request ( method , path , body , token ) {
calls . push ({ method , path , body , token })
if ( fault === 'throws' ) throw new Error ( 'connection closed' )
return { http : fault === 'network' ? 0 : fault === 'server' ? 503 : 200 ,
requestId : 'request' , traceId : 'trace' , body : { code : fault === 'rejected' ? 'A0409' : '200' ,
data : { transferNo : fault === 'wrong order' ? 'OTHER' : 'WT-1' ,
status : fault === 'wrong status' ? 'DRAFT' : 'PENDING_REVIEW' } } }
} }
if ( fault ) await assert . rejects ( submitClientTransfer ( context , 'merchant' , 'WT-1' ))
else assert . equal (( await submitClientTransfer ( context , 'merchant' , 'WT-1' )). status , 'PENDING_REVIEW' )
assert . equal ( calls . length , 1 )
assert . equal ( calls [ 0 ]. method , 'POST' )
assert . equal ( calls [ 0 ]. path , '/api/merchant/v1/last-leg/warehouse-transfers/WT-1/submit' )
const proof = context . createdResources . phase4OverseasClientServices . transferSubmission
assert . equal ( proof . status , ! fault ? 'PENDING_REVIEW' : [ 'network' , 'server' , 'throws' ]. includes ( fault )
? 'UNKNOWN_RESULT' : fault === 'rejected' ? 'REJECTED' : 'UNEXPECTED_RESULT' )
assert . equal ( JSON . stringify ( proof ). includes ( 'merchant' ), false )
await assert . rejects ( submitClientTransfer ( context , 'merchant' , 'WT-1' ), /禁止重复提交/ )
assert . equal ( calls . length , 1 )
})
}
2026-10-08 17:07:57 +08:00
test ( 'discard proof follows reserved approval and keeps actual PDA confirmation separate' , () => {
assert . match ( source , /Approval retains the existing reservation without deducting stock/ )
assert . match ( source , /Actual PDA confirmation deducts the confirmed quantity/ )
assert . doesNotMatch ( source , /require approval-transaction deduction/ )
assert . match ( source , /completed-confirm recovery\/replay/ )
})
2026-08-25 00:24:43 +08:00
test ( 'client services module uses only stable merchant self-service paths' , () => {
for ( const path of [
'/api/merchant/v1/last-leg/orders' ,
'/api/merchant/v1/last-leg/relabel-applications' ,
'/api/merchant/v1/last-leg/warehouse-transfers' ,
'/api/merchant/v1/last-leg/cargo-discards' ,
'inventory-candidates' ,
]) assert . ok ( source . includes ( path ), `missing ${ path } ` )
2026-10-03 06:26:33 +08:00
for ( const forbidden of [ '/api/platform/v1/last-leg/dispatches' , '/outbound' , '/delivery' , '/dispatch' , '/depart' ]) {
2026-08-25 00:24:43 +08:00
assert . equal ( source . includes ( forbidden ), false , `must exclude ${ forbidden } ` )
}
})
test ( 'module never borrows historical order or inventory candidates' , () => {
assert . match ( source , /fixture\?\.marker !== context\.marker/ )
assert . match ( source , /pda\?\.marker !== context\.marker/ )
assert . match ( source , /pda\?\.finalStatus !== 'COMPLETED'/ )
assert . match ( source , /completedContainers/ )
assert . match ( source , /markerOrderIds\.has\(id\(value\?\.orderId\)\)/ )
assert . match ( source , /禁止借历史一件代发库存/ )
assert . doesNotMatch ( source , /candidates\[0\]/ )
})
test ( 'relabel transfer and discard cover create replay conflict list detail and legal initial states' , () => {
2026-09-11 01:50:46 +08:00
assert . match ( source , /deliveryMethod: 'TRUCK', printShippingLabel: false/ )
2026-09-06 12:24:42 +08:00
assert . match ( source , /chargeAmount: 0\.01/ )
2026-09-07 14:03:00 +08:00
assert . match ( source , /cartonMark:/ )
assert . match ( source , /fbaCartonMark:/ )
2026-09-09 21:13:10 +08:00
for ( const field of [
'operationWarehouseId:' , 'sourceInventoryBalanceId:' , 'sourceInventoryPositionId:' , 'sourceInboundOrderId:' ,
'destinationFbaWarehouseCode:' ,
]) assert . match ( source , new RegExp ( field ))
assert . match ( source , /query\(`\$\{RELABEL\}\/inventory-candidates`, \{/ )
2026-08-25 00:24:43 +08:00
for ( const token of [
'客户创建贴标申请' , '客户贴标申请幂等重放' , '贴标同幂等键异载荷返回A0409' ,
2026-09-29 14:18:20 +08:00
'客户查询自身贴标申请列表' , '客户查询自身贴标申请详情' , 'PENDING_REVIEW' ,
2026-08-25 00:24:43 +08:00
'客户创建仓储转运申请' , '客户仓储转运申请幂等重放' , '转运同幂等键异载荷返回A0409' ,
'客户查询自身仓储转运列表' , '客户查询自身仓储转运详情' ,
'客户创建货物废弃申请' , '客户货物废弃申请幂等重放' ,
'客户查询自身货物废弃列表' , '客户查询自身货物废弃详情' , 'PENDING_REVIEW' ,
]) assert . ok ( source . includes ( token ), `missing ${ token } ` )
})
test ( 'customer isolation is exercised with merchant token, forged customerId and platform denial' , () => {
assert . match ( source , /merchantQualificationFixture/ )
2026-09-29 19:48:34 +08:00
assert . match ( source , /accessToken/ )
2026-08-25 00:24:43 +08:00
assert . match ( source , /customerId: 0/ )
2026-09-29 19:48:34 +08:00
assert . match ( source , /平台统一Token不能冒充客户访问/ )
2026-08-25 00:24:43 +08:00
assert . match ( source , /response\?\.http === 403.*codeOf\(response\) === 'A0403'/s)
})
test ( 'module records marker-owned resources without forbidden fulfillment cleanup' , () => {
assert . match ( source , /createdResources\.phase4OverseasClientServices/ )
assert . match ( source , /relabelNo = relabelNo/ )
assert . match ( source , /transferNo = transferNo/ )
assert . match ( source , /discardNo = discardNo/ )
2026-10-22 15:41:50 +08:00
assert . match ( source , /不自动删除业务数据/ )
assert . match ( source , /transfer: transferDone \? context\.createdResources\.phase4OverseasClientServices\.transferFulfillment\?\.status \?\? null : null/ )
2026-10-22 14:37:59 +08:00
assert . match ( source , /await verifyCargoDiscardFulfillment\(context/ )
assert . match ( source , /snapshot: \(\) => discardSnapshot\(context, token, proof, proof.approvedNo\)/ )
2026-08-25 00:24:43 +08:00
})
2026-09-07 21:26:03 +08:00
test ( 'focused client services keeps the marker-owned merchant fixture when reusable credentials exist' , () => {
const parsed = parseArguments ([
'--modules' , 'phase4-overseas-client-services' ,
'--merchant-username' , 'existing-merchant' ,
'--merchant-password' , 'existing-password' ,
])
const fixture = parsed . modules . indexOf ( 'merchant-qualification-fixture' )
const clientServices = parsed . modules . indexOf ( 'phase4-overseas-client-services' )
assert . ok ( fixture >= 0 && fixture < clientServices )
})
2026-10-03 06:26:33 +08:00
2026-10-08 17:07:57 +08:00
test ( 'discard approval retains reservation and keeps actual confirmation evidence pending' , () => {
assert . match ( source , /批准只保留锁箱且库存数量流水不变/ )
2026-10-03 06:26:33 +08:00
assert . match ( source , /废弃拒绝释放原箱且全部库存数量流水不变/ )
assert . match ( source , /APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING/ )
assert . doesNotMatch ( source , /INSERT INTO|UPDATE ll_|fleet-trip|\/api\/pda\// )
})
2026-10-08 17:07:57 +08:00
for ( const fault of [ '' , 'pending deducts' , 'reject does not release' , 'approve deducts' , 'create conflict mutates' ]) {
2026-10-03 06:26:33 +08:00
test ( `discard approval stock boundaries: ${ fault || 'formal approval contract' } ` , async () => {
let state = 'PENDING_REVIEW' , approved = false , conflictSeen = false
const baseline = { balances : [{ customerId : '123' , overseasWarehouseId : '42' , availablePackages : '4' }], ledger : [],
source : { orderId : '9' , overseasWarehouseId : '42' , inboundCode : 'IP' , availablePackages : '4' }, detail : null }
const proof = { sourceOrderId : '9' , customerId : '123' , warehouseId : '42' , inboundCode : 'IP' , snapshots : { before : baseline }}
const calls = []
const context = { marker : 'fixture' , token : 'admin' , addStep (){},
assert ( _m , name , _r , passed ){ assert . ok ( passed , name ); if ( name === '重复批准不会提前扣库存或新增历史' ) throw new Error ( 'CHECKPOINT_PASSED' ) },
apiSuccess ( _m , _n , r ){ assert . equal ( r . body . code , '200' ); return r . body . data },
async request ( method , raw , body ){
calls . push ({ method , raw , body })
const path = new URL ( raw , 'https://offline.invalid' ). pathname
let data
if ( method === 'POST' && path . endsWith ( '/review' )) { state = body . decision === 'REJECT' ? 'REJECTED' : 'APPROVED' ; data = {}}
else if ( method === 'POST' && path . endsWith ( '/cargo-discards' )) {
if ( body . remark . endsWith ( '-DIFFERENT' )) { conflictSeen = true ; return { http : 200 , body : { code : 'A0409' }, requestId : 'req' , traceId : 'trace' }}
approved = true ; state = 'PENDING_REVIEW' ; data = { summary : { discardNo : 'NEW' }}
} else if ( path . endsWith ( '/balances' )) {
2026-10-08 17:07:57 +08:00
const changed = ( state === 'PENDING_REVIEW' && fault === 'pending deducts' ) || ( state === 'APPROVED' && fault === 'approve deducts' ) || ( conflictSeen && fault === 'create conflict mutates' )
2026-10-03 06:26:33 +08:00
data = { total : '1' , records : [{... baseline . balances [ 0 ], availablePackages : changed ? '3' : '4' }]}
} else if ( path . endsWith ( '/ledger' )) data = { total : '0' , records : []}
else if ( path . endsWith ( '/inventory-candidates' )) data = [{... baseline . source , availablePackages : state === 'REJECTED' && fault !== 'reject does not release' ? '4' : '3' }]
else data = { summary : { discardNo : approved ? 'NEW' : 'OLD' , status : state }}
return { http : 200 , body : { code : '200' , data }}
}}
await assert . rejects ( verifyDiscardApproval ( context , 'merchant' ,{ idempotencyKey : 'key' , remark : 'fixture' }, 'OLD' , proof ),
2026-10-08 17:07:57 +08:00
fault ? /仅锁定|释放原箱|只保留锁箱|异载荷拒绝后/ : /CHECKPOINT_PASSED/ )
2026-10-03 06:26:33 +08:00
assert . ok ( ! calls . some ( call => call . raw . includes ( '/pda/' )))
})
}