test(fleet): read complete customer inventory snapshots
This commit is contained in:
@@ -375,6 +375,8 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行
|
||||
|
||||
`fba-core-regression-evidence.mjs` 是 Admin/F40 共用的内部只读取证器,不是独立 CLI。只在明确的 TEST Jenkins 节点读取 `firstleg-test-order`、`firstleg-test-lastleg` 对应数据库的一致性只读快照,原始库存、位置、流水及事件身份严格按本轮客户/仓库/柜次过滤。连接凭据只在执行节点内存中处理,不执行 SQL 写入;调用者保存独立报告,失败不能用空结果代替。直接合同为 `fba-core-regression-evidence.test.mjs`,覆盖只读 SQL 边界、完整作用域、长 ID、零事件保留与敏感错误脱敏;离线合同不代替真实数据库取证。
|
||||
|
||||
`fleet-inventory-evidence.mjs` 是上述取证器的内部库存查询模块,通过 `createTestEvidenceReaders(...).readFleetInventory({ marker, customerId, warehouseId })` 调用,无独立命令、环境加载或写入能力。对明确客户和仓库读取全部原始余额、位置与所有类型流水,包含零数量、版本和时间;同一只读事务的逐表计数用于拒绝遗漏、截断和重复行,不使用非零汇总代替。每个字段保留为字符串或空值,避免长 ID 精度丢失。调用者保存报告及本轮来源证明,不能用历史客户的探针替代新场景验收。合同为 `fleet-inventory-evidence.test.mjs`。
|
||||
|
||||
海外卸柜反向故障验收从 Admin 总入口显式传 `--modules phase4-overseas-pda --verify-unload-event-recovery true`,按正式依赖准备新客户、新柜及实收,首个本轮柜完成前设置独立故障 hook;普通收货默认不启用。目标事件固定为 `OverseasContainerUnloaded`,生产者为 Lastleg,消费者为 Firstleg,事件平台作用域固定 0、库存仍按本轮实际客户隔离。按原事件验证 ACK 丢失、DEAD、人工 retry 与重复确认;在恢复服务配置后,用相同只读库存取证器采集 `AFTER_EVENT_RECOVERY`,核对原始库存、位置、托盘组成、收货完整键及流水零漂移。任一方向恢复失败保留任务恢复记录并判失败;不得与独立部署或共享环境造数并行,不修改业务 SQL。报告沿用显式新 `--output`,旧失败报告和业务样本保留,不自动清理。此项不覆盖签收完成回写、设备和车辆新版链路。
|
||||
|
||||
Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-executor.mjs` 和 `test-event-fault-runtime.py`,不是额外 CLI 入口。它们仅在显式 `CHONGCHONG_ENV=test` 和已授权环境中运行:在首个本轮计划完成前启动已有 ACK 丢失代理,短暂重建同镜像 Firstleg 容器,保留数据卷、端口和网络身份,只变更事件目标与本次故障退避参数。仅选定的装柜事件被抑制 ACK;其他事件直通。核对 12 次失败、DEAD、受保护原事件 retry、DUPLICATE 及业务订单零新增,结束或失败都恢复原镜像配置并检查健康,随后停止本任务代理。不修改业务 SQL、载荷、事件 ID 或 hash,不重放已发布事件。恢复失败必须保留阻断和任务元数据;独立后端发布或其他环境造数不得与此窗口并行。凭据仅在执行节点内存与权限600的临时恢复文件中使用,恢复完成后移除临时文件中的凭据。离线合同为 `test-event-fault-executor.test.mjs`。
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Internal explicit TEST evidence queries; no network or environment reads on import.
|
||||
import { buildFleetInventorySql, fleetInventoryFromRows } from './fleet-inventory-evidence.mjs'
|
||||
const stages = new Set(['BEFORE_COMPLETE', 'AFTER_COMPLETE', 'AFTER_SAME_KEY_REPLAY', 'AFTER_FRESH_KEY_REJECTION', 'AFTER_EVENT_RECOVERY'])
|
||||
const numeric = new Set(['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages', 'receivedPackages', 'beforePackages', 'deltaPackages', 'afterPackages', 'packageCount'])
|
||||
const fields = {
|
||||
@@ -173,6 +174,7 @@ export function createTestEvidenceReaders(env, fetcher = globalThis.fetch) {
|
||||
return result.rows
|
||||
}
|
||||
return {
|
||||
readFleetInventory: async scope => fleetInventoryFromRows(scope, await execute('lastleg', buildFleetInventorySql(scope))),
|
||||
readReceiving: createReceivingEvidenceReader(sql => execute('lastleg',sql)),
|
||||
readCompletion: async scope => completionEvidenceFromRows(scope,await execute('order',buildCompletionEvidenceSql(scope))),
|
||||
preflight: async () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Internal read-only snapshot for two-customer fleet acceptance. Import has no side effects.
|
||||
const collections = {
|
||||
balances: { table: 'll_inventory_balance', fields: 'id customerId customerName overseasWarehouseId overseasWarehouseCode overseasWarehouseName destinationWarehouseId destinationWarehouseCode destinationWarehouseName inboundOrderId inboundOrderNo inboundCode skuCode productNameZh productNameEn availablePackages holdPackages allocatedPackages pickedPackages inTransitPackages receivingPackages procurementPackages version createdAt updatedAt' },
|
||||
positions: { table: 'll_inventory_position', fields: 'id customerId balanceId overseasWarehouseId destinationWarehouseCode palletId palletNo batchNo warehouseLocationId warehouseLocationCode availablePackages holdPackages allocatedPackages pickedPackages receivingPackages status version createdAt updatedAt' },
|
||||
ledger: { table: 'll_inventory_ledger', fields: 'id customerId customerName balanceId positionId overseasWarehouseId destinationWarehouseCode inboundOrderId palletId palletNo batchNo warehouseLocationCode skuCode cartonMark cartonPackages businessType businessOrderId businessOrderNo inboundCode inboundOrderNo outboundOrderNo sourceEventId idempotencyKey statusFrom statusTo beforePackages deltaPackages afterPackages operatorId remark occurredAt createdAt' },
|
||||
}
|
||||
const snake = value => value.replace(/[A-Z]/g, letter => '_' + letter.toLowerCase())
|
||||
function validate(scope) {
|
||||
if (!scope || !/^[A-Za-z0-9_-]{1,100}$/.test(scope.marker || '')
|
||||
|| ['customerId', 'warehouseId'].some(key => typeof scope[key] !== 'string' || !/^[1-9][0-9]{0,19}$/.test(scope[key]))) {
|
||||
throw Error('Invalid fleet inventory scope')
|
||||
}
|
||||
}
|
||||
const literal = value => `CONVERT(0x${Buffer.from(value, 'utf8').toString('hex')} USING utf8mb4) COLLATE utf8mb4_bin`
|
||||
|
||||
export function buildFleetInventorySql(scope) {
|
||||
validate(scope)
|
||||
const where = `BINARY customer_id=BINARY (${literal(scope.customerId)}) AND BINARY overseas_warehouse_id=BINARY (${literal(scope.warehouseId)})`
|
||||
const queries = []
|
||||
for (const [collection, { table, fields }] of Object.entries(collections)) {
|
||||
const json = fields.split(' ').map(field => `'${field}',CAST(${snake(field)} AS CHAR)`).join(',')
|
||||
queries.push(`SELECT JSON_OBJECT('collection','${collection}','row',JSON_OBJECT(${json})) FROM ${table} WHERE ${where} ORDER BY id;`)
|
||||
queries.push(`SELECT JSON_OBJECT('collection','${collection}','count',CAST(COUNT(*) AS CHAR)) FROM ${table} WHERE ${where};`)
|
||||
}
|
||||
return ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;',
|
||||
'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;', ...queries, 'COMMIT;'].join('\n')
|
||||
}
|
||||
|
||||
export function fleetInventoryFromRows(scope, rows) {
|
||||
validate(scope)
|
||||
if (!Array.isArray(rows)) throw Error('Missing fleet inventory snapshot')
|
||||
const result = { scope: { marker: scope.marker, customerId: scope.customerId, warehouseId: scope.warehouseId, coverage: 'CUSTOMER_WAREHOUSE_COMPLETE' }, balances: [], positions: [], ledger: [] }
|
||||
const counts = new Map()
|
||||
for (const entry of rows) {
|
||||
const definition = collections[entry?.collection]
|
||||
if (!definition) throw Error('Unexpected fleet inventory collection')
|
||||
if (Object.hasOwn(entry, 'count')) {
|
||||
if (counts.has(entry.collection) || entry.row || !/^(0|[1-9][0-9]*)$/.test(String(entry.count))) throw Error('Invalid fleet inventory count')
|
||||
counts.set(entry.collection, BigInt(entry.count)); continue
|
||||
}
|
||||
const row = entry.row
|
||||
if (!row || definition.fields.split(' ').some(field => !Object.hasOwn(row, field))
|
||||
|| typeof row.id !== 'string' || !/^[1-9][0-9]*$/.test(row.id)
|
||||
|| row.customerId !== scope.customerId || row.overseasWarehouseId !== scope.warehouseId) throw Error('Incomplete or foreign fleet inventory row')
|
||||
result[entry.collection].push(row)
|
||||
}
|
||||
for (const name of Object.keys(collections)) {
|
||||
if (counts.get(name) !== BigInt(result[name].length) || new Set(result[name].map(row => row.id)).size !== result[name].length) {
|
||||
throw Error('Truncated or duplicated fleet inventory snapshot')
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { buildFleetInventorySql, fleetInventoryFromRows } from './fleet-inventory-evidence.mjs'
|
||||
import { assertReadOnlySql, createTestEvidenceReaders } from './fba-core-regression-evidence.mjs'
|
||||
|
||||
const scope = { marker: 'CROSS-TEST-1', customerId: '2106128532165988353', warehouseId: '193000000000200003' }
|
||||
const empty = () => ['balances', 'positions', 'ledger'].map(collection => ({ collection, count: '0' }))
|
||||
test('customer/warehouse snapshot includes zero rows and all ledger kinds in a single read-only transaction', () => {
|
||||
const sql = assertReadOnlySql(buildFleetInventorySql(scope))
|
||||
assert.equal((sql.match(/SELECT JSON_OBJECT/g) || []).length, 6)
|
||||
assert.doesNotMatch(sql, /\bLIMIT\b|available_packages\s*>|business_type\s*=/)
|
||||
assert.match(sql, /'deltaPackages',CAST\(delta_packages AS CHAR\)/)
|
||||
assert.match(sql, /'version',CAST\(version AS CHAR\)/)
|
||||
assert.equal((sql.match(/BINARY customer_id=/g) || []).length, 6)
|
||||
assert.equal((sql.match(/BINARY overseas_warehouse_id=/g) || []).length, 6)
|
||||
assert.equal(fleetInventoryFromRows(scope, empty()).scope.coverage, 'CUSTOMER_WAREHOUSE_COMPLETE')
|
||||
})
|
||||
test('rejects missing count, truncation, duplicate count, foreign or incomplete rows', () => {
|
||||
for (const rows of [[], empty().slice(1), [...empty(), empty()[0]], [...empty(), { collection: 'balances', row: { id: '1', customerId: 'wrong' } }], [{ collection: 'balances', count: '1' }, ...empty().slice(1)]]) {
|
||||
assert.throws(() => fleetInventoryFromRows(scope, rows))
|
||||
}
|
||||
for (const changed of [{ customerId: '1 OR 1=1' }, { warehouseId: 193000000000200003 }, { marker: "';DROP" }]) assert.throws(() => buildFleetInventorySql({ ...scope, ...changed }))
|
||||
})
|
||||
test('transport integration uses the existing secret-safe TEST reader', async () => {
|
||||
let requests = 0
|
||||
const reader = createTestEvidenceReaders({ JENKINS_URL: 'https://ci.example.invalid/', JENKINS_USER: 'test', JENKINS_API_TOKEN: 'secret', FBA_CORE_JENKINS_AGENT: 'java-test-agent' }, async (_url, options) => {
|
||||
requests++; assert.equal(options.method, 'POST')
|
||||
return { ok: true, text: async () => 'FBA_CORE_JSON=' + JSON.stringify({ ok: true, rows: empty() }) }
|
||||
})
|
||||
assert.deepEqual((await reader.readFleetInventory(scope)).balances, [])
|
||||
assert.equal(requests, 1)
|
||||
})
|
||||
Reference in New Issue
Block a user