test: harden carrier node HTTP acceptance
This commit is contained in:
@@ -1,17 +1,11 @@
|
||||
const MODULE = 'phase3-carrier-schedule-queries'
|
||||
const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/carrier-schedule-queries'
|
||||
const OCEAN = '/api/platform/v1/ocean-bookings'
|
||||
const AUTOMATED_CARRIERS = Object.freeze(['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE'])
|
||||
import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function data(response) { return codeOf(response) === '200' ? response.body.data : null }
|
||||
function localIso(value) { return value.toISOString().slice(0, 19) }
|
||||
function step(context, name, response, passed, evidence) {
|
||||
context.addStep(MODULE, name, response, passed, evidence, { ...OPTIONS, deploymentBlocker: !passed })
|
||||
}
|
||||
async function request(context, method, path, body, headers = {}) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
@@ -32,153 +26,87 @@ async function request(context, method, path, body, headers = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function currentOutboundFixture(context) {
|
||||
const loading = context?.createdResources?.phase3Loading
|
||||
const outbound = context?.createdResources?.phase3OutboundDispatch
|
||||
const marker = context?.marker
|
||||
const matches = loading?.marker === marker && outbound?.marker === marker
|
||||
&& id(loading?.containerBookingId) && id(loading?.containerBookingId) === id(outbound?.containerBookingId)
|
||||
&& id(loading?.oceanBookingId) && id(loading?.containerNo)
|
||||
&& outbound?.confirmedStatus === 'WAITING_FOR_LOADING'
|
||||
return matches ? {
|
||||
marker, customerId: id(outbound.tenantId || loading.tenantId), oceanBookingId: id(loading.oceanBookingId),
|
||||
containerBookingId: id(loading.containerBookingId), trackingNo: id(loading.containerNo),
|
||||
} : null
|
||||
}
|
||||
|
||||
export function ownsCarrierQuery(resource, marker) {
|
||||
return Boolean(resource?.ownedByModule && resource?.queryId && resource?.marker === marker)
|
||||
return Boolean(resource?.ownedByModule && resource?.queryId && resource?.marker === marker
|
||||
&& resource?.triggerSource === 'OUTBOUND_CONFIRM')
|
||||
}
|
||||
|
||||
export function markerTrackingNo(marker, explicit = process.env.ADMIN_TEST_CARRIER_CONTAINER_NO) {
|
||||
const configured = String(explicit || '').trim().toUpperCase()
|
||||
if (configured) return configured
|
||||
let hash = 0x811c9dc5
|
||||
for (const char of String(marker || 'carrier-marker')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193)
|
||||
return `TSTU${String(hash >>> 0).padStart(10, '0').slice(-7)}`
|
||||
function blocker(context, name, evidence) {
|
||||
context.addStep(MODULE, name, null, false, evidence, OPTIONS)
|
||||
}
|
||||
|
||||
function oceanDraft(context, suffix, master) {
|
||||
return {
|
||||
...oceanBookingDraft(context, suffix, master),
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
|
||||
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
|
||||
remark: context.marker,
|
||||
parties: [
|
||||
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
|
||||
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureOcean(context, suffix, master) {
|
||||
const existing = context.createdResources?.oceanBooking
|
||||
if (existing?.id && existing.marker === context.marker && id(existing.customerId) === id(master.customerId)) {
|
||||
return { id: id(existing.id), containerBookingId: null }
|
||||
}
|
||||
const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) },
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-query-ocean`.slice(0, 128) })
|
||||
const detail = context.apiSuccess(MODULE, '创建本轮船司查询隔离订舱', response, '', OPTIONS)
|
||||
return { id: id(detail?.oceanBooking?.id), containerBookingId: null }
|
||||
}
|
||||
|
||||
function nodeSnapshot(detail) {
|
||||
return (Array.isArray(detail?.nodes) ? detail.nodes : []).map((item) => ({
|
||||
id: id(item.id), providerCode: item.providerCode, providerEventId: item.providerEventId,
|
||||
standardNode: item.standardNode, observedAt: item.observedAt, estimatedAt: item.estimatedAt, actualAt: item.actualAt,
|
||||
}))
|
||||
}
|
||||
function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) }
|
||||
|
||||
export async function run(context) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询',
|
||||
'COSCO Shipping Open Platform 待正式凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF',
|
||||
'external-third-party')
|
||||
|
||||
const suffix = context.marker.replace(/\D/g, '').slice(-10)
|
||||
const carriersResponse = await request(context, 'GET', `${BASE}/carriers`)
|
||||
const carriers = context.apiSuccess(MODULE, '六家船司查询策略入口已部署', carriersResponse, '', OPTIONS)
|
||||
const carriers = context.apiSuccess(MODULE, '查询六家船司自动能力', carriersResponse, '', OPTIONS)
|
||||
const byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item]))
|
||||
context.assert(MODULE, '五家自动Provider与COSCO覆盖缺口精确', carriersResponse,
|
||||
['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code))
|
||||
&& AUTOMATED_CARRIERS.every((code) => byCode.get(code)?.automated === true
|
||||
&& byCode.get(code)?.coverageGap === false)
|
||||
&& byCode.get('COSCO')?.automated === false && byCode.get('COSCO')?.coverageGap === true
|
||||
&& byCode.get('COSCO')?.accessMode === 'OFFICIAL_API'
|
||||
&& byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED'
|
||||
&& [...byCode.values()].every((item) => item.officialUrl?.startsWith('http')),
|
||||
&& byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED',
|
||||
`automated=${AUTOMATED_CARRIERS.filter((code) => byCode.get(code)?.automated).join(',') || 'none'}`, OPTIONS)
|
||||
|
||||
const master = await resolveOceanBookingMaster(context, MODULE)
|
||||
if (!master) return
|
||||
const ocean = await ensureOcean(context, suffix, master)
|
||||
if (!ocean.id) return
|
||||
const trackingNo = markerTrackingNo(context.marker)
|
||||
const createBody = {
|
||||
expectedVersion: 0, customerId: master.customerId, oceanBookingId: ocean.id,
|
||||
containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO',
|
||||
trackingNo, remark: context.marker,
|
||||
const fixture = currentOutboundFixture(context)
|
||||
if (!fixture) {
|
||||
blocker(context, '缺少本轮phase3出仓fixture,禁止借历史Query验收',
|
||||
`marker=${context.marker}, loading=${context.createdResources?.phase3Loading?.marker || 'missing'}, outbound=${context.createdResources?.phase3OutboundDispatch?.marker || 'missing'}`)
|
||||
return
|
||||
}
|
||||
const createKey = `${context.marker}-carrier-query-create`.slice(0, 128)
|
||||
const createResponse = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey })
|
||||
const created = context.apiSuccess(MODULE, '创建本轮marker专属船司查询任务', createResponse, '', OPTIONS)
|
||||
const queryId = id(created?.query?.query?.id)
|
||||
context.assert(MODULE, '本轮查询任务使用ZIM柜号契约', createResponse,
|
||||
Boolean(queryId) && created?.query?.query?.status === 'READY'
|
||||
&& created?.query?.query?.carrierCode === 'ZIM'
|
||||
&& created?.query?.query?.trackingType === 'CONTAINER_NO'
|
||||
&& created?.query?.query?.trackingNo === trackingNo,
|
||||
`queryId=${queryId || 'missing'}, trackingNo=${created?.query?.query?.trackingNo || 'missing'}`, OPTIONS)
|
||||
if (!queryId) return
|
||||
|
||||
const selector = new URLSearchParams({
|
||||
customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId,
|
||||
containerBookingId: fixture.containerBookingId, keyword: fixture.trackingNo, page: '1', size: '20',
|
||||
})
|
||||
const pageResponse = await request(context, 'GET', `${BASE}?${selector}`)
|
||||
const page = context.apiSuccess(MODULE, '查询本轮出仓自动创建的船司任务', pageResponse, '', OPTIONS)
|
||||
const matches = (Array.isArray(page?.records) ? page.records : []).filter((item) => {
|
||||
const task = item?.query
|
||||
return id(task?.customerId) === fixture.customerId && id(task?.oceanBookingId) === fixture.oceanBookingId
|
||||
&& id(task?.containerBookingId) === fixture.containerBookingId && task?.trackingNo === fixture.trackingNo
|
||||
&& task?.triggerSource === 'OUTBOUND_CONFIRM'
|
||||
})
|
||||
context.assert(MODULE, '本轮出仓范围唯一命中CarrierScheduleQuery', pageResponse,
|
||||
matches.length === 1, `matches=${matches.length}, total=${page?.total ?? 'missing'}`, OPTIONS)
|
||||
if (matches.length !== 1) return
|
||||
|
||||
const task = matches[0].query
|
||||
const queryId = id(task.id)
|
||||
const detailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(queryId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '回查本轮出仓船节点详情', detailResponse, `queryId=${queryId}`, OPTIONS)
|
||||
const departed = (detail?.nodes || []).filter((item) => item.standardNode === 'DEPARTED_WAREHOUSE'
|
||||
&& item.actualAt != null && item.estimatedAt == null)
|
||||
context.assert(MODULE, '出仓自动任务携带实际DEPARTED_WAREHOUSE事实与长ID', detailResponse,
|
||||
id(detail?.query?.query?.id) === queryId && validLongId(detail?.query?.query?.id)
|
||||
&& departed.length >= 1 && departed.every((item) => validLongId(item.id)
|
||||
&& item.providerCode === 'FIRSTLEG' && item.providerEventId && item.providerNodeCode
|
||||
&& typeof item.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(item.rawPayloadHash)),
|
||||
`queryId=${queryId}, departed=${departed.length}`, OPTIONS)
|
||||
|
||||
context.createdResources.phase3CarrierScheduleQuery = {
|
||||
queryId, marker: context.marker, ownedByModule: true, customerId: id(master.customerId),
|
||||
oceanBookingId: ocean.id, trackingNo,
|
||||
queryId, marker: context.marker, ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM',
|
||||
customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId,
|
||||
containerBookingId: fixture.containerBookingId, trackingNo: fixture.trackingNo,
|
||||
version: Number(data(detailResponse)?.query?.query?.version),
|
||||
}
|
||||
|
||||
const replay = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey })
|
||||
context.assert(MODULE, '创建任务幂等重放返回同一marker资源', replay,
|
||||
id(data(replay)?.query?.query?.id) === queryId,
|
||||
`first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS)
|
||||
|
||||
const unknownCarrier = await request(context, 'POST', BASE,
|
||||
{ ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${trackingNo}U` },
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-unknown`.slice(0, 128) })
|
||||
step(context, '非法船司代码被拒绝', unknownCarrier,
|
||||
unknownCarrier.http === 200 && ['A0400', '400'].includes(codeOf(unknownCarrier)),
|
||||
`HTTP=${unknownCarrier.http}, code=${codeOf(unknownCarrier)}`)
|
||||
|
||||
const initialTask = created.query.query
|
||||
const observedAt = localIso(new Date())
|
||||
const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000))
|
||||
const manualBody = {
|
||||
expectedVersion: Number(initialTask.version), standardNode: 'ARRIVED_DESTINATION_PORT',
|
||||
observedAt, estimatedAt, actualAt: null, locationCode: 'USLAX', locationName: 'Los Angeles',
|
||||
reason: `本轮marker预计节点验收 ${context.marker}`,
|
||||
}
|
||||
const manualKey = `${context.marker}-carrier-manual-node`.slice(0, 128)
|
||||
const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody,
|
||||
{ 'Idempotency-Key': manualKey })
|
||||
const manualDetail = context.apiSuccess(MODULE, '仅向本轮marker任务补录预计节点', manual, '', OPTIONS)
|
||||
const manualTask = manualDetail?.query?.query
|
||||
context.assert(MODULE, '预计事实可见、版本前进且实际进度零推进', manual,
|
||||
id(manualTask?.id) === queryId && Number(manualTask?.version) > Number(initialTask.version)
|
||||
&& manualTask?.status === initialTask.status
|
||||
&& manualTask?.latestActualNode === initialTask.latestActualNode
|
||||
&& manualTask?.latestActualAt === initialTask.latestActualAt
|
||||
&& manualDetail?.nodes?.some((item) => item.providerCode === 'MANUAL'
|
||||
&& item.standardNode === 'ARRIVED_DESTINATION_PORT' && item.estimatedAt === estimatedAt && item.actualAt == null),
|
||||
`version=${manualTask?.version ?? 'missing'}, latest=${manualTask?.latestActualNode || 'none'}`, OPTIONS)
|
||||
|
||||
const replayManual = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody,
|
||||
{ 'Idempotency-Key': manualKey })
|
||||
const replayDetail = context.apiSuccess(MODULE, '人工节点同key同payload幂等重放', replayManual, '', OPTIONS)
|
||||
context.assert(MODULE, '人工节点重放版本和事实集合不漂移', replayManual,
|
||||
Number(replayDetail?.query?.query?.version) === Number(manualTask?.version)
|
||||
&& JSON.stringify(nodeSnapshot(replayDetail)) === JSON.stringify(nodeSnapshot(manualDetail)),
|
||||
`version=${replayDetail?.query?.query?.version ?? 'missing'}, nodes=${replayDetail?.nodes?.length ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const stale = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody,
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-manual-stale`.slice(0, 128) })
|
||||
step(context, '旧version配合新幂等键被A0409拒绝', stale,
|
||||
stale.http === 200 && ['A0409', '409'].includes(codeOf(stale)),
|
||||
`HTTP=${stale.http}, code=${codeOf(stale)}`)
|
||||
|
||||
const detailResponse = await request(context, 'GET', `${BASE}/${queryId}`)
|
||||
const detail = context.apiSuccess(MODULE, '回查本轮marker任务零漂移', detailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '并发拒绝后版本、实际进度和事实集合零漂移', detailResponse,
|
||||
Number(detail?.query?.query?.version) === Number(manualTask?.version)
|
||||
&& detail?.query?.query?.latestActualNode === manualTask?.latestActualNode
|
||||
&& detail?.query?.query?.latestActualAt === manualTask?.latestActualAt
|
||||
&& JSON.stringify(nodeSnapshot(detail)) === JSON.stringify(nodeSnapshot(manualDetail))
|
||||
&& !Object.hasOwn(detail || {}, 'attempts')
|
||||
&& detail?.histories?.some((item) => item.action === 'CREATE')
|
||||
&& detail?.histories?.some((item) => item.action === 'MANUAL_NODE'),
|
||||
`version=${detail?.query?.query?.version ?? 'missing'}, nodes=${detail?.nodes?.length ?? 'missing'}`, OPTIONS)
|
||||
context.createdResources.phase3CarrierScheduleQuery.version = Number(manualTask?.version)
|
||||
}
|
||||
|
||||
@@ -4,39 +4,49 @@ import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { markerTrackingNo, ownsCarrierQuery } from './phase3-carrier-schedule-queries.mjs'
|
||||
import { currentOutboundFixture, ownsCarrierQuery } from './phase3-carrier-schedule-queries.mjs'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase3-carrier-schedule-queries.mjs'), 'utf8')
|
||||
|
||||
test('marker ownership requires explicit module ownership and the exact current marker', () => {
|
||||
assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'M', ownedByModule: true }, 'M'), true)
|
||||
assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'OLD', ownedByModule: true }, 'M'), false)
|
||||
assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'M', ownedByModule: false }, 'M'), false)
|
||||
assert.equal(ownsCarrierQuery({ marker: 'M', ownedByModule: true }, 'M'), false)
|
||||
function context(marker = 'RUN') {
|
||||
return { marker, createdResources: {
|
||||
phase3Loading: { marker, tenantId: '10001', oceanBookingId: '20001', containerBookingId: '30001', containerNo: 'TSTU1234567' },
|
||||
phase3OutboundDispatch: { marker, tenantId: '10001', containerBookingId: '30001', confirmedStatus: 'WAITING_FOR_LOADING' },
|
||||
} }
|
||||
}
|
||||
|
||||
test('outbound fixture requires exact current marker and matching loading/outbound container', () => {
|
||||
assert.deepEqual(currentOutboundFixture(context()), {
|
||||
marker: 'RUN', customerId: '10001', oceanBookingId: '20001', containerBookingId: '30001', trackingNo: 'TSTU1234567',
|
||||
})
|
||||
const old = context(); old.createdResources.phase3OutboundDispatch.marker = 'OLD'
|
||||
assert.equal(currentOutboundFixture(old), null)
|
||||
const mismatch = context(); mismatch.createdResources.phase3OutboundDispatch.containerBookingId = 'OTHER'
|
||||
assert.equal(currentOutboundFixture(mismatch), null)
|
||||
})
|
||||
|
||||
test('default tracking number is deterministic marker data while explicit samples are opt-in', () => {
|
||||
const first = markerTrackingNo('RUN-20260821-001', '')
|
||||
const second = markerTrackingNo('RUN-20260821-001', '')
|
||||
assert.equal(first, second)
|
||||
assert.match(first, /^TSTU\d{7}$/)
|
||||
assert.equal(markerTrackingNo('RUN', ' zcsu-test-only '), 'ZCSU-TEST-ONLY')
|
||||
test('carrier query ownership requires explicit outbound-confirm marker registration', () => {
|
||||
assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'RUN', ownedByModule: true,
|
||||
triggerSource: 'OUTBOUND_CONFIRM' }, 'RUN'), true)
|
||||
assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'RUN', ownedByModule: true,
|
||||
triggerSource: 'MANUAL' }, 'RUN'), false)
|
||||
assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'OLD', ownedByModule: true,
|
||||
triggerSource: 'OUTBOUND_CONFIRM' }, 'RUN'), false)
|
||||
})
|
||||
|
||||
test('phase3 contract creates and mutates only its marker-owned container query', () => {
|
||||
assert.match(source, /carrierCode:\s*'ZIM',\s*trackingType:\s*'CONTAINER_NO'/)
|
||||
assert.match(source, /phase3CarrierScheduleQuery\s*=\s*\{[\s\S]*queryId,[\s\S]*marker:\s*context\.marker,[\s\S]*ownedByModule:\s*true/)
|
||||
assert.match(source, /\/manual-nodes/)
|
||||
assert.match(source, /'Idempotency-Key'/)
|
||||
assert.match(source, /expectedVersion:\s*Number\(initialTask\.version\)/)
|
||||
assert.match(source, /actualAt:\s*null/)
|
||||
assert.match(source, /A0409/)
|
||||
assert.match(source, /nodeSnapshot\(detail\)[\s\S]*nodeSnapshot\(manualDetail\)/)
|
||||
assert.doesNotMatch(source, /\/manual-results/)
|
||||
assert.doesNotMatch(source, /\/attempts|\/scheduler|\/internal|\/pda/)
|
||||
assert.doesNotMatch(source, /BILL_OF_LADING/)
|
||||
assert.doesNotMatch(source, /createManualSchedule|portScheduleId|\/api\/platform\/v1\/port-schedules/)
|
||||
test('phase3 script only discovers the current outbound task and blocks missing prerequisites', () => {
|
||||
assert.match(source, /phase3Loading/)
|
||||
assert.match(source, /phase3OutboundDispatch/)
|
||||
assert.match(source, /缺少本轮phase3出仓fixture,禁止借历史Query验收/)
|
||||
assert.match(source, /deploymentBlocker:\s*true/)
|
||||
assert.match(source, /triggerSource === 'OUTBOUND_CONFIRM'/)
|
||||
assert.match(source, /DEPARTED_WAREHOUSE/)
|
||||
assert.match(source, /providerEventId/)
|
||||
assert.match(source, /\^\\d\{16,/)
|
||||
assert.doesNotMatch(source, /request\(context,\s*'POST',\s*BASE/)
|
||||
assert.doesNotMatch(source, /manual-nodes|manual-results|BILL_OF_LADING/)
|
||||
assert.doesNotMatch(source, /records\[0\]/)
|
||||
})
|
||||
|
||||
test('carrier capability assertion freezes five automated providers and COSCO only gap', () => {
|
||||
|
||||
@@ -4,145 +4,202 @@ 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'
|
||||
import { EXCEPTION_CODES, NODE_SEQUENCES, QUERY_STATUSES, STANDARD_NODES,
|
||||
manualNodePayload, 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')
|
||||
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 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}`)
|
||||
},
|
||||
}
|
||||
function ok(data, requestId = 'req-ok') {
|
||||
return { http: 200, body: { code: '200', data, requestId }, requestId, traceId: 'trace-ok' }
|
||||
}
|
||||
function conflict(requestId) {
|
||||
return { http: 200, body: { code: 'A0409', message: 'conflict', requestId }, requestId, traceId: 'trace-conflict' }
|
||||
}
|
||||
function clone(value) { return structuredClone(value) }
|
||||
|
||||
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', () => {
|
||||
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('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 },
|
||||
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('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: 'M', createdResources: {
|
||||
phase3CarrierScheduleQuery: { queryId: '1', marker: 'M', ownedByModule: false },
|
||||
assert.equal(markerOwnedCarrierQuery({ marker: MARKER, createdResources: {
|
||||
phase3CarrierScheduleQuery: { ...valid, triggerSource: 'MANUAL' },
|
||||
} }), 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 })
|
||||
test('missing phase3 outbound query 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 })
|
||||
if (requestPath === `${BASE}/carriers`) return ok([])
|
||||
if (requestPath === `${BASE}?page=1&size=20`) return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
throw new Error(`unexpected path ${requestPath}`)
|
||||
},
|
||||
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(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}`)
|
||||
assert.deepEqual(calls.map((item) => item.method), ['GET', 'GET'])
|
||||
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 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: 'TSTU1234567', 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: CUSTOMER_ID, 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: 'HISTORICAL000' }, 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}`)
|
||||
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: CUSTOMER_ID, 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) => 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 }
|
||||
}
|
||||
|
||||
test('marker-owned chain covers idempotency conflicts, all actual nodes, exception DTO and empty-return stop', async () => {
|
||||
const fixture = markerOwnedContext()
|
||||
|
||||
await run(fixture.context)
|
||||
|
||||
const posts = fixture.context.calls.filter((item) => item.method === 'POST')
|
||||
assert.equal(posts.length, 14)
|
||||
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, 2)
|
||||
assert.equal(fixture.task.status, 'STOPPED')
|
||||
assert.equal(fixture.task.latestActualNode, 'EMPTY_RETURNED')
|
||||
assert.equal(fixture.task.stopReason, '已还柜')
|
||||
const actualNodes = new Set(fixture.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode))
|
||||
assert.deepEqual([...actualNodes].sort(), [...STANDARD_NODES].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)
|
||||
})
|
||||
|
||||
@@ -9,24 +9,37 @@ export const STANDARD_NODES = Object.freeze([
|
||||
'PICKED_UP', 'EMPTY_RETURNED',
|
||||
])
|
||||
export const EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD'])
|
||||
export const NODE_SEQUENCES = Object.freeze(Object.fromEntries(STANDARD_NODES.map((node, index) => [node, (index + 1) * 10])))
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function number(value) { return Number.isFinite(Number(value)) }
|
||||
function nullableText(value) { return value == null || typeof value === 'string' }
|
||||
function nullableId(value) { return value == null || Boolean(id(value)) }
|
||||
function nullableId(value) { return value == null || validLongId(value) }
|
||||
function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) }
|
||||
function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
|
||||
function localIso(value) { return value.toISOString().slice(0, 19) }
|
||||
|
||||
export function markerOwnedCarrierQuery(context) {
|
||||
const resource = context?.createdResources?.phase3CarrierScheduleQuery
|
||||
return resource?.ownedByModule === true && Boolean(id(resource.queryId)) && resource.marker === context?.marker
|
||||
? resource : null
|
||||
return resource?.ownedByModule === true && validLongId(resource.queryId)
|
||||
&& resource.marker === context?.marker && resource.triggerSource === 'OUTBOUND_CONFIRM' ? resource : null
|
||||
}
|
||||
|
||||
export function manualNodePayload({ expectedVersion, standardNode, observedAt, estimatedAt = null,
|
||||
actualAt = null, marker, index = 0 }) {
|
||||
return {
|
||||
expectedVersion, standardNode, observedAt, estimatedAt, actualAt,
|
||||
locationCode: 'USLAX', locationName: 'Los Angeles',
|
||||
reason: `${marker} manual node ${String(index).padStart(2, '0')} ${standardNode}`,
|
||||
}
|
||||
}
|
||||
|
||||
function validTask(task) {
|
||||
return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId))
|
||||
&& QUERY_STATUSES.includes(task?.status)
|
||||
&& nullableId(task?.containerBookingId)
|
||||
&& nullableText(task?.latestActualNode)
|
||||
return validLongId(task?.id) && Boolean(task?.queryNo) && validLongId(task?.customerId)
|
||||
&& validLongId(task?.oceanBookingId) && QUERY_STATUSES.includes(task?.status)
|
||||
&& nullableId(task?.containerBookingId) && nullableText(task?.latestActualNode)
|
||||
&& (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode))
|
||||
&& number(task?.version)
|
||||
&& ['latestActualAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt']
|
||||
@@ -35,9 +48,8 @@ function validTask(task) {
|
||||
}
|
||||
|
||||
function validNode(node) {
|
||||
return Boolean(id(node?.id) && node?.providerCode && node?.providerEventId && node?.providerNodeCode)
|
||||
&& STANDARD_NODES.includes(node?.standardNode)
|
||||
&& number(node?.nodeSequence) && Number(node.nodeSequence) > 0
|
||||
return validLongId(node?.id) && Boolean(node?.providerCode && node?.providerEventId && node?.providerNodeCode)
|
||||
&& STANDARD_NODES.includes(node?.standardNode) && Number(node?.nodeSequence) === NODE_SEQUENCES[node.standardNode]
|
||||
&& nullableIso(node?.observedAt) && node?.observedAt != null
|
||||
&& nullableIso(node?.estimatedAt) && nullableIso(node?.actualAt)
|
||||
&& nullableText(node?.locationCode) && nullableText(node?.locationName)
|
||||
@@ -46,7 +58,7 @@ function validNode(node) {
|
||||
}
|
||||
|
||||
function validException(value) {
|
||||
return Boolean(id(value?.id) && value?.providerCode && value?.providerEventId && value?.providerStatus)
|
||||
return validLongId(value?.id) && Boolean(value?.providerCode && value?.providerEventId && value?.providerStatus)
|
||||
&& EXCEPTION_CODES.includes(value?.exceptionCode) && typeof value?.active === 'boolean'
|
||||
&& nullableIso(value?.observedAt) && value?.observedAt != null
|
||||
&& nullableText(value?.locationCode) && nullableText(value?.locationName)
|
||||
@@ -55,17 +67,42 @@ function validException(value) {
|
||||
}
|
||||
|
||||
function validHistory(item) {
|
||||
return Boolean(id(item?.id) && item?.action)
|
||||
return validLongId(item?.id) && Boolean(item?.action)
|
||||
&& (item?.fromVersion == null || number(item.fromVersion))
|
||||
&& (item?.toVersion == null || number(item.toVersion))
|
||||
&& nullableText(item?.reason) && nullableId(item?.operatorId)
|
||||
&& nullableText(item?.operatorName) && nullableIso(item?.createdAt)
|
||||
}
|
||||
|
||||
function nodeSnapshot(detail) {
|
||||
return (Array.isArray(detail?.nodes) ? detail.nodes : []).map((item) => ({
|
||||
id: id(item.id), providerEventId: item.providerEventId, standardNode: item.standardNode,
|
||||
observedAt: item.observedAt, estimatedAt: item.estimatedAt, actualAt: item.actualAt,
|
||||
}))
|
||||
}
|
||||
|
||||
function assertDetail(context, response, detail, name) {
|
||||
const nodes = Array.isArray(detail?.nodes) ? detail.nodes : []
|
||||
const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : []
|
||||
const histories = Array.isArray(detail?.histories) ? detail.histories : []
|
||||
context.assert(MODULE, name, response,
|
||||
validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean'
|
||||
&& Array.isArray(detail?.nodes) && nodes.every(validNode)
|
||||
&& Array.isArray(detail?.exceptions) && exceptions.every(validException)
|
||||
&& Array.isArray(detail?.histories) && histories.every(validHistory)
|
||||
&& !Object.hasOwn(detail || {}, 'attempts'),
|
||||
`status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`,
|
||||
OPTIONS)
|
||||
}
|
||||
|
||||
function blocker(context, name, evidence, response = null) {
|
||||
context.addStep(MODULE, name, response, false, evidence, OPTIONS)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const carriersResponse = await context.request('GET', `${BASE}/carriers`)
|
||||
const carriers = context.apiSuccess(MODULE, '查询船司只读辅助信息', carriersResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '船司辅助信息保持公开只读描述结构', carriersResponse,
|
||||
context.assert(MODULE, '船司辅助信息结构稳定', carriersResponse,
|
||||
Array.isArray(carriers) && carriers.every((item) => item?.carrierCode && item?.carrierName
|
||||
&& nullableText(item?.officialUrl) && nullableText(item?.accessMode)
|
||||
&& typeof item?.automated === 'boolean' && typeof item?.coverageGap === 'boolean'),
|
||||
@@ -74,68 +111,116 @@ export async function run(context) {
|
||||
const pageResponse = await context.request('GET', `${BASE}?page=1&size=20`)
|
||||
const page = context.apiSuccess(MODULE, '查询四期船节点任务分页', pageResponse, '', OPTIONS)
|
||||
const records = Array.isArray(page?.records) ? page.records : []
|
||||
context.assert(MODULE, '船节点分页结构稳定', pageResponse,
|
||||
context.assert(MODULE, '船节点分页与长ID结构稳定', pageResponse,
|
||||
Array.isArray(page?.records) && ['page', 'size', 'total', 'pages'].every((field) => number(page?.[field]))
|
||||
&& records.every((item) => typeof item?.expired === 'boolean' && validTask(item?.query)),
|
||||
`records=${records.length}, total=${page?.total ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const ownedResource = markerOwnedCarrierQuery(context)
|
||||
if (records.length === 0 && !ownedResource) {
|
||||
context.assert(MODULE, '无真实任务时返回稳定分页空态', pageResponse,
|
||||
Number(page?.total) === 0 && Number(page?.pages) === 0,
|
||||
`records=0, total=${page?.total ?? 'missing'}, pages=${page?.pages ?? 'missing'}`, OPTIONS)
|
||||
const owned = markerOwnedCarrierQuery(context)
|
||||
if (!owned) {
|
||||
blocker(context, '缺少本轮phase3出仓CarrierScheduleQuery,禁止借历史任务写节点',
|
||||
`marker=${context.marker}, registered=${context.createdResources?.phase3CarrierScheduleQuery?.marker || 'missing'}`, pageResponse)
|
||||
return
|
||||
}
|
||||
|
||||
const queryId = ownedResource ? id(ownedResource.queryId) : id(records[0]?.query?.id)
|
||||
const queryId = id(owned.queryId)
|
||||
const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '查询真实船节点任务详情', detailResponse, `queryId=${queryId}`, OPTIONS)
|
||||
const nodes = Array.isArray(detail?.nodes) ? detail.nodes : []
|
||||
const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : []
|
||||
const histories = Array.isArray(detail?.histories) ? detail.histories : []
|
||||
context.assert(MODULE, '详情返回Task、标准节点与业务历史', detailResponse,
|
||||
validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean'
|
||||
&& Array.isArray(detail?.nodes) && nodes.every(validNode)
|
||||
&& Array.isArray(detail?.exceptions) && exceptions.every(validException)
|
||||
&& Array.isArray(detail?.histories) && histories.every(validHistory),
|
||||
`status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, OPTIONS)
|
||||
|
||||
const task = detail.query.query
|
||||
if (!ownedResource) {
|
||||
context.assert(MODULE, 'standalone无本轮marker资源时严格只读并跳过人工写', detailResponse,
|
||||
true, `readOnlyQueryId=${queryId}`, OPTIONS)
|
||||
const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`)
|
||||
const history = context.apiSuccess(MODULE, '只读查询历史任务历史', historyResponse, `queryId=${queryId}`, OPTIONS)
|
||||
context.assert(MODULE, 'standalone历史详情只读且结构稳定', historyResponse,
|
||||
Array.isArray(history) && history.every(validHistory),
|
||||
`history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
const ownershipMatches = id(task?.id) === queryId && task?.trackingNo === ownedResource.trackingNo
|
||||
context.assert(MODULE, 'phase4写入目标严格匹配本轮phase3登记', detailResponse,
|
||||
ownershipMatches, `registered=${queryId}, actual=${id(task?.id) || 'missing'}`, OPTIONS)
|
||||
let detail = context.apiSuccess(MODULE, '查询本轮marker-owned船节点详情', detailResponse, `queryId=${queryId}`, OPTIONS)
|
||||
assertDetail(context, detailResponse, detail, '详情返回Task、十节点DTO、异常与历史结构')
|
||||
const initialTask = detail?.query?.query
|
||||
const ownershipMatches = id(initialTask?.id) === queryId && initialTask?.trackingNo === owned.trackingNo
|
||||
&& id(initialTask?.oceanBookingId) === id(owned.oceanBookingId)
|
||||
&& id(initialTask?.containerBookingId) === id(owned.containerBookingId)
|
||||
&& initialTask?.triggerSource === 'OUTBOUND_CONFIRM'
|
||||
context.assert(MODULE, '写入目标严格匹配本轮phase3出仓登记', detailResponse,
|
||||
ownershipMatches, `registered=${queryId}, actual=${id(initialTask?.id) || 'missing'}`, OPTIONS)
|
||||
if (!ownershipMatches) return
|
||||
const observedAt = task.latestActualAt || '2026-08-21T00:00:00'
|
||||
const manualResponse = await context.request('POST', `${BASE}/${encodeURIComponent(queryId)}/manual-nodes`, {
|
||||
expectedVersion: task.version,
|
||||
standardNode: task.latestActualNode || 'DEPARTED_WAREHOUSE',
|
||||
observedAt,
|
||||
estimatedAt: observedAt,
|
||||
actualAt: null,
|
||||
locationCode: null,
|
||||
locationName: null,
|
||||
reason: '四期船节点人工预计事实验收',
|
||||
}, context.token, { 'Idempotency-Key': `${context.marker || 'phase4'}-carrier-manual-${queryId}`.slice(0, 128) })
|
||||
const manualDetail = context.apiSuccess(MODULE, '人工补录预计节点事实', manualResponse, `queryId=${queryId}`, OPTIONS)
|
||||
context.assert(MODULE, '人工预计事实可见且不改变任务操作态', manualResponse,
|
||||
QUERY_STATUSES.includes(manualDetail?.query?.query?.status)
|
||||
&& manualDetail?.nodes?.some((item) => item?.providerCode === 'MANUAL'
|
||||
&& item?.estimatedAt != null && item?.actualAt == null),
|
||||
`status=${manualDetail?.query?.query?.status || 'missing'}, nodes=${manualDetail?.nodes?.length ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`)
|
||||
const history = context.apiSuccess(MODULE, '查询真实船节点任务历史', historyResponse, `queryId=${queryId}`, OPTIONS)
|
||||
context.assert(MODULE, '独立历史端点返回可审计操作者与nullable字段', historyResponse,
|
||||
Array.isArray(history) && history.every(validHistory),
|
||||
const observedAt = localIso(new Date())
|
||||
const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000))
|
||||
const estimatedBody = manualNodePayload({ expectedVersion: Number(initialTask.version),
|
||||
standardNode: 'ARRIVED_DESTINATION_PORT', observedAt, estimatedAt, marker: context.marker })
|
||||
const estimatedKey = `${context.marker}-carrier-estimated`.slice(0, 128)
|
||||
const estimatedResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, estimatedBody,
|
||||
context.token, { 'Idempotency-Key': estimatedKey })
|
||||
const estimatedDetail = context.apiSuccess(MODULE, '完整payload补录预计到港事实', estimatedResponse,
|
||||
`queryId=${queryId}`, OPTIONS)
|
||||
assertDetail(context, estimatedResponse, estimatedDetail, '预计事实响应保持完整DTO')
|
||||
context.assert(MODULE, '预计节点递增版本但不推进latestActual', estimatedResponse,
|
||||
Number(estimatedDetail?.query?.query?.version) > Number(initialTask.version)
|
||||
&& estimatedDetail?.query?.query?.status !== 'STOPPED'
|
||||
&& estimatedDetail?.query?.query?.latestActualNode === initialTask.latestActualNode
|
||||
&& estimatedDetail?.query?.query?.latestActualAt === initialTask.latestActualAt
|
||||
&& estimatedDetail?.nodes?.some((item) => item.providerCode === 'MANUAL'
|
||||
&& item.standardNode === 'ARRIVED_DESTINATION_PORT' && item.estimatedAt === estimatedAt && item.actualAt == null),
|
||||
`version=${estimatedDetail?.query?.query?.version ?? 'missing'}, latest=${estimatedDetail?.query?.query?.latestActualNode || 'none'}`,
|
||||
OPTIONS)
|
||||
|
||||
const replayResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, estimatedBody,
|
||||
context.token, { 'Idempotency-Key': estimatedKey })
|
||||
const replayDetail = context.apiSuccess(MODULE, '同Idempotency-Key同payload重放', replayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '重放版本和节点集合零漂移', replayResponse,
|
||||
Number(replayDetail?.query?.query?.version) === Number(estimatedDetail?.query?.query?.version)
|
||||
&& JSON.stringify(nodeSnapshot(replayDetail)) === JSON.stringify(nodeSnapshot(estimatedDetail)),
|
||||
`version=${replayDetail?.query?.query?.version ?? 'missing'}, nodes=${replayDetail?.nodes?.length ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const hashConflict = await context.request('POST', `${BASE}/${queryId}/manual-nodes`,
|
||||
{ ...estimatedBody, reason: `${estimatedBody.reason} altered` }, context.token,
|
||||
{ 'Idempotency-Key': estimatedKey })
|
||||
context.addStep(MODULE, '同Idempotency-Key异payload返回A0409', hashConflict, rejected(hashConflict),
|
||||
`HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}`, OPTIONS)
|
||||
|
||||
const staleResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, estimatedBody,
|
||||
context.token, { 'Idempotency-Key': `${context.marker}-carrier-stale-version`.slice(0, 128) })
|
||||
context.addStep(MODULE, '旧expectedVersion配合新key返回A0409', staleResponse, rejected(staleResponse),
|
||||
`HTTP=${staleResponse.http}, code=${codeOf(staleResponse)}`, OPTIONS)
|
||||
|
||||
const zeroDriftResponse = await context.request('GET', `${BASE}/${queryId}`)
|
||||
detail = context.apiSuccess(MODULE, '冲突请求后回查零漂移', zeroDriftResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '两类A0409后版本与节点集合零漂移', zeroDriftResponse,
|
||||
Number(detail?.query?.query?.version) === Number(estimatedDetail?.query?.query?.version)
|
||||
&& JSON.stringify(nodeSnapshot(detail)) === JSON.stringify(nodeSnapshot(estimatedDetail)),
|
||||
`version=${detail?.query?.query?.version ?? 'missing'}, nodes=${detail?.nodes?.length ?? 'missing'}`, OPTIONS)
|
||||
|
||||
let version = Number(detail.query.query.version)
|
||||
const baseActual = Date.now() + 60 * 60_000
|
||||
for (let index = 0; index < STANDARD_NODES.length; index += 1) {
|
||||
const standardNode = STANDARD_NODES[index]
|
||||
const actualAt = localIso(new Date(baseActual + index * 60_000))
|
||||
const body = manualNodePayload({ expectedVersion: version, standardNode,
|
||||
observedAt: localIso(new Date()), actualAt, marker: context.marker, index: index + 1 })
|
||||
const key = `${context.marker}-carrier-actual-${String(index + 1).padStart(2, '0')}`.slice(0, 128)
|
||||
const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body,
|
||||
context.token, { 'Idempotency-Key': key })
|
||||
const updated = context.apiSuccess(MODULE, `补录实际节点${standardNode}`, response, `queryId=${queryId}`, OPTIONS)
|
||||
assertDetail(context, response, updated, `实际节点${standardNode}响应DTO完整`)
|
||||
const updatedTask = updated?.query?.query
|
||||
const isEmptyReturned = standardNode === 'EMPTY_RETURNED'
|
||||
context.assert(MODULE, `${standardNode}实际事实单调推进且停止语义正确`, response,
|
||||
updatedTask?.latestActualNode === standardNode && updatedTask?.latestActualAt === actualAt
|
||||
&& (isEmptyReturned
|
||||
? updatedTask.status === 'STOPPED' && updatedTask.stopReason === '已还柜' && updatedTask.stoppedAt != null
|
||||
: updatedTask.status !== 'STOPPED')
|
||||
&& updated?.nodes?.some((item) => item.providerCode === 'MANUAL'
|
||||
&& item.standardNode === standardNode && item.actualAt === actualAt && item.estimatedAt == null
|
||||
&& item.providerEventId && item.providerNodeCode && item.sourceReference
|
||||
&& /^[0-9a-f]{64}$/.test(item.rawPayloadHash)),
|
||||
`status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS)
|
||||
version = Number(updatedTask.version)
|
||||
detail = updated
|
||||
}
|
||||
|
||||
const finalTask = detail.query.query
|
||||
const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode))
|
||||
context.assert(MODULE, '最终详情覆盖十标准实际节点且只有EMPTY_RETURNED触发停止', detailResponse,
|
||||
STANDARD_NODES.every((node) => actualNodes.has(node)) && finalTask.status === 'STOPPED'
|
||||
&& finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜',
|
||||
`actualNodes=${[...actualNodes].join(',')}, status=${finalTask.status}`, OPTIONS)
|
||||
|
||||
const historyResponse = await context.request('GET', `${BASE}/${queryId}/history`)
|
||||
const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS)
|
||||
context.assert(MODULE, '历史只含业务动作且人工节点完整留痕', historyResponse,
|
||||
Array.isArray(history) && history.every(validHistory)
|
||||
&& history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length + 1,
|
||||
`history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user