test(order): add isolated dependency failure and recovery verification
This commit is contained in:
@@ -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 = []
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user