test(events): add cross-region HTTP acceptance gates
This commit is contained in:
@@ -13,7 +13,7 @@ const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
|
||||
const DEFAULT_OVERSEAS_GATEWAY = 'http://8.163.30.128:9300'
|
||||
const DEFAULT_USERNAME = 'platform_admin'
|
||||
const DEFAULT_PASSWORD = 'ChangeMe123!'
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
|
||||
|
||||
export function normalizeModuleOrder(modules) {
|
||||
const normalized = [...modules]
|
||||
@@ -65,6 +65,14 @@ export function normalizeModuleOrder(modules) {
|
||||
&& !normalized.includes('merchant-qualification-fixture')) {
|
||||
normalized.splice(normalized.indexOf('phase4-overseas-business'), 0, 'merchant-qualification-fixture')
|
||||
}
|
||||
if (normalized.includes('phase4-cross-region-events')) {
|
||||
const prerequisites = ['system', 'merchant-qualification-fixture', 'orders', 'inbound-measurement',
|
||||
'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate',
|
||||
'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda']
|
||||
const withoutPrerequisites = normalized.filter((module) => !prerequisites.includes(module))
|
||||
withoutPrerequisites.splice(withoutPrerequisites.indexOf('phase4-cross-region-events'), 0, ...prerequisites)
|
||||
normalized.splice(0, normalized.length, ...withoutPrerequisites)
|
||||
}
|
||||
const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']
|
||||
const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name))
|
||||
if (firstFinanceIndex >= 0) {
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function run(context) {
|
||||
| Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` |
|
||||
| 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
|
||||
| 四期海外 PDA 入仓 HTTP 生命周期 | `phase4-overseas-pda.mjs`(自动选择未转换尾程单、创建目标仓/库位/托盘并完成入库;完成事实保留审计) | `phase4-overseas-pda-contract.test.mjs` |
|
||||
| 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
|
||||
| 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
| 四期船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` |
|
||||
@@ -66,7 +67,7 @@ export async function run(context) {
|
||||
四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块只调用 `/api/pda/v1/last-leg/inbound-plans/**` 完成入仓,不调用出库、派送或内部接口;船节点禁止手工创建任务。
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --overseas-gateway <development-overseas-gateway> --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes
|
||||
node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --overseas-gateway <development-overseas-gateway> --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes
|
||||
```
|
||||
|
||||
只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
import { run } from './phase4-cross-region-events.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./phase4-cross-region-events.mjs', import.meta.url), 'utf8')
|
||||
const system = readFileSync(new URL('./system.mjs', import.meta.url), 'utf8')
|
||||
|
||||
function response(path, data, http = 200, code = '200') {
|
||||
return { method: 'GET', path, http, body: { code, data }, requestId: 'request-1', traceId: 'trace-1' }
|
||||
}
|
||||
|
||||
function context(resources = {}) {
|
||||
const steps = []
|
||||
const gaps = []
|
||||
const calls = []
|
||||
return {
|
||||
marker: 'MARKER-1', token: 'domestic-token', overseasToken: 'overseas-token',
|
||||
createdResources: resources, steps, gaps, calls,
|
||||
addCoverageGap(module, scenario, name, reason, category, metadata) {
|
||||
gaps.push({ module, scenario, name, reason, category, ...metadata })
|
||||
},
|
||||
addStep(module, name, value, passed, evidence, options) {
|
||||
steps.push({ module, name, passed: Boolean(passed), evidence, options })
|
||||
return Boolean(passed)
|
||||
},
|
||||
assert(module, name, value, passed, evidence, options) {
|
||||
return this.addStep(module, name, value, passed, evidence, options)
|
||||
},
|
||||
async requestOverseas(method, path, body, headers, token) {
|
||||
calls.push({ region: 'OVERSEAS', method, path, token: token || this.overseasToken })
|
||||
if (token === this.token) return response(path, null, 403, 'A0403')
|
||||
if (path.includes('/last-leg/orders/9001')) {
|
||||
return response(path, { order: { id: '9001', containerNo: 'MSCU1234567' } })
|
||||
}
|
||||
return response(path, { records: [{ id: '7001', containerNo: 'MSCU1234567' }] })
|
||||
},
|
||||
async request(method, path, body, token) {
|
||||
calls.push({ region: 'CN', method, path, token: token || this.token })
|
||||
if (token === this.overseasToken) return response(path, null, 403, 'A0403')
|
||||
if (path.includes('/overview')) return response(path, { container: {
|
||||
id: '3001', containerNo: 'MSCU1234567', unloadingCompletedAt: '2026-08-24T10:00:00Z',
|
||||
unloadingPhotoUploadedAt: '2026-08-24T09:00:00Z', estimatedEmptyReportedAt: '2026-08-26T09:00:00Z',
|
||||
actualEmptyReportedAt: null, emptyReportSourceVersion: 7,
|
||||
} })
|
||||
return response(path, { records: [{ id: '3001', containerNo: 'MSCU1234567' }] })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('entry orders marker-owned prerequisites before cross-region acceptance', () => {
|
||||
const modules = normalizeModuleOrder(['phase4-cross-region-events'])
|
||||
const cross = modules.indexOf('phase4-cross-region-events')
|
||||
for (const prerequisite of ['system', 'phase3-loading-finalize', 'phase4-overseas-pda']) {
|
||||
assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < cross, prerequisite)
|
||||
}
|
||||
})
|
||||
|
||||
test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => {
|
||||
for (const token of [
|
||||
'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered',
|
||||
'overseas-cn-event-not-delivered', 'requestId=', 'traceId=', 'pollRequests=',
|
||||
'装柜完成事件重试保持建单幂等', '海外柜完成事件重试保持回写幂等',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
assert.match(system, /缺少canonical海外订单权限必须403/)
|
||||
assert.match(system, /restrictedOverseasToken/)
|
||||
})
|
||||
|
||||
test('full observable cross-region flow performs requests and passes', async () => {
|
||||
const ctx = context({
|
||||
phase3Loading: { marker: 'MARKER-1', finalizeSucceeded: true, containerBookingId: '3001',
|
||||
containerNo: 'MSCU1234567', loadingPlanId: '4001' },
|
||||
phase4OverseasPda: { marker: 'MARKER-1', finalStatus: 'COMPLETED', customerId: '100', orderId: '9001' },
|
||||
})
|
||||
|
||||
await run(ctx)
|
||||
|
||||
assert.ok(ctx.calls.length >= 8)
|
||||
assert.equal(ctx.steps.every((step) => step.passed), true)
|
||||
assert.equal(ctx.gaps.length, 0)
|
||||
assert.ok(ctx.calls.some((call) => call.region === 'CN' && call.token === 'overseas-token'))
|
||||
assert.ok(ctx.calls.some((call) => call.region === 'OVERSEAS' && call.token === 'domestic-token'))
|
||||
})
|
||||
|
||||
test('missing marker fixture is a blocking coverage gap after real security request', async () => {
|
||||
const ctx = context()
|
||||
|
||||
await assert.rejects(() => run(ctx), /blocking coverage gap/)
|
||||
|
||||
assert.ok(ctx.calls.length > 0)
|
||||
assert.equal(ctx.gaps.length, 1)
|
||||
assert.equal(ctx.gaps[0].deploymentBlocker, true)
|
||||
assert.equal(ctx.steps.some((step) => !step.passed && step.options?.deploymentBlocker), true)
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
const MODULE = 'phase4-cross-region-events'
|
||||
const OPTIONS = { scenario: 'phase4-cross-region-events', deploymentBlocker: true }
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] }
|
||||
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||
function denied(response) {
|
||||
return response?.http === 401 || response?.http === 403
|
||||
|| ['401', '403', 'A0401', 'A0403'].includes(String(response?.body?.code))
|
||||
}
|
||||
function traced(response) { return Boolean(response?.requestId) && Boolean(response?.traceId) }
|
||||
function evidence(response, extra = '') {
|
||||
return `${extra}${extra ? '; ' : ''}HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
||||
}
|
||||
function blockingGap(context, name, reason, category, response = null) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, name, reason, category, { deploymentBlocker: true })
|
||||
context.addStep(MODULE, name, response, false, reason, OPTIONS)
|
||||
throw new Error(`blocking coverage gap: ${name}: ${reason}`)
|
||||
}
|
||||
async function poll(request, matches, attempts = 12) {
|
||||
let last = null
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
last = await request()
|
||||
if (matches(last)) return { response: last, attempts: index + 1 }
|
||||
if (index + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
|
||||
}
|
||||
return { response: last, attempts }
|
||||
}
|
||||
function sameIds(left, right) {
|
||||
const ids = (value) => records(value).map((item) => id(item?.id)).filter(Boolean).sort()
|
||||
return JSON.stringify(ids(left)) === JSON.stringify(ids(right))
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
if (!context.token || !context.overseasToken) {
|
||||
blockingGap(context, '双Token前置', '国内或海外访问令牌缺失,不能执行跨区安全门禁', 'missing-dual-token')
|
||||
}
|
||||
|
||||
const domesticTokenAgainstOverseas = await context.requestOverseas('GET',
|
||||
'/api/platform/v1/last-leg/orders?page=1&size=1', undefined, {}, context.token)
|
||||
context.assert(MODULE, '国内Token不能访问海外Audience', domesticTokenAgainstOverseas,
|
||||
denied(domesticTokenAgainstOverseas) && traced(domesticTokenAgainstOverseas),
|
||||
evidence(domesticTokenAgainstOverseas), OPTIONS)
|
||||
|
||||
const phase3 = context.createdResources?.phase3Loading
|
||||
if (phase3?.marker !== context.marker || !phase3?.finalizeSucceeded
|
||||
|| !phase3?.containerBookingId || !phase3?.containerNo || !phase3?.loadingPlanId) {
|
||||
blockingGap(context, '国内装柜完成事件前置',
|
||||
'缺少本轮marker的LOADED装柜事实;必须先完成三期装柜链,禁止借用历史事件',
|
||||
'missing-marker-loading-completed')
|
||||
}
|
||||
|
||||
const overseasTokenAgainstDomestic = await context.request('GET',
|
||||
`/api/platform/v1/containers/${encodeURIComponent(id(phase3.containerBookingId))}`,
|
||||
undefined, context.overseasToken)
|
||||
context.assert(MODULE, '海外Token不能访问国内Audience', overseasTokenAgainstDomestic,
|
||||
denied(overseasTokenAgainstDomestic) && traced(overseasTokenAgainstDomestic),
|
||||
evidence(overseasTokenAgainstDomestic), OPTIONS)
|
||||
|
||||
const taillegPath = `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(phase3.containerNo)}`
|
||||
const delivered = await poll(
|
||||
() => context.requestOverseas('GET', taillegPath),
|
||||
(response) => success(response) && records(response.body?.data)
|
||||
.some((item) => item?.containerNo === phase3.containerNo))
|
||||
const deliveredRows = records(delivered.response?.body?.data)
|
||||
.filter((item) => item?.containerNo === phase3.containerNo)
|
||||
context.assert(MODULE, '国内装柜完成经Bridge生成Lastleg订单', delivered.response,
|
||||
deliveredRows.length > 0 && traced(delivered.response),
|
||||
evidence(delivered.response, `pollRequests=${delivered.attempts}, containerNo=${phase3.containerNo}, orders=${deliveredRows.length}`), OPTIONS)
|
||||
if (deliveredRows.length === 0) {
|
||||
blockingGap(context, '国内到海外事件闭环',
|
||||
`本轮柜号${phase3.containerNo}在轮询后仍无Lastleg订单;需结合requestId/traceId检查Bridge重试与下游诊断`,
|
||||
'cn-overseas-event-not-delivered', delivered.response)
|
||||
}
|
||||
|
||||
const deliveredAgain = await context.requestOverseas('GET', taillegPath)
|
||||
context.assert(MODULE, '装柜完成事件重试保持建单幂等', deliveredAgain,
|
||||
success(deliveredAgain) && sameIds(delivered.response.body?.data, deliveredAgain.body?.data)
|
||||
&& traced(deliveredAgain),
|
||||
evidence(deliveredAgain, `firstOrders=${deliveredRows.map((item) => id(item.id)).join(',')}`), OPTIONS)
|
||||
|
||||
const overseasPda = context.createdResources?.phase4OverseasPda
|
||||
if (overseasPda?.marker !== context.marker || overseasPda?.finalStatus !== 'COMPLETED'
|
||||
|| !overseasPda?.customerId || !overseasPda?.orderId) {
|
||||
blockingGap(context, '海外柜完成事件前置',
|
||||
'缺少本轮marker的海外PDA COMPLETED柜事实;必须先完成四期海外PDA闭环',
|
||||
'missing-marker-overseas-container-completed')
|
||||
}
|
||||
|
||||
const orderDetailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(overseasPda.orderId)}?customerId=${encodeURIComponent(overseasPda.customerId)}`
|
||||
const orderDetail = await context.requestOverseas('GET', orderDetailPath)
|
||||
const order = orderDetail.body?.data?.order || orderDetail.body?.data
|
||||
const completedContainerNo = order?.containerNo || ''
|
||||
context.assert(MODULE, '读取海外完成柜来源尾程单', orderDetail,
|
||||
success(orderDetail) && Boolean(completedContainerNo) && traced(orderDetail),
|
||||
evidence(orderDetail, `orderId=${overseasPda.orderId}, containerNo=${completedContainerNo || 'missing'}`), OPTIONS)
|
||||
if (!completedContainerNo) {
|
||||
blockingGap(context, '海外完成柜来源身份', '尾程订单未返回containerNo,无法核对国内回写',
|
||||
'missing-overseas-container-identity', orderDetail)
|
||||
}
|
||||
|
||||
const containerPagePath = `/api/platform/v1/containers?containerNo=${encodeURIComponent(completedContainerNo)}&page=1&size=20`
|
||||
const containerPage = await context.request('GET', containerPagePath)
|
||||
const domesticMatches = records(containerPage.body?.data)
|
||||
.filter((item) => item?.containerNo === completedContainerNo)
|
||||
context.assert(MODULE, '物理柜号唯一定位国内柜聚合', containerPage,
|
||||
success(containerPage) && domesticMatches.length === 1 && traced(containerPage),
|
||||
evidence(containerPage, `containerNo=${completedContainerNo}, matches=${domesticMatches.length}`), OPTIONS)
|
||||
if (domesticMatches.length !== 1) {
|
||||
blockingGap(context, '海外到国内柜身份映射',
|
||||
`物理柜号${completedContainerNo}匹配国内柜数量为${domesticMatches.length},无法验证回写`,
|
||||
'domestic-container-identity-not-unique', containerPage)
|
||||
}
|
||||
|
||||
const overviewPath = `/api/platform/v1/containers/${encodeURIComponent(id(domesticMatches[0].id))}/overview`
|
||||
const writtenBack = await poll(
|
||||
() => context.request('GET', overviewPath),
|
||||
(response) => {
|
||||
const container = response?.body?.data?.container
|
||||
return success(response) && Boolean(container?.unloadingCompletedAt)
|
||||
&& Boolean(container?.unloadingPhotoUploadedAt) && Boolean(container?.estimatedEmptyReportedAt)
|
||||
})
|
||||
const snapshot = writtenBack.response?.body?.data?.container || {}
|
||||
const completedAt = Date.parse(snapshot.unloadingCompletedAt)
|
||||
const uploadedAt = Date.parse(snapshot.unloadingPhotoUploadedAt)
|
||||
const estimatedAt = Date.parse(snapshot.estimatedEmptyReportedAt)
|
||||
const validTimeline = Number.isFinite(completedAt) && Number.isFinite(uploadedAt)
|
||||
&& Number.isFinite(estimatedAt) && completedAt >= uploadedAt
|
||||
&& estimatedAt - uploadedAt === 2 * 24 * 60 * 60 * 1_000
|
||||
context.assert(MODULE, '海外柜完成经Bridge回写Firstleg时间事实', writtenBack.response,
|
||||
validTimeline && traced(writtenBack.response),
|
||||
evidence(writtenBack.response,
|
||||
`pollRequests=${writtenBack.attempts}, completed=${snapshot.unloadingCompletedAt || 'missing'}, photo=${snapshot.unloadingPhotoUploadedAt || 'missing'}, estimatedEmpty=${snapshot.estimatedEmptyReportedAt || 'missing'}`), OPTIONS)
|
||||
if (!validTimeline) {
|
||||
blockingGap(context, '海外到国内事件闭环',
|
||||
`柜号${completedContainerNo}轮询后仍无有效卸柜完成/拍照/预计报空回写;需检查Bridge重试与诊断证据`,
|
||||
'overseas-cn-event-not-delivered', writtenBack.response)
|
||||
}
|
||||
|
||||
const writtenBackAgain = await context.request('GET', overviewPath)
|
||||
const replaySnapshot = writtenBackAgain.body?.data?.container || {}
|
||||
const frozen = ['unloadingCompletedAt', 'unloadingPhotoUploadedAt', 'estimatedEmptyReportedAt',
|
||||
'actualEmptyReportedAt', 'emptyReportSourceVersion']
|
||||
context.assert(MODULE, '海外柜完成事件重试保持回写幂等', writtenBackAgain,
|
||||
success(writtenBackAgain) && frozen.every((field) => (replaySnapshot[field] ?? null) === (snapshot[field] ?? null))
|
||||
&& traced(writtenBackAgain),
|
||||
evidence(writtenBackAgain, `containerId=${id(domesticMatches[0].id)}, sourceVersion=${snapshot.emptyReportSourceVersion ?? 'missing'}`), OPTIONS)
|
||||
}
|
||||
@@ -4,10 +4,16 @@ import test from 'node:test'
|
||||
import { run } from './phase4-overseas-master-data.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./phase4-overseas-master-data.mjs', import.meta.url), 'utf8')
|
||||
const controllerSource = [
|
||||
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java',
|
||||
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java',
|
||||
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java',
|
||||
].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8')).join('\n')
|
||||
const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin'
|
||||
const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data'
|
||||
const ok = (data) => ({ http: 200, body: { code: '200', message: 'success', data }, requestId: 'req-p4', traceId: 'trace-p4' })
|
||||
const conflict = () => ({ http: 200, body: { code: 'A0409', message: 'record version conflict', data: null }, requestId: 'req-conflict' })
|
||||
const forbidden = () => ({ http: 403, body: { code: 'A0403', message: 'Access denied', data: null }, requestId: 'req-forbidden' })
|
||||
|
||||
function detailQuote(state) {
|
||||
return {
|
||||
@@ -22,21 +28,25 @@ function vehicleWire(state) {
|
||||
return { ...state.vehicle, mileageKm: String(state.vehicle.mileageKm), recordVersion: String(state.vehicle.recordVersion) }
|
||||
}
|
||||
|
||||
function fakeContext() {
|
||||
function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
|
||||
const calls = []
|
||||
const coverageGaps = []
|
||||
const steps = []
|
||||
const marker = 'ADMIN-API-20260821123456789'
|
||||
const state = { warehouse: null, quote: null, exchange: null, vehicle: null }
|
||||
const state = { warehouse: null, quote: null, exchange: null, vehicle: null, optionReads: 0, role: null, user: null }
|
||||
const context = {
|
||||
marker, calls, coverageGaps, createdResources: {},
|
||||
marker, calls, coverageGaps, steps, createdResources: {},
|
||||
async findAvailablePhone() { return '13800138000' },
|
||||
apiSuccess(_module, name, response) {
|
||||
assert.equal(response?.http, 200, `${name}: HTTP`)
|
||||
assert.equal(String(response?.body?.code), '200', `${name}: code`)
|
||||
return response.body.data
|
||||
},
|
||||
assert(_module, name, _response, condition, evidence) {
|
||||
assert.equal(Boolean(condition), true, `${name}: ${evidence}`)
|
||||
return true
|
||||
assert(_module, name, _response, condition, evidence, options = {}) {
|
||||
const passed = Boolean(condition)
|
||||
steps.push({ name, passed, deploymentBlocker: Boolean(options.deploymentBlocker && !passed), evidence })
|
||||
if (!passed && !softAssertions) assert.equal(passed, true, `${name}: ${evidence}`)
|
||||
return passed
|
||||
},
|
||||
expectConflict(_module, name, response) {
|
||||
assert.equal(response.http, 200, `${name}: HTTP`)
|
||||
@@ -64,12 +74,40 @@ function fakeContext() {
|
||||
state.warehouse.status = 'INACTIVE'
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'POST' && path === '/api/platform/v1/iam/roles') {
|
||||
state.role = { id: '940000000000000001', ...body, status: 'ACTIVE' }
|
||||
return ok({ ...state.role })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/platform/v1/iam/users') {
|
||||
state.user = { membershipId: '950000000000000001', ...body, status: 'ACTIVE' }
|
||||
return ok({ ...state.user })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/auth/login') {
|
||||
return ok({ accessToken: 'deny-domestic-token', overseasAccessToken: 'deny-overseas-token', overseasExpiresIn: 900 })
|
||||
}
|
||||
if (method === 'PUT' && path === '/api/platform/v1/iam/users/950000000000000001/status') {
|
||||
state.user.status = body.status
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'PUT' && path === '/api/platform/v1/iam/roles/940000000000000001/status') {
|
||||
state.role.status = body.status
|
||||
return ok(null)
|
||||
}
|
||||
assert.fail(`unexpected domestic call ${method} ${path}`)
|
||||
},
|
||||
async requestOverseas(method, path, body) {
|
||||
calls.push({ gateway: 'overseas', method, path, body })
|
||||
async requestOverseas(method, path, body, extraHeaders = {}, token = 'admin-overseas-token') {
|
||||
calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' })
|
||||
assert.notEqual(method, 'DELETE')
|
||||
if (token === 'deny-overseas-token') return forbidden()
|
||||
if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) {
|
||||
state.optionReads += 1
|
||||
if (!state.warehouse) return ok([])
|
||||
return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode,
|
||||
warehouseName: state.warehouse.warehouseName, countryCode: 'US' }])
|
||||
}
|
||||
if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) {
|
||||
state.optionReads += 1
|
||||
if (refreshEmpty) return ok([])
|
||||
return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode,
|
||||
warehouseName: state.warehouse.warehouseName, countryCode: 'US' }])
|
||||
}
|
||||
@@ -81,6 +119,10 @@ function fakeContext() {
|
||||
}
|
||||
return ok(detailQuote(state.quote))
|
||||
}
|
||||
if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=`)) {
|
||||
return ok({ records: state.quote ? [{ ...state.quote.summary }] : [], page: 1, size: 20,
|
||||
total: state.quote ? 1 : 0, pages: state.quote ? 1 : 0 })
|
||||
}
|
||||
if (path === `${OVERSEAS_BASE}/overseas-quotes/920000000000000001`) {
|
||||
if (method === 'GET') return ok(detailQuote(state.quote))
|
||||
if (method === 'PUT') {
|
||||
@@ -100,7 +142,10 @@ function fakeContext() {
|
||||
return ok(detailQuote(state.quote))
|
||||
}
|
||||
if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=`)) {
|
||||
return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
const matches = state.exchange && path.includes(`monthFrom=${state.exchange.month}`)
|
||||
&& (!path.includes('status=') || path.includes(`status=${state.exchange.status}`))
|
||||
return ok({ records: matches ? [{ ...state.exchange }] : [], page: 1, size: 20,
|
||||
total: matches ? 1 : 0, pages: matches ? 1 : 0 })
|
||||
}
|
||||
if (method === 'POST' && path === `${OVERSEAS_BASE}/exchange-rates`) {
|
||||
state.exchange = { ...body, baseCurrency: 'USD', quoteCurrency: 'CNY', status: 'ACTIVE', recordVersion: 0 }
|
||||
@@ -139,13 +184,21 @@ function fakeContext() {
|
||||
state.vehicle = { ...state.vehicle, status: body.status, recordVersion: state.vehicle.recordVersion + 1 }
|
||||
return ok(vehicleWire(state))
|
||||
}
|
||||
if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=`)) {
|
||||
return ok({ records: state.vehicle ? [vehicleWire(state)] : [], page: 1, size: 20,
|
||||
total: state.vehicle ? 1 : 0, pages: state.vehicle ? 1 : 0 })
|
||||
}
|
||||
assert.fail(`unexpected overseas call ${method} ${path}`)
|
||||
},
|
||||
async requestOverseasBinary(method, path) {
|
||||
calls.push({ gateway: 'overseas-binary', method, path })
|
||||
assert.equal(method, 'GET')
|
||||
assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/)
|
||||
return { method, path, http: 200, body: null, bytes: new TextEncoder().encode('id,status\n1,ACTIVE\n'),
|
||||
let csv = ''
|
||||
if (path.includes('/overseas-quotes/')) csv = `报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n${state.quote.summary.id},${state.quote.summary.name},${state.quote.summary.warehouseCode},${state.quote.summary.warehouseName},${state.quote.summary.providerName},USD,2098-01-01,2098-12-31,V1,ACTIVE,2026-08-21\r\n`
|
||||
else if (path.includes('/exchange-rates/')) csv = `月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n${state.exchange.month},USD,CNY,${state.exchange.usdToCnyAverage},${state.exchange.status},admin,2026-08-21\r\n`
|
||||
else csv = `车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n${state.vehicle.id},${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},2026-08-21\r\n`
|
||||
return { method, path, http: 200, body: null, bytes: new TextEncoder().encode(`\uFEFF${csv}`),
|
||||
contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' }
|
||||
},
|
||||
}
|
||||
@@ -160,13 +213,20 @@ test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => {
|
||||
assert.match(source, /context\.requestOverseas\('PATCH'/)
|
||||
assert.match(source, /context\.requestOverseasBinary\('GET'/)
|
||||
assert.match(source, /context\.expectConflict/)
|
||||
assert.match(source, /warehouse-options\?refresh=true/)
|
||||
assert.match(source, /空候选不负缓存30分钟/)
|
||||
assert.match(source, /30分钟正缓存与刷新快照一致/)
|
||||
assert.match(source, /无quote:list权限访问海外报价被拒绝/)
|
||||
assert.match(source, /表头与marker内容/)
|
||||
assert.match(source, /finally\s*\{/)
|
||||
assert.match(source, /context\.createdResources\.phase4OverseasMasterData/)
|
||||
assert.match(source, /context\.addCoverageGap/)
|
||||
assert.doesNotMatch(source, /context\.addCoverageGap/)
|
||||
assert.match(source, /2090 \+ Math\.floor/)
|
||||
assert.doesNotMatch(source, /\/internal\/|overseas-partners|https?:\/\/|fetch\(|process\.env|request(?:Overseas)?\('DELETE'/)
|
||||
for (const endpoint of ['overseas-quotes/warehouse-options', 'overseas-quotes/export',
|
||||
'exchange-rates/export', 'vehicles/export']) assert.ok(source.includes(endpoint))
|
||||
assert.doesNotMatch(controllerSource, /Idempotency-Key|IdempotencyKey/)
|
||||
assert.doesNotMatch(source, /Idempotency-Key/)
|
||||
})
|
||||
|
||||
test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全部停用', async () => {
|
||||
@@ -186,30 +246,39 @@ test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全
|
||||
assert.equal(state.quote.summary.status, 'INACTIVE')
|
||||
assert.equal(state.exchange.status, 'INACTIVE')
|
||||
assert.equal(state.vehicle.status, 'INACTIVE')
|
||||
assert.equal(state.role.status, 'DISABLED')
|
||||
assert.equal(state.user.status, 'DISABLED')
|
||||
assert.equal(state.optionReads, 4)
|
||||
assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 3)
|
||||
assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0)
|
||||
assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 7)
|
||||
assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 9)
|
||||
assert.equal(context.calls.filter((call) => call.tokenKind === 'denied').length, 1)
|
||||
assert.equal(context.coverageGaps.length, 0)
|
||||
})
|
||||
|
||||
test('候选空缓存登记覆盖缺口并继续汇率车辆,finally停用国内合作仓', async () => {
|
||||
const { context, state } = fakeContext()
|
||||
const original = context.requestOverseas
|
||||
context.requestOverseas = async (method, path, body) => {
|
||||
if (method === 'GET' && path.endsWith('/overseas-quotes/warehouse-options')) return ok([])
|
||||
return original(method, path, body)
|
||||
}
|
||||
test('创建前空候选不形成假PASS,刷新后本轮合作仓进入报价完整生命周期', async () => {
|
||||
const { context } = fakeContext()
|
||||
await run(context)
|
||||
const optionCalls = context.calls.filter((call) => call.path.includes('warehouse-options'))
|
||||
assert.equal(optionCalls.length, 4)
|
||||
assert.equal(optionCalls[0].path.endsWith('warehouse-options'), true)
|
||||
assert.equal(optionCalls[1].path.endsWith('warehouse-options'), true)
|
||||
assert.equal(optionCalls[2].path.endsWith('warehouse-options?refresh=true'), true)
|
||||
assert.equal(optionCalls[3].path.endsWith('warehouse-options'), true)
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.quote.id, '920000000000000001')
|
||||
assert.equal(context.coverageGaps.length, 0)
|
||||
})
|
||||
|
||||
test('refresh后仍为空时报价前置明确deployment blocker且不产生coverageGap假PASS', async () => {
|
||||
const { context, state } = fakeContext({ softAssertions: true, refreshEmpty: true })
|
||||
await run(context)
|
||||
assert.equal(state.warehouse.status, 'INACTIVE')
|
||||
assert.equal(state.quote, null)
|
||||
assert.equal(state.warehouse.status, 'INACTIVE')
|
||||
assert.equal(state.exchange.status, 'INACTIVE')
|
||||
assert.equal(state.vehicle.status, 'INACTIVE')
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.quote.skipped, true)
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.partnerWarehouse.finalStatus, 'INACTIVE')
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.exchangeRate.finalStatus, 'INACTIVE')
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE')
|
||||
assert.equal(context.coverageGaps.length, 1)
|
||||
assert.equal(context.coverageGaps[0].name, '海外报价完整写生命周期')
|
||||
assert.equal(context.coverageGaps[0].category, 'deployment-prerequisite')
|
||||
assert.match(context.coverageGaps[0].reason, /30分钟空缓存/)
|
||||
assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 2)
|
||||
assert.equal(context.coverageGaps.length, 0)
|
||||
assert.equal(context.steps.some((step) => step.name === '刷新后新建合作仓立即可见'
|
||||
&& !step.passed && step.deploymentBlocker), true)
|
||||
assert.equal(context.steps.some((step) => step.name === '海外报价生命周期执行'
|
||||
&& !step.passed && step.deploymentBlocker), true)
|
||||
})
|
||||
|
||||
@@ -14,13 +14,26 @@ function requireData(context, name, response) {
|
||||
return value
|
||||
}
|
||||
|
||||
function assertBinaryCsv(context, name, response) {
|
||||
function assertBinaryCsv(context, name, response, expectedHeader, expectedTokens) {
|
||||
const text = response?.bytes instanceof Uint8Array ? new TextDecoder().decode(response.bytes).replace(/^\uFEFF/, '') : ''
|
||||
const header = text.split(/\r?\n/, 1)[0]
|
||||
context.assert(MODULE, name, response,
|
||||
response?.http === 200
|
||||
&& String(response.contentType || '').toLowerCase().startsWith('text/csv')
|
||||
&& response.bytes instanceof Uint8Array && response.bytes.length > 0,
|
||||
`HTTP=${response?.http ?? 0}, contentType=${response?.contentType || 'missing'}, bytes=${response?.bytes?.length || 0}`,
|
||||
BLOCKER)
|
||||
context.assert(MODULE, `${name}表头与marker内容`, response,
|
||||
header === expectedHeader && expectedTokens.every((token) => text.includes(String(token))),
|
||||
`header=${header || 'missing'}, expectedTokens=${expectedTokens.join('|')}`, BLOCKER)
|
||||
}
|
||||
|
||||
const optionIds = (values) => Array.isArray(values) ? values.map((item) => asId(item?.warehouseId)).filter(Boolean) : []
|
||||
|
||||
function assertForbidden(context, name, response) {
|
||||
context.assert(MODULE, name, response,
|
||||
response?.http === 403 || (response?.http === 200 && ['403', 'A0403'].includes(String(response?.body?.code))),
|
||||
`HTTP=${response?.http ?? 0}, code=${response?.body?.code ?? 'missing'}`, BLOCKER)
|
||||
}
|
||||
|
||||
async function safeCleanup(context, name, action) {
|
||||
@@ -97,6 +110,16 @@ async function createAndVerifyPartnerWarehouse(context, resource) {
|
||||
BLOCKER)
|
||||
}
|
||||
|
||||
async function captureWarehouseOptionsBeforeCreate(context, resource) {
|
||||
const response = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const options = requireData(context, '创建合作仓前查询报价候选缓存基线', response)
|
||||
context.assert(MODULE, '合作仓候选基线为规范数组', response,
|
||||
Array.isArray(options) && options.every((item) => asId(item?.warehouseId)
|
||||
&& item.warehouseCode && item.warehouseName && /^[A-Z]{2}$/.test(item.countryCode)),
|
||||
`count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER)
|
||||
resource.partnerWarehouseOptionsBefore = optionIds(options)
|
||||
}
|
||||
|
||||
function quoteCreatePayload(context, warehouseId) {
|
||||
return {
|
||||
name: `${context.marker} 海外报价`, warehouseId,
|
||||
@@ -127,27 +150,25 @@ async function quoteLifecycle(context, resource) {
|
||||
Array.isArray(options) && options.every((item) => asId(item.warehouseId)
|
||||
&& item.warehouseCode && item.warehouseName && item.countryCode),
|
||||
`count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER)
|
||||
if (Array.isArray(options) && options.length === 0) {
|
||||
resource.quote = {
|
||||
skipped: true,
|
||||
reason: 'warehouse-options-empty-cache',
|
||||
finalStatus: null,
|
||||
}
|
||||
context.addCoverageGap(MODULE, BLOCKER.scenario, '海外报价完整写生命周期',
|
||||
'warehouse-options 返回空数组;已创建的 OVERSEAS_PARTNER 可能尚未穿透服务端30分钟空缓存。本轮跳过报价且不猜测 warehouseId,继续验收汇率与车辆。',
|
||||
'deployment-prerequisite', {
|
||||
marker: context.marker,
|
||||
partnerWarehouseId: resource.partnerWarehouse?.id || null,
|
||||
http: optionsResponse?.http ?? null,
|
||||
code: optionsResponse?.body?.code ?? null,
|
||||
requestId: optionsResponse?.requestId ?? null,
|
||||
traceId: optionsResponse?.traceId ?? null,
|
||||
})
|
||||
return false
|
||||
const createdId = resource.partnerWarehouse?.id
|
||||
if (resource.partnerWarehouseOptionsBefore?.length === 0) {
|
||||
context.assert(MODULE, '空候选不负缓存30分钟', optionsResponse,
|
||||
optionIds(options).includes(createdId), `createdId=${createdId}, ids=${optionIds(options).join(',') || 'empty'}`, BLOCKER)
|
||||
}
|
||||
const createdOption = options.find((item) => asId(item.warehouseId) === resource.partnerWarehouse?.id)
|
||||
const option = createdOption || options[0]
|
||||
if (!option) throw new Error('海外报价接口未返回可用合作仓候选')
|
||||
|
||||
const refreshResponse = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`)
|
||||
const refreshed = requireData(context, '强制刷新海外报价合作仓候选', refreshResponse)
|
||||
const option = Array.isArray(refreshed) ? refreshed.find((item) => asId(item.warehouseId) === createdId) : null
|
||||
context.assert(MODULE, '刷新后新建合作仓立即可见', refreshResponse, Boolean(option),
|
||||
`createdId=${createdId}, ids=${optionIds(refreshed).join(',') || 'empty'}`, BLOCKER)
|
||||
if (!option) throw new Error(`warehouse-options refresh=true 未返回本轮合作仓 ${createdId}`)
|
||||
|
||||
const cachedResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const cached = requireData(context, '刷新后读取30分钟正缓存', cachedResponse)
|
||||
context.assert(MODULE, '30分钟正缓存与刷新快照一致', cachedResponse,
|
||||
JSON.stringify(cached) === JSON.stringify(refreshed),
|
||||
`refreshed=${optionIds(refreshed).join(',')}, cached=${optionIds(cached).join(',')}`, BLOCKER)
|
||||
|
||||
const payload = quoteCreatePayload(context, asId(option.warehouseId))
|
||||
const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload)
|
||||
@@ -164,6 +185,13 @@ async function quoteLifecycle(context, resource) {
|
||||
&& Array.isArray(detail?.warehouseFees) && detail.warehouseFees.length > 0,
|
||||
`id=${asId(detail?.summary?.id) || 'missing'}, version=${detail?.summary?.recordVersion ?? 'missing'}`, BLOCKER)
|
||||
|
||||
const listResponse = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=${encodeURIComponent(context.marker)}`)
|
||||
const page = requireData(context, '分页查询marker海外报价', listResponse)
|
||||
context.assert(MODULE, '报价列表包含本轮长ID与recordVersion', listResponse,
|
||||
Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)),
|
||||
`id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER)
|
||||
|
||||
const staleVersion = Number(detail.summary.recordVersion)
|
||||
const updatePayload = quoteUpdatePayload({ ...payload, transitPrices: payload.transitPrices }, context, staleVersion)
|
||||
const updateResponse = await context.requestOverseas('PUT', path, updatePayload)
|
||||
@@ -191,7 +219,9 @@ async function quoteLifecycle(context, resource) {
|
||||
detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`)
|
||||
assertBinaryCsv(context, '导出海外报价CSV', csv)
|
||||
assertBinaryCsv(context, '导出海外报价CSV', csv,
|
||||
'报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间',
|
||||
[id, context.marker])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -252,9 +282,17 @@ async function exchangeLifecycle(context, resource) {
|
||||
context.assert(MODULE, '汇率状态切换为INACTIVE', statusResponse,
|
||||
rate.status === 'INACTIVE', `status=${rate.status}`, BLOCKER)
|
||||
|
||||
const listResponse = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}&status=INACTIVE`)
|
||||
const page = requireData(context, '分页查询marker月汇率', listResponse)
|
||||
context.assert(MODULE, '汇率列表包含本轮月份与recordVersion', listResponse,
|
||||
Array.isArray(page?.records) && page.records.some((item) => item.month === month && recordVersion(item.recordVersion)),
|
||||
`month=${month}, total=${page?.total ?? 'missing'}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET',
|
||||
`${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`)
|
||||
assertBinaryCsv(context, '导出marker月汇率CSV', csv)
|
||||
assertBinaryCsv(context, '导出marker月汇率CSV', csv,
|
||||
'月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE'])
|
||||
}
|
||||
|
||||
function vehiclePayload(context, edited = false) {
|
||||
@@ -316,8 +354,48 @@ async function vehicleLifecycle(context, resource) {
|
||||
context.assert(MODULE, '车辆状态切换为MAINTENANCE', statusResponse,
|
||||
vehicle.status === 'MAINTENANCE', `status=${vehicle.status}`, BLOCKER)
|
||||
|
||||
const listResponse = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
const page = requireData(context, '分页查询marker车辆', listResponse)
|
||||
context.assert(MODULE, '车辆列表包含本轮长ID与recordVersion', listResponse,
|
||||
Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)),
|
||||
`id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
assertBinaryCsv(context, '导出marker车辆CSV', csv)
|
||||
assertBinaryCsv(context, '导出marker车辆CSV', csv,
|
||||
'车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间',
|
||||
[id, createPayload.plateNumber, 'MAINTENANCE'])
|
||||
}
|
||||
|
||||
async function verifyCanonicalPermissionDenial(context, resource) {
|
||||
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
|
||||
const roleResponse = await context.request('POST', '/api/platform/v1/iam/roles', {
|
||||
roleCode: `P4_MD_DENY_${suffix}`.slice(0, 64), roleName: `四期主数据拒绝-${suffix}`, description: context.marker,
|
||||
})
|
||||
const role = requireData(context, '创建无主数据权限角色', roleResponse)
|
||||
const roleId = asId(role?.id)
|
||||
if (!roleId) throw new Error('无主数据权限角色未返回ID')
|
||||
resource.permissionProbe = { roleId, membershipId: null, roleStatus: 'ACTIVE', userStatus: null }
|
||||
|
||||
const phone = await context.findAvailablePhone(MODULE)
|
||||
const username = `p4_md_deny_${suffix}`.slice(0, 64).toLowerCase()
|
||||
const password = `P4md${suffix.slice(-8)}A1!`
|
||||
const userResponse = await context.request('POST', '/api/platform/v1/iam/users', {
|
||||
username, displayName: `四期主数据拒绝-${suffix}`, phone,
|
||||
email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE',
|
||||
})
|
||||
const user = requireData(context, '创建无主数据权限用户', userResponse)
|
||||
const membershipId = asId(user?.membershipId)
|
||||
if (!membershipId) throw new Error('无主数据权限用户未返回membershipId')
|
||||
resource.permissionProbe.membershipId = membershipId
|
||||
resource.permissionProbe.userStatus = 'ACTIVE'
|
||||
|
||||
const loginResponse = await context.request('POST', '/api/auth/login', { username, password }, '')
|
||||
const login = requireData(context, '无主数据权限用户登录', loginResponse)
|
||||
if (!login?.overseasAccessToken) throw new Error('无主数据权限用户未签发海外Token')
|
||||
const denied = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/overseas-quotes?page=1&size=1`, undefined, {}, login.overseasAccessToken)
|
||||
assertForbidden(context, '无quote:list权限访问海外报价被拒绝', denied)
|
||||
}
|
||||
|
||||
async function cleanupPartnerWarehouse(context, resource) {
|
||||
@@ -355,16 +433,33 @@ async function cleanupVersioned(context, resource, key, path, extract) {
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupPermissionProbe(context, resource) {
|
||||
const probe = resource.permissionProbe
|
||||
if (!probe) return
|
||||
if (probe.membershipId && probe.userStatus !== 'DISABLED') {
|
||||
const response = await context.request('PUT', `/api/platform/v1/iam/users/${probe.membershipId}/status`, { status: 'DISABLED' })
|
||||
context.apiSuccess(MODULE, '无权限探针用户finally停用', response, `membershipId=${probe.membershipId}`, BLOCKER)
|
||||
if (success(response)) probe.userStatus = 'DISABLED'
|
||||
}
|
||||
if (probe.roleId && probe.roleStatus !== 'DISABLED') {
|
||||
const response = await context.request('PUT', `/api/platform/v1/iam/roles/${probe.roleId}/status`, { status: 'DISABLED' })
|
||||
context.apiSuccess(MODULE, '无权限探针角色finally停用', response, `roleId=${probe.roleId}`, BLOCKER)
|
||||
if (success(response)) probe.roleStatus = 'DISABLED'
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const resource = { marker: context.marker }
|
||||
context.createdResources.phase4OverseasMasterData = resource
|
||||
try {
|
||||
await continueLifecycle(context, '海外报价', async () => {
|
||||
await captureWarehouseOptionsBeforeCreate(context, resource)
|
||||
await createAndVerifyPartnerWarehouse(context, resource)
|
||||
await quoteLifecycle(context, resource)
|
||||
})
|
||||
await continueLifecycle(context, '月度汇率', () => exchangeLifecycle(context, resource))
|
||||
await continueLifecycle(context, '车辆主数据', () => vehicleLifecycle(context, resource))
|
||||
await continueLifecycle(context, 'canonical权限拒绝', () => verifyCanonicalPermissionDenial(context, resource))
|
||||
} finally {
|
||||
await safeCleanup(context, '海外报价', async () => {
|
||||
if (!resource.quote?.id) return
|
||||
@@ -382,5 +477,6 @@ export async function run(context) {
|
||||
await cleanupVersioned(context, resource, 'vehicle', path, (value) => value)
|
||||
})
|
||||
await safeCleanup(context, '合作仓', () => cleanupPartnerWarehouse(context, resource))
|
||||
await safeCleanup(context, 'canonical权限探针', () => cleanupPermissionProbe(context, resource))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,21 @@ export async function run(context) {
|
||||
const resetLoginResponse = await context.request('POST', '/api/auth/login', { username, password: resetPassword }, '')
|
||||
const resetLoginPassed = resetLoginResponse.http === 200 && String(resetLoginResponse.body?.code) === '200' && Boolean(resetLoginResponse.body?.data?.accessToken)
|
||||
context.addStep(module, '内部用户使用重置密码登录', resetLoginResponse, resetLoginPassed, `username=${username}`)
|
||||
if (context.options.modules.includes('phase4-cross-region-events')) {
|
||||
const restrictedOverseasToken = resetLoginResponse.body?.data?.overseasAccessToken || ''
|
||||
context.assert(module, '受限角色登录同时签发海外Token', resetLoginResponse,
|
||||
Boolean(restrictedOverseasToken), `tokenPresent=${Boolean(restrictedOverseasToken)}`,
|
||||
{ scenario: 'canonical-overseas-permission-denial', deploymentBlocker: true })
|
||||
if (restrictedOverseasToken) {
|
||||
const denied = await context.requestOverseas('GET', '/api/platform/v1/last-leg/orders?page=1&size=1',
|
||||
undefined, {}, restrictedOverseasToken)
|
||||
context.assert(module, '缺少canonical海外订单权限必须403', denied,
|
||||
(denied.http === 403 || ['403', 'A0403'].includes(String(denied.body?.code)))
|
||||
&& Boolean(denied.requestId) && Boolean(denied.traceId),
|
||||
`HTTP=${denied.http}, code=${denied.body?.code ?? 'missing'}, requestId=${denied.requestId || 'missing'}, traceId=${denied.traceId || 'missing'}`,
|
||||
{ scenario: 'canonical-overseas-permission-denial', deploymentBlocker: true })
|
||||
}
|
||||
}
|
||||
|
||||
const disableUserResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/status`, { status: 'DISABLED' })
|
||||
const disabledUser = context.apiSuccess(module, '停用平台内部用户', disableUserResponse)
|
||||
|
||||
@@ -10,6 +10,7 @@ test('phase-four HTTP acceptance modules are registered in the Admin acceptance
|
||||
for (const module of [
|
||||
'phase4-overseas-warehouse',
|
||||
'phase4-overseas-pda',
|
||||
'phase4-cross-region-events',
|
||||
'phase4-overseas-business',
|
||||
'phase4-overseas-master-data',
|
||||
'phase4-carrier-nodes',
|
||||
|
||||
Reference in New Issue
Block a user