test(fleet): resume reviewed post-departure acceptance safely

This commit is contained in:
2026-10-03 20:06:02 +08:00
parent bab967acce
commit 6d922516f6
2 changed files with 256 additions and 15 deletions
+114 -4
View File
@@ -1,12 +1,122 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs'
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname, resolve } from 'node:path'
import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
import { run as runInboundMeasurement } from './admin-test-env-modules/inbound-measurement.mjs'
import { verifyPalletLabels } from './admin-test-env-modules/phase3-loading-finalize.mjs'
import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, guardFleetWrites, redactFleetWrite } from './fleet-test-env-acceptance.mjs'
function departedRecoveryFixture() {
const snapshotTrip = { tripNo: 'TR-OWN', status: 'IN_TRANSIT', recordVersion: '27', vehicleId: 'V1', driverId: 'D1', warehouseId: 'W1', departedAt: '2026-10-03T11:00:00Z' }
const cargo = [{ dispatchNo: 'OWN', dispatchItemId: 'I1', warehousePalletId: 'P1', packages: 2 }, { dispatchNo: 'EXT', dispatchItemId: 'I2', warehousePalletId: 'P2', packages: 1 }]
const departure = { action: 'DEPART', recordVersion: '27', snapshot: { trip: snapshotTrip, confirmedLoad: { manifestHash: 'a'.repeat(64), cargo } } }
const proof = { marker: 'RUN', tripNo: 'TR-OWN', warehouseId: 'W1', customerId: 'C1', status: 'CHECKING', stage: 'TRIP_OPERATING_COSTS', checkpoints: [],
dispatchNos: ['OWN', 'EXT'], picker: { membershipId: 'OLD-PDA' }, departureRequest: { idempotencyKey: 'old-key' },
departure: { tripNo: 'TR-OWN', status: 'IN_TRANSIT', departedAt: snapshotTrip.departedAt, shippedPackages: 3 } }
const trip = { marker: 'RUN', tripNo: 'TR-OWN', costAcceptance: { marker: 'RUN', status: 'FAILED', costs: [], departure,
requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200' }] },
externalReceipt: { status: 'VERIFIED', bookingNo: 'BK', warehousePalletId: 'P2' }, externalDispatch: { dispatchNo: 'EXT', deliveryNo: 'DL-EXT' } }
const report = { success: false, steps: [{ name: '独立预期覆盖全部实物且没有重叠托盘', passed: false }, { name: '模块执行', passed: false }],
createdResources: { fleetAcceptance: proof, fleetTrips: [trip], fleetCostActors: { status: 'DISABLED' }, fleetFeeCoverageCases: [{ mode: 'PARTIAL', proof: { deliveryNos: ['DL-OWN'] } }] } }
const base = '/api/platform/v1/last-leg/fleet/trips/TR-OWN'
const responses = new Map([[base, { trip: structuredClone(snapshotTrip), dispatches: [{ dispatchNo: 'OWN', customerId: 'C1' }, { dispatchNo: 'EXT', customerId: 'C1' }] }],
[base + '/history?page=1&size=100', { total: 1, records: [structuredClone(departure)] }],
[base + '/costs?page=1&size=100', { page: { records: [], total: 0 }, summary: { manifestHash: 'a'.repeat(64), departureVersion: '27', totals: [] } }],
[base + '/handovers', { records: [], total: 0 }]])
for (const row of cargo) {
responses.set('/api/platform/v1/last-leg/dispatches/' + row.dispatchNo, { dispatchNo: row.dispatchNo, status: 'DEPARTED', overseasWarehouseId: 'W1',
packageCount: row.packages, pickedPackageCount: row.packages, shippedPackageCount: row.packages,
items: [{ id: row.dispatchItemId, warehousePalletId: row.warehousePalletId, packageCount: row.packages }] })
responses.set('/api/platform/v1/last-leg/delivery-orders/DL-' + row.dispatchNo + '?customerId=C1', { order: { deliveryNo: 'DL-' + row.dispatchNo,
customerId: 'C1', dispatchNo: row.dispatchNo, status: 'WAIT_DELIVERY', version: '1', packageCount: row.packages,
documentSummary: { categories: [{ fileCount: 0 }] } } })
}
const calls = [], ctx = { marker: 'RUN', token: 'writer', context: {}, createdResources: report.createdResources, async checkpoint() { calls.push('checkpoint') },
async request(method, path) { assert.equal(method, 'GET', 'recovery verification must never write'); calls.push(path); assert.ok(responses.has(path), path)
return { http: 200, body: { code: '200', data: responses.get(path) }, requestId: 'r', traceId: 't' } } }
return { report, ctx, calls, responses, base, trip }
}
test('departed recovery requires reviewed SHA and exactly the original read-only cost failure', () => {
assert.throws(() => parseFleetArguments(['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS']), /SHA256/)
const args = ['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS', '--resume-sha256', 'a'.repeat(64)]
assert.equal(parseFleetArguments(args).resumeSha256, 'a'.repeat(64))
assert.throws(() => parseFleetArguments([...args, '--scenario', 'return-redispatch']), /fresh run/)
assert.equal(reviewedFullCheckpoint(departedRecoveryFixture().report, 'DEPARTED_BEFORE_COSTS').departed, true)
for (const change of [r => r.createdResources.fleetTrips[0].costAcceptance.requests.push({ method: 'POST' }),
r => r.createdResources.fleetTrips[0].handover = {}, r => r.createdResources.fleetAcceptance.receipts = [],
r => r.createdResources.fleetCostActors.status = 'READY', r => r.steps[0].name = 'unknown write']) {
const { report } = departedRecoveryFixture(); change(report)
assert.throws(() => reviewedFullCheckpoint(report, 'DEPARTED_BEFORE_COSTS'), /checkpoint/)
}
})
for (const mutation of ['none', 'version', 'history', 'cost', 'handover', 'cargo', 'signature', 'document'])
test(`recovery rechecks actual state before any session/business write: ${mutation}`, async () => {
const { ctx, responses, base } = departedRecoveryFixture()
if (mutation === 'version') responses.get(base).trip.recordVersion = '28'
if (mutation === 'history') responses.get(base + '/history?page=1&size=100').records[0].snapshot.confirmedLoad.manifestHash = 'changed'
if (mutation === 'cost') responses.get(base + '/costs?page=1&size=100').page.records.push({ costNo: 'COST' })
if (mutation === 'handover') responses.get(base + '/handovers').records.push({ handoverNo: 'H' })
if (mutation === 'cargo') responses.get('/api/platform/v1/last-leg/dispatches/OWN').items[0].packageCount = 3
const delivery = responses.get('/api/platform/v1/last-leg/delivery-orders/DL-OWN?customerId=C1').order
if (mutation === 'signature') delivery.signedAt = 'now'
if (mutation === 'document') delivery.documentSummary.categories[0].fileCount = 1
if (mutation === 'none') { await verifyDepartedRecovery(ctx); assert.equal(ctx.createdResources.fleetDepartureRecoveryVerification.status, 'VERIFIED') }
else await assert.rejects(continueReviewedDeparture(ctx, { prepareActors: () => assert.fail('no new actors before validation') }), /recovery refused/)
})
test('reviewed recovery archives old evidence and creates fresh sessions before shared post-departure flow only', async () => {
const { ctx, trip, calls } = departedRecoveryFixture(); const oldCost = trip.costAcceptance
await continueReviewedDeparture(ctx, {
prepareActors: async () => { assert.equal(ctx.createdResources.fleetDepartureRecoveryVerification.status, 'VERIFIED'); calls.push('new-actors'); return { marker: 'RUN', listOnlyToken: 'reader', deniedToken: 'denied', async close() { calls.push('close-actors') } } },
resolveAccess: async () => ({}), makePicker: async child => { assert.match(child.marker, /RECOVERY-PDA/); calls.push('new-picker'); return { membershipId: 'NEW', token: 'new-pda' } },
closePicker: async () => calls.push('close-picker'),
complete: async (context, actualTrip, picker, dispatches) => {
assert.equal(actualTrip, trip); assert.equal(trip.costAcceptance, undefined); assert.equal(picker.membershipId, 'NEW'); assert.equal(dispatches.length, 2)
assert.equal(trip.previousReadOnlyCostAttempts[0], oldCost); assert.equal(context.createdResources.fleetAcceptance.previousPicker.membershipId, 'OLD-PDA')
calls.push('continue-from-costs')
},
})
assert.deepEqual(calls.filter(x => !x.startsWith('/') && x !== 'checkpoint'), ['new-actors', 'new-picker', 'continue-from-costs', 'close-picker', 'close-actors'])
})
test('shared continuation starts at costs, retains unavailable allocation and never enters picking or DEPART', async () => {
const { ctx, trip, calls } = departedRecoveryFixture()
const verified = await verifyDepartedRecovery(ctx), operations = []
await completeDepartedFleetScenario(ctx, trip, { token: 'pda' }, verified.dispatchDetails, {
costActors: { marker: 'RUN' },
verifyCosts: async () => { operations.push('costs'); trip.costAcceptance = { status: 'VERIFIED', expected: { allocationStatus: 'UNAVAILABLE' } } },
handover: async () => { assert.equal(trip.costAcceptance.expected.allocationStatus, 'UNAVAILABLE'); operations.push('handover') },
completeDelivery: async (context, order) => { operations.push('signature:' + order.deliveryNo); return { order: { status: 'DELIVERED', signedAt: 'signed' } } },
closeTrip: async () => { operations.push('close-trip'); return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } },
cleanup: async () => operations.push('cleanup'),
})
assert.deepEqual(operations, ['costs', 'handover', 'signature:DL-OWN', 'signature:DL-EXT', 'close-trip', 'cleanup'])
assert.equal(ctx.createdResources.fleetAcceptance.stage, 'FINISHED')
assert.ok(!calls.some(x => /\/depart$|\/pick|\/loading/.test(x)))
})
test('changed source report SHA blocks recovery before creating context or logging in', async () => {
const temporary = mkdtempSync(join(tmpdir(), 'fleet-recovery-sha-'))
const root = join(temporary, 'chongchong-global', 'baiyue-firstleg'); mkdirSync(join(root, 'reports', 'original'), { recursive: true })
writeFileSync(join(root, 'reports', 'original', 'report.json'), JSON.stringify(departedRecoveryFixture().report))
try {
const result = await runFleetAcceptance({ execute: true, environment: 'TEST', runId: 'reviewed', resumeStage: 'DEPARTED_BEFORE_COSTS',
resumeReport: 'reports/original/report.json', resumeSha256: 'b'.repeat(64) }, { root,
env: { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.invalid', CHONGCHONG_ADMIN_USERNAME: 'admin',
CHONGCHONG_ADMIN_PASSWORD: 'secret', ADMIN_PDA_USERNAME: 'pda', ADMIN_PDA_PASSWORD: 'secret' },
createContext: () => assert.fail('SHA mismatch must precede context/network'),
})
assert.equal(result.success, false); assert.match(result.fatal, /SHA256 mismatch/)
} finally {
assert.equal(dirname(resolve(temporary)), resolve(tmpdir()))
assert.ok(resolve(temporary).startsWith(join(resolve(tmpdir()), 'fleet-recovery-sha-')))
rmSync(temporary, { recursive: true, force: true })
}
})
import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario } from './fleet-test-env-acceptance.mjs'
for (const response of [
{ http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' },
@@ -281,8 +391,8 @@ for (const failLogin of [false, true]) test(`cost actors create exact isolated r
test('main chain performs handover after actual DEPART and before receipts, and cleanup follows current-resource closure', async () => {
const source = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8')
const savedDeparture = source.indexOf('proof.departure = departed'), handover = source.indexOf("stage('IN_TRANSIT_HANDOVER')")
const receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await finishFleetTrip')
const cleanup = source.indexOf('await cleanupFleetHandoverResources(context, MODULE, trip)')
const receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await closeTrip')
const cleanup = source.indexOf('await cleanup(context, MODULE, trip)')
assert.ok(savedDeparture > 0 && savedDeparture < handover && handover < receipt && receipt < closure && closure < cleanup)
assert.match(source, /finally \{ await disablePicker\(context, picker, 71\) \}/)
})