完成单机TEST统一入口与可靠投递回归收尾

This commit is contained in:
2026-09-29 19:48:34 +08:00
parent 64a70c9f28
commit 6c0a026cc4
89 changed files with 968 additions and 983 deletions
+24 -119
View File
@@ -286,7 +286,6 @@ function usage() {
选项:
--gateway <url> 必填,国内 Gateway 地址
--overseas-gateway <url> 四期海外模块必填,海外 Gateway 地址
--username <name> 必填,管理员账号
--password <value> 必填,管理员密码
--pda-username <name> 国内装柜链必填,实际领取任务的 PDA 作业员账号
@@ -315,7 +314,7 @@ function usage() {
--output <path> JSON 报告路径,默认 reports/
--help 显示帮助
也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME,
MERCHANT_TEST_PASSWORD, ADMIN_PDA_USERNAME, ADMIN_PDA_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`)
}
@@ -336,6 +335,8 @@ export function parseArguments(argv) {
values.set(name, next); index += 1
}
}
if (values.has('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Remove overseas-gateway / ADMIN_OVERSEAS_GATEWAY; use one TEST gateway')
if (values.has('environment') && values.get('environment') !== 'test') throw new Error('Only test acceptance is supported')
const requestedModules = (values.get('modules') || DEFAULT_MODULES.join(','))
.split(',').map((v) => v.trim()).filter(Boolean)
const modules = normalizeModuleOrder(requestedModules)
@@ -354,8 +355,8 @@ export function parseArguments(argv) {
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
}
return {
environment: 'test',
gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''),
overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''),
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '',
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '',
pdaUsername: values.get('pda-username') || process.env.ADMIN_PDA_USERNAME || '',
@@ -396,10 +397,8 @@ export function validateExecutionOptions(options) {
for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) {
if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`)
}
if (options.modules.some((module) => module.startsWith('phase4-overseas-'))
&& (!options.overseasGateway || options.overseasGateway.trim() === '')) {
throw new Error('四期海外模块缺少必填执行参数: overseas-gateway')
}
if (options.environment && options.environment !== 'test') throw new Error('Only test acceptance is supported')
if ('overseasGateway' in options) throw new Error('Use the single gateway option')
if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading'].includes(module))
&& (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) {
throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)')
@@ -561,8 +560,7 @@ export class AcceptanceRun {
this.token = ''
this.pdaToken = ''
this.refreshToken = ''
this.overseasToken = ''
this.overseasExpiresAt = 0
this.expiresAt = 0
this.fatal = null
}
@@ -597,29 +595,6 @@ export class AcceptanceRun {
}
throw new Error('unreachable request attempt state')
}
async requestOverseas(method, path, body = undefined, extraHeaders = {}, token = this.overseasToken) {
const headers = { Accept: 'application/json', ...extraHeaders }
if (token) headers.Authorization = `Bearer ${token}`
if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
try {
const response = await fetch(`${this.options.overseasGateway}${path}`, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
})
const content = await response.text()
let payload = null
try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ }
return {
method, path, http: response.status, body: payload,
raw: payload === null ? content.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id'),
}
} catch (error) {
return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
}
}
async requestMultipart(method, path, file, token = this.token, extraHeaders = {}) {
if (this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) token = this.pdaToken
const form = file instanceof FormData ? file : new FormData()
@@ -631,31 +606,6 @@ export class AcceptanceRun {
return this.requestWithBody(method, path, form, token, extraHeaders)
}
async requestOverseasMultipart(method, path, file, token = this.overseasToken, extraHeaders = {}) {
const form = file instanceof FormData ? file : new FormData()
if (!(file instanceof FormData)) {
if (!file || file.content === undefined) throw new TypeError('multipart file requires content')
const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content)
form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin')
}
const headers = { Accept: 'application/json', ...extraHeaders }
if (token) headers.Authorization = `Bearer ${token}`
try {
const response = await fetch(`${this.options.overseasGateway}${path}`, {
method, headers, body: form, signal: AbortSignal.timeout(60_000),
})
const content = await response.text(); let payload = null
try { payload = content ? JSON.parse(content) : null } catch { /* bounded raw response */ }
return { method, path, http: response.status, body: payload,
raw: payload === null ? content.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id') }
} catch (error) {
return { method, path, http: 0, body: null,
raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
}
}
async requestWithBody(method, path, body, token = this.token, extraHeaders = {}) {
const headers = { Accept: 'application/json', ...extraHeaders }
if (token) headers.Authorization = `Bearer ${token}`
@@ -706,31 +656,6 @@ export class AcceptanceRun {
throw new Error('unreachable binary request attempt state')
}
async requestOverseasBinary(method, path) {
const headers = { Accept: '*/*' }
if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}`
try {
const response = await fetch(`${this.options.overseasGateway}${path}`, {
method, headers, signal: AbortSignal.timeout(120_000),
})
const bytes = new Uint8Array(await response.arrayBuffer())
const contentType = response.headers.get('content-type') || ''
let payload = null
if (contentType.toLowerCase().includes('json')) {
try { payload = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* Invalid envelope remains binary evidence. */ }
}
return {
method, path, http: response.status, body: payload, bytes, contentType,
raw: payload ? null : `${bytes.length} bytes (${contentType || 'unknown'})`,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id'),
}
} catch (error) {
return { method, path, http: 0, body: null, bytes: new Uint8Array(), contentType: '',
raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
}
}
addStep(module, name, response, passed, evidence = '', options = {}) {
const step = {
module, scenario: options.scenario || null, name, passed: Boolean(passed),
@@ -947,24 +872,16 @@ export class AcceptanceRun {
async refreshSession(reason) {
const response = await this.request('POST', '/api/auth/refresh',
{ refreshToken: this.refreshToken }, '')
const next = this.apiSuccess('authentication', '国内 refresh 签发新双区域会话', response,
`reason=${reason}`, { scenario: 'dual-token-refresh', deploymentBlocker: true })
const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken)
&& Boolean(next?.overseasAccessToken) && Number(next?.overseasExpiresIn) > 0
this.assert('authentication', '刷新响应完整后原子替换国内与海外Token', response, valid,
`accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}, overseasTokenPresent=${Boolean(next?.overseasAccessToken)}, overseasExpiresIn=${next?.overseasExpiresIn ?? 'missing'}`,
{ scenario: 'dual-token-refresh', deploymentBlocker: true })
const next = this.apiSuccess('authentication', '刷新统一会话', response,
`reason=${reason}`, { scenario: 'session-refresh', deploymentBlocker: true })
const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken) && Number(next?.expiresIn) > 0
this.assert('authentication', '刷新响应完整后原子替换会话', response, valid,
`accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}`,
{ scenario: 'session-refresh', deploymentBlocker: true })
if (!valid) return false
const replacement = {
token: next.accessToken,
refreshToken: next.refreshToken,
overseasToken: next.overseasAccessToken,
overseasExpiresAt: Date.now() + Number(next.overseasExpiresIn) * 1000,
}
this.token = replacement.token
this.refreshToken = replacement.refreshToken
this.overseasToken = replacement.overseasToken
this.overseasExpiresAt = replacement.overseasExpiresAt
this.token = next.accessToken
this.refreshToken = next.refreshToken
this.expiresAt = Date.now() + Number(next.expiresIn) * 1000
return true
}
@@ -1809,23 +1726,12 @@ export class AcceptanceRun {
`tokenPresent=${Boolean(this.pdaToken)}`, { deploymentBlocker: true, scenario: 'phase3-pda' })
}
this.refreshToken = login.refreshToken || ''
this.overseasToken = login.overseasAccessToken || ''
this.overseasExpiresAt = Number(login.overseasExpiresIn) > 0
? Date.now() + Number(login.overseasExpiresIn) * 1000 : 0
const requiresOverseas = this.options.modules.some((module) =>
module.startsWith('phase4-overseas-') || module.startsWith('phase5-overseas-'))
if (requiresOverseas) {
this.assert('authentication', '登录响应包含有效海外访问令牌', loginResponse,
Boolean(this.overseasToken) && Number(login.overseasExpiresIn) > 0,
`tokenPresent=${Boolean(this.overseasToken)}, expiresIn=${login.overseasExpiresIn ?? 'missing'}`,
{ deploymentBlocker: true, scenario: 'phase4-dual-token' })
}
this.expiresAt = Number(login.expiresIn) > 0 ? Date.now() + Number(login.expiresIn) * 1000 : 0
const handlers = { customers: () => this.runCustomers() }
for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) {
const module = this.options.modules[moduleIndex]
if ((module.startsWith('phase4-overseas-') || module.startsWith('phase5-overseas-'))
&& this.overseasExpiresAt > 0 && Date.now() + 120_000 >= this.overseasExpiresAt) {
await this.refreshSession(`海外模块 ${module} 执行前Token临近到期`)
if (this.expiresAt > 0 && Date.now() + 120_000 >= this.expiresAt) {
await this.refreshSession(`模块 ${module} 执行前Token临近到期`)
}
const fullFinanceChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'].every((name) => this.options.modules.includes(name))
if (module === 'finance' && fullFinanceChain) {
@@ -1913,12 +1819,11 @@ export class AcceptanceRun {
if (module === 'system' && laterOverseasModule) {
const refreshed = await this.refreshSession('system模块权限与角色变更完成')
if (refreshed) {
const probe = await this.requestOverseas('GET',
'/api/platform/v1/last-leg/orders?page=1&size=1')
this.assert('authentication', 'system后新admin海外Token只读探针可用', probe,
const probe = await this.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1')
this.assert('authentication', 'system后新admin统一Token只读探针可用', probe,
probe.http === 200 && String(probe.body?.code) === '200',
`HTTP=${probe.http}, code=${probe.body?.code ?? 'missing'}, requestId=${probe.requestId || 'missing'}, traceId=${probe.traceId || 'missing'}`,
{ scenario: 'dual-token-refresh', deploymentBlocker: true })
{ scenario: 'session-refresh', deploymentBlocker: true })
}
}
}
@@ -1929,7 +1834,7 @@ export class AcceptanceRun {
createdResources: this.createdResources,
})
const forbiddenValues = [this.options.password, this.options.merchantPassword,
this.token, this.refreshToken, this.overseasToken]
this.token, this.refreshToken]
.filter((value) => typeof value === 'string' && value.length >= 8)
const sensitivePatterns = [
/"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i,
@@ -1946,7 +1851,7 @@ export class AcceptanceRun {
const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps)
return {
reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker,
gateway: this.options.gateway, overseasGateway: this.options.overseasGateway,
gateway: this.options.gateway, gateway: this.options.gateway,
generatedAt: new Date().toISOString(), startedAt: this.startedAt,
success: this.fatal === null && summary.failed === 0, fatal: this.fatal,
summary,