test: strengthen customs and fulfillment acceptance boundaries
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test'
|
||||
import { relabelOutboundMarksMatch } from './phase5-overseas-fulfillment.mjs'
|
||||
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, verifyPartialOrderCompletion,
|
||||
completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -114,3 +115,191 @@ test('manual exception retries only a transport failure with the identical busin
|
||||
assert.match(source, /人工异常连接失败后使用同载荷重试/)
|
||||
assert.match(source, /manualExceptionBody/)
|
||||
})
|
||||
|
||||
test('quantity snapshots ignore timestamps but preserve every inventory bucket and source identity', () => {
|
||||
const original = { id: '1', inboundCode: 'IN-1', totalPackages: 5, availablePackages: 3,
|
||||
holdPackages: 2, allocatedPackages: 0, pickedPackages: 0, updatedAt: 'earlier' }
|
||||
assert.deepEqual(inventoryQuantitySnapshot([original]), inventoryQuantitySnapshot([{ ...original, updatedAt: 'later' }]))
|
||||
for (const field of ['id', 'inboundCode', 'totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages']) {
|
||||
assert.notDeepEqual(inventoryQuantitySnapshot([original]),
|
||||
inventoryQuantitySnapshot([{ ...original, [field]: field === 'id' || field === 'inboundCode' ? 'different' : 99 }]))
|
||||
}
|
||||
})
|
||||
|
||||
function contextFor(request) {
|
||||
const assertions = []
|
||||
const calls = []
|
||||
return { marker: 'TAIL-CURRENT', options: { gateway: 'https://test.invalid' }, createdResources: {},
|
||||
assertions, calls,
|
||||
async request(method, path, body, token) { calls.push({ method, path, body, token }); return request(method, path, body, token) },
|
||||
apiSuccess(module, name, response) { assertions.push({ name, passed: response?.body?.code === '200' }); return response.body?.data },
|
||||
assert(module, name, response, passed) { assertions.push({ name, passed }) },
|
||||
addStep(module, name, response, passed) { assertions.push({ name, passed }) },
|
||||
addCoverageGap(module, scenario, name) { assertions.push({ name, passed: false }) },
|
||||
}
|
||||
}
|
||||
const ok = data => ({ http: 200, body: { code: '200', data } })
|
||||
|
||||
test('partial completion rejects different Orders and an already delivered pending fragment', async () => {
|
||||
for (const second of [{ orderId: 'OTHER', processStatus: 'WAIT_SHIP' },
|
||||
{ orderId: 'ORDER', processStatus: 'DELIVERED' }]) {
|
||||
const context = contextFor((method, path) => ok({ records: path.includes('inboundCode=IN-A')
|
||||
? [{ inboundCode: 'IN-A', orderId: 'ORDER', processStatus: 'DELIVERED' }]
|
||||
: [{ inboundCode: 'IN-B', ...second }] }))
|
||||
assert.equal(await verifyPartialOrderCompletion(context, { orderId: 'ORDER', customerId: '100',
|
||||
deliveredInboundCode: 'IN-A', pendingInboundCodes: ['IN-B'] }), false)
|
||||
assert.equal(context.calls.some(call => call.path.startsWith('/api/platform/v1/orders/')), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('partial completion verifies an unfinished valid sibling and catches premature COMPLETED', async () => {
|
||||
for (const mainStatus of ['IN_TRANSIT', 'COMPLETED']) {
|
||||
const context = contextFor((method, path) => path.startsWith('/api/platform/v1/orders/')
|
||||
? ok({ summary: { mainStatus } }) : ok({ records: [{ inboundCode: path.includes('inboundCode=IN-A') ? 'IN-A' : 'IN-B',
|
||||
orderId: 'ORDER', processStatus: path.includes('inboundCode=IN-A') ? 'DELIVERED' : 'WAIT_SHIP' }] }))
|
||||
assert.equal(await verifyPartialOrderCompletion(context, { orderId: 'ORDER', customerId: '100',
|
||||
deliveredInboundCode: 'IN-A', pendingInboundCodes: ['IN-B'] }), mainStatus !== 'COMPLETED')
|
||||
}
|
||||
})
|
||||
|
||||
test('same dispatch-return exception reaches actual restored inventory and retries without another ledger row', async () => {
|
||||
for (const initialStatus of ['PENDING', 'PROCESSING']) {
|
||||
let completed = false
|
||||
let version = initialStatus === 'PROCESSING' ? 1 : 0
|
||||
let uploads = 0
|
||||
const source = { outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }
|
||||
const context = contextFor((method, path, body) => {
|
||||
if (path.includes('/inventory/balances/1/positions')) return ok([{ id: '3', balanceId: '1',
|
||||
customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71',
|
||||
palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1 }])
|
||||
if (path.includes('/inventory/balances')) return ok({ total: 1, records: [{ id: '1', customerId: '100',
|
||||
overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', totalPackages: completed ? 9 : 8,
|
||||
availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0 }] })
|
||||
if (path.includes('/inventory/ledger')) return ok({ total: completed ? 1 : 0, records: completed ? [{ id: '2',
|
||||
customerId: '100', inboundCode: 'IN-A', businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-1',
|
||||
outboundOrderNo: 'OB-1', deltaPackages: 1, beforePackages: 0, afterPackages: 1 }] : [] })
|
||||
if (path.includes('/locations')) return ok({ total: 1, records: [{ id: '72', locationCode: 'L-1' }] })
|
||||
if (method === 'POST' && path.endsWith('/pallets')) return ok('71')
|
||||
if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' })
|
||||
if (path.includes('/start-processing')) { version = 1; return ok({ status: 'PROCESSING', version }) }
|
||||
if (path.includes('/complete')) {
|
||||
assert.deepEqual(body.receipts[0], { outboundPalletItemId: '51', inventoryPositionId: '61', receivedPackages: 1,
|
||||
targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', targetLocationId: '72', targetLocationCode: 'L-1' })
|
||||
const replayed = completed; completed = true
|
||||
return ok({ status: 'COMPLETED', restoredPackages: 1, inventoryAdjustmentId: '81', replayed })
|
||||
}
|
||||
if (path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'OB-1' } })
|
||||
return ok({ order: { status: completed ? 'COMPLETED' : initialStatus, exceptionType: 'RETURN',
|
||||
outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', version, evidenceFileIds: ['91'] }, returnSources: [source] })
|
||||
})
|
||||
context.requestMultipart = async () => { uploads += 1; return ok({ fileId: '91' }) }
|
||||
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' }
|
||||
const resume = initialStatus === 'PROCESSING'
|
||||
? { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' } : null
|
||||
const result = await completeReturnedException(context, { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume })
|
||||
assert.equal(result?.restoredPackages, 1)
|
||||
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
||||
assert.equal(context.calls.filter(call => call.path.includes('/complete')).length, 2)
|
||||
if (initialStatus === 'PROCESSING') {
|
||||
assert.equal(context.calls.some(call => call.method === 'POST' && call.path.endsWith('/pallets')), false)
|
||||
assert.equal(context.calls.some(call => call.path.includes('/start-processing')), false)
|
||||
assert.equal(uploads, 0)
|
||||
assert.ok(context.calls.filter(call => call.path.includes('/complete')).every(call => call.body.idempotencyKey === 'ORIGINAL-COMPLETE'))
|
||||
}
|
||||
assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91')
|
||||
}
|
||||
})
|
||||
|
||||
test('PROCESSING continuation without the original pallet and evidence remains blocked without creating replacements', async () => {
|
||||
const context = contextFor(() => ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', version: 1 },
|
||||
returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] }))
|
||||
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' }
|
||||
assert.equal(await completeReturnedException(context,
|
||||
{ customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1' }), null)
|
||||
assert.equal(context.calls.length, 1)
|
||||
assert.ok(context.assertions.some(row => !row.passed))
|
||||
})
|
||||
|
||||
test('failed same-exception completion still checks zero inventory drift and retains the original resume evidence', async () => {
|
||||
const context = contextFor((method, path) => {
|
||||
if (path.includes('/inventory/')) return ok({ records: [], total: 0 })
|
||||
if (path.includes('/locations')) return ok({ records: [{ id: '72', locationCode: 'L-1' }], total: 1 })
|
||||
if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' })
|
||||
if (path.includes('/complete')) return { http: 200, body: { code: 'A0400', message: 'Source rejected' } }
|
||||
return ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', inboundCode: 'IN-A', version: 1 },
|
||||
returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] })
|
||||
})
|
||||
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' }
|
||||
const resume = { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' }
|
||||
assert.equal(await completeReturnedException(context,
|
||||
{ customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume }), null)
|
||||
assert.equal(context.calls.filter(call => call.path.includes('/inventory/balances')).length, 2)
|
||||
assert.equal(context.calls.filter(call => call.path.includes('/inventory/ledger')).length, 2)
|
||||
assert.ok(context.assertions.some(row => row.name === '退回实收失败后库存及流水保持不变并保留原续验资料' && row.passed))
|
||||
assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91')
|
||||
assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.completeIdempotencyKey, 'ORIGINAL-COMPLETE')
|
||||
assert.equal(context.calls.some(call => call.method === 'POST' && !call.path.includes('/complete')), false)
|
||||
})
|
||||
|
||||
test('customer receipt acceptance downloads exact bytes and rejects a foreign customer without a platform fallback', async () => {
|
||||
const context = contextFor((method, path, body, token) => path.endsWith('/iam/tenant/profile')
|
||||
? ok({ id: token === 'FOREIGN' ? '200' : '100' }) : token === 'FOREIGN'
|
||||
? { http: 404, body: { code: 'A0404' } }
|
||||
: ok({ deliveryNo: 'DEL-1', files: [{ fileId: '91' }] }))
|
||||
const previousFetch = globalThis.fetch
|
||||
globalThis.fetch = async (url, options) => options.headers.Authorization === 'Bearer FOREIGN'
|
||||
? new Response(JSON.stringify({ code: 'A0404' }), { status: 404, headers: { 'content-type': 'application/json' } })
|
||||
: new Response('CURRENT-RECEIPT', { status: 200, headers: { 'content-type': 'application/octet-stream' } })
|
||||
try {
|
||||
assert.equal(await verifyCustomerSignedDocuments(context, 'OWN',
|
||||
[{ deliveryNo: 'DEL-1', fileId: '91', content: 'CURRENT-RECEIPT' }], 'FOREIGN'), true)
|
||||
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
||||
assert.equal(context.calls.some(call => call.path.includes('/api/platform/')), false)
|
||||
} finally { globalThis.fetch = previousFetch }
|
||||
})
|
||||
|
||||
test('SIGNED-B fixture keeps primary options and credentials unchanged and records no password or token', async () => {
|
||||
const context = contextFor((method, path, body, token) => {
|
||||
assert.equal(path, '/api/auth/login')
|
||||
assert.equal(token, '')
|
||||
assert.deepEqual(body, { loginId: 'SECONDARY', password: 'SECONDARY-IN-MEMORY' })
|
||||
return ok({ accessToken: 'SECONDARY-TOKEN' })
|
||||
})
|
||||
context.options.customerName = 'PRIMARY'
|
||||
context.context = { merchantQualificationFixture: { tenantId: '100', username: 'PRIMARY', password: 'PRIMARY-IN-MEMORY' } }
|
||||
context.createdResources.phase5OverseasFulfillment = { marker: context.marker }
|
||||
const primary = context.context.merchantQualificationFixture
|
||||
const options = context.options
|
||||
const create = async (secondary, flags) => {
|
||||
assert.equal(flags.publishEnvironment, false)
|
||||
assert.equal(secondary.marker, `${context.marker}-SIGNED-B`)
|
||||
assert.notEqual(secondary.options, context.options)
|
||||
assert.notEqual(secondary.context, context.context)
|
||||
assert.notEqual(secondary.createdResources, context.createdResources)
|
||||
assert.equal(secondary.options.customerName, '')
|
||||
secondary.context.merchantQualificationFixture = { tenantId: '200', tenantName: 'SECONDARY', username: 'SECONDARY',
|
||||
password: 'SECONDARY-IN-MEMORY', marker: secondary.marker }
|
||||
// Even unexpected producer fields cannot enter the report's explicit metadata whitelist.
|
||||
secondary.createdResources.merchantQualificationFixture = { ...secondary.context.merchantQualificationFixture, token: 'UNEXPECTED' }
|
||||
}
|
||||
assert.equal(await createSignedDocumentIsolationCustomer(context, { create }), 'SECONDARY-TOKEN')
|
||||
assert.equal(context.options, options)
|
||||
assert.equal(context.options.customerName, 'PRIMARY')
|
||||
assert.equal(context.context.merchantQualificationFixture, primary)
|
||||
assert.equal(context.context.phase5TailVerification.foreignMerchantToken, 'SECONDARY-TOKEN')
|
||||
const report = JSON.stringify(context.createdResources)
|
||||
assert.doesNotMatch(report, /password|token|IN-MEMORY|UNEXPECTED|SECONDARY-TOKEN/)
|
||||
assert.equal(context.createdResources.phase5OverseasFulfillment.signedDocumentIsolationCustomer.marker, 'TAIL-CURRENT-SIGNED-B')
|
||||
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
||||
})
|
||||
|
||||
test('incomplete SIGNED-B producer remains a blocker and never logs in as the primary customer', async () => {
|
||||
const context = contextFor(() => { throw new Error('must not log in') })
|
||||
context.context = { merchantQualificationFixture: { tenantId: '100', username: 'PRIMARY', password: 'PRIMARY-IN-MEMORY' } }
|
||||
context.createdResources.phase5OverseasFulfillment = { marker: context.marker }
|
||||
const token = await createSignedDocumentIsolationCustomer(context, { create: async () => {} })
|
||||
assert.equal(token, null)
|
||||
assert.equal(context.calls.length, 0)
|
||||
assert.ok(context.assertions.some(row => !row.passed))
|
||||
assert.equal(context.context.phase5TailVerification, undefined)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user