test(fleet): cover separate customers sharing one physical trip

This commit is contained in:
2026-10-03 07:42:18 +08:00
parent 4e129a7f4a
commit 18d22418ca
8 changed files with 1007 additions and 0 deletions
+4
View File
@@ -1,5 +1,9 @@
# Admin 测试环境 HTTP 模块
`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收、客户确认及预排接口生成两套独立本轮货物,再共用一个真实车次并各自转派送,停在尚未拣货。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。
`fleet-cross-customer-acceptance.mjs` 是跨客户共享车次的内部后半链验收函数,不是独立入口。默认调用只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收并分别预排/转派送、关联同一合格车次且未拣货,以及调用方提供的完整库存只读取证器。逐客户审核和拣货,验证第二单未就绪时整车拒绝且全部快照不变,再验证统一发车、原键重放和分别签收。前置门禁证据与事务中途故障回滚严格区分,后者保持 `NOT_TESTED`。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。
`TERMINAL_READONLY` 用于全部业务写入和结案助手均完成、仅专项入口汇总状态断言错误的已复核报告。结案助手成功状态为 `CARGO_COMPLETED_AND_RESOURCES_RELEASED`。该检查点登录后仅 GET 原车次、每张排车/派送、资源占用测试计划与精确财务来源版本,不重放任何业务写入,不覆盖原失败报告;真实状态、数量、签收或费用不符仍失败。它不补充生产者 ACK 或页面验收证据。
`EXTERNAL_RECEIPT_CONFIRMED` 仅接受原运营实收确认明确成功、后续只读/重放断言失败且尚未开始排车关联的样本。先核对原来货已可排车、实际容量及单一库存流水,再原键原载荷重放实收确认;不重新入库。重放比较以数据库持久化后的完整预订为基准,避免直接拿命令返回的纳秒 `updatedAt` 与数据库毫秒精度比较;货量、报价、版本及全部业务字段仍精确核对。实收总箱数字段为正式 `receivedPackages`。
@@ -0,0 +1,99 @@
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/)
})
@@ -0,0 +1,193 @@
import { controlledPalletMeasurements } from './fleet-trip-fulfillment.mjs'
const MODULE = 'fleet-cross-customer-acceptance'
const ROOT = '/api/platform/v1/last-leg'
const PDA = '/api/pda/v1/last-leg/dispatches'
const OPTIONS = { scenario: 'fleet-cross-customer-departure', deploymentBlocker: true }
const id = value => value == null ? '' : String(value)
const ok = response => response?.http === 200 && id(response.body?.code) === '200'
const encode = encodeURIComponent
const scoped = (path, customerId) => `${path}?${new URLSearchParams({ customerId })}`
const canonical = value => JSON.stringify(normalize(value))
function normalize(value) {
// Route stops and other business arrays are ordered. Raw readers return stable ID order.
if (Array.isArray(value)) return value.map(normalize)
return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])) : value
}
function integer(value) {
if (!['number','string'].includes(typeof value) || !/^(0|[1-9][0-9]*)$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('Invalid inventory integer')
return Number(value)
}
const buckets = ['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages']
const balanceBuckets = [...buckets,'inTransitPackages','procurementPackages']
export const fixtureContract = Object.freeze({
stage: 'TWO_FRESH_RECEIVED_DISPATCHES_BOUND_AND_TRANSFERRED_NOT_PICKED',
fixture: 'marker, warehouseId, tripNo, pdaToken, members[2]: {marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified:true}',
preparation: 'Two independently qualified current-run customers; each formally received one physical pallet/box in the same warehouse; draft lock/pre-arrange separately; bind both to one assigned qualified vehicle/driver trip; transfer each to its own outbound and delivery. No picking yet.',
reader: 'readInventory(scope) => {scope:{marker,customerId,warehouseId,coverage:"CUSTOMER_WAREHOUSE_COMPLETE"}, balances:all raw rows including zero, positions:all raw rows including zero, ledger:all immutable movement rows}. Retain camelCase IDs and every quantity bucket; never substitute a grouped public SKU page.',
helperLimit: 'Existing external-receipt helper selects plan.cargo[0].customerId and requires an existing own-cargo capacity plan; external-dispatch helper has one mutable externalReceipt slot. Do not run either twice pretending to create two customers.',
boundary: 'This exercises a prerequisite gate rejection, not a mid-transaction rollback. No fault injection, account activation, SQL writes, deployment, cleanup deletion or implicit fixture creation.',
})
/** Import/default call is network-free. Explicit execute:true requires separately authorized TEST execution.
* Tokens stay in the caller, never in the persisted proof. Failure retains fixture and exact request keys.
*/
export async function verifyFleetCrossCustomer(context, fixture, { execute = false, readInventory } = {}) {
if (!execute) return fixtureContract
if (!context?.marker || fixture?.marker !== context.marker || fixture.stage !== fixtureContract.stage
|| !fixture.tripNo || !fixture.warehouseId || !fixture.pdaToken || typeof readInventory !== 'function'
|| fixture.members?.length !== 2 || new Set(fixture.members.map(row=>row.customerId)).size !== 2
|| fixture.members.some(row=>row.marker!==context.marker || row.receiptVerified!==true
|| ['customerId','incomingNo','dispatchNo','outboundNo','deliveryNo','balanceId','positionId','palletId','palletNo'].some(key=>!id(row[key])))) {
throw new Error('Requires two distinct current-run customers, immutable receipt references and a complete raw inventory reader')
}
for (const key of ['dispatchNo','outboundNo','deliveryNo','positionId','palletId']) {
if (new Set(fixture.members.map(row=>id(row[key]))).size!==2) throw new Error(`Cross-customer fixture reuses ${key}`)
}
context.createdResources ||= {}
if (context.createdResources.fleetCrossCustomer) throw new Error('Proof already exists; retain failed run and reconcile explicitly')
const proof = context.createdResources.fleetCrossCustomer = { marker:context.marker,tripNo:fixture.tripNo,
warehouseId:fixture.warehouseId,members:fixture.members.map(({marker,customerId,incomingNo,dispatchNo,outboundNo,deliveryNo,balanceId,positionId,palletId,palletNo})=>({marker,customerId,incomingNo,dispatchNo,outboundNo,deliveryNo,balanceId,positionId,palletId,palletNo})),
status:'CHECKING',requests:[],snapshots:{},gateProof:'NOT_RUN',midTransactionRollback:'NOT_TESTED',cleanup:'Caller owns account/session lifecycle; retain all business fixtures' }
const check = (name, passed) => {
context.assert(MODULE,name,null,Boolean(passed),'',OPTIONS)
if (!passed) throw new Error(name)
}
const key = suffix => `${context.marker}-CROSS-${suffix}`
const tripPath = `${ROOT}/fleet/trips/${encode(fixture.tripNo)}`
const request = async (method,path,body,label,pda=false,rejection=false) => {
const response = await context.request(method,path,body,pda?fixture.pdaToken:context.token,{})
proof.requests.push({method,path,body,label,http:response?.http,code:response?.body?.code,requestId:response?.requestId,traceId:response?.traceId})
const denied = response?.http===409 || (response?.http===200 && id(response.body?.code)==='A0409')
check(label,Boolean(response?.requestId && response?.traceId) && (rejection?denied && rejection(id(response.body?.message)):ok(response)))
return response.body?.data
}
const stock = async member => {
const scope={marker:context.marker,customerId:member.customerId,warehouseId:fixture.warehouseId}
const raw=await readInventory(scope)
check('Inventory reader proves complete customer/warehouse scope',canonical(raw?.scope)===canonical({...scope,coverage:'CUSTOMER_WAREHOUSE_COMPLETE'}))
for(const collection of ['balances','positions','ledger'])check(`Complete ${collection} rows have unique identities and customer ownership`,Array.isArray(raw[collection])
&& new Set(raw[collection].map(row=>id(row.id))).size===raw[collection].length
&& raw[collection].every(row=>id(row.id) && id(row.customerId)===id(member.customerId) && id(row.overseasWarehouseId)===id(fixture.warehouseId)))
check('Original receipt balance and physical position are retained even at zero',raw.balances.some(row=>id(row.id)===id(member.balanceId))
&& raw.positions.some(row=>id(row.id)===id(member.positionId) && id(row.balanceId)===id(member.balanceId) && id(row.palletId)===id(member.palletId) && row.palletNo===member.palletNo))
for(const balance of raw.balances) {
for(const field of balanceBuckets) integer(balance[field])
const positions=raw.positions.filter(row=>id(row.balanceId)===id(balance.id))
for(const field of buckets) check(`Raw balance and every position conserve ${field}`,integer(balance[field])===positions.reduce((sum,row)=>sum+integer(row[field]),0))
}
check('No orphan stock positions',raw.positions.every(row=>raw.balances.some(balance=>id(balance.id)===id(row.balanceId))))
return raw
}
const snapshot = async label => {
const trip=await request('GET',tripPath,undefined,'Read shared trip')
check('Trip members are exactly the two independent customers',trip.trip?.tripNo===fixture.tripNo && id(trip.trip.warehouseId)===id(fixture.warehouseId)
&& trip.dispatches?.length===2 && fixture.members.every(member=>trip.dispatches.some(row=>row.dispatchNo===member.dispatchNo && id(row.customerId)===id(member.customerId))))
const members=[]
for(const member of fixture.members) {
const dispatch=await request('GET',`${ROOT}/dispatches/${encode(member.dispatchNo)}`,undefined,'Read customer dispatch')
const outbound=await request('GET',scoped(`${ROOT}/outbound-orders/${encode(member.outboundNo)}`,member.customerId),undefined,'Read customer outbound')
const delivery=await request('GET',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`,member.customerId),undefined,'Read customer delivery')
const task=await request('GET',`${PDA}/${encode(member.dispatchNo)}`,undefined,'Read PDA task',true)
check('Execution records retain exact customer, warehouse and dispatch ownership',dispatch.dispatchNo===member.dispatchNo
&& id(dispatch.overseasWarehouseId)===id(fixture.warehouseId) && dispatch.items?.length===1
&& id(dispatch.items[0].warehousePalletId)===id(member.palletId)
&& outbound.outbound?.outboundNo===member.outboundNo && outbound.outbound.dispatchNo===member.dispatchNo
&& id(outbound.outbound.overseasWarehouseId)===id(fixture.warehouseId) && outbound.deliveries?.length===1
&& outbound.deliveries[0].deliveryNo===member.deliveryNo && id(outbound.deliveries[0].customerId)===id(member.customerId)
&& delivery.order?.deliveryNo===member.deliveryNo && id(delivery.order.customerId)===id(member.customerId)
&& delivery.order.dispatchNo===member.dispatchNo && delivery.order.outboundNo===member.outboundNo
&& task.dispatchNo===member.dispatchNo && id(task.warehouseId)===id(fixture.warehouseId) && task.tripNo===fixture.tripNo)
members.push({dispatch,outbound,delivery,task,stock:await stock(member)})
}
const value={trip,members};proof.snapshots[label]=value;return value
}
const equal = (a,b,name)=>check(name,canonical(a)===canonical(b))
const pick = async (member,index) => {
const before=await snapshot(`BEFORE_PICK_${index}`),task=before.members[index].task
check('PDA exposes the single real current-run pallet and position',task.items?.length===1 && integer(task.items[0].scheduledPackages)===1
&& integer(task.items[0].pickedPackages)===0 && task.items[0].palletNo===member.palletNo && task.items[0].positions?.length===1
&& id(task.items[0].positions[0].id)===id(member.positionId) && integer(task.items[0].positions[0].availablePackages)===1)
const body={positionId:member.positionId,packages:1,productQuantity:1,idempotencyKey:key(`PICK-${index}`),remark:context.marker}
const path=`${PDA}/${encode(member.dispatchNo)}/items/${encode(task.items[0].dispatchItemId)}/pick`
await request('POST',path,body,'Pick this customer only',true)
await request('POST',`${PDA}/${encode(member.dispatchNo)}/complete`,{idempotencyKey:key(`COMPLETE-${index}`)},'Complete this customer picking',true)
const after=await snapshot(`AFTER_PICK_${index}`)
equal(before.members[1-index].stock,after.members[1-index].stock,'Other customer inventory unchanged by picking')
const expected=structuredClone(before.members[index].stock)
for(const collection of ['balances','positions']) {
const row=expected[collection].find(row=>id(row.id)===id(collection==='balances'?member.balanceId:member.positionId))
row.availablePackages=integer(row.availablePackages)-1
}
const counts=raw=>Object.fromEntries(['balances','positions'].map(collection=>[collection,raw[collection].map(row=>({id:id(row.id),...Object.fromEntries((collection==='balances'?balanceBuckets:buckets).map(field=>[field,integer(row[field])]))}))]))
equal(counts(expected),counts(after.members[index].stock),'Only selected customer position and balance lose one box')
const old=before.members[index].stock.ledger,newRows=after.members[index].stock.ledger.filter(row=>!old.some(prior=>id(prior.id)===id(row.id)))
equal(old,after.members[index].stock.ledger.filter(row=>old.some(prior=>id(prior.id)===id(row.id))),'Immutable old movements preserved')
check('Single ALLOCATE/PICK/SHIP sequence has exact source and quantity',newRows.length===3 && ['ALLOCATE','PICK','SHIP'].every(type=>newRows.filter(row=>row.businessType===type
&& id(row.positionId)===id(member.positionId) && row.businessOrderNo===member.dispatchNo && Number(row.deltaPackages)===(type==='SHIP'?-1:1)).length===1))
await request('POST',path,body,'Replay exact customer pick',true)
equal(after,await snapshot(`PICK_REPLAY_${index}`),'Pick replay changes neither customer nor any execution record')
}
try {
let initial=await snapshot('INITIAL')
check('Minimal fresh fixture has one available box per customer and no prior execution',initial.trip.trip.status==='PLANNED'
&& initial.members.every((row,index)=>row.dispatch.status==='PENDING_DEPARTURE' && integer(row.dispatch.packageCount)===1
&& integer(row.dispatch.pickedPackageCount)===0 && row.outbound.outbound.status==='WAIT_OUTBOUND' && row.delivery.order.status==='WAIT_DELIVERY'
&& integer(row.stock.positions.find(p=>id(p.id)===id(fixture.members[index].positionId)).availablePackages)===1
&& row.stock.balances.every(balance=>balanceBuckets.filter(field=>field!=='availablePackages').every(field=>integer(balance[field])===0))
&& row.stock.balances.reduce((sum,balance)=>sum+integer(balance.availablePackages),0)===1))
let detail=initial.trip
for(const member of fixture.members)detail=await request('POST',`${tripPath}/dispatches/${encode(member.dispatchNo)}/review`,
{recordVersion:detail.trip.recordVersion,decision:'APPROVED'},'Review each customer dispatch separately')
check('Both customer dispatches approved',detail.dispatches.every(row=>row.review?.status==='APPROVED'))
await pick(fixture.members[0],0)
const rejectedBefore=await snapshot('GATE_BEFORE')
check('Precisely one customer is ready',rejectedBefore.members[0].dispatch.status==='READY_TO_DEPART' && integer(rejectedBefore.members[1].dispatch.pickedPackageCount)===0)
await request('POST',`${PDA}/${encode(fixture.members[0].dispatchNo)}/depart`,
{recordVersion:rejectedBefore.trip.trip.recordVersion,idempotencyKey:key('UNREADY-DEPART'),remark:context.marker},'Unready second customer blocks whole trip before execution',true,
message=>message.includes(fixture.members[1].dispatchNo) && message.includes('请完成拣货并核平全部箱数'))
equal(rejectedBefore,await snapshot('GATE_AFTER'),'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged')
proof.gateProof='PREREQUISITE_REJECTION_VERIFIED'
await pick(fixture.members[1],1)
const loading=await request('GET',`${tripPath}/loading`,undefined,'Read ready shared loading')
check('Both real pallets ready for loading',!loading.blockReason && loading.cargo?.length===2)
detail=await request('GET',tripPath,undefined,'Read current trip route')
const measured=controlledPalletMeasurements(loading.cargo,detail.trip.stops)
const loaded=await request('POST',`${tripPath}/loading`,{recordVersion:loading.recordVersion,manifestHash:loading.manifestHash,pallets:measured,reason:context.marker},'Confirm controlled TEST measurements')
const reviewed=await request('POST',`${tripPath}/loading-review`,{recordVersion:loaded.recordVersion,decision:'APPROVED'},'Approve whole-trip loading')
const ready=await snapshot('READY')
const departBody={recordVersion:reviewed.trip.recordVersion,idempotencyKey:key('DEPART'),remark:context.marker}
const departPath=`${PDA}/${encode(fixture.members[0].dispatchNo)}/depart`
const departed=await request('POST',departPath,departBody,'Depart whole trip once',true)
check('Departure includes both customer dispatches',departed.tripNo===fixture.tripNo && departed.status==='IN_TRANSIT' && integer(departed.shippedPackages)===2
&& canonical(departed.dispatches.map(row=>row.dispatchNo).sort())===canonical(fixture.members.map(row=>row.dispatchNo).sort()))
const after=await snapshot('DEPARTED')
check('Both outbounds completed and trip in transit',after.trip.trip.status==='IN_TRANSIT' && after.members.every(row=>row.dispatch.status==='DEPARTED' && row.outbound.outbound.status==='OUTBOUNDED'))
equal(ready.members.map(row=>row.stock),after.members.map(row=>row.stock),'Departure does not deduct either inventory again')
const replay=await request('POST',departPath,departBody,'Replay exact shared departure',true)
check('Shared departure replay is explicit',replay.replayed===true)
equal(after,await snapshot('DEPART_REPLAY'),'Departure replay preserves both complete inventories and execution facts')
for(const [index,member] of fixture.members.entries()) {
const before=await snapshot(`BEFORE_SIGN_${index}`),path=scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`,member.customerId)
await request('PUT',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/status`,member.customerId),
{status:'DELIVERING',expectedVersion:before.members[index].delivery.order.version,remark:context.marker},'Start only this customer delivery')
const documentPath=scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents`,member.customerId)
const docs=await request('GET',documentPath,undefined,'Read this delivery document version')
const uploaded=await context.requestMultipart('POST',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`,member.customerId),
{name:`${context.marker}-${index}-receipt.txt`,type:'text/plain',content:new TextEncoder().encode(`${context.marker} synthetic receipt ${member.deliveryNo}`)})
proof.requests.push({method:'POST',path:scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`,member.customerId),label:'Upload own synthetic receipt',http:uploaded?.http,code:uploaded?.body?.code,requestId:uploaded?.requestId,traceId:uploaded?.traceId})
check('Receipt uploaded through correct delivery endpoint',ok(uploaded) && id(uploaded.body?.data?.fileId))
const documents=(docs.categories||[]).filter(row=>row.category!=='RECEIPT').map(row=>({category:row.category,fileIds:(row.files||[]).map(file=>file.fileId)}))
documents.push({category:'RECEIPT',fileIds:[id(uploaded.body.data.fileId)]})
await request('PUT',documentPath,{expectedVersion:docs.version,idempotencyKey:key(`SIGN-${index}`),bolNo:docs.bolNo||null,documents},'Attach receipt to independently complete this delivery')
const signed=await snapshot(`SIGNED_${index}`)
check('This customer independently signed',signed.members[index].delivery.order.status==='DELIVERED')
equal(before.members[1-index].delivery,signed.members[1-index].delivery,'Other customer delivery is not signed or changed implicitly')
equal(before.members.map(row=>row.stock),signed.members.map(row=>row.stock),'Signing does not mutate either customer inventory')
// Retain the independently readable result as evidence, not a fabricated completion command.
await request('GET',path,undefined,'Read signed customer delivery')
}
proof.status='VERIFIED';return proof
} catch(error) {proof.status='FAILED';proof.error=error.message;throw error}
}
@@ -0,0 +1,211 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createFleetCrossCustomerFixture, preparationContract } from './fleet-cross-customer-fixture.mjs'
import { fixtureContract } from './fleet-cross-customer-acceptance.mjs'
// Strict offline HTTP model executes the real qualification and prepareFleetTrip helpers too.
function model(fault = '') {
const copy = value => structuredClone(value), calls = [], customers = [], transfers = [], dispatches = []
const options = { execute: true, warehouseId: '10', destinationWarehouseId: '20', pdaToken: 'caller-pda-secret' }
const trip = { trip: { tripNo: 'TRIP', recordVersion: '0', warehouseId: '10', status: 'PLANNED', maxPalletSpaces: 30,
vehiclePlate: 'TEST', driverName: 'TEST', driverPhone: '19900000000' }, dispatches: [] }
const origin = { warehouse: { id: '10', tenantId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE', warehouseCode: 'ORIGIN', warehouseName: 'Origin' },
capabilities: [{ capabilityCode: 'RECEIVING', enabled: fault !== 'disabled receiving' }] }
const destination = { warehouse: { id: '20', tenantId: '0', warehouseType: 'FBA', status: 'ACTIVE', warehouseCode: 'FBA', warehouseName: 'Destination' },
addresses: [{ addressLine1: 'TEST street', countryCode: 'US' }] }
const response = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'offline-request', traceId: 'offline-trace' })
const buckets = { availablePackages: '1', holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0' }
const stock = transfer => {
const n = transfers.indexOf(transfer), scope = { customerId: transfer.customerId, overseasWarehouseId: '10', inboundCode: transfer.transferNo }
const position = { ...scope, ...buckets, id: `P${n}`, balanceId: `B${n}`, palletId: `PAL${n}`, palletNo: `PALLET${n}` }
if (fault === 'foreign position') position.customerId = 'OTHER'
return { balance: { ...scope, ...buckets, id: `B${n}`, inTransitPackages: '0', procurementPackages: '0' }, positions: [position],
ledger: [{ ...scope, id: `L${n}`, balanceId: `B${n}`, positionId: `P${n}`, palletNo: `PALLET${n}`, businessType: 'STOCK_IN', businessOrderNo: transfer.transferNo,
beforePackages: '0', deltaPackages: '1', afterPackages: '1' }] }
}
const cargo = () => trip.dispatches.map(binding => {
const d = dispatches.find(row => row.dispatchNo === binding.dispatchNo), n = dispatches.indexOf(d)
return { dispatchNo: d.dispatchNo, customerId: binding.customerId, warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`,
destinationKey: 'FBA:FBA', destinationLabel: 'FBA', packages: 1 }
})
const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' }
const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture },
createdResources: { merchantQualificationFixture: { marker: 'PARENT' } },
assert(_module, label, _response, passed) { assert.ok(passed, label) },
apiSuccess(_module, label, result) { assert.equal(result.http, 200, label); assert.equal(result.body.code, '200', label); return result.body.data },
async findAvailablePhone() { return `1990000000${customers.length}` },
async requestWithBody(method, raw, form) {
calls.push({ method, raw, multipart: true }); assert.ok(form instanceof FormData)
assert.match(raw, /^\/api\/platform\/v1\/files\/qualifications\/\d+\/required-package\?/)
return response({})
},
async requestMultipart(method, raw, file, token = this.token) {
calls.push({ method, raw, token, multipart: true }); assert.ok(file.content.length > 0)
if (raw.includes('/license?')) return response({ id: 'DRIVER', recordVersion: '1', licenseFileId: 'LICENSE' })
assert.match(raw, /\/warehouse-transfers\/IN[01]\/measurement-photos\/files$/); assert.equal(token, options.pdaToken)
return response({ fileId: 'PHOTO', sizeBytes: file.content.length })
},
async request(method, raw, body, token = this.token) {
calls.push({ method, raw, body: copy(body), token })
const url = new URL(raw, 'https://offline.invalid'), path = url.pathname
if (path.startsWith('/api/platform/')) assert.equal(token, 'admin-secret')
if (path.startsWith('/api/pda/')) assert.equal(token, options.pdaToken)
if (path.endsWith('/warehouse-admin/10')) return response(origin)
if (path.endsWith('/warehouse-admin/20')) return response(destination)
if (path === '/api/platform/v1/iam/tenants') {
const n = customers.length, id = fault === 'reused customer' ? '100' : String(100 + n)
const customer = { id, username: `user${n}`, password: `fresh-secret-${n}`, marker: body.internalRemark }
customers.push(customer)
return response({ tenant: { id }, administratorUsername: customer.username, temporaryPassword: customer.password })
}
if (path.endsWith('/submit-review')) return response({ status: 'PENDING' })
if (path === '/api/platform/v1/iam/reviews') return response(customers.map(row => ({ id: `REV${row.id}`, tenantId: row.id })))
if (path.endsWith('/dimension-initialization')) return response({ status: 'READY' })
if (path.endsWith('/approve')) return response({})
if (path.endsWith('/qualifications')) return response(['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].map(qualificationType => ({ qualificationType, status: 'APPROVED' })))
if (path === '/api/auth/login') {
assert.equal(token, '')
const customer = customers.find(row => row.username === body.loginId); assert.equal(body.password, customer.password)
return response({ accessToken: `merchant-${customer.id}-secret`, refreshToken: `refresh-${customer.id}-secret` })
}
if (path === '/api/auth/logout') {
assert.equal(body.refreshToken, token.replace('merchant-', 'refresh-'))
if (fault === 'logout failure' && token.includes('100')) throw new Error('offline logout failure')
return response(null)
}
const merchant = customers.find(row => token === `merchant-${row.id}-secret`)
if (path === '/api/merchant/v1/iam/tenant/profile') return response({ id: fault === 'foreign login' ? 'OTHER' : merchant.id })
if (path === '/api/merchant/v1/last-leg/warehouse-transfers') {
assert.ok(merchant); assert.equal(body.inboundCode, undefined); assert.deepEqual(body.sourceLastlegOrderIds, [])
assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1)
const transferNo = `IN${transfers.length}`
const transfer = { transferNo, inboundCode: transferNo, customerId: merchant.id, operatingWarehouseId: body.operatingWarehouseId,
status: 'DRAFT', pallets: [] }
transfers.push(transfer); return response(transfer)
}
if (path.includes('/warehouse-transfers/')) {
const transfer = transfers.find(row => path.includes(`/${row.transferNo}`)), n = transfers.indexOf(transfer)
assert.ok(transfer)
if (path.startsWith('/api/merchant/')) assert.equal(transfer.customerId, merchant.id)
if (path.endsWith('/submit')) transfer.status = 'PENDING_REVIEW'
else if (path.endsWith('/review')) { assert.equal(url.searchParams.get('customerId'), transfer.customerId); assert.equal(body.decision, 'APPROVE'); transfer.status = 'WAIT_PDA_RECEIVING' }
else if (path.endsWith('/pallets/drafts')) transfer.pallets = [{ id: `DRAFT${n}`, status: 'DRAFT', cartonCount: body.cartonCount }]
else if (path.endsWith('/generate')) Object.assign(transfer.pallets[0], { status: 'GENERATED', warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}` })
else if (path.endsWith('/bind')) { assert.equal(body.palletCode, transfer.pallets[0].palletNo); transfer.pallets[0].status = 'BOUND' }
else if (path.endsWith('/palletize')) { transfer.status = 'WAIT_CUSTOMER_CONFIRMATION'; transfer.pallets[0].status = 'COMPLETED' }
else if (path.endsWith('/confirm-measurement')) transfer.status = 'READY_FOR_DISPATCH'
else assert.equal(method, 'GET')
return response(transfer)
}
if (path.includes('/inventory/')) {
const transfer = transfers.find(row => row.customerId === url.searchParams.get('customerId')), facts = stock(transfer)
if (path.endsWith('/positions')) return response(facts.positions)
if (/\/balances\/B[01]$/.test(path)) return response(facts.balance)
// Match InventoryLedgerView: the public DTO has neither balanceId nor positionId.
if (path.endsWith('/ledger')) return response({ records: facts.ledger.map(({ balanceId, positionId, ...view }) => view), total: fault === 'truncated ledger' ? 2 : 1 })
assert.ok(path.endsWith('/balances')); return response({ records: [facts.balance], total: 1 })
}
if (path.endsWith('/dispatches/candidates')) {
const n = transfers.findIndex(row => row.transferNo === url.searchParams.get('orderNo')), transfer = transfers[n]
assert.equal(transfer.status, 'READY_FOR_DISPATCH')
return response([{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId, overseasWarehouseId: '10', inboundCode: transfer.transferNo,
warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, planPalletId: `PLAN${n}`, sourceStage: 'RECEIVED', packageCount: 1 }])
}
if (path.endsWith('/drafts/containers')) return response({ id: `CONTAINER${dispatches.length}` })
if (path.endsWith('/draft-locks/lock')) return response(null)
if (path.endsWith('/pre-arrange')) {
const n = Number(body.planPalletIds[0].slice(-1))
const dispatch = { dispatchNo: `DS${n}`, overseasWarehouseId: '10', status: 'PENDING_DEPARTURE', items: [{ warehousePalletId: `PAL${n}` }],
packageCount: 1, pickedPackageCount: 0, shippedPackageCount: 0 }
dispatches.push(dispatch); return response(dispatch)
}
if (path.endsWith('/master-data/vehicles')) return response({ id: 'VEHICLE' })
if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' })
if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' })
if (path.endsWith('/fleet/trips')) { Object.assign(trip.trip, body); return response(trip) }
if (path.includes('/fleet/trips/TRIP')) {
if (path.endsWith('/loading')) return response({ recordVersion: trip.trip.recordVersion, cargo: fault === 'missing second cargo' ? cargo().slice(0, 1) : cargo() })
if (method !== 'GET') {
assert.equal(body.recordVersion, trip.trip.recordVersion)
trip.trip.recordVersion = String(Number(trip.trip.recordVersion) + 1)
}
if (path.endsWith('/assignment')) Object.assign(trip.trip, { vehicleId: body.vehicleId, driverId: body.driverId })
else if (path.endsWith('/dispatches/bind')) trip.dispatches.push({ dispatchNo: body.dispatchNo, customerId: transfers[Number(body.dispatchNo.slice(-1))].customerId, review: { status: 'PENDING' } })
else if (path.endsWith('/plan')) trip.trip.stops = body.stops
else if (path.endsWith('/review')) trip.dispatches.find(row => path.includes(`/${row.dispatchNo}/`)).review.status = 'APPROVED'
return response(trip)
}
const n = Number(path.match(/(?:DS|OUT|DL)([01])/)[1]), dispatch = dispatches[n]
const order = { customerId: transfers[n].customerId, deliveryNo: `DL${n}`, outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, status: 'WAIT_DELIVERY' }
if (path.endsWith('/external-freight')) return response({ requiresDeliveryFee: true, quotes: [] })
if (path.endsWith('/transfer-delivery')) {
assert.equal(body.fees.length, 1); assert.equal(body.fees[0].amount, 30)
return response({ dispatchNo: dispatch.dispatchNo, outboundNo: `OUT${n}`, deliveries: [order], packageCount: 1 })
}
if (path.includes('/delivery-orders/')) { assert.equal(url.searchParams.get('customerId'), transfers[n].customerId); return response({ order: fault === 'foreign delivery' ? { ...order, customerId: 'OTHER' } : order }) }
if (path.includes('/outbound-orders/')) return response({ outbound: { outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, overseasWarehouseId: '10', status: 'WAIT_OUTBOUND' }, deliveries: [order] })
if (path.endsWith(`/dispatches/${dispatch.dispatchNo}`)) return response(dispatch)
throw new Error(`Unexpected offline request ${method} ${path}`)
},
}
options.readInventory = async scope => {
assert.equal(scope.marker, context.marker); assert.equal(scope.warehouseId, options.warehouseId)
const facts = stock(transfers.find(row => row.customerId === scope.customerId))
const raw = { scope: { ...scope, coverage: 'CUSTOMER_WAREHOUSE_COMPLETE' }, balances: [facts.balance], positions: facts.positions, ledger: facts.ledger }
if (fault === 'wrong raw scope') raw.scope.marker = 'OLD'
if (fault === 'wrong raw ledger position') raw.ledger[0].positionId = 'OTHER'
if (fault === 'wrong raw ledger balance') raw.ledger[0].balanceId = 'OTHER'
return copy(raw)
}
return { context, options, calls, customers, transfers, trip, parentFixture }
}
test('default preparation is network-free and exposes caller-owned lifecycle', async () => {
assert.equal(await createFleetCrossCustomerFixture(), preparationContract)
assert.equal(preparationContract.stage, fixtureContract.stage)
})
test('real helpers build independent current-run customers, receipts and one complete shared trip without picking', async () => {
const m = model(), oldEnvironment = { username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }
const fixture = await createFleetCrossCustomerFixture(m.context, m.options)
assert.equal(fixture.stage, fixtureContract.stage)
assert.equal(fixture.members.length, 2); assert.notEqual(fixture.members[0].customerId, fixture.members[1].customerId)
assert.ok(fixture.members.every(row => row.receiptVerified && row.marker === m.context.marker && row.positionId && row.balanceId))
assert.equal(m.context.context.merchantQualificationFixture, m.parentFixture)
assert.deepEqual(m.context.createdResources.merchantQualificationFixture, { marker: 'PARENT' })
assert.equal(m.context.options.customerName, 'Parent customer')
assert.deepEqual({ username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }, oldEnvironment)
assert.notEqual(m.customers[0].marker, m.customers[1].marker)
const proof = m.context.createdResources.fleetCrossCustomerFixture
assert.equal(proof.status, 'READY'); assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT'])
assert.equal(m.trip.dispatches.length, 2); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA'])
assert.equal(m.calls.filter(row => row.raw.endsWith('/plan')).length, 2)
assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 2)
assert.ok(!m.calls.some(row => /\/pick$|\/depart$|\/bookings|\/capacity-plan/.test(row.raw)))
const persisted = JSON.stringify(m.context.createdResources)
for (const secret of ['caller-pda-secret', 'admin-secret', 'fresh-secret-', 'merchant-100-secret', 'refresh-100-secret']) assert.ok(!persisted.includes(secret))
})
for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) {
test(`fail closed, retain partial resources and clean owned sessions: ${fault}`, async () => {
const m = model(fault)
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options))
const proof = m.context.createdResources.fleetCrossCustomerFixture
assert.equal(proof.status, 'FAILED')
const logins = m.calls.filter(row => row.raw === '/api/auth/login'), logouts = m.calls.filter(row => row.raw === '/api/auth/logout')
assert.equal(logouts.length, logins.length)
assert.ok(!m.calls.some(row => row.method === 'DELETE' || row.raw.includes('/users/')))
if (fault === 'disabled receiving') assert.equal(m.calls.filter(row => row.method !== 'GET').length, 0)
const previousCount = m.calls.length
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /already started/)
assert.equal(m.calls.length, previousCount)
})
}
test('missing explicit warehouse or caller PDA fails before reading or writing APIs', async () => {
for (const field of ['warehouseId', 'destinationWarehouseId', 'pdaToken', 'readInventory']) {
const m = model(); delete m.options[field]
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /Missing/)
assert.equal(m.calls.length, 0)
}
})
@@ -0,0 +1,267 @@
import { createHash } from 'node:crypto'
import { run as createQualifiedMerchant } from './merchant-qualification-fixture.mjs'
import { prepareFleetTrip } from './fleet-trip-fulfillment.mjs'
import { fixtureContract } from './fleet-cross-customer-acceptance.mjs'
const MODULE = 'fleet-cross-customer-fixture'
const ROOT = '/api/platform/v1/last-leg'
const MERCHANT = '/api/merchant/v1/last-leg/warehouse-transfers'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
const id = value => value == null ? '' : String(value)
const encode = encodeURIComponent
const ok = response => response?.http === 200 && id(response.body?.code) === '200'
const buckets = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']
const balanceBuckets = [...buckets, 'inTransitPackages', 'procurementPackages']
const testImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
// LocalDateTime API fields use the runner's local wall clock, with no misleading UTC suffix.
const localTime = delay => {
const date = new Date(Date.now() + delay)
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
}
export const preparationContract = Object.freeze({
stage: fixtureContract.stage,
inputs: 'execute:true, current-run context, warehouseId (public active OVERSEAS with RECEIVING), destinationWarehouseId (public active FBA), pdaToken bound to warehouseId, readInventory(scope) returning all raw balances/positions/ledger with CUSTOMER_WAREHOUSE_COMPLETE scope',
writes: 'Two fresh qualified customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; two dispatches, one shared trip with a test vehicle/qualified driver, two outbounds and deliveries. No picking.',
lifecycle: 'Caller creates/disables PDA identity and supplies raw readFleetInventory for verification. This helper logs out its own merchant sessions, retains all business fixtures, and refuses automatic retry. No SQL, environment files or external bookings.',
})
/** Preparation is opt-in and only for separately authorized TEST execution. Import/default call is offline. */
export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, pdaToken, readInventory } = {}) {
if (!execute) return preparationContract
if (!context?.marker || !id(warehouseId) || !id(destinationWarehouseId) || !pdaToken || typeof readInventory !== 'function'
|| ['request', 'requestWithBody', 'requestMultipart', 'findAvailablePhone', 'apiSuccess', 'assert'].some(name => typeof context[name] !== 'function'))
throw new Error('Missing current-run context, public warehouse/destination, caller-owned PDA identity or raw inventory reader')
context.createdResources ??= {}
if (context.createdResources.fleetCrossCustomerFixture) throw new Error('Fixture preparation already started; retain partial resources and reconcile explicitly')
const proof = context.createdResources.fleetCrossCustomerFixture = {
marker: context.marker, warehouseId: id(warehouseId), destinationWarehouseId: id(destinationWarehouseId), status: 'PREPARING',
members: [], requests: [], sessionCleanup: [], lifecycle: preparationContract.lifecycle,
}
const sessions = []
const check = (label, passed) => {
context.assert(MODULE, label, null, Boolean(passed), '', OPTIONS)
if (!passed) throw new Error(label)
}
const key = label => createHash('sha256').update(`${context.marker}:CROSS-FIXTURE:${label}`).digest('hex')
const record = (method, path, body, response, label) => {
// Auth payloads contain credentials; never persist them or response tokens.
proof.requests.push({ method, path, body: path.startsWith('/api/auth/') ? undefined : body, label,
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
check(label, ok(response) && response.requestId && response.traceId)
return response.body.data
}
const request = async (method, path, body, label, token = context.token) =>
record(method, path, body, await context.request(method, path, body, token), label)
const own = member => new URLSearchParams({ customerId: member.customerId })
const stock = async member => {
const query = new URLSearchParams({ customerId: member.customerId, overseasWarehouseId: id(warehouseId),
inboundCode: member.incomingNo, page: '1', size: '200' })
const grouped = await request('GET', `${ROOT}/inventory/balances?${query}`, undefined, 'Read exact fresh receipt balance page')
const ledger = await request('GET', `${ROOT}/inventory/ledger?${query}&businessOrderNo=${encode(member.incomingNo)}`, undefined, 'Read exact receipt movement page')
check('Fresh one-box receipt has one complete balance and movement page', grouped?.records?.length === 1 && Number(grouped.total) === 1
&& ledger?.records?.length === 1 && Number(ledger.total) === 1 && id(grouped.records[0].id))
const path = `${ROOT}/inventory/balances/${encode(grouped.records[0].id)}`
const balance = await request('GET', `${path}?${own(member)}`, undefined, 'Read original raw balance identity')
const positions = await request('GET', `${path}/positions?${own(member)}`, undefined, 'Read original raw physical position')
const sourceMatches = row => id(row.customerId) === member.customerId && id(row.overseasWarehouseId) === id(warehouseId)
&& row.inboundCode === member.incomingNo
check('Receipt raw balance and position belong to exactly this customer and physical pallet', balance && sourceMatches(balance)
&& id(balance.id) === id(grouped.records[0].id) && positions?.length === 1 && id(positions[0].id) && sourceMatches(positions[0])
&& id(positions[0].balanceId) === id(balance.id) && id(positions[0].palletId) === member.palletId && positions[0].palletNo === member.palletNo
&& balanceBuckets.every(field => String(balance[field]) === (field === 'availablePackages' ? '1' : '0'))
&& buckets.every(field => String(positions[0][field]) === (field === 'availablePackages' ? '1' : '0')))
const movement = ledger.records[0]
check('Exactly one immutable STOCK_IN movement proves the real receipt', sourceMatches(movement) && id(movement.id)
&& movement.businessType === 'STOCK_IN' && movement.businessOrderNo === member.incomingNo
&& movement.palletNo === member.palletNo
&& String(movement.beforePackages) === '0' && String(movement.deltaPackages) === '1' && String(movement.afterPackages) === '1')
// InventoryLedgerView intentionally exposes no balanceId/positionId. Only the complete raw reader proves those links.
const scope = { marker: context.marker, customerId: member.customerId, warehouseId: id(warehouseId) }
const raw = await readInventory(scope)
check('Raw receipt reader proves complete current customer and warehouse scope', raw?.scope?.marker === scope.marker
&& raw.scope.customerId === scope.customerId && raw.scope.warehouseId === scope.warehouseId
&& raw.scope.coverage === 'CUSTOMER_WAREHOUSE_COMPLETE')
for (const collection of ['balances', 'positions', 'ledger']) check(`Fresh customer raw ${collection} has exactly one original receipt row`,
Array.isArray(raw[collection]) && raw[collection].length === 1 && id(raw[collection][0].id) && sourceMatches(raw[collection][0]))
const rawBalance = raw.balances[0], rawPosition = raw.positions[0], rawMovement = raw.ledger[0]
check('Raw receipt identities and quantities match the public evidence', id(rawBalance.id) === id(balance.id)
&& id(rawPosition.id) === id(positions[0].id) && id(rawPosition.balanceId) === id(balance.id)
&& id(rawPosition.palletId) === member.palletId && rawPosition.palletNo === member.palletNo
&& balanceBuckets.every(field => String(rawBalance[field]) === String(balance[field]))
&& buckets.every(field => String(rawPosition[field]) === String(positions[0][field]))
&& id(rawMovement.id) === id(movement.id) && id(rawMovement.balanceId) === id(balance.id) && id(rawMovement.positionId) === id(positions[0].id)
&& rawMovement.businessType === 'STOCK_IN' && rawMovement.businessOrderNo === member.incomingNo
&& rawMovement.palletNo === member.palletNo && ['beforePackages', 'deltaPackages', 'afterPackages'].every(field => String(rawMovement[field]) === String(movement[field])))
return { balance, positions, ledger: ledger.records, raw }
}
let fixture, failure
try {
// Explicit IDs are re-read through the public master-data API before any fixture writes.
const origin = await request('GET', `/api/platform/v1/warehouse-admin/${encode(warehouseId)}?tenantId=0`, undefined, 'Verify receiving warehouse')
const destination = await request('GET', `/api/platform/v1/warehouse-admin/${encode(destinationWarehouseId)}?tenantId=0`, undefined, 'Verify FBA destination')
const validMaster = (detail, expectedId, type) => id(detail?.warehouse?.id) === id(expectedId)
&& id(detail.warehouse.tenantId) === '0' && detail.warehouse.status === 'ACTIVE' && detail.warehouse.warehouseType === type
&& detail.warehouse.warehouseCode && detail.warehouse.warehouseName
check('Origin and destination are current public active warehouse masters', validMaster(origin, warehouseId, 'OVERSEAS')
&& origin.capabilities?.some(row => row.capabilityCode === 'RECEIVING' && row.enabled === true)
&& validMaster(destination, destinationWarehouseId, 'FBA'))
const address = destination.addresses?.find(row => row.addressLine1)
check('FBA destination has a real address', Boolean(address))
const deliveryAddress = [address.countryCode, address.stateProvince, address.city, address.district,
address.addressLine1, address.addressLine2, address.postalCode].filter(Boolean).join(' ')
for (const suffix of ['A', 'B']) {
const member = { marker: context.marker, customerMarker: `${context.marker}-CROSS-${suffix}`, receiptVerified: false }
proof.members.push(member)
// Qualification helper's mutable fixture, options and resources must not touch the parent's merchant slot.
const child = Object.create(context)
child.marker = member.customerMarker
child.options = { ...context.options, customerName: `跨客户车次-${key(suffix).slice(0, 12)}-${suffix}` }
child.context = {}; child.createdResources = {}
child.request = async (method, path, body, token = context.token) => {
const response = await context.request(method, path, body, token)
record(method, path, body, response, `Qualify fresh customer ${suffix}`)
if (method === 'POST' && path === '/api/platform/v1/iam/tenants')
member.customerId = id((response.body.data?.tenant || response.body.data?.customer || response.body.data)?.id || response.body.data?.tenantId)
return response
}
await createQualifiedMerchant(child, { publishEnvironment: false })
const credentials = child.context.merchantQualificationFixture
check('Qualification belongs to this independent current-run context', credentials?.marker === member.customerMarker
&& id(credentials.tenantId) === member.customerId && proof.members.filter(row => row.customerId === member.customerId).length === 1)
member.qualification = child.createdResources.merchantQualificationFixture
let auth
try {
auth = await request('POST', '/api/auth/login', { loginId: credentials.username, password: credentials.password }, `Login fresh customer ${suffix}`, '')
} finally { delete child.context.merchantQualificationFixture }
// Retain a returned session for cleanup even if the login contract is incomplete.
if (auth?.accessToken) sessions.push({ customerId: member.customerId, accessToken: auth.accessToken, refreshToken: auth.refreshToken })
check('Fresh customer session is complete', auth?.accessToken && auth.refreshToken)
const profile = await request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, 'Verify newly authenticated customer identity', auth.accessToken)
check('Merchant session belongs to the newly qualified customer', id(profile?.id) === member.customerId)
const createBody = { sourceLastlegOrderIds: [], operatingWarehouseId: id(warehouseId),
fbaWarehouseCode: destination.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: id(destinationWarehouseId),
destinationWarehouseCode: destination.warehouse.warehouseCode, destinationWarehouseName: destination.warehouse.warehouseName,
deliveryAddress, packageCount: 1, volumeCbm: 1, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: localTime(1800000),
needRelabel: false, remark: member.customerMarker, idempotencyKey: key(`CREATE-${suffix}`),
items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1 }] }
let incoming = await request('POST', MERCHANT, createBody, `Create new incoming ${suffix}`, auth.accessToken)
member.incomingNo = incoming?.transferNo
check('Ordinary transfer creates fresh incoming without borrowed source inventory', member.incomingNo && incoming.inboundCode === member.incomingNo
&& incoming.status === 'DRAFT' && id(incoming.operatingWarehouseId) === id(warehouseId))
const merchantPath = `${MERCHANT}/${encode(member.incomingNo)}`
incoming = await request('POST', `${merchantPath}/submit`, undefined, `Submit own incoming ${suffix}`, auth.accessToken)
check('Own incoming submitted for review', incoming?.status === 'PENDING_REVIEW')
incoming = await request('POST', `${ROOT}/warehouse-transfers/${encode(member.incomingNo)}/review?${own(member)}`,
{ decision: 'APPROVE', remark: member.customerMarker }, `Approve incoming ${suffix}`)
check('Approved fresh incoming awaits real PDA receipt', incoming?.status === 'WAIT_PDA_RECEIVING')
const pdaPath = `/api/pda/v1/last-leg/warehouse-transfers/${encode(member.incomingNo)}`
const task = await request('GET', pdaPath, undefined, 'PDA warehouse binding resolves exact new incoming', pdaToken)
check('PDA task is untouched and belongs to the requested receiving warehouse', task?.transferNo === member.incomingNo
&& task.inboundCode === member.incomingNo && id(task.operatingWarehouseId) === id(warehouseId)
&& task.status === 'WAIT_PDA_RECEIVING' && task.pallets?.length === 0)
const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${suffix}`),
cartonCount: 1, lengthCm: 100, widthCm: 100, heightCm: 100, volumeCbm: 1, grossWeightKg: 100 }, 'Measure one real test pallet', pdaToken)
check('Measurement created one pallet draft', measured?.pallets?.length === 1 && measured.pallets[0].id && measured.pallets[0].status === 'DRAFT')
const palletPath = `${pdaPath}/pallets/${encode(measured.pallets[0].id)}`
const generated = await request('POST', `${palletPath}/generate`, { idempotencyKey: key(`GENERATE-${suffix}`) }, 'Generate authoritative warehouse pallet', pdaToken)
const pallet = generated?.pallets?.[0]
check('Generated physical pallet has real references', generated?.pallets?.length === 1 && pallet.status === 'GENERATED' && pallet.warehousePalletId && pallet.palletNo)
member.palletId = id(pallet.warehousePalletId); member.palletNo = pallet.palletNo
const bound = await request('POST', `${palletPath}/bind`, { palletCode: member.palletNo, idempotencyKey: key(`BIND-${suffix}`) }, 'Bind scanned physical pallet', pdaToken)
check('Bound pallet is exactly the measured one-box pallet', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND'
&& id(bound.pallets[0].warehousePalletId) === member.palletId && Number(bound.pallets[0].cartonCount) === 1)
const photoPath = `${pdaPath}/measurement-photos/files`
const photo = record('POST', photoPath, undefined, await context.requestMultipart('POST', photoPath,
{ name: `${member.customerMarker}-TEST-MEASUREMENT.png`, type: 'image/png', content: testImage }, pdaToken), 'Upload TEST measurement evidence')
check('Measurement evidence is a stored file', photo?.fileId && Number(photo.sizeBytes) > 0)
incoming = await request('POST', `${pdaPath}/palletize`, { idempotencyKey: key(`PALLETIZE-${suffix}`), photoFileIds: [id(photo.fileId)] }, 'Complete real PDA receipt', pdaToken)
check('Real PDA receipt completed with original physical pallet', incoming?.status === 'WAIT_CUSTOMER_CONFIRMATION'
&& incoming.pallets?.length === 1 && incoming.pallets[0].status === 'COMPLETED' && id(incoming.pallets[0].warehousePalletId) === member.palletId)
incoming = await request('POST', `${merchantPath}/confirm-measurement`, { idempotencyKey: key(`CONFIRM-${suffix}`), remark: member.customerMarker }, 'Customer confirms measured incoming', auth.accessToken)
check('Customer confirmation makes ordinary incoming dispatchable', incoming?.transferNo === member.incomingNo && incoming.status === 'READY_FOR_DISPATCH')
member.receipt = await stock(member)
member.balanceId = id(member.receipt.balance.id); member.positionId = id(member.receipt.positions[0].id); member.receiptVerified = true
const dispatchBase = `${ROOT}/dispatches`
const candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: member.incomingNo })}`, undefined, 'Read own real received dispatch candidate')
check('Candidate has exact customer, incoming, warehouse and physical pallet ownership', candidates?.length === 1
&& id(candidates[0].customerId) === member.customerId && id(candidates[0].overseasWarehouseId) === id(warehouseId)
&& candidates[0].inboundCode === member.incomingNo && id(candidates[0].warehousePalletId) === member.palletId
&& candidates[0].palletNo === member.palletNo && candidates[0].sourceStage === 'RECEIVED' && Number(candidates[0].packageCount) === 1 && candidates[0].planPalletId)
member.draftToken = key(`DRAFT-${suffix}`)
const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken: member.draftToken, capacity: 30 }, 'Create isolated dispatch draft')
check('Dispatch draft has explicit container identity', container?.id)
member.draftContainerId = id(container.id)
await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidates[0].planPalletId), draftToken: member.draftToken,
draftContainerId: member.draftContainerId }, 'Lock this customer received pallet')
const dispatch = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds: [id(candidates[0].planPalletId)], draftToken: member.draftToken }, 'Prearrange one independent customer dispatch')
check('Dispatch contains exactly this unpicked pallet', dispatch?.dispatchNo && dispatch.status === 'PENDING_DEPARTURE'
&& dispatch.items?.length === 1 && id(dispatch.items[0].warehousePalletId) === member.palletId
&& Number(dispatch.packageCount) === 1 && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0)
member.dispatchNo = dispatch.dispatchNo
}
for (const field of ['customerId', 'incomingNo', 'palletId', 'positionId', 'balanceId', 'dispatchNo'])
check(`Both customers have independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2)
const tripProof = await prepareFleetTrip(context, MODULE, proof.members[0].dispatchNo, id(warehouseId))
proof.tripNo = tripProof.tripNo
const tripPath = `${ROOT}/fleet/trips/${encode(proof.tripNo)}`
let detail = await request('GET', tripPath, undefined, 'Read first member trip version')
detail = await request('POST', `${tripPath}/dispatches/bind`, { recordVersion: detail.trip.recordVersion,
dispatchNo: proof.members[1].dispatchNo, reason: context.marker }, 'Bind second independent customer dispatch')
const loading = await request('GET', `${tripPath}/loading`, undefined, 'Read complete two-customer cargo destinations')
check('Shared trip cargo consists of exactly both owned physical pallets', loading?.cargo?.length === 2 && proof.members.every(member =>
loading.cargo.some(row => row.dispatchNo === member.dispatchNo && id(row.customerId) === member.customerId
&& id(row.warehousePalletId) === member.palletId && row.palletNo === member.palletNo && row.destinationKey && Number(row.packages) === 1)))
const destinations = new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }]))
detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion,
plannedDepartureAt: detail.trip.plannedDepartureAt, expectedDriverEndAt: detail.trip.expectedDriverEndAt,
stops: [{ name: origin.warehouse.warehouseName }, ...destinations.values()], reason: context.marker }, 'Replace route with complete actual destinations')
tripProof.stops = detail.trip.stops
check('Route includes every actual destination', [...destinations.keys()].every(destinationKey => detail.trip.stops?.slice(1).some(row => row.destinationKey === destinationKey)))
for (const [index, member] of proof.members.entries()) {
const path = `${ROOT}/dispatches/${encode(member.dispatchNo)}`
const freight = await request('GET', `${path}/external-freight`, undefined, 'Verify ordinary incoming has no external quote')
check('Ordinary incoming requires its own explicit delivery fee', freight?.requiresDeliveryFee === true && freight.quotes?.length === 0)
const transferred = await request('POST', `${path}/transfer-delivery`, { idempotencyKey: key(`DELIVERY-${index}`), plannedDeliveryAt: localTime(21600000),
deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK',
vehicleSize: Number(detail.trip.maxPalletSpaces) <= 15 ? '15托' : '30托', vehiclePlate: detail.trip.vehiclePlate,
driverName: detail.trip.driverName, driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', remark: member.customerMarker,
fees: [{ feeType: 'DELIVERY_FEE', feeName: '受控测试派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 }] }, 'Create this customer outbound and delivery')
check('One customer dispatch produces one outbound and one delivery', transferred?.dispatchNo === member.dispatchNo && transferred.outboundNo
&& transferred.deliveries?.length === 1 && transferred.deliveries[0].deliveryNo && Number(transferred.packageCount) === 1)
member.outboundNo = transferred.outboundNo; member.deliveryNo = transferred.deliveries[0].deliveryNo
const delivery = await request('GET', `${ROOT}/delivery-orders/${encode(member.deliveryNo)}?${own(member)}`, undefined, 'Verify independently owned delivery')
const outbound = await request('GET', `${ROOT}/outbound-orders/${encode(member.outboundNo)}?${own(member)}`, undefined, 'Verify independently owned outbound')
const dispatch = await request('GET', path, undefined, 'Verify customer dispatch remains unpicked')
check('Customer execution records are distinct and untouched', id(delivery?.order?.customerId) === member.customerId
&& delivery.order.deliveryNo === member.deliveryNo && delivery.order.dispatchNo === member.dispatchNo && delivery.order.outboundNo === member.outboundNo
&& delivery.order.status === 'WAIT_DELIVERY' && outbound?.outbound?.outboundNo === member.outboundNo && outbound.outbound.dispatchNo === member.dispatchNo
&& id(outbound.outbound.overseasWarehouseId) === id(warehouseId) && outbound.outbound.status === 'WAIT_OUTBOUND'
&& outbound.deliveries?.length === 1 && id(outbound.deliveries[0].customerId) === member.customerId && outbound.deliveries[0].deliveryNo === member.deliveryNo
&& dispatch?.status === 'PENDING_DEPARTURE' && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0)
const finalStock = await stock(member)
check('Trip preparation preserves original available receipt records', JSON.stringify(finalStock) === JSON.stringify(member.receipt))
}
detail = await request('GET', tripPath, undefined, 'Verify final two-customer shared trip membership')
check('Final trip has only the two fresh customers and remains planned', detail?.trip?.status === 'PLANNED' && detail.dispatches?.length === 2
&& proof.members.every(member => detail.dispatches.some(row => row.dispatchNo === member.dispatchNo && id(row.customerId) === member.customerId)))
for (const field of ['outboundNo', 'deliveryNo']) check(`Independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2)
fixture = { marker: context.marker, stage: fixtureContract.stage, tripNo: proof.tripNo, warehouseId: id(warehouseId), pdaToken,
members: proof.members.map(({ marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified }) =>
({ marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified })) }
} catch (error) { failure = error }
// Always attempt every successfully opened session, including partial fixture failures. PDA lifecycle belongs to caller.
const cleanupErrors = []
for (const session of sessions) {
try {
await request('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, 'Logout owned merchant fixture session', session.accessToken)
proof.sessionCleanup.push({ customerId: session.customerId, status: 'LOGGED_OUT' })
} catch (error) {
cleanupErrors.push(error); proof.sessionCleanup.push({ customerId: session.customerId, status: 'FAILED' })
}
}
proof.status = failure || cleanupErrors.length ? 'FAILED' : 'READY'
if (failure && !cleanupErrors.length) throw failure
if (cleanupErrors.length) throw new AggregateError([...(failure ? [failure] : []), ...cleanupErrors], 'Fixture/session cleanup failed; retain recorded resources')
return fixture
}