diff --git a/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs b/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs new file mode 100644 index 000000000..d7257fb53 --- /dev/null +++ b/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs @@ -0,0 +1,49 @@ +export const EVENT_BRIDGE_RECOVERY_FIXTURE = Object.freeze({ + customerCode: 'C10000000', + eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', + initialOutboxStatus: 'PENDING', + initialRetryCount: 62, + failedTarget: '172.31.198.8:9313', + currentOutboxStatus: 'PENDING', + currentRetryCount: 81, + currentFailureCode: 'A0401', + currentFailureMessage: '内部服务令牌无效', + currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704', + currentTraceId: 'e8e6d620c1cff40caa765e6968f89024', + dockerDnsProven: true, +}) + +export const TEST_EVENT_BRIDGE_URLS = Object.freeze({ + LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312', + FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312', +}) + +export const INTERNAL_TOKEN_COHORT_SERVICES = Object.freeze([ + 'firstleg', + 'event-bridge-cn', + 'event-bridge-overseas', + 'lastleg-service', +]) + +export function validTestEventBridgeEnvironment(values = {}) { + return Object.entries(TEST_EVENT_BRIDGE_URLS) + .every(([key, expected]) => values[key] === expected) + && Object.values(values).every((value) => !/^https?:\/\/(?:\d{1,3}\.){3}\d{1,3}(?::|\/|$)/.test(String(value))) +} + +// Callers must provide only a non-secret fingerprint/revision marker, never the token value. +export function validInternalTokenCohort(fingerprints = {}) { + const values = INTERNAL_TOKEN_COHORT_SERVICES.map((service) => fingerprints[service]) + return values.every((value) => typeof value === 'string' && value.length > 0) + && new Set(values).size === 1 +} + +export function recoveredEventBridgeFacts(facts = {}) { + return facts.firstlegOutboxStatus === 'SENT' + && Number(facts.overseasBridgeEventCount) === 1 + && Number(facts.lastlegInboxEventCount) === 1 + && Number(facts.lastlegOrderCount) === 1 + && Number(facts.cnBridgeDuplicateCount) === 0 + && Number(facts.manualReplayCount) === 0 + && Number(facts.sqlMutationCount) === 0 +} diff --git a/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs new file mode 100644 index 000000000..6397cefe9 --- /dev/null +++ b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs @@ -0,0 +1,47 @@ +export const IMMUTABLE_TAILLEG_EVENT_FIXTURE = Object.freeze({ + eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', + status: 'PROCESSED', + payloadMutable: false, + reusableForAcceptance: false, +}) + +export const TAILLEG_ORDER_NUMBER = Object.freeze({ + pattern: /^LMO-\d{6}-\d{4}$/, + timeZone: 'Asia/Shanghai', + sharedBy: 'containerNo', +}) + +export function validOwnerSnapshots(orders, expectedCount = orders?.length) { + if (!Array.isArray(orders) || orders.length !== expectedCount || orders.length === 0) return false + const owners = new Set() + for (const order of orders) { + const customerId = String(order?.customerId ?? '').trim() + const customerName = String(order?.customerName ?? '').trim() + if (!customerId || !customerName) return false + owners.add(`${customerId}\u0000${customerName}`) + } + return owners.size === 1 +} + +export function validContainerOrderGroup(rows) { + if (!Array.isArray(rows) || rows.length === 0) return false + const containerNos = new Set(rows.map((row) => String(row?.containerNo ?? '').trim())) + const orderNos = new Set(rows.map((row) => String(row?.orderNo ?? '').trim())) + const sourceInboundIds = rows.map((row) => String(row?.sourceInboundId ?? '').trim()) + return containerNos.size === 1 && !containerNos.has('') + && orderNos.size === 1 && TAILLEG_ORDER_NUMBER.pattern.test([...orderNos][0]) + && sourceInboundIds.every(Boolean) && new Set(sourceInboundIds).size === rows.length +} + +export function validEventReplay(before, after) { + return Number(before?.orderCount) === Number(after?.orderCount) + && String(before?.orderNo ?? '') === String(after?.orderNo ?? '') + && Number(before?.detailCount) === Number(after?.detailCount) + && Number(before?.historyCount) === Number(after?.historyCount) +} + +export function adminPreservesOrderNumber(apiValue, displayedValue, copiedValue) { + return TAILLEG_ORDER_NUMBER.pattern.test(String(apiValue ?? '')) + && displayedValue === apiValue + && copiedValue === apiValue +} diff --git a/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs b/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs new file mode 100644 index 000000000..2a1234cb5 --- /dev/null +++ b/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + EVENT_BRIDGE_RECOVERY_FIXTURE, + INTERNAL_TOKEN_COHORT_SERVICES, + recoveredEventBridgeFacts, + TEST_EVENT_BRIDGE_URLS, + validInternalTokenCohort, + validTestEventBridgeEnvironment, +} from './event-bridge-pending-recovery.mjs' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const cnCompose = read('../../deploy/docker-compose.yml') +const overseasCompose = read('../../deploy/docker-compose.overseas.yml') +const deployBuilt = read('../../deploy/scripts/deploy-built-service.sh') +const deployServices = read('../../deploy/scripts/deploy-services.sh') +const crossRegionHttp = read('phase4-cross-region-events.mjs') + +test('test bridge URLs use stable Compose DNS and internal port 9312 only', () => { + assert.deepEqual(TEST_EVENT_BRIDGE_URLS, { + LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312', + FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312', + }) + assert.equal(validTestEventBridgeEnvironment(TEST_EVENT_BRIDGE_URLS), true) + assert.equal(validTestEventBridgeEnvironment({ + ...TEST_EVENT_BRIDGE_URLS, LASTLEG_EVENT_BRIDGE_URL: 'http://172.31.198.8:9313', + }), false) +}) + +test('Compose wires both producers to the stable service aliases used by test env', () => { + assert.match(cnCompose, /LASTLEG_EVENT_BRIDGE_URL:\s*\$\{LASTLEG_EVENT_BRIDGE_URL\}/) + assert.match(overseasCompose, + /FIRSTLEG_EVENT_BRIDGE_URL:\s*\$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(cnCompose, /aliases:\s*\[event-bridge-cn\]/) + assert.match(overseasCompose, /aliases:\s*\[event-bridge-overseas\]/) + for (const source of [cnCompose, overseasCompose]) { + assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*(?:\d{1,3}\.){3}\d{1,3}/) + assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*:9313/) + } +}) + +test('all four recovery services inherit INTERNAL_SERVICE_TOKEN and deploy from one env file', () => { + assert.deepEqual(INTERNAL_TOKEN_COHORT_SERVICES, [ + 'firstleg', 'event-bridge-cn', 'event-bridge-overseas', 'lastleg-service', + ]) + assert.match(cnCompose, /x-common:\s*&common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/) + assert.match(overseasCompose, + /x-overseas-common:\s*&overseas-common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/) + assert.match(cnCompose, /firstleg:[\s\S]*<<:\s*\*common/) + assert.match(cnCompose, /event-bridge-service:[\s\S]*<<:\s*\*common/) + assert.match(overseasCompose, /lastleg-service:[\s\S]*<<:\s*\*overseas-common/) + assert.match(overseasCompose, /event-bridge-service:[\s\S]*<<:\s*\*overseas-common/) + for (const source of [deployBuilt, deployServices]) { + assert.match(source, /--env-file\s+"\$ENV_FILE"/) + } + assert.equal(validInternalTokenCohort({ + firstleg: 'revision-20260827', + 'event-bridge-cn': 'revision-20260827', + 'event-bridge-overseas': 'revision-20260827', + 'lastleg-service': 'revision-20260827', + }), true) + assert.equal(validInternalTokenCohort({ + firstleg: 'revision-20260827', + 'event-bridge-cn': 'revision-20260827', + 'event-bridge-overseas': 'stale-revision', + 'lastleg-service': 'revision-20260827', + }), false) +}) + +test('incident fixture records DNS recovery followed by retry81 token rejection', () => { + assert.deepEqual(EVENT_BRIDGE_RECOVERY_FIXTURE, { + customerCode: 'C10000000', + eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', + initialOutboxStatus: 'PENDING', + initialRetryCount: 62, + failedTarget: '172.31.198.8:9313', + currentOutboxStatus: 'PENDING', + currentRetryCount: 81, + currentFailureCode: 'A0401', + currentFailureMessage: '内部服务令牌无效', + currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704', + currentTraceId: 'e8e6d620c1cff40caa765e6968f89024', + dockerDnsProven: true, + }) +}) + +test('post-rebuild convergence uses the original pending event with no replay or SQL repair', () => { + assert.equal(recoveredEventBridgeFacts({ + firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, + lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0, + manualReplayCount: 0, sqlMutationCount: 0, + }), true) + assert.equal(recoveredEventBridgeFacts({ + firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, + lastlegInboxEventCount: 1, lastlegOrderCount: 2, cnBridgeDuplicateCount: 0, + manualReplayCount: 0, sqlMutationCount: 0, + }), false) + assert.equal(recoveredEventBridgeFacts({ + firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, + lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0, + manualReplayCount: 1, sqlMutationCount: 0, + }), false) + assert.match(crossRegionHttp, /const delivered = await poll/) + assert.match(crossRegionHttp, /deliveredRows\.length > 0/) + assert.match(crossRegionHttp, /sameIds\(delivered\.response\.body\?\.data, deliveredAgain\.body\?\.data\)/) +}) diff --git a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs new file mode 100644 index 000000000..a71ca5a63 --- /dev/null +++ b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + adminPreservesOrderNumber, + IMMUTABLE_TAILLEG_EVENT_FIXTURE, + TAILLEG_ORDER_NUMBER, + validContainerOrderGroup, + validEventReplay, + validOwnerSnapshots, +} from './tailleg-owner-order-number-contract.mjs' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const eventV2 = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java') +const producer = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java') +const producerRows = read('../../firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java') +const consumer = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java') +const orderEntity = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java') +const orderService = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java') +const generator = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java') +const generatorTests = read('../../lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java') +const consumerTests = read('../../lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java') +const adminApi = read('../../../apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') +const adminPresentation = read('../../../apps/admin-web/src/modules/overseas-warehouse/lastMilePresentation.ts') +const adminList = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue') +const adminDetail = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue') + +test('new 15-order event requires one non-empty trusted owner snapshot', () => { + const orders = Array.from({ length: 15 }, (_, index) => ({ + sourceInboundId: `inbound-${index + 1}`, customerId: 'customer-1', customerName: '验收货主', + })) + assert.equal(validOwnerSnapshots(orders, 15), true) + assert.equal(validOwnerSnapshots(orders.map((order, index) => index === 7 + ? { ...order, customerName: '' } : order), 15), false) + assert.equal(validOwnerSnapshots(orders.map((order, index) => index === 7 + ? { ...order, customerName: '另一货主' } : order), 15), false) +}) + +test('the processed historical event is immutable and cannot be reused for acceptance', () => { + assert.deepEqual(IMMUTABLE_TAILLEG_EVENT_FIXTURE, { + eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', + status: 'PROCESSED', payloadMutable: false, reusableForAcceptance: false, + }) +}) + +test('V2 producer freezes customerId and customerName and rejects a blank name', () => { + assert.match(eventV2, /List items,\s*String customerId,\s*String customerName,\s*String orderType/) + assert.match(producerRows, /b\.customer_name/) + assert.match(producer, /first\.getCustomerName\(\)/) + assert.match(producer, /(?:customerName|getCustomerName)[\s\S]{0,300}(?:isBlank|hasText)/) +}) + +test('Lastleg rejects a missing owner snapshot, persists it, and never calls IAM to repair it', () => { + assert.match(consumer, /source\.customerName\(\)/) + assert.match(consumer, /customerName[\s\S]{0,240}(?:isBlank|hasText)/) + assert.match(consumer, /order\.setCustomerName\(customerName\)/) + assert.match(orderEntity, /private String customerName;/) + assert.doesNotMatch(consumer, /(?:Iam|IAM|\/iam|CustomerClient|customer.*client)/) +}) + +test('Lastleg query DTO and Admin list/detail use the frozen owner name', () => { + assert.match(orderService, /o\.getCustomerName\(\)/) + assert.match(adminApi, /customerName\?: string \| null/) + assert.match(adminPresentation, /owner:\s*text\(dto\.customerName\)/) + assert.match(adminDetail, /label:\s*'货主',\s*value:\s*text\(order\?\.customerName\)/) +}) + +test('new tailleg number is Shanghai daily LMO-yyMMdd-#### with concurrency and cross-day tests', () => { + assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO-260827-0001'), true) + assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO2087379674555461633'), false) + assert.equal(TAILLEG_ORDER_NUMBER.timeZone, 'Asia/Shanghai') + assert.match(generator, /Asia\/Shanghai/) + assert.match(generator, /yyMMdd/) + assert.match(generator, /LMO-/) + assert.match(generator, /%04d|padStart\(4|String\.format/) + assert.match(generatorTests, /(?:concurrent|并发)/i) + assert.match(generatorTests, /(?:cross.?day|next.?day|跨天)/i) + assert.match(generatorTests, /\^LMO-\\d\{6\}-\\d\{4\}\$/) +}) + +test('one container with 15 inbound rows shares one new LMO number', () => { + const rows = Array.from({ length: 15 }, (_, index) => ({ + containerNo: 'CONT-15', orderNo: 'LMO-260827-0042', sourceInboundId: `inbound-${index + 1}`, + })) + assert.equal(validContainerOrderGroup(rows), true) + assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14 + ? { ...row, orderNo: 'LMO-260827-0043' } : row)), false) + assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.[^(]+\(payload\.containerNo\(\)\);[\s\S]*for \(int index/) + assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource\)/) +}) + +test('same event replay adds nothing and preserves the allocated number', () => { + const before = { orderCount: 15, orderNo: 'LMO-260827-0042', detailCount: 30, historyCount: 15 } + assert.equal(validEventReplay(before, { ...before }), true) + assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false) + assert.match(consumer, /if\s*\(existing\s*!=\s*null\)[\s\S]{0,400}return countOrders/) + assert.match(consumerTests, /(?:replay|duplicate|重复)[\s\S]{0,900}(?:orderNo|OrderNo)/i) +}) + +test('Admin displays and copies the exact queried number without reformatting', () => { + const value = 'LMO-260827-0042' + assert.equal(adminPreservesOrderNumber(value, value, value), true) + assert.equal(adminPreservesOrderNumber(value, value.replaceAll('-', ''), value), false) + assert.match(adminPresentation, /orderNo:\s*text\(dto\.orderNo\)/) + assert.match(adminList, /prop:\s*'orderNo'/) + assert.match(adminDetail, /reference-value="referenceValue"/) + const adminOrderSources = `${adminList}\n${adminDetail}` + assert.match(adminOrderSources, /(?:clipboard\.writeText|copyText|copyToClipboard)[\s\S]{0,240}(?:orderNo|referenceValue)|(?:orderNo|referenceValue)[\s\S]{0,240}(?:clipboard\.writeText|copyText|copyToClipboard)/) +})