From 6eaf484fcd7997fb9c402b6d7d79dbedb3841354 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 12:02:57 +0800 Subject: [PATCH] test(firstleg): package carrier node demo checks --- .../phase3-carrier-schedule-queries.mjs | 15 ++++++++++++ .../phase3-carrier-schedule-queries.test.mjs | 15 +++++++++--- ...4-departure-pickup-chain-contract.test.mjs | 24 +++++++++++++++++++ .../phase4-carrier-nodes-contract.test.mjs | 4 ++-- .../phase4-carrier-nodes.mjs | 16 +++++++++++-- 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index 886f39387..f202c34fb 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -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)), diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs index 1917c0ccb..1a98665b4 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs @@ -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,/) diff --git a/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs index 0552e9393..b9c9d1c20 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs @@ -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'\)/) +}) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 12ed6ecb0..7caafaa4e 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -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)) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index 3402042e5..f89fc08e3 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -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']