fix(order): make merchant draft edits and cancellations versioned and idempotent
This commit is contained in:
+1
-1
@@ -170,7 +170,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
|
||||
| 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 |
|
||||
|---|---|---|---|
|
||||
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、海外排车/出库/派送/异常/回访、财务、通知、统计等模块 |
|
||||
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、权限和站内消息数据 | 注册/审核、企业与个人资料、客户隔离、订单、文件、客户侧权限、消息已读与账号安全;未完成注册的同凭据重放须复用账号/客户/成员身份,不同凭据重复用户名须冲突;测量/计费继续使用下列专项入口 |
|
||||
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、权限和站内消息数据,并保留 Excel 源文件及错误报告 | 注册/审核、企业与个人资料、客户隔离、订单、文件、客户侧权限、消息已读与账号安全;Merchant 草稿编辑/取消使用冻结版本与幂等命令,拒绝及重放核对完整订单快照;实际中文 XLSX 验证 PARTIAL、ATOMIC 中途冲突回滚及原键恢复重试,JSON 批量草稿不能替代此项。未完成注册的同凭据重放须复用账号/客户/成员身份,不同凭据重复用户名须冲突;测量/计费继续使用下列专项入口 |
|
||||
| Client 快速下单定向 HTTP 验收 | `scripts/client-quick-order-test-env-acceptance.mjs` | 使用 `MERCHANT_TEST_USERNAME/PASSWORD` 对应客户创建快速下单草稿 | 真实调用 `/api/merchant/v1/orders/first-leg/batch`,覆盖 FBA、多产品、海外仓、多个私人地址、地址归属失败、渠道失败、重复行、逐行隔离、失败重试、详情归属、幂等重放与冲突;只创建草稿,不提交、不审核 |
|
||||
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用 `MERCHANT_TEST_USERNAME/PASSWORD` 配置的客户账号创建指定数量订单 | 使用 `ADMIN_TEST_GATEWAY`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
|
||||
| Client 入库测量与计费专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 入库测量与计费客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 |
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import vm from 'node:vm'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { inflateRawSync, deflateRawSync } from 'node:zlib'
|
||||
|
||||
const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
|
||||
@@ -11,3 +14,265 @@ test('client registration distinguishes incomplete-registration replay from cred
|
||||
assert.match(source, /duplicate username with different credentials is rejected/)
|
||||
assert.match(source, /body: \{ \.\.\.registrationBody, password: `Different/)
|
||||
})
|
||||
|
||||
const helperSource = source.slice(source.indexOf('// BEGIN ORDER ACCEPTANCE HELPERS'), source.indexOf('// END ORDER ACCEPTANCE HELPERS'))
|
||||
const workflowSource = source.slice(source.indexOf('async function ownedOrderSnapshot('), source.indexOf('async function apiStep('))
|
||||
function runtime(extra = {}) {
|
||||
const context = vm.createContext({ Buffer, createHash, inflateRawSync, structuredClone, Blob, FormData,
|
||||
assertEqual: (actual, expected, label) => assert.equal(actual, expected, label),
|
||||
assertTrue: (value, label) => assert.ok(value, label),
|
||||
assertPresent: (value, label) => assert.ok(value !== null && value !== undefined && value !== '', label),
|
||||
requireSuccess: json => assert.equal(String(json?.code), '200'), ...extra })
|
||||
vm.runInContext(`${helperSource}\n${workflowSource}`, context)
|
||||
return context
|
||||
}
|
||||
const plain = value => JSON.parse(JSON.stringify(value))
|
||||
|
||||
test('frozen edit command preserves original version, key and nested payload through retries', () => {
|
||||
const r = runtime(), snapshot = { order: { version: 7 } }, payload = { draft: { cargoes: [{ skuNo: 'ORIGINAL' }] } }
|
||||
const command = r.orderMutationCommand(snapshot, payload, 'original-key')
|
||||
snapshot.order.version = 99; payload.draft.cargoes[0].skuNo = 'CHANGED'
|
||||
assert.deepEqual(plain(command), { headers: { 'Idempotency-Key': 'original-key' }, body: { expectedVersion: 7, draft: { cargoes: [{ skuNo: 'ORIGINAL' }] } } })
|
||||
assert.ok(Object.isFrozen(command.body.draft.cargoes[0]))
|
||||
assert.throws(() => r.orderMutationCommand({ order: {} }, payload, 'key'), /version/)
|
||||
assert.throws(() => r.orderMutationCommand(snapshot, payload, ''), /key/)
|
||||
})
|
||||
|
||||
test('zero-drift validator detects changes in every aggregate and preserves array ordering', () => {
|
||||
const r = runtime(), before = { order: { version: 2 }, cargoes: [{ id: '1', amount: 2 }], parties: [], internalNotes: [], statusHistory: [{ id: 'h1' }, { id: 'h2' }] }
|
||||
r.assertOrderUnchanged(before, structuredClone(before))
|
||||
for (const field of Object.keys(before)) {
|
||||
const changed = structuredClone(before)
|
||||
if (field === 'order') changed.order.version++
|
||||
else changed[field].push({ id: 'new' })
|
||||
assert.throws(() => r.assertOrderUnchanged(before, changed), /unchanged/)
|
||||
}
|
||||
assert.throws(() => r.assertOrderUnchanged(before, { ...before, statusHistory: [...before.statusHistory].reverse() }), /unchanged/)
|
||||
r.assertOrderUnchanged({ b: 2, a: 1 }, { a: 1, b: 2 })
|
||||
})
|
||||
|
||||
test('partial import validates physical row, exact counts, failure details and identities', () => {
|
||||
const r = runtime(), data = { mode: 'PARTIAL', batchNo: 'batch', sourceFileId: 'f', errorReportFileId: 'e', result: {
|
||||
total: 2, succeeded: 1, failed: 1, items: [
|
||||
{ index: 0, clientRowId: 'excel-row-3', success: false, orderId: null, errorCode: 'A0422', errorMessage: 'cargo required' },
|
||||
{ index: 1, clientRowId: 'excel-row-4', success: true, orderId: '99', orderNo: 'FL99' },
|
||||
],
|
||||
} }
|
||||
r.assertImportResult(data, 'PARTIAL', [false, true])
|
||||
for (const mutate of [
|
||||
value => value.result.succeeded++, value => value.result.items[0].clientRowId = 'excel-row-2',
|
||||
value => value.result.items[0].orderId = 'unexpected', value => value.result.items[0].errorCode = 'S0500',
|
||||
value => value.result.items[1].orderNo = '', value => value.errorReportFileId = null,
|
||||
]) {
|
||||
const changed = structuredClone(data); mutate(changed)
|
||||
assert.throws(() => r.assertImportResult(changed, 'PARTIAL', [false, true]))
|
||||
}
|
||||
})
|
||||
|
||||
function templateFixture(r) {
|
||||
return new Map([
|
||||
['xl/workbook.xml', '<workbook><sheets><sheet name="订单导入"/><sheet name="选项数据"/></sheets></workbook>'],
|
||||
['xl/sharedStrings.xml', '<sst><si><t>运输方式 *</t></si><si><t>客户备注</t></si><si><t>仓库|CODE</t></si></sst>'],
|
||||
['xl/worksheets/sheet1.xml', '<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>订单基础</t></is></c></row><row r="2"><c r="A2" t="s"><v>0</v></c><c r="B2"/><c r="AE2" t="s"><v>1</v></c></row><row r="3"><c r="A3" t="inlineStr"><is><t>old data</t></is></c></row></sheetData><dataValidations count="1"/></worksheet>'],
|
||||
['xl/worksheets/sheet2.xml', '<worksheet><sheetData><row r="1"><c r="A1" t="s"><v>2</v></c></row></sheetData></worksheet>'],
|
||||
])
|
||||
}
|
||||
|
||||
test('XLSX mutation preserves downloaded headers/options and writes 31 columns past Z', () => {
|
||||
const r = runtime(), template = templateFixture(r), row = Array.from({ length: 31 }, (_, i) => `column-${i}`)
|
||||
row[30] = '备注<&"\'中文'
|
||||
const bytes = r.orderWorkbookRows(template, [row]), parsed = r.orderZipRead(bytes), rows = r.orderSheetRows(parsed)
|
||||
assert.equal(parsed.get('xl/worksheets/sheet2.xml'), template.get('xl/worksheets/sheet2.xml'))
|
||||
assert.match(parsed.get('xl/worksheets/sheet1.xml'), /dataValidations/)
|
||||
assert.deepEqual(plain(rows.find(item => item.number === 2).cells.map(cell => cell.text)), ['运输方式 *', '', '客户备注'])
|
||||
assert.equal(rows[2].cells[30].ref, 'AE'); assert.equal(rows[2].cells[30].text, row[30])
|
||||
assert.doesNotMatch(parsed.get('xl/worksheets/sheet1.xml'), /old data/)
|
||||
assert.deepEqual(plain(r.orderSheetRows(parsed, 'xl/worksheets/sheet2.xml')[0].cells), [{ ref: 'A', text: '仓库|CODE' }])
|
||||
const corrupted = Buffer.from(bytes); corrupted[80] ^= 1
|
||||
assert.throws(() => r.orderZipRead(corrupted))
|
||||
assert.throws(() => r.orderZipRead(Buffer.from('not zip')))
|
||||
})
|
||||
|
||||
test('ZIP reader accepts independently compressed entries and rejects damaged checksums', () => {
|
||||
const r = runtime(), data = Buffer.from('<worksheet>中文</worksheet>'), name = Buffer.from('xl/worksheets/sheet1.xml')
|
||||
const compressed = deflateRawSync(data)
|
||||
let checksum = 0xffffffff
|
||||
for (const byte of data) { checksum ^= byte; for (let i = 0; i < 8; i++) checksum = checksum & 1 ? 0xedb88320 ^ (checksum >>> 1) : checksum >>> 1 }
|
||||
checksum = (checksum ^ 0xffffffff) >>> 0
|
||||
const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50); local.writeUInt16LE(8, 8); local.writeUInt16LE(name.length, 26)
|
||||
const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50); central.writeUInt16LE(8, 10)
|
||||
central.writeUInt32LE(checksum, 16); central.writeUInt32LE(compressed.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(name.length, 28)
|
||||
const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50); end.writeUInt16LE(1, 10); end.writeUInt32LE(local.length + name.length + compressed.length, 16)
|
||||
const archive = Buffer.concat([local, name, compressed, central, name, end])
|
||||
assert.equal(r.orderZipRead(archive).get(name.toString()), data.toString())
|
||||
central.writeUInt32LE((checksum ^ 1) >>> 0, 16)
|
||||
assert.throws(() => r.orderZipRead(Buffer.concat([local, name, compressed, central, name, end])), /checksum/)
|
||||
})
|
||||
|
||||
test('rollback plan changes only row 3 validity and row 4 hash without moving physical rows', () => {
|
||||
const r = runtime(), first = Array.from({ length: 31 }, (_, index) => `first-${index}`), second = Array.from({ length: 31 }, (_, index) => `second-${index}`)
|
||||
const plan = r.importRollbackRows(first, second)
|
||||
assert.equal(plan.seed[0][10], ''); assert.deepEqual(plain(plan.seed[1]), second)
|
||||
assert.deepEqual(plain(plan.conflict[0]), first)
|
||||
assert.deepEqual(plain(plan.conflict[1].slice(0, 30)), second.slice(0, 30))
|
||||
assert.notEqual(plan.conflict[1][30], second[30]); assert.deepEqual(plain(plan.retry), [first, second])
|
||||
const rows = r.orderSheetRows(r.orderZipRead(r.orderWorkbookRows(templateFixture(r), plan.conflict)))
|
||||
assert.deepEqual(plain(rows.slice(2).map(row => row.number)), [3, 4])
|
||||
})
|
||||
|
||||
test('actual Java path derives row keys without mode and joins both writes to the atomic transaction', async () => {
|
||||
const service = await readFile(new URL('../order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java', import.meta.url), 'utf8')
|
||||
const item = await readFile(new URL('../order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java', import.meta.url), 'utf8')
|
||||
const orders = await readFile(new URL('../order/src/main/java/com/baiyue/order/service/OrderApplicationService.java', import.meta.url), 'utf8')
|
||||
assert.match(service, /batchKey\.trim\(\) \+ "\\n" \+ rowId/)
|
||||
assert.match(service, /@Transactional\s+public MerchantBatchDraftDtos.Response createAtomic/)
|
||||
assert.match(service, /itemService.createJoined\(tenantId, userId, username, item.draft\(\),\s*derivedKey\(batchKey, item.clientRowId\(\)\)\)/)
|
||||
assert.match(item, /@Transactional\(propagation = Propagation.MANDATORY\)/)
|
||||
assert.match(orders, /requestHash\("CREATE_DRAFT", request\)/)
|
||||
})
|
||||
|
||||
function mutationHarness(fault = '') {
|
||||
let nextId = 100, nextKey = 0, nextHistory = 0
|
||||
const draft = { customerRemark: 'original', cargoes: [{ skuNo: 'SKU', packageCount: 2, model: 'M', productImageFileId: 'img' }] }
|
||||
const make = (id, status, body) => ({ order: { id, orderNo: `FL${id}`, inboundCode: null, tenantId: 'tenant', customerId: 'customer', version: 0, status, customerRemark: body.customerRemark },
|
||||
cargoes: structuredClone(body.cargoes), parties: [], internalNotes: [], statusHistory: [] })
|
||||
const orders = new Map([['submitted', make('submitted', 'SUBMITTED', draft)]]), commands = new Map(), requests = [], report = { resources: { orderIds: [] } }
|
||||
const r = runtime({ merchantToken: 'owner', existingClientToken: 'other', marker: 'local-contract', report,
|
||||
orderAcceptanceFixture: { payload: draft, orderId: 'submitted' }, newIdempotencyKey: () => `key-${++nextKey}`,
|
||||
apiStep: async (module, name, request, validate) => {
|
||||
requests.push({ name, request: structuredClone(request) })
|
||||
const id = request.path.split('/')[6], current = orders.get(id)
|
||||
let code = '200', data = null
|
||||
if (!request.method || request.method === 'GET') data = structuredClone(current)
|
||||
else if (request.path.endsWith('/first-leg')) {
|
||||
data = String(nextId++); orders.set(data, make(data, 'DRAFT', request.body))
|
||||
} else if (request.token === 'other') code = 'A0404'
|
||||
else if (request.path.endsWith('/submit')) code = 'A0409'
|
||||
else {
|
||||
const key = request.headers?.['Idempotency-Key'], payload = request.body
|
||||
const signature = JSON.stringify(payload), stored = commands.get(key)
|
||||
if (!key) code = 'A0400'
|
||||
else if (payload.expectedVersion == null || (request.method === 'POST' && !payload.reason?.trim()) || payload.draft?.cargoes[0].productImageFileId === -1) code = 'A0422'
|
||||
else if (stored) {
|
||||
code = stored === signature ? '200' : 'A0409'
|
||||
if (fault === 'replay-write' && code === '200') current.order.version++
|
||||
} else if (payload.expectedVersion !== current.order.version || current.order.status !== 'DRAFT') code = 'A0409'
|
||||
else {
|
||||
commands.set(key, signature); current.order.version++
|
||||
const cancelled = request.method === 'POST'
|
||||
if (cancelled) current.order.status = 'CANCELLED'
|
||||
else { current.order.customerRemark = payload.draft.customerRemark; current.cargoes = structuredClone(payload.draft.cargoes) }
|
||||
current.statusHistory.push({ id: `h${++nextHistory}`, action: cancelled ? 'CANCELLED' : 'UPDATE', fromStatus: 'DRAFT', toStatus: current.order.status,
|
||||
reason: payload.reason ?? null, operatorId: 'owner', occurredAt: '2026-10-02T12:00:00' })
|
||||
}
|
||||
}
|
||||
if (fault === 'rejection-drift' && code !== '200' && current) current.parties.push({ id: 'unexpected' })
|
||||
const response = { status: 200, json: { code, data } }; validate(response); return response
|
||||
},
|
||||
})
|
||||
return { r, requests, report, orders }
|
||||
}
|
||||
|
||||
test('F06 actual orchestration covers frozen success replay, stale versions, missing protocol and isolation', async () => {
|
||||
const { r, requests, report } = mutationHarness()
|
||||
await r.orderMutationEvidenceModule()
|
||||
const firstEdit = requests.find(item => item.name === 'versioned draft edit succeeds').request
|
||||
const editReplay = requests.find(item => item.name === 'original edit key and frozen payload replay successfully').request
|
||||
assert.deepEqual(editReplay, firstEdit)
|
||||
const firstCancel = requests.find(item => item.name === 'cancel owned draft with reason').request
|
||||
const cancelReplay = requests.find(item => item.name === 'original cancellation key payload and version replay successfully').request
|
||||
assert.deepEqual(cancelReplay, firstCancel)
|
||||
const newCancel = requests.find(item => item.name === 'repeat cancellation cannot add history').request
|
||||
assert.notEqual(newCancel.headers['Idempotency-Key'], firstCancel.headers['Idempotency-Key'])
|
||||
assert.ok(newCancel.body.expectedVersion > firstCancel.body.expectedVersion)
|
||||
assert.ok(report.orderMutationRequests.every(item => !('token' in item)))
|
||||
assert.ok(requests.some(item => item.name === 'edit missing version is rejected'))
|
||||
assert.ok(requests.some(item => item.name === 'cancel missing key is rejected'))
|
||||
})
|
||||
|
||||
for (const fault of ['replay-write', 'rejection-drift']) test(`F06 refuses false success when ${fault}`, async () => {
|
||||
const { r } = mutationHarness(fault)
|
||||
await assert.rejects(() => r.orderMutationEvidenceModule(), /unchanged/)
|
||||
})
|
||||
|
||||
test('full order collection detects leaked rows, duplicated pages and truncated results', async () => {
|
||||
for (const records of [[{ orderId: 'expected' }, { orderId: 'leaked' }], [{ orderId: 'expected' }, { orderId: 'expected' }]]) {
|
||||
const r = runtime({ merchantToken: 'owner', apiStep: async (_module, _name, _request, validate) => {
|
||||
validate({ json: { code: '200', data: { page: 1, total: 2, records } } })
|
||||
} })
|
||||
await assert.rejects(() => r.importOrderSet('rollback evidence', ['expected']))
|
||||
}
|
||||
const r = runtime({ merchantToken: 'owner', apiStep: async (_module, _name, _request, validate) => {
|
||||
validate({ json: { code: '200', data: { page: 1, total: 201, records: [{ orderId: 'only-one' }] } } })
|
||||
} })
|
||||
await assert.rejects(() => r.importOrderSet('complete pagination'), /intermediate/)
|
||||
})
|
||||
|
||||
function importHarness(fault = '') {
|
||||
const report = { resources: { orderIds: [], tenantId: 'tenant' } }, orders = new Map(), requests = []
|
||||
const masterData = { receiving: { id: 'domestic', warehouseName: '国内仓', warehouseCode: 'CN' },
|
||||
destination: { id: 'fba', warehouseName: '目的仓', warehouseCode: 'US' }, channel: { channelName: '海运渠道', channelCode: 'SEA' } }
|
||||
let nextKey = 0, r
|
||||
const resultItem = (index, success, orderId) => ({ index, clientRowId: `excel-row-${index + 3}`, success,
|
||||
orderId: success ? orderId : null, orderNo: success ? `FL${orderId}` : null,
|
||||
errorCode: success ? null : 'A0422', errorMessage: success ? null : 'cargo required' })
|
||||
const responseData = (mode, items) => ({ mode, batchNo: 'batch', sourceFileId: 'source-file', errorReportFileId: 'error-file',
|
||||
result: { total: items.length, succeeded: items.filter(item => item.success).length, failed: items.filter(item => !item.success).length, items } })
|
||||
const addDraft = (id, cells) => orders.set(id, { order: { id, orderNo: `FL${id}`, version: 0, status: 'DRAFT', tenantId: 'tenant',
|
||||
customerRemark: cells[30], referenceId: cells[27], receivingWarehouseId: 'domestic', destinationWarehouseId: 'fba', channelCode: 'SEA' },
|
||||
cargoes: [{ skuNo: cells[18], packageCount: 2 }], parties: [], internalNotes: [], statusHistory: [] })
|
||||
const cells = (values, row) => values.map((value, index) => `<c r="${r.orderColumn(index)}${row}" t="inlineStr"><is><t>${r.orderXmlEncode(value)}</t></is></c>`).join('')
|
||||
r = runtime({ report, marker: 'contract', merchantToken: 'owner', orderAcceptanceFixture: { masterData, payload: { pricingTaxMode: 'TAX_INCLUDED' } },
|
||||
newIdempotencyKey: () => `key-${++nextKey}`,
|
||||
apiStep: async (_module, name, request, validate) => {
|
||||
requests.push({ name, path: request.path, key: request.headers?.['Idempotency-Key'] })
|
||||
let code = '200', data = null, bytes = null
|
||||
if (request.path.endsWith('/template')) {
|
||||
const template = templateFixture(r)
|
||||
const headers = Array.from({ length: 31 }, (_, index) => index === 0 ? '运输方式 *' : index === 30 ? '客户备注' : `字段${index}`)
|
||||
template.set('xl/worksheets/sheet1.xml', `<worksheet><sheetData><row r="1">${cells(['订单基础'], 1)}</row><row r="2">${cells(headers, 2)}</row></sheetData></worksheet>`)
|
||||
template.set('xl/worksheets/sheet2.xml', `<worksheet><sheetData><row r="1">${cells(['国内仓|CN', '目的仓|US', '海运渠道|SEA'], 1)}</row></sheetData></worksheet>`)
|
||||
bytes = r.orderZipWrite(template)
|
||||
} else if (request.path.includes('/error-reports/')) {
|
||||
bytes = r.orderZipWrite(new Map([['xl/worksheets/sheet1.xml', `<worksheet><sheetData><row r="1">${cells(['Excel行号', '错误代码', '错误说明'], 1)}</row><row r="2">${cells(['3', 'A0422', 'cargo required'], 2)}</row></sheetData></worksheet>`]]))
|
||||
} else if (request.method === 'POST') {
|
||||
const file = request.body.get('file'), body = Buffer.from(await file.arrayBuffer())
|
||||
if (name.startsWith('PARTIAL')) {
|
||||
const parsed = r.orderSheetRows(r.orderZipRead(body)).slice(2).map(row => row.cells.map(cell => cell.text))
|
||||
if (!orders.has('second')) addDraft('second', parsed[1])
|
||||
data = responseData('PARTIAL', [resultItem(0, false), resultItem(1, true, 'second')])
|
||||
} else if (name.startsWith('ATOMIC retry')) {
|
||||
const parsed = r.orderSheetRows(r.orderZipRead(body)).slice(2).map(row => row.cells.map(cell => cell.text))
|
||||
addDraft('first', parsed[0]); data = responseData('ATOMIC', [resultItem(0, true, 'first'), resultItem(1, true, 'second')])
|
||||
} else if (name.startsWith('ATOMIC later')) {
|
||||
code = 'A0409'
|
||||
if (fault === 'leaked-first-row') {
|
||||
const parsed = r.orderSheetRows(r.orderZipRead(body)).slice(2).map(row => row.cells.map(cell => cell.text))
|
||||
addDraft('leaked-first', parsed[0])
|
||||
}
|
||||
if (fault === 'changed-existing-row') orders.get('second').order.customerRemark = 'incorrectly-overwritten'
|
||||
} else code = 'A0422'
|
||||
} else if (request.path.includes('?page=')) {
|
||||
data = { page: 1, total: orders.size, records: [...orders.keys()].map(orderId => ({ orderId })) }
|
||||
} else data = structuredClone(orders.get(request.path.split('/').at(-1)))
|
||||
const response = { status: 200, headers: new Map([['content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']]), bytes, json: { code, data } }
|
||||
validate(response); return response
|
||||
},
|
||||
})
|
||||
return { r, report, requests }
|
||||
}
|
||||
|
||||
test('F04 runs actual import orchestration with the same seed/conflict/retry key and records limitations', async () => {
|
||||
const { r, report, requests } = importHarness()
|
||||
await r.orderExcelImportModule()
|
||||
const keys = requests.filter(item => item.key && /PARTIAL preserves|PARTIAL same key|ATOMIC later|ATOMIC retry/.test(item.name)).map(item => item.key)
|
||||
assert.equal(keys.length, 4); assert.equal(new Set(keys).size, 1)
|
||||
assert.deepEqual(report.resources.orderIds, ['second', 'first'])
|
||||
assert.ok(report.orderImportLimitations.some(item => item.includes('remote source uploads')))
|
||||
assert.ok(requests.some(item => item.name === 'forged option adds no orders: complete order page 1'))
|
||||
})
|
||||
|
||||
for (const fault of ['leaked-first-row', 'changed-existing-row']) test(`F04 rejects apparent ATOMIC failure with ${fault}`, async () => {
|
||||
const { r } = importHarness(fault)
|
||||
await assert.rejects(() => r.orderExcelImportModule(), /unchanged/)
|
||||
})
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { randomUUID, createHash } from 'node:crypto'
|
||||
import { resolve } from 'node:path'
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
import { inflateRawSync } from 'node:zlib'
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if ((args.environment && args.environment !== 'test') || args['overseas-gateway'] || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Only one TEST gateway is supported; remove regional options')
|
||||
@@ -84,6 +85,7 @@ let merchantToken
|
||||
let merchantRefreshToken
|
||||
let platformToken
|
||||
let existingClientToken
|
||||
let orderAcceptanceFixture
|
||||
let ownerPasswordNeedsRestore = false
|
||||
let temporaryOwnerPassword
|
||||
|
||||
@@ -97,6 +99,8 @@ try {
|
||||
await rbacAndMemberModule()
|
||||
await orderLifecycleModule()
|
||||
await isolationModule()
|
||||
await orderMutationEvidenceModule()
|
||||
await orderExcelImportModule()
|
||||
await notificationInboxModule()
|
||||
await accountSecurityModule()
|
||||
report.success = report.steps.every((step) => step.passed)
|
||||
@@ -1211,8 +1215,11 @@ async function orderLifecycleModule() {
|
||||
editedPayload.customerRemark = `edited-${marker}`
|
||||
editedPayload.cargoes[0].cargoRemark = `edited-cargo-${marker}`
|
||||
editedPayload.cargoes[0].model = `MODEL-EDIT-${marker}`.slice(0, 64)
|
||||
const editBefore = await ownedOrderSnapshot('order-lifecycle', 'freeze original draft edit version', orderId)
|
||||
const editCommand = orderMutationCommand(editBefore, { draft: editedPayload }, newIdempotencyKey('edit-draft'))
|
||||
recordOrderMutationRequest('initial lifecycle edit', { method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, ...editCommand }, editBefore)
|
||||
await apiStep('order-lifecycle', 'edit draft', {
|
||||
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload,
|
||||
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, ...editCommand,
|
||||
}, ({ json }) => requireSuccess(json), () => ({ orderId, customerRemark: editedPayload.customerRemark }))
|
||||
|
||||
const detail = await apiStep('order-lifecycle', 'order detail preserves complete product fields', {
|
||||
@@ -1231,6 +1238,7 @@ async function orderLifecycleModule() {
|
||||
cargo: cargoEvidence(json.data.cargoes[0]),
|
||||
}))
|
||||
const orderNo = detail.json.data.order.orderNo
|
||||
orderAcceptanceFixture = { masterData, payload: editedPayload, orderId }
|
||||
|
||||
await apiStep('order-lifecycle', 'order list contains edited draft', {
|
||||
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&orderNo=${encodeURIComponent(orderNo)}`, token: merchantToken,
|
||||
@@ -1321,7 +1329,9 @@ async function orderLifecycleModule() {
|
||||
}, ({ json }) => ({ orderId: 1, rejectionCode: json?.code }))
|
||||
|
||||
await softApiStep('order-boundary', 'submitted order cannot be edited by client', {
|
||||
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload,
|
||||
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken,
|
||||
...orderMutationCommand(await ownedOrderSnapshot('order-boundary', 'freeze submitted edit attempt', orderId),
|
||||
{ draft: editedPayload }, newIdempotencyKey('submitted-edit')),
|
||||
}, ({ json, status }) => {
|
||||
assertEqual(status, 200, 'HTTP status')
|
||||
assertEqual(String(json?.code), 'A0409', 'business code')
|
||||
@@ -1356,10 +1366,13 @@ async function orderLifecycleModule() {
|
||||
}, ({ json }) => ({ orderId: json.data }))
|
||||
const cancelOrderId = cancelDraft.json.data
|
||||
report.resources.orderIds.push(cancelOrderId)
|
||||
const cancelBefore = await ownedOrderSnapshot('order-lifecycle', 'freeze cancellation version', cancelOrderId)
|
||||
const cancelCommand = orderMutationCommand(cancelBefore, { reason: `客户端自动化取消 ${marker}` }, newIdempotencyKey('cancel-draft'))
|
||||
recordOrderMutationRequest('initial lifecycle cancellation', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, ...cancelCommand }, cancelBefore)
|
||||
|
||||
await apiStep('order-lifecycle', 'cancel second draft', {
|
||||
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken,
|
||||
body: { reason: `客户端自动化取消 ${marker}` },
|
||||
...cancelCommand,
|
||||
}, ({ json }) => requireSuccess(json), () => ({ orderId: cancelOrderId }))
|
||||
|
||||
await apiStep('order-lifecycle', 'cancelled status is persisted', {
|
||||
@@ -1371,7 +1384,8 @@ async function orderLifecycleModule() {
|
||||
|
||||
await softApiStep('order-boundary', 'cancelled order cannot be cancelled again', {
|
||||
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken,
|
||||
body: { reason: `重复取消 ${marker}` },
|
||||
...orderMutationCommand(await ownedOrderSnapshot('order-boundary', 'freeze repeat cancellation attempt', cancelOrderId),
|
||||
{ reason: `重复取消 ${marker}` }, newIdempotencyKey('repeat-cancel')),
|
||||
}, ({ json, status }) => {
|
||||
assertEqual(status, 200, 'HTTP status')
|
||||
assertEqual(String(json?.code), 'A0409', 'business code')
|
||||
@@ -1992,6 +2006,455 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
|
||||
}
|
||||
}
|
||||
|
||||
// BEGIN ORDER ACCEPTANCE HELPERS — loaded without CLI side effects by the direct contract.
|
||||
function orderCanonical(value) {
|
||||
if (Array.isArray(value)) return value.map(orderCanonical)
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, orderCanonical(value[key])]))
|
||||
return value
|
||||
}
|
||||
|
||||
function orderDigest(value) { return createHash('sha256').update(JSON.stringify(orderCanonical(value))).digest('hex') }
|
||||
function assertOrderUnchanged(before, after) {
|
||||
assertEqual(orderDigest(after), orderDigest(before), 'order, cargo, parties, notes and history must remain unchanged')
|
||||
}
|
||||
function assertOrderDetail(data, id) {
|
||||
assertEqual(String(data?.order?.id), String(id), 'owned order identity')
|
||||
for (const field of ['cargoes', 'parties', 'internalNotes', 'statusHistory']) assertTrue(Array.isArray(data?.[field]), `complete ${field} snapshot required`)
|
||||
assertPresent(data.order.version, 'order version')
|
||||
}
|
||||
function orderMutationCommand(snapshot, payload, key) {
|
||||
assertTrue(Number.isInteger(snapshot?.order?.version) && snapshot.order.version >= 0, 'frozen order version required')
|
||||
assertTrue(typeof key === 'string' && key.length > 0, 'frozen idempotency key required')
|
||||
const command = structuredClone({ headers: { 'Idempotency-Key': key }, body: { ...payload, expectedVersion: snapshot.order.version } })
|
||||
const freeze = value => { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value) } }
|
||||
freeze(command)
|
||||
return command
|
||||
}
|
||||
function assertImportResult(data, mode, expected) {
|
||||
assertEqual(data?.mode, mode, 'import mode')
|
||||
assertPresent(data?.batchNo, 'batch number')
|
||||
assertPresent(data?.sourceFileId, 'source file')
|
||||
const result = data?.result
|
||||
assertEqual(result?.total, expected.length, 'import total')
|
||||
assertEqual(result?.succeeded, expected.filter(Boolean).length, 'import succeeded')
|
||||
assertEqual(result?.failed, expected.filter(value => !value).length, 'import failed')
|
||||
assertEqual(result?.items?.length, expected.length, 'import item count')
|
||||
result.items.forEach((item, index) => {
|
||||
assertEqual(item.index, index, 'result index')
|
||||
assertEqual(item.clientRowId, `excel-row-${index + 3}`, 'physical Excel row')
|
||||
assertEqual(item.success, expected[index], 'row success')
|
||||
if (item.success) { assertPresent(item.orderId, 'created order ID'); assertPresent(item.orderNo, 'created order number') }
|
||||
else {
|
||||
assertEqual(item.orderId, null, 'failed row has no order')
|
||||
assertEqual(item.errorCode, 'A0422', 'invalid row code')
|
||||
assertPresent(item.errorMessage, 'row error reason')
|
||||
}
|
||||
})
|
||||
if (result.failed) assertPresent(data.errorReportFileId, 'downloadable error report')
|
||||
}
|
||||
|
||||
// Read the downloaded POI template, preserving its headers, options and validations.
|
||||
// ZIP64/encrypted archives are deliberately rejected; template fixtures are small XLSX files.
|
||||
function orderZipRead(bytes) {
|
||||
const input = Buffer.from(bytes)
|
||||
let end = input.length - 22
|
||||
while (end >= Math.max(0, input.length - 65557) && input.readUInt32LE(end) !== 0x06054b50) end--
|
||||
assertTrue(end >= 0 && input.readUInt32LE(end) === 0x06054b50, 'valid XLSX ZIP directory')
|
||||
const entries = new Map()
|
||||
let offset = input.readUInt32LE(end + 16)
|
||||
for (let index = 0; index < input.readUInt16LE(end + 10); index++) {
|
||||
assertEqual(input.readUInt32LE(offset), 0x02014b50, 'ZIP entry signature')
|
||||
const method = input.readUInt16LE(offset + 10), size = input.readUInt32LE(offset + 20)
|
||||
const length = input.readUInt32LE(offset + 24), nameLength = input.readUInt16LE(offset + 28)
|
||||
const name = input.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
|
||||
const local = input.readUInt32LE(offset + 42)
|
||||
assertTrue(!(input.readUInt16LE(offset + 8) & 1) && [0, 8].includes(method) && length <= 16 * 1024 * 1024, 'supported bounded ZIP entry')
|
||||
assertEqual(input.readUInt32LE(local), 0x04034b50, 'ZIP local signature')
|
||||
const start = local + 30 + input.readUInt16LE(local + 26) + input.readUInt16LE(local + 28)
|
||||
const packed = input.subarray(start, start + size)
|
||||
const content = method === 8 ? inflateRawSync(packed, { maxOutputLength: 16 * 1024 * 1024 }) : packed
|
||||
assertEqual(content.length, length, 'ZIP uncompressed size')
|
||||
assertEqual(orderCrc(content), input.readUInt32LE(offset + 16), 'ZIP checksum')
|
||||
assertTrue(!entries.has(name), 'unique ZIP entry')
|
||||
entries.set(name, content.toString('utf8'))
|
||||
offset += 46 + nameLength + input.readUInt16LE(offset + 30) + input.readUInt16LE(offset + 32)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
function orderCrc(bytes) {
|
||||
let crc = 0xffffffff
|
||||
for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0) }
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
function orderZipWrite(entries) {
|
||||
const local = [], directory = []
|
||||
let offset = 0
|
||||
for (const [name, text] of entries) {
|
||||
const filename = Buffer.from(name), data = Buffer.from(text), crc = orderCrc(data)
|
||||
const header = Buffer.alloc(30)
|
||||
header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4); header.writeUInt16LE(0x800, 6)
|
||||
header.writeUInt32LE(crc, 14); header.writeUInt32LE(data.length, 18); header.writeUInt32LE(data.length, 22); header.writeUInt16LE(filename.length, 26)
|
||||
const central = Buffer.alloc(46)
|
||||
central.writeUInt32LE(0x02014b50); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x800, 8)
|
||||
central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24)
|
||||
central.writeUInt16LE(filename.length, 28); central.writeUInt32LE(offset, 42)
|
||||
local.push(header, filename, data); directory.push(central, filename)
|
||||
offset += header.length + filename.length + data.length
|
||||
}
|
||||
const central = Buffer.concat(directory), end = Buffer.alloc(22)
|
||||
end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.size, 8); end.writeUInt16LE(entries.size, 10)
|
||||
end.writeUInt32LE(central.length, 12); end.writeUInt32LE(offset, 16)
|
||||
return Buffer.concat([...local, central, end])
|
||||
}
|
||||
function orderXmlDecode(text) {
|
||||
return text.replace(/&#(x[0-9a-f]+|[0-9]+);|&(lt|gt|quot|apos|amp);/gi, (match, code, named) => code
|
||||
? String.fromCodePoint(code[0].toLowerCase() === 'x' ? parseInt(code.slice(1), 16) : Number(code))
|
||||
: ({ lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[named.toLowerCase()])
|
||||
}
|
||||
function orderXmlEncode(text) { return String(text).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]) }
|
||||
function orderColumn(index) {
|
||||
let result = ''
|
||||
for (let value = index + 1; value; value = Math.floor((value - 1) / 26)) result = String.fromCharCode(65 + (value - 1) % 26) + result
|
||||
return result
|
||||
}
|
||||
function orderSheetRows(entries, path = 'xl/worksheets/sheet1.xml') {
|
||||
const strings = [...(entries.get('xl/sharedStrings.xml') ?? '').matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map(item =>
|
||||
[...item[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map(text => orderXmlDecode(text[1])).join(''))
|
||||
const sheet = entries.get(path)
|
||||
assertPresent(sheet, `template ${path}`)
|
||||
return [...sheet.matchAll(/<row\b[^>]*\br="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)].map(row => ({ number: Number(row[1]), cells:
|
||||
[...row[2].matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)].map(cell => {
|
||||
const ref = cell[1].match(/\br="([A-Z]+)\d+"/)?.[1]
|
||||
const content = cell[2] ?? ''
|
||||
const value = content.match(/<v\b[^>]*>([\s\S]*?)<\/v>/)?.[1]
|
||||
const text = /\bt="s"/.test(cell[1]) ? strings[Number(value)] : /\bt="inlineStr"/.test(cell[1])
|
||||
? [...content.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map(item => orderXmlDecode(item[1])).join('') : orderXmlDecode(value ?? '')
|
||||
return { ref, text }
|
||||
}) }))
|
||||
}
|
||||
function orderWorkbookRows(template, rows) {
|
||||
const entries = new Map(template), sheet = entries.get('xl/worksheets/sheet1.xml')
|
||||
const headers = [...sheet.matchAll(/<row\b[^>]*\br="([12])"[^>]*>[\s\S]*?<\/row>/g)].map(row => row[0]).join('')
|
||||
assertTrue(headers.includes('r="1"') && headers.includes('r="2"'), 'both original header rows preserved')
|
||||
const data = rows.map((row, index) => `<row r="${index + 3}">${row.map((text, col) => `<c r="${orderColumn(col)}${index + 3}" t="inlineStr"><is><t xml:space="preserve">${orderXmlEncode(text)}</t></is></c>`).join('')}</row>`).join('')
|
||||
entries.set('xl/worksheets/sheet1.xml', sheet.replace(/<sheetData\b[^>]*>[\s\S]*?<\/sheetData>/, `<sheetData>${headers}${data}</sheetData>`))
|
||||
return orderZipWrite(entries)
|
||||
}
|
||||
function importRollbackRows(validFirst, validSecond) {
|
||||
const invalid = [...validFirst]; invalid[10] = '' // mandatory cargo name: seed only the second row
|
||||
const changed = [...validSecond]; changed[30] += '-changed' // SaveRequest.customerRemark participates in CREATE_DRAFT hash
|
||||
return { seed: [invalid, validSecond], conflict: [validFirst, changed], retry: [validFirst, validSecond] }
|
||||
}
|
||||
// END ORDER ACCEPTANCE HELPERS
|
||||
|
||||
async function ownedOrderSnapshot(module, name, id, expected, validate = () => {}) {
|
||||
const response = await apiStep(module, name, {
|
||||
path: `/api/merchant/v1/orders/first-leg/${id}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json); assertOrderDetail(json.data, id)
|
||||
if (expected) assertOrderUnchanged(expected, json.data)
|
||||
validate(json.data)
|
||||
}, ({ json }) => ({ orderId: String(id), status: json.data?.order?.status, version: json.data?.order?.version, snapshotSha256: orderDigest(json.data) }))
|
||||
return response.json.data
|
||||
}
|
||||
|
||||
function recordOrderMutationRequest(name, request, before) {
|
||||
report.orderMutationRequests ??= []
|
||||
report.orderMutationRequests.push({ name, method: request.method, path: request.path,
|
||||
idempotencyKey: request.headers?.['Idempotency-Key'] ?? null, body: structuredClone(request.body),
|
||||
beforeVersion: before.order.version, beforeSnapshotSha256: orderDigest(before) })
|
||||
}
|
||||
|
||||
async function rejectOrderMutation(name, id, request, codes, statuses = [200]) {
|
||||
const before = await ownedOrderSnapshot('order-mutation-evidence', `${name}: before`, id)
|
||||
recordOrderMutationRequest(name, request, before)
|
||||
await apiStep('order-mutation-evidence', name, request, ({ json, status }) => {
|
||||
assertTrue(statuses.includes(status), `expected HTTP ${statuses}, actual=${status}`)
|
||||
assertTrue(codes.includes(String(json?.code)), `expected ${codes}, actual=${json?.code}`)
|
||||
}, ({ json }) => ({ orderId: String(id), rejectionCode: json?.code }))
|
||||
await ownedOrderSnapshot('order-mutation-evidence', `${name}: all facts unchanged`, id, before)
|
||||
}
|
||||
|
||||
async function orderMutationEvidenceModule() {
|
||||
const module = 'order-mutation-evidence', base = '/api/merchant/v1/orders/first-leg'
|
||||
assertPresent(orderAcceptanceFixture, 'completed order lifecycle fixture')
|
||||
assertPresent(existingClientToken, 'independent client session')
|
||||
const payload = structuredClone(orderAcceptanceFixture.payload)
|
||||
payload.customerRemark = `mutation-${marker}`
|
||||
const created = await apiStep(module, 'create owned mutable draft for isolation and cancellation', {
|
||||
method: 'POST', path: base, token: merchantToken, body: payload, headers: { 'Idempotency-Key': newIdempotencyKey('mutation-draft') },
|
||||
}, ({ json }) => { requireSuccess(json); assertPresent(json.data, 'draft ID') }, ({ json }) => ({ orderId: json.data }))
|
||||
const id = created.json.data
|
||||
report.resources.orderIds.push(id)
|
||||
const original = await ownedOrderSnapshot(module, 'freeze draft page version and request', id)
|
||||
const editPayload = structuredClone(payload); editPayload.customerRemark += '-edited'
|
||||
const editCommand = orderMutationCommand(original, { draft: editPayload }, newIdempotencyKey('versioned-edit'))
|
||||
const editRequest = { method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...editCommand }
|
||||
recordOrderMutationRequest('versioned draft edit', editRequest, original)
|
||||
await apiStep(module, 'versioned draft edit succeeds', editRequest, ({ json }) => requireSuccess(json),
|
||||
() => ({ orderId: id, expectedVersion: original.order.version }))
|
||||
const edited = await ownedOrderSnapshot(module, 'edited draft full snapshot', id, null, data => {
|
||||
assertEqual(data.order.status, 'DRAFT', 'edit stays draft')
|
||||
assertTrue(data.order.version > original.order.version, 'edit increments version')
|
||||
assertEqual(data.order.customerRemark, editPayload.customerRemark, 'edited source remark')
|
||||
for (const field of ['id', 'orderNo', 'inboundCode', 'tenantId', 'customerId']) assertEqual(data.order[field], original.order[field], `stable edit ${field}`)
|
||||
assertEqual(data.cargoes.length, editPayload.cargoes.length, 'complete edited cargo set')
|
||||
data.cargoes.forEach((cargo, index) => {
|
||||
for (const field of ['skuNo', 'packageCount', 'model', 'productImageFileId']) assertEqual(cargo[field], editPayload.cargoes[index][field], `edited cargo ${field}`)
|
||||
})
|
||||
const oldIds = new Set(original.statusHistory.map(item => String(item.id)))
|
||||
const added = data.statusHistory.filter(item => !oldIds.has(String(item.id)))
|
||||
assertOrderUnchanged(original.statusHistory, data.statusHistory.filter(item => oldIds.has(String(item.id))))
|
||||
assertEqual(added.length, 1, 'single edit audit'); assertEqual(added[0].action, 'UPDATE', 'edit audit action')
|
||||
})
|
||||
recordOrderMutationRequest('original edit replay', editRequest, original)
|
||||
await apiStep(module, 'original edit key and frozen payload replay successfully', editRequest, ({ json }) => requireSuccess(json),
|
||||
() => ({ orderId: id, expectedVersion: original.order.version, replayed: true }))
|
||||
await ownedOrderSnapshot(module, 'edit replay adds no version cargo or history', id, edited)
|
||||
const changedEdit = structuredClone(editCommand); changedEdit.body.draft.customerRemark += '-conflict'
|
||||
await rejectOrderMutation('edit original key different payload conflicts', id, {
|
||||
method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...changedEdit,
|
||||
}, ['A0409'])
|
||||
await rejectOrderMutation('old page version cannot overwrite newer draft', id, {
|
||||
method: 'PUT', path: `${base}/${id}`, token: merchantToken,
|
||||
...orderMutationCommand(original, { draft: payload }, newIdempotencyKey('stale-edit')),
|
||||
}, ['A0409'])
|
||||
for (const operation of ['edit', 'cancel']) {
|
||||
const method = operation === 'edit' ? 'PUT' : 'POST', path = `${base}/${id}${operation === 'edit' ? '' : '/cancel'}`
|
||||
const command = orderMutationCommand(edited, operation === 'edit' ? { draft: editPayload } : { reason: 'missing protocol guard' }, newIdempotencyKey(`missing-${operation}`))
|
||||
const withoutVersion = structuredClone(command); delete withoutVersion.body.expectedVersion
|
||||
await rejectOrderMutation(`${operation} missing version is rejected`, id, { method, path, token: merchantToken, ...withoutVersion }, ['A0422'])
|
||||
await rejectOrderMutation(`${operation} missing key is rejected`, id, { method, path, token: merchantToken, body: command.body }, ['A0400'], [200, 400])
|
||||
}
|
||||
await rejectOrderMutation('other customer cannot edit owned draft', id, {
|
||||
method: 'PUT', path: `${base}/${id}`, token: existingClientToken,
|
||||
...orderMutationCommand(edited, { draft: payload }, newIdempotencyKey('foreign-edit')),
|
||||
}, ['A0403', 'A0404'])
|
||||
await rejectOrderMutation('other customer cannot cancel owned draft', id, {
|
||||
method: 'POST', path: `${base}/${id}/cancel`, token: existingClientToken,
|
||||
...orderMutationCommand(edited, { reason: `foreign-${marker}` }, newIdempotencyKey('foreign-cancel')),
|
||||
}, ['A0403', 'A0404'])
|
||||
const invalid = structuredClone(payload); invalid.cargoes[0].productImageFileId = -1
|
||||
await rejectOrderMutation('invalid product image edit rolls back all facts', id, {
|
||||
method: 'PUT', path: `${base}/${id}`, token: merchantToken,
|
||||
...orderMutationCommand(edited, { draft: invalid }, newIdempotencyKey('invalid-edit')),
|
||||
}, ['A0422'])
|
||||
await rejectOrderMutation('submitted order cannot be overwritten as draft', orderAcceptanceFixture.orderId, {
|
||||
method: 'PUT', path: `${base}/${orderAcceptanceFixture.orderId}`, token: merchantToken,
|
||||
...orderMutationCommand(await ownedOrderSnapshot(module, 'freeze submitted order edit request', orderAcceptanceFixture.orderId),
|
||||
{ draft: payload }, newIdempotencyKey('submitted-edit-evidence')),
|
||||
}, ['A0409'])
|
||||
await rejectOrderMutation('blank cancellation reason preserves all facts', id, {
|
||||
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken,
|
||||
...orderMutationCommand(edited, { reason: ' ' }, newIdempotencyKey('empty-reason')),
|
||||
}, ['A0422'])
|
||||
await rejectOrderMutation('old page version cannot cancel updated draft', id, {
|
||||
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken,
|
||||
...orderMutationCommand(original, { reason: 'stale page' }, newIdempotencyKey('stale-cancel')),
|
||||
}, ['A0409'])
|
||||
const before = await ownedOrderSnapshot(module, 'before valid cancellation', id)
|
||||
const reason = `F06-cancel-${marker}`
|
||||
const cancelCommand = orderMutationCommand(before, { reason }, newIdempotencyKey('versioned-cancel'))
|
||||
const cancelRequest = { method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...cancelCommand }
|
||||
recordOrderMutationRequest('versioned cancellation', cancelRequest, before)
|
||||
await apiStep(module, 'cancel owned draft with reason', cancelRequest, ({ json }) => requireSuccess(json), () => ({ orderId: id, reason }))
|
||||
await apiStep(module, 'cancellation persists one attributed history and preserves cargo', {
|
||||
path: `${base}/${id}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json); assertOrderDetail(json.data, id)
|
||||
assertEqual(json.data.order.status, 'CANCELLED', 'cancelled status')
|
||||
assertTrue(json.data.order.version > before.order.version, 'version increases')
|
||||
for (const field of ['cargoes', 'parties', 'internalNotes']) assertOrderUnchanged(before[field], json.data[field])
|
||||
for (const field of ['id', 'orderNo', 'inboundCode', 'tenantId', 'customerId']) assertEqual(json.data.order[field], before.order[field], `stable ${field}`)
|
||||
const oldIds = new Set(before.statusHistory.map(item => String(item.id)))
|
||||
const added = json.data.statusHistory.filter(item => !oldIds.has(String(item.id)))
|
||||
assertEqual(added.length, 1, 'one cancellation history')
|
||||
assertOrderUnchanged(before.statusHistory, json.data.statusHistory.filter(item => oldIds.has(String(item.id))))
|
||||
assertEqual(added[0].action, 'CANCELLED', 'cancellation action'); assertEqual(added[0].reason, reason, 'cancellation reason')
|
||||
assertEqual(added[0].fromStatus, 'DRAFT', 'from draft'); assertEqual(added[0].toStatus, 'CANCELLED', 'to cancelled')
|
||||
assertPresent(added[0].operatorId, 'operator'); assertPresent(added[0].occurredAt, 'server operation time')
|
||||
}, ({ json }) => ({ orderId: id, status: json.data?.order?.status, snapshotSha256: orderDigest(json.data) }))
|
||||
const cancelled = await ownedOrderSnapshot(module, 'freeze cancelled facts before replay', id)
|
||||
recordOrderMutationRequest('original cancellation replay', cancelRequest, before)
|
||||
await apiStep(module, 'original cancellation key payload and version replay successfully', cancelRequest,
|
||||
({ json }) => requireSuccess(json), () => ({ orderId: id, expectedVersion: before.order.version, replayed: true }))
|
||||
await ownedOrderSnapshot(module, 'cancel replay creates no second history or version', id, cancelled)
|
||||
const changedCancel = structuredClone(cancelCommand); changedCancel.body.reason += '-conflict'
|
||||
await rejectOrderMutation('cancellation original key different reason conflicts', id, {
|
||||
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...changedCancel,
|
||||
}, ['A0409'])
|
||||
await rejectOrderMutation('repeat cancellation cannot add history', id, {
|
||||
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken,
|
||||
...orderMutationCommand(cancelled, { reason }, newIdempotencyKey('new-key-repeat-cancel')),
|
||||
}, ['A0409'])
|
||||
await rejectOrderMutation('cancelled draft cannot be submitted', id, {
|
||||
method: 'POST', path: `${base}/${id}/submit`, token: merchantToken, headers: { 'Idempotency-Key': newIdempotencyKey('cancelled-submit-proof') },
|
||||
}, ['A0409'])
|
||||
report.orderMutationLimitations = [
|
||||
'Requires deployed Merchant edit/cancel versioned idempotent protocol; local contracts do not prove TEST deployment.',
|
||||
'Irreversible-fulfillment cancellation is not exercised by these draft/submitted fixtures.',
|
||||
]
|
||||
}
|
||||
|
||||
async function importOrderSet(name, expectedIds) {
|
||||
const ids = [], seen = new Set()
|
||||
let total = null
|
||||
for (let page = 1; ; page++) {
|
||||
await apiStep('order-excel-import', `${name}: complete order page ${page}`, {
|
||||
path: `/api/merchant/v1/orders/first-leg?page=${page}&size=200`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Array.isArray(json.data?.records), 'order page records required')
|
||||
assertEqual(Number(json.data.page), page, 'order page identity')
|
||||
const count = Number(json.data.total)
|
||||
assertTrue(Number.isSafeInteger(count) && count >= 0, 'valid order total')
|
||||
if (total === null) total = count
|
||||
assertEqual(count, total, 'stable order page total')
|
||||
for (const row of json.data.records) {
|
||||
assertPresent(row.orderId, 'listed order ID')
|
||||
const id = String(row.orderId)
|
||||
assertTrue(!seen.has(id), 'no duplicate order across pages'); seen.add(id); ids.push(id)
|
||||
}
|
||||
assertTrue(ids.length <= total, 'order pages do not exceed total')
|
||||
if (page * 200 >= total) {
|
||||
assertEqual(ids.length, total, 'complete order set')
|
||||
if (expectedIds) assertOrderUnchanged([...expectedIds].sort(), [...ids].sort())
|
||||
} else assertEqual(json.data.records.length, 200, 'full intermediate order page')
|
||||
}, () => ({ page, total, collected: ids.length, orderIdsSha256: orderDigest([...ids].sort()) }))
|
||||
if (page * 200 >= total) return ids
|
||||
}
|
||||
}
|
||||
|
||||
async function orderExcelImportModule() {
|
||||
const module = 'order-excel-import', base = '/api/merchant/v1/orders/first-leg/batch-imports'
|
||||
const { masterData, payload } = orderAcceptanceFixture
|
||||
let template
|
||||
await apiStep(module, 'download and inspect actual Chinese customer template', {
|
||||
path: `${base}/template`, token: merchantToken, responseType: 'bytes',
|
||||
}, ({ status, bytes, headers }) => {
|
||||
assertEqual(status, 200, 'template HTTP status')
|
||||
assertTrue((headers.get('content-type') ?? '').includes('spreadsheetml'), 'XLSX content type')
|
||||
template = orderZipRead(bytes)
|
||||
const header = orderSheetRows(template).find(row => row.number === 2)
|
||||
assertEqual(header?.cells?.length, 31, 'current 31-column template')
|
||||
assertEqual(header.cells[0].text, '运输方式 *', 'Chinese first header')
|
||||
assertEqual(header.cells[30].text, '客户备注', 'Chinese final header')
|
||||
assertTrue(template.get('xl/workbook.xml').includes('选项数据'), 'server-generated option sheet')
|
||||
}, ({ bytes }) => ({ sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }))
|
||||
const options = new Set(orderSheetRows(template, 'xl/worksheets/sheet2.xml').flatMap(row => row.cells.map(cell => cell.text)))
|
||||
const businessLabel = (name, code) => !name || name.toLowerCase() === code.toLowerCase() ? code : `${name}|${code}`
|
||||
const receiving = businessLabel(masterData.receiving.warehouseName, masterData.receiving.warehouseCode)
|
||||
const destination = businessLabel(masterData.destination.warehouseName, masterData.destination.warehouseCode)
|
||||
const channel = businessLabel(masterData.channel.channelName, masterData.channel.channelCode)
|
||||
for (const value of [receiving, destination, channel]) assertTrue(options.has(value), `authoritative template option ${value}`)
|
||||
const makeRow = suffix => [
|
||||
'海运', '', '自行快递', receiving, channel, '亚马逊FBA', destination, '', '', '',
|
||||
`Excel-${suffix}`, '纸箱', '2', '20.5', '0.024', '塑料', '否', '否', `SKU-${suffix}`, '123456789012',
|
||||
`SHIP-${suffix}`, `REF-${suffix}`, '买单报关', payload.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税',
|
||||
'散货拼箱', '否', `SHIP-${suffix}`, `REF-${suffix}`, '普通头程', '', `${marker}-${suffix}`,
|
||||
]
|
||||
const upload = async (name, mode, key, bytes, validate, filename = 'orders.xlsx') => {
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), filename)
|
||||
return apiStep(module, name, {
|
||||
method: 'POST', path: `${base}?mode=${mode}`, token: merchantToken, headers: { 'Idempotency-Key': key }, body: form,
|
||||
}, validate, ({ json }) => ({ batchNo: json?.data?.batchNo, mode, result: json?.data?.result, sourceFileId: json?.data?.sourceFileId,
|
||||
errorReportFileId: json?.data?.errorReportFileId, requestBytesSha256: createHash('sha256').update(bytes).digest('hex') }))
|
||||
}
|
||||
const rejection = code => ({ json, status }) => {
|
||||
assertEqual(status, 200, 'HTTP status'); assertEqual(String(json?.code), code, 'rejection business code')
|
||||
}
|
||||
const success = (mode, rows) => ({ json }) => { requireSuccess(json); assertImportResult(json.data, mode, rows) }
|
||||
const verifyDraft = async (id, row, name) => {
|
||||
await apiStep(module, name, { path: `/api/merchant/v1/orders/first-leg/${id}`, token: merchantToken }, ({ json }) => {
|
||||
requireSuccess(json); assertOrderDetail(json.data, id)
|
||||
assertEqual(json.data.order.status, 'DRAFT', 'import creates draft only')
|
||||
assertEqual(String(json.data.order.tenantId), String(report.resources.tenantId), 'authenticated customer scope')
|
||||
assertEqual(json.data.order.customerRemark, row[30], 'exact source remark')
|
||||
assertEqual(json.data.order.referenceId, row[27], 'exact source reference')
|
||||
assertEqual(String(json.data.order.receivingWarehouseId), String(masterData.receiving.id), 'authoritative receiving warehouse')
|
||||
assertEqual(String(json.data.order.destinationWarehouseId), String(masterData.destination.id), 'authoritative destination')
|
||||
assertEqual(json.data.order.channelCode, masterData.channel.channelCode, 'authoritative channel')
|
||||
assertEqual(json.data.cargoes.length, 1, 'one Excel row creates one order cargo')
|
||||
assertEqual(json.data.cargoes[0].skuNo, row[18], 'source SKU')
|
||||
assertEqual(Number(json.data.cargoes[0].packageCount), 2, 'source package count')
|
||||
assertTrue(!json.data.statusHistory.some(item => item.toStatus === 'SUBMITTED'), 'import never submits')
|
||||
}, ({ json }) => ({ orderId: String(id), status: json.data?.order?.status, snapshotSha256: orderDigest(json.data) }))
|
||||
}
|
||||
const initial = await importOrderSet('before import matrix')
|
||||
const rows = importRollbackRows(makeRow('rollback-first'), makeRow('rollback-second'))
|
||||
const key = newIdempotencyKey('excel-transaction')
|
||||
const seedBytes = orderWorkbookRows(template, rows.seed)
|
||||
const seed = await upload('PARTIAL preserves only valid second row', 'PARTIAL', key, seedBytes, success('PARTIAL', [false, true]))
|
||||
const retainedId = String(seed.json.data.result.items[1].orderId)
|
||||
report.resources.orderIds.push(retainedId)
|
||||
const seeded = [...initial, retainedId]
|
||||
await importOrderSet('PARTIAL exactly one new order', seeded)
|
||||
await verifyDraft(retainedId, rows.seed[1], 'PARTIAL source fields and customer match')
|
||||
const retained = await ownedOrderSnapshot(module, 'seed second row full snapshot', retainedId)
|
||||
const replay = await upload('PARTIAL same key bytes returns same order', 'PARTIAL', key, seedBytes, response => {
|
||||
success('PARTIAL', [false, true])(response)
|
||||
assertEqual(String(response.json.data.result.items[1].orderId), retainedId, 'replayed order identity')
|
||||
})
|
||||
await importOrderSet('PARTIAL replay adds no orders', seeded)
|
||||
await ownedOrderSnapshot(module, 'PARTIAL replay preserves full existing order', retainedId, retained)
|
||||
const errorData = replay.json.data
|
||||
await apiStep(module, 'download actual row error workbook', {
|
||||
path: `/api/merchant/v1/files/order-batch-imports/${encodeURIComponent(errorData.batchNo)}/error-reports/${errorData.errorReportFileId}`,
|
||||
token: merchantToken, responseType: 'bytes',
|
||||
}, ({ status, bytes }) => {
|
||||
assertEqual(status, 200, 'error workbook download')
|
||||
const rows = orderSheetRows(orderZipRead(bytes))
|
||||
assertEqual(rows.length, 2, 'header and exactly one failed row')
|
||||
assertEqual(rows[1].cells[0].text, '3', 'actual Excel error row')
|
||||
assertEqual(rows[1].cells[1].text, 'A0422', 'error report code')
|
||||
assertEqual(rows[1].cells[2].text, errorData.result.items[0].errorMessage, 'error report exact reason')
|
||||
}, ({ bytes }) => ({ sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }))
|
||||
await upload('ATOMIC parse rejection creates no orders', 'ATOMIC', newIdempotencyKey('excel-parse'), seedBytes, rejection('A0422'))
|
||||
await importOrderSet('ATOMIC parse failure has zero additions', seeded)
|
||||
// Same tenant/operator/key and physical row IDs; only row 4 customerRemark changes.
|
||||
// Row 3 passes validation and writes before row 4 CREATE_DRAFT fingerprint conflicts.
|
||||
await upload('ATOMIC later row idempotency conflict rolls back earlier row', 'ATOMIC', key,
|
||||
orderWorkbookRows(template, rows.conflict), rejection('A0409'))
|
||||
await importOrderSet('ATOMIC mid-write conflict leaves no first-row order', seeded)
|
||||
await ownedOrderSnapshot(module, 'ATOMIC conflict preserves seeded order and history', retainedId, retained)
|
||||
await upload('ATOMIC retry with original second-row hash succeeds', 'ATOMIC', key,
|
||||
orderWorkbookRows(template, rows.retry), response => {
|
||||
success('ATOMIC', [true, true])(response)
|
||||
assertEqual(String(response.json.data.result.items[1].orderId), retainedId, 'second row reuses original ID')
|
||||
assertTrue(String(response.json.data.result.items[0].orderId) !== retainedId, 'new first row is distinct')
|
||||
}).then(async response => {
|
||||
const firstId = String(response.json.data.result.items[0].orderId)
|
||||
report.resources.orderIds.push(firstId); seeded.push(firstId)
|
||||
await importOrderSet('ATOMIC retry adds exactly first row', seeded)
|
||||
await verifyDraft(firstId, rows.retry[0], 'ATOMIC first row source facts match')
|
||||
})
|
||||
await ownedOrderSnapshot(module, 'successful ATOMIC replay preserves second row', retainedId, retained)
|
||||
const invalidCases = [
|
||||
['wrong extension', seedBytes, 'orders.csv'], ['corrupt xlsx', Buffer.from('not-an-xlsx'), 'orders.xlsx'],
|
||||
['over five MiB', Buffer.alloc(5 * 1024 * 1024 + 1), 'orders.xlsx'],
|
||||
['51 effective rows', orderWorkbookRows(template, Array.from({ length: 51 }, (_, index) => makeRow(`limit-${index}`))), 'orders.xlsx'],
|
||||
]
|
||||
const missingHeader = orderZipRead(orderWorkbookRows(template, [makeRow('header')]))
|
||||
missingHeader.set('xl/worksheets/sheet1.xml', missingHeader.get('xl/worksheets/sheet1.xml').replace(/<row\b[^>]*\br="2"[^>]*>[\s\S]*?<\/row>/, '<row r="2"/>'))
|
||||
invalidCases.push(['missing Chinese headers', orderZipWrite(missingHeader), 'orders.xlsx'])
|
||||
for (const [name, bytes, filename] of invalidCases) {
|
||||
await upload(`reject ${name}`, 'ATOMIC', newIdempotencyKey('excel-invalid'), bytes, rejection('A0422'), filename)
|
||||
await importOrderSet(`${name}: order set unchanged`, seeded)
|
||||
}
|
||||
const forged = makeRow('forged-catalog'); forged[3] = `FORGED-${marker}`
|
||||
const tampered = new Map(template)
|
||||
tampered.set('xl/worksheets/sheet2.xml', tampered.get('xl/worksheets/sheet2.xml').replace('</sheetData>', `<row r="9999"><c r="A9999" t="inlineStr"><is><t>${orderXmlEncode(forged[3])}</t></is></c></row></sheetData>`))
|
||||
await upload('forged hidden option is rejected against authoritative catalog', 'ATOMIC', newIdempotencyKey('excel-forged'),
|
||||
orderWorkbookRows(tampered, [forged]), rejection('A0422'))
|
||||
await importOrderSet('forged option adds no orders', seeded)
|
||||
report.orderImportLimitations = [
|
||||
'ATOMIC assertions concern Order rows and their public facts; remote source uploads are not rolled back.',
|
||||
'Template selected options are checked; exhaustive warehouse pagination and cross-customer private-address template comparison remain open.',
|
||||
'This HTTP matrix does not prove browser import interaction or irreversible order fulfillment cancellation.',
|
||||
]
|
||||
}
|
||||
|
||||
async function apiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
|
||||
const startedAt = new Date().toISOString()
|
||||
const started = Date.now()
|
||||
|
||||
Reference in New Issue
Block a user