Files
baiyue-firstleg/scripts/fleet-trip-rollback-test-env.test.mjs
T

362 lines
33 KiB
JavaScript
Raw Normal View History

import test from 'node:test'
import assert from 'node:assert/strict'
2026-10-20 10:35:52 +08:00
import fs from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { validateRollbackRequest, validateRollbackScope, resolveRollbackScope, rollbackSql, buildRollbackRemoteScript,
fullRollbackSnapshot, assertRollbackBaseline, assertTargetTransferUpdate, assertMidTransactionObservation, assertRestoredSnapshot,
runTripRollback, requestRollbackDeparture, assertDatabaseLockConflict, createDepartureRollbackVerifier } from './fleet-trip-rollback-test-env.mjs'
const sqlSource = fs.readFileSync(new URL('../lastleg-service/src/main/resources/db/lastleg.sql', import.meta.url), 'utf8')
// Read actual CREATE columns (including multiple columns on a line), respecting quoted comments and nested type parentheses.
function ddlColumns(table) {
const body = sqlSource.match(new RegExp(`CREATE TABLE(?: IF NOT EXISTS)? ${table} \\(([\\s\\S]*?)\\) ENGINE=`, 'i'))?.[1]
assert.ok(body, `actual schema table ${table}`)
const parts = []; let start = 0, depth = 0, quoted = false
for (let i = 0; i < body.length; i++) {
if (body[i] === "'" && body[i - 1] !== '\\') quoted = !quoted
if (!quoted) {
if (body[i] === '(') depth++
if (body[i] === ')') depth--
if (body[i] === ',' && depth === 0) { parts.push(body.slice(start, i)); start = i + 1 }
}
}
parts.push(body.slice(start))
const primary = body.match(/PRIMARY KEY\s*\(([^)]+)\)/i)[1].split(',').map(c => c.trim())
return parts.map(part => part.trim().match(/^([a-z][a-z0-9_]*)\s+((?:BIGINT|INT|VARCHAR|CHAR|TEXT|LONGTEXT|MEDIUMTEXT|DATETIME|TIMESTAMP|DATE|TIME|JSON|DECIMAL|TINYINT|SMALLINT|BOOLEAN|BIT|DOUBLE|FLOAT)(?:\([^)]*\))?)/i))
.filter(Boolean).map((match, i) => ({ collection: 'column', table, column: match[1], ordinal: i + 1, type: match[2].toLowerCase(), key: primary.includes(match[1]) ? 'PRI' : '' }))
}
const tableNames = ['ll_dispatch_order', 'll_dispatch_history', 'll_dispatch_item', 'll_dispatch_command', 'll_dispatch_pallet_lock', 'll_dispatch_physical_pallet_lock', 'll_physical_pallet_guard', 'll_dispatch_item_source',
'll_fleet_trip', 'll_fleet_trip_dispatch', 'll_fleet_trip_history', 'll_fleet_pool_order_link', 'll_outbound_order', 'll_delivery_order',
'll_order', 'll_loading_order_status_history', 'll_warehouse_transfer_order', 'll_dispatch_pda_task', 'll_dispatch_pda_pick',
'll_dispatch_pda_pick_order', 'll_inventory_balance', 'll_inventory_position', 'll_inventory_ledger']
const columns = [...tableNames].sort().flatMap(ddlColumns)
const request = { marker: 'OWN_RUN', tripNo: 'TR-OWN', dispatchNo: 'DS-OWN', warehouseId: '51', commandKey: 'ORIGINAL-DEPART', commandVersion: '11', expectedImage: 'sha256:' + 'a'.repeat(64),
// Reverse input and numeric ordering deliberately differ from Java string ordering.
members: [{ customerId: '99', incomingNo: 'WT-99', dispatchItemId: '302', palletId: '402', positionId: '502', balanceId: '602', outboundNo: 'OB-99', deliveryNo: 'DL-99' },
{ customerId: '101', incomingNo: 'WT-101', dispatchItemId: '301', palletId: '401', positionId: '501', balanceId: '601', outboundNo: 'OB-101', deliveryNo: 'DL-101' }] }
function resolution() {
return [{ collection: 'root', id: '201', dispatchNo: request.dispatchNo, warehouseId: '51', status: 'ACTIVE', stage: 'AFTER_LOADING', version: '11' },
{ collection: 'trip', id: '211', tripNo: request.tripNo, warehouseId: '51', status: 'PLANNED' },
{ collection: 'binding', dispatchId: '201', dispatchNo: request.dispatchNo, tripId: '211', tripNo: request.tripNo },
{ collection: 'task', id: '221', dispatchId: '201', warehouseId: '51' }, ...columns,
...request.members.flatMap(m => {
const suffix = m.customerId === '101' ? '1' : '2'
return [{ collection: 'item', id: m.dispatchItemId, dispatchId: '201', customerId: m.customerId, palletId: m.palletId, orderId: '70' + suffix, sourceType: null, sourceNo: null, incomingNo: m.incomingNo, packages: '1' },
{ collection: 'transfer', id: '80' + suffix, customerId: m.customerId, incomingNo: m.incomingNo, orderId: '70' + suffix, warehouseId: '51', needRelabel: '0', relabelNo: null, outboundAt: null },
{ collection: 'outbound', id: '90' + suffix, customerId: m.customerId, dispatchId: '201', outboundNo: m.outboundNo, status: 'WAIT_OUTBOUND' },
{ collection: 'delivery', id: '100' + suffix, customerId: m.customerId, dispatchId: '201', deliveryNo: m.deliveryNo, outboundNo: m.outboundNo, status: 'WAIT_DELIVERY' }]
})]
}
const scope = resolveRollbackScope(request, resolution())
const row = (table, values) => {
const actual = columns.filter(c => c.table === table).map(c => c.column)
for (const key of Object.keys(values)) assert.ok(actual.includes(key), `real ${table}.${key}`)
return { ...Object.fromEntries(actual.map(c => [c, null])), ...values }
}
function baseline() {
const value = Object.fromEntries(tableNames.map(table => [table, []]))
value.ll_dispatch_order.push(row('ll_dispatch_order', { id: '201', dispatch_no: request.dispatchNo, trip_id: '211', overseas_warehouse_id: '51', status: 'ACTIVE', stage: 'AFTER_LOADING', record_version: '11', shipped_package_count: '0' }))
value.ll_fleet_trip.push(row('ll_fleet_trip', { id: '211', trip_no: request.tripNo, status: 'PLANNED', record_version: '7' }))
value.ll_fleet_trip_dispatch.push(row('ll_fleet_trip_dispatch', { dispatch_id: '201', dispatch_no: request.dispatchNo, trip_id: '211', trip_no: request.tripNo }))
value.ll_dispatch_pda_task.push(row('ll_dispatch_pda_task', { id: '221', dispatch_order_id: '201' }))
for (const [index, m] of scope.members.entries()) {
const suffix = String(index + 1)
value.ll_dispatch_item.push(row('ll_dispatch_item', { id: m.dispatchItemId, dispatch_order_id: '201', customer_id: m.customerId }))
value.ll_outbound_order.push(row('ll_outbound_order', { id: m.outboundId, customer_id: m.customerId, dispatch_order_id: '201', outbound_no: m.outboundNo, status: 'WAIT_OUTBOUND' }))
value.ll_delivery_order.push(row('ll_delivery_order', { id: m.deliveryId, customer_id: m.customerId, dispatch_order_id: '201', delivery_no: m.deliveryNo, status: 'WAIT_DELIVERY', version: '0' }))
value.ll_order.push(row('ll_order', { id: m.orderId, customer_id: m.customerId, picked_package_count: '1', shipped_package_count: '0' }))
value.ll_warehouse_transfer_order.push(row('ll_warehouse_transfer_order', { id: m.transferId, customer_id: m.customerId, lastleg_order_id: m.orderId, transfer_no: m.incomingNo, status: 'RECEIVED' }))
value.ll_dispatch_pallet_lock.push(row('ll_dispatch_pallet_lock', { id: '110' + suffix, physical_lock_id: '170' + suffix, customer_id: m.customerId, dispatch_order_id: '201', warehouse_pallet_id: m.palletId, status: 'ACTIVE', active_key: 'OWN-' + suffix }))
value.ll_dispatch_physical_pallet_lock.push(row('ll_dispatch_physical_pallet_lock', { id: '170' + suffix, warehouse_id: '51',
warehouse_pallet_id: m.palletId, active_warehouse_pallet_id: m.palletId, dispatch_order_id: scope.dispatchId, status: 'ACTIVE' }))
value.ll_physical_pallet_guard.push(row('ll_physical_pallet_guard', { warehouse_pallet_id: m.palletId, created_at: `2026-10-19 11:00:00.00${suffix}` }))
value.ll_dispatch_pda_pick.push(row('ll_dispatch_pda_pick', { id: '120' + suffix, customer_id: m.customerId, task_id: '221', dispatch_item_id: m.dispatchItemId, inventory_position_id: m.positionId, packages: '1' }))
value.ll_dispatch_pda_pick_order.push(row('ll_dispatch_pda_pick_order', { id: '130' + suffix, customer_id: m.customerId, pick_id: '120' + suffix, receipt_id: '140' + suffix, order_id: m.orderId, packages: '1' }))
value.ll_inventory_balance.push(row('ll_inventory_balance', { id: m.balanceId, customer_id: m.customerId, overseas_warehouse_id: '51', available_packages: '0' }))
value.ll_inventory_position.push(row('ll_inventory_position', { id: m.positionId, balance_id: m.balanceId, customer_id: m.customerId, overseas_warehouse_id: '51', available_packages: '0' }))
value.ll_inventory_ledger.push(row('ll_inventory_ledger', { id: '150' + suffix, customer_id: m.customerId, overseas_warehouse_id: '51', business_type: 'SHIP', business_order_no: request.dispatchNo, balance_id: m.balanceId, position_id: m.positionId, delta_packages: '-1' }))
}
return value
}
function snapshotRows(snapshot) {
return [...structuredClone(columns), ...tableNames.flatMap(table => [{ collection: 'coverage', table, count: snapshot[table].length },
...snapshot[table].map(value => ({ collection: 'row', table, row: structuredClone(value) }))])]
}
const updateSql = "UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=?,updated_at=? WHERE customer_id=? AND lastleg_order_id=? AND outbound_at IS NULL"
const lease = () => ({ collection: 'lease', connectionId: '11', transactionId: '111', lockId: '802', mode: 'X,REC_NOT_GAP', ageSeconds: 1 })
function midRows() {
const dirty = baseline()
for (const [index, m] of scope.members.entries()) {
Object.assign(dirty.ll_outbound_order[index], { status: 'OUTBOUNDED' })
Object.assign(dirty.ll_delivery_order[index], { status: 'DELIVERING', dispatched_at: '2026-10-19 12:00:00.000', version: '1' })
Object.assign(dirty.ll_order[index], { picked_package_count: '0', shipped_package_count: '1', outbound_order_no: m.outboundNo })
dirty.ll_loading_order_status_history.push(row('ll_loading_order_status_history', { id: '160' + (index + 1), customer_id: m.customerId, order_id: m.orderId, event_id: 'DISPATCH_DEPART:' + scope.dispatchNo }))
}
Object.assign(dirty.ll_warehouse_transfer_order[0], { status: 'OUTBOUNDED', outbound_at: '2026-10-19 12:00:00.000' })
return [lease(), { collection: 'wait', requester: '12', blocker: '11', requestingTransactionId: '112', blockingTransactionId: '111', lockId: '802', blockingLockId: '802', objectName: 'll_warehouse_transfer_order', indexName: 'PRIMARY',
requestStatus: 'WAITING', blockingStatus: 'GRANTED', ownsRoot: 1, statement: updateSql, sessionLockWaitSeconds: '50' }, ...snapshotRows(dirty)]
}
const conflict = () => ({ http: 200, body: { code: 'A0409', message: '资源正在处理中,请稍后重试' }, requestId: 'request', traceId: 'trace' })
test('business-only request resolves one native root and real customer children in Java string order', () => {
assert.equal(scope.dispatchId, '201'); assert.equal(scope.members[0].customerId, '101'); assert.equal(scope.members[1].customerId, '99')
assert.equal(scope.members[1].transferId, '802'); assert.equal(request.members[0].customerId, '99')
assert.doesNotThrow(() => validateRollbackScope(scope))
const sql = rollbackSql(request, 'RESOLVE')
assert.match(sql, /ll_dispatch_order WHERE dispatch_no=/)
assert.match(sql, /'incomingNo',CAST\(inbound_code AS CHAR\)/)
assert.doesNotMatch(sql, /FOR UPDATE|firstDispatchId|secondDispatchId/)
})
2026-10-20 10:35:52 +08:00
for (const [name, mutate] of [
['two roots', rows => rows.push({ ...rows[0], id: '999' })],
['two Fleet bindings', rows => rows.push({ ...rows.find(r => r.collection === 'binding'), dispatchId: '999' })],
['wrong root version', rows => { rows[0].version = '12' }],
['invented ordinary source alias', rows => { rows.find(r => r.collection === 'item').sourceType = 'WAREHOUSE_TRANSFER' }],
['relabel source', rows => { rows.find(r => r.collection === 'transfer').needRelabel = '1' }],
['wrong incoming', rows => { rows.find(r => r.collection === 'transfer').incomingNo = 'BORROWED' }],
['foreign child', rows => { rows.find(r => r.collection === 'delivery').dispatchId = '999' }],
['missing private ID', rows => { rows.find(r => r.collection === 'transfer').id = undefined }],
['truncated schema', rows => rows.splice(rows.findIndex(r => r.collection === 'column' && r.table === 'll_order'), 1)],
]) test(`native resolution blocks ${name}`, () => {
const rows = resolution(); mutate(rows); assert.throws(() => resolveRollbackScope(request, rows))
})
2026-10-20 10:35:52 +08:00
test('scope rejects injection, repeated ownership, reversed sort and unpinned image', () => {
for (const change of [{ marker: "OWN';DELETE" }, { expectedImage: 'latest' }, { dispatchId: '1 OR 1=1' }, { members: [...scope.members].reverse() }])
assert.throws(() => validateRollbackScope({ ...scope, ...change }))
const bad = structuredClone(request); bad.members[1].customerId = bad.members[0].customerId
assert.throws(() => validateRollbackRequest(bad))
})
2026-10-20 10:35:52 +08:00
test('lease is only the second actual transfer PK, fixed read-only lease and automatic rollback', () => {
const sql = rollbackSql(scope, 'LEASE')
assert.match(sql, /FROM ll_warehouse_transfer_order WHERE id=802 AND customer_id=/)
assert.match(sql, /lastleg_order_id=702 AND transfer_no=/)
assert.match(sql, /outbound_at IS NULL FOR UPDATE;.*SLEEP\(65\)\);ROLLBACK;/)
assert.doesNotMatch(sql, /\b(?:INSERT|DELETE|ALTER|DROP|TRUNCATE|REPLACE|KILL|SET GLOBAL)\b|ll_dispatch_pallet_lock/)
assert.equal((sql.match(/\bUPDATE\b/g) || []).length, 1)
assert.throws(() => rollbackSql(scope, 'CUSTOM_SQL'))
})
2026-10-20 10:35:52 +08:00
test('wait proof joins exact granted/blocking PK, real transaction SQL, root ownership and requester session variable', () => {
const sql = rollbackSql(scope, 'OBSERVE')
for (const text of ['READ UNCOMMITTED', 'w.BLOCKING_ENGINE_LOCK_ID', "bl.LOCK_DATA=l.LOCK_DATA", "l.LOCK_DATA='802'", 'r.trx_query',
'v.THREAD_ID=th.THREAD_ID', 'th.PROCESSLIST_ID=r.trx_mysql_thread_id', "v.VARIABLE_NAME='innodb_lock_wait_timeout'", "dl.LOCK_DATA='201'"])
assert.ok(sql.includes(text), text)
assert.doesNotMatch(sql, /@@GLOBAL|ll_dispatch_pda_operation|DEPARTED/)
assert.match(rollbackSql(scope, 'RESTORED'), /REPEATABLE READ;\nSTART TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;/)
assert.doesNotMatch(rollbackSql(scope, 'RESTORED'), /READ UNCOMMITTED/)
})
2026-10-20 10:35:52 +08:00
test('generated resolution and snapshot projections use current native schema columns', () => {
const sql = rollbackSql(request, 'RESOLVE') + '\n' + rollbackSql(scope, 'BASELINE')
for (const query of sql.split(';')) {
const match = query.match(/SELECT JSON_OBJECT\([\s\S]*? FROM (ll_[a-z_]+) WHERE/)
if (!match) continue
const actual = new Set(ddlColumns(match[1]).map(c => c.column))
for (const column of query.slice(0, match.index + match[0].indexOf(' FROM ')).matchAll(/CAST\(`?([a-z_]+)`? AS CHAR/g))
assert.ok(actual.has(column[1]), `${match[1]}.${column[1]}`)
}
assert.ok(columns.filter(c => c.table === 'll_order').length > 50)
assert.doesNotThrow(() => assertRollbackBaseline(fullRollbackSnapshot(snapshotRows(baseline()), scope), scope))
})
2026-10-20 10:35:52 +08:00
test('remote envelope pins TEST container, healthy image, DB identity and preflights observation permissions', () => {
function decode(script) { const encoded = script.match(/decoder.decode\(\[((?:'[A-Za-z0-9+/=]*',?)+)\]\.join\(''\)\)/)[1]; return Buffer.from([...encoded.matchAll(/'([^']*)'/g)].map(m => m[1]).join(''), 'base64').toString() }
const python = decode(decode(buildRollbackRemoteScript(scope, 'OBSERVE', 'test-agent')))
for (const text of ['firstleg-test-lastleg', 'firstleg-test', "e.get('DEPLOY_ENV')!='test'", "get('Status')!='healthy'", scope.expectedImage, 'MYSQL_PWD', 'variables_by_thread']) assert.ok(python.includes(text), text)
assert.ok(buildRollbackRemoteScript({}, 'RUNTIME', 'test-agent'))
assert.throws(() => buildRollbackRemoteScript(scope, 'LEASE', "agent');evil()"))
})
test('full-field baseline cannot accept missing columns, rows, coverage or changed schema', () => {
for (const mutate of [
rows => { delete rows.find(r => r.collection === 'row').row.updated_at },
rows => rows.splice(rows.findIndex(r => r.collection === 'row'), 1),
rows => rows.splice(rows.findIndex(r => r.collection === 'coverage'), 1),
rows => { rows.find(r => r.collection === 'column').type = 'changed' },
rows => rows.push({ ...rows.find(r => r.collection === 'coverage') }),
]) { const rows = snapshotRows(baseline()); mutate(rows); assert.throws(() => fullRollbackSnapshot(rows, scope)) }
})
for (const [name, mutate] of [
['no committed SHIP', b => { b.ll_inventory_ledger = [] }],
['wrong scan source', b => { b.ll_dispatch_pda_pick_order[0].order_id = '999' }],
['prior departure command', b => { b.ll_dispatch_command.push(row('ll_dispatch_command', { id: '999', action: 'DEPART' })) }],
['source contribution on ordinary transfer', b => { b.ll_dispatch_item_source.push(row('ll_dispatch_item_source', { id: '991', dispatch_order_id: '201', dispatch_item_id: scope.members[0].dispatchItemId })) }],
['ordinary receipt also claims relabel contribution', b => { b.ll_dispatch_pda_pick_order[0].dispatch_item_source_id = '991' }],
['ordinary receipt also claims native return carton', b => { b.ll_dispatch_pda_pick_order[0].return_carton_id = '991' }],
['missing physical lock', b => { b.ll_dispatch_physical_pallet_lock.pop() }],
['missing permanent physical guard', b => { b.ll_physical_pallet_guard.pop() }],
['guard for a foreign pallet', b => { b.ll_physical_pallet_guard[0].warehouse_pallet_id = '991' }],
['extra foreign permanent guard', b => { b.ll_physical_pallet_guard.push(row('ll_physical_pallet_guard', { warehouse_pallet_id: '991', created_at: '2026-10-19 11:00:00.001' })) }],
['guard without original created timestamp', b => { b.ll_physical_pallet_guard[0].created_at = null }],
['guard with invalid creation timestamp', b => { b.ll_physical_pallet_guard[0].created_at = 'NOT-A-CREATION-TIME' }],
['logical claim points to other physical pallet', b => { b.ll_dispatch_pallet_lock[0].physical_lock_id = b.ll_dispatch_physical_pallet_lock[1].id }],
['logical claim on other main', b => { b.ll_dispatch_pallet_lock[0].dispatch_order_id = '991' }],
['physical claim on other main', b => { b.ll_dispatch_physical_pallet_lock[0].dispatch_order_id = '991' }],
['physical claim on other warehouse', b => { b.ll_dispatch_physical_pallet_lock[0].warehouse_id = '991' }],
['physical claim for other pallet', b => { b.ll_dispatch_physical_pallet_lock[0].warehouse_pallet_id = '991' }],
['physical active key mismatch', b => { b.ll_dispatch_physical_pallet_lock[0].active_warehouse_pallet_id = '991' }],
['physical claim released', b => { b.ll_dispatch_physical_pallet_lock[0].status = 'RELEASED'; b.ll_dispatch_physical_pallet_lock[0].active_warehouse_pallet_id = null }],
['physical draft ownership', b => { b.ll_dispatch_physical_pallet_lock[0].draft_token = 'OTHER-DRAFT'; b.ll_dispatch_physical_pallet_lock[0].draft_container_id = '991' }],
['pool fixture', b => { b.ll_fleet_pool_order_link.push(row('ll_fleet_pool_order_link', { id: '999' })) }],
]) test(`baseline rejects ${name}`, () => { const value = baseline(); mutate(value); assert.throws(() => assertRollbackBaseline(value, scope)) })
test('late dirty observer proves first completed transfer, both source writes, unchanged native roots and prior SHIP', () => {
const proof = assertMidTransactionObservation(midRows(), scope, baseline())
assert.equal(proof.dirtyObservationOnly, true); assert.equal(proof.firstCustomerId, '101'); assert.equal(proof.sessionLockWaitSeconds, '50')
assert.doesNotThrow(() => assertTargetTransferUpdate(updateSql.replaceAll('=', ' = '), scope))
assert.doesNotThrow(() => assertTargetTransferUpdate(updateSql.replace('customer_id=?', "customer_id='99'").replace('lastleg_order_id=?', 'lastleg_order_id=702'), scope))
})
for (const [name, mutate] of [
['foreign blocker', r => { r[1].blocker = '77' }],
['missing wait', r => r.splice(1, 1)],
['multiple waits', r => r.push({ ...r[1], requester: '13' })],
['wrong target row', r => { r[1].lockId = '801' }],
['wrong blocking row', r => { r[1].blockingLockId = '801' }],
['wrong blocking transaction', r => { r[1].blockingTransactionId = '113' }],
['unrelated transaction', r => { r[1].ownsRoot = 0 }],
['unavailable session variable', r => { delete r[1].sessionLockWaitSeconds }],
['global timeout stand-in', r => { delete r[1].sessionLockWaitSeconds; r[1].globalLockWaitSeconds = '50' }],
['session timeout beyond lease', r => { r[1].sessionLockWaitSeconds = '120' }],
['unavailable requesting SQL', r => { r[1].statement = null }],
['early child lock', r => { r[1].statement = 'SELECT * FROM ll_outbound_order WHERE dispatch_order_id=201 FOR UPDATE' }],
['different transfer UPDATE', r => { r[1].statement = updateSql.replace('lastleg_order_id=?', 'lastleg_order_id=701') }],
['first outbound not written', r => { r.find(x => x.table === 'll_outbound_order' && x.row?.id === '901').row.status = 'WAIT_OUTBOUND' }],
['first order not written', r => { r.find(x => x.table === 'll_order' && x.row?.id === '701').row.shipped_package_count = '0' }],
['no first source history', r => { r.find(x => x.table === 'll_loading_order_status_history' && x.row?.order_id === '701').row.event_id = 'OTHER' }],
['first transfer not written', r => { r.find(x => x.table === 'll_warehouse_transfer_order' && x.row?.id === '801').row.outbound_at = null }],
['second transfer already changed', r => { r.find(x => x.table === 'll_warehouse_transfer_order' && x.row?.id === '802').row.status = 'OUTBOUNDED' }],
['root changed too soon', r => { r.find(x => x.table === 'll_dispatch_order' && x.row).row.stage = 'DELIVERING' }],
['physical lock released before late source update', r => { r.find(x => x.table === 'll_dispatch_physical_pallet_lock' && x.row).row.status = 'RELEASED' }],
['permanent guard deleted in departure transaction', r => { r.splice(r.findIndex(x => x.table === 'll_physical_pallet_guard' && x.row), 1); r.find(x => x.collection === 'coverage' && x.table === 'll_physical_pallet_guard').count-- }],
['permanent guard recreated in departure transaction', r => { r.find(x => x.table === 'll_physical_pallet_guard' && x.row).row.created_at = '2026-10-19 12:00:00.999' }],
['permanent guard key replaced in departure transaction', r => { r.find(x => x.table === 'll_physical_pallet_guard' && x.row).row.warehouse_pallet_id = '991' }],
['source contribution added before late source update', r => { const value = row('ll_dispatch_item_source', { id: '991', dispatch_order_id: '201' }); r.find(x => x.collection === 'coverage' && x.table === 'll_dispatch_item_source').count = 1; r.push({ collection: 'row', table: 'll_dispatch_item_source', row: value }) }],
['prior SHIP changed', r => { r.find(x => x.table === 'll_inventory_ledger' && x.row).row.delta_packages = '0' }],
]) test(`rejects insufficient middle evidence: ${name}`, () => { const value = midRows(); mutate(value); assert.throws(() => assertMidTransactionObservation(value, scope, baseline())) })
test('rollback compares every full field, not just business status or inventory totals', () => {
const before = baseline(), after = structuredClone(before)
const proof = assertRestoredSnapshot(before, after)
assert.equal(proof.beforeSha256, proof.afterSha256); assert.equal(proof.committedRead, true)
for (const table of tableNames.filter(t => after[t].length)) {
const changed = structuredClone(after); changed[table][0][Object.keys(changed[table][0]).at(-1)] = 'CHANGED'
assert.throws(() => assertRestoredSnapshot(before, changed), table)
}
})
test('database conflict requires exact handler result and trace; timeout or precondition rejection fails', () => {
assert.doesNotThrow(() => assertDatabaseLockConflict(conflict()))
for (const wrong of [{ http: 0 }, { http: 500 }, { body: { code: 'S0500', message: 'error' } }, { body: { code: 'A0409', message: '请完成拣货' } }, { traceId: null }])
assert.throws(() => assertDatabaseLockConflict({ ...conflict(), ...wrong }))
})
test('single-attempt request keeps original key/version/body on the 60-second channel', async () => {
const body = { recordVersion: '11', idempotencyKey: 'ORIGINAL' }, calls = []
const context = { request: () => { throw Error('30_SECOND_CHANNEL') }, requestWithBody: async (...args) => { calls.push(args); return { http: 0, body: null } } }
assert.equal((await requestRollbackDeparture(context, '/owned/depart', body, 'owned-token')).http, 0)
assert.deepEqual(calls, [['POST', '/owned/depart', body, 'owned-token']]); assert.equal(calls[0][2], body)
})
test('plan has no network; non-TEST execution fails before any fetch', async () => {
const fetch = globalThis.fetch; globalThis.fetch = () => { throw Error('NETWORK_FORBIDDEN') }
try {
const plan = await runTripRollback({ execute: false }, { env: {} })
assert.equal(plan.businessSqlWrites, false); assert.equal(plan.deployment, false); assert.match(plan.fault, /One native dispatch/)
await assert.rejects(runTripRollback({ execute: true, environment: 'TEST' }, { env: { CHONGCHONG_ENV: 'prod' } }), /TEST/)
} finally { globalThis.fetch = fetch }
})
test('offline verifier preserves original command and requires all phases before caller can depart/sign', async () => {
const original = process.cwd(), temp = fs.mkdtempSync(join(tmpdir(), 'fleet-rollback-offline-'))
try {
process.chdir(temp); fs.mkdirSync('reports/fleet-cross-offline', { recursive: true })
const calls = [], ready = { trip: { dispatches: [{ dispatchNo: request.dispatchNo }] }, dispatch: { dispatchNo: request.dispatchNo }, task: { recordVersion: '11' } }
const fixture = { ...request, pdaToken: 'synthetic-offline-token' }, body = { recordVersion: '11', idempotencyKey: request.commandKey, remark: request.marker }
const runtime = { imageId: request.expectedImage, rows: [{ collection: 'databaseRuntime', serverUuid: 'offline', databaseName: 'offline', performanceSchema: 1 }] }
const read = async (_env, _scope, mode) => {
calls.push(mode)
if (mode === 'RESOLVE') return { rows: resolution() }
if (mode === 'BASELINE' || mode === 'RESTORED') return { rows: snapshotRows(baseline()) }
if (mode === 'LEASE') return { rows: [{ collection: 'held', lockId: '802', connectionId: '11', customerId: '99' }, { released: true }] }
if (mode === 'LOCKS') return { rows: [lease()] }
if (mode === 'OBSERVE') return { rows: midRows() }
if (mode === 'RELEASED') return { rows: [] }
throw Error('UNEXPECTED_MODE')
}
const context = { marker: request.marker, requestWithBody: async (...args) => { calls.push(args); return conflict() } }
const proof = await createDepartureRollbackVerifier({}, { runId: 'offline' }, runtime, { read, wait: () => { throw Error('NO_REAL_WAIT') } })
({ context, fixture, ready, departPath: '/owned/depart', departBody: body, snapshot: async () => ready })
assert.equal(proof.verified, true); assert.equal(proof.consistentSnapshot.fullFields, true)
assert.deepEqual(calls.filter(Array.isArray), [['POST', '/owned/depart', body, fixture.pdaToken]])
assert.deepEqual(calls.filter(x => typeof x === 'string'), ['RESOLVE', 'BASELINE', 'LEASE', 'LOCKS', 'OBSERVE', 'RELEASED', 'RESTORED'])
assert.deepEqual(proof.command.body, body)
const saved = JSON.parse(fs.readFileSync('reports/fleet-cross-offline/rollback.json', 'utf8'))
assert.equal(saved.verified, true); assert.ok(saved.finishedAt)
const caller = fs.readFileSync(new URL('./admin-test-env-modules/fleet-cross-customer-acceptance.mjs', import.meta.url), 'utf8')
2026-10-21 15:10:44 +08:00
assert.match(caller, /await confirmFleetLoading\([^\n]+async command => \{/)
assert.match(caller, /await verifyDepartureRollback\(\{ context, fixture, ready, departPath: command.path, departBody: command.body, snapshot \}\)/)
assert.match(caller, /Rollback restores both complete inventory snapshots before loading completion/)
const loading = fs.readFileSync(new URL('./admin-test-env-modules/fleet-trip-fulfillment.mjs', import.meta.url), 'utf8')
const hook = loading.indexOf('if (beforeComplete) await beforeComplete(proof.loadingCommand)')
const completion = loading.indexOf("const confirmed = await command(context, module, 'POST', proof.loadingCommand.path")
assert.ok(hook >= 0 && completion > hook, '回滚验证必须在真正提交装车完成命令前结束')
assert.match(caller, /Original PDA actor exact replay of loading completion/); assert.match(caller, /This customer independently signed/)
2026-10-20 10:35:52 +08:00
} finally {
process.chdir(original)
assert.ok(resolve(temp).startsWith(resolve(tmpdir()) + '\\') || resolve(temp).startsWith(resolve(tmpdir()) + '/'))
fs.rmSync(temp, { recursive: true, force: true })
}
})
test('23 full snapshots include batch-only permanent guards, empty ordinary contributions and both customer-free physical owners from actual DDL', () => {
const full = fullRollbackSnapshot(snapshotRows(baseline()), scope)
assert.equal(tableNames.length, 23); assert.equal(Object.keys(full).length, 23)
assert.equal(snapshotRows(baseline()).filter(row => row.collection === 'coverage').length, 23)
assert.equal(full.ll_dispatch_item_source.length, 0)
assert.deepEqual(full.ll_physical_pallet_guard.map(row => row.warehouse_pallet_id).sort(), ['401', '402'])
assert.deepEqual(ddlColumns('ll_physical_pallet_guard').map(column => [column.column, column.type, column.key]), [['warehouse_pallet_id', 'bigint', 'PRI'], ['created_at', 'datetime(3)', '']])
assert.deepEqual(Object.keys(full.ll_physical_pallet_guard[0]).sort(), ['created_at', 'warehouse_pallet_id'])
assert.equal(full.ll_dispatch_physical_pallet_lock.length, 2)
assert.deepEqual(Object.keys(full.ll_dispatch_physical_pallet_lock[0]).sort(), ddlColumns('ll_dispatch_physical_pallet_lock').map(column => column.column).sort())
assert.equal(Object.hasOwn(full.ll_dispatch_physical_pallet_lock[0], 'customer_id'), false)
assert.equal(ddlColumns('ll_dispatch_pallet_lock').some(column => column.column === 'active_warehouse_pallet_id'), false)
assert.ok(ddlColumns('ll_dispatch_pallet_lock').some(column => column.column === 'physical_lock_id'))
assert.ok(ddlColumns('ll_dispatch_pda_pick_order').some(column => column.column === 'dispatch_item_source_id'))
assert.doesNotThrow(() => assertRollbackBaseline(full, scope))
const sql = rollbackSql(scope, 'BASELINE')
assert.match(sql, /FROM ll_dispatch_physical_pallet_lock WHERE dispatch_order_id=201 OR warehouse_pallet_id IN \(401,402\)/)
assert.match(sql, /FROM ll_dispatch_item_source WHERE dispatch_order_id=201 OR dispatch_item_id IN \(301,302\)/)
for (const mode of ['BASELINE', 'OBSERVE', 'RESTORED']) {
const guardQueries = rollbackSql(scope, mode).split(';').filter(query => query.includes('FROM ll_physical_pallet_guard'))
assert.equal(guardQueries.length, 2)
for (const query of guardQueries) assert.ok(query.endsWith('FROM ll_physical_pallet_guard WHERE warehouse_pallet_id IN (401,402)'))
assert.ok(guardQueries.some(query => query.includes('COUNT(*)')))
assert.ok(guardQueries.some(query => query.includes("'created_at',CAST(`created_at` AS CHAR CHARACTER SET utf8mb4)")))
}
assert.match(sql, /'dispatch_item_source_id',CAST\(`dispatch_item_source_id` AS CHAR CHARACTER SET utf8mb4\)/)
})
test('missing source-table zero coverage or partial new-table schema cannot masquerade as a full rollback snapshot', () => {
for (const table of ['ll_dispatch_item_source', 'll_dispatch_physical_pallet_lock', 'll_physical_pallet_guard']) {
const rows = snapshotRows(baseline()).filter(row => !(row.collection === 'coverage' && row.table === table))
assert.throws(() => fullRollbackSnapshot(rows, scope), /EXACT_COVERAGE_REQUIRED/)
const resolvedRows = resolution().filter(row => !(row.collection === 'column' && row.table === table))
assert.throws(() => resolveRollbackScope(request, resolvedRows), /COMPLETE_ACTUAL_SCHEMA_REQUIRED/)
}
const changed = snapshotRows(baseline())
const physical = changed.find(row => row.table === 'll_dispatch_physical_pallet_lock' && row.row)
delete physical.row.released_at
assert.throws(() => fullRollbackSnapshot(changed, scope), /COMPLETE_FULL_FIELD_SNAPSHOT_REQUIRED/)
})
test('permanent guard schema cannot introduce customer, owner, state or TTL or change the original PK/time types', () => {
for (const mutate of [
rows => { rows.find(row => row.table === 'll_physical_pallet_guard' && row.column === 'warehouse_pallet_id').key = '' },
rows => { rows.find(row => row.table === 'll_physical_pallet_guard' && row.column === 'created_at').type = 'datetime' },
...['customer_id', 'owner', 'state', 'expires_at'].map(column => rows => rows.push({ collection: 'column', table: 'll_physical_pallet_guard', column, ordinal: 3, type: 'varchar(128)', key: '' })),
]) {
const rows = resolution(); mutate(rows)
assert.throws(() => resolveRollbackScope(request, rows))
}
})
for (const fault of ['delete', 'recreate', 'created timestamp', 'key replacement']) test(`committed rollback rejects permanent guard ${fault}`, () => {
const before = baseline(), restored = structuredClone(before)
if (fault === 'delete') restored.ll_physical_pallet_guard.pop()
if (fault === 'recreate') { const old = restored.ll_physical_pallet_guard.shift(); restored.ll_physical_pallet_guard.push({ ...old, created_at: '2026-10-19 12:00:00.123' }) }
if (fault === 'created timestamp') restored.ll_physical_pallet_guard[0].created_at = '2026-10-19 11:00:00.999'
if (fault === 'key replacement') restored.ll_physical_pallet_guard[0].warehouse_pallet_id = '991'
assert.throws(() => assertRestoredSnapshot(before, restored), /FULL_FIELD_COMMITTED_ROLLBACK_SNAPSHOT_CHANGED/)
})