146 lines
9.6 KiB
JavaScript
146 lines
9.6 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { readFile } from 'node:fs/promises'
|
|
import { rejected, classifyRace, stableState, verifyNoMutation, exactReceiptLedger } from './return-boundary-scenarios.mjs'
|
|
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
|
|
|
|
const response = (code = '200', http = 200) => ({ http, body: { code }, requestId: 'request', traceId: 'trace' })
|
|
|
|
test('拒绝断言只接受明确业务错误,不把网络故障、越权、成功当成参数拒绝', () => {
|
|
for (const code of ['A0400', 'A0422']) assert.equal(rejected(response(code), [400, 422]), true)
|
|
for (const code of ['200', 'A0403', 'A0409', 'S0500', 'S0503']) assert.equal(rejected(response(code), [400, 422]), false)
|
|
for (const http of [0, 401, 403, 500, 502, 503]) assert.equal(rejected(response('error', http), [400, 422]), false)
|
|
assert.equal(rejected({ ...response('A0409'), traceId: '' }, [409]), false)
|
|
})
|
|
|
|
test('并发判定区分双成功、双失败、服务故障与唯一成功', () => {
|
|
assert.deepEqual(classifyRace([response(), response('A0409')]), { successes: 1, conflicts: 1, unexpected: 0 })
|
|
assert.deepEqual(classifyRace([response(), response()]), { successes: 2, conflicts: 0, unexpected: 0 })
|
|
assert.deepEqual(classifyRace([response('A0409'), response('A0409')]), { successes: 0, conflicts: 2, unexpected: 0 })
|
|
assert.deepEqual(classifyRace([response(), response('S0500', 500)]), { successes: 1, conflicts: 0, unexpected: 1 })
|
|
})
|
|
|
|
test('库存快照保留订单、批次、SKU、来源与状态变化,仅忽略排序及展示时间', () => {
|
|
const baseline = [{ returnNo: 'R1', receiptBatchNo: 'B1', quantity: 2, status: 'AVAILABLE', skuTypes: 1, items: [{ skuCode: 'A', quantity: 2 }], updatedAt: 'old' }]
|
|
assert.deepEqual(stableState(baseline), stableState([{ ...baseline[0], updatedAt: 'new' }]))
|
|
for (const change of [{ returnNo: 'R2' }, { receiptBatchNo: 'B2' }, { quantity: 1 }, { status: 'MERGED' }, { skuTypes: 0 }, { items: [] }]) {
|
|
assert.notDeepEqual(stableState(baseline), stableState([{ ...baseline[0], ...change }]))
|
|
}
|
|
})
|
|
|
|
test('每个失败请求都立即前后回查,即使接口错误类型不符也检查落账', async () => {
|
|
for (const code of ['A0409', '200', 'S0500']) {
|
|
let quantity = 2
|
|
const events = [], checks = []
|
|
await verifyNoMutation({ name: '拒绝操作', snapshot: async () => { events.push('snapshot'); return { quantity } },
|
|
action: async () => { events.push('action'); quantity -= 1; return response(code) },
|
|
expected: result => rejected(result, [409]), check: (name, result, passed) => checks.push(passed) })
|
|
assert.deepEqual(events, ['snapshot', 'action', 'snapshot'])
|
|
assert.deepEqual(checks, [code === 'A0409', false])
|
|
}
|
|
})
|
|
|
|
test('缺少快照不得执行危险动作或伪造未落账通过', async () => {
|
|
let called = false
|
|
await assert.rejects(verifyNoMutation({ snapshot: async () => { throw new Error('snapshot unavailable') },
|
|
action: async () => { called = true }, check: () => {}, expected: () => true }), /snapshot unavailable/)
|
|
assert.equal(called, false)
|
|
})
|
|
|
|
test('扩展由退货主模块调用且不进入换标出库,保留各并发响应诊断及清理', async () => {
|
|
const [source, main] = await Promise.all(['return-boundary-scenarios.mjs', 'phase4-overseas-return-receipt.mjs']
|
|
.map(file => readFile(new URL(file, import.meta.url), 'utf8')))
|
|
assert.match(main, /await runReturnBoundaries\(context, \{ merchantToken, warehouse, picker, access \}\)/)
|
|
for (const name of ['跨客户隔离', '跨仓隔离', '并发签收', '并发合箱', '多单全量合箱与来源追溯', '多候选与输入边界']) assert.ok(source.includes(`scenario('${name}'`))
|
|
assert.match(source, /recordRace\('并发签收'/)
|
|
assert.match(source, /recordRace\('并发合箱'/)
|
|
assert.match(source, /recordRace\('并发部分合箱'/)
|
|
assert.match(source, /concurrencyRounds: 3/)
|
|
assert.match(source, /publishEnvironment: false/)
|
|
assert.match(source, /finally[\s\S]*disablePicker/)
|
|
assert.match(source, /executed: false, passed: false/)
|
|
assert.doesNotMatch(source, /\bfetch\s*\(|process\.env|relabel-applications|outbound-orders|outbound-tasks|\b(?:INSERT|UPDATE|SELECT)\s/i)
|
|
})
|
|
|
|
test('第二客户准备运行真实fixture编排但不替换主客户内存与环境凭据', async () => {
|
|
const names = ['MERCHANT_TEST_USERNAME', 'MERCHANT_TEST_PASSWORD']
|
|
const saved = names.map(name => process.env[name])
|
|
try {
|
|
process.env.MERCHANT_TEST_USERNAME = 'primary-user'
|
|
process.env.MERCHANT_TEST_PASSWORD = 'primary-placeholder'
|
|
const primary = { context: { merchantQualificationFixture: { tenantId: 'A' } }, createdResources: {} }
|
|
const secondary = Object.create(primary)
|
|
Object.assign(secondary, { marker: 'OFFLINE-20260915-B', options: { fixedCustomerId: '101', merchantUsername: 'secondary-user', merchantPassword: 'secondary-placeholder' }, context: { ...primary.context }, createdResources: {},
|
|
findAvailablePhone: async () => '00000000000',
|
|
assert: (module, name, result, condition) => assert.ok(condition, name),
|
|
apiSuccess: (module, name, result) => { assert.equal(result.body.code, '200', name); return result.body.data },
|
|
requestWithBody: async () => ({ body: { code: '200', data: {} } }),
|
|
request: async (method, path) => {
|
|
let data
|
|
if (path.endsWith('/login')) data = { accessToken: 'secondary-token', refreshToken: 'secondary-refresh' }
|
|
else if (path.endsWith('/profile')) data = { id: '101', status: 'ACTIVE', tenantName: 'Existing customer' }
|
|
else if (path.endsWith('/dimension-initialization')) data = { status: 'READY' }
|
|
else if (path.endsWith('/logout')) data = null
|
|
else throw new Error(`Unexpected fixture request: ${method} ${path}`)
|
|
return { http: 200, body: { code: '200', data } }
|
|
} })
|
|
await createMerchant(secondary, { publishEnvironment: false, requireDistinctCustomer: false })
|
|
assert.equal(primary.context.merchantQualificationFixture.tenantId, 'A')
|
|
assert.equal(secondary.context.merchantQualificationFixture.tenantId, '101')
|
|
assert.equal(process.env.MERCHANT_TEST_USERNAME, 'primary-user')
|
|
assert.equal(process.env.MERCHANT_TEST_PASSWORD, 'primary-placeholder')
|
|
assert.ok(!JSON.stringify(secondary.createdResources).includes('secondary-placeholder'))
|
|
await createMerchant(secondary)
|
|
assert.equal(process.env.MERCHANT_TEST_USERNAME, 'secondary-user')
|
|
} finally {
|
|
names.forEach((name, index) => { if (saved[index] === undefined) delete process.env[name]; else process.env[name] = saved[index] })
|
|
}
|
|
})
|
|
|
|
test('流水断言逐行核对归属和业务引用,不能仅凭合计相等通过', () => {
|
|
const a = { customerId: 'A', overseasWarehouseId: 'W1', skuCode: 'SKU', movementType: 'RECEIPT', referenceNo: 'RR-1', totalDelta: 2, availableDelta: 2 }
|
|
const b = { ...a, referenceNo: 'RR-2', totalDelta: 1, availableDelta: 1 }
|
|
assert.equal(exactReceiptLedger({ total: 2, records: [b, a] }, [a, b]), true)
|
|
for (const change of [{ customerId: 'B' }, { overseasWarehouseId: 'W2' }, { skuCode: 'OTHER' },
|
|
{ referenceNo: 'RR-1' }, { movementType: 'MERGE' }, { totalDelta: 2 }, { availableDelta: 0 }]) {
|
|
assert.equal(exactReceiptLedger({ total: 2, records: [a, { ...b, ...change }] }, [a, b]), false)
|
|
}
|
|
assert.equal(exactReceiptLedger({ total: 3, records: [a, b] }, [a, b]), false)
|
|
assert.equal(exactReceiptLedger({ total: 0, records: [] }, []), true)
|
|
assert.equal(exactReceiptLedger(undefined, []), false)
|
|
})
|
|
|
|
test('真实退货验收使用TH及RR和RB日流水并保留原箱码与重放检查', async () => {
|
|
const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8')
|
|
for (const prefix of ['TH', 'RR', 'RB']) {
|
|
assert.ok(source.includes(`/^${prefix}[0-9]{12}$/`), prefix)
|
|
}
|
|
assert.ok(source.includes('data.order.lastlegOrderNo === data.order.returnNo'))
|
|
assert.ok(source.includes('/^BX-[0-9A-Z]+$/'))
|
|
assert.ok(source.includes('同键并发合箱后重试仍返回原箱'))
|
|
assert.ok(source.includes('签收重放不重复产生流水'))
|
|
})
|
|
|
|
test('高价值扩展覆盖混合回滚、同键合箱、同码隔离、权限和流水且仍逐次回查', async () => {
|
|
const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8')
|
|
for (const name of ['混合明细整笔回滚', '同键并发合箱', '相同SKU跨客户跨仓隔离', '身份入口权限', '签收流水与余额核对']) {
|
|
assert.ok(source.includes(`scenario('${name}'`), name)
|
|
}
|
|
for (const evidence of ['后项超收', '后项未预报', '后项超库存', '后项不存在', 'additionalSummaries',
|
|
'同键并发合箱复用同一目标箱', '同码A签收不改变B客户或异仓数据', '无登录', '超收失败不留下流水']) assert.ok(source.includes(evidence), evidence)
|
|
assert.match(source, /snapshot: withLedger[\s\S]*签收重放不重复产生流水/)
|
|
assert.match(source, /sameKeyResponses/)
|
|
})
|
|
|
|
test('边界fixture遵守审核、托盘实收和可选照片新契约', async () => {
|
|
const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8')
|
|
assert.match(source, /customerByToken\.set\(tokenB, String\(fixtureB\.tenantId\)\)/)
|
|
assert.match(source, /'X-Customer-Id': customerId/)
|
|
assert.match(source, /status !== 'PENDING_REVIEW'/)
|
|
assert.match(source, /unitType: 'PARCEL', unitCode: order.unitCode/)
|
|
assert.match(source, /requestMultipart\('POST',[^\n]+form, operator\.token\)/)
|
|
assert.match(source, /两个合法SKU同包裹签收生成独立散件库存/)
|
|
assert.match(source, /unitCode: `\$\{order.unitCode\}-SECOND`/)
|
|
assert.doesNotMatch(source, /\['receiptNo', 'receiptBatchNo', 'cartonMark'\]/)
|
|
})
|