100 lines
9.8 KiB
JavaScript
100 lines
9.8 KiB
JavaScript
import test from 'node:test'
|
|||
|
|
import assert from 'node:assert/strict'
|
||
|
|
import { readFile } from 'node:fs/promises'
|
||
|
|
import { verifyFleetCrossCustomer, fixtureContract } from './fleet-cross-customer-acceptance.mjs'
|
||
|
|
|
||
|
|
function model(fault='') {
|
||
|
|
const copy=value=>structuredClone(value),calls=[],keys=new Set()
|
||
|
|
const members=[0,1].map(i=>({marker:'RUN',customerId:`C${i}`,incomingNo:`IN${i}`,dispatchNo:`DS${i}`,outboundNo:`OUT${i}`,deliveryNo:`DL${i}`,
|
||
|
|
balanceId:`B${i}`,positionId:`P${i}`,palletId:`PAL${i}`,palletNo:`PALLET${i}`,receiptVerified:true}))
|
||
|
|
const fixture={marker:'RUN',stage:fixtureContract.stage,tripNo:'TRIP',warehouseId:'WH',pdaToken:'not-a-real-token',members}
|
||
|
|
const trip={trip:{tripNo:'TRIP',warehouseId:'WH',status:'PLANNED',recordVersion:'0',vehicleId:'V',driverId:'D',stops:[{name:'Origin'},
|
||
|
|
{name:'A',destinationKey:'FBA:A'},{name:'B',destinationKey:'FBA:B'}]},dispatches:members.map(m=>({dispatchNo:m.dispatchNo,customerId:m.customerId,review:{status:'PENDING'}}))}
|
||
|
|
const state=members.map(m=>({dispatch:{dispatchNo:m.dispatchNo,overseasWarehouseId:'WH',status:'PENDING_DEPARTURE',packageCount:1,pickedPackageCount:0,
|
||
|
|
items:[{id:`ITEM${m.customerId}`,warehousePalletId:m.palletId}]},delivery:{order:{deliveryNo:m.deliveryNo,customerId:m.customerId,dispatchNo:m.dispatchNo,outboundNo:m.outboundNo,status:'WAIT_DELIVERY',version:'0'}},
|
||
|
|
outbound:{outboundNo:m.outboundNo,dispatchNo:m.dispatchNo,overseasWarehouseId:'WH',status:'WAIT_OUTBOUND'},
|
||
|
|
stock:{scope:{marker:'RUN',customerId:m.customerId,warehouseId:'WH',coverage:'CUSTOMER_WAREHOUSE_COMPLETE'},
|
||
|
|
balances:[{id:m.balanceId,customerId:m.customerId,overseasWarehouseId:'WH',availablePackages:'1',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0',inTransitPackages:'0',procurementPackages:'0'}],
|
||
|
|
positions:[{id:m.positionId,customerId:m.customerId,overseasWarehouseId:'WH',balanceId:m.balanceId,palletId:m.palletId,palletNo:m.palletNo,availablePackages:'1',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0'}],ledger:[]}}))
|
||
|
|
const cargo=members.map((m,i)=>({dispatchNo:m.dispatchNo,customerId:m.customerId,warehousePalletId:m.palletId,palletNo:m.palletNo,destinationKey:i?'FBA:B':'FBA:A',packages:1}))
|
||
|
|
const response=data=>({http:200,body:{code:'200',data:copy(data)},requestId:'request',traceId:'trace'})
|
||
|
|
const context={marker:'RUN',token:'admin',createdResources:{},assert(_m,label,_r,condition){assert.ok(condition,label)},
|
||
|
|
async request(method,raw,body,token){
|
||
|
|
calls.push({method,raw,body:copy(body),token});const path=new URL(raw,'https://offline.invalid').pathname
|
||
|
|
let index=members.findIndex(m=>path.includes(m.dispatchNo)||path.includes(m.outboundNo)||path.includes(m.deliveryNo))
|
||
|
|
const row=state[index],member=members[index]
|
||
|
|
if(path.endsWith('/review')){trip.dispatches[index].review.status='APPROVED';trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response(trip)}
|
||
|
|
if(path.endsWith('/loading-review')){trip.loadingReview={status:'APPROVED'};trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response(trip)}
|
||
|
|
if(path.endsWith('/loading')){if(method==='POST')trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response({recordVersion:trip.trip.recordVersion,manifestHash:'HASH',cargo,blockReason:null})}
|
||
|
|
if(path.endsWith('/TRIP'))return response(trip)
|
||
|
|
if(path.endsWith('/pick')){
|
||
|
|
if(keys.has(body.idempotencyKey))return response({replayed:true})
|
||
|
|
keys.add(body.idempotencyKey);row.dispatch.pickedPackageCount=1
|
||
|
|
row.stock.positions[0].availablePackages='0';row.stock.balances[0].availablePackages='0'
|
||
|
|
row.stock.ledger=['ALLOCATE','PICK','SHIP'].map(type=>({id:`${type}${index}`,customerId:member.customerId,overseasWarehouseId:'WH',positionId:member.positionId,businessOrderNo:member.dispatchNo,businessType:type,deltaPackages:type==='SHIP'?'-1':'1'}))
|
||
|
|
if(fault==='duplicate ship')row.stock.ledger.push({...row.stock.ledger[2],id:'EXTRA'})
|
||
|
|
return response({replayed:false})
|
||
|
|
}
|
||
|
|
if(path.endsWith('/complete')){row.dispatch.status='READY_TO_DEPART';return response({status:'READY_TO_DEPART'})}
|
||
|
|
if(path.endsWith('/depart')){
|
||
|
|
if(state.some(s=>s.dispatch.pickedPackageCount!==1)){
|
||
|
|
if(fault==='gate reorders route') [trip.trip.stops[1],trip.trip.stops[2]]=[trip.trip.stops[2],trip.trip.stops[1]]
|
||
|
|
if(fault==='gate mutates second customer'){state[1].stock.balances[0].holdPackages='1';state[1].stock.balances[0].availablePackages='0';state[1].stock.positions[0].holdPackages='1';state[1].stock.positions[0].availablePackages='0'}
|
||
|
|
return {http:fault==='outage disguised as rejection'?503:200,body:{code:fault==='outage disguised as rejection'?'S0503':'A0409',message:fault==='unrelated gate rejection'?'车次版本已变化':'DS1:请完成拣货并核平全部箱数'},requestId:'r',traceId:'t'}
|
||
|
|
}
|
||
|
|
const replayed=keys.has(body.idempotencyKey);keys.add(body.idempotencyKey);trip.trip.status='IN_TRANSIT'
|
||
|
|
for(const [i,s] of state.entries())if(!(fault==='partial departure'&&i===1)){s.dispatch.status='DEPARTED';s.outbound.status='OUTBOUNDED'}
|
||
|
|
if(fault==='depart deducts again'){row.stock.balances[0].availablePackages='-1';row.stock.positions[0].availablePackages='-1'}
|
||
|
|
return response({tripNo:'TRIP',status:'IN_TRANSIT',shippedPackages:'2',dispatches:members.map(m=>({dispatchNo:m.dispatchNo})),replayed})
|
||
|
|
}
|
||
|
|
if(path.includes('/api/pda/'))return response({dispatchNo:member.dispatchNo,warehouseId:'WH',tripNo:'TRIP',status:row.dispatch.pickedPackageCount?'COMPLETED':'PDA_PENDING',
|
||
|
|
items:[{dispatchItemId:`ITEM${member.customerId}`,palletNo:member.palletNo,scheduledPackages:1,pickedPackages:String(row.dispatch.pickedPackageCount),
|
||
|
|
positions:fault==='missing PDA position'?[]:[{id:member.positionId,balanceId:member.balanceId,availablePackages:row.stock.positions[0].availablePackages}]}]})
|
||
|
|
if(path.includes('/dispatches/'))return response(row.dispatch)
|
||
|
|
if(path.includes('/outbound-orders/'))return response({outbound:row.outbound,deliveries:[row.delivery.order]})
|
||
|
|
if(path.endsWith('/status')){row.delivery.order.status='DELIVERING';row.delivery.order.version=String(Number(row.delivery.order.version)+1);return response({deliveryStatus:'DELIVERING'})}
|
||
|
|
if(path.endsWith('/documents')){
|
||
|
|
if(method==='GET')return response({version:row.delivery.order.version,categories:[]})
|
||
|
|
row.delivery.order.status='DELIVERED';row.delivery.order.version=String(Number(row.delivery.order.version)+1)
|
||
|
|
if(fault==='signs both')state[1-index].delivery.order.status='DELIVERED'
|
||
|
|
return response({version:row.delivery.order.version})
|
||
|
|
}
|
||
|
|
if(path.includes('/delivery-orders/'))return response(row.delivery)
|
||
|
|
throw new Error(`Unexpected ${method} ${path}`)
|
||
|
|
},async requestMultipart(method,path,body){calls.push({method,raw:path,multipart:true});return response({fileId:`FILE-${path.includes('DL0')?0:1}`})}}
|
||
|
|
const readInventory=async scope=>{const value=copy(state[members.findIndex(m=>m.customerId===scope.customerId)].stock);if(fault==='wrong stock scope')value.positions[0].customerId='OTHER';return value}
|
||
|
|
return {context,fixture,readInventory,calls,state}
|
||
|
|
}
|
||
|
|
|
||
|
|
test('default call and import do not request, create or activate anything',async()=>{
|
||
|
|
let called=false;const result=await verifyFleetCrossCustomer({request(){called=true}},{})
|
||
|
|
assert.equal(result,fixtureContract);assert.equal(called,false)
|
||
|
|
})
|
||
|
|
test('current DTO-shaped two-customer trip rejects unready gate, departs once and signs independently',async()=>{
|
||
|
|
const m=model();const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory})
|
||
|
|
assert.equal(proof.status,'VERIFIED');assert.equal(proof.gateProof,'PREREQUISITE_REJECTION_VERIFIED');assert.equal(proof.midTransactionRollback,'NOT_TESTED')
|
||
|
|
assert.equal(m.calls.filter(c=>c.raw.endsWith('/depart')).length,3)
|
||
|
|
assert.deepEqual(m.state.map(s=>s.delivery.order.status),['DELIVERED','DELIVERED'])
|
||
|
|
assert.ok(m.calls.filter(c=>c.raw.includes('/api/pda/')).every(c=>c.token===m.fixture.pdaToken))
|
||
|
|
assert.ok(!JSON.stringify(proof).includes(m.fixture.pdaToken))
|
||
|
|
})
|
||
|
|
for(const fault of ['wrong stock scope','missing PDA position','duplicate ship','gate mutates second customer','gate reorders route','outage disguised as rejection','unrelated gate rejection','partial departure','depart deducts again','signs both']) {
|
||
|
|
test(`fails closed without widening the claimed evidence: ${fault}`,async()=>{
|
||
|
|
const m=model(fault);await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}),
|
||
|
|
fault==='gate reorders route'?/Rejected departure leaves both complete inventories\/outbounds\/deliveries\/trip unchanged/:undefined)
|
||
|
|
assert.equal(m.context.createdResources.fleetCrossCustomer.status,'FAILED')
|
||
|
|
assert.equal(m.context.createdResources.fleetCrossCustomer.midTransactionRollback,'NOT_TESTED')
|
||
|
|
})
|
||
|
|
}
|
||
|
|
test('missing reader, reused customer and stale marker reject before any API request',async()=>{
|
||
|
|
for(const change of ['reader','customer','marker']){
|
||
|
|
const m=model();if(change==='customer')m.fixture.members[1].customerId=m.fixture.members[0].customerId;if(change==='marker')m.fixture.marker='OLD'
|
||
|
|
await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:change==='reader'?undefined:m.readInventory}));assert.equal(m.calls.length,0)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
test('module contains no autonomous runner or SQL/environment/credential plumbing',async()=>{
|
||
|
|
const source=await readFile(new URL('./fleet-cross-customer-acceptance.mjs',import.meta.url),'utf8')
|
||
|
|
assert.doesNotMatch(source,/process\.env|fetch\(|execSync|INSERT INTO|UPDATE ll_|createPicker|createMerchant/)
|
||
|
|
assert.match(source,/controlledPalletMeasurements/)
|
||
|
|
})
|