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
@@ -2,7 +2,54 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationType, freezeOrderCommand, verifyPlatformDraftEdit, verifyPlatformSubmitCancel } from './orders.mjs'
import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationType, freezeOrderCommand, verifyPlatformDraftEdit, verifyPlatformSubmitCancel,
buildOrderDependencyFaultPlan, assertOrderDependencyFaultObservation } from './orders.mjs'
function dependencyFixture() {
const detail = id => ({order:{id,orderNo:`FL${id}`,tenantId:'20',status:'DRAFT',version:4},
cargoes:[{skuNo:'current'}],parties:[],internalNotes:[],statusHistory:[]})
return {marker:'CURRENT-RUN',customerId:'20',registeredCustomerId:'20',merchant:detail('101'),platform:detail('102')}
}
function dependencyObservation(plan, fixture, dependency, role) {
const phase=plan.phases.find(row=>row.dependency===dependency),command=plan.commands[role]
const snapshot={detail:structuredClone(fixture[role]),task:null,inbounds:[],inboundPageComplete:true}
return {request:{method:command.method,path:command.path,body:structuredClone(command.body),headers:{...command.headers,'X-Request-Id':`${dependency}-${role}`}},
response:{http:200,body:{code:'S0503',message:phase.expectedMessages[0]}},before:snapshot,after:structuredClone(snapshot),
proxyRecords:[{requestId:`${dependency}-${role}`,method:'GET',path:phase.targetPath,matched:true,responseStatus:503,forwarded:false,internalTokenPresent:true}]}
}
test('dependency plan binds two new-customer drafts and only named Order upstream properties; it performs no runtime actions',()=>{
const fixture=dependencyFixture(),plan=buildOrderDependencyFaultPlan(fixture)
assert.equal(plan.runtimeChangesExecuted,false)
assert.deepEqual(plan.phases.map(row=>row.urlProperty),['baiyue.clients.iam.url','baiyue.clients.finance.url'])
assert.equal(plan.commands.merchant.body.expectedVersion,4)
fixture.merchant.order.version=9
assert.equal(plan.commands.merchant.body.expectedVersion,4)
assert.ok(Object.isFrozen(plan.commands.merchant.body))
for(const change of [f=>f.customerId='0',f=>f.registeredCustomerId='21',f=>f.platform.order.id='101',f=>f.merchant.order.status='SUBMITTED',f=>delete f.merchant.cargoes]) {
const value=dependencyFixture();change(value);assert.throws(()=>buildOrderDependencyFaultPlan(value))
}
})
test('all four dependency-role observations require matching authenticated 503 injection and actual Order S0503 with zero drift',()=>{
const fixture=dependencyFixture(),plan=buildOrderDependencyFaultPlan(fixture)
for(const dependency of ['IAM','FINANCE'])for(const role of ['merchant','platform']) {
const evidence=dependencyObservation(plan,fixture,dependency,role)
assert.equal(assertOrderDependencyFaultObservation(plan,dependency,role,evidence).verified,true)
const wrapped=structuredClone(evidence)
wrapped.response.body.message=plan.phases.find(row=>row.dependency===dependency).expectedMessages[1]
assert.equal(assertOrderDependencyFaultObservation(plan,dependency,role,wrapped).verified,true)
for(const mutate of [
value=>value.response.http=503,value=>value.response.body.code='A0403',value=>value.response.body.message='资格未审核',
value=>value.request.body.expectedVersion=5,value=>value.request.headers['Idempotency-Key']='new-key',
value=>value.proxyRecords=[],value=>value.proxyRecords[0].internalTokenPresent=false,value=>value.proxyRecords[0].forwarded=true,
value=>value.proxyRecords[0].path+='/other',value=>value.proxyRecords[0].requestId='other-request',
value=>value.proxyRecords.push({...value.proxyRecords[0],path:'/internal/iam/tenants/21/order-eligibility'}),
value=>value.after.detail.order.version++,value=>value.after.detail.statusHistory.push({id:'unexpected'}),
value=>value.after.task={orderId:plan.commands[role].orderId,tenantId:'20',id:'unexpected'},
value=>value.after.inbounds.push({tenantId:'20',orderNo:plan.commands[role].orderNo,id:'unexpected'}),
value=>value.after.inboundPageComplete=false,
]) { const changed=structuredClone(evidence);mutate(changed);assert.throws(()=>assertOrderDependencyFaultObservation(plan,dependency,role,changed)) }
}
})
function platformLifecycleFixture(fault = '', faultAction = 'submit') {
const drafts = new Map(), memo = new Map(), calls = []
+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')