344 lines
19 KiB
JavaScript
344 lines
19 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import test from 'node:test'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import { EXCEPTION_CODES, NODE_SEQUENCES, QUERY_STATUSES, STANDARD_NODES,
|
|
manualNodePayload, markerOwnedCarrierQuery, run, runUntilPickupReady } from './phase4-carrier-nodes.mjs'
|
|
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8')
|
|
const BASE = '/api/platform/v1/carrier-schedule-queries'
|
|
const QUERY_ID = '9007199254740993001'
|
|
const CUSTOMER_ID = '9007199254740993002'
|
|
const OCEAN_ID = '9007199254740993003'
|
|
const CONTAINER_ID = '9007199254740993004'
|
|
const MARKER = 'T-PHASE4'
|
|
|
|
function ok(data, requestId = 'req-ok') {
|
|
return { http: 200, body: { code: '200', data, requestId }, requestId, traceId: 'trace-ok' }
|
|
}
|
|
function conflict(requestId, message = 'conflict') {
|
|
return { http: 200, body: { code: 'A0409', message, requestId }, requestId, traceId: 'trace-conflict' }
|
|
}
|
|
function clone(value) { return structuredClone(value) }
|
|
|
|
test('focused carrier node execution injects the complete outbound-confirm producer chain', () => {
|
|
const modules = normalizeModuleOrder(['phase4-carrier-nodes'])
|
|
const required = ['merchant-qualification-fixture', 'orders', 'inbound-measurement',
|
|
'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate',
|
|
'phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-port-schedules',
|
|
'truck-appointments', 'service-center-flow', 'phase3-outbound-dispatch',
|
|
'phase3-carrier-schedule-queries', 'phase4-carrier-pickup-ready',
|
|
'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase5-terminal-drayage',
|
|
'phase4-carrier-nodes']
|
|
for (const module of required) assert.ok(modules.includes(module), module)
|
|
for (let index = 1; index < required.length; index += 1) {
|
|
assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index])
|
|
}
|
|
})
|
|
|
|
test('real lifecycle opens the shipping gate before PDA and delegates pickup and empty return to terminal drayage', () => {
|
|
const modules = normalizeModuleOrder(['phase4-overseas-pda', 'phase4-carrier-nodes'])
|
|
const pickup = modules.indexOf('phase4-carrier-pickup-ready')
|
|
const pda = modules.indexOf('phase4-overseas-pda')
|
|
const drayage = modules.indexOf('phase5-terminal-drayage')
|
|
const completion = modules.indexOf('phase4-carrier-nodes')
|
|
assert.ok(pickup >= 0 && pickup < pda)
|
|
assert.ok(pda < drayage && drayage < completion)
|
|
assert.match(source, /phase4CarrierPickupReady/)
|
|
assert.match(source, /phase5TerminalDrayage/)
|
|
assert.doesNotMatch(source, /PDA完成后补录实际节点/)
|
|
})
|
|
|
|
test('canonical states, ten ordered nodes, exception codes and complete payload are frozen', () => {
|
|
assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED'])
|
|
assert.deepEqual(STANDARD_NODES, [
|
|
'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT',
|
|
'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY',
|
|
'PICKED_UP', 'EMPTY_RETURNED',
|
|
])
|
|
assert.deepEqual(Object.values(NODE_SEQUENCES), [10, 20, 30, 40, 50, 60, 70, 80, 90, 100])
|
|
assert.deepEqual(EXCEPTION_CODES, ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD'])
|
|
assert.deepEqual(Object.keys(manualNodePayload({ expectedVersion: 7, standardNode: 'PICKED_UP',
|
|
observedAt: '2026-08-24T12:00:00', actualAt: '2026-08-24T11:59:00', marker: MARKER })),
|
|
['expectedVersion', 'standardNode', 'observedAt', 'estimatedAt', 'actualAt',
|
|
'locationCode', 'locationName', 'reason'])
|
|
})
|
|
|
|
test('actual node timestamps advance from the frozen outbound-confirm fact instead of the runner timezone', () => {
|
|
assert.match(source, /Date\.parse\(`\$\{frozenLatestActual\}Z`\)/)
|
|
assert.match(source, /frozenLatestActualMs\) \? Date\.now\(\) : frozenLatestActualMs/)
|
|
assert.equal((source.match(/observedAt: localWallIso\(new Date\(\)\)/g) || []).length, 3)
|
|
assert.match(source, /const observedAt = localWallIso\(new Date\(\)\)/)
|
|
})
|
|
|
|
test('module exposes no scheduler, attempt, internal or arbitrary stop endpoint', () => {
|
|
assert.match(source, /\/manual-nodes/)
|
|
assert.match(source, /'Idempotency-Key'/)
|
|
assert.doesNotMatch(source, /\/manual-results|\/attempts|\/scheduler|\/internal|\/pda|\/stop/)
|
|
assert.doesNotMatch(source, /\bfetch\s*\(/)
|
|
for (const method of ['PUT', 'PATCH', 'DELETE']) {
|
|
assert.doesNotMatch(source, new RegExp(`context\\.request\\(['"]${method}['"]`))
|
|
}
|
|
})
|
|
|
|
test('system-created carrier histories accept operator zero while user writes still require numeric identities', () => {
|
|
assert.match(source, /function validOperatorId\(value\) \{ return value === '0' \|\| validNumericId\(value\) \}/)
|
|
assert.match(source, /item\?\.operatorId == null \|\| validOperatorId\(item\.operatorId\)/)
|
|
})
|
|
|
|
test('ownership requires current marker, long query id and outbound-confirm source', () => {
|
|
const valid = { queryId: QUERY_ID, marker: MARKER, ownedByModule: true,
|
|
triggerSource: 'OUTBOUND_CONFIRM' }
|
|
assert.equal(markerOwnedCarrierQuery({ marker: MARKER,
|
|
createdResources: { phase3CarrierScheduleQuery: valid } }).queryId, QUERY_ID)
|
|
assert.equal(markerOwnedCarrierQuery({ marker: 'OLD',
|
|
createdResources: { phase3CarrierScheduleQuery: valid } }), null)
|
|
assert.equal(markerOwnedCarrierQuery({ marker: MARKER, createdResources: {
|
|
phase3CarrierScheduleQuery: { ...valid, queryId: '1' },
|
|
} }), null)
|
|
assert.equal(markerOwnedCarrierQuery({ marker: MARKER, createdResources: {
|
|
phase3CarrierScheduleQuery: { ...valid, triggerSource: 'MANUAL' },
|
|
} }), null)
|
|
})
|
|
|
|
test('missing terminal drayage completion is an explicit deployment blocker and performs no write', async () => {
|
|
const calls = []; const steps = []
|
|
const context = {
|
|
marker: MARKER, token: 'token', createdResources: {}, calls,
|
|
request: async (method, requestPath) => { calls.push({ method, path: requestPath }); throw new Error('unexpected request') },
|
|
apiSuccess: (_module, _name, response) => response.body.data,
|
|
assert: (_module, name, _response, condition, evidence) => assert.ok(condition, `${name}: ${evidence}`),
|
|
addStep: (_module, name, _response, passed, evidence, options) => steps.push({ name, passed, evidence, options }),
|
|
}
|
|
|
|
await run(context)
|
|
|
|
assert.deepEqual(calls, [])
|
|
assert.equal(steps.length, 1)
|
|
assert.equal(steps[0].passed, false)
|
|
assert.equal(steps[0].options.deploymentBlocker, true)
|
|
assert.match(steps[0].name, /取得本轮码头拖车还柜终态/)
|
|
})
|
|
|
|
function markerOwnedContext() {
|
|
let nextId = 9007199254740994000n
|
|
const calls = []; const steps = []; const assertions = []; const idempotency = new Map()
|
|
const task = {
|
|
id: QUERY_ID, queryNo: `CSQ${QUERY_ID}`, customerId: CUSTOMER_ID, oceanBookingId: OCEAN_ID,
|
|
containerBookingId: CONTAINER_ID, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO',
|
|
trackingNo: 'ZCSU7881970', status: 'READY', triggerSource: 'OUTBOUND_CONFIRM',
|
|
latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-08-24T08:00:00', version: 0,
|
|
lastSuccessAt: null, sourceUpdatedAt: '2026-08-24T08:00:00', expiresAt: null,
|
|
stoppedAt: null, stopReason: null,
|
|
}
|
|
const nodes = [{
|
|
id: String(nextId++), providerCode: 'FIRSTLEG', providerEventId: 'OUTBOUND_CONFIRM',
|
|
providerNodeCode: 'OUTBOUND_CONFIRM', standardNode: 'DEPARTED_WAREHOUSE', nodeSequence: 10,
|
|
observedAt: '2026-08-24T08:00:00', estimatedAt: null, actualAt: '2026-08-24T08:00:00',
|
|
locationCode: null, locationName: null, sourceReference: 'OUTBOUND_CONFIRM',
|
|
rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-24T08:00:01',
|
|
}]
|
|
const exceptions = [{
|
|
id: String(nextId++), providerCode: 'MATSON_PUBLIC_TRACKING', providerEventId: 'hold-1',
|
|
providerStatus: 'CONTAINER ON HOLD - Customs Clearance', exceptionCode: 'CUSTOMS_HOLD', active: true,
|
|
observedAt: '2026-08-24T08:01:00', locationCode: 'USLAX', locationName: 'Long Beach',
|
|
sourceReference: 'MATSON:MASKED', rawPayloadHash: 'b'.repeat(64), createdAt: '2026-08-24T08:01:01',
|
|
}]
|
|
const histories = [{
|
|
id: String(nextId++), action: 'AUTO_CREATE', fromVersion: null, toVersion: 0,
|
|
reason: '国内确认出仓自动创建', operatorId: '1000001', operatorName: 'fixture',
|
|
createdAt: '2026-08-24T08:00:01',
|
|
}]
|
|
const detail = () => ({ query: { query: clone(task), expired: true },
|
|
nodes: clone(nodes), exceptions: clone(exceptions), histories: clone(histories) })
|
|
const context = {
|
|
marker: MARKER, token: 'token', calls, steps,
|
|
createdResources: { phase3CarrierScheduleQuery: {
|
|
queryId: QUERY_ID, marker: MARKER, ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM',
|
|
customerId: CUSTOMER_ID, oceanBookingId: OCEAN_ID, containerBookingId: CONTAINER_ID,
|
|
trackingNo: task.trackingNo, version: 0,
|
|
} },
|
|
request: async (method, requestPath, body, _token, headers = {}) => {
|
|
calls.push(method === 'POST' ? { method, path: requestPath, body: clone(body), headers: clone(headers) }
|
|
: { method, path: requestPath })
|
|
if (method === 'GET' && requestPath === `${BASE}/carriers`) return ok([{
|
|
carrierCode: 'ZIM', carrierName: 'ZIM', officialUrl: 'https://api.zim.com/',
|
|
accessMode: 'OFFICIAL_API', automated: true, coverageGap: false,
|
|
}])
|
|
if (method === 'GET' && requestPath === `${BASE}?page=1&size=20`) return ok({ records: [{
|
|
query: { ...task, id: '9007199254740993999', trackingNo: 'MATU4646247' }, expired: false,
|
|
}], page: 1, size: 20, total: 2, pages: 1 })
|
|
if (method === 'GET' && requestPath === `${BASE}/${QUERY_ID}`) return ok(detail())
|
|
if (method === 'GET' && requestPath === `${BASE}/${QUERY_ID}/history`) return ok(clone(histories))
|
|
if (method !== 'POST' || requestPath !== `${BASE}/${QUERY_ID}/manual-nodes`) {
|
|
throw new Error(`unexpected ${method} ${requestPath}`)
|
|
}
|
|
const key = headers['Idempotency-Key']; const signature = JSON.stringify(body)
|
|
if (idempotency.has(key)) {
|
|
const previous = idempotency.get(key)
|
|
return previous.signature === signature ? ok(clone(previous.detail), `req-replay-${key}`)
|
|
: conflict(`req-hash-${key}`)
|
|
}
|
|
if (Number(body.expectedVersion) !== Number(task.version)) return conflict(`req-version-${key}`)
|
|
if (body.actualAt != null
|
|
&& NODE_SEQUENCES[body.standardNode] <= NODE_SEQUENCES[task.latestActualNode]) {
|
|
return conflict(`req-monotonic-${key}`,
|
|
'实际船节点必须按业务顺序向后推进')
|
|
}
|
|
const providerEventId = `manual-${String(nextId)}`
|
|
nodes.push({
|
|
id: String(nextId++), providerCode: 'MANUAL', providerEventId, providerNodeCode: body.standardNode,
|
|
standardNode: body.standardNode, nodeSequence: NODE_SEQUENCES[body.standardNode],
|
|
observedAt: body.observedAt, estimatedAt: body.estimatedAt, actualAt: body.actualAt,
|
|
locationCode: body.locationCode, locationName: body.locationName, sourceReference: `MANUAL:${CUSTOMER_ID}`,
|
|
rawPayloadHash: 'c'.repeat(64), createdAt: body.observedAt,
|
|
})
|
|
task.version += 1
|
|
if (body.actualAt != null) {
|
|
task.latestActualNode = body.standardNode; task.latestActualAt = body.actualAt
|
|
if (body.standardNode === 'EMPTY_RETURNED') {
|
|
task.status = 'STOPPED'; task.stopReason = '已还柜'; task.stoppedAt = body.observedAt
|
|
} else task.status = 'READY'
|
|
}
|
|
histories.push({ id: String(nextId++), action: 'MANUAL_NODE', fromVersion: task.version - 1,
|
|
toVersion: task.version, reason: body.reason, operatorId: '1000001', operatorName: 'fixture',
|
|
createdAt: body.observedAt })
|
|
const saved = detail(); idempotency.set(key, { signature, detail: clone(saved) })
|
|
return ok(saved, `req-${key}`)
|
|
},
|
|
apiSuccess: (_module, _name, response) => {
|
|
assert.equal(response.http, 200); assert.equal(response.body.code, '200'); return response.body.data
|
|
},
|
|
assert: (_module, name, response, condition, evidence, options) => {
|
|
assertions.push({ name, passed: Boolean(condition), evidence, options, requestId: response?.requestId || null })
|
|
assert.ok(condition, `${name}: ${evidence}`)
|
|
},
|
|
addStep: (_module, name, response, passed, evidence, options) => {
|
|
steps.push({ name, passed, evidence, options, requestId: response?.requestId || null })
|
|
assert.ok(passed, `${name}: ${evidence}`)
|
|
},
|
|
}
|
|
return { context, task, nodes, exceptions, histories, steps, assertions }
|
|
}
|
|
|
|
test('marker-owned pre-PDA chain covers idempotency conflicts through pickup-ready', async () => {
|
|
const fixture = markerOwnedContext()
|
|
|
|
await runUntilPickupReady(fixture.context)
|
|
|
|
const posts = fixture.context.calls.filter((item) => item.method === 'POST')
|
|
assert.equal(posts.length, 15)
|
|
assert.ok(posts.every((item) => item.path === `${BASE}/${QUERY_ID}/manual-nodes`))
|
|
assert.ok(posts.every((item) => item.headers['Idempotency-Key']))
|
|
assert.ok(posts.every((item) => Object.keys(item.body).length === 8))
|
|
assert.equal(fixture.steps.filter((item) => /A0409/.test(item.name)).length, 6)
|
|
assert.ok(fixture.assertions.some((item) => /重复当前实际节点A0409后task、node、history零漂移/.test(item.name)))
|
|
assert.ok(fixture.assertions.some((item) => /更早实际节点A0409后task、node、history零漂移/.test(item.name)))
|
|
assert.ok(fixture.steps.some((item) => /同级actual失败请求同键重试.*operation claim零残留/.test(item.name)))
|
|
assert.ok(fixture.steps.some((item) => /更早actual失败请求同键重试.*operation claim零残留/.test(item.name)))
|
|
assert.equal(fixture.task.status, 'READY')
|
|
assert.equal(fixture.task.latestActualNode, 'PICKUP_READY')
|
|
assert.equal(fixture.task.stopReason, null)
|
|
const actualNodes = new Set(fixture.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode))
|
|
assert.deepEqual([...actualNodes].sort(), [...STANDARD_NODES.slice(0, 8)].sort())
|
|
assert.equal(fixture.exceptions[0].exceptionCode, 'CUSTOMS_HOLD')
|
|
assert.ok(fixture.nodes.every((item) => /^\d{16,}$/.test(item.id)))
|
|
assert.equal(fixture.context.calls.some((item) => /attempts|scheduler|internal|\/stop/.test(item.path)), false)
|
|
const firstPageQuery = fixture.context.calls.find((item) => item.path === `${BASE}?page=1&size=20`)
|
|
assert.ok(firstPageQuery)
|
|
})
|
|
|
|
async function taskPageStructure(overrides) {
|
|
const fixture = markerOwnedContext()
|
|
const task = { ...fixture.task, ...overrides }
|
|
const checks = []
|
|
const originalRequest = fixture.context.request
|
|
fixture.context.createdResources = {}
|
|
fixture.context.request = async (method, requestPath, ...args) => {
|
|
if (method === 'GET' && requestPath === `${BASE}?page=1&size=20`) {
|
|
fixture.context.calls.push({ method, path: requestPath })
|
|
return ok({ records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 })
|
|
}
|
|
return originalRequest(method, requestPath, ...args)
|
|
}
|
|
fixture.context.assert = (_module, name, _response, condition) => checks.push({ name, passed: Boolean(condition) })
|
|
fixture.context.addStep = (_module, name, _response, passed) => checks.push({ name, passed })
|
|
await runUntilPickupReady(fixture.context)
|
|
assert.ok(fixture.context.calls.every((call) => call.method === 'GET'))
|
|
assert.ok(checks.some((check) => check.name.includes('缺少本轮phase3出仓CarrierScheduleQuery') && !check.passed))
|
|
return checks.find((check) => check.name === '船节点分页与长ID结构稳定')?.passed
|
|
}
|
|
|
|
test('task page permits explicit platform scope zero and positive customer scopes as serialized longs', async () => {
|
|
for (const customerId of ['0', '7', CUSTOMER_ID]) {
|
|
assert.equal(await taskPageStructure({ customerId }), true, `customerId=${customerId}`)
|
|
}
|
|
})
|
|
|
|
test('船节点分页接受已规范化的非 ISO 柜号,仍拒绝空值和未规范化输入', async () => {
|
|
for (const trackingNo of ['JZXH10231043', 'TEST-GRAY-CAB-NOISO', 'ZCSU7881971']) {
|
|
assert.equal(await taskPageStructure({ trackingNo }), true, trackingNo)
|
|
}
|
|
for (const trackingNo of ['', ' ', '\t', 'jzxh10231043', ' JZXH10231043', 'JZXH 10231043', null, undefined, 123]) {
|
|
assert.equal(await taskPageStructure({ trackingNo }), false, String(trackingNo))
|
|
}
|
|
})
|
|
|
|
test('非 ISO 柜号沿本轮归属核验完成船节点详情及人工节点链', async () => {
|
|
const fixture = markerOwnedContext()
|
|
fixture.task.trackingNo = 'TEST-GRAY-CAB-NOISO'
|
|
fixture.context.createdResources.phase3CarrierScheduleQuery.trackingNo = fixture.task.trackingNo
|
|
|
|
await runUntilPickupReady(fixture.context)
|
|
|
|
assert.equal(fixture.task.latestActualNode, 'PICKUP_READY')
|
|
assert.ok(fixture.assertions.every((check) => check.passed))
|
|
assert.ok(fixture.context.calls.filter((call) => call.method === 'POST')
|
|
.every((call) => call.path === `${BASE}/${QUERY_ID}/manual-nodes`))
|
|
})
|
|
|
|
test('task page still rejects negative, missing, numeric and noncanonical customer scopes', async () => {
|
|
for (const customerId of ['-1', '-9007199254740993002', '', '00', '01', '+0', '0.0', 'customer', null, undefined, 0, 7]) {
|
|
assert.equal(await taskPageStructure({ customerId }), false, `customerId=${String(customerId)}`)
|
|
}
|
|
})
|
|
|
|
test('allowing platform scope zero never relaxes positive long task, booking or container identifiers', async () => {
|
|
for (const field of ['id', 'oceanBookingId', 'containerBookingId']) {
|
|
for (const invalid of ['0', '0000000000000000', '09007199254740993', '-9007199254740993001', '7',
|
|
'9007199254740993.001', null, 9007199254740993]) {
|
|
assert.equal(await taskPageStructure({ customerId: '0', [field]: invalid }), false,
|
|
`${field}=${String(invalid)}`)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('platform scope zero completes the same detail and manual-node lifecycle assertions without changing business identifiers', async () => {
|
|
const fixture = markerOwnedContext()
|
|
fixture.task.customerId = '0'
|
|
fixture.context.createdResources.phase3CarrierScheduleQuery.customerId = '0'
|
|
|
|
await runUntilPickupReady(fixture.context)
|
|
|
|
assert.equal(fixture.task.customerId, '0')
|
|
assert.equal(fixture.task.id, QUERY_ID)
|
|
assert.equal(fixture.task.oceanBookingId, OCEAN_ID)
|
|
assert.equal(fixture.task.containerBookingId, CONTAINER_ID)
|
|
assert.equal(fixture.task.latestActualNode, 'PICKUP_READY')
|
|
assert.equal(fixture.context.calls.filter((call) => call.method === 'POST').length, 15)
|
|
assert.ok(fixture.assertions.every((check) => check.passed))
|
|
assert.ok(fixture.steps.every((check) => check.passed))
|
|
})
|
|
|
|
test('还柜终态拆分为节点覆盖与停止语义,人工节点下限扣除出仓自动节点和拖车回写节点', () => {
|
|
assert.match(source, /拖车业务回写后覆盖十标准实际节点/)
|
|
assert.match(source, /还柜终态停止语义与本轮货柜关联/)
|
|
assert.match(source, /STANDARD_NODES\.length - 2/)
|
|
assert.match(source, /stopReason=\$\{finalTask\.stopReason/)
|
|
assert.doesNotMatch(source, /覆盖十标准实际节点且EMPTY_RETURNED停止/)
|
|
})
|