2026-08-08 10:11:04 +08:00
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Client test-environment acceptance flow.
|
|
|
|
|
*
|
|
|
|
|
* Run directly and enter the platform/client test credentials when prompted.
|
|
|
|
|
* Optional environment variables for CI:
|
|
|
|
|
* CLIENT_TEST_GATEWAY, PLATFORM_USERNAME, EXISTING_CLIENT_USERNAME,
|
|
|
|
|
* PLATFORM_PASSWORD, EXISTING_CLIENT_PASSWORD, CLIENT_TEST_REPORT_DIR
|
|
|
|
|
*
|
|
|
|
|
* Example:
|
|
|
|
|
* node scripts/client-test-env-acceptance.mjs
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
|
|
|
import { randomUUID } from 'node:crypto'
|
|
|
|
|
import { resolve } from 'node:path'
|
|
|
|
|
import { createInterface } from 'node:readline/promises'
|
|
|
|
|
|
|
|
|
|
const args = parseArgs(process.argv.slice(2))
|
|
|
|
|
const gateway = trimSlash(args.gateway ?? process.env.CLIENT_TEST_GATEWAY ?? 'http://8.163.30.128:9000')
|
|
|
|
|
const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME
|
|
|
|
|
?? await promptText('平台审核账号', 'platform_admin')
|
|
|
|
|
const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD
|
|
|
|
|
?? await promptSecret('平台审核密码')
|
|
|
|
|
const existingClientUsername = args['existing-client-username'] ?? process.env.EXISTING_CLIENT_USERNAME
|
|
|
|
|
?? await promptText('跨客户隔离测试账号', 'merchant_admin')
|
|
|
|
|
const existingClientPassword = args['existing-client-password'] ?? process.env.EXISTING_CLIENT_PASSWORD
|
|
|
|
|
?? await promptSecret('跨客户隔离测试密码')
|
|
|
|
|
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports')
|
|
|
|
|
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
|
|
|
|
|
|
|
|
|
|
if (!platformPassword) {
|
|
|
|
|
console.error('Platform password is required')
|
|
|
|
|
process.exit(2)
|
|
|
|
|
}
|
|
|
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
|
|
|
|
|
console.error('--timeout-ms must be at least 1000')
|
|
|
|
|
process.exit(2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}`
|
|
|
|
|
const username = `client_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64)
|
|
|
|
|
const clientPassword = `Client${randomUUID().replaceAll('-', '').slice(0, 14)}9!`
|
|
|
|
|
const phoneSuffix = `${Date.now()}`.slice(-8)
|
|
|
|
|
const legalPhone = `139${phoneSuffix}`
|
|
|
|
|
const companyName = `客户端全链路测试企业-${marker}`.slice(0, 128)
|
|
|
|
|
const creditCode = `91${compactTimestamp(now)}${randomUUID().replaceAll('-', '').slice(0, 2).toUpperCase()}`.slice(0, 18)
|
|
|
|
|
const reportPath = resolve(reportDir, `client-test-env-${marker}.json`)
|
|
|
|
|
|
|
|
|
|
const report = {
|
|
|
|
|
marker,
|
|
|
|
|
gateway,
|
|
|
|
|
generatedAt: now.toISOString(),
|
|
|
|
|
completedAt: null,
|
|
|
|
|
success: false,
|
|
|
|
|
fatal: null,
|
|
|
|
|
testAccount: { username, companyName, legalPhone },
|
|
|
|
|
resources: {
|
|
|
|
|
tenantId: null,
|
|
|
|
|
membershipId: null,
|
|
|
|
|
reviewId: null,
|
2026-08-08 10:17:43 +08:00
|
|
|
businessLicenseQualificationId: null,
|
|
|
|
|
businessLicenseDocumentUrl: null,
|
2026-08-08 10:11:04 +08:00
|
|
|
productImageFileId: null,
|
|
|
|
|
enterpriseAddressId: null,
|
|
|
|
|
receivingWarehouseId: null,
|
|
|
|
|
destinationWarehouseId: null,
|
|
|
|
|
channelCode: null,
|
|
|
|
|
roleId: null,
|
|
|
|
|
testMembershipId: null,
|
|
|
|
|
orderIds: [],
|
|
|
|
|
},
|
|
|
|
|
steps: [],
|
|
|
|
|
futureModules: ['measurement', 'ocr', 'mojia', 'billing'],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let merchantToken
|
|
|
|
|
let merchantRefreshToken
|
|
|
|
|
let platformToken
|
|
|
|
|
let existingClientToken
|
|
|
|
|
let ownerPasswordNeedsRestore = false
|
|
|
|
|
let temporaryOwnerPassword
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await registrationModule()
|
|
|
|
|
await merchantBeforeApprovalModule()
|
|
|
|
|
await qualificationAndReviewModule()
|
|
|
|
|
await merchantAfterApprovalModule()
|
|
|
|
|
await profileManagementModule()
|
|
|
|
|
await rbacAndMemberModule()
|
|
|
|
|
await orderLifecycleModule()
|
|
|
|
|
await isolationModule()
|
|
|
|
|
await accountSecurityModule()
|
|
|
|
|
report.success = report.steps.every((step) => step.passed)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
report.fatal = sanitizeError(error)
|
|
|
|
|
report.success = false
|
|
|
|
|
} finally {
|
|
|
|
|
if (ownerPasswordNeedsRestore) await recoverOwnerPassword()
|
|
|
|
|
report.completedAt = new Date().toISOString()
|
|
|
|
|
await mkdir(reportDir, { recursive: true })
|
|
|
|
|
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
|
|
|
|
printSummary()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
process.exitCode = report.success ? 0 : 1
|
|
|
|
|
|
|
|
|
|
async function registrationModule() {
|
|
|
|
|
const registrationBody = {
|
|
|
|
|
username,
|
|
|
|
|
password: clientPassword,
|
|
|
|
|
displayName: `测试管理员-${marker}`.slice(0, 64),
|
|
|
|
|
companyName,
|
|
|
|
|
legalPerson: '测试法人',
|
|
|
|
|
legalPhone,
|
|
|
|
|
contactEmail: `${username}@example.com`,
|
|
|
|
|
countryCode: 'CN',
|
|
|
|
|
address: '广东省深圳市南山区测试路 1 号',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const registration = await apiStep('registration', 'enterprise self-registration', {
|
|
|
|
|
method: 'POST', path: '/api/auth/register', body: registrationBody,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenantStatus')
|
|
|
|
|
assertEqual(json.data?.nextStep, 'LOGIN_AND_COMPLETE_QUALIFICATIONS', 'nextStep')
|
|
|
|
|
assertPresent(json.data?.tenantId, 'tenantId')
|
|
|
|
|
assertPresent(json.data?.membershipId, 'membershipId')
|
|
|
|
|
}, ({ json }) => ({
|
|
|
|
|
tenantId: json.data.tenantId,
|
|
|
|
|
membershipId: json.data.membershipId,
|
|
|
|
|
tenantStatus: json.data.tenantStatus,
|
|
|
|
|
nextStep: json.data.nextStep,
|
|
|
|
|
}))
|
|
|
|
|
report.resources.tenantId = registration.json.data.tenantId
|
|
|
|
|
report.resources.membershipId = registration.json.data.membershipId
|
|
|
|
|
|
|
|
|
|
await apiStep('registration', 'duplicate username is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/auth/register', body: registrationBody,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0409', 'business code')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const login = await apiStep('registration', 'login before approval', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'accessToken')
|
|
|
|
|
}, () => ({ username }))
|
|
|
|
|
merchantToken = login.json.data.accessToken
|
|
|
|
|
merchantRefreshToken = login.json.data.refreshToken
|
|
|
|
|
|
|
|
|
|
await apiStep('registration', 'profile is incomplete before qualifications', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(String(json.data?.id), String(report.resources.tenantId), 'profile tenant id')
|
|
|
|
|
assertEqual(json.data?.status, 'PROFILE_REQUIRED', 'profile status')
|
|
|
|
|
}, ({ json }) => ({ id: json.data.id, status: json.data.status, tenantName: json.data.tenantName }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function merchantBeforeApprovalModule() {
|
|
|
|
|
const masterData = await loadOrderMasterData(merchantToken, 'pre-approval-master-data')
|
|
|
|
|
const productImageFileId = await uploadProductImage(merchantToken, 'pre-approval-product-image')
|
|
|
|
|
report.resources.productImageFileId = productImageFileId
|
|
|
|
|
const payload = buildOrderPayload(masterData, productImageFileId, 'PRE', 'draft before enterprise approval')
|
|
|
|
|
|
|
|
|
|
const invalid = structuredClone(payload)
|
|
|
|
|
delete invalid.pricingTaxMode
|
|
|
|
|
await apiStep('order-validation', 'missing pricingTaxMode is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': newIdempotencyKey('invalid-create') }, body: invalid,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0422', 'business code')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const createKey = newIdempotencyKey('pre-approval-create')
|
|
|
|
|
const create = await apiStep('order-before-approval', 'create draft before approval', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': createKey }, body: payload,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data, 'order id')
|
|
|
|
|
}, ({ json }) => ({ orderId: json.data, status: 'DRAFT' }))
|
|
|
|
|
const orderId = create.json.data
|
|
|
|
|
report.resources.orderIds.push(orderId)
|
|
|
|
|
|
|
|
|
|
await apiStep('order-before-approval', 'create draft idempotent replay', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': createKey }, body: payload,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(String(json.data), String(orderId), 'replayed order id')
|
|
|
|
|
}, ({ json }) => ({ orderId: json.data, replayed: true }))
|
|
|
|
|
|
|
|
|
|
await apiStep('order-before-approval', 'submit is blocked before approval', {
|
|
|
|
|
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': newIdempotencyKey('pre-approval-submit') },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0403', 'business code')
|
|
|
|
|
}, ({ json }) => ({ orderId, rejectionCode: json.code }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function qualificationAndReviewModule() {
|
|
|
|
|
const png = testPng()
|
|
|
|
|
const form = new FormData()
|
|
|
|
|
form.append('businessLicense', new Blob([png], { type: 'image/png' }), `business-license-${marker}.png`)
|
|
|
|
|
form.append('legalPersonId', new Blob([png], { type: 'image/png' }), `legal-person-id-${marker}.png`)
|
|
|
|
|
|
|
|
|
|
await apiStep('qualification-review', 'upload required qualification package', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
path: `/api/merchant/v1/files/qualifications/required-package?unifiedSocialCreditCode=${encodeURIComponent(creditCode)}`,
|
|
|
|
|
token: merchantToken,
|
|
|
|
|
body: form,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.businessLicenseQualificationId, 'business license qualification id')
|
|
|
|
|
assertPresent(json.data?.legalPersonIdQualificationId, 'legal person qualification id')
|
|
|
|
|
assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenant status after upload')
|
|
|
|
|
}, ({ json }) => ({
|
|
|
|
|
businessLicenseQualificationId: json.data.businessLicenseQualificationId,
|
|
|
|
|
legalPersonIdQualificationId: json.data.legalPersonIdQualificationId,
|
|
|
|
|
tenantStatus: json.data.tenantStatus,
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
for (const qualificationType of ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE']) {
|
|
|
|
|
const optionalForm = new FormData()
|
|
|
|
|
optionalForm.append('file', new Blob([png], { type: 'image/png' }), `${qualificationType.toLowerCase()}-${marker}.png`)
|
|
|
|
|
await apiStep('qualification-review', `upload optional qualification ${qualificationType}`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
path: `/api/merchant/v1/files/qualifications?qualificationType=${qualificationType}&replace=false`,
|
|
|
|
|
token: merchantToken,
|
|
|
|
|
body: optionalForm,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.qualificationId, `${qualificationType} qualification id`)
|
|
|
|
|
assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenant status after optional upload')
|
|
|
|
|
}, ({ json }) => ({ qualificationType, qualificationId: json.data.qualificationId, tenantStatus: json.data.tenantStatus }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const duplicateOptionalForm = new FormData()
|
|
|
|
|
duplicateOptionalForm.append('file', new Blob([png], { type: 'image/png' }), `duplicate-freight-${marker}.png`)
|
|
|
|
|
await softApiStep('qualification-review', 'duplicate optional qualification requires explicit replacement', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
path: '/api/merchant/v1/files/qualifications?qualificationType=FREIGHT_FORWARDER_CERTIFICATE&replace=false',
|
|
|
|
|
token: merchantToken,
|
|
|
|
|
body: duplicateOptionalForm,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0409', 'business code')
|
|
|
|
|
}, ({ json }) => ({ qualificationType: 'FREIGHT_FORWARDER_CERTIFICATE', rejectionCode: json?.code }))
|
|
|
|
|
|
|
|
|
|
const replacementOptionalForm = new FormData()
|
|
|
|
|
replacementOptionalForm.append('file', new Blob([png], { type: 'image/png' }), `replacement-freight-${marker}.png`)
|
|
|
|
|
await apiStep('qualification-review', 'replace optional qualification explicitly', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
path: '/api/merchant/v1/files/qualifications?qualificationType=FREIGHT_FORWARDER_CERTIFICATE&replace=true',
|
|
|
|
|
token: merchantToken,
|
|
|
|
|
body: replacementOptionalForm,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.qualificationId, 'replacement qualification id')
|
|
|
|
|
}, ({ json }) => ({ qualificationType: 'FREIGHT_FORWARDER_CERTIFICATE', qualificationId: json.data.qualificationId, replaced: true }))
|
|
|
|
|
|
|
|
|
|
await apiStep('qualification-review', 'required qualifications are pending', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'], 'PENDING')
|
|
|
|
|
assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'PENDING')
|
|
|
|
|
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
|
|
|
|
|
|
|
|
|
|
await apiStep('qualification-review', 'submit enterprise review', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/iam/tenant/qualifications/submit-review', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.status, 'PENDING', 'tenant status')
|
|
|
|
|
}, ({ json }) => ({ tenantId: json.data.id, status: json.data.status }))
|
|
|
|
|
|
|
|
|
|
const platformLogin = await apiStep('qualification-review', 'platform reviewer login', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username: platformUsername, password: platformPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'platform accessToken')
|
|
|
|
|
}, () => ({ username: platformUsername }))
|
|
|
|
|
platformToken = platformLogin.json.data.accessToken
|
|
|
|
|
|
|
|
|
|
const reviews = await apiStep('qualification-review', 'platform finds pending review', {
|
|
|
|
|
method: 'GET', path: '/api/platform/v1/iam/reviews', token: platformToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
const review = arrayData(json.data).find((item) => String(item.tenantId) === String(report.resources.tenantId))
|
|
|
|
|
assertPresent(review?.id, 'pending review id')
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
const review = arrayData(json.data).find((item) => String(item.tenantId) === String(report.resources.tenantId))
|
|
|
|
|
return { reviewId: review?.id, tenantId: review?.tenantId, status: review?.status }
|
|
|
|
|
})
|
|
|
|
|
const review = arrayData(reviews.json.data).find((item) => String(item.tenantId) === String(report.resources.tenantId))
|
|
|
|
|
report.resources.reviewId = review.id
|
|
|
|
|
|
|
|
|
|
await apiStep('qualification-review', 'platform sees required qualifications', {
|
|
|
|
|
method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/qualifications`, token: platformToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'], 'PENDING')
|
|
|
|
|
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
|
|
|
|
|
|
|
|
|
|
const approval = await apiStep('qualification-review', 'platform approves enterprise', {
|
|
|
|
|
method: 'POST', path: `/api/platform/v1/iam/reviews/${review.id}/approve`, token: platformToken,
|
|
|
|
|
body: { comment: `客户端全链路自动审核通过 ${marker}` },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(String(json.data?.tenantId), String(report.resources.tenantId), 'approved tenant id')
|
|
|
|
|
assertEqual(json.data?.administratorCreated, false, 'administratorCreated')
|
|
|
|
|
assertTrue(json.data?.administratorUsername == null, 'administratorUsername must not be returned')
|
|
|
|
|
assertTrue(json.data?.temporaryPassword == null, 'temporaryPassword must not be returned')
|
|
|
|
|
}, ({ json }) => ({
|
|
|
|
|
tenantId: json.data.tenantId,
|
|
|
|
|
administratorCreated: json.data.administratorCreated,
|
|
|
|
|
generatedCredentialsReturned: Boolean(json.data.administratorUsername || json.data.temporaryPassword),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
await apiStep('qualification-review', 'registered account remains enterprise owner', {
|
|
|
|
|
method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/members`, token: platformToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
const owner = arrayData(json.data).find((item) => item.memberType === 'OWNER')
|
|
|
|
|
assertPresent(owner, 'OWNER member')
|
|
|
|
|
assertEqual(String(owner.membershipId), String(report.resources.membershipId), 'owner membership id')
|
|
|
|
|
assertEqual(owner.username, username, 'owner username')
|
|
|
|
|
assertEqual(owner.status, 'ACTIVE', 'owner status')
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
const owner = arrayData(json.data).find((item) => item.memberType === 'OWNER')
|
|
|
|
|
return { membershipId: owner?.membershipId, username: owner?.username, memberType: owner?.memberType, status: owner?.status }
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function merchantAfterApprovalModule() {
|
|
|
|
|
const login = await apiStep('merchant-session', 'client re-login after approval', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'accessToken')
|
|
|
|
|
}, () => ({ username }))
|
|
|
|
|
merchantToken = login.json.data.accessToken
|
|
|
|
|
merchantRefreshToken = login.json.data.refreshToken
|
|
|
|
|
|
|
|
|
|
await apiStep('merchant-session', 'profile is active after approval', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.status, 'ACTIVE', 'profile status')
|
|
|
|
|
assertEqual(json.data?.unifiedSocialCreditCode, creditCode, 'unified social credit code')
|
|
|
|
|
}, ({ json }) => ({ id: json.data.id, status: json.data.status, tenantName: json.data.tenantName }))
|
|
|
|
|
|
|
|
|
|
const approvedQualifications = await apiStep('merchant-session', 'required qualifications are approved', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'], 'APPROVED')
|
|
|
|
|
assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'APPROVED')
|
|
|
|
|
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
|
|
|
|
|
|
|
|
|
|
const businessLicense = arrayData(approvedQualifications.json.data)
|
|
|
|
|
.find((item) => item.qualificationType === 'BUSINESS_LICENSE')
|
|
|
|
|
assertPresent(businessLicense?.documentUrl, 'business license documentUrl')
|
2026-08-08 10:17:43 +08:00
|
|
|
report.resources.businessLicenseQualificationId = businessLicense.id ?? businessLicense.qualificationId
|
|
|
|
|
report.resources.businessLicenseDocumentUrl = businessLicense.documentUrl
|
2026-08-08 10:11:04 +08:00
|
|
|
await apiStep('merchant-session', 'preview approved qualification file', {
|
|
|
|
|
method: 'GET', path: apiPath(businessLicense.documentUrl), token: merchantToken, responseType: 'bytes',
|
|
|
|
|
}, ({ status, bytes, headers }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertTrue(bytes?.length > 0, 'qualification preview must not be empty')
|
|
|
|
|
assertTrue((headers.get('content-type') ?? '').startsWith('image/'), 'qualification preview must return an image')
|
|
|
|
|
}, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function profileManagementModule() {
|
|
|
|
|
const tenantProfile = await apiStep('profile-management', 'load enterprise profile for editing', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(String(json.data?.id), String(report.resources.tenantId), 'enterprise id')
|
|
|
|
|
}, ({ json }) => ({ id: json.data.id, tenantName: json.data.tenantName, status: json.data.status }))
|
|
|
|
|
|
|
|
|
|
const editedTenantName = `${companyName}-已编辑`.slice(0, 128)
|
|
|
|
|
const tenantUpdate = {
|
|
|
|
|
tenantName: editedTenantName,
|
|
|
|
|
legalName: tenantProfile.json.data.legalName || companyName,
|
|
|
|
|
legalRepresentative: tenantProfile.json.data.legalRepresentative || '测试法人',
|
|
|
|
|
contactName: `企业联系人-${marker}`.slice(0, 64),
|
|
|
|
|
contactPhone: legalPhone,
|
|
|
|
|
contactEmail: `company_${marker.replaceAll('-', '')}@example.com`,
|
|
|
|
|
}
|
|
|
|
|
await apiStep('profile-management', 'edit enterprise profile', {
|
|
|
|
|
method: 'PUT', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, body: tenantUpdate,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.tenantName, tenantUpdate.tenantName, 'tenantName')
|
|
|
|
|
assertEqual(json.data?.contactName, tenantUpdate.contactName, 'contactName')
|
|
|
|
|
assertEqual(json.data?.contactEmail, tenantUpdate.contactEmail, 'contactEmail')
|
|
|
|
|
assertEqual(json.data?.status, 'ACTIVE', 'enterprise status')
|
|
|
|
|
}, ({ json }) => ({ id: json.data.id, tenantName: json.data.tenantName, contactName: json.data.contactName, status: json.data.status }))
|
|
|
|
|
|
|
|
|
|
await apiStep('profile-management', 'enterprise profile edit is persisted', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.tenantName, tenantUpdate.tenantName, 'tenantName')
|
|
|
|
|
assertEqual(json.data?.contactEmail, tenantUpdate.contactEmail, 'contactEmail')
|
|
|
|
|
}, ({ json }) => ({ id: json.data.id, tenantName: json.data.tenantName, contactEmail: json.data.contactEmail }))
|
|
|
|
|
|
|
|
|
|
await softApiStep('profile-validation', 'invalid enterprise contact email is rejected', {
|
|
|
|
|
method: 'PUT', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
|
|
|
|
|
body: { ...tenantUpdate, contactEmail: 'invalid-email' },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0422', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code }))
|
|
|
|
|
|
|
|
|
|
await softApiStep('profile-validation', 'blank enterprise address detail is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/iam/tenant/addresses', token: merchantToken,
|
|
|
|
|
body: { addressType: 'OPERATING', countryCode: 'CN', detail: ' ', isDefault: true },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0422', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code }))
|
|
|
|
|
|
|
|
|
|
const addressBody = {
|
|
|
|
|
addressType: 'OPERATING',
|
|
|
|
|
countryCode: 'CN',
|
|
|
|
|
province: '广东省',
|
|
|
|
|
city: '深圳市',
|
|
|
|
|
district: '南山区',
|
|
|
|
|
detail: `客户端验收地址 ${marker}`,
|
|
|
|
|
postalCode: '518000',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
}
|
|
|
|
|
const createdAddress = await apiStep('profile-management', 'add enterprise operating address', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/iam/tenant/addresses', token: merchantToken, body: addressBody,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.id, 'enterprise address id')
|
|
|
|
|
assertEqual(json.data?.addressType, addressBody.addressType, 'addressType')
|
|
|
|
|
assertEqual(json.data?.detail, addressBody.detail, 'address detail')
|
|
|
|
|
assertEqual(json.data?.isDefault, true, 'address isDefault')
|
|
|
|
|
}, ({ json }) => ({ id: json.data.id, addressType: json.data.addressType, city: json.data.city, isDefault: json.data.isDefault }))
|
|
|
|
|
report.resources.enterpriseAddressId = createdAddress.json.data.id
|
|
|
|
|
|
|
|
|
|
await apiStep('profile-management', 'enterprise address list contains created address', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/tenant/addresses', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
const address = arrayData(json.data).find((item) => String(item.id) === String(report.resources.enterpriseAddressId))
|
|
|
|
|
assertPresent(address, 'created enterprise address')
|
|
|
|
|
assertEqual(address.detail, addressBody.detail, 'address detail')
|
|
|
|
|
assertEqual(address.postalCode, addressBody.postalCode, 'address postalCode')
|
|
|
|
|
}, ({ json }) => ({ addressCount: arrayData(json.data).length, addressId: report.resources.enterpriseAddressId }))
|
|
|
|
|
|
|
|
|
|
await apiStep('profile-management', 'platform can read created enterprise address', {
|
|
|
|
|
method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/addresses`, token: platformToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
const address = arrayData(json.data).find((item) => String(item.id) === String(report.resources.enterpriseAddressId))
|
|
|
|
|
assertPresent(address, 'platform enterprise address')
|
|
|
|
|
assertEqual(address.detail, addressBody.detail, 'platform address detail')
|
|
|
|
|
}, ({ json }) => ({ addressCount: arrayData(json.data).length, addressId: report.resources.enterpriseAddressId }))
|
|
|
|
|
|
|
|
|
|
const personal = await apiStep('profile-management', 'load personal profile', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/profile', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.username, username, 'personal username')
|
|
|
|
|
}, ({ json }) => ({ userId: json.data.userId, username: json.data.username, displayName: json.data.displayName }))
|
|
|
|
|
const personalUpdate = {
|
|
|
|
|
displayName: `测试企业管理员-${marker}`.slice(0, 64),
|
|
|
|
|
phone: legalPhone,
|
|
|
|
|
email: `owner_${marker.replaceAll('-', '')}@example.com`,
|
|
|
|
|
}
|
|
|
|
|
await softApiStep('profile-validation', 'invalid personal email is rejected', {
|
|
|
|
|
method: 'PUT', path: '/api/merchant/v1/iam/profile', token: merchantToken,
|
|
|
|
|
body: { ...personalUpdate, email: 'invalid-email' },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0422', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code }))
|
|
|
|
|
await apiStep('profile-management', 'edit personal profile', {
|
|
|
|
|
method: 'PUT', path: '/api/merchant/v1/iam/profile', token: merchantToken, body: personalUpdate,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(String(json.data?.userId), String(personal.json.data.userId), 'personal user id')
|
|
|
|
|
assertEqual(json.data?.displayName, personalUpdate.displayName, 'displayName')
|
|
|
|
|
assertEqual(json.data?.email, personalUpdate.email, 'email')
|
|
|
|
|
}, ({ json }) => ({ userId: json.data.userId, username: json.data.username, displayName: json.data.displayName }))
|
|
|
|
|
|
|
|
|
|
const relogin = await apiStep('profile-management', 're-login after personal profile token revocation', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'accessToken')
|
|
|
|
|
}, () => ({ username }))
|
|
|
|
|
merchantToken = relogin.json.data.accessToken
|
|
|
|
|
merchantRefreshToken = relogin.json.data.refreshToken
|
|
|
|
|
|
|
|
|
|
await apiStep('profile-management', 'personal profile edit is persisted', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/profile', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.displayName, personalUpdate.displayName, 'displayName')
|
|
|
|
|
assertEqual(json.data?.email, personalUpdate.email, 'email')
|
|
|
|
|
}, ({ json }) => ({ userId: json.data.userId, username: json.data.username, displayName: json.data.displayName }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function rbacAndMemberModule() {
|
|
|
|
|
const permissions = await apiStep('rbac-member', 'load merchant permission resources', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/permissions', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertTrue(arrayData(json.data).length > 0, 'permission resources must not be empty')
|
|
|
|
|
}, ({ json }) => ({ permissionCount: arrayData(json.data).length }))
|
|
|
|
|
const desiredCodes = ['merchant:order:list', 'merchant:order:view']
|
|
|
|
|
const grantedPermissions = arrayData(permissions.json.data)
|
|
|
|
|
.filter((item) => item.status === 'ACTIVE' && desiredCodes.includes(item.permissionCode))
|
|
|
|
|
assertEqual(grantedPermissions.length, desiredCodes.length, 'order permission count')
|
|
|
|
|
const permissionIds = grantedPermissions.map((item) => item.id)
|
2026-08-08 10:17:43 +08:00
|
|
|
const orderListPermissionId = grantedPermissions
|
|
|
|
|
.find((item) => item.permissionCode === 'merchant:order:list')?.id
|
|
|
|
|
assertPresent(orderListPermissionId, 'merchant:order:list permission id')
|
2026-08-08 10:11:04 +08:00
|
|
|
|
|
|
|
|
const ownerPermissions = await apiStep('menu-permissions', 'load owner effective permissions', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
const values = arrayData(json.data)
|
|
|
|
|
for (const code of requiredOwnerBackendPermissions()) {
|
|
|
|
|
assertTrue(values.includes(code), `owner permission is missing: ${code}`)
|
|
|
|
|
}
|
|
|
|
|
}, ({ json }) => ({
|
|
|
|
|
backendPermissionCount: arrayData(json.data).length,
|
|
|
|
|
visibleClientMenus: visibleClientMenus(arrayData(json.data)),
|
|
|
|
|
}))
|
|
|
|
|
assertSetEqual(
|
|
|
|
|
visibleClientMenus(arrayData(ownerPermissions.json.data)),
|
|
|
|
|
['account:profile:view', 'company:member:list', 'company:permission:list', 'company:profile:view',
|
|
|
|
|
'company:role:list', 'dashboard:view', 'order:first-leg:list'],
|
|
|
|
|
'owner visible Client menus',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await apiStep('rbac-member', 'load initial merchant roles', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/roles', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertTrue(Array.isArray(json.data), 'role list must be an array')
|
|
|
|
|
}, ({ json }) => ({ roleCount: arrayData(json.data).length }))
|
|
|
|
|
|
|
|
|
|
const roleCode = `E2E_${marker.replaceAll('-', '_')}`.slice(0, 64)
|
|
|
|
|
const createdRole = await apiStep('rbac-member', 'create custom role', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/iam/roles', token: merchantToken,
|
|
|
|
|
body: { roleCode, roleName: `订单只读-${marker}`.slice(0, 64), description: `Client E2E ${marker}` },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.id, 'role id')
|
|
|
|
|
assertEqual(json.data?.roleCode, roleCode, 'roleCode')
|
|
|
|
|
assertEqual(json.data?.roleType, 'CUSTOM', 'roleType')
|
|
|
|
|
}, ({ json }) => ({ roleId: json.data.id, roleCode: json.data.roleCode, roleType: json.data.roleType, status: json.data.status }))
|
|
|
|
|
const roleId = createdRole.json.data.id
|
|
|
|
|
report.resources.roleId = roleId
|
|
|
|
|
|
|
|
|
|
await apiStep('rbac-member', 'grant permissions to custom role', {
|
|
|
|
|
method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken,
|
|
|
|
|
body: { roleId, permissionIds },
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ roleId, permissionIds, permissionCodes: desiredCodes }))
|
|
|
|
|
|
|
|
|
|
await apiStep('rbac-member', 'custom role permission detail is persisted', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertSetEqual(arrayData(json.data?.permissionIds).map(String), permissionIds.map(String), 'role permissionIds')
|
|
|
|
|
}, ({ json }) => ({ roleId: json.data.id, roleName: json.data.roleName, permissionIds: json.data.permissionIds }))
|
|
|
|
|
|
|
|
|
|
await apiStep('rbac-member', 'edit custom role', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken,
|
|
|
|
|
body: { roleName: `订单查询员-${marker}`.slice(0, 64), description: `edited ${marker}` },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.description, `edited ${marker}`, 'role description')
|
|
|
|
|
}, ({ json }) => ({ roleId: json.data.id, roleName: json.data.roleName, description: json.data.description }))
|
|
|
|
|
|
|
|
|
|
await apiStep('rbac-member', 'disable custom role', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'DISABLED' },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.status, 'DISABLED', 'role status')
|
|
|
|
|
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
|
|
|
|
|
await apiStep('rbac-member', 'enable custom role', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'ACTIVE' },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.status, 'ACTIVE', 'role status')
|
|
|
|
|
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
|
|
|
|
|
|
|
|
|
|
const memberUsername = `member_${compactTimestamp(new Date())}_${randomUUID().slice(0, 5)}`.slice(0, 64)
|
|
|
|
|
const memberPassword = `Member${randomUUID().replaceAll('-', '').slice(0, 14)}8!`
|
|
|
|
|
const resetMemberPassword = `Reset${randomUUID().replaceAll('-', '').slice(0, 15)}7!`
|
|
|
|
|
const memberBody = {
|
|
|
|
|
username: memberUsername,
|
|
|
|
|
password: memberPassword,
|
|
|
|
|
displayName: `测试订单成员-${marker}`.slice(0, 64),
|
|
|
|
|
phone: `137${`${Date.now() + 1}`.slice(-8)}`,
|
|
|
|
|
email: `${memberUsername}@example.com`,
|
|
|
|
|
memberType: 'EMPLOYEE',
|
|
|
|
|
memberNo: `EMP-${marker}`.slice(0, 64),
|
|
|
|
|
roleIds: [roleId],
|
|
|
|
|
}
|
|
|
|
|
await softApiStep('member-validation', 'member with weak password and invalid email is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/iam/members', token: merchantToken,
|
|
|
|
|
body: { ...memberBody, username: `bad_${randomUUID().slice(0, 8)}`, password: 'password', email: 'invalid-email' },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0422', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code }))
|
|
|
|
|
const createdMember = await apiStep('rbac-member', 'create enterprise member with role', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/iam/members', token: merchantToken, body: memberBody,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.membershipId, 'membership id')
|
|
|
|
|
assertEqual(json.data?.memberType, 'EMPLOYEE', 'memberType')
|
|
|
|
|
assertSetEqual(arrayData(json.data?.roleIds).map(String), [String(roleId)], 'member roleIds')
|
|
|
|
|
}, ({ json }) => ({ membershipId: json.data.membershipId, memberType: json.data.memberType, status: json.data.status, roleIds: json.data.roleIds }))
|
|
|
|
|
const membershipId = createdMember.json.data.membershipId
|
|
|
|
|
report.resources.testMembershipId = membershipId
|
|
|
|
|
|
|
|
|
|
await apiStep('rbac-member', 'enterprise member list contains created member and roles', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/members', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
const member = arrayData(json.data).find((item) => String(item.membershipId) === String(membershipId))
|
|
|
|
|
assertPresent(member, 'created member list row')
|
|
|
|
|
assertEqual(member.displayName, memberBody.displayName, 'member displayName')
|
|
|
|
|
assertEqual(member.status, 'ACTIVE', 'member status')
|
|
|
|
|
assertSetEqual(arrayData(member.roleIds).map(String), [String(roleId)], 'member list roleIds')
|
|
|
|
|
}, ({ json }) => ({ memberCount: arrayData(json.data).length, membershipId }))
|
|
|
|
|
|
|
|
|
|
await apiStep('rbac-member', 'enterprise member detail matches created fields and roles', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/iam/members/${membershipId}`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(String(json.data?.membershipId), String(membershipId), 'membership id')
|
|
|
|
|
assertEqual(json.data?.displayName, memberBody.displayName, 'member displayName')
|
|
|
|
|
assertEqual(json.data?.phone, memberBody.phone, 'member phone')
|
|
|
|
|
assertEqual(json.data?.email, memberBody.email, 'member email')
|
|
|
|
|
assertEqual(json.data?.memberNo, memberBody.memberNo, 'memberNo')
|
|
|
|
|
assertSetEqual(arrayData(json.data?.roleIds).map(String), [String(roleId)], 'member detail roleIds')
|
|
|
|
|
}, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status, roleIds: json.data.roleIds }))
|
|
|
|
|
|
|
|
|
|
await apiStep('rbac-member', 'duplicate member username is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/iam/members', token: merchantToken, body: memberBody,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0409', 'business code')
|
|
|
|
|
}, ({ json }) => ({ username: memberUsername, rejectionCode: json.code }))
|
|
|
|
|
await apiStep('rbac-member', 'assigned custom role cannot be deleted', {
|
|
|
|
|
method: 'DELETE', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0409', 'business code')
|
|
|
|
|
}, ({ json }) => ({ roleId, rejectionCode: json.code }))
|
|
|
|
|
await apiStep('rbac-member', 'enterprise owner cannot be disabled', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/iam/members/${report.resources.membershipId}/status`, token: merchantToken,
|
|
|
|
|
body: { status: 'DISABLED' },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0403', 'business code')
|
|
|
|
|
}, ({ json }) => ({ membershipId: report.resources.membershipId, rejectionCode: json.code }))
|
|
|
|
|
await apiStep('rbac-member', 'enterprise owner cannot be edited through member API', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/iam/members/${report.resources.membershipId}`, token: merchantToken,
|
|
|
|
|
body: {
|
|
|
|
|
displayName: '非法修改企业所有者', phone: legalPhone, email: `${username}@example.com`,
|
|
|
|
|
memberType: 'OWNER', memberNo: 'OWNER', roleIds: [],
|
|
|
|
|
},
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0403', 'business code')
|
|
|
|
|
}, ({ json }) => ({ membershipId: report.resources.membershipId, rejectionCode: json.code }))
|
|
|
|
|
|
|
|
|
|
const memberLogin = await apiStep('rbac-member', 'new enterprise member can login', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'member accessToken')
|
|
|
|
|
}, () => ({ username: memberUsername }))
|
|
|
|
|
let memberToken = memberLogin.json.data.accessToken
|
|
|
|
|
|
|
|
|
|
await apiStep('rbac-member', 'member can use granted order list permission', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
|
|
|
|
}, ({ json }) => requireSuccess(json), ({ json }) => ({ total: json.data?.total }))
|
|
|
|
|
await apiStep('rbac-member', 'member can view order with granted permission', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(String(json.data?.order?.id), String(report.resources.orderIds[0]), 'order id')
|
|
|
|
|
}, ({ json }) => ({ orderId: json.data.order.id, status: json.data.order.status }))
|
|
|
|
|
|
|
|
|
|
await apiStep('menu-permissions', 'read-only member effective permissions match role', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertSetEqual(arrayData(json.data), desiredCodes, 'member backend permissions')
|
|
|
|
|
assertSetEqual(
|
|
|
|
|
visibleClientMenus(arrayData(json.data)),
|
|
|
|
|
['account:profile:view', 'dashboard:view', 'order:first-leg:list'],
|
|
|
|
|
'member visible Client menus',
|
|
|
|
|
)
|
|
|
|
|
}, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) }))
|
|
|
|
|
|
2026-08-08 10:31:44 +08:00
|
|
|
const disabledRole = await softApiStep('menu-permissions', 'disable assigned role', {
|
2026-08-08 10:11:04 +08:00
|
|
|
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'DISABLED' },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.status, 'DISABLED', 'role status')
|
|
|
|
|
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
|
2026-08-08 10:31:44 +08:00
|
|
|
if (disabledRole) {
|
2026-08-08 11:33:18 +08:00
|
|
|
await softApiStep('menu-permissions', 'disabled role revokes stale token permission query', {
|
2026-08-08 10:11:04 +08:00
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
|
|
|
|
|
}, ({ json, status }) => {
|
2026-08-08 11:33:18 +08:00
|
|
|
assertEqual(status, 401, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0401', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
2026-08-08 10:11:04 +08:00
|
|
|
await softApiStep('menu-permissions', 'disabled role blocks stale token order access', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertTrue([200, 401, 403].includes(status), `unexpected HTTP status ${status}`)
|
|
|
|
|
assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`)
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
|
|
|
|
|
|
|
|
|
const disabledRoleLogin = await apiStep('menu-permissions', 'member login succeeds while role is disabled', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'member accessToken')
|
|
|
|
|
}, () => ({ username: memberUsername }))
|
|
|
|
|
memberToken = disabledRoleLogin.json.data.accessToken
|
|
|
|
|
await apiStep('menu-permissions', 'disabled role removes member menus and permissions', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(arrayData(json.data).length, 0, 'effective backend permission count')
|
|
|
|
|
assertSetEqual(visibleClientMenus(arrayData(json.data)), ['account:profile:view', 'dashboard:view'], 'visible Client menus')
|
|
|
|
|
}, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) }))
|
|
|
|
|
await softApiStep('menu-permissions', 'disabled role blocks member order list', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertTrue([200, 403].includes(status), `unexpected HTTP status ${status}`)
|
|
|
|
|
assertEqual(String(json?.code), 'A0403', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code }))
|
|
|
|
|
|
|
|
|
|
await apiStep('menu-permissions', 're-enable assigned role', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'ACTIVE' },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.status, 'ACTIVE', 'role status')
|
|
|
|
|
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
|
|
|
|
|
const enabledRoleLogin = await apiStep('menu-permissions', 'member re-login after role is enabled', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'member accessToken')
|
|
|
|
|
}, () => ({ username: memberUsername }))
|
|
|
|
|
memberToken = enabledRoleLogin.json.data.accessToken
|
|
|
|
|
await apiStep('menu-permissions', 're-enabled role restores member menus', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertSetEqual(arrayData(json.data), desiredCodes, 'member backend permissions')
|
|
|
|
|
assertSetEqual(visibleClientMenus(arrayData(json.data)), ['account:profile:view', 'dashboard:view', 'order:first-leg:list'], 'visible Client menus')
|
|
|
|
|
}, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) }))
|
2026-08-08 10:31:44 +08:00
|
|
|
} else {
|
|
|
|
|
recordSkipped('menu-permissions', 'role disable dependent authorization checks',
|
|
|
|
|
'Role status change failed; dependent checks require a successfully disabled role')
|
|
|
|
|
}
|
2026-08-08 10:11:04 +08:00
|
|
|
|
2026-08-08 10:31:44 +08:00
|
|
|
const reducedPermissions = await softApiStep('menu-permissions', 'remove order view permission from assigned role', {
|
2026-08-08 10:17:43 +08:00
|
|
|
method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken,
|
|
|
|
|
body: { roleId, permissionIds: [orderListPermissionId] },
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ roleId, permissionCodes: [desiredCodes[0]] }))
|
2026-08-08 10:31:44 +08:00
|
|
|
if (reducedPermissions) {
|
2026-08-08 10:17:43 +08:00
|
|
|
await softApiStep('menu-permissions', 'permission removal revokes stale member token', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertTrue([200, 401, 403].includes(status), `unexpected HTTP status ${status}`)
|
|
|
|
|
assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`)
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
|
|
|
|
|
|
|
|
|
const reducedPermissionLogin = await apiStep('menu-permissions', 'member re-login after permission removal', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'member accessToken')
|
|
|
|
|
}, () => ({ username: memberUsername }))
|
|
|
|
|
memberToken = reducedPermissionLogin.json.data.accessToken
|
|
|
|
|
await apiStep('menu-permissions', 'permission removal is reflected after re-login', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertSetEqual(arrayData(json.data), [desiredCodes[0]], 'reduced member permissions')
|
|
|
|
|
}, ({ json }) => ({ backendPermissions: json.data }))
|
|
|
|
|
await apiStep('menu-permissions', 'member without view permission cannot read order detail', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertTrue([200, 403].includes(status), `unexpected HTTP status ${status}`)
|
|
|
|
|
assertEqual(String(json?.code), 'A0403', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code }))
|
|
|
|
|
|
|
|
|
|
await apiStep('menu-permissions', 'restore assigned role permissions', {
|
|
|
|
|
method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken,
|
|
|
|
|
body: { roleId, permissionIds },
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ roleId, permissionCodes: desiredCodes }))
|
|
|
|
|
const restoredPermissionLogin = await apiStep('menu-permissions', 'member re-login after permission restoration', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'member accessToken')
|
|
|
|
|
}, () => ({ username: memberUsername }))
|
|
|
|
|
memberToken = restoredPermissionLogin.json.data.accessToken
|
|
|
|
|
await apiStep('menu-permissions', 'restored permission allows order detail again', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(String(json.data?.order?.id), String(report.resources.orderIds[0]), 'order id')
|
|
|
|
|
}, ({ json }) => ({ orderId: json.data.order.id }))
|
2026-08-08 10:31:44 +08:00
|
|
|
} else {
|
|
|
|
|
recordSkipped('menu-permissions', 'permission removal dependent authorization checks',
|
|
|
|
|
'Role permission update failed; dependent checks require a successfully reduced permission set')
|
|
|
|
|
}
|
2026-08-08 10:17:43 +08:00
|
|
|
|
2026-08-08 10:11:04 +08:00
|
|
|
const editedMemberBody = {
|
|
|
|
|
displayName: `已编辑订单成员-${marker}`.slice(0, 64),
|
|
|
|
|
phone: memberBody.phone,
|
|
|
|
|
email: `edited_${memberUsername}@example.com`,
|
|
|
|
|
memberType: 'EMPLOYEE', memberNo: memberBody.memberNo, roleIds: [roleId],
|
|
|
|
|
}
|
|
|
|
|
await apiStep('rbac-member', 'edit enterprise member', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}`, token: merchantToken, body: editedMemberBody,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.displayName, editedMemberBody.displayName, 'member displayName')
|
|
|
|
|
assertEqual(json.data?.email, editedMemberBody.email, 'member email')
|
|
|
|
|
}, ({ json }) => ({ membershipId: json.data.membershipId, displayName: json.data.displayName, status: json.data.status }))
|
|
|
|
|
|
|
|
|
|
await apiStep('rbac-member', 'reset enterprise member password', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/password`, token: merchantToken,
|
|
|
|
|
body: { newPassword: resetMemberPassword },
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ membershipId }))
|
|
|
|
|
await apiStep('rbac-member', 'member old password is rejected after reset', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0401', 'business code')
|
|
|
|
|
}, ({ json }) => ({ username: memberUsername, rejectionCode: json.code }))
|
|
|
|
|
const resetLogin = await apiStep('rbac-member', 'member can login with reset password', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: resetMemberPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'member accessToken')
|
|
|
|
|
}, () => ({ username: memberUsername }))
|
|
|
|
|
memberToken = resetLogin.json.data.accessToken
|
|
|
|
|
|
|
|
|
|
await apiStep('rbac-member', 'disable enterprise member', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/status`, token: merchantToken, body: { status: 'DISABLED' },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.status, 'DISABLED', 'member status')
|
|
|
|
|
}, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status }))
|
2026-08-08 10:17:43 +08:00
|
|
|
await softApiStep('rbac-member', 'disabled member stale token is rejected', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertTrue([200, 401, 403].includes(status), `unexpected HTTP status ${status}`)
|
|
|
|
|
assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`)
|
|
|
|
|
}, ({ json }) => ({ membershipId, rejectionCode: json?.code }))
|
2026-08-08 10:11:04 +08:00
|
|
|
await apiStep('rbac-member', 'disabled member login is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: resetMemberPassword },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`)
|
|
|
|
|
}, ({ json }) => ({ username: memberUsername, rejectionCode: json.code }))
|
|
|
|
|
await apiStep('rbac-member', 'enable enterprise member', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/status`, token: merchantToken, body: { status: 'ACTIVE' },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.status, 'ACTIVE', 'member status')
|
|
|
|
|
}, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status }))
|
|
|
|
|
|
2026-08-08 10:17:43 +08:00
|
|
|
const enabledMemberLogin = await apiStep('rbac-member', 'member re-login after account is enabled', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: resetMemberPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'member accessToken')
|
|
|
|
|
}, () => ({ username: memberUsername }))
|
|
|
|
|
memberToken = enabledMemberLogin.json.data.accessToken
|
|
|
|
|
await apiStep('rbac-member', 're-enabled member can list orders before roles are cleared', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
|
|
|
|
}, ({ json }) => requireSuccess(json), ({ json }) => ({ total: json.data?.total }))
|
|
|
|
|
|
2026-08-08 10:11:04 +08:00
|
|
|
await apiStep('rbac-member', 'clear enterprise member roles', {
|
|
|
|
|
method: 'PUT', path: '/api/merchant/v1/iam/members/roles', token: merchantToken,
|
|
|
|
|
body: { membershipId, roleIds: [] },
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ membershipId, roleIds: [] }))
|
|
|
|
|
await apiStep('rbac-member', 'member role clearing is persisted', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/iam/members/${membershipId}/roles`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(String(json.data?.membershipId), String(membershipId), 'membership id')
|
|
|
|
|
assertEqual(arrayData(json.data?.roleIds).length, 0, 'member role count')
|
|
|
|
|
}, ({ json }) => ({ membershipId: json.data.membershipId, roleIds: json.data.roleIds }))
|
2026-08-08 10:31:44 +08:00
|
|
|
await softApiStep('rbac-member', 'role clearing revokes stale member token', {
|
2026-08-08 10:11:04 +08:00
|
|
|
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 401, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0401', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json.code }))
|
|
|
|
|
const noRoleLogin = await apiStep('rbac-member', 'member re-login after role clearing', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: resetMemberPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'member accessToken')
|
|
|
|
|
}, () => ({ username: memberUsername }))
|
|
|
|
|
memberToken = noRoleLogin.json.data.accessToken
|
|
|
|
|
await apiStep('rbac-member', 'member without roles cannot list orders', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertTrue([200, 403].includes(status), `unexpected HTTP status ${status}`)
|
|
|
|
|
assertEqual(String(json?.code), 'A0403', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json.code }))
|
|
|
|
|
|
|
|
|
|
await apiStep('rbac-member', 'disable test member after verification', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/status`, token: merchantToken, body: { status: 'DISABLED' },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.status, 'DISABLED', 'member status')
|
|
|
|
|
}, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status }))
|
|
|
|
|
await apiStep('rbac-member', 'delete unassigned custom role', {
|
|
|
|
|
method: 'DELETE', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ roleId }))
|
|
|
|
|
report.resources.roleId = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function orderLifecycleModule() {
|
|
|
|
|
const masterData = await loadOrderMasterData(merchantToken, 'order-master-data')
|
|
|
|
|
await estimateAvailablePrice(masterData)
|
|
|
|
|
const productImageFileId = await uploadProductImage(merchantToken, 'order-product-image')
|
|
|
|
|
report.resources.productImageFileId = productImageFileId
|
|
|
|
|
|
|
|
|
|
const invalidImageForm = new FormData()
|
|
|
|
|
invalidImageForm.append('file', new Blob(['not-an-image'], { type: 'text/plain' }), `invalid-product-${marker}.txt`)
|
|
|
|
|
await softApiStep('order-product-image', 'invalid product image MIME type is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: invalidImageForm,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0400', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
|
|
|
|
|
2026-08-08 10:17:43 +08:00
|
|
|
const emptyImageForm = new FormData()
|
|
|
|
|
emptyImageForm.append('file', new Blob([], { type: 'image/png' }), `empty-product-${marker}.png`)
|
|
|
|
|
await softApiStep('order-product-image', 'empty product image is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: emptyImageForm,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0400', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
|
|
|
|
|
|
|
|
|
const forgedImageForm = new FormData()
|
|
|
|
|
forgedImageForm.append('file', new Blob(['not-a-real-png'], { type: 'image/png' }), `forged-product-${marker}.png`)
|
|
|
|
|
await softApiStep('order-product-image', 'forged product image signature is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: forgedImageForm,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0400', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
|
|
|
|
|
|
|
|
|
const oversizedImageForm = new FormData()
|
|
|
|
|
oversizedImageForm.append('file', new Blob([Buffer.alloc(5 * 1024 * 1024 + 1)], { type: 'image/png' }), `oversized-product-${marker}.png`)
|
|
|
|
|
await softApiStep('order-product-image', 'product image over 5MB is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: oversizedImageForm,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0400', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
|
|
|
|
|
2026-08-08 10:11:04 +08:00
|
|
|
await apiStep('order-lifecycle', 'download own product image', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/files/product-images/${productImageFileId}`, token: merchantToken,
|
|
|
|
|
responseType: 'bytes',
|
|
|
|
|
}, ({ status, bytes, headers }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertTrue(bytes?.length > 0, 'downloaded image must not be empty')
|
|
|
|
|
assertTrue((headers.get('content-type') ?? '').startsWith('image/'), 'download must return an image')
|
|
|
|
|
}, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') }))
|
|
|
|
|
|
|
|
|
|
const payload = buildOrderPayload(masterData, productImageFileId, 'ACTIVE', 'fresh draft after approval')
|
|
|
|
|
const invalidOrderCases = [
|
|
|
|
|
{
|
|
|
|
|
name: 'FBA order missing Amazon identifiers is rejected',
|
|
|
|
|
mutate(value) {
|
|
|
|
|
value.amazonWarehouseCode = null
|
|
|
|
|
value.shipmentId = null
|
|
|
|
|
value.referenceId = null
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'FBA cargo missing SKU UPC shipment and reference is rejected',
|
|
|
|
|
mutate(value) {
|
|
|
|
|
value.cargoes[0].skuNo = null
|
|
|
|
|
value.cargoes[0].upcNo = null
|
|
|
|
|
value.cargoes[0].shipmentId = null
|
|
|
|
|
value.cargoes[0].referenceId = null
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'PICKUP order without sender is rejected',
|
|
|
|
|
mutate(value) { value.sender = null },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'PRIVATE destination without receiver is rejected',
|
|
|
|
|
mutate(value) {
|
|
|
|
|
value.destinationType = 'PRIVATE'
|
|
|
|
|
value.receiver = null
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'invalid product image file ID is rejected',
|
|
|
|
|
mutate(value) { value.cargoes[0].productImageFileId = -1 },
|
|
|
|
|
},
|
2026-08-08 10:17:43 +08:00
|
|
|
{
|
|
|
|
|
name: 'zero package count weight and volume are rejected',
|
|
|
|
|
mutate(value) {
|
|
|
|
|
value.cargoes[0].packageCount = 0
|
|
|
|
|
value.cargoes[0].grossWeightKg = 0
|
|
|
|
|
value.cargoes[0].volumeCbm = 0
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'customer remark over 1000 characters is rejected',
|
|
|
|
|
mutate(value) { value.customerRemark = 'x'.repeat(1001) },
|
|
|
|
|
},
|
2026-08-08 10:11:04 +08:00
|
|
|
]
|
|
|
|
|
for (const testCase of invalidOrderCases) {
|
|
|
|
|
const invalidPayload = structuredClone(payload)
|
|
|
|
|
testCase.mutate(invalidPayload)
|
|
|
|
|
await softApiStep('order-validation', testCase.name, {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': newIdempotencyKey('invalid-order') }, body: invalidPayload,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0422', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
|
|
|
|
}
|
2026-08-08 10:17:43 +08:00
|
|
|
await softApiStep('order-validation', 'missing Idempotency-Key is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, body: payload,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertTrue([200, 400].includes(status), `unexpected HTTP status ${status}`)
|
|
|
|
|
assertEqual(String(json?.code), 'A0400', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
|
|
|
|
await softApiStep('order-validation', 'Idempotency-Key over 128 characters is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': 'x'.repeat(129) }, body: payload,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0422', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
2026-08-08 10:11:04 +08:00
|
|
|
const createKey = newIdempotencyKey('approved-create')
|
|
|
|
|
const created = await apiStep('order-lifecycle', 'create valid draft after approval', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': createKey }, body: payload,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data, 'order id')
|
|
|
|
|
}, ({ json }) => ({ orderId: json.data }))
|
|
|
|
|
const orderId = created.json.data
|
|
|
|
|
report.resources.orderIds.push(orderId)
|
|
|
|
|
|
|
|
|
|
await apiStep('order-lifecycle', 'create order idempotent replay', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': createKey }, body: payload,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(String(json.data), String(orderId), 'replayed order id')
|
|
|
|
|
}, ({ json }) => ({ orderId: json.data, replayed: true }))
|
|
|
|
|
|
|
|
|
|
const conflictingPayload = structuredClone(payload)
|
|
|
|
|
conflictingPayload.customerRemark = `idempotency-conflict-${marker}`
|
|
|
|
|
await softApiStep('order-boundary', 'same idempotency key with different payload is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': createKey }, body: conflictingPayload,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0409', 'business code')
|
|
|
|
|
}, ({ json }) => ({ orderId, rejectionCode: json.code }))
|
|
|
|
|
|
|
|
|
|
const editedPayload = structuredClone(payload)
|
|
|
|
|
editedPayload.customerRemark = `edited-${marker}`
|
|
|
|
|
editedPayload.cargoes[0].cargoRemark = `edited-cargo-${marker}`
|
|
|
|
|
editedPayload.cargoes[0].model = `MODEL-EDIT-${marker}`.slice(0, 64)
|
|
|
|
|
await apiStep('order-lifecycle', 'edit draft', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload,
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ orderId, customerRemark: editedPayload.customerRemark }))
|
|
|
|
|
|
|
|
|
|
const detail = await apiStep('order-lifecycle', 'order detail preserves complete product fields', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(String(json.data?.order?.id), String(orderId), 'order id')
|
|
|
|
|
assertEqual(json.data?.order?.status, 'DRAFT', 'order status')
|
|
|
|
|
assertEqual(json.data?.order?.customerRemark, editedPayload.customerRemark, 'customer remark')
|
|
|
|
|
const cargo = arrayData(json.data?.cargoes)[0]
|
|
|
|
|
assertCargo(cargo, editedPayload.cargoes[0])
|
|
|
|
|
}, ({ json }) => ({
|
|
|
|
|
orderId: json.data.order.id,
|
|
|
|
|
orderNo: json.data.order.orderNo,
|
|
|
|
|
status: json.data.order.status,
|
|
|
|
|
cargo: cargoEvidence(json.data.cargoes[0]),
|
|
|
|
|
}))
|
|
|
|
|
const orderNo = detail.json.data.order.orderNo
|
|
|
|
|
|
|
|
|
|
await apiStep('order-lifecycle', 'order list contains edited draft', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&orderNo=${encodeURIComponent(orderNo)}`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
const row = pageRecords(json.data).find((item) => String(item.orderId) === String(orderId))
|
|
|
|
|
assertPresent(row, 'order list row')
|
|
|
|
|
assertEqual(row.status, 'DRAFT', 'list order status')
|
|
|
|
|
assertEqual(row.referenceId, editedPayload.referenceId, 'list referenceId')
|
|
|
|
|
assertEqual(row.remark, editedPayload.customerRemark, 'list remark')
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
const row = pageRecords(json.data).find((item) => String(item.orderId) === String(orderId))
|
|
|
|
|
return { total: json.data?.total, orderId: row?.orderId, orderNo: row?.orderNo, status: row?.status }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await apiStep('order-filters', 'order keyword filter finds draft by order number', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&keyword=${encodeURIComponent(orderNo)}`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'keyword-filtered order')
|
|
|
|
|
}, ({ json }) => ({ total: json.data?.total, orderId }))
|
|
|
|
|
|
|
|
|
|
await apiStep('order-filters', 'DRAFT status filter contains draft', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=50&status=DRAFT', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'draft status-filtered order')
|
|
|
|
|
}, ({ json }) => ({ total: json.data?.total, orderId, status: 'DRAFT' }))
|
|
|
|
|
|
|
|
|
|
const submitKey = newIdempotencyKey('approved-submit')
|
|
|
|
|
await apiStep('order-lifecycle', 'submit order', {
|
|
|
|
|
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': submitKey },
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ orderId }))
|
|
|
|
|
|
|
|
|
|
await apiStep('order-lifecycle', 'submit order idempotent replay', {
|
|
|
|
|
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': submitKey },
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ orderId, replayed: true }))
|
|
|
|
|
|
|
|
|
|
await apiStep('order-lifecycle', 'submitted status is persisted', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.order?.status, 'SUBMITTED', 'order status')
|
|
|
|
|
}, ({ json }) => ({ orderId, orderNo: json.data.order.orderNo, status: json.data.order.status }))
|
|
|
|
|
|
|
|
|
|
await apiStep('order-filters', 'SUBMITTED status filter contains submitted order', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=50&status=SUBMITTED', token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'submitted status-filtered order')
|
|
|
|
|
}, ({ json }) => ({ total: json.data?.total, orderId, status: 'SUBMITTED' }))
|
|
|
|
|
|
2026-08-08 10:17:43 +08:00
|
|
|
await apiStep('order-filters', 'warehouse channel and destination filters contain submitted order', {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
path: `/api/merchant/v1/orders/first-leg?page=1&size=50&receivingWarehouseId=${payload.receivingWarehouseId}`
|
|
|
|
|
+ `&channelName=${encodeURIComponent(payload.channelName)}&destinationType=${payload.destinationType}`
|
|
|
|
|
+ `&destinationWarehouseId=${payload.destinationWarehouseId}`,
|
|
|
|
|
token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'combined-filtered order')
|
|
|
|
|
}, ({ json }) => ({ total: json.data?.total, orderId }))
|
|
|
|
|
await apiStep('order-filters', 'sender phone filter contains submitted order', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&customerContact=${encodeURIComponent(legalPhone)}`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'sender-phone-filtered order')
|
|
|
|
|
}, ({ json }) => ({ total: json.data?.total, orderId }))
|
|
|
|
|
await apiStep('order-filters', 'unmatched keyword returns an empty order page', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&keyword=${randomUUID()}`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(pageRecords(json.data).length, 0, 'unmatched order count')
|
|
|
|
|
}, ({ json }) => ({ total: json.data?.total }))
|
|
|
|
|
await softApiStep('order-validation', 'invalid order status filter is rejected', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20&status=UNKNOWN', token: merchantToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertTrue([200, 400].includes(status), `unexpected HTTP status ${status}`)
|
2026-08-08 10:31:44 +08:00
|
|
|
assertEqual(String(json?.code), 'A0422', 'business code')
|
2026-08-08 10:17:43 +08:00
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
|
|
|
|
await apiStep('order-boundary', 'unknown order detail is rejected', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/orders/first-leg/1', token: merchantToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0404', 'business code')
|
|
|
|
|
}, ({ json }) => ({ orderId: 1, rejectionCode: json?.code }))
|
|
|
|
|
|
2026-08-08 10:11:04 +08:00
|
|
|
await softApiStep('order-boundary', 'submitted order cannot be edited by client', {
|
|
|
|
|
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0409', 'business code')
|
|
|
|
|
}, ({ json }) => ({ orderId, rejectionCode: json.code, message: json.message }))
|
|
|
|
|
|
|
|
|
|
await softApiStep('unified-orders', 'unified order list contains submitted order', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders?page=1&size=50&orderNo=${encodeURIComponent(orderNo)}`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
const row = pageRecords(json.data).find((item) => String(item.orderId) === String(orderId))
|
|
|
|
|
assertPresent(row, 'unified order row')
|
|
|
|
|
assertEqual(row.mainStatus, 'PENDING_REVIEW', 'unified mainStatus')
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
const row = pageRecords(json.data).find((item) => String(item.orderId) === String(orderId))
|
|
|
|
|
return { total: json.data?.total, orderId: row?.orderId, mainStatus: row?.mainStatus, internalOrderStatus: row?.internalOrderStatus }
|
|
|
|
|
})
|
|
|
|
|
await softApiStep('unified-orders', 'unified order detail returns timeline', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/${orderId}`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(String(json.data?.summary?.orderId), String(orderId), 'unified order id')
|
|
|
|
|
assertTrue(Array.isArray(json.data?.timeline), 'unified timeline must be an array')
|
|
|
|
|
}, ({ json }) => ({ orderId: json.data?.summary?.orderId, mainStatus: json.data?.summary?.mainStatus, timelineCount: arrayData(json.data?.timeline).length }))
|
|
|
|
|
|
|
|
|
|
const cancelPayload = buildOrderPayload(masterData, productImageFileId, 'CANCEL', 'draft created for cancellation')
|
|
|
|
|
const cancelDraft = await apiStep('order-lifecycle', 'create second draft for cancellation', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': newIdempotencyKey('cancel-create') }, body: cancelPayload,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data, 'order id')
|
|
|
|
|
}, ({ json }) => ({ orderId: json.data }))
|
|
|
|
|
const cancelOrderId = cancelDraft.json.data
|
|
|
|
|
report.resources.orderIds.push(cancelOrderId)
|
|
|
|
|
|
|
|
|
|
await apiStep('order-lifecycle', 'cancel second draft', {
|
|
|
|
|
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken,
|
|
|
|
|
body: { reason: `客户端自动化取消 ${marker}` },
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ orderId: cancelOrderId }))
|
|
|
|
|
|
|
|
|
|
await apiStep('order-lifecycle', 'cancelled status is persisted', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}`, token: merchantToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.order?.status, 'CANCELLED', 'order status')
|
|
|
|
|
}, ({ json }) => ({ orderId: cancelOrderId, status: json.data.order.status }))
|
|
|
|
|
|
|
|
|
|
await softApiStep('order-boundary', 'cancelled order cannot be cancelled again', {
|
|
|
|
|
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken,
|
|
|
|
|
body: { reason: `重复取消 ${marker}` },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0409', 'business code')
|
|
|
|
|
}, ({ json }) => ({ orderId: cancelOrderId, rejectionCode: json.code, message: json.message }))
|
|
|
|
|
await softApiStep('order-boundary', 'cancelled order cannot be submitted', {
|
|
|
|
|
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/submit`, token: merchantToken,
|
|
|
|
|
headers: { 'Idempotency-Key': newIdempotencyKey('cancelled-submit') },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0409', 'business code')
|
|
|
|
|
}, ({ json }) => ({ orderId: cancelOrderId, rejectionCode: json.code, message: json.message }))
|
|
|
|
|
await softApiStep('order-boundary', 'invalid order page size is rejected', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=201', token: merchantToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0422', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json.code }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function isolationModule() {
|
|
|
|
|
if (!existingClientUsername || !existingClientPassword) {
|
|
|
|
|
recordSkipped('isolation', 'cross-client order access is rejected',
|
|
|
|
|
'Set EXISTING_CLIENT_USERNAME and EXISTING_CLIENT_PASSWORD to enable this check')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const login = await apiStep('isolation', 'existing client login', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username: existingClientUsername, password: existingClientPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'accessToken')
|
|
|
|
|
}, () => ({ username: existingClientUsername }))
|
|
|
|
|
existingClientToken = login.json.data.accessToken
|
|
|
|
|
const orderId = report.resources.orderIds[1]
|
|
|
|
|
await apiStep('isolation', 'cross-client order access is rejected', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: existingClientToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
|
|
|
|
|
}, ({ json }) => ({ orderId, rejectionCode: json.code }))
|
|
|
|
|
await softApiStep('isolation', 'cross-client product image access is rejected', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/files/product-images/${report.resources.productImageFileId}`,
|
|
|
|
|
token: existingClientToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`)
|
|
|
|
|
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
|
|
|
|
|
}, ({ json }) => ({ fileId: report.resources.productImageFileId, rejectionCode: json?.code }))
|
2026-08-08 10:17:43 +08:00
|
|
|
await softApiStep('isolation', 'cross-client member detail access is rejected', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/iam/members/${report.resources.testMembershipId}`, token: existingClientToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
|
|
|
|
|
}, ({ json }) => ({ membershipId: report.resources.testMembershipId, rejectionCode: json?.code }))
|
|
|
|
|
await apiStep('isolation', 'existing client address list excludes new enterprise address', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/tenant/addresses', token: existingClientToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertTrue(!arrayData(json.data).some((item) => String(item.id) === String(report.resources.enterpriseAddressId)),
|
|
|
|
|
'cross-client address must not be visible')
|
|
|
|
|
}, ({ json }) => ({ addressCount: arrayData(json.data).length, excludedAddressId: report.resources.enterpriseAddressId }))
|
|
|
|
|
await apiStep('isolation', 'existing client qualification list excludes new enterprise qualification', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: existingClientToken,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertTrue(!arrayData(json.data).some((item) => String(item.id ?? item.qualificationId)
|
|
|
|
|
=== String(report.resources.businessLicenseQualificationId)), 'cross-client qualification must not be visible')
|
|
|
|
|
}, ({ json }) => ({ qualificationCount: arrayData(json.data).length,
|
|
|
|
|
excludedQualificationId: report.resources.businessLicenseQualificationId }))
|
|
|
|
|
await softApiStep('isolation', 'cross-client qualification file access is rejected', {
|
|
|
|
|
method: 'GET', path: apiPath(report.resources.businessLicenseDocumentUrl), token: existingClientToken,
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`)
|
|
|
|
|
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
|
|
|
|
|
}, ({ json }) => ({ qualificationId: report.resources.businessLicenseQualificationId, rejectionCode: json?.code }))
|
2026-08-08 10:11:04 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function accountSecurityModule() {
|
2026-08-08 10:17:43 +08:00
|
|
|
const consumedRefreshToken = merchantRefreshToken
|
2026-08-08 10:11:04 +08:00
|
|
|
const rotated = await apiStep('account-security', 'rotate refresh token', {
|
|
|
|
|
method: 'POST', path: '/api/auth/refresh', body: { refreshToken: merchantRefreshToken },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'rotated accessToken')
|
|
|
|
|
assertPresent(json.data?.refreshToken, 'rotated refreshToken')
|
|
|
|
|
}, () => ({ rotated: true }))
|
|
|
|
|
merchantToken = rotated.json.data.accessToken
|
|
|
|
|
merchantRefreshToken = rotated.json.data.refreshToken
|
2026-08-08 10:17:43 +08:00
|
|
|
await apiStep('account-security', 'consumed refresh token cannot be replayed', {
|
|
|
|
|
method: 'POST', path: '/api/auth/refresh', body: { refreshToken: consumedRefreshToken },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0401', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json?.code }))
|
2026-08-08 10:11:04 +08:00
|
|
|
|
|
|
|
|
temporaryOwnerPassword = `Temporary${randomUUID().replaceAll('-', '').slice(0, 14)}6!`
|
|
|
|
|
await apiStep('account-security', 'change owner password to temporary value', {
|
|
|
|
|
method: 'PUT', path: '/api/auth/password', token: merchantToken,
|
|
|
|
|
body: { oldPassword: clientPassword, newPassword: temporaryOwnerPassword },
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ username, passwordChanged: true }))
|
|
|
|
|
ownerPasswordNeedsRestore = true
|
|
|
|
|
|
|
|
|
|
await apiStep('account-security', 'original password is rejected after change', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0401', 'business code')
|
|
|
|
|
}, ({ json }) => ({ username, rejectionCode: json.code }))
|
|
|
|
|
|
|
|
|
|
const temporaryLogin = await apiStep('account-security', 'temporary password login succeeds', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username, password: temporaryOwnerPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'accessToken')
|
|
|
|
|
}, () => ({ username }))
|
|
|
|
|
merchantToken = temporaryLogin.json.data.accessToken
|
|
|
|
|
|
|
|
|
|
await apiStep('account-security', 'restore owner password', {
|
|
|
|
|
method: 'PUT', path: '/api/auth/password', token: merchantToken,
|
|
|
|
|
body: { oldPassword: temporaryOwnerPassword, newPassword: clientPassword },
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ username, passwordRestored: true }))
|
|
|
|
|
ownerPasswordNeedsRestore = false
|
|
|
|
|
temporaryOwnerPassword = undefined
|
|
|
|
|
|
|
|
|
|
const restoredLogin = await apiStep('account-security', 'restored owner password login succeeds', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'accessToken')
|
|
|
|
|
assertPresent(json.data?.refreshToken, 'refreshToken')
|
|
|
|
|
}, () => ({ username, passwordRestored: true }))
|
|
|
|
|
merchantToken = restoredLogin.json.data.accessToken
|
|
|
|
|
merchantRefreshToken = restoredLogin.json.data.refreshToken
|
|
|
|
|
|
|
|
|
|
const loggedOutRefreshToken = merchantRefreshToken
|
|
|
|
|
await apiStep('account-security', 'logout current client session', {
|
|
|
|
|
method: 'POST', path: '/api/auth/logout', token: merchantToken, body: { refreshToken: loggedOutRefreshToken },
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ username }))
|
|
|
|
|
await apiStep('account-security', 'logged-out refresh token is rejected', {
|
|
|
|
|
method: 'POST', path: '/api/auth/refresh', body: { refreshToken: loggedOutRefreshToken },
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0401', 'business code')
|
|
|
|
|
}, ({ json }) => ({ rejectionCode: json.code }))
|
2026-08-08 10:31:44 +08:00
|
|
|
await apiStep('account-security', 'repeated logout with revoked session is rejected', {
|
2026-08-08 10:17:43 +08:00
|
|
|
method: 'POST', path: '/api/auth/logout', body: { refreshToken: loggedOutRefreshToken },
|
2026-08-08 10:31:44 +08:00
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 401, 'HTTP status')
|
|
|
|
|
assertEqual(String(json?.code), 'A0401', 'business code')
|
|
|
|
|
}, ({ json }) => ({ repeated: true, rejectionCode: json?.code }))
|
2026-08-08 10:11:04 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function recoverOwnerPassword() {
|
|
|
|
|
try {
|
|
|
|
|
const login = await apiStep('account-security-recovery', 'login with temporary owner password for recovery', {
|
|
|
|
|
method: 'POST', path: '/api/auth/login', body: { username, password: temporaryOwnerPassword },
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.accessToken, 'recovery accessToken')
|
|
|
|
|
}, () => ({ username }))
|
|
|
|
|
await apiStep('account-security-recovery', 'restore owner password after interrupted test', {
|
|
|
|
|
method: 'PUT', path: '/api/auth/password', token: login.json.data.accessToken,
|
|
|
|
|
body: { oldPassword: temporaryOwnerPassword, newPassword: clientPassword },
|
|
|
|
|
}, ({ json }) => requireSuccess(json), () => ({ username, passwordRestored: true }))
|
|
|
|
|
ownerPasswordNeedsRestore = false
|
|
|
|
|
temporaryOwnerPassword = undefined
|
|
|
|
|
} catch (error) {
|
|
|
|
|
report.fatal ??= sanitizeError(error)
|
|
|
|
|
report.passwordRecovery = { success: false, error: sanitizeError(error) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadOrderMasterData(token, module) {
|
|
|
|
|
const receivingWarehouses = await apiStep(module, 'load active domestic receiving warehouses', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', token,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertTrue(pageRecords(json.data).length > 0, 'at least one active DOMESTIC warehouse is required')
|
|
|
|
|
}, ({ json }) => ({ total: json.data?.total, warehouseCount: pageRecords(json.data).length }))
|
|
|
|
|
const receiving = pageRecords(receivingWarehouses.json.data)[0]
|
|
|
|
|
|
|
|
|
|
const destinationWarehouses = await apiStep(module, 'load active FBA destination warehouses', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', token,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertTrue(pageRecords(json.data).length > 0, 'at least one active FBA warehouse is required')
|
|
|
|
|
}, ({ json }) => ({ total: json.data?.total, warehouseCount: pageRecords(json.data).length }))
|
|
|
|
|
const destination = pageRecords(destinationWarehouses.json.data)[0]
|
|
|
|
|
report.resources.receivingWarehouseId = receiving.id
|
|
|
|
|
report.resources.destinationWarehouseId = destination.id
|
|
|
|
|
|
|
|
|
|
await apiStep(module, 'load receiving warehouse detail', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/warehouses/${receiving.id}`, token,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertEqual(json.data?.warehouse?.warehouseType, 'DOMESTIC', 'receiving warehouse type')
|
|
|
|
|
assertTrue(arrayData(json.data?.addresses).length > 0, 'receiving warehouse address is required')
|
|
|
|
|
}, ({ json }) => ({ id: json.data.warehouse.id, name: json.data.warehouse.warehouseName }))
|
|
|
|
|
|
|
|
|
|
const channels = await apiStep(module, 'load active pricing channels', {
|
|
|
|
|
method: 'GET', path: `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${receiving.id}`, token,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertTrue(arrayData(json.data).length > 0, 'at least one active pricing channel is required')
|
|
|
|
|
}, ({ json }) => ({ channels: arrayData(json.data).map((item) => ({ channelCode: item.channelCode, priceModel: item.priceModel, pricingTaxModes: item.pricingTaxModes })) }))
|
|
|
|
|
const channel = arrayData(channels.json.data).find((item) => arrayData(item.pricingTaxModes).length > 0)
|
|
|
|
|
assertPresent(channel, 'pricing channel with pricingTaxModes')
|
|
|
|
|
report.resources.channelCode = channel.channelCode
|
|
|
|
|
|
|
|
|
|
const partners = await apiStep(module, 'load public shipper candidates', {
|
|
|
|
|
method: 'GET', path: '/api/merchant/v1/partners/candidates?type=SHIPPER', token,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
}, ({ json }) => ({ candidateCount: arrayData(json.data).length }))
|
|
|
|
|
const partner = arrayData(partners.json.data).find((item) => item.status === 'ACTIVE' && arrayData(item.types).includes('SHIPPER'))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
receiving,
|
|
|
|
|
destination,
|
|
|
|
|
destinationCandidates: pageRecords(destinationWarehouses.json.data),
|
|
|
|
|
channel,
|
|
|
|
|
channelCandidates: arrayData(channels.json.data).filter((item) => arrayData(item.pricingTaxModes).length > 0),
|
|
|
|
|
partner,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function estimateAvailablePrice(masterData) {
|
|
|
|
|
const destinations = masterData.destinationCandidates.slice(0, 30)
|
|
|
|
|
const channels = masterData.channelCandidates
|
|
|
|
|
let attempt = 0
|
|
|
|
|
for (const channel of channels) {
|
|
|
|
|
for (const destination of destinations) {
|
|
|
|
|
attempt += 1
|
|
|
|
|
const pricingTaxMode = channel.pricingTaxModes[0]
|
|
|
|
|
const estimate = await apiStep('pricing-estimate', `probe configured price ${attempt}`, {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/first-leg/pricing/estimate', token: merchantToken,
|
|
|
|
|
body: {
|
|
|
|
|
originWarehouseId: masterData.receiving.id,
|
|
|
|
|
channelCode: channel.channelCode,
|
|
|
|
|
destinationCode: destination.warehouseCode,
|
|
|
|
|
destinationType: 'FBA',
|
|
|
|
|
destinationWarehouseId: destination.id,
|
|
|
|
|
destinationName: destination.warehouseName,
|
|
|
|
|
destinationRegion: null,
|
|
|
|
|
pricingTaxMode,
|
|
|
|
|
actualWeightKg: 20.5,
|
|
|
|
|
actualVolumeCbm: 0.024,
|
|
|
|
|
},
|
|
|
|
|
}, ({ json, status }) => {
|
|
|
|
|
assertEqual(status, 200, 'HTTP status')
|
|
|
|
|
assertTrue(['200', 'A0404'].includes(String(json?.code)), `unexpected business code ${json?.code}`)
|
|
|
|
|
if (String(json?.code) === '200') {
|
|
|
|
|
assertEqual(json.data?.channelCode, channel.channelCode, 'estimate channelCode')
|
|
|
|
|
assertEqual(json.data?.pricingTaxMode, pricingTaxMode, 'estimate pricingTaxMode')
|
|
|
|
|
assertTrue(Number(json.data?.chargeableQuantity) > 0, 'chargeableQuantity must be positive')
|
|
|
|
|
assertTrue(Number(json.data?.unitPrice) >= 0, 'unitPrice must not be negative')
|
|
|
|
|
assertTrue(Number(json.data?.estimatedAmount) >= 0, 'estimatedAmount must not be negative')
|
|
|
|
|
}
|
|
|
|
|
}, ({ json }) => String(json?.code) === '200' ? {
|
|
|
|
|
matched: true,
|
|
|
|
|
destinationWarehouseId: destination.id,
|
|
|
|
|
destinationCode: destination.warehouseCode,
|
|
|
|
|
priceBookVersionId: json.data.priceBookVersionId,
|
|
|
|
|
channelCode: json.data.channelCode,
|
|
|
|
|
priceModel: json.data.priceModel,
|
|
|
|
|
pricingTaxMode: json.data.pricingTaxMode,
|
|
|
|
|
chargeableQuantity: json.data.chargeableQuantity,
|
|
|
|
|
chargeUnit: json.data.chargeUnit,
|
|
|
|
|
unitPrice: json.data.unitPrice,
|
|
|
|
|
estimatedAmount: json.data.estimatedAmount,
|
|
|
|
|
currency: json.data.currency,
|
|
|
|
|
} : {
|
|
|
|
|
matched: false,
|
|
|
|
|
destinationWarehouseId: destination.id,
|
|
|
|
|
destinationCode: destination.warehouseCode,
|
|
|
|
|
channelCode: channel.channelCode,
|
|
|
|
|
code: json?.code,
|
|
|
|
|
message: json?.message,
|
|
|
|
|
})
|
|
|
|
|
if (String(estimate.json?.code) === '200') return estimate.json.data
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`No configured price matched ${attempt} visible channel/destination combinations`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function uploadProductImage(token, module) {
|
|
|
|
|
const form = new FormData()
|
|
|
|
|
form.append('file', new Blob([testPng()], { type: 'image/png' }), `product-${marker}.png`)
|
|
|
|
|
const upload = await apiStep(module, 'upload product image', {
|
|
|
|
|
method: 'POST', path: '/api/merchant/v1/files/product-images', token, body: form,
|
|
|
|
|
}, ({ json }) => {
|
|
|
|
|
requireSuccess(json)
|
|
|
|
|
assertPresent(json.data?.fileId, 'product image fileId')
|
|
|
|
|
assertEqual(json.data?.contentType, 'image/png', 'product image contentType')
|
|
|
|
|
}, ({ json }) => ({ fileId: json.data.fileId, fileName: json.data.fileName, contentType: json.data.contentType, sizeBytes: json.data.sizeBytes }))
|
|
|
|
|
return upload.json.data.fileId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
|
|
|
|
|
const cargoShipmentId = `SHIP-${suffix}-${marker}`.slice(0, 64)
|
|
|
|
|
const cargoReferenceId = `REF-${suffix}-${marker}`.slice(0, 64)
|
|
|
|
|
const unitsPerPackage = 12
|
|
|
|
|
const packageCount = 2
|
|
|
|
|
const totalQuantity = unitsPerPackage * packageCount
|
|
|
|
|
const unitDeclaredValue = 8.5
|
|
|
|
|
return {
|
|
|
|
|
customerId: masterData.partner?.id ?? null,
|
|
|
|
|
customerName: masterData.partner?.partnerName ?? companyName,
|
|
|
|
|
transportMode: 'SEA',
|
|
|
|
|
tradeTerm: 'FOB',
|
|
|
|
|
destinationCountryCode: masterData.destination.countryCode,
|
|
|
|
|
destinationWarehouseId: masterData.destination.id,
|
|
|
|
|
destinationWarehouseName: masterData.destination.warehouseName,
|
|
|
|
|
expectedShipDate: addDays(new Date(), 30),
|
|
|
|
|
customerRemark: `${remark} ${marker}`,
|
|
|
|
|
pickupMethod: 'PICKUP',
|
|
|
|
|
receivingWarehouseId: masterData.receiving.id,
|
|
|
|
|
receivingWarehouseName: masterData.receiving.warehouseName,
|
|
|
|
|
channelCode: masterData.channel.channelCode,
|
|
|
|
|
channelName: masterData.channel.channelName,
|
|
|
|
|
carrierId: null,
|
|
|
|
|
carrierName: null,
|
|
|
|
|
taxMode: 'BUY_DECLARATION',
|
|
|
|
|
pricingTaxMode: masterData.channel.pricingTaxModes[0],
|
|
|
|
|
containerMode: 'LCL',
|
|
|
|
|
customsInspection: false,
|
|
|
|
|
destinationType: 'FBA',
|
|
|
|
|
amazonWarehouseCode: masterData.destination.warehouseCode,
|
|
|
|
|
shipmentId: cargoShipmentId,
|
|
|
|
|
referenceId: cargoReferenceId,
|
|
|
|
|
sender: {
|
|
|
|
|
companyName, contactName: '测试发货人', phone: legalPhone, email: `${username}@example.com`,
|
|
|
|
|
countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区',
|
|
|
|
|
addressLine: '测试路 1 号', postalCode: '518000',
|
|
|
|
|
},
|
|
|
|
|
receiver: null,
|
|
|
|
|
cargoes: [{
|
|
|
|
|
skuNo: `SKU-${suffix}-${marker}`.slice(0, 64),
|
|
|
|
|
cargoName: '蓝牙音箱',
|
|
|
|
|
cargoNameEn: 'Bluetooth Speaker',
|
|
|
|
|
hsCode: '8518210000',
|
|
|
|
|
customsHsCode: '8518210000',
|
|
|
|
|
packageType: 'CARTON',
|
|
|
|
|
lengthCm: 30,
|
|
|
|
|
widthCm: 20,
|
|
|
|
|
heightCm: 20,
|
|
|
|
|
unitsPerPackage,
|
|
|
|
|
packageCount,
|
|
|
|
|
grossWeightKg: 20.5,
|
|
|
|
|
volumeCbm: 0.024,
|
|
|
|
|
totalQuantity,
|
|
|
|
|
netWeightKg: 18,
|
|
|
|
|
unitDeclaredValue,
|
|
|
|
|
declaredValue: unitDeclaredValue * totalQuantity,
|
|
|
|
|
currency: 'USD',
|
|
|
|
|
material: '塑料 / Plastic',
|
|
|
|
|
usage: '音频播放 / Audio playback',
|
|
|
|
|
model: `MODEL-${suffix}`,
|
|
|
|
|
brand: 'TEST-NO-LOGO',
|
|
|
|
|
productLengthCm: 12,
|
|
|
|
|
productWidthCm: 8,
|
|
|
|
|
productHeightCm: 6,
|
|
|
|
|
shipmentId: cargoShipmentId,
|
|
|
|
|
referenceId: cargoReferenceId,
|
|
|
|
|
upcNo: `123456${`${Date.now()}`.slice(-6)}`.slice(0, 12),
|
|
|
|
|
productImageFileId,
|
|
|
|
|
dangerousGoods: false,
|
|
|
|
|
cargoRemark: `cargo-${remark} ${marker}`,
|
|
|
|
|
}],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
|
|
|
|
|
const startedAt = new Date().toISOString()
|
|
|
|
|
const started = Date.now()
|
|
|
|
|
let response
|
|
|
|
|
try {
|
|
|
|
|
response = await httpRequest(request)
|
|
|
|
|
validate(response)
|
|
|
|
|
const step = makeStep(module, name, true, startedAt, started, response, evidenceBuilder(response))
|
|
|
|
|
report.steps.push(step)
|
|
|
|
|
console.log(`PASS [${module}] ${name}`)
|
|
|
|
|
return response
|
|
|
|
|
} catch (error) {
|
|
|
|
|
let evidence = null
|
|
|
|
|
if (response) {
|
|
|
|
|
try {
|
|
|
|
|
evidence = evidenceBuilder(response)
|
|
|
|
|
} catch {
|
|
|
|
|
evidence = defaultEvidence(response)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const step = makeStep(module, name, false, startedAt, started, response, evidence)
|
|
|
|
|
step.error = sanitizeError(error)
|
|
|
|
|
report.steps.push(step)
|
|
|
|
|
console.error(`FAIL [${module}] ${name}: ${error.message}`)
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function softApiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
|
|
|
|
|
try {
|
|
|
|
|
return await apiStep(module, name, request, validate, evidenceBuilder)
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) {
|
|
|
|
|
const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers }
|
|
|
|
|
if (token) requestHeaders.Authorization = `Bearer ${token}`
|
|
|
|
|
let requestBody = body
|
|
|
|
|
if (body != null && !(body instanceof FormData)) {
|
|
|
|
|
requestHeaders['Content-Type'] = 'application/json'
|
|
|
|
|
requestBody = JSON.stringify(body)
|
|
|
|
|
}
|
|
|
|
|
const response = await fetch(`${gateway}${path}`, {
|
|
|
|
|
method,
|
|
|
|
|
headers: requestHeaders,
|
|
|
|
|
body: requestBody,
|
|
|
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
|
|
|
})
|
|
|
|
|
const result = { status: response.status, headers: response.headers, json: null, bytes: null, text: null }
|
|
|
|
|
if (responseType === 'bytes') {
|
|
|
|
|
result.bytes = new Uint8Array(await response.arrayBuffer())
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
const text = await response.text()
|
|
|
|
|
result.text = text
|
|
|
|
|
if (text) {
|
|
|
|
|
try {
|
|
|
|
|
result.json = JSON.parse(text)
|
|
|
|
|
} catch {
|
|
|
|
|
throw new Error(`Expected JSON but received ${text.slice(0, 200)}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeStep(module, name, passed, startedAt, started, response, evidence) {
|
|
|
|
|
return {
|
|
|
|
|
module,
|
|
|
|
|
name,
|
|
|
|
|
passed,
|
|
|
|
|
skipped: false,
|
|
|
|
|
startedAt,
|
|
|
|
|
durationMs: Date.now() - started,
|
|
|
|
|
http: response?.status ?? null,
|
|
|
|
|
code: response?.json?.code == null ? null : String(response.json.code),
|
|
|
|
|
message: response?.json?.message ?? null,
|
|
|
|
|
requestId: headerOrBody(response, 'x-request-id', 'requestId'),
|
|
|
|
|
traceId: headerOrBody(response, 'x-trace-id', 'traceId'),
|
|
|
|
|
evidence,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recordSkipped(module, name, reason) {
|
|
|
|
|
report.steps.push({
|
|
|
|
|
module, name, passed: true, skipped: true, startedAt: new Date().toISOString(), durationMs: 0,
|
|
|
|
|
http: null, code: null, message: reason, requestId: null, traceId: null, evidence: null,
|
|
|
|
|
})
|
|
|
|
|
console.log(`SKIP [${module}] ${name}: ${reason}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requireSuccess(json) {
|
|
|
|
|
assertPresent(json, 'response body')
|
|
|
|
|
assertEqual(String(json.code), '200', 'business code')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertCargo(actual, expected) {
|
|
|
|
|
assertPresent(actual, 'cargo detail')
|
|
|
|
|
const exactFields = [
|
|
|
|
|
'skuNo', 'cargoName', 'cargoNameEn', 'hsCode', 'customsHsCode', 'packageType', 'unitsPerPackage',
|
|
|
|
|
'packageCount', 'totalQuantity', 'currency', 'material', 'model', 'brand', 'shipmentId',
|
|
|
|
|
'referenceId', 'upcNo', 'cargoRemark',
|
|
|
|
|
]
|
|
|
|
|
for (const field of exactFields) assertEqual(String(actual[field]), String(expected[field]), `cargo.${field}`)
|
|
|
|
|
assertEqual(String(actual.productUsage), String(expected.usage), 'cargo.productUsage')
|
|
|
|
|
const decimalFields = [
|
|
|
|
|
'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm', 'netWeightKg',
|
|
|
|
|
'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm',
|
|
|
|
|
]
|
|
|
|
|
for (const field of decimalFields) assertEqual(Number(actual[field]), Number(expected[field]), `cargo.${field}`)
|
|
|
|
|
assertEqual(String(actual.productImageFileId), String(expected.productImageFileId), 'cargo.productImageFileId')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertQualificationStatuses(values, requiredTypes, expectedStatus) {
|
|
|
|
|
const rows = arrayData(values)
|
|
|
|
|
for (const type of requiredTypes) {
|
|
|
|
|
const item = rows.find((row) => row.qualificationType === type && row.status === expectedStatus)
|
|
|
|
|
assertPresent(item, `${type} qualification`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function qualificationEvidence(values) {
|
|
|
|
|
return arrayData(values).map((item) => ({
|
|
|
|
|
qualificationId: item.id ?? item.qualificationId,
|
|
|
|
|
qualificationType: item.qualificationType,
|
|
|
|
|
status: item.status,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cargoEvidence(cargo) {
|
|
|
|
|
if (!cargo) return null
|
|
|
|
|
return {
|
|
|
|
|
id: cargo.id,
|
|
|
|
|
skuNo: cargo.skuNo,
|
|
|
|
|
cargoName: cargo.cargoName,
|
|
|
|
|
cargoNameEn: cargo.cargoNameEn,
|
|
|
|
|
packageCount: cargo.packageCount,
|
|
|
|
|
totalQuantity: cargo.totalQuantity,
|
|
|
|
|
grossWeightKg: cargo.grossWeightKg,
|
|
|
|
|
volumeCbm: cargo.volumeCbm,
|
|
|
|
|
shipmentId: cargo.shipmentId,
|
|
|
|
|
referenceId: cargo.referenceId,
|
|
|
|
|
productImageFileId: cargo.productImageFileId,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function defaultEvidence({ json }) {
|
|
|
|
|
if (!json || json.data == null) return null
|
|
|
|
|
if (typeof json.data !== 'object') return { data: json.data }
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function headerOrBody(response, headerName, bodyName) {
|
|
|
|
|
return response?.headers?.get(headerName)
|
|
|
|
|
?? response?.json?.[bodyName]
|
|
|
|
|
?? response?.json?.data?.[bodyName]
|
|
|
|
|
?? null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pageRecords(data) {
|
|
|
|
|
if (Array.isArray(data)) return data
|
|
|
|
|
return arrayData(data?.records ?? data?.items ?? data?.content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function arrayData(value) {
|
|
|
|
|
return Array.isArray(value) ? value : []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertEqual(actual, expected, label) {
|
|
|
|
|
if (actual !== expected) throw new Error(`${label}: expected=${format(expected)} actual=${format(actual)}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertPresent(value, label) {
|
|
|
|
|
if (value == null || value === '') throw new Error(`${label} is required`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertTrue(value, message) {
|
|
|
|
|
if (!value) throw new Error(message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertSetEqual(actual, expected, label) {
|
|
|
|
|
const actualValues = [...new Set(actual)].sort()
|
|
|
|
|
const expectedValues = [...new Set(expected)].sort()
|
|
|
|
|
assertEqual(JSON.stringify(actualValues), JSON.stringify(expectedValues), label)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requiredOwnerBackendPermissions() {
|
|
|
|
|
return [
|
|
|
|
|
'merchant:order:list', 'merchant:order:view', 'merchant:order:create', 'merchant:order:edit',
|
|
|
|
|
'merchant:order:submit', 'merchant:order:cancel', 'merchant:profile:view', 'merchant:profile:edit',
|
|
|
|
|
'merchant:qualification:upload', 'merchant:member:list', 'merchant:member:create',
|
|
|
|
|
'merchant:member:edit', 'merchant:member:grant-role', 'merchant:member:status',
|
|
|
|
|
'merchant:member:reset-password', 'merchant:role:list', 'merchant:role:create',
|
|
|
|
|
'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete', 'merchant:role:grant',
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function visibleClientMenus(backendPermissions) {
|
|
|
|
|
const permissions = new Set(backendPermissions)
|
|
|
|
|
const menus = ['dashboard:view', 'account:profile:view']
|
|
|
|
|
if (permissions.has('merchant:order:list') || permissions.has('merchant:order:view')) menus.push('order:first-leg:list')
|
|
|
|
|
if (permissions.has('merchant:profile:view')) menus.push('company:profile:view')
|
|
|
|
|
if (permissions.has('merchant:member:list')) menus.push('company:member:list')
|
|
|
|
|
if (permissions.has('merchant:role:list')) menus.push('company:role:list', 'company:permission:list')
|
|
|
|
|
return menus.sort()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function newIdempotencyKey(operation) {
|
|
|
|
|
return `client-e2e-${operation}-${marker}-${randomUUID()}`.slice(0, 128)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function testPng() {
|
|
|
|
|
return Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addDays(value, days) {
|
|
|
|
|
const date = new Date(value)
|
|
|
|
|
date.setUTCDate(date.getUTCDate() + days)
|
|
|
|
|
return date.toISOString().slice(0, 10)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compactTimestamp(value) {
|
|
|
|
|
return value.toISOString().replace(/[-:TZ.]/g, '').slice(0, 14)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trimSlash(value) {
|
|
|
|
|
return String(value).replace(/\/+$/, '')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function apiPath(value) {
|
|
|
|
|
const url = new URL(String(value), `${gateway}/`)
|
|
|
|
|
return `${url.pathname}${url.search}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function format(value) {
|
|
|
|
|
return typeof value === 'string' ? JSON.stringify(value) : String(value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sanitizeError(error) {
|
|
|
|
|
return { name: error?.name ?? 'Error', message: error?.message ?? String(error) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function promptText(label, defaultValue = '') {
|
|
|
|
|
if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultValue
|
|
|
|
|
const reader = createInterface({ input: process.stdin, output: process.stdout })
|
|
|
|
|
try {
|
|
|
|
|
const suffix = defaultValue ? ` [${defaultValue}]` : ''
|
|
|
|
|
const value = await reader.question(`${label}${suffix}: `)
|
|
|
|
|
return value.trim() || defaultValue
|
|
|
|
|
} finally {
|
|
|
|
|
reader.close()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function promptSecret(label) {
|
|
|
|
|
if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined
|
|
|
|
|
const input = process.stdin
|
|
|
|
|
const output = process.stdout
|
|
|
|
|
const previousRawMode = input.isRaw
|
|
|
|
|
let value = ''
|
|
|
|
|
output.write(`${label}: `)
|
|
|
|
|
input.setEncoding('utf8')
|
|
|
|
|
input.setRawMode(true)
|
|
|
|
|
input.resume()
|
|
|
|
|
return new Promise((resolveSecret, rejectSecret) => {
|
|
|
|
|
const finish = (error) => {
|
|
|
|
|
input.off('data', onData)
|
|
|
|
|
input.setRawMode(Boolean(previousRawMode))
|
|
|
|
|
input.pause()
|
|
|
|
|
output.write('\n')
|
|
|
|
|
if (error) rejectSecret(error)
|
|
|
|
|
else resolveSecret(value)
|
|
|
|
|
}
|
|
|
|
|
const onData = (chunk) => {
|
|
|
|
|
for (const character of chunk) {
|
|
|
|
|
if (character === '\u0003') return finish(new Error('Credential input cancelled'))
|
|
|
|
|
if (character === '\r' || character === '\n') return finish()
|
|
|
|
|
if (character === '\u007f' || character === '\b') {
|
|
|
|
|
if (value.length > 0) {
|
|
|
|
|
value = value.slice(0, -1)
|
|
|
|
|
output.write('\b \b')
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if (character >= ' ' && character !== '\u007f') {
|
|
|
|
|
value += character
|
|
|
|
|
output.write('*')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
input.on('data', onData)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseArgs(values) {
|
|
|
|
|
const parsed = {}
|
|
|
|
|
for (let index = 0; index < values.length; index += 1) {
|
|
|
|
|
const item = values[index]
|
|
|
|
|
if (!item.startsWith('--')) throw new Error(`Unknown argument: ${item}`)
|
|
|
|
|
const separator = item.indexOf('=')
|
|
|
|
|
if (separator > 2) {
|
|
|
|
|
parsed[item.slice(2, separator)] = item.slice(separator + 1)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
const key = item.slice(2)
|
|
|
|
|
const next = values[index + 1]
|
|
|
|
|
if (next == null || next.startsWith('--')) throw new Error(`Missing value for --${key}`)
|
|
|
|
|
parsed[key] = next
|
|
|
|
|
index += 1
|
|
|
|
|
}
|
|
|
|
|
return parsed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function printSummary() {
|
|
|
|
|
const passed = report.steps.filter((step) => step.passed && !step.skipped).length
|
|
|
|
|
const failed = report.steps.filter((step) => !step.passed).length
|
|
|
|
|
const skipped = report.steps.filter((step) => step.skipped).length
|
|
|
|
|
console.log(JSON.stringify({ success: report.success, marker, passed, failed, skipped, reportPath }, null, 2))
|
|
|
|
|
}
|