210 lines
17 KiB
JavaScript
210 lines
17 KiB
JavaScript
import { exportWorkbook } from './download-center-fixture.mjs'
|
|
import { parseXlsx } from './finance-downloads.mjs'
|
|
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { run, runQuoteSession, verifyOperations, requireCurrentFixture, fixtureContract } from './fleet-quotes-operations.mjs'
|
|
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
|
|
|
|
const copy = value => structuredClone(value)
|
|
test('fixed quote supplier is reused without creation or deactivation', async () => {
|
|
const { context } = harness(), request = context.request.bind(context), writes = []
|
|
context.options = { fixedCustomerId: '101' }
|
|
const supplier = { id: '901', name: 'Existing', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }
|
|
context.request = async (method, path, ...args) => {
|
|
if (path.includes('/suppliers')) {
|
|
if (method !== 'GET') writes.push({ method, path })
|
|
return { http: 200, body: { code: '200', data: path.includes('/901') ? supplier : { records: [supplier], total: 1 } } }
|
|
}
|
|
return request(method, path, ...args)
|
|
}
|
|
await run(context)
|
|
assert.equal(context.createdResources.fleetQuoteAcceptance.supplierReused, true)
|
|
assert.deepEqual(writes, [])
|
|
})
|
|
function harness(fault) {
|
|
const marker = 'FLEET-CURRENT-RUN', tripNo = 'TR-CURRENT', departedAt = '2026-10-03T01:00:00Z'
|
|
const rows = new Map(), histories = new Map(), commands = new Map(), measuredKeys = new Map(), calls = []
|
|
let next = 0, supplierStatus = 'ACTIVE'
|
|
let measured = { tripNo, recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] } }
|
|
const measuredHistory = []
|
|
const exportTasks = new Map()
|
|
const expected = { physicalPallets: 1, palletSpaces: 2, ownPackages: 11, externalPackages: 4, externalAgreedAmounts: [{ currency: 'USD', amount: 0 }], costs: [] }
|
|
const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'offline-request', traceId: 'offline-trace' })
|
|
function snapshot(row, action) { histories.get(row.quoteNo).unshift({ action, recordVersion: row.recordVersion, snapshot: copy(row) }) }
|
|
const natural = d => JSON.stringify([d.kind, d.partyId, d.originCity, d.destinationCode, d.distanceMiles, d.currency, d.validFrom, d.validTo])
|
|
function create(request, batchHash = '') {
|
|
const hash = batchHash + JSON.stringify(request.details), prior = commands.get(request.idempotencyKey)
|
|
if (prior) return prior.hash === hash ? response(fault === 'mutable creation replay' ? rows.get(prior.row.quoteNo) : prior.row) : response(null, 'A0409')
|
|
const d = request.details
|
|
if ([d.selfPickupPrice, d.fullDeliveryPrice, d.looseDeliveryPrice].every(v => v == null)) return response(null, 'A0400')
|
|
if ([...rows.values()].some(r => natural(r.details) === natural(d))) return response(null, 'A0409')
|
|
const row = { quoteNo: `FQ-${++next}`, recordVersion: '0', details: copy(d), partyName: '本轮往来单位', status: 'ACTIVE' }
|
|
if (fault === 'null price becomes zero') row.details.selfPickupPrice = 0
|
|
rows.set(row.quoteNo, row); histories.set(row.quoteNo, []); snapshot(row, 'CREATE'); commands.set(request.idempotencyKey, { hash, row: copy(row) }); return response(row)
|
|
}
|
|
const context = {
|
|
marker, context: { fleetQuotesOperationsFixture: { marker, stage: fixtureContract.stage, departureVerified: true, tripNo, warehouseId: '92', departedAt, expected } },
|
|
createdResources: { merchantQualificationFixture: { marker, tenantId: '101' } },
|
|
assert(_module, label, _response, condition) { assert.ok(condition, label) },
|
|
apiSuccess(_module, label, result) { assert.equal(result.http, 200, label); assert.equal(result.body.code, '200', label); return result.body.data },
|
|
async request(method, path, body) {
|
|
calls.push({ method, path, body: copy(body) }); const u = new URL(path, 'http://offline.invalid'), p = u.pathname
|
|
if (p === '/api/platform/v1/files/export-tasks' && method === 'POST') {
|
|
const taskId = String(exportTasks.size + 1)
|
|
exportTasks.set(taskId, body)
|
|
return response({ id: taskId, status: 'PENDING' })
|
|
}
|
|
if (p.startsWith('/api/platform/v1/files/export-tasks/')) {
|
|
const task = exportTasks.get(p.split('/').at(-1))
|
|
const failed = task.parameters.kind === 'INVALID' || (task.parameters.from && Date.parse(task.parameters.from) > Date.parse(task.parameters.to))
|
|
return response({ id: p.split('/').at(-1), status: failed ? 'FAILED' : 'SUCCEEDED', failureMessage: failed ? '筛选参数无效' : null })
|
|
}
|
|
if (p === '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT') return response({ trip: { tripNo, warehouseId: '92', departedAt, status: 'IN_TRANSIT', remark: fault === 'historical trip' ? 'OLD' : marker } })
|
|
if (p === '/api/platform/v1/suppliers' && method === 'POST') {
|
|
for (const field of ['name', 'region', 'legalRepresentative', 'contactName', 'phone', 'settlementPeriod']) assert.ok(body[field], field)
|
|
assert.equal(body.truckingScope, 'OVERSEAS'); return response('901')
|
|
}
|
|
if (p === '/api/platform/v1/suppliers/901/status') { supplierStatus = body.status; return response(null) }
|
|
if (p === '/api/platform/v1/suppliers/901') return response({ id: '901', status: supplierStatus })
|
|
if (p === '/api/platform/v1/last-leg/fleet/quotes/import') {
|
|
const backup = { rows: copy(rows), histories: copy(histories), commands: copy(commands) }, result = []
|
|
for (let index = 0; index < body.rows.length; index++) {
|
|
const outcome = create({ idempotencyKey: `${body.idempotencyKey}:${index + 1}`, details: body.rows[index] }, JSON.stringify(body.rows))
|
|
if (outcome.body.code !== '200') {
|
|
if (fault !== 'partial import leak') { rows.clear(); histories.clear(); commands.clear(); backup.rows.forEach((v, k) => rows.set(k, v)); backup.histories.forEach((v, k) => histories.set(k, v)); backup.commands.forEach((v, k) => commands.set(k, v)) }
|
|
return outcome
|
|
}
|
|
result.push(outcome.body.data)
|
|
}
|
|
return response({ importedRows: result.length, records: result })
|
|
}
|
|
if (p === '/api/platform/v1/last-leg/fleet/quotes') {
|
|
if (method === 'POST') return create(body)
|
|
let records = [...rows.values()]
|
|
for (const field of ['kind', 'status']) if (u.searchParams.get(field)) records = records.filter(r => (field === 'kind' ? r.details.kind : r.status) === u.searchParams.get(field))
|
|
const keyword = u.searchParams.get('keyword') || ''; records = records.filter(r => [r.details.originCity, r.details.destinationCode, r.quoteNo].some(v => v.includes(keyword)))
|
|
if (fault === 'inactive leaks into active list' && u.searchParams.get('status') === 'ACTIVE') records.push([...rows.values()].find(r => r.status === 'INACTIVE'))
|
|
return response({ records, total: String(records.length), page: '1', size: '200', pages: '1' })
|
|
}
|
|
const quote = p.match(/\/quotes\/(FQ-\d+)(?:\/(history|status))?$/)
|
|
if (quote) {
|
|
const row = rows.get(quote[1]); assert.ok(row)
|
|
if (method === 'GET') return response(quote[2] === 'history' ? histories.get(row.quoteNo) : row)
|
|
if (String(body.recordVersion) !== row.recordVersion) {
|
|
if (fault === 'stale write accepted' && method === 'PUT') { row.details = copy(body.details); return response(row) }
|
|
return response(null, 'A0409')
|
|
}
|
|
if (method === 'PUT') row.details = copy(body.details); else row.status = body.status
|
|
row.recordVersion = String(Number(row.recordVersion) + 1); snapshot(row, method === 'PUT' ? 'UPDATE' : body.status); return response(row)
|
|
}
|
|
if (p === '/api/platform/v1/last-leg/fleet/operations/TR-CURRENT/record/history') return response({ records: measuredHistory, total: String(measuredHistory.length), page: '1', size: '20', pages: '1' })
|
|
if (p === '/api/platform/v1/last-leg/fleet/operations/TR-CURRENT/record') {
|
|
if (method === 'GET') return response(measured)
|
|
const prior = measuredKeys.get(body.idempotencyKey)
|
|
if (prior) { if (fault === 'measured replay adds history') measuredHistory.push(copy(measured)); return response(prior) }
|
|
if (String(body.recordVersion) !== measured.recordVersion) return response(null, 'A0409')
|
|
assert.deepEqual(body.details.evidenceFileIds, []); assert.equal(Object.hasOwn(body.details, 'evidenceFiles'), false)
|
|
const { evidenceFileIds, ...values } = body.details
|
|
measured = { tripNo, recordVersion: String(Number(measured.recordVersion) + 1), details: { ...copy(values), evidenceFiles: [] } }
|
|
measuredKeys.set(body.idempotencyKey, copy(measured)); measuredHistory.unshift(copy(measured)); return response(measured)
|
|
}
|
|
if (p === '/api/platform/v1/last-leg/fleet/operations') {
|
|
assert.equal(u.searchParams.get('keyword'), tripNo); assert.equal(u.searchParams.get('warehouseId'), '92')
|
|
const metrics = { physicalPallets: 1, palletSpaces: 2, ownPackages: 11, externalPackages: 4, externalAgreedAmounts: [{ currency: 'USD', amount: '0.00' }] }
|
|
const row = { tripNo, ...metrics, measured: measured.details, financeStatus: 'NOT_QUERIED', costs: { totals: [] }, costStatus: fault === 'unknown cost becomes zero' ? 'AVAILABLE' : 'NOT_RECORDED' }
|
|
return response({ page: { records: [row], total: '1' }, summary: { trips: fault === 'counts destinations as trips' ? '2' : '1', ...metrics, costs: { visible: true, unrecordedTrips: 1 } }, charts: { physicalPallets: [{ points: [{ value: 1 }] }] } })
|
|
}
|
|
throw new Error(`Unexpected offline HTTP: ${method} ${path}`)
|
|
},
|
|
async requestBinary(method, path) {
|
|
calls.push({ method, path }); const u = new URL(path, 'http://offline.invalid')
|
|
assert.match(u.pathname, /files\/export-tasks\/[^/]+\/download$/)
|
|
if (fault === 'json export masquerades as csv') return { http: 200, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bytes: Buffer.from('{"code":"A0400"}') }
|
|
const task = exportTasks.get(u.pathname.split('/').at(-2))
|
|
const bytes = task.exportType === 'FLEET_QUOTE_LIST' ? exportWorkbook(['报价号'], [[...rows.keys()].join(' / ')]) : exportWorkbook(['车次号', '实际里程(mile)'], [tripNo, '25.5'])
|
|
return { http: 200, body: null, bytes, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', requestId: 'offline-request', traceId: 'offline-trace' }
|
|
},
|
|
}
|
|
return { context, rows, calls, supplierStatus: () => supplierStatus }
|
|
}
|
|
|
|
test('offline full flow checks quotes, atomic import, current departure and measured history without live network', async () => {
|
|
const { context, rows, calls, supplierStatus } = harness(); await run(context); await verifyOperations(context, { writeMeasured: true, idempotencyNamespace: 'OPERATIONS-INDEPENDENT-KEY' })
|
|
const proof = context.createdResources.fleetQuoteAcceptance
|
|
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.financePayment, 'NOT_TESTED'); assert.equal(proof.upstreamDeparture, 'NOT_TESTED')
|
|
assert.equal(proof.operationsStatus, 'NOT_TESTED'); assert.equal(proof.quoteStatus, 'VERIFIED')
|
|
assert.equal(context.createdResources.fleetOperationsAcceptance.operationsStatus, 'VERIFIED'); assert.equal(context.createdResources.fleetOperationsAcceptance.quoteStatus, 'NOT_TESTED')
|
|
assert.equal(rows.size, 4); assert.ok([...rows.values()].every(r => r.status === 'INACTIVE')); assert.equal(supplierStatus(), 'INACTIVE')
|
|
assert.ok(!calls.some(call => call.method === 'DELETE')); assert.ok(!JSON.stringify(proof).includes('password'))
|
|
})
|
|
for (const fault of ['null price becomes zero', 'mutable creation replay', 'partial import leak', 'inactive leaks into active list', 'stale write accepted', 'counts destinations as trips', 'unknown cost becomes zero', 'measured replay adds history', 'json export masquerades as csv', 'historical trip']) {
|
|
test(`verifier fails closed for ${fault}`, async () => {
|
|
const { context } = harness(fault)
|
|
const operations = ['counts destinations as trips', 'unknown cost becomes zero', 'measured replay adds history', 'historical trip'].includes(fault)
|
|
await assert.rejects(operations ? verifyOperations(context, { writeMeasured: true, idempotencyNamespace: 'INDEPENDENT-KEY' }) : run(context))
|
|
assert.equal(context.createdResources[operations ? 'fleetOperationsAcceptance' : 'fleetQuoteAcceptance'].status, 'FAILED')
|
|
})
|
|
}
|
|
test('missing or stale fixture fails before any HTTP and cannot be treated as skipped success', async () => {
|
|
for (const alter of [c => delete c.context.fleetQuotesOperationsFixture, c => { c.context.fleetQuotesOperationsFixture.marker = 'OLD' }, c => { c.context.fleetQuotesOperationsFixture.departureVerified = false }]) {
|
|
const { context, calls } = harness(); alter(context); await assert.rejects(verifyOperations(context), /current-run/); assert.equal(calls.length, 0); assert.equal(context.createdResources.fleetOperationsAcceptance.status, 'FAILED')
|
|
}
|
|
const { context, calls } = harness(); context.createdResources.merchantQualificationFixture.marker = 'OLD'
|
|
await assert.rejects(run(context), /current-run/); assert.equal(calls.length, 0)
|
|
})
|
|
test('proof cannot be overwritten on rerun and CSV detector rejects misleading success content', async () => {
|
|
const { context } = harness(); await run(context); const proof = context.createdResources.fleetQuoteAcceptance
|
|
await assert.rejects(run(context), /Existing proof/); assert.equal(context.createdResources.fleetQuoteAcceptance, proof)
|
|
assert.throws(() => parseXlsx(Buffer.from('{"code":"A0403"}')), /ZIP/)
|
|
assert.throws(() => requireCurrentFixture({ marker: 'x' }), /current-run/)
|
|
})
|
|
test('unknown expected money is rejected before HTTP instead of comparing as zero', async () => {
|
|
const { context, calls } = harness(); context.context.fleetQuotesOperationsFixture.expected.externalAgreedAmounts[0].amount = null
|
|
await assert.rejects(verifyOperations(context), /never convert unknown to zero/); assert.equal(calls.length, 0)
|
|
})
|
|
test('quote acceptance is independent of DEPART and never requests operations, inventory or finance', async () => {
|
|
const { context, calls } = harness(); delete context.context.fleetQuotesOperationsFixture
|
|
await run(context)
|
|
assert.equal(context.createdResources.fleetQuoteAcceptance.scope, 'QUOTES_ONLY')
|
|
assert.equal(context.createdResources.fleetQuoteAcceptance.operationsStatus, 'NOT_TESTED')
|
|
assert.ok(calls.every(call => !/operations|inventory|finance|\/trips\//.test(call.path)))
|
|
})
|
|
test('operations only reads business data and creates download tasks, never customer, supplier or quote records', async () => {
|
|
const { context, calls, rows } = harness(); delete context.createdResources.merchantQualificationFixture
|
|
await verifyOperations(context)
|
|
assert.ok(calls.every(call => call.method === 'GET' || (call.method === 'POST' && call.path === '/api/platform/v1/files/export-tasks' && call.body.exportType === 'TRIP_OPERATIONS_LIST'))); assert.equal(rows.size, 0)
|
|
assert.equal(context.createdResources.fleetOperationsAcceptance.measuredStatus, 'NOT_TESTED')
|
|
})
|
|
test('measured writes require an explicit independent idempotency namespace', async () => {
|
|
const { context, calls } = harness(); await assert.rejects(verifyOperations(context, { writeMeasured: true }), /independent idempotency/)
|
|
assert.equal(calls.length, 0)
|
|
})
|
|
test('independent quote session logs out after a failed quote scenario and persists scoped failure without secrets', async () => {
|
|
const offline = harness('null price becomes zero')
|
|
const context = new AcceptanceRun({ environment: 'test', gateway: 'http://offline.invalid', username: 'test-user', password: 'synthetic-password', quiet: true })
|
|
const authCalls = []
|
|
context.request = async (method, path, body) => {
|
|
if (path.startsWith('/api/auth/')) {
|
|
authCalls.push(path)
|
|
if (path.endsWith('/logout')) { assert.equal(body.refreshToken, 'synthetic-refresh'); return { http: 200, body: { code: '200', data: null } } }
|
|
return { http: 200, body: { code: '200', data: { accessToken: 'synthetic-access', refreshToken: 'synthetic-refresh' } } }
|
|
}
|
|
return offline.context.request(method, path, body)
|
|
}
|
|
context.requestBinary = offline.context.requestBinary
|
|
const report = await runQuoteSession(context, { marker: 'FLEET-QUOTE-20261003', prepareCustomer: async (current, options) => {
|
|
assert.equal(options.publishEnvironment, false)
|
|
current.createdResources.merchantQualificationFixture = { marker: current.marker, tenantId: '101' }
|
|
} })
|
|
assert.deepEqual(authCalls, ['/api/auth/login', '/api/auth/logout'])
|
|
assert.equal(report.success, false); assert.equal(report.loggedOut, true); assert.equal(report.operationsStatus, 'NOT_TESTED')
|
|
assert.equal(report.createdResources.fleetQuoteAcceptance.status, 'FAILED')
|
|
assert.equal(context.token, ''); assert.equal(context.refreshToken, '')
|
|
assert.ok(!/synthetic-password|synthetic-access|synthetic-refresh/.test(JSON.stringify(report)))
|
|
})
|
|
test('session helper rejects a borrowed login before touching HTTP', async () => {
|
|
const context = new AcceptanceRun({ environment: 'test', quiet: true }); context.token = 'already-logged-in'
|
|
context.request = () => { throw new Error('must not run') }
|
|
await assert.rejects(runQuoteSession(context), /fresh TEST context/)
|
|
})
|