test(fleet): persist independent acceptance requests and current-trip evidence
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync } from 'node:fs'
|
||||
import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync, fsyncSync } from 'node:fs'
|
||||
import { resolve, dirname, basename, relative, isAbsolute, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
@@ -10,6 +10,8 @@ import { verifyFleetBookingReservation } from './admin-test-env-modules/fleet-bo
|
||||
import { verifyFleetFeeCoverage } from './admin-test-env-modules/fleet-fee-coverage-acceptance.mjs'
|
||||
import { verifyFleetExternalReceipt } from './admin-test-env-modules/fleet-external-receipt-acceptance.mjs'
|
||||
import { verifyFleetExternalDispatch } from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs'
|
||||
import { verifyFleetHandover, cleanupFleetHandoverResources } from './admin-test-env-modules/fleet-handover-acceptance.mjs'
|
||||
import { verifyFleetTripCosts } from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs'
|
||||
import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs'
|
||||
import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs'
|
||||
|
||||
@@ -30,27 +32,30 @@ export function parseFleetArguments(args) {
|
||||
if (action) throw Error('Choose exactly one plan or execute action')
|
||||
action = name; config.execute = name === '--execute'; continue
|
||||
}
|
||||
if (!['--environment', '--run-id', '--resume-report', '--resume-stage'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument')
|
||||
if (!['--environment', '--run-id', '--output', '--resume-report', '--resume-stage'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument')
|
||||
if (seen.has(name)) throw Error('Duplicate argument')
|
||||
seen.add(name)
|
||||
const value = args[++index]
|
||||
if (name === '--environment') config.environment = value
|
||||
else if (name === '--run-id') config.runId = value
|
||||
else if (name === '--output') config.outputDir = value
|
||||
else if (name === '--resume-report') config.resumeReport = value
|
||||
else config.resumeStage = value
|
||||
}
|
||||
if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)
|
||||
|| (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required')
|
||||
if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required')
|
||||
if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Output must be a fresh named directory directly under reports')
|
||||
return config
|
||||
}
|
||||
export function fleetExecutionPlan(config) {
|
||||
return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId,
|
||||
outputDir: `reports/fleet-${config.runId}`, producer: config.resumeReport ? 'Reviewed read-only FULL_COVERAGE checkpoint in the same original fixture' : 'phase4-overseas-dispatch with its normal fresh prerequisites',
|
||||
outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? 'Reviewed read-only FULL_COVERAGE checkpoint in the same original fixture' : 'phase4-overseas-dispatch with its normal fresh prerequisites',
|
||||
resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null,
|
||||
stages: ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle',
|
||||
'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking',
|
||||
'atomic whole-trip departure', 'per-destination receipt and trip resource release'],
|
||||
'atomic whole-trip departure', 'actual in-transit handover and PDA readback', 'same-fixture optional H5 checkpoint',
|
||||
'per-destination receipt and replacement-resource release', 'explicit handover-fixture cleanup'],
|
||||
boundaries: 'TEST only; serial fresh fixtures; no SQL writes, deployment or configuration changes. Failure retains original requests/resources. No automatic rerun/resume.',
|
||||
limits: 'Finance pool proves consumer persistence; producer ACK requires separate event evidence. UI and remaining fleet product scope are separate.' }
|
||||
}
|
||||
@@ -158,8 +163,122 @@ export async function verifyFleetTerminalState(context) {
|
||||
return proof
|
||||
}
|
||||
|
||||
export async function runFleetScenario(context, { wait = ms => new Promise(resolve => setTimeout(resolve, ms)), resumeFull = false, resumePersisted = false, resumeReceiptReady = false, resumePalletized = false, resumeReceiptConfirmed = false, terminalReadOnly = false } = {}) {
|
||||
export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady } = {}) {
|
||||
const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails)
|
||||
const run = context.createdResources.fleetAcceptance
|
||||
if (state?.status !== 'VERIFIED' || state.marker !== context.marker || run?.departure?.tripNo !== trip.tripNo
|
||||
|| run.departure.status !== 'IN_TRANSIT' || !state.originalDeparture?.snapshot?.confirmedLoad || !state.browser
|
||||
|| trip.costAcceptance?.status !== 'VERIFIED' || trip.costAcceptance.marker !== context.marker)
|
||||
throw Error('Only the current verified DEPART and handover can publish a shared fixture')
|
||||
const fixture = { marker: context.marker, runId: context.context?.fleetRunId, tripNo: trip.tripNo, warehouseId: trip.warehouseId, dispatchNos: state.dispatchNos,
|
||||
departure: run.departure, immutableDeparture: state.originalDeparture, handoverNo: state.handoverNo,
|
||||
originalResources: state.previous, replacementResources: state.replacement, browser: state.browser,
|
||||
costs: trip.costAcceptance,
|
||||
status: 'DEPART_AND_HANDOVER_VERIFIED', requestCount: state.requestCount, pdaReadCount: state.pdaReadCount }
|
||||
context.context?.saveFleetHandoverFixture?.(fixture)
|
||||
if (onReady) await onReady(fixture)
|
||||
return fixture
|
||||
}
|
||||
|
||||
export function fleetCostShares(trip, dispatchDetails) {
|
||||
const external = trip.externalReceipt, externalDispatch = trip.externalDispatch
|
||||
if (external?.status !== 'VERIFIED' || !external.bookingNo || !external.warehousePalletId || !externalDispatch?.dispatchNo)
|
||||
throw Error('Cost expectations require this run\'s verified external receipt')
|
||||
const shares = dispatchDetails.map(row => {
|
||||
const pallets = [...new Set((row.items || []).map(item => id(item.warehousePalletId)))]
|
||||
if (!row.dispatchNo || !pallets.length || pallets.some(value => !value)) throw Error('Cost expectations require actual dispatch pallets')
|
||||
const isExternal = row.dispatchNo === externalDispatch.dispatchNo
|
||||
if (isExternal && (pallets.length !== 1 || pallets[0] !== id(external.warehousePalletId))) throw Error('External cost source does not match actual receipt')
|
||||
// The fixture's explicit measurement rule is one space per physical pallet; never derive this from cost totals.
|
||||
return { businessNo: isExternal ? external.bookingNo : row.dispatchNo, sourceType: isExternal ? 'EXTERNAL' : 'OWN',
|
||||
dispatchNo: row.dispatchNo, warehousePalletIds: pallets, palletSpaces: pallets.length }
|
||||
})
|
||||
const pallets = shares.flatMap(row => row.warehousePalletIds)
|
||||
if (shares.filter(row => row.sourceType === 'EXTERNAL').length !== 1 || !shares.some(row => row.sourceType === 'OWN')
|
||||
|| new Set(pallets).size !== pallets.length) throw Error('Cost fixture requires distinct own and external physical pallets')
|
||||
return shares
|
||||
}
|
||||
|
||||
export async function prepareFleetCostActors(context) {
|
||||
if (!context.marker || context.createdResources.fleetCostActors || typeof context.checkpoint !== 'function')
|
||||
throw Error('Cost actors require fresh current-run ownership and durable checkpoints')
|
||||
const proof = context.createdResources.fleetCostActors = { marker: context.marker, status: 'CREATING', actors: [], requests: [] }
|
||||
const base = '/api/platform/v1/iam', sessions = []
|
||||
async function request(method, path, body, token) {
|
||||
const entry = { method, path, body: redactEvidence(body), status: 'SENDING' }
|
||||
proof.requests.push(entry); await context.checkpoint()
|
||||
try {
|
||||
const response = await context.request(method, path, body, token)
|
||||
Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId, result: redactEvidence(response?.body?.data) })
|
||||
await context.checkpoint()
|
||||
if (!success(response) || !response.requestId || !response.traceId) throw Error(`Cost actor request failed: ${method} ${path}`)
|
||||
return response.body.data
|
||||
} catch (error) {
|
||||
if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint() }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
async function close() {
|
||||
const errors = []
|
||||
for (const actor of proof.actors) {
|
||||
try {
|
||||
if (actor.membershipId && !actor.userDisabled) {
|
||||
const result = await request('PUT', `${base}/users/${actor.membershipId}/status`, { status: 'DISABLED' })
|
||||
if (result?.status !== 'DISABLED') throw Error('Cost test account was not disabled')
|
||||
actor.userDisabled = true
|
||||
}
|
||||
if (actor.roleId && !actor.roleDisabled) {
|
||||
const result = await request('PUT', `${base}/roles/${actor.roleId}/status`, { status: 'DISABLED' })
|
||||
if (result?.status !== 'DISABLED') throw Error('Cost test role was not disabled')
|
||||
actor.roleDisabled = true
|
||||
}
|
||||
} catch (error) { errors.push(error) }
|
||||
}
|
||||
proof.status = errors.length ? 'CLEANUP_FAILED' : 'DISABLED'; await context.checkpoint()
|
||||
if (errors.length) throw new AggregateError(errors, 'Cost actor cleanup requires review; do not recreate accounts')
|
||||
}
|
||||
try {
|
||||
const permissions = await request('GET', `${base}/permissions`)
|
||||
const list = permissions.filter(row => row.permissionCode === 'platform:fleet:trip:list' && row.status === 'ACTIVE')
|
||||
if (list.length !== 1 || !list[0].id) throw Error('Active trip list permission missing or ambiguous')
|
||||
for (const kind of ['reader', 'denied']) {
|
||||
const suffix = randomUUID().replaceAll('-', '').slice(0, 16), username = `fleet_${kind}_${suffix}`
|
||||
const actor = { kind, username }; proof.actors.push(actor)
|
||||
const roleCode = `FLEET_${kind.toUpperCase()}_${suffix.toUpperCase()}`
|
||||
const role = await request('POST', `${base}/roles`, { roleCode, roleName: `${context.marker}-${kind}`, description: context.marker })
|
||||
if (!id(role?.id) || role.roleCode !== roleCode || role.roleType !== 'CUSTOM') throw Error('Fresh cost role ownership mismatch')
|
||||
actor.roleId = id(role.id)
|
||||
await request('PUT', `${base}/roles/permissions`, { roleId: actor.roleId, permissionIds: kind === 'reader' ? [id(list[0].id)] : [] })
|
||||
const password = `Fleet${randomUUID().replaceAll('-', '')}A9`
|
||||
const user = await request('POST', `${base}/users`, { username, displayName: `${context.marker}-${kind}`,
|
||||
phone: await context.findAvailablePhone(MODULE), email: `${username}@example.com`, tenantId: '0',
|
||||
roleIds: [actor.roleId], password, status: 'ACTIVE' })
|
||||
if (!id(user?.membershipId) || user.username !== username || id(user.tenantId) !== '0'
|
||||
|| user.roleIds?.length !== 1 || id(user.roleIds[0]) !== actor.roleId) throw Error('Fresh cost account ownership mismatch')
|
||||
actor.membershipId = id(user.membershipId)
|
||||
const login = await request('POST', '/api/auth/login', { loginId: username, password }, '')
|
||||
if (!login?.accessToken || login.accessToken === context.token || sessions.includes(login.accessToken)) throw Error('Independent cost session missing')
|
||||
sessions.push(login.accessToken)
|
||||
const actual = await request('GET', `${base}/authorization/permissions`, undefined, login.accessToken)
|
||||
const expected = kind === 'reader' ? ['platform:fleet:trip:list'] : []
|
||||
if (!Array.isArray(actual) || JSON.stringify([...actual].sort()) !== JSON.stringify(expected)) throw Error('Cost actor effective permissions differ from exact scope')
|
||||
}
|
||||
proof.status = 'READY'; await context.checkpoint()
|
||||
return { marker: context.marker, listOnlyToken: sessions[0], deniedToken: sessions[1], close }
|
||||
} catch (error) {
|
||||
proof.status = 'FAILED'; await context.checkpoint()
|
||||
try { await close() } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Cost actor creation and cleanup failed') }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function runFleetScenario(context, { wait = ms => new Promise(resolve => setTimeout(resolve, ms)), onHandoverReady,
|
||||
costActors, resumeFull = false, resumePersisted = false, resumeReceiptReady = false, resumePalletized = false, resumeReceiptConfirmed = false, terminalReadOnly = false } = {}) {
|
||||
if (terminalReadOnly) return verifyFleetTerminalState(context)
|
||||
if (costActors?.marker !== context.marker || !costActors.listOnlyToken || !costActors.deniedToken
|
||||
|| costActors.listOnlyToken === costActors.deniedToken || [costActors.listOnlyToken, costActors.deniedToken].includes(context.token))
|
||||
throw Error('Prepare two independent current-run platform cost permission sessions before starting the fleet scenario')
|
||||
const proof = resumeFull || resumeReceiptReady ? context.createdResources.fleetAcceptance
|
||||
: context.createdResources.fleetAcceptance = { marker: context.marker, status: 'CHECKING', stage: 'SOURCES', checkpoints: [] }
|
||||
function stage(value) { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log(`FLEET_STAGE=${value}`) }
|
||||
@@ -300,6 +419,15 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol
|
||||
const departedReplay = await request('POST', departPath, proof.departureRequest, '整车发车同键重放', picker.token)
|
||||
check('发车重放不改变整车实发数量', departedReplay?.replayed === true && Number(departedReplay.shippedPackages) === Number(departed.shippedPackages))
|
||||
proof.departure = departed
|
||||
stage('TRIP_OPERATING_COSTS')
|
||||
await verifyFleetTripCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) })
|
||||
stage('IN_TRANSIT_HANDOVER')
|
||||
await runFleetHandoverStage(context, trip, picker.token, dispatchDetails, {
|
||||
onReady: onHandoverReady ? async fixture => {
|
||||
stage('PDA_UI_CHECKPOINT')
|
||||
await onHandoverReady({ fixture, picker })
|
||||
} : undefined,
|
||||
})
|
||||
stage('RECEIPTS_AND_RESOURCE_RELEASE')
|
||||
proof.receipts = []
|
||||
for (const [index, deliveryNo] of [...partial.proof.deliveryNos, external.deliveryNo].entries()) {
|
||||
@@ -311,19 +439,24 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol
|
||||
}
|
||||
proof.closure = await finishFleetTrip(context, MODULE, trip)
|
||||
check('本次车次货物结案及车辆司机释放完成', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
|
||||
stage('HANDOVER_RESOURCE_CLEANUP')
|
||||
await cleanupFleetHandoverResources(context, MODULE, trip)
|
||||
proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; stage('FINISHED')
|
||||
} finally { await disablePicker(context, picker, 71) }
|
||||
return proof
|
||||
}
|
||||
|
||||
export async function runFleetAcceptance(config, { env = process.env, readers, createContext, scenario = runFleetScenario, root = ROOT } = {}) {
|
||||
export async function runFleetAcceptance(config, { env = process.env, readers, createContext, scenario = runFleetScenario, prepareCostActors = prepareFleetCostActors, root = ROOT } = {}) {
|
||||
if (!config.execute || config.environment !== 'TEST' || env.CHONGCHONG_ENV?.toLowerCase() !== 'test'
|
||||
|| basename(root) !== 'baiyue-firstleg' || basename(dirname(root)) !== 'chongchong-global') throw Error('Explicit TEST workspace and configuration required')
|
||||
if (scenario === runFleetScenario && config.resumeStage !== 'TERMINAL_READONLY' && typeof prepareCostActors !== 'function')
|
||||
throw Error('Current-run cost actor preparation and lifecycle must be provided before any environment requests')
|
||||
const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL)
|
||||
if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash
|
||||
|| /(?:^|[.-])(?:dev|prod|production)(?:[.-]|$)/i.test(base.hostname)) throw Error('Invalid TEST gateway')
|
||||
for (const name of ['CHONGCHONG_ADMIN_USERNAME', 'CHONGCHONG_ADMIN_PASSWORD', 'ADMIN_PDA_USERNAME', 'ADMIN_PDA_PASSWORD']) if (!env[name]) throw Error(`Missing ${name}`)
|
||||
const output = resolve(root, `reports/fleet-${config.runId}`), rel = relative(resolve(root, 'reports'), output)
|
||||
if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Report scope invalid')
|
||||
const output = resolve(root, config.outputDir || `reports/fleet-${config.runId}`), rel = relative(resolve(root, 'reports'), output)
|
||||
if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw Error('Report scope invalid')
|
||||
mkdirSync(resolve(root, 'reports'), { recursive: true })
|
||||
const lockPath = resolve(root, 'reports/.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx')
|
||||
@@ -351,6 +484,23 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
|
||||
pdaUsername: env.ADMIN_PDA_USERNAME, pdaPassword: env.ADMIN_PDA_PASSWORD, merchantUsername: '', merchantPassword: '',
|
||||
markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) })
|
||||
context = createContext ? createContext(options) : new AcceptanceRun(options)
|
||||
context.context.fleetRunId = config.runId
|
||||
context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture)
|
||||
let handoverCheckpoint = 0
|
||||
context.context.checkpointFleetHandover = proof => {
|
||||
const payload = JSON.stringify(redactEvidence({ marker: context.marker, runId: config.runId,
|
||||
proof, createdResources: context.createdResources }, [...secrets, context.token, context.pdaToken, context.refreshToken]), null, 2) + '\n'
|
||||
// Never truncate the prior checkpoint: interruption or disk failure leaves the earlier request recoverable.
|
||||
const descriptor = openSync(join(output, 'evidence', `handover-${String(++handoverCheckpoint).padStart(6, '0')}.json`), 'wx')
|
||||
try { writeFileSync(descriptor, payload); fsyncSync(descriptor) }
|
||||
finally { closeSync(descriptor) }
|
||||
}
|
||||
context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance)
|
||||
const fleetScenario = async (ctx, options = {}) => {
|
||||
const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx)
|
||||
try { return await scenario(ctx, { ...options, costActors }) }
|
||||
finally { await costActors?.close() }
|
||||
}
|
||||
let evidenceNumber = 0
|
||||
context.context.phase4ReceivingEvidenceReader = async scope => {
|
||||
const evidence = await readers.readReceiving(scope)
|
||||
@@ -362,7 +512,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
|
||||
save('progress.json', { stage: name, steps: context.steps, modules: context.modules, createdResources: context.createdResources })
|
||||
if (context.steps.some(step => step.passed === false) || context.modules.at(-1)?.success === false) throw Error(`Producer stopped: ${name}`)
|
||||
if (name === 'phase4-overseas-dispatch') {
|
||||
await executeModule(MODULE, () => scenario(context))
|
||||
await executeModule(MODULE, () => fleetScenario(context))
|
||||
if (context.modules.at(-1)?.success === false) throw Error('Fleet case stopped at original fixture')
|
||||
}
|
||||
}
|
||||
@@ -371,7 +521,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
|
||||
const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')
|
||||
if (!success(login) || !login.body?.data?.accessToken) throw Error('Resume administrator login failed')
|
||||
context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || ''
|
||||
await context.executeModule(MODULE, () => scenario(context, { resumeFull: !resumed.receiptReady,
|
||||
await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady,
|
||||
resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal }))
|
||||
const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps)
|
||||
report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules,
|
||||
|
||||
Reference in New Issue
Block a user