test(firstleg): package carrier node demo checks
This commit is contained in:
@@ -50,6 +50,18 @@ function blocker(context, name, evidence) {
|
||||
}
|
||||
|
||||
function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) }
|
||||
export function validContainerNo(value) {
|
||||
if (typeof value !== 'string' || !/^[A-Z]{3}[UJZ][0-9]{7}$/.test(value)) return false
|
||||
const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38]
|
||||
let sum = 0
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
const number = code >= 48 && code <= 57 ? code - 48 : letterValues[code - 65]
|
||||
sum += number * (2 ** index)
|
||||
}
|
||||
return Number(value[10]) === (sum % 11) % 10
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询',
|
||||
@@ -84,6 +96,7 @@ export async function run(context) {
|
||||
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?.trackingType === 'CONTAINER_NO' && validContainerNo(task?.trackingNo)
|
||||
&& task?.triggerSource === 'OUTBOUND_CONFIRM'
|
||||
})
|
||||
context.assert(MODULE, '本轮出仓范围唯一命中CarrierScheduleQuery', pageResponse,
|
||||
@@ -104,6 +117,8 @@ export async function run(context) {
|
||||
&& item.actualAt != null && item.estimatedAt == null)
|
||||
context.assert(MODULE, '出仓自动任务携带实际DEPARTED_WAREHOUSE事实与长ID', detailResponse,
|
||||
id(detail?.query?.query?.id) === queryId && validLongId(detail?.query?.query?.id)
|
||||
&& detail?.query?.query?.trackingType === 'CONTAINER_NO'
|
||||
&& validContainerNo(detail?.query?.query?.trackingNo)
|
||||
&& 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)),
|
||||
|
||||
@@ -4,21 +4,21 @@ import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { currentOutboundFixture, ownsCarrierQuery } from './phase3-carrier-schedule-queries.mjs'
|
||||
import { currentOutboundFixture, ownsCarrierQuery, validContainerNo } 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')
|
||||
|
||||
function context(marker = 'RUN') {
|
||||
return { marker, createdResources: {
|
||||
phase3Loading: { marker, tenantId: '10001', oceanBookingId: '20001', containerBookingId: '30001', containerNo: 'TSTU1234567' },
|
||||
phase3Loading: { marker, tenantId: '10001', oceanBookingId: '20001', containerBookingId: '30001', containerNo: 'ZCSU7881970' },
|
||||
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',
|
||||
marker: 'RUN', customerId: '10001', oceanBookingId: '20001', containerBookingId: '30001', trackingNo: 'ZCSU7881970',
|
||||
})
|
||||
const old = context(); old.createdResources.phase3OutboundDispatch.marker = 'OLD'
|
||||
assert.equal(currentOutboundFixture(old), null)
|
||||
@@ -26,6 +26,13 @@ test('outbound fixture requires exact current marker and matching loading/outbou
|
||||
assert.equal(currentOutboundFixture(mismatch), null)
|
||||
})
|
||||
|
||||
test('carrier fixture only accepts normalized ISO 6346 physical container numbers', () => {
|
||||
assert.equal(validContainerNo('ZCSU7881970'), true)
|
||||
assert.equal(validContainerNo('zcsu7881970'), false)
|
||||
assert.equal(validContainerNo('ZCSU7881971'), false)
|
||||
assert.equal(validContainerNo('CAB-260901-0001'), false)
|
||||
})
|
||||
|
||||
test('carrier query ownership requires explicit outbound-confirm marker registration', () => {
|
||||
assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'RUN', ownedByModule: true,
|
||||
triggerSource: 'OUTBOUND_CONFIRM' }, 'RUN'), true)
|
||||
@@ -41,6 +48,8 @@ test('phase3 script only discovers the current outbound task and blocks missing
|
||||
assert.match(source, /缺少本轮phase3出仓fixture,禁止借历史Query验收/)
|
||||
assert.match(source, /deploymentBlocker:\s*true/)
|
||||
assert.match(source, /triggerSource === 'OUTBOUND_CONFIRM'/)
|
||||
assert.match(source, /trackingType === 'CONTAINER_NO'/)
|
||||
assert.match(source, /validContainerNo\(task\?\.trackingNo\)/)
|
||||
assert.match(source, /DEPARTED_WAREHOUSE/)
|
||||
assert.match(source, /providerEventId/)
|
||||
assert.match(source, /\^\\d\{16,/)
|
||||
|
||||
@@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const read = (name) => readFileSync(resolve(here, name), 'utf8')
|
||||
const readFirstlegTest = (name) => readFileSync(resolve(here,
|
||||
'../../firstleg/src/test/java/com/baiyue/firstleg/portschedule/service', name), 'utf8')
|
||||
|
||||
test('current-marker departure batch proves four documents photos customs and container-level confirm', () => {
|
||||
const customs = read('phase3-customs.mjs')
|
||||
@@ -48,3 +50,25 @@ test('outbound-created query supports automated or manual carrier then reaches P
|
||||
assert.match(pda, /deploymentBlocker: true/)
|
||||
assert.doesNotMatch(pda, /addCoverageGap\([^\n]*PICKUP_READY/)
|
||||
})
|
||||
|
||||
test('demo package locks provider failure retry and refuses stray facts on failed calls', () => {
|
||||
const scheduler = readFirstlegTest('CarrierScheduleQuerySchedulerTest.java')
|
||||
const service = readFirstlegTest('CarrierScheduleQueryServiceTest.java')
|
||||
assert.match(scheduler, /retryableProviderResultCannotAdvanceEvenWhenItCarriesStrayFacts/)
|
||||
assert.match(scheduler, /verify\(service, never\(\)\)\.applyProviderObservation/)
|
||||
assert.match(scheduler, /completeScheduledAttempt\(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable"\)/)
|
||||
assert.match(service, /retryableFailureUsesFifteenThenThirtyMinutesAndStopsAfterThreeCallsInTheSlot/)
|
||||
assert.match(service, /containsExactly\(1, 2, 3\)/)
|
||||
assert.match(service, /retryThatWouldReachTheNextSlotWaitsForTheSlotInstead/)
|
||||
})
|
||||
|
||||
test('demo package exposes the stable Admin task, node, exception and history DTO contract', () => {
|
||||
const nodes = read('phase4-carrier-nodes.mjs')
|
||||
assert.match(nodes, /trackingType === 'CONTAINER_NO'/)
|
||||
assert.match(nodes, /validContainerNo\(task\?\.trackingNo\)/)
|
||||
assert.match(nodes, /Array\.isArray\(detail\?\.nodes\)/)
|
||||
assert.match(nodes, /nodes\.every\(validNode\)/)
|
||||
assert.match(nodes, /exceptions\.every\(validException\)/)
|
||||
assert.match(nodes, /histories\.every\(validHistory\)/)
|
||||
assert.match(nodes, /!Object\.hasOwn\(detail \|\| \{\}, 'attempts'\)/)
|
||||
})
|
||||
|
||||
@@ -125,7 +125,7 @@ function markerOwnedContext() {
|
||||
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',
|
||||
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,
|
||||
@@ -165,7 +165,7 @@ function markerOwnedContext() {
|
||||
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,
|
||||
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))
|
||||
|
||||
@@ -10,14 +10,25 @@ export const STANDARD_NODES = Object.freeze([
|
||||
])
|
||||
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])))
|
||||
const ISO_CONTAINER_PATTERN = /^[A-Z]{3}[UJZ][0-9]{7}$/
|
||||
|
||||
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 || 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 validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' }
|
||||
function validContainerNo(value) {
|
||||
if (typeof value !== 'string' || !ISO_CONTAINER_PATTERN.test(value)) return false
|
||||
const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38]
|
||||
let sum = 0
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
sum += (code >= 48 && code <= 57 ? code - 48 : letterValues[code - 65]) * (2 ** index)
|
||||
}
|
||||
return Number(value[10]) === (sum % 11) % 10
|
||||
}
|
||||
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 monotonicRejected(response) {
|
||||
@@ -45,7 +56,8 @@ export function manualNodePayload({ expectedVersion, standardNode, observedAt, e
|
||||
function validTask(task) {
|
||||
return validLongId(task?.id) && Boolean(task?.queryNo) && validNumericId(task?.customerId)
|
||||
&& validLongId(task?.oceanBookingId) && QUERY_STATUSES.includes(task?.status)
|
||||
&& nullableId(task?.containerBookingId) && nullableText(task?.latestActualNode)
|
||||
&& validLongId(task?.containerBookingId) && task?.trackingType === 'CONTAINER_NO'
|
||||
&& validContainerNo(task?.trackingNo) && nullableText(task?.latestActualNode)
|
||||
&& (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode))
|
||||
&& number(task?.version)
|
||||
&& ['latestActualAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt']
|
||||
|
||||
Reference in New Issue
Block a user