test(dispatch): verify multi-photo evidence and synchronize PDF storage assertions
This commit is contained in:
@@ -45,8 +45,9 @@ class OwnedBusinessPhotoFileTest {
|
||||
verifyNoInteractions(files, storage);
|
||||
}
|
||||
|
||||
/** 装车驾驶证允许 PDF;油耗仍限图片,两类都拒绝空文件、伪造内容和超限文件。 */
|
||||
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||
void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception {
|
||||
void enforcesLoadingAndFuelFormatPoliciesAndRejectsInvalidFiles(String type) throws Exception {
|
||||
var storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
storage.initializeRoot();
|
||||
@@ -60,8 +61,14 @@ class OwnedBusinessPhotoFileTest {
|
||||
for (var upload : new MockMultipartFile[]{
|
||||
new MockMultipartFile("file", "empty.png", "image/png", new byte[0]),
|
||||
new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()),
|
||||
new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())})
|
||||
new MockMultipartFile("file", "spoof.pdf", "application/pdf", "not-pdf".getBytes())})
|
||||
assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class);
|
||||
var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nTEST license".getBytes());
|
||||
if (LOADING.equals(type)) {
|
||||
var saved = storage.store(0, "71", pdf, category);
|
||||
assertThat(saved.contentType()).isEqualTo("application/pdf");
|
||||
assertThat(storage.read(saved.objectKey())).isEqualTo(pdf.getBytes());
|
||||
} else assertThatThrownBy(() -> storage.store(0, "71", pdf, category)).isInstanceOf(BizException.class);
|
||||
MultipartFile oversized = mock(MultipartFile.class);
|
||||
when(oversized.isEmpty()).thenReturn(false);
|
||||
when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1);
|
||||
|
||||
@@ -8,6 +8,8 @@ Admin 全量可显式传 `--stop-file <本轮唯一停止文件>`。运行前文
|
||||
|
||||
`dispatch-loading-flow-test-env.mjs` 是 TEST 固定客户两托一车的定向 HTTP 验收入口,验证实收入库、确认预约、审核、扫码拣货、五张装车图片的 Admin 读取、自动出库派送及服务端幂等。默认只预览;显式 `--execute --run-id <唯一标识> --warehouse-id <起运仓> --destination-warehouse-id <FBA仓> --test-image <明确标注TEST的PNG>` 写入业务测试记录,通过 `node --env-file="<统一TEST配置>"` 读取凭据、固定客户、`ADMIN_TEST_OVERSEAS_PDA_ACTORS` 和 `ADMIN_TEST_FLEET_RESOURCES`;不自动创建账号或车辆司机主档。可加 `--stop-after-draft` 准备浏览器验收样本:新建两托及正式预约后停在零已拣草稿,不分配车辆、不送审、不拣货;报告标记 `DRAFT_FIXTURE_ONLY`,不代表完整链路通过,不与 `--resume-report` 混用。报告位于 `reports/dispatch-loading-<run-id>/`,保留失败前的请求和业务数据;同标识拒绝重跑。`--resume-report <原报告>` 仅接受准备写入前失败或全部准备写入均收到明确拒绝的失败报告,回查原单仍为零拣货草稿后重新准备车辆、三类审核照片与审核,不重复建货或建单;未知或成功写入不能使用此恢复入口。固定PDA账号仅注销会话,不停用;`--keep-pda-for-ui` 在成功后将账号交接信息保存到系统临时目录,页面验收使用私有配置中的既有凭据重新登录,不能将已注销Token当有效会话。所有 HTTP 会话结束时注销,不执行 SQL、删除、重置或部署。新增入口验证运行语法检查和 `script-documentation-contract.test.mjs`,经 `local-check.ps1`。
|
||||
|
||||
该入口可追加 `--verify-loading-documents --replacement-vehicle-id <独立TEST车辆ID> --replacement-driver-id <独立TEST司机ID>`,在本轮零拣货样本上验证审核自动带入、换车换司机后五类旧资料失效、旧轮次上传/绑定拒绝、平台/PDA混合上传至每类5张、第6张拒绝、满额替换及幂等重放、双端25张内容一致,再继续拣货与自动派送。替换资源必须已维护车牌照片、司机照片与已审核驾驶证,且与初次资源不同;脚本不创建或修改主档,不与草稿模式或旧报告恢复混用。
|
||||
|
||||
Admin/Client TEST 回归统一配置 `ADMIN_TEST_FIXED_CUSTOMER_ID` 与 `MERCHANT_TEST_USERNAME/PASSWORD`,复用指定 ACTIVE 客户;独立 Admin runner 同样读取这些显式环境变量。Client 默认不再注册审核、修改固定客户资料/账号、创建成员角色或通知,也不运行余额/渠道共享配置及缺第二客户的隔离场景;这些范围列为 skipped,`fullCoverage=false`,执行范围通过不代表全量覆盖。Admin 排除的主档生命周期范围见模块 README。业务单据及附件仍按本轮证据需要新增;不自动清理历史记录。
|
||||
|
||||
## TEST 业务数据重置准备
|
||||
|
||||
@@ -20,13 +20,16 @@ export function parseFlowArguments(args) {
|
||||
if(args[i]==='--execute')config.execute=true
|
||||
else if(args[i]==='--keep-pda-for-ui')config.keepPda=true
|
||||
else if(args[i]==='--stop-after-draft')config.stopAfterDraft=true
|
||||
else if(args[i]==='--verify-loading-documents')config.verifyLoadingDocuments=true
|
||||
else {
|
||||
const key={'--run-id':'runId','--warehouse-id':'warehouseId','--destination-warehouse-id':'destinationId','--test-image':'image','--resume-report':'resumeReport'}[args[i]]
|
||||
const key={'--run-id':'runId','--warehouse-id':'warehouseId','--destination-warehouse-id':'destinationId','--test-image':'image','--resume-report':'resumeReport','--replacement-vehicle-id':'replacementVehicleId','--replacement-driver-id':'replacementDriverId'}[args[i]]
|
||||
if(!key || config[key] || !args[i+1] || args[i+1].startsWith('--'))throw Error('Unsupported or duplicate argument')
|
||||
config[key]=args[++i]
|
||||
}
|
||||
}
|
||||
if(config.execute && (!/^[A-Za-z0-9_-]{1,48}$/.test(config.runId || '') || !/^[1-9]\d*$/.test(config.warehouseId || '') || !/^[1-9]\d*$/.test(config.destinationId || '') || !config.image))throw Error('Explicit run, warehouses and synthetic image required')
|
||||
if(config.verifyLoadingDocuments && (config.stopAfterDraft || config.resumeReport || !/^[1-9]\d*$/.test(config.replacementVehicleId || '') || !/^[1-9]\d*$/.test(config.replacementDriverId || '')))throw Error('Loading document verification requires explicit replacement resources and a fresh full flow')
|
||||
if(!config.verifyLoadingDocuments && (config.replacementVehicleId || config.replacementDriverId))throw Error('Replacement resources require loading document verification')
|
||||
if(config.stopAfterDraft && config.resumeReport)throw Error('Draft UI preparation requires a fresh attempt')
|
||||
return config
|
||||
}
|
||||
@@ -69,6 +72,8 @@ export async function runLoadingFlow(config, env=process.env) {
|
||||
if(r.http!==200 || id(r.body?.code)!=='200')throw Error(`Upload failed ${path}: ${r.body?.message}, requestId=${r.requestId}`)
|
||||
return r.body.data
|
||||
}
|
||||
// 二进制助手使用当前会话;串行切换后立即恢复,确保实际覆盖 PDA 文件权限。
|
||||
const binary=async(path,token=context.token)=>{const previous=context.token;try{context.token=token;return await context.requestBinary('GET',path)}finally{context.token=previous}}
|
||||
const phase=value=>{report.phase=value;save();console.log(JSON.stringify({phase:value,dispatchNo:report.dispatchNo}))}
|
||||
try {
|
||||
const auth=await send('POST','/api/auth/login',{loginId:options.username,password:options.password},'');context.token=auth.accessToken;context.refreshToken=auth.refreshToken
|
||||
@@ -130,9 +135,84 @@ export async function runLoadingFlow(config, env=process.env) {
|
||||
assert((previous.createdResources.fleetTripCommands||[]).every(c=>c.status==='RESPONDED' && c.http===200 && String(c.code)!=='200'),'Only a pre-write failure or definite rejected preparation may resume')
|
||||
report.trip=await prepareFleetTrip(context,'dispatch-loading-flow',report.dispatchNo,config.warehouseId)
|
||||
}else report.trip=await prepareFleetTrip(context,'dispatch-loading-flow',report.dispatchNo,config.warehouseId)
|
||||
if(config.verifyLoadingDocuments){
|
||||
phase('RESOURCE_PHOTO_ROUNDS_AND_FIVE_PER_CATEGORY')
|
||||
const read=()=>send('GET',path+'/loading-documents')
|
||||
const first=await read(), firstRound=id(first.reviewRound)
|
||||
check('approved trip evidence automatically imported',first.photos.some(p=>p.slot==='LOADING_SCENE') && first.photos.some(p=>p.slot==='LOADING_LIST') && first.photos.every(p=>['LOADING_SCENE','LOADING_LIST'].includes(p.slot)))
|
||||
const append=async(slot,label,round,platform=false,replaceFileId)=>{
|
||||
const base=(platform?path:pp)+'/loading-documents',token=platform?context.token:picker.token,uploadKey=key('UP-'+label)
|
||||
const photo=await upload(`${base}/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${round}`,`${config.runId}-TEST-${label}.png`,token)
|
||||
const current=await read(),body={recordVersion:current.recordVersion,idempotencyKey:key('SAVE-'+label),fileId:id(photo.fileId),uploadKey,...(replaceFileId?{replaceFileId}:{})}
|
||||
return {photo,body,base,token}
|
||||
}
|
||||
for(const slot of slots.slice(2)){
|
||||
const p=await append(slot,'BEFORE-'+slot,firstRound)
|
||||
await send('PUT',`${p.base}/${slot}`,p.body,p.token)
|
||||
}
|
||||
const pending=await append('PRE_DEPARTURE_CHECK','OLD-PENDING',firstRound)
|
||||
const beforeChange=await read(),oldFiles=new Set(beforeChange.photos.map(p=>id(p.fileId)))
|
||||
let detail=await send('GET',path)
|
||||
check('replacement resources differ from original',id(detail.vehicleInfo.vehicle.vehicleId)!==config.replacementVehicleId && id(detail.vehicleInfo.driver.driverId)!==config.replacementDriverId)
|
||||
const vehicleArchive=await send('GET',`${ROOT}/fleet/resource-photos/VEHICLE/${config.replacementVehicleId}`)
|
||||
const driverArchive=await send('GET',`${ROOT}/fleet/drivers/${config.replacementDriverId}`)
|
||||
const driverPortrait=await send('GET',`${ROOT}/fleet/resource-photos/DRIVER/${config.replacementDriverId}`)
|
||||
assert(vehicleArchive.photo?.fileId && driverArchive.licenseFileId && driverPortrait.photo?.fileId,'Replacement resources require maintained vehicle photo, approved driver license and driver portrait')
|
||||
await send('POST',path+'/return-to-review',{recordVersion:detail.recordVersion,idempotencyKey:key('RETURN'),reason:'TEST zero-pick resource evidence invalidation'})
|
||||
detail=await send('GET',path)
|
||||
let result=await send('PUT',path,{recordVersion:detail.recordVersion,idempotencyKey:key('SWAP'),maintenance:detail.vehicleInfo.maintenance,
|
||||
vehicle:{mode:'MASTER',vehicleId:config.replacementVehicleId},driver:{mode:'MASTER',driverId:config.replacementDriverId,evidence:{expectedTaskEndAt:report.trip.expectedDriverEndAt}},
|
||||
plannedDepartureAt:report.trip.updateRequest.plannedDepartureAt,estimatedArrivalAt:report.trip.updateRequest.estimatedArrivalAt,remark:'TEST replacement with maintained archives'})
|
||||
for(const action of ['submit-review','dispatch-review','loading-review'])result=await send('POST',`${path}/${action}`,{recordVersion:result.recordVersion,idempotencyKey:key('SECOND-'+action),...(action==='submit-review'?{remark:'TEST second review'}:{decision:'APPROVED',reason:'TEST second review'})})
|
||||
const second=await read(),round=id(second.reviewRound)
|
||||
check('new review round invalidates all old resource evidence',Number(round)>Number(firstRound) && second.photos.length===2 && second.photos.every(p=>!oldFiles.has(id(p.fileId))) && ['LOADING_SCENE','LOADING_LIST'].every(s=>second.photos.some(p=>p.slot===s)))
|
||||
const sourcePaths={LOADING_SCENE:`${ROOT}/fleet/drivers/${config.replacementDriverId}/license/${driverArchive.licenseFileId}`,LOADING_LIST:`${ROOT}/fleet/resource-photos/VEHICLE/${config.replacementVehicleId}/${vehicleArchive.photo.fileId}/content`}
|
||||
report.importedArchives=[]
|
||||
for(const photo of second.photos){
|
||||
const original=await context.requestBinary('GET',sourcePaths[photo.slot])
|
||||
const copied=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`)
|
||||
const pdaCopy=await binary(`${pp}/loading-documents/${photo.slot}/files/${photo.fileId}/content`,picker.token)
|
||||
report.importedArchives.push({slot:photo.slot,fileId:photo.fileId,contentType:copied.contentType,sourceHttp:original.http,copyHttp:copied.http,pdaHttp:pdaCopy.http,sha256:hash(copied.bytes),requestId:copied.requestId,traceId:copied.traceId})
|
||||
check('maintained archive copied exactly '+photo.slot,original.http===200 && copied.http===200 && pdaCopy.http===200 && original.bytes.length>0 && hash(original.bytes)===hash(copied.bytes) && hash(original.bytes)===hash(pdaCopy.bytes))
|
||||
}
|
||||
await send('PUT',`${pending.base}/PRE_DEPARTURE_CHECK`,{...pending.body,recordVersion:second.recordVersion},pending.token,true)
|
||||
check('stale upload bind preserves current photos',JSON.stringify(await read())===JSON.stringify(second))
|
||||
const stale=await context.requestMultipart('POST',`${pp}/loading-documents/PRE_DEPARTURE_CHECK/uploads?idempotencyKey=${key('STALE-ROUND')}&reviewRound=${firstRound}`,{name:'TEST-stale-round.png',type:'image/png',content:image},picker.token)
|
||||
check('stale review round upload rejected',id(stale.body?.code)==='A0409')
|
||||
for(const [slotIndex,slot] of slots.entries()){
|
||||
let docs=await read(),count=docs.photos.filter(p=>p.slot===slot).length
|
||||
for(let index=count;index<5;index++){
|
||||
const p=await append(slot,`${slot}-${index}`,round,(index+slotIndex)%2===0)
|
||||
await send('PUT',`${p.base}/${slot}`,p.body,p.token)
|
||||
}
|
||||
docs=await read();check('five saved '+slot,docs.photos.filter(p=>p.slot===slot).length===5)
|
||||
const excess=await append(slot,'EXCESS-'+slot,round)
|
||||
const rejected=await context.request('PUT',`${excess.base}/${slot}`,excess.body,excess.token,{})
|
||||
check('sixth rejected '+slot,['A0400','A0409'].includes(id(rejected.body?.code)))
|
||||
check('sixth rejection preserves version '+slot,JSON.stringify(await read())===JSON.stringify(docs))
|
||||
const replace=await append(slot,'REPLACE-'+slot,round,true,id(docs.photos.find(p=>p.slot===slot).fileId))
|
||||
const replaced=await send('PUT',`${replace.base}/${slot}`,replace.body,replace.token)
|
||||
const replay=await send('PUT',`${replace.base}/${slot}`,replace.body,replace.token)
|
||||
check('full category replacement and replay '+slot,replaced.photos.filter(p=>p.slot===slot).length===5 && replaced.photos.some(p=>id(p.fileId)===id(replace.photo.fileId)) && !replaced.photos.some(p=>id(p.fileId)===replace.body.replaceFileId) && JSON.stringify(replay)===JSON.stringify(replaced))
|
||||
}
|
||||
const adminDocs=await read(),pdaDocs=await send('GET',pp+'/loading-documents',undefined,picker.token)
|
||||
check('Admin and PDA share all 25 photos',adminDocs.photos.length===25 && adminDocs.complete && JSON.stringify(adminDocs)===JSON.stringify(pdaDocs))
|
||||
report.sharedFiles=[]
|
||||
for(const photo of adminDocs.photos){
|
||||
const admin=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`)
|
||||
const pda=await binary(`${pp}/loading-documents/${photo.slot}/files/${photo.fileId}/content`,picker.token)
|
||||
report.sharedFiles.push({slot:photo.slot,fileId:photo.fileId,adminHttp:admin.http,pdaHttp:pda.http,adminSha256:hash(admin.bytes),pdaSha256:hash(pda.bytes),requestId:pda.requestId,traceId:pda.traceId})
|
||||
check('Admin and PDA file bytes '+photo.slot+' '+photo.fileId,admin.http===200 && pda.http===200 && hash(admin.bytes)===hash(image) && hash(pda.bytes)===hash(image))
|
||||
}
|
||||
report.loadingDocumentVerification={reviewRound:round,photos:25,replacementVehicleId:config.replacementVehicleId,replacementDriverId:config.replacementDriverId}
|
||||
}
|
||||
for(const [index,m] of report.members.entries()) {
|
||||
const task=await send('GET',pp,undefined,picker.token),item=task.items.find(i=>i.palletNo===m.palletNo);assert(item);m.dispatchItemId=id(item.dispatchItemId)
|
||||
const body={recordVersion:task.recordVersion,positionId:m.positionId,packages:1,productQuantity:1,idempotencyKey:key('PICK-'+index),remark:context.marker}
|
||||
// 新实收库存仍须走当前仓租预校验,不能用旧脚本绕过正式扫码前置。
|
||||
let preparation=await send('POST',PDA+'/storage-rent/preparations',{mode:'DISPATCH',businessNo:report.dispatchNo,itemId:m.dispatchItemId,positionId:m.positionId,packages:1,productQuantity:1,idempotencyKey:body.idempotencyKey,cartonMarks:[]},picker.token)
|
||||
for(let attempt=0;!preparation.ready && preparation.status==='FREEZE_PENDING' && attempt<20;attempt++){await new Promise(resolve=>setTimeout(resolve,500));preparation=await send('GET',PDA+'/storage-rent/preparations/'+preparation.preparationId,undefined,picker.token)}
|
||||
check('storage rent ready before pick '+index,preparation.ready===true)
|
||||
const picked=await send('POST',`${pp}/items/${m.dispatchItemId}/pick`,body,picker.token)
|
||||
const replay=await send('POST',`${pp}/items/${m.dispatchItemId}/pick`,body,picker.token);check('pick replay '+index,replay.replayed===true && id(replay.recordVersion)===id(picked.recordVersion))
|
||||
if(index===0){const preview=await send('GET',pp+'/loading',undefined,picker.token);await send('POST',pp+'/loading-complete',{recordVersion:preview.recordVersion,manifestHash:preview.manifestHash,idempotencyKey:key('INCOMPLETE')},picker.token,true)}
|
||||
@@ -142,8 +222,8 @@ export async function runLoadingFlow(config, env=process.env) {
|
||||
phase('PDA_PHOTOS_AND_ADMIN_FILE_READBACK')
|
||||
const initialDocuments=await send('GET',pp+'/loading-documents',undefined,picker.token),reviewRound=id(initialDocuments.reviewRound),uploadedFiles=new Set()
|
||||
assert.match(reviewRound,/^[1-9]\d*$/)
|
||||
for(const slot of slots){const uploadKey=key('UPLOAD-'+slot);const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${reviewRound}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);assert.equal(id(docs.reviewRound),reviewRound);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId),uploadKey},picker.token);uploadedFiles.add(id(up.fileId))}
|
||||
const docs=await send('GET',path+'/loading-documents');check('Admin sees imported and five added photos',docs.complete && docs.photos.length===initialDocuments.photos.length+5)
|
||||
if(!config.verifyLoadingDocuments)for(const slot of slots){const uploadKey=key('UPLOAD-'+slot);const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${reviewRound}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);assert.equal(id(docs.reviewRound),reviewRound);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId),uploadKey},picker.token);uploadedFiles.add(id(up.fileId))}
|
||||
const docs=await send('GET',path+'/loading-documents');check('Admin sees all saved photos',docs.complete && docs.photos.length===initialDocuments.photos.length+(config.verifyLoadingDocuments?0:5))
|
||||
report.photos=[]
|
||||
for(const photo of docs.photos){const r=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`);check('Admin bytes '+photo.slot,r.http===200 && r.bytes.length>0 && (!uploadedFiles.has(id(photo.fileId)) || hash(r.bytes)===hash(image)));report.photos.push({slot:photo.slot,fileId:photo.fileId,sha256:hash(r.bytes)})}
|
||||
phase('AUTOMATIC_OUTBOUND_AND_DELIVERY')
|
||||
|
||||
Reference in New Issue
Block a user