Files
baiyue-firstleg/scripts/appointment-work-test-env.test.mjs

160 lines
14 KiB
JavaScript

import assert from 'node:assert/strict'
import test from 'node:test'
import { spawnSync } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { options, plan, validateFixture, validatePage, assertItem, verify, execute } from './appointment-work-test-env.mjs'
import { guardFleetWrites } from './fleet-test-env-acceptance.mjs'
const WORK='/api/platform/v1/last-leg/appointment-work', API='/api/platform/v1/last-leg'
const now=Date.parse('2026-10-15T08:00:00Z')
function sample() {
return {schemaVersion:1,environment:'TEST',runId:'aw-test',producerRunId:'producer-test',producedAt:'2026-10-15T07:00:00Z',producerReport:'reports/producer/report.json',producerSha256:'a'.repeat(64),
cases:[{name:'private',containerBookingId:'1',containerIds:['1'],type:'DELIVERY',flow:'TRANSFER',requirementKey:'DISPATCH:20',batchKey:'DISPATCH:20',batchNo:'D-20',expected:{executionStatus:'PENDING',planStatus:'UNPLANNED',dateBasis:'NONE',calendarDate:null}}],
batches:[{case:'private',kind:'CUSTOMER',dispatchNo:'D-20',packageCount:4,pallets:[{planPalletId:'10',packageCount:4}]}],sharedTrips:[],retired:[],workflow:{case:'private',ownerId:'90',followupDate:'2026-10-16',noBooking:true}}
}
function workItem(c=sample().cases[0]) {
return {workItemKey:'WORK-1',version:1,type:c.type,flow:c.flow,requirementKey:c.requirementKey,batchKey:c.batchKey,batchNo:c.batchNo,businessNo:'D-20',
containers:c.containerIds.map(id=>({containerBookingId:id,containerNo:`CNT-${id}`,bookingNo:null})),...c.expected,actual:c.expected.actual||null,
allowedActions:[{code:'SAVE_FOLLOWUP'},{code:'SAVE_REQUIREMENT'}],blockReasons:[],reviewRequired:false,owner:null,followupDate:null}
}
function response(data,http=200,code='200') {return {http,body:{code,data},requestId:'REQ',traceId:'TRACE'}}
function harness(fixture=sample()) {
const requests=[], detail={item:workItem(fixture.cases[0]),history:[],references:[],relatedItems:[],requirement:{bookingRequired:true},review:null,returnHandoff:null},replays=new Map()
const clone=v=>structuredClone(v)
async function request(actor,method,path,body) {
requests.push({actor,method,path,body:clone(body)})
if(actor==='anonymous')return response(null,401,'A0401')
if(actor==='merchant'||actor==='reader'&&method==='POST')return response(null,403,'A0403')
if(path===`${WORK}/summary`)return response({all:'1',followToday:'0',overdue:'0',undated:'1',review:'0'})
if(path.startsWith(`${WORK}/items?`))return response({records:[clone(detail.item)],page:'1',size:'100',total:'1',pages:'1'})
if(path===`${WORK}/items/WORK-1`&&method==='GET')return response(clone(detail))
if(path===`${API}/dispatches/D-20`)return response({id:'20',dispatchNo:'D-20',packageCount:4,items:[{planPalletId:'10',packageCount:4}]})
if(path===`${WORK}/items/WORK-1/commands`) {
if(replays.has(body.idempotencyKey)){const old=replays.get(body.idempotencyKey);return JSON.stringify(old.body)===JSON.stringify(body)?response(clone(old.result)):response(null,409,'A0409')}
if(body.expectedVersion!==detail.item.version)return response(null,409,'A0409')
if(body.command==='SAVE_FOLLOWUP'){detail.item.owner={id:body.payload.ownerId,name:'验收操作员'};detail.item.followupDate=body.payload.followupDate}
else if(body.command==='SAVE_REQUIREMENT'){detail.item.planStatus='NOT_REQUIRED';detail.requirement.bookingRequired=false}
else throw Error('Unexpected command')
detail.item.version++;detail.history.push({action:body.command,summary:'fixture'})
const result={item:clone(detail.item),affectedWorkItemKeys:[]};replays.set(body.idempotencyKey,{body:clone(body),result:clone(result)});return response(result)
}
throw Error(`Unexpected request ${method} ${path}`)
}
return {request,requests,detail}
}
test('default and explicit plan perform no network and no fixture/file reads',async()=>{
const original=globalThis.fetch;globalThis.fetch=()=>{throw Error('NETWORK_FORBIDDEN')}
try {assert.equal((await execute([],[],{})).execute,false);assert.deepEqual(await execute(['--plan'],[],{}),plan())}finally{globalThis.fetch=original}
const child=spawnSync(process.execPath,['scripts/appointment-work-test-env.mjs','--plan'],{encoding:'utf8'})
assert.equal(child.status,0,child.stderr);assert.equal(JSON.parse(child.stdout).execute,false)
})
test('execution rejects ambiguous environments, duplicate args and wrong private profile',()=>{
const args=['--execute','--environment=TEST','--run-id=aw-test','--mode=workflow','--fixture=reports/new/fixture.json']
const env={CHONGCHONG_ENV:'test',CHONGCHONG_ADMIN_API_BASE_URL:'https://example.invalid/api',CHONGCHONG_ADMIN_USERNAME:'admin',CHONGCHONG_ADMIN_PASSWORD:'secret',CHONGCHONG_MERCHANT_USERNAME:'customer',CHONGCHONG_MERCHANT_PASSWORD:'secret',APPOINTMENT_WORK_READER_USERNAME:'reader',APPOINTMENT_WORK_READER_PASSWORD:'secret'}
const flags=['--env-file=C:/Users/test/.codex/env/chongchong-global/test-acceptance.env']
assert.equal(options(args,flags,env).mode,'workflow')
assert.throws(()=>options([...args,'--mode=read'],flags,env))
assert.throws(()=>options(args,flags,{...env,CHONGCHONG_ENV:'prod'}))
assert.throws(()=>options(args,['--env-file=other.env'],env))
assert.throws(()=>options(args,flags,{...env,CHONGCHONG_ADMIN_API_BASE_URL:'https://example.invalid/overseas-api'}))
assert.throws(()=>options(args,flags,{...env,APPOINTMENT_WORK_READER_PASSWORD:''}))
})
test('fixture requires current producer evidence and explicit nonempty expectations',()=>{
assert.equal(validateFixture(sample(),{runId:'aw-test',mode:'workflow'},now).cases.length,1)
for(const patch of [{cases:[]},{runId:'old'},{producedAt:'2026-10-13T00:00:00Z'},{producerSha256:''},{batches:[{case:'private',kind:'CUSTOMER',dispatchNo:'D-20',packageCount:4,pallets:[]}]}])assert.throws(()=>validateFixture({...sample(),...patch},{runId:'aw-test',mode:'workflow'},now))
const incomplete=sample();incomplete.cases[0].expected.executionStatus='COMPLETED';assert.throws(()=>validateFixture(incomplete,{runId:'aw-test',mode:'read'},now))
})
test('pagination and actual evidence cannot silently truncate or invent completion',()=>{
assert.throws(()=>validatePage({records:[],page:1,size:100,total:1,pages:1},1))
const c=sample().cases[0], item=workItem(c);assertItem(item,c)
assert.throws(()=>assertItem({...item,actual:{localOccurredAt:'2026-10-15T11:00:00',occurredAt:null,timezone:null}},c))
assert.throws(()=>assertItem({...item,containers:[{containerBookingId:'foreign'}]},c))
})
test('Long wire counters preserve exact pagination and reject missing or unsafe counts',()=>{
const page={records:[{workItemKey:'WORK-1'}],page:'1',size:'100',total:'1',pages:'1'}
assert.equal(validatePage(page,1).length,1)
for(const total of [null,undefined,'',true,'1.5','01','9007199254740993',-1])assert.throws(()=>validatePage({...page,total},1))
assert.throws(()=>validatePage({...page,page:null},1))
assert.throws(()=>validatePage({...page,pages:'2'},1))
})
test('omitted nullable wire fields remain unknown and never imply completion',()=>{
const c=sample().cases[0], item=workItem(c)
delete item.actual; delete item.calendarDate
assert.equal(assertItem(item,c),item)
assert.throws(()=>assertItem({...item,actual:{localOccurredAt:'2026-10-15T10:00:00'}},c))
assert.throws(()=>assertItem({...item,executionStatus:undefined},c))
})
test('workflow runs denial/replay/stale/no-booking checks without original-business writes',async()=>{
const h=harness(),result=await verify(h,sample(),{mode:'workflow',runId:'aw-test'})
assert.ok(result.checks.includes('no booking does not mean executed'))
assert.ok(result.checks.includes('stale version rejected'))
assert.equal(h.detail.item.executionStatus,'PENDING');assert.equal(h.detail.item.actual,null)
assert.equal(h.detail.history.length,2)
assert.ok(h.requests.filter(r=>r.method!=='GET').every(r=>r.method==='POST'&&r.path===`${WORK}/items/WORK-1/commands`))
assert.ok(!h.requests.some(r=>r.path.includes('/DISPATCH%3A20/dispatch-candidates')))
})
test('read mode is GET-only and rejects private dispatch identity or quantity drift',async()=>{
const h=harness();await verify(h,sample(),{mode:'read',runId:'aw-test'});assert.ok(h.requests.every(r=>r.method==='GET'))
for(const wrong of [{id:'21',dispatchNo:'D-20',packageCount:4,items:[{planPalletId:'10',packageCount:4}]},{id:'20',dispatchNo:'D-20',packageCount:4,items:[{planPalletId:'10',packageCount:3}]}]){
const h=harness();await assert.rejects(()=>verify({request:async(...args)=>args[2]===`${API}/dispatches/D-20`?response(wrong):h.request(...args)},sample(),{mode:'read',runId:'aw-test'}))
}
})
test('denied writes must leave history and versions unchanged',async()=>{
const h=harness();await assert.rejects(()=>verify({request:async(...args)=>{const r=await h.request(...args);if(args[0]==='reader'&&args[1]==='POST')h.detail.item.version++;return r}},sample(),{mode:'workflow',runId:'aw-test'}),/Denied write/)
})
test('FBA batch source allocation and candidate quantity are independently checked',async()=>{
const f=sample(),c=f.cases[0];Object.assign(c,{name:'fba',batchKey:'FBA_APPOINTMENT:20',requirementKey:'FBA_APPOINTMENT:20',batchNo:'AP-20'})
const expected={customerId:'C1',sourceKey:'ORDER:1',destinationKey:'FBA:ONT8',packageCount:4}
f.batches=[{case:'fba',kind:'FBA',appointmentNo:'AP-20',sources:[expected],minimumReadyCandidates:1}]
const h=harness(f)
let count=4
const request=async(...args)=>{
if(args[2]===`${API}/appointment-planning/batches/FBA_APPOINTMENT%3A20`)return response({appointment:{batchKey:c.batchKey,appointmentNo:'AP-20',version:1,items:[expected]}})
if(args[2].endsWith('/dispatch-candidates'))return response({batchKey:c.batchKey,version:1,blockReasons:[],candidates:[{planPalletId:'10',customerId:'C1',packageCount:4,blockReasons:[],allocations:[{...expected,packageCount:count,batchKey:c.batchKey,planPalletId:'10'}]}]})
return h.request(...args)
}
await verify({request},f,{mode:'read',runId:'aw-test'});count=3;await assert.rejects(()=>verify({request},f,{mode:'read',runId:'aw-test'}),/quantity conservation/)
})
test('shared trip checks original dispatch identities, quantities and current containers',async()=>{
const f=sample(),c=f.cases[0];Object.assign(c,{name:'trip',type:'TRANSPORT',containerIds:['1','2'],batchKey:null,batchNo:null,requirementKey:null});f.batches=[]
f.sharedTrips=[{case:'trip',tripNo:'TR-1',tripId:'100',containerIds:['1','2'],dispatches:[{dispatchNo:'D-20',customerId:'C1',packageCount:4}]}]
const h=harness(f);h.detail.item.businessNo='TR-1';h.detail.references=[{kind:'FLEET_TRIP',key:'100',businessNo:'TR-1'}]
const request=async(...args)=>args[2]===`${API}/fleet/trips/TR-1`?response({trip:{tripNo:'TR-1'},dispatches:f.sharedTrips[0].dispatches}):h.request(...args)
await verify({request},f,{mode:'read',runId:'aw-test'})
h.detail.references[0].key='999';await assert.rejects(()=>verify({request},f,{mode:'read',runId:'aw-test'}),/canonical trip identity/)
})
test('completed local facts keep their explicit date and do not disappear behind the pending filter',async()=>{
const f=sample();f.batches=[];Object.assign(f.cases[0].expected,{executionStatus:'COMPLETED',dateBasis:'ACTUAL',calendarDate:'2026-10-15',actual:{occurredAt:null,localOccurredAt:'2026-10-15T23:30:00',timezone:null}})
const h=harness(f),request=async(...args)=>args[2].startsWith(`${WORK}/calendar?`)?response({days:[{date:'2026-10-15',total:1,typeCounts:{DELIVERY:1}}]}):h.request(...args)
await verify({request},f,{mode:'read',runId:'aw-test'})
assert.ok(h.requests.some(r=>r.path.includes('/items?')&&r.path.includes('date=2026-10-15')))
assert.equal(h.requests.filter(r=>r.method==='POST').length,0)
})
test('retired cancellation requires visible original history and exact replacement identities',async()=>{
const f=sample();f.retired=[{workItemKey:'OLD',containerBookingId:'1',replacementCases:['private']}]
const h=harness();let replacements=[structuredClone(h.detail.item)]
const request=async(...args)=>args[2]===`${WORK}/items/OLD`?response({history:[{action:'REPLACED'}],item:{allowedActions:[{code:'VIEW_HISTORY'}]},relatedItems:replacements}):h.request(...args)
await verify({request},f,{mode:'read',runId:'aw-test'});replacements=[]
await assert.rejects(()=>verify({request},f,{mode:'read',runId:'aw-test'}),/original requirement restored/)
})
test('existing write guard persists before send and halts after unknown result',async()=>{
const events=[],context={options:{},request:async()=>{events.push('SENT');return {http:503,body:null}}}
guardFleetWrites(context,async entry=>events.push(entry.status))
await assert.rejects(()=>context.request('POST',`${WORK}/items/WORK-1/commands`,{}),/Unknown write outcome/)
await assert.rejects(()=>context.request('POST',`${WORK}/items/WORK-1/commands`,{}))
assert.deepEqual(events,['SENDING','SENT','UNKNOWN_RESULT'])
let sends=0;const diskFailure={options:{},request:async()=>{sends++}}
guardFleetWrites(diskFailure,async()=>{throw Error('DISK_FAILED')})
await assert.rejects(()=>diskFailure.request('POST','/api/auth/login',{}),/DISK_FAILED/);assert.equal(sends,0)
})
test('entry keeps report exclusive and reuses formal transport without SQL/internal sync/deploy',async()=>{
const code=await readFile(new URL('./appointment-work-test-env.mjs',import.meta.url),'utf8')
assert.match(code,/new AcceptanceRun/);assert.match(code,/guardFleetWrites\(transport,checkpoint\)/)
assert.match(code,/await mkdir\(directory\)/);assert.match(code,/'wx',0o600/);assert.match(code,/await file\.sync\(\)/)
assert.match(code,/digest\(producer\)===fixture\.producerSha256/)
assert.doesNotMatch(code,/mysql|JENKINS_|\/internal\/|fetch\(/)
})