115 lines
6.5 KiB
JavaScript
115 lines
6.5 KiB
JavaScript
import test from 'node:test'
|
|||
|
|
import assert from 'node:assert/strict'
|
||
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||
|
|
import { tmpdir } from 'node:os'
|
||
|
|
import { join } from 'node:path'
|
||
|
|
import { runDeliveryStage, validateDeliveryInput, verifyOutbound, withDeliveryJournal } from './prod-release-delivery-stage.mjs'
|
||
|
|
|
||
|
|
const input = { batch: 'acceptance-1234', orderId: '101', orderNo: 'FL-101', tailOrderId: '201',
|
||
|
|
warehouseId: '301', inboundCode: 'IN-101', dispatchNo: 'DS-101', outboundNo: 'OB-101', deliveryNo: 'DL-101', packageCount: 1 }
|
||
|
|
function fixture() {
|
||
|
|
const dispatch = { dispatchNo: 'DS-101', overseasWarehouseId: '301', status: 'DEPARTED', packageCount: 1,
|
||
|
|
shippedPackageCount: 1, pickedPackageCount: 1, remainingPackageCount: 0, items: [{ inboundCode: 'IN-101' }] }
|
||
|
|
const outbound = { outbound: { outboundNo: 'OB-101', dispatchNo: 'DS-101', overseasWarehouseId: '301', status: 'OUTBOUNDED', packageCount: 1 },
|
||
|
|
deliveries: [{ deliveryNo: 'DL-101' }], pallets: [{ inboundCode: 'IN-101', packageCount: 1, pickedPackageCount: 1, status: 'PICKED' }] }
|
||
|
|
const tail = { order: { id: '201', customerId: '501', sourceOrderNo: 'FL-101', inboundCode: 'IN-101', overseasWarehouseId: '301', subOrderStatus: 'OUTBOUND:OUTBOUNDED' } }
|
||
|
|
return { dispatch, outbound, tail }
|
||
|
|
}
|
||
|
|
function harness(state = { steps: {} }) {
|
||
|
|
const facts = fixture(), writes = []
|
||
|
|
let status = 'WAIT_DELIVERY', files = [], uploads = 0
|
||
|
|
const request = async (region, method, path, body) => {
|
||
|
|
if (method !== 'GET') writes.push({ path, body })
|
||
|
|
if (path.endsWith('/status?customerId=501')) { status = 'DELIVERING'; return {} }
|
||
|
|
if (path.includes('/documents?')) {
|
||
|
|
if (method === 'PUT') { files = [{ fileId: '701' }]; status = 'DELIVERED' }
|
||
|
|
return { version: 0, categories: [{ category: 'RECEIPT', status: files.length ? 'UPLOADED' : 'MISSING', files }] }
|
||
|
|
}
|
||
|
|
if (path.includes('/dispatches/')) return facts.dispatch
|
||
|
|
if (path.includes('/outbound-orders/')) return facts.outbound
|
||
|
|
if (path.includes('/last-leg/orders/')) return facts.tail
|
||
|
|
if (path.includes('/delivery-orders/')) return { order: { customerId: '501', deliveryNo: 'DL-101', dispatchNo: 'DS-101', outboundNo: 'OB-101',
|
||
|
|
remark: 'RELEASE-ACCEPTANCE:acceptance-1234', status, version: 0 }, operations: status === 'DELIVERED' ? [{ operationNode: 'RECEIPT_AUTO_COMPLETE' }] : [] }
|
||
|
|
if (region === 'cn' && path.includes('/platform/inbounds?')) return { records: [{ inboundCode: 'IN-101', orderId: '101', processStatus: 'DELIVERED' }] }
|
||
|
|
if (region === 'cn') return { summary: { mainStatus: 'COMPLETED' } }
|
||
|
|
throw new Error('unexpected request')
|
||
|
|
}
|
||
|
|
const options = { input, customerId: '501', state, save: async patch => Object.assign(state, patch), request,
|
||
|
|
upload: async (_path, _name, content) => { assert.match(content, /NOT A REAL PROOF/); uploads++; return { fileId: '701' } }, wait: async () => {} }
|
||
|
|
return { options, facts, writes, uploads: () => uploads }
|
||
|
|
}
|
||
|
|
|
||
|
|
test('reject path injection, imprecise ids and excessive scope', () => {
|
||
|
|
validateDeliveryInput(input)
|
||
|
|
for (const patch of [{ dispatchNo: '../wrong' }, { orderId: 101 }, { packageCount: 100 }]) assert.throws(() => validateDeliveryInput({ ...input, ...patch }))
|
||
|
|
})
|
||
|
|
test('receipt closes delivery and both domestic states; completed rerun has no writes', async () => {
|
||
|
|
const h = harness()
|
||
|
|
const first = await runDeliveryStage(h.options)
|
||
|
|
assert.equal(first.deliveryClosureVerified, true)
|
||
|
|
assert.equal(first.fullFlowVerified, false)
|
||
|
|
assert.equal(first.status, 'DELIVERY_STAGE_PASSED')
|
||
|
|
const count = h.writes.length
|
||
|
|
await runDeliveryStage(h.options)
|
||
|
|
assert.equal(h.writes.length, count)
|
||
|
|
assert.equal(h.uploads(), 1)
|
||
|
|
})
|
||
|
|
test('stale pallet detail blocks release despite successful dispatch and receipt', async () => {
|
||
|
|
const h = harness()
|
||
|
|
h.facts.outbound.pallets[0].pickedPackageCount = 0
|
||
|
|
h.facts.tail.order.subOrderStatus = 'OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY'
|
||
|
|
const result = await runDeliveryStage(h.options)
|
||
|
|
assert.equal(result.status, 'DELIVERY_STAGE_WITH_BLOCKERS')
|
||
|
|
assert.equal(result.deliveryClosureVerified, true)
|
||
|
|
assert.deepEqual(result.blockers, ['OUTBOUND_PALLET_PICK_STATE_STALE', 'TAIL_OUTBOUND_SUB_STATUS_STALE'])
|
||
|
|
})
|
||
|
|
test('wrong source order or additional inbound blocks before mutations', async () => {
|
||
|
|
for (const mutate of [h => { h.facts.tail.order.sourceOrderNo = 'OTHER' }, h => { h.facts.dispatch.items.push({ inboundCode: 'OTHER' }) }]) {
|
||
|
|
const h = harness(); mutate(h)
|
||
|
|
await assert.rejects(runDeliveryStage(h.options))
|
||
|
|
assert.equal(h.writes.length, 0)
|
||
|
|
assert.equal(h.uploads(), 0)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
test('uncertain upload and uncertain status mutation are never resent', async () => {
|
||
|
|
const h = harness({ uploadIntent: true })
|
||
|
|
await assert.rejects(runDeliveryStage(h.options), /禁止重复上传/)
|
||
|
|
assert.equal(h.uploads(), 0)
|
||
|
|
const h2 = harness({ startIntent: true })
|
||
|
|
await assert.rejects(runDeliveryStage(h2.options), /禁止重发/)
|
||
|
|
assert.equal(h2.writes.length, 0)
|
||
|
|
})
|
||
|
|
test('lost document save response reuses persisted payload and key', async () => {
|
||
|
|
const h = harness()
|
||
|
|
const request = h.options.request
|
||
|
|
let lost = false
|
||
|
|
h.options.request = async (...args) => {
|
||
|
|
const value = await request(...args)
|
||
|
|
if (!lost && args[1] === 'PUT' && args[2].includes('/documents?')) { lost = true; throw new Error('timeout') }
|
||
|
|
return value
|
||
|
|
}
|
||
|
|
await assert.rejects(runDeliveryStage(h.options), /timeout/)
|
||
|
|
const body = structuredClone(h.options.state.saveBody)
|
||
|
|
const result = await runDeliveryStage(h.options)
|
||
|
|
assert.equal(result.deliveryClosureVerified, true)
|
||
|
|
assert.deepEqual(h.options.state.saveBody, body)
|
||
|
|
assert.equal(h.uploads(), 1)
|
||
|
|
})
|
||
|
|
test('missing domestic event remains a blocker', async () => {
|
||
|
|
const h = harness(), original = h.options.request
|
||
|
|
h.options.request = (...args) => args[0] === 'cn' ? Promise.resolve({ records: [], summary: { mainStatus: 'IN_TRANSIT' } }) : original(...args)
|
||
|
|
const result = await runDeliveryStage(h.options)
|
||
|
|
assert.equal(result.deliveryClosureVerified, false)
|
||
|
|
assert.ok(result.blockers.includes('CN_DELIVERY_EVENT_NOT_RECONCILED'))
|
||
|
|
})
|
||
|
|
test('journal rejects changed binding and concurrent processes', async () => {
|
||
|
|
const dir = await mkdtemp(join(tmpdir(), 'prod-delivery-test-')), path = join(dir, 'journal.json')
|
||
|
|
try {
|
||
|
|
await withDeliveryJournal(path, 'first', async (_state, save) => {
|
||
|
|
await save({ fileId: '701' })
|
||
|
|
await assert.rejects(withDeliveryJournal(path, 'first', async () => {}), /EEXIST/)
|
||
|
|
})
|
||
|
|
await assert.rejects(withDeliveryJournal(path, 'changed', async () => {}), /不符/)
|
||
|
|
} finally { await rm(dir, { recursive: true, force: true }) }
|
||
|
|
})
|