144 lines
13 KiB
JavaScript
144 lines
13 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('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\(/)
|
||
|
|
})
|