149 lines
7.7 KiB
JavaScript
149 lines
7.7 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, QUERY_STATUSES, STANDARD_NODES, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs'
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8')
|
|
|
|
function response(data) { return { http: 200, body: { code: '200', data } } }
|
|
function contextFor(routes, createdResources = {}) {
|
|
const calls = []
|
|
return {
|
|
calls,
|
|
marker: 'T-PHASE4', token: 'token', createdResources,
|
|
request: async (method, requestPath, body, _token, headers = {}) => {
|
|
calls.push(method === 'POST' ? { method, path: requestPath, body, headers } : { method, path: requestPath })
|
|
assert.ok(['GET', 'POST'].includes(method))
|
|
assert.ok(Object.hasOwn(routes, requestPath), `unexpected path: ${requestPath}`)
|
|
return response(routes[requestPath])
|
|
},
|
|
apiSuccess: (_module, _name, result) => {
|
|
assert.equal(result.http, 200)
|
|
assert.equal(result.body.code, '200')
|
|
return result.body.data
|
|
},
|
|
assert: (_module, name, _result, condition, evidence) => {
|
|
assert.ok(condition, `${name}: ${evidence}`)
|
|
},
|
|
}
|
|
}
|
|
|
|
const task = {
|
|
id: '9007199254740993', queryNo: 'CSQ9007199254740993', customerId: '10001',
|
|
oceanBookingId: '20001', containerBookingId: '30001', carrierCode: 'COSCO',
|
|
trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567', status: 'STOPPED',
|
|
latestActualNode: 'PICKED_UP', latestActualAt: '2026-08-20T12:00:00', version: 5,
|
|
lastSuccessAt: '2026-08-20T12:00:00', sourceUpdatedAt: '2026-08-20T12:00:00',
|
|
expiresAt: null, stoppedAt: null, stopReason: null,
|
|
}
|
|
const node = {
|
|
id: '40001', providerCode: 'COSCO', providerEventId: 'event-40001', providerNodeCode: 'OUTGATE', standardNode: 'PICKED_UP',
|
|
nodeSequence: 90, observedAt: '2026-08-20T12:00:00', estimatedAt: null,
|
|
actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles',
|
|
sourceReference: null, rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T12:00:01',
|
|
}
|
|
const manualNode = { ...node, id: '40002', providerCode: 'MANUAL', providerEventId: 'manual-40002',
|
|
providerNodeCode: 'PICKED_UP', estimatedAt: '2026-08-20T12:00:00', actualAt: null, rawPayloadHash: 'b'.repeat(64) }
|
|
const exception = { id: '45001', providerCode: 'MATSON_PUBLIC_TRACKING', providerEventId: 'hold-1',
|
|
providerStatus: 'CONTAINER ON HOLD - Customs Clearance', exceptionCode: 'CUSTOMS_HOLD', active: true,
|
|
observedAt: '2026-08-20T12:00:00', locationCode: 'USLAX', locationName: 'Long Beach',
|
|
sourceReference: 'MATSON:9974749', rawPayloadHash: 'c'.repeat(64), createdAt: '2026-08-20T12:00:01' }
|
|
const history = {
|
|
id: '50001', action: 'STOP', fromVersion: 4, toVersion: 5,
|
|
reason: null, operatorId: null, operatorName: '系统', createdAt: '2026-08-20T12:01:00',
|
|
}
|
|
const carriers = [{
|
|
carrierCode: 'COSCO', carrierName: 'COSCO', officialUrl: 'https://example.invalid',
|
|
accessMode: 'INTERNAL_PROVIDER', automated: true, coverageGap: false,
|
|
}]
|
|
|
|
test('module only permits the documented manual-node write and forbids internal execution endpoints', () => {
|
|
assert.match(source, /context\.request\('GET'/)
|
|
assert.doesNotMatch(source, /\bfetch\s*\(/)
|
|
assert.match(source, /context\.request\('POST',[\s\S]*\/manual-nodes/)
|
|
assert.match(source, /'Idempotency-Key'/)
|
|
for (const method of ['PUT', 'PATCH', 'DELETE']) {
|
|
assert.doesNotMatch(source, new RegExp(`context\\.request\\(['"]${method}['"]`))
|
|
}
|
|
for (const endpoint of ['/scheduler', '/attempts', '/manual-results', '/internal', '/pda']) {
|
|
assert.doesNotMatch(source, new RegExp(endpoint.replace('/', '\\/'), 'i'))
|
|
}
|
|
})
|
|
|
|
test('canonical task node and exception sets are locked to the phase-4 contract', () => {
|
|
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(EXCEPTION_CODES, ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD'])
|
|
})
|
|
|
|
test('marker ownership requires explicit module ownership, query id and exact current marker', () => {
|
|
assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: {} }), null)
|
|
assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: {
|
|
phase3CarrierScheduleQuery: { queryId: '1', marker: 'OLD', ownedByModule: true },
|
|
} }), null)
|
|
assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: {
|
|
phase3CarrierScheduleQuery: { queryId: '1', marker: 'M', ownedByModule: false },
|
|
} }), null)
|
|
assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: {
|
|
phase3CarrierScheduleQuery: { queryId: '1', marker: 'M', ownedByModule: true },
|
|
} }).queryId, '1')
|
|
})
|
|
|
|
test('empty page validates read-only pagination without detail calls', async () => {
|
|
const context = contextFor({
|
|
'/api/platform/v1/carrier-schedule-queries/carriers': carriers,
|
|
'/api/platform/v1/carrier-schedule-queries?page=1&size=20': { records: [], page: 1, size: 20, total: 0, pages: 0 },
|
|
})
|
|
await run(context)
|
|
assert.deepEqual(context.calls, [
|
|
{ method: 'GET', path: '/api/platform/v1/carrier-schedule-queries/carriers' },
|
|
{ method: 'GET', path: '/api/platform/v1/carrier-schedule-queries?page=1&size=20' },
|
|
])
|
|
})
|
|
|
|
test('standalone existing historical task is read-only and never receives manual nodes', async () => {
|
|
const base = '/api/platform/v1/carrier-schedule-queries'
|
|
const context = contextFor({
|
|
[`${base}/carriers`]: carriers,
|
|
[`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 },
|
|
[`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], exceptions: [exception], histories: [history] },
|
|
[`${base}/${task.id}/history`]: [history],
|
|
})
|
|
await run(context)
|
|
assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'GET'])
|
|
assert.equal(context.calls.some((item) => item.method === 'POST'), false)
|
|
assert.equal(context.calls.at(-1).path, `${base}/${task.id}/history`)
|
|
})
|
|
|
|
test('phase4 writes only the exact current-marker query registered by phase3', async () => {
|
|
const base = '/api/platform/v1/carrier-schedule-queries'
|
|
const ownedTask = { ...task, id: '9007199254740994', queryNo: 'CSQ9007199254740994',
|
|
status: 'READY', trackingNo: 'TSTU1234567', version: 6 }
|
|
const resource = { queryId: ownedTask.id, marker: 'T-PHASE4', ownedByModule: true, trackingNo: ownedTask.trackingNo }
|
|
const context = contextFor({
|
|
[`${base}/carriers`]: carriers,
|
|
[`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 },
|
|
[`${base}/${ownedTask.id}`]: { query: { query: ownedTask, expired: false }, nodes: [node], exceptions: [exception], histories: [history] },
|
|
[`${base}/${ownedTask.id}/manual-nodes`]: { query: { query: { ...ownedTask, version: 7 }, expired: false },
|
|
nodes: [node, manualNode], exceptions: [exception], histories: [history] },
|
|
[`${base}/${ownedTask.id}/history`]: [history],
|
|
}, { phase3CarrierScheduleQuery: resource })
|
|
|
|
await run(context)
|
|
|
|
assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'POST', 'GET'])
|
|
const write = context.calls.find((item) => item.method === 'POST')
|
|
assert.equal(write.path, `${base}/${ownedTask.id}/manual-nodes`)
|
|
assert.equal(write.body.expectedVersion, ownedTask.version)
|
|
assert.equal(write.body.actualAt, null)
|
|
assert.equal(write.headers['Idempotency-Key'], `T-PHASE4-carrier-manual-${ownedTask.id}`)
|
|
})
|