#!/usr/bin/env node import { randomUUID } from 'node:crypto' import { mkdir, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' const args = parseArgs(process.argv.slice(2)) if (args.help) { console.log('Usage: node scripts/client-quick-order-test-env-acceptance.mjs [--report-dir ] [--timeout-ms ]') process.exit(0) } const gateway = trimSlash(process.env.ADMIN_TEST_GATEWAY) const username = process.env.MERCHANT_TEST_USERNAME const password = process.env.MERCHANT_TEST_PASSWORD const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports') const marker = `${compactTimestamp()}-${randomUUID().slice(0, 8)}` const reportPath = resolve(reportDir, `client-quick-order-test-env-${marker}.json`) const report = { marker, generatedAt: new Date().toISOString(), completedAt: null, success: false, fatal: null, resources: { batchKey: null, orderNos: [] }, steps: [], } if (!gateway || !username || !password) { console.error('ADMIN_TEST_GATEWAY, MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD are required') process.exit(2) } if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { console.error('--timeout-ms must be at least 1000') process.exit(2) } let token try { await step('unauthenticated batch request is rejected', async () => { const response = await rawRequest('/api/merchant/v1/orders/first-leg/batch', { method: 'POST', body: [], headers: { 'Idempotency-Key': `quick-order-anonymous-${marker}` }, }) assert([401, 403].includes(response.status), `expected HTTP 401/403, got ${response.status}`) return { httpStatus: response.status } }) const login = await step('merchant login', async () => { const response = await jsonRequest('/api/auth/login', { method: 'POST', body: { loginId: username, password }, authenticated: false, }) requireSuccess(response) assert(response.data?.accessToken, 'accessToken is required') token = response.data.accessToken return { authenticated: true } }) assert(login.authenticated, 'login did not complete') const profile = await step('load authoritative enterprise profile', async () => { const response = await jsonRequest('/api/merchant/v1/iam/tenant/profile') requireSuccess(response) assert(response.data?.tenantName?.trim(), 'tenantName is required') return { tenantId: String(response.data.id), tenantName: response.data.tenantName.trim() } }) const policy = await step('load inbound-code policy', async () => { const response = await jsonRequest('/api/merchant/v1/orders/first-leg/inbound-code-policy') requireSuccess(response) assert(['AUTO', 'LEGACY_MANUAL'].includes(response.data?.mode), `unexpected policy ${response.data?.mode}`) return response.data }) const privateAddresses = await step('load customer-owned private destination addresses', async () => { const response = await jsonRequest('/api/merchant/v1/orders/first-leg/addresses?addressType=PRIVATE_DESTINATION') requireSuccess(response) const addresses = arrayData(response.data) assert(addresses.length > 0, 'at least one customer-owned private destination address is required') return addresses }) const domestic = await loadWarehouses('DOMESTIC') const fba = await loadWarehouses('FBA') const overseas = await loadWarehouses('OVERSEAS') assert(domestic.length > 0, 'at least one ACTIVE domestic warehouse is required') assert(fba.length > 0, 'at least one ACTIVE FBA warehouse is required') assert(overseas.length > 0, 'at least one ACTIVE overseas warehouse is required') let receiving let channel for (const warehouse of domestic) { const response = await jsonRequest(`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${warehouse.id}`) requireSuccess(response) const candidate = arrayData(response.data).find((item) => arrayData(item.pricingTaxModes).length > 0) if (candidate) { receiving = warehouse; channel = candidate; break } } assert(receiving && channel, 'no domestic warehouse has an active pricing channel') const privateAddress = privateAddresses[0] const privateDestination = { destinationType: 'PRIVATE', destinationCountryCode: privateAddress.countryCode, destinationWarehouseName: privateAddress.addressName, privateDestinationAddressId: String(privateAddress.id), } const rows = [ { clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, { destinationType: 'FBA', destinationCountryCode: fba[0].countryCode, destinationWarehouseId: fba[0].id, destinationWarehouseName: fba[0].warehouseName, amazonWarehouseCode: fba[0].warehouseCode, }, 'FBA') }, { clientRowId: `quick-order-overseas-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, { destinationType: 'OVERSEAS', destinationCountryCode: overseas[0].countryCode, destinationWarehouseId: overseas[0].id, destinationWarehouseName: overseas[0].warehouseName, }, 'OVERSEAS') }, { clientRowId: `quick-order-private-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, privateDestination, 'PRIVATE') }, { clientRowId: `quick-order-invalid-channel-${marker}`, draft: { ...quickDraft(profile.tenantName, policy, receiving, channel, { destinationType: 'FBA', destinationCountryCode: fba[0].countryCode, destinationWarehouseId: fba[0].id, destinationWarehouseName: fba[0].warehouseName, amazonWarehouseCode: fba[0].warehouseCode, }, 'INVALID'), channelCode: `INVALID-${marker}`, channelName: '不存在的渠道', } }, ] const batchKey = `quick-order-http-${randomUUID()}` report.resources.batchKey = batchKey const created = await step('create FBA, overseas and private quick-order drafts with one invalid row', async () => { const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey }, }) requireSuccess(response) assert(response.data?.total === 4, `expected total=4, got ${response.data?.total}`) const rowEvidence = arrayData(response.data?.items).map((item) => ({ clientRowId: item.clientRowId, success: item.success, orderId: item.orderId, orderNo: item.orderNo, errorCode: item.errorCode, errorMessage: item.errorMessage, })) assert(response.data?.succeeded === 3, `expected succeeded=3, got ${response.data?.succeeded}; rows=${JSON.stringify(rowEvidence)}`) assert(response.data?.failed === 1, `expected failed=1, got ${response.data?.failed}; rows=${JSON.stringify(rowEvidence)}`) const failed = response.data.items.find((item) => item.clientRowId === rows[3].clientRowId) assert(failed && !failed.success && failed.errorCode, 'invalid channel row must fail independently') return { ...response.data, items: rowEvidence } }) const successfulItems = created.items.filter((item) => item.success) for (const item of successfulItems) { await step(`verify created draft ${item.clientRowId}`, async () => { assert(item.orderId != null, 'successful row must include orderId') assert(item.orderNo, 'successful row must include orderNo') const response = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`) requireSuccess(response) assert(response.data?.order?.status === 'DRAFT', `expected DRAFT, got ${response.data?.order?.status}`) assert(response.data?.order?.customerName === profile.tenantName, 'draft must belong to the authenticated enterprise') report.resources.orderNos.push(item.orderNo) return { orderNo: item.orderNo, status: response.data.order.status, customerName: response.data.order.customerName } }) } await step('same idempotency key replays the same quick-order drafts', async () => { const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey }, }) requireSuccess(response) const originalIds = successfulItems.map((item) => String(item.orderId)) const replayIds = response.data.items.filter((item) => item.success).map((item) => String(item.orderId)) assert(JSON.stringify(replayIds) === JSON.stringify(originalIds), 'idempotent replay returned different order IDs') return { replayedOrderNos: response.data.items.filter((item) => item.success).map((item) => item.orderNo) } }) report.success = report.steps.every((item) => item.passed) } catch (error) { report.fatal = sanitizeError(error) report.success = false } finally { report.completedAt = new Date().toISOString() await mkdir(reportDir, { recursive: true }) await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') console.log(JSON.stringify({ success: report.success, reportPath, orderNos: report.resources.orderNos, fatal: report.fatal })) } process.exitCode = report.success ? 0 : 1 function quickDraft(customerName, policy, receiving, channel, destination, suffix) { const inboundCode = policy.mode === 'LEGACY_MANUAL' ? `QO${compactTimestamp()}${suffix.slice(0, 3)}` : undefined return { customerName, transportMode: 'SEA', tradeTerm: 'FOB', expectedShipDate: undefined, customerRemark: `快速下单 HTTP 验收 ${marker} ${suffix}`, pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: receiving.id, receivingWarehouseName: receiving.warehouseName, channelCode: channel.channelCode, channelName: channel.channelName, pricingTaxMode: channel.pricingTaxModes[0], taxMode: 'BUY_DECLARATION', containerMode: 'FULL_CONTAINER', customsInspection: null, inboundCode, ...destination, cargoes: [{ cargoName: '', packageType: 'CARTON', packageCount: 2, grossWeightKg: 0, volumeCbm: 0, currency: 'USD', dangerousGoods: false, woodenProduct: false, skuNo: `SKU-${suffix}-${marker}`, totalQuantity: 2, unitsPerPackage: 1, }], } } async function loadWarehouses(type) { const all = [] for (let page = 1; ; page += 1) { const response = await jsonRequest(`/api/merchant/v1/warehouses?page=${page}&size=200&status=ACTIVE&warehouseType=${type}`) requireSuccess(response) all.push(...pageRecords(response.data)) if (page >= Number(response.data?.pages ?? 1)) return all } } async function step(name, action) { const startedAt = new Date().toISOString() try { const evidence = await action() report.steps.push({ name, passed: true, startedAt, completedAt: new Date().toISOString(), evidence }) return evidence } catch (error) { report.steps.push({ name, passed: false, startedAt, completedAt: new Date().toISOString(), error: sanitizeError(error) }) throw error } } async function rawRequest(path, { method = 'GET', body, headers = {}, authenticated = true } = {}) { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) const requestHeaders = { ...headers } if (authenticated && token) requestHeaders.Authorization = `Bearer ${token}` let requestBody = body if (body != null && !(body instanceof FormData)) { requestHeaders['Content-Type'] = 'application/json' requestBody = JSON.stringify(body) } try { return await fetch(`${gateway}${path}`, { method, headers: requestHeaders, body: requestBody, signal: controller.signal }) } finally { clearTimeout(timer) } } async function jsonRequest(path, options) { const response = await rawRequest(path, options) const text = await response.text() let json try { json = text ? JSON.parse(text) : {} } catch { throw new Error(`${path} returned non-JSON HTTP ${response.status}`) } if (!response.ok) throw new Error(`${path} HTTP ${response.status} code=${json.code ?? '—'} requestId=${json.requestId ?? '—'} traceId=${json.traceId ?? '—'}`) return json } function requireSuccess(response) { if (String(response?.code) !== '200') { throw new Error(`business failure code=${response?.code ?? '—'} message=${response?.message ?? '—'} requestId=${response?.requestId ?? '—'} traceId=${response?.traceId ?? '—'}`) } } function parseArgs(values) { const result = {} for (let index = 0; index < values.length; index += 1) { const value = values[index] if (value === '--help') { result.help = true; continue } if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`) const key = value.slice(2) const next = values[++index] if (!next || next.startsWith('--')) throw new Error(`--${key} requires a value`) result[key] = next } return result } function trimSlash(value) { return String(value ?? '').replace(/\/+$/, '') } function compactTimestamp() { return new Date().toISOString().replace(/\D/g, '').slice(0, 14) } function pageRecords(value) { return value?.records ?? value?.content ?? value?.items ?? [] } function arrayData(value) { return Array.isArray(value) ? value : [] } function assert(condition, message) { if (!condition) throw new Error(message) } function sanitizeError(error) { return { name: error?.name ?? 'Error', message: String(error?.message ?? error) } }