136 lines
9.7 KiB
JavaScript
136 lines
9.7 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { verifyFleetTripCosts, expectedAllocations, TRIP_COST_EXPECTATIONS } from './fleet-trip-cost-acceptance.mjs'
|
|
|
|
const copy = value => value === undefined ? undefined : structuredClone(value)
|
|
const prefix = 'platform:fleet:trip:'
|
|
function fixture(fault) {
|
|
const trip = { marker: 'fixture-vehicle', tripNo: 'FT-CURRENT' }, calls = [], rows = [], histories = new Map(), keys = new Map()
|
|
const shares = [
|
|
{ businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, warehousePalletIds: ['P1'] },
|
|
{ businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: 'EXTERNAL', palletSpaces: 1, warehousePalletIds: ['P2'] },
|
|
]
|
|
const inputs = { shares, listOnlyToken: 'offline-reader', deniedToken: 'offline-denied' }
|
|
const depart = { recordVersion: 7, action: 'DEPART', snapshot: {
|
|
trip: { tripNo: trip.tripNo, departedAt: '2026-10-03T01:00:00Z' },
|
|
confirmedLoad: { manifestHash: 'original-manifest', pallets: [
|
|
{ warehousePalletId: 'P1', palletSpaces: 2, deliveryIndex: 1 }, { warehousePalletId: 'P2', palletSpaces: 1, deliveryIndex: 1 }],
|
|
cargo: [{ warehousePalletId: 'P1', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 5 },
|
|
{ warehousePalletId: 'P2', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', packages: 9 }] },
|
|
externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', status: 'BOUND',
|
|
receipt: { source: { receivedPackages: 9, pallets: [{ warehousePalletId: 'P2', packages: 9 }] } } }] } }
|
|
if (fault === 'unbound external') depart.snapshot.externalBookings[0].status = 'CONFIRMED'
|
|
if (fault === 'wrong stop') depart.snapshot.confirmedLoad.pallets[1].deliveryIndex = 2
|
|
if (fault === 'wrong receipt') depart.snapshot.externalBookings[0].receipt.source.pallets[0].packages = 8
|
|
if (fault === 'wrong weights') shares[0].palletSpaces = 3
|
|
function summary() {
|
|
return { departureVersion: fault === 'wrong departure' ? 8 : 7, manifestHash: 'original-manifest',
|
|
allocationMethod: 'SAME_ROUTE_PALLET_SPACES', allocationBlockReason: null,
|
|
totals: ['CNY', 'USD'].filter(currency => rows.some(row => row.details.currency === currency)).map(currency => {
|
|
const amount = rows.filter(row => row.details.currency === currency).reduce((sum, row) => sum + Math.round(Number(row.details.amount) * 100), 0)
|
|
// Hard-coded independently calculated expected cent splits for 2:1 physical spaces.
|
|
const own = currency === 'USD' ? 871 : amount === 10003 ? 6669 : 6667
|
|
const external = currency === 'USD' ? 436 : 3334
|
|
return { currency, directCost: amount / 100, ownCost: own / 100, externalCost: external / 100, allocations: [
|
|
{ businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, amount: own / 100 },
|
|
{ businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: fault === 'external counted own' ? 'OWN' : 'EXTERNAL',
|
|
palletSpaces: 1, amount: (external + (fault === 'lost cent' ? 1 : 0)) / 100 }] }
|
|
}) }
|
|
}
|
|
const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'request', traceId: 'trace' })
|
|
const listing = () => ({ page: { records: copy(rows), total: rows.length, page: 1, size: 100 }, summary: summary() })
|
|
function audit(row, action) {
|
|
const history = histories.get(row.costNo) || []
|
|
history.push({ recordVersion: row.recordVersion, action, reason: 'controlled audit', occurredAt: new Date().toISOString(),
|
|
snapshot: { cost: copy(row), summary: summary() } })
|
|
histories.set(row.costNo, history)
|
|
}
|
|
const context = { marker: 'CURRENT-RUN', token: 'offline-writer', createdResources: { fleetTrips: [trip],
|
|
fleetAcceptance: { marker: 'CURRENT-RUN', tripNo: trip.tripNo,
|
|
departure: { tripNo: trip.tripNo, status: 'IN_TRANSIT', departedAt: depart.snapshot.trip.departedAt } } },
|
|
assert(_module, label, _response, condition) { assert.ok(condition, label) }, async checkpoint() {},
|
|
async request(method, path, body, token) {
|
|
calls.push({ method, path, body: copy(body), actor: token })
|
|
if (path.endsWith('/authorization/permissions')) return response((token === 'offline-writer' || fault === 'reader is admin'
|
|
? ['list', 'cost-create', 'cost-edit'] : token === 'offline-reader' ? ['list'] : []).map(name => prefix + name))
|
|
const denied = token === 'offline-denied' || (token === 'offline-reader' && method !== 'GET')
|
|
if (denied && fault !== 'permission bypass') return fault === 'denial is outage' ? response(null, 'S0503', 503) : response(null, 'A0403', 403)
|
|
if (method === 'GET' && path.includes('/history') && !path.includes('/costs')) return response({ records: [depart], total: 1 })
|
|
if (method === 'GET' && path.includes('/history')) {
|
|
const costNo = path.split('/costs/')[1].split('/')[0], history = copy(histories.get(costNo))
|
|
if (fault === 'history overwritten') history[0].snapshot.cost.details.amount = '100.03'
|
|
if (fault === 'history summary drift') history[0].snapshot.summary.totals[0].directCost = 999
|
|
return response({ records: history, total: history.length })
|
|
}
|
|
if (method === 'GET') return response(listing())
|
|
if (method === 'POST') {
|
|
const saved = keys.get(body.idempotencyKey)
|
|
if (saved) {
|
|
if (JSON.stringify(saved.details) !== JSON.stringify(body.details)) return response(null, 'A0409')
|
|
if (fault === 'replay duplicate') { const duplicate = { ...copy(saved.row), costNo: `TC-${rows.length + 1}` }; rows.push(duplicate); return response(duplicate) }
|
|
if (fault === 'creation overwrites correction') saved.row.details = copy(body.details)
|
|
return response(saved.row)
|
|
}
|
|
const row = { costNo: `TC-${rows.length + 1}`, tripNo: trip.tripNo, recordVersion: 0, details: copy(body.details) }
|
|
rows.push(row); keys.set(body.idempotencyKey, { details: copy(body.details), row }); audit(row, 'CREATE'); return response(row)
|
|
}
|
|
assert.equal(method, 'PUT')
|
|
const row = rows.find(item => path.endsWith(item.costNo)); assert.ok(row)
|
|
if (Number(body.recordVersion) !== row.recordVersion && fault !== 'stale accepted') return response(null, 'A0409')
|
|
if (JSON.stringify(row.details) === JSON.stringify(body.details) && fault !== 'noop audit') return response(row)
|
|
row.details = copy(body.details); row.recordVersion++; audit(row, 'UPDATE'); return response(row)
|
|
} }
|
|
return { context, trip, inputs, calls, rows }
|
|
}
|
|
|
|
test('physical DEPART costs include original currencies, correction, immutable history and three permission actors', async () => {
|
|
const f = fixture(), proof = await verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)
|
|
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.expected.finalCny, '100.03')
|
|
assert.equal(proof.final.summary.totals.find(row => row.currency === 'CNY').ownCost, 66.69)
|
|
assert.deepEqual(proof.coverage.sourceTypes, ['OWN', 'EXTERNAL'])
|
|
assert.ok(f.calls.every(call => call.path.includes('/fleet/trips/FT-CURRENT') || call.path.endsWith('/authorization/permissions')))
|
|
assert.ok(!JSON.stringify(proof).includes('offline-reader')); assert.ok(!JSON.stringify(proof).includes('offline-denied'))
|
|
assert.ok(proof.requests.filter(row => row.method !== 'GET').every(row => row.body))
|
|
const count = f.calls.length
|
|
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs), /重复开始/)
|
|
assert.equal(f.calls.length, count)
|
|
})
|
|
|
|
test('cost expectation uses stable largest remainder and conserves every cent including tie and zero', () => {
|
|
const shares = ['B', 'A', 'C'].map(businessNo => ({ businessNo, sourceType: 'OWN', dispatchNo: businessNo, palletSpaces: 1 }))
|
|
assert.deepEqual(expectedAllocations('0.02', shares).map(row => [row.businessNo, row.cents]), [['A', '1'], ['B', '1'], ['C', '0']])
|
|
assert.deepEqual(expectedAllocations('0', shares).map(row => row.cents), ['0', '0', '0'])
|
|
assert.equal(TRIP_COST_EXPECTATIONS.finalCny, '100.03')
|
|
})
|
|
|
|
for (const fault of ['unbound external', 'wrong stop', 'wrong receipt', 'wrong weights', 'wrong departure', 'external counted own',
|
|
'lost cent', 'reader is admin', 'denial is outage', 'permission bypass', 'history overwritten', 'history summary drift',
|
|
'replay duplicate', 'creation overwrites correction', 'stale accepted', 'noop audit']) {
|
|
test(`rejects ${fault} and preserves evidence without cleanup`, async () => {
|
|
const f = fixture(fault)
|
|
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs))
|
|
assert.equal(f.trip.costAcceptance.status, 'FAILED')
|
|
assert.ok(f.calls.every(call => call.method !== 'DELETE'))
|
|
})
|
|
}
|
|
|
|
test('missing current-run membership or permission session fails before HTTP', async () => {
|
|
for (const mutate of [f => { f.inputs.deniedToken = '' }, f => { f.context.createdResources.fleetTrips = [] },
|
|
f => { f.context.createdResources.fleetAcceptance.marker = 'OLD' }, f => { f.inputs.shares = [] }]) {
|
|
const f = fixture(); mutate(f)
|
|
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)); assert.equal(f.calls.length, 0)
|
|
}
|
|
})
|
|
|
|
test('unknown HTTP outcome retains original key and does not retry or hide failure', async () => {
|
|
const f = fixture(), original = f.context.request
|
|
f.context.request = async (...args) => {
|
|
if (args[0] === 'POST' && args[3] === f.context.token) throw new Error('controlled network loss')
|
|
return original(...args)
|
|
}
|
|
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs), /network loss/)
|
|
const entry = f.trip.costAcceptance.requests.at(-1)
|
|
assert.equal(entry.status, 'UNKNOWN_RESULT'); assert.ok(entry.body.idempotencyKey)
|
|
assert.equal(f.rows.length, 0)
|
|
})
|