161 lines
8.7 KiB
JavaScript
161 lines
8.7 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 { executeDelivery, 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 }) }
|
|
})
|
|
|
|
|
|
test('missing or malformed delivery version blocks status write and upload', async () => {
|
|
for (const version of [undefined, null, '', ' ', false, -1, '9007199254740993']) {
|
|
const h = harness(), original = h.options.request
|
|
h.options.request = async (...args) => {
|
|
const result = await original(...args)
|
|
if (result?.order?.deliveryNo) result.order.version = version
|
|
return result
|
|
}
|
|
await assert.rejects(runDeliveryStage(h.options), /版本缺失或无效/)
|
|
assert.equal(h.writes.length, 0)
|
|
assert.equal(h.uploads(), 0)
|
|
}
|
|
})
|
|
|
|
test('delivery executor uses one gateway/accessToken for status, multipart receipt and domestic reconciliation', async t => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'single-delivery-test-'))
|
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
const h = harness(), calls = []
|
|
const settings = { environment: 'gray', apiBase: 'https://fixture.invalid/api', customer: { id: '501' },
|
|
credentials: { ADMIN: { loginId: 'admin-fixture', password: 'secret-fixture' } } }
|
|
const fetcher = async (url, options) => {
|
|
assert.ok(url.startsWith(settings.apiBase + '/'))
|
|
const path = url.slice(settings.apiBase.length)
|
|
calls.push(path)
|
|
let data
|
|
if (path === '/auth/login') data = { accessToken: 'single-access' }
|
|
else {
|
|
assert.equal(options.headers.Authorization, 'Bearer single-access')
|
|
if (options.body instanceof FormData) {
|
|
assert.equal(path, '/platform/v1/last-leg/delivery-orders/DL-101/documents/RECEIPT/files?customerId=501')
|
|
assert.match(await options.body.get('file').text(), /NOT A REAL PROOF/)
|
|
data = { fileId: '701' }
|
|
} else {
|
|
data = await h.options.request(path.startsWith('/platform/v1/last-leg/') ? 'overseas' : 'cn',
|
|
options.method, path, options.body ? JSON.parse(options.body) : undefined)
|
|
}
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({ code: '200', data }) }
|
|
}
|
|
const result = await executeDelivery(settings, input, join(dir, 'journal.json'), fetcher)
|
|
assert.equal(result.status, 'DELIVERY_STAGE_PASSED')
|
|
assert.equal(result.fullFlowVerified, false)
|
|
assert.equal(calls.filter(path => path === '/auth/login').length, 1)
|
|
})
|