test: harden carrier node HTTP acceptance
This commit is contained in:
@@ -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