test: integrate reliable event recovery and receiving evidence

This commit is contained in:
2026-10-02 20:14:17 +08:00
parent d0df738bba
commit 4b531c7e22
17 changed files with 1527 additions and 37 deletions
+80 -2
View File
@@ -383,6 +383,7 @@ export function parseArguments(argv) {
routeMode: values.get('route-mode') || 'fixed',
quiet: values.get('quiet') === 'true',
returnMainChainOnly: values.get('return-main-chain-only') === 'true',
verifyUnloadEventRecovery: values.get('verify-unload-event-recovery') === 'true',
manualSplitFixtureReport: values.get('manual-split-fixture-report') || '',
manualSplitPlanId: values.get('manual-split-plan-id') || '',
manualPreloadSourceReport: values.get('manual-preload-source-report') || '',
@@ -394,6 +395,9 @@ export function parseArguments(argv) {
}
export function validateExecutionOptions(options) {
if (options.verifyUnloadEventRecovery && !options.modules.includes('phase4-overseas-pda')) {
throw new Error('卸柜事件恢复验收必须包含正式 phase4-overseas-pda 收货链')
}
const unavailable = options.modules.filter((module) => !AVAILABLE_MODULES.has(module))
if (unavailable.length > 0) throw new Error(`未注册的 HTTP 验收模块: ${unavailable.join(',')}`)
for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) {
@@ -1424,7 +1428,7 @@ export class AcceptanceRun {
const attempt = (this.phase4ProducerAttempts.get(label) || 0) + 1
this.phase4ProducerAttempts.set(label, attempt)
const variantMarker = `${matrixMarker}-P4-${label}${attempt === 1 ? '' : `-R${attempt}`}`
const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker']
const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker', 'waitSplitRoute']
const createdKeys = [
'phase3Loading', 'phase3LoadingCompletedProducer', 'phase3ContainerDocuments', 'orchestrationFixtures', 'oceanBooking',
'phase3CustomsSupplier', 'phase3Customs', 'serviceCenterFlow', 'portSchedule',
@@ -1557,6 +1561,11 @@ export class AcceptanceRun {
if (!hasOperationsFbaProducer()) {
await this.runPhase4ProducerVariant('FBA-OPERATIONS', 'FBA', 'STANDARD', true)
}
const hasRouteFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
.some((item) => /-P4-FBA-ROUTE(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
if (!hasRouteFbaProducer()) {
await this.runPhase4ProducerVariant('FBA-ROUTE', 'FBA', 'STANDARD', true)
}
if (Number(readiness().overseasContainerCount || 0) < 1) {
await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true)
}
@@ -1576,6 +1585,8 @@ export class AcceptanceRun {
/-P4-FBA-SECOND(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
const operationsFbaReady = containers.find((item) =>
/-P4-FBA-OPERATIONS(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
const routeFbaReady = containers.find((item) =>
/-P4-FBA-ROUTE(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
const awaitingVariants = containers.filter((item) => ['SECONDARY']
.some((label) => new RegExp(`-P4-${label}(?:-R\\d+)?:`).test(String(item?.resourceKey || ''))))
const shippingMatrixReady = dropshipReady?.shippingReady === true
@@ -1583,6 +1594,7 @@ export class AcceptanceRun {
&& standardFbaReady?.shippingReady === true && Boolean(standardFbaReady?.carrierScheduleQueryId)
&& secondStandardFbaReady?.shippingReady === true && Boolean(secondStandardFbaReady?.carrierScheduleQueryId)
&& operationsFbaReady?.shippingReady === true && Boolean(operationsFbaReady?.carrierScheduleQueryId)
&& routeFbaReady?.shippingReady === true && Boolean(routeFbaReady?.carrierScheduleQueryId)
&& overseasReady?.shippingReady === true && Boolean(overseasReady?.carrierScheduleQueryId)
&& awaitingVariants.length >= 1
&& awaitingVariants.every((item) => item.shippingReady === false
@@ -1830,6 +1842,11 @@ export class AcceptanceRun {
}
}
}
if (this.options.verifyUnloadEventRecovery) {
this.addStep('phase4-overseas-pda', '显式卸柜故障场景必须实际完成且只验证本轮一个柜', null,
unloadRecoveryWasVerified(this), '不以未触发故障或普通收货成功代替事件恢复证据',
{ scenario: 'reverse-unload-event-recovery', deploymentBlocker: true })
}
const reportSurface = JSON.stringify({
steps: this.steps, modules: this.modules, scenarios: this.scenarios,
deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps,
@@ -1870,11 +1887,72 @@ export class AcceptanceRun {
}
}
export function unloadRecoveryWasVerified(run) {
const records = Object.entries(run.createdResources?.phase4OverseasPda?.receivingCompletionEvidence || {})
.filter(([, row]) => row.eventRecovery)
if (records.length !== 1) return false
const [bookingId, row] = records[0]
return row.status === 'VERIFIED' && row.eventRecoveryInventoryUnchanged === true
&& row.eventRecovery.restored === true && row.eventRecovery.marker === run.marker
&& row.eventRecovery.containerBookingId === bookingId
}
export async function configureReadOnlyEvidence(run, environment, createReaders, faultFactories) {
if (!run.options.modules.some(name => ['phase4-overseas-pda', 'phase5-root-fulfillment', 'manual-state-matrix'].includes(name))) return
const factory = createReaders || (await import('./fba-core-regression-evidence.mjs')).createTestEvidenceReaders
const readers = factory(environment)
await readers.preflight()
run.context.phase4ReceivingEvidenceReader = readers.readReceiving
run.context.f40ReadOnlyOutboxEvidence = readers.readCompletion
const faults = []
if (run.options.modules.includes('phase4-cross-region-events')) {
const { createTestFaultExecutor } = faultFactories || await import('./test-event-fault-executor.mjs')
const fault = createTestFaultExecutor(environment)
run.context.beforeLoadingComplete = fault.prepare
run.context.directDeliveryReplayEvidenceReader = fault.readProof
faults.push(fault)
}
if (run.options.verifyUnloadEventRecovery) {
const { createTestUnloadFaultExecutor } = faultFactories || await import('./test-event-fault-executor.mjs')
const fault = createTestUnloadFaultExecutor(environment)
faults.push(fault)
let selected = null
run.context.beforeReceivingComplete = async scope => {
if (selected) return
selected = { marker: run.marker, customerId: scope.customerId, containerBookingId: scope.bookingId }
await fault.prepare(selected)
}
run.context.afterReceivingComplete = async scope => {
if (!selected || scope.bookingId !== selected.containerBookingId || scope.customerId !== selected.customerId) return null
return fault.readProof(selected)
}
}
if (faults.length) return {
cleanup: async () => {
const errors = []
for (const fault of [...faults].reverse()) {
try { await fault.cleanup() } catch { errors.push('TEST事件故障验收恢复失败,保留原任务恢复记录') }
}
if (errors.length) throw new Error(errors.join('; '))
},
}
}
async function main() {
const options = validateExecutionOptions(parseArguments(process.argv.slice(2)))
const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14)
const outputPath = resolve(options.output || `reports/admin-test-env-acceptance-${timestamp}.json`)
const report = await new AcceptanceRun(options).run()
const run = new AcceptanceRun(options)
const fault = await configureReadOnlyEvidence(run, process.env)
let report
try { report = await run.run() }
finally {
try { await fault?.cleanup() }
catch (error) { run.addStep('phase4-cross-region-events', '故障验收后必须恢复原服务配置与健康状态', null,
false, String(error.message), { scenario: 'direct-delivery-replay', deploymentBlocker: true }) }
}
report.summary = buildAcceptanceSummary(run.steps, run.modules, run.coverageGaps)
report.success = report.fatal === null && report.summary.failed === 0
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
console.log(`REPORT=${outputPath}`)