diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 5df154b64..f46f6459f 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -60,7 +60,7 @@ export async function run(context) { |---|---|---| | 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` | | 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` | -| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | +| 订单创建、可计价主数据、幂等零漂移与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-lifecycle-idempotency-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs b/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs new file mode 100644 index 000000000..8c71f0bff --- /dev/null +++ b/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { orderPersistenceFacts, requestedOrderDestinationType } from './orders.mjs' + +const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8') + +test('order demo payload covers multi-box multi-SKU images and both destination families', () => { + for (const boxType of ['SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE']) { + assert.match(source, new RegExp(`boxType: '${boxType}'`)) + } + assert.match(source, /cargoes: selectedBoxes\.map/) + assert.match(source, /productImageFileId/) + assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'FBA' } }), 'FBA') + assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'OVERSEAS' } }), 'OVERSEAS') +}) + +test('create and submit lifecycle covers replay payload conflict and zero drift', () => { + assert.match(source, /Client 创建订单幂等重放/) + assert.match(source, /Client 创建订单同键异载荷返回A0409/) + assert.match(source, /创建同键异载荷失败后订单全事实零漂移/) + assert.match(source, /Client 提交订单同键同载荷精确重放/) + assert.match(source, /已提交订单使用新键重复提交返回A0409/) + assert.match(source, /重复提交失败后订单状态货物与历史零漂移/) + assert.match(source, /orderPersistenceFacts\(draftAfterConflict\) === orderPersistenceFacts\(draft\)/) + assert.match(source, /orderPersistenceFacts\(submittedAfterFailure\) === orderPersistenceFacts\(submitted\)/) +}) + +test('persistence fact comparison includes order cargo parties notes and history', () => { + const detail = { + order: { id: '1', status: 'DRAFT' }, cargoes: [{ skuNo: 'SKU-1' }], + parties: [{ role: 'SENDER' }], internalNotes: [{ content: 'note' }], + statusHistory: [{ toStatus: 'DRAFT' }], + } + const baseline = orderPersistenceFacts(detail) + assert.equal(orderPersistenceFacts(structuredClone(detail)), baseline) + assert.notEqual(orderPersistenceFacts({ ...detail, order: { ...detail.order, status: 'SUBMITTED' } }), baseline) + assert.notEqual(orderPersistenceFacts({ ...detail, cargoes: [{ skuNo: 'SKU-2' }] }), baseline) + assert.notEqual(orderPersistenceFacts({ ...detail, statusHistory: [...detail.statusHistory, { toStatus: 'SUBMITTED' }] }), baseline) +}) + +test('approval convergence is a hard WAIT_MEASURE prerequisite without coverage downgrade', () => { + assert.match(source, /审核后入库单与履约任务异步收敛/) + assert.match(source, /MEASURABLE_STATUSES/) + assert.match(source, /throw new Error\('订单审核后的入库单与履约任务未在30秒内收敛/) + assert.doesNotMatch(source, /审核后入库单与履约任务异步收敛[\s\S]{0,300}addCoverageGap/) +}) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 9e8e4e7c8..6f6404e3d 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -25,6 +25,15 @@ function rotate(values, offset) { return [...values.slice(shift), ...values.slice(0, shift)] } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } +export function orderPersistenceFacts(detail) { + return JSON.stringify({ + order: detail?.order ?? null, + cargoes: Array.isArray(detail?.cargoes) ? detail.cargoes : [], + parties: Array.isArray(detail?.parties) ? detail.parties : [], + internalNotes: Array.isArray(detail?.internalNotes) ? detail.internalNotes : [], + statusHistory: Array.isArray(detail?.statusHistory) ? detail.statusHistory : [], + }) +} export function requestedOrderDestinationType(context) { const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA' @@ -547,13 +556,41 @@ export async function run(context) { assertCargoContract(context, draftResponse, draft?.cargoes, payload) context.createdResources.orders.orderNo = orderNo + const createConflictPayload = { + ...payload, + customerRemark: `${payload.customerRemark} 同键异载荷`, + } + const createConflictResponse = await request(context, 'POST', createPath, createConflictPayload, merchantToken, + { 'Idempotency-Key': key(context, 'client-create') }) + context.assert(MODULE, 'Client 创建订单同键异载荷返回A0409', createConflictResponse, + createConflictResponse.http === 200 && String(createConflictResponse.body?.code) === 'A0409', + `http=${createConflictResponse.http}, code=${createConflictResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS) + const draftAfterConflictResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken) + const draftAfterConflict = context.apiSuccess(MODULE, 'Client 回查创建冲突后的订单草稿', draftAfterConflictResponse) + context.assert(MODULE, '创建同键异载荷失败后订单全事实零漂移', draftAfterConflictResponse, + orderPersistenceFacts(draftAfterConflict) === orderPersistenceFacts(draft), + `orderId=${orderId}, status=${draftAfterConflict?.order?.status ?? 'missing'}`, CONTRACT_OPTIONS) + const submitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined, merchantToken, { 'Idempotency-Key': key(context, 'client-submit') }) context.apiSuccess(MODULE, 'Client 提交隔离订单', submitResponse, `orderId=${orderId}`) + const submitReplayResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined, + merchantToken, { 'Idempotency-Key': key(context, 'client-submit') }) + context.apiSuccess(MODULE, 'Client 提交订单同键同载荷精确重放', submitReplayResponse, `orderId=${orderId}`) const submittedResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken) const submitted = context.apiSuccess(MODULE, 'Client 回查已提交订单', submittedResponse) context.assert(MODULE, 'Client 订单已进入待审核状态', submittedResponse, submitted?.order?.status === 'SUBMITTED', `status=${submitted?.order?.status ?? 'missing'}`) + const duplicateSubmitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined, + merchantToken, { 'Idempotency-Key': key(context, 'client-submit-new-key') }) + context.assert(MODULE, '已提交订单使用新键重复提交返回A0409', duplicateSubmitResponse, + duplicateSubmitResponse.http === 200 && String(duplicateSubmitResponse.body?.code) === 'A0409', + `http=${duplicateSubmitResponse.http}, code=${duplicateSubmitResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS) + const submittedAfterFailureResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken) + const submittedAfterFailure = context.apiSuccess(MODULE, 'Client 回查重复提交失败后的订单', submittedAfterFailureResponse) + context.assert(MODULE, '重复提交失败后订单状态货物与历史零漂移', submittedAfterFailureResponse, + orderPersistenceFacts(submittedAfterFailure) === orderPersistenceFacts(submitted), + `orderId=${orderId}, status=${submittedAfterFailure?.order?.status ?? 'missing'}`, CONTRACT_OPTIONS) const unifiedListResponse = await context.request('GET', `/api/platform/v1/orders?page=1&size=100&tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}`)