test(order): add isolated dependency failure and recovery verification

This commit is contained in:
2026-10-03 05:25:30 +08:00
parent 5e683da3b9
commit bde1cd532e
8 changed files with 1272 additions and 5 deletions
+72 -4
View File
@@ -2,8 +2,6 @@ import { createHash } from 'node:crypto'
const MODULE = 'orders'
const CONTRACT_OPTIONS = { scenario: 'isolated-order-chain', deploymentBlocker: true }
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
const BOXES = [
{ boxType: 'SMALL', lengthCm: 30, widthCm: 20, heightCm: 20 },
{ boxType: 'MEDIUM', lengthCm: 40, widthCm: 30, heightCm: 30 },
@@ -243,6 +241,69 @@ export function orderPersistenceFacts(detail) {
statusHistory: Array.isArray(detail?.statusHistory) ? detail.statusHistory : [],
})
}
// Read-only planning and evidence validation only. These exports never configure services or send requests.
export function buildOrderDependencyFaultPlan(fixture) {
const customerId = id(fixture?.customerId)
if (!/^[1-9]\d*$/.test(customerId) || customerId !== id(fixture?.registeredCustomerId)
|| !/^[A-Za-z0-9_-]{1,80}$/.test(fixture?.marker || '')) throw new Error('Dependency fault fixture must belong to this run registered customer')
const commands = {}
for (const role of ['merchant','platform']) {
const detail = fixture[role]
if (id(detail?.order?.tenantId) !== customerId || !/^[1-9]\d*$/.test(id(detail?.order?.id))
|| detail.order.status !== 'DRAFT' || !detail.order.orderNo
|| !['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(detail[field]))) {
throw new Error(`Incomplete owned ${role} DRAFT evidence`)
}
const command = freezeOrderCommand(detail, `dep-${role}-${fixture.marker}`)
commands[role] = { method:'POST', path:`/api/${role}/v1/orders/first-leg/${detail.order.id}/submit${role==='platform'?`?tenantId=${customerId}`:''}`,
...command, orderId:id(detail.order.id), orderNo:detail.order.orderNo,
initialOrderSha256:createHash('sha256').update(orderPersistenceFacts(detail)).digest('hex') }
}
if (commands.merchant.orderId === commands.platform.orderId) throw new Error('Use two independent drafts for same-key recovery')
return { marker:fixture.marker, customerId, runtimeChangesExecuted:false, commands,
phases:[
{ dependency:'IAM', urlProperty:'baiyue.clients.iam.url', targetMethod:'GET', targetPath:`/internal/iam/tenants/${customerId}/order-eligibility`,
faultHttp:503, expectedHttp:200, expectedCode:'S0503', expectedMessages:['IAM eligibility service is unavailable','IAM 下单资格响应无效'] },
{ dependency:'FINANCE', urlProperty:'baiyue.clients.finance.url', targetMethod:'GET', targetPath:`/internal/finance/v1/wallet/accounts/${customerId}`,
faultHttp:503, expectedHttp:200, expectedCode:'S0503', expectedMessages:['Finance account service is unavailable','Finance account response is invalid'] },
],
recovery:'Restore original Order image/config/network identity; submit each original frozen command once, then replay it; require one submission history and no duplicate Firstleg facts.' }
}
export function assertOrderDependencyFaultObservation(plan, dependency, role, observation) {
const phase = plan?.phases?.find(item => item.dependency === dependency), command = plan?.commands?.[role]
const require = (ok, message) => { if (!ok) throw new Error(`Dependency fault evidence: ${message}`) }
require(Boolean(phase && command), 'unknown dependency or role')
const request = observation?.request, response = observation?.response
require(request?.method === command.method && request.path === command.path
&& JSON.stringify(request.body) === JSON.stringify(command.body)
&& request.headers?.['Idempotency-Key'] === command.headers['Idempotency-Key'], 'original frozen request/version/key changed')
const requestId = request.headers?.['X-Request-Id']
require(typeof requestId === 'string' && requestId.length > 0, 'missing correlation ID')
require(response?.http === phase.expectedHttp && String(response.body?.code) === phase.expectedCode
&& phase.expectedMessages.includes(response.body.message), 'must be the real Order dependency failure, not auth/version/business validation')
require(Array.isArray(observation.proxyRecords) && observation.proxyRecords.some(row =>
row.requestId === requestId && row.method === phase.targetMethod && row.path === phase.targetPath
&& row.matched === true && row.responseStatus === phase.faultHttp && row.forwarded === false
&& row.internalTokenPresent === true), 'missing exact authenticated upstream fault hit')
require(observation.proxyRecords.filter(row => row.matched === true).every(row => row.method === phase.targetMethod
&& row.path === phase.targetPath && row.responseStatus === phase.faultHttp && row.forwarded === false), 'fault escaped selected customer/path')
for (const label of ['before','after']) {
const snapshot = observation[label], detail = snapshot?.detail
require(snapshot?.inboundPageComplete === true && Array.isArray(snapshot.inbounds)
&& ['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(detail?.[field]))
&& id(detail?.order?.id) === command.orderId && id(detail.order.tenantId) === plan.customerId
&& detail.order.status === 'DRAFT', `${label} incomplete/foreign order or Firstleg evidence`)
require(snapshot.task === null || snapshot.task && id(snapshot.task.orderId) === command.orderId
&& id(snapshot.task.tenantId) === plan.customerId, `${label} task source mismatch`)
require(snapshot.inbounds.every(row => id(row.tenantId) === plan.customerId && row.orderNo === command.orderNo), `${label} inbound source mismatch`)
}
require(createHash('sha256').update(orderPersistenceFacts(observation.before.detail)).digest('hex') === command.initialOrderSha256,
'baseline differs from prepared draft')
require(JSON.stringify(observation.before) === JSON.stringify(observation.after), 'order/version/history/Firstleg facts changed')
return { dependency, role, orderId:command.orderId, verified:true, requestId,
snapshotSha256:createHash('sha256').update(JSON.stringify(observation.after)).digest('hex') }
}
export function requestedOrderDestinationType(context) {
const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase()
return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA'
@@ -848,11 +909,18 @@ async function waitForInbound(context, tenantId, orderId) {
}
export async function run(context) {
// Resolve per invocation: this module is cached across serial fresh-customer runs.
const fixture = context.context?.merchantQualificationFixture
const configured = context.options?.merchantUsername || context.options?.merchantPassword
const credentials = fixture ? { username: fixture.username, password: fixture.password }
: configured ? { username: context.options.merchantUsername, password: context.options.merchantPassword }
: { username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }
if (!credentials.username || !credentials.password) throw new Error('Client 当前运行缺少完整登录凭据')
const loginResponse = await context.request('POST', '/api/auth/login', {
loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
loginId: credentials.username, password: credentials.password,
}, '')
const login = context.apiSuccess(MODULE, 'Client 测试账号登录', loginResponse,
`username=${MERCHANT_USERNAME}`)
'current-run customer credentials')
const merchantToken = login?.accessToken
if (!merchantToken) throw new Error('Client 登录未返回 accessToken')