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:
2026-09-01 11:38:42 +08:00
* ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
* MERCHANT_TEST_USERNAME, MERCHANT_TEST_PASSWORD, CLIENT_TEST_REPORT_DIR
2026-08-08 10:11:04 +08:00
*
* 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 ))
2026-09-29 19:48:34 +08:00
if (( args . environment && args . environment !== 'test' ) || args [ 'overseas-gateway' ] || process . env . ADMIN_OVERSEAS_GATEWAY ) throw new Error ( 'Only one TEST gateway is supported; remove regional options' )
2026-09-01 11:38:42 +08:00
const gateway = trimSlash ( args . gateway ?? process . env . ADMIN_TEST_GATEWAY )
const platformUsername = args [ 'platform-username' ] ?? process . env . ADMIN_TEST_USERNAME
?? await promptText ( '平台审核账号' , '' )
const platformPassword = args [ 'platform-password' ] ?? process . env . ADMIN_TEST_PASSWORD
2026-08-08 10:11:04 +08:00
?? await promptSecret ( '平台审核密码' )
2026-09-01 11:38:42 +08:00
const existingClientUsername = args [ 'existing-client-username' ] ?? process . env . MERCHANT_TEST_USERNAME
2026-08-26 11:10:42 +08:00
?? await promptText ( '跨客户隔离测试账号(必须由正式注册/建档产生)' , '' )
2026-09-01 11:38:42 +08:00
const existingClientPassword = args [ 'existing-client-password' ] ?? process . env . MERCHANT_TEST_PASSWORD
2026-08-08 10:11:04 +08:00
?? 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 )
2026-09-01 11:38:42 +08:00
if ( ! gateway || ! platformUsername || ! platformPassword || ! existingClientUsername || ! existingClientPassword ) {
console . error ( 'ADMIN_TEST_GATEWAY, platform credentials, and an independently onboarded client account are required' )
2026-08-08 10:11:04 +08:00
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 )
2026-09-02 12:19:46 +08:00
const temporaryUsername = `renamed_ ${ compactTimestamp ( now ) } _ ${ randomUUID (). slice ( 0 , 6 ) } ` . slice ( 0 , 64 )
2026-08-08 10:11:04 +08:00
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 ,
2026-08-14 10:09:46 +08:00
notificationTemplateId : null ,
notificationMessageIds : [],
2026-08-08 10:11:04 +08:00
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 ()
2026-09-02 12:19:46 +08:00
await loginIdentifierModule ()
2026-08-08 10:11:04 +08:00
await rbacAndMemberModule ()
await orderLifecycleModule ()
await isolationModule ()
2026-08-14 10:09:46 +08:00
await notificationInboxModule ()
2026-08-08 10:11:04 +08:00
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 : '测试法人' ,
2026-09-02 12:19:46 +08:00
phone : legalPhone ,
2026-08-08 10:11:04 +08:00
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
2026-09-14 13:48:21 +08:00
await apiStep ( 'registration' , 'same credentials replay the incomplete registration without creating another customer' , {
2026-08-08 10:11:04 +08:00
method : 'POST' , path : '/api/auth/register' , body : registrationBody ,
2026-09-14 13:48:21 +08:00
}, ({ json }) => {
requireSuccess ( json )
for ( const field of [ 'accountId' , 'tenantId' , 'membershipId' ]) {
assertPresent ( json . data ? .[ field ], field )
assertEqual ( String ( json . data [ field ]), String ( registration . json . data [ field ]), `replayed ${ field } ` )
}
assertEqual ( json . data ? . tenantStatus , 'PROFILE_REQUIRED' , 'replayed tenantStatus' )
})
await apiStep ( 'registration' , 'duplicate username with different credentials is rejected' , {
method : 'POST' , path : '/api/auth/register' ,
body : { ... registrationBody , password : `Different ${ randomUUID () } 9!` },
2026-08-08 10:11:04 +08:00
}, ({ json , status }) => {
assertEqual ( status , 200 , 'HTTP status' )
assertEqual ( String ( json ? . code ), 'A0409' , 'business code' )
})
const login = await apiStep ( 'registration' , 'login before approval' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : username , password : clientPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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` )
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' )
2026-09-02 12:19:46 +08:00
assertTrue ( json . data ? . legalPersonIdQualificationId == null , 'legal person qualification id must stay optional' )
2026-08-08 10:11:04 +08:00
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 )
2026-09-02 12:19:46 +08:00
assertQualificationStatuses ( json . data , [ 'BUSINESS_LICENSE' ], 'PENDING' )
assertTrue ( ! arrayData ( json . data ). some (( item ) => item . qualificationType === 'LEGAL_PERSON_ID' ),
'optional legal person ID must remain absent' )
2026-08-08 10:11:04 +08:00
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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : platformUsername , password : platformPassword },
2026-08-08 10:11:04 +08:00
}, ({ json }) => {
requireSuccess ( json )
assertPresent ( json . data ? . accessToken , 'platform accessToken' )
}, () => ({ username : platformUsername }))
platformToken = platformLogin . json . data . accessToken
2026-09-02 12:19:46 +08:00
const dimensionPath = `/api/platform/v1/iam/tenants/ ${ report . resources . tenantId } /dimension-initialization`
for ( let attempt = 0 ; attempt < 30 ; attempt += 1 ) {
const statusResponse = await httpRequest ({ method : 'GET' , path : dimensionPath , token : platformToken })
if ( String ( statusResponse . json ? . data ? . status ) === 'READY' ) break
if ( attempt < 29 ) await new Promise (( resolve ) => setTimeout ( resolve , 1_000 ))
}
await apiStep ( 'qualification-review' , 'customer default dimensions are ready before approval' , {
method : 'GET' , path : dimensionPath , token : platformToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( json . data ? . status , 'READY' , 'dimension initialization status' )
}, ({ json }) => ({ status : json . data . status , attemptCount : json . data . attemptCount }))
2026-08-08 10:11:04 +08:00
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 )
2026-09-02 12:19:46 +08:00
assertQualificationStatuses ( json . data , [ 'BUSINESS_LICENSE' ], 'PENDING' )
assertTrue ( ! arrayData ( json . data ). some (( item ) => item . qualificationType === 'LEGAL_PERSON_ID' ),
'platform must not require a legal person ID record' )
2026-08-08 10:11:04 +08:00
}, ({ 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 }
})
}
2026-09-02 12:19:46 +08:00
async function loginIdentifierModule () {
const staleToken = merchantToken
await apiStep ( 'login-identifiers' , 'change username with current-password verification' , {
method : 'PUT' , path : '/api/merchant/v1/iam/profile/login-identifiers' , token : merchantToken ,
body : { username : temporaryUsername , phone : legalPhone , currentPassword : clientPassword },
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( json . data ? . username , temporaryUsername , 'updated username' )
assertEqual ( json . data ? . phone , legalPhone , 'updated phone' )
}, ({ json }) => ({ username : json . data . username , phone : json . data . phone }))
await apiStep ( 'login-identifiers' , 'identifier change revokes the previous access token' , {
method : 'GET' , path : '/api/merchant/v1/iam/profile' , token : staleToken ,
}, ({ status , json }) => {
assertEqual ( status , 401 , 'HTTP status' )
assertTrue ( String ( json ? . code || '' ). length > 0 , 'authentication error code must be present' )
}, ({ status , json }) => ({ status , rejectionCode : json ? . code }))
const renamedLogin = await apiStep ( 'login-identifiers' , 'login with the changed username' , {
2026-09-03 18:13:00 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : temporaryUsername , password : clientPassword },
2026-09-02 12:19:46 +08:00
}, ({ json }) => {
requireSuccess ( json )
assertPresent ( json . data ? . accessToken , 'accessToken' )
}, () => ({ username : temporaryUsername }))
merchantToken = renamedLogin . json . data . accessToken
merchantRefreshToken = renamedLogin . json . data . refreshToken
await apiStep ( 'login-identifiers' , 'restore the original username for the remaining acceptance flow' , {
method : 'PUT' , path : '/api/merchant/v1/iam/profile/login-identifiers' , token : merchantToken ,
body : { username , phone : legalPhone , currentPassword : clientPassword },
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( json . data ? . username , username , 'restored username' )
}, ({ json }) => ({ username : json . data . username }))
const restoredLogin = await apiStep ( 'login-identifiers' , 'login after restoring the original username' , {
2026-09-03 18:13:00 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : username , password : clientPassword },
2026-09-02 12:19:46 +08:00
}, ({ json }) => {
requireSuccess ( json )
assertPresent ( json . data ? . accessToken , 'accessToken' )
}, () => ({ username }))
merchantToken = restoredLogin . json . data . accessToken
merchantRefreshToken = restoredLogin . json . data . refreshToken
}
2026-08-08 10:11:04 +08:00
async function merchantAfterApprovalModule () {
const login = await apiStep ( 'merchant-session' , 'client re-login after approval' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : username , password : clientPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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 )
2026-09-02 12:19:46 +08:00
assertQualificationStatuses ( json . data , [ 'BUSINESS_LICENSE' ], 'APPROVED' )
assertTrue ( ! arrayData ( json . data ). some (( item ) => item . qualificationType === 'LEGAL_PERSON_ID' ),
'approved enterprise must not gain a synthetic legal person ID record' )
2026-08-08 10:11:04 +08:00
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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : username , password : clientPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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' ,
2026-08-14 10:09:46 +08:00
'company:role:list' , 'dashboard:view' , 'notification:inbox:view' , 'order:first-leg:list' ],
2026-08-08 10:11:04 +08:00
'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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : memberUsername , password : memberPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : memberUsername , password : memberPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : memberUsername , password : memberPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : memberUsername , password : memberPassword },
2026-08-08 10:17:43 +08:00
}, ({ 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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : memberUsername , password : memberPassword },
2026-08-08 10:17:43 +08:00
}, ({ 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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : memberUsername , password : memberPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : memberUsername , password : resetMemberPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : memberUsername , password : resetMemberPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : memberUsername , password : resetMemberPassword },
2026-08-08 10:17:43 +08:00
}, ({ 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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : memberUsername , password : resetMemberPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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' )
2026-09-02 12:19:46 +08:00
const incompleteSubmitCases = [
2026-08-08 10:11:04 +08:00
{
2026-09-02 12:19:46 +08:00
name : 'FBA order missing Amazon identifiers' ,
2026-08-08 10:11:04 +08:00
mutate ( value ) {
value . amazonWarehouseCode = null
value . shipmentId = null
value . referenceId = null
},
},
{
2026-09-02 12:19:46 +08:00
name : 'FBA cargo missing SKU UPC shipment and reference' ,
2026-08-08 10:11:04 +08:00
mutate ( value ) {
value . cargoes [ 0 ]. skuNo = null
value . cargoes [ 0 ]. upcNo = null
value . cargoes [ 0 ]. shipmentId = null
value . cargoes [ 0 ]. referenceId = null
},
},
2026-09-02 12:19:46 +08:00
]
for ( const testCase of incompleteSubmitCases ) {
const incompletePayload = structuredClone ( payload )
testCase . mutate ( incompletePayload )
const draft = await apiStep ( 'order-validation' , ` ${ testCase . name } can be saved as a draft` , {
method : 'POST' , path : '/api/merchant/v1/orders/first-leg' , token : merchantToken ,
headers : { 'Idempotency-Key' : newIdempotencyKey ( 'incomplete-draft' ) }, body : incompletePayload ,
}, ({ json }) => {
requireSuccess ( json )
assertPresent ( json . data , 'incomplete draft order id' )
}, ({ json }) => ({ orderId : json . data , status : 'DRAFT' }))
report . resources . orderIds . push ( draft . json . data )
await softApiStep ( 'order-validation' , ` ${ testCase . name } is rejected on submit` , {
method : 'POST' , path : `/api/merchant/v1/orders/first-leg/ ${ draft . json . data } /submit` , token : merchantToken ,
headers : { 'Idempotency-Key' : newIdempotencyKey ( 'incomplete-submit' ) },
}, ({ json , status }) => {
assertEqual ( status , 200 , 'HTTP status' )
assertEqual ( String ( json ? . code ), 'A0422' , 'business code' )
}, ({ json }) => ({ orderId : draft . json . data , rejectionCode : json ? . code , message : json ? . message }))
}
const invalidOrderCases = [
2026-08-08 10:11:04 +08:00
{
2026-09-02 12:19:46 +08:00
name : 'PICKUP order without a selected pickup address is rejected' ,
mutate ( value ) {
value . pickupMethod = 'PICKUP'
value . requestedPickupAt = new Date ( Date . now () + 7 * 86_400_000 ). toISOString (). slice ( 0 , 19 )
value . sender = null
value . senderAddressId = null
},
2026-08-08 10:11:04 +08:00
},
{
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 }))
2026-09-02 12:19:46 +08:00
await apiStep ( 'order-filters' , 'customer contact filter contains submitted order by customer name' , {
method : 'GET' , path : `/api/merchant/v1/orders/first-leg?page=1&size=50&customerContact= ${ encodeURIComponent ( payload . customerName ) } ` , token : merchantToken ,
2026-08-08 10:17:43 +08:00
}, ({ json }) => {
requireSuccess ( json )
2026-09-02 12:19:46 +08:00
assertPresent ( pageRecords ( json . data ). find (( item ) => String ( item . orderId ) === String ( orderId )), 'customer-contact-filtered order' )
2026-08-08 10:17:43 +08:00
}, ({ 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' ,
2026-09-01 11:38:42 +08:00
'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check' )
2026-08-08 10:11:04 +08:00
return
}
const login = await apiStep ( 'isolation' , 'existing client login' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : existingClientUsername , password : existingClientPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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
}
2026-08-14 10:09:46 +08:00
async function notificationInboxModule () {
const module = 'notification-inbox'
const templateBase = '/api/platform/v1/notification-templates'
const inboxBase = '/api/merchant/v1/notifications'
const suffix = marker . replace ( /[^0-9A-Za-z]/g , '' ). slice ( - 24 ). toUpperCase ()
const templateCode = `CLIENT_INBOX_ ${ suffix } ` . slice ( 0 , 64 )
const category = 'CLIENT_ACCEPTANCE'
let template = null
try {
const createTemplate = await apiStep ( module , 'platform creates client inbox template' , {
method : 'POST' , path : templateBase , token : platformToken ,
body : {
templateCode ,
templateName : `客户消息闭环- ${ marker } ` . slice ( 0 , 128 ),
category ,
titleTemplate : '客户消息验收 {{sequence}}' ,
bodyTemplate : '客户 {{marker}} 的第 {{sequence}} 条真实站内消息。' ,
variableWhitelist : [ 'marker' , 'sequence' ],
},
}, ({ json }) => {
requireSuccess ( json )
assertPresent ( json . data ? . id , 'notification template id' )
assertEqual ( json . data ? . status , 'DRAFT' , 'notification template status' )
assertEqual ( json . data ? . channel , 'IN_APP' , 'notification template channel' )
}, ({ json }) => ({ templateId : json . data . id , templateCode : json . data . templateCode , status : json . data . status }))
template = createTemplate . json . data
report . resources . notificationTemplateId = template . id
const activateTemplate = await apiStep ( module , 'platform activates client inbox template' , {
method : 'PUT' , path : ` ${ templateBase } / ${ template . id } /status` , token : platformToken ,
body : { targetStatus : 'ACTIVE' , version : template . version },
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( json . data ? . status , 'ACTIVE' , 'notification template status' )
}, ({ json }) => ({ templateId : json . data . id , status : json . data . status , version : json . data . version }))
template = activateTemplate . json . data
await apiStep ( module , 'client normalizes existing inbox messages to read' , {
method : 'PUT' , path : ` ${ inboxBase } /read-all` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertTrue ( Number ( json . data ? . updatedCount ) >= 0 , 'updatedCount must be non-negative' )
}, ({ json }) => ({ updatedCount : json . data . updatedCount }))
await apiStep ( module , 'client unread baseline is zero' , {
method : 'GET' , path : ` ${ inboxBase } /unread-count` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( Number ( json . data ? . unreadCount ), 0 , 'unread baseline' )
}, ({ json }) => ({ unreadCount : json . data . unreadCount }))
const sendMessage = async ( sequence ) => apiStep ( module , `platform sends client message ${ sequence } ` , {
method : 'POST' , path : '/api/platform/v1/notifications' , token : platformToken ,
body : {
tenantId : String ( report . resources . tenantId ),
templateCode ,
recipientType : 'MEMBERSHIP' ,
recipientIds : [ String ( report . resources . membershipId )],
variables : { marker , sequence : String ( sequence ) },
scheduledAt : null ,
idempotencyKey : newIdempotencyKey ( `client-notification- ${ sequence } ` ),
sourceEventId : `client-e2e-notification- ${ sequence } - ${ marker } ` . slice ( 0 , 128 ),
},
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( json . data ? . status , 'SENT' , 'notification task status' )
assertEqual ( Number ( json . data ? . recipientCount ), 1 , 'notification recipient count' )
}, ({ json }) => ({ taskId : json . data . id , taskNo : json . data . taskNo , status : json . data . status , recipientCount : json . data . recipientCount }))
await sendMessage ( 1 )
await apiStep ( module , 'client unread count increments after first delivery' , {
method : 'GET' , path : ` ${ inboxBase } /unread-count` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( Number ( json . data ? . unreadCount ), 1 , 'unread count after first delivery' )
}, ({ json }) => ({ unreadCount : json . data . unreadCount }))
const firstUnreadPage = await apiStep ( module , 'client filters first message as unread' , {
method : 'GET' , path : ` ${ inboxBase } ?page=1&size=20&status=UNREAD&category= ${ category } ` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
const message = pageRecords ( json . data ). find (( item ) => item . title === `客户消息验收 1` )
assertPresent ( message ? . id , 'first unread notification id' )
assertEqual ( message . status , 'UNREAD' , 'first notification status' )
}, ({ json }) => ({ total : json . data . total , unreadIds : pageRecords ( json . data ). map (( item ) => item . id ) }))
const firstMessage = pageRecords ( firstUnreadPage . json . data ). find (( item ) => item . title === '客户消息验收 1' )
const firstMessageId = firstMessage . id
report . resources . notificationMessageIds . push ( firstMessageId )
await apiStep ( module , 'client reads first message detail while it is unread' , {
method : 'GET' , path : ` ${ inboxBase } / ${ firstMessageId } ` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( String ( json . data ? . id ), String ( firstMessageId ), 'first notification id' )
assertEqual ( json . data ? . status , 'UNREAD' , 'first notification detail status' )
assertTrue ( String ( json . data ? . body ). includes ( marker ), 'first notification body must contain marker' )
}, ({ json }) => ({ messageId : json . data . id , status : json . data . status , category : json . data . category }))
if ( existingClientToken ) {
await apiStep ( module , 'other client cannot read first message detail' , {
method : 'GET' , path : ` ${ inboxBase } / ${ firstMessageId } ` , token : existingClientToken ,
}, ({ json , status }) => {
assertEqual ( status , 200 , 'HTTP status' )
assertTrue ([ 'A0403' , 'A0404' ]. includes ( String ( json ? . code )), `expected A0403/A0404, actual= ${ json ? . code } ` )
}, ({ json }) => ({ messageId : firstMessageId , rejectionCode : json . code }))
} else {
recordSkipped ( module , 'other client cannot read first message detail' , 'Cross-client credentials were not supplied' )
}
await apiStep ( module , 'client marks first message read' , {
method : 'PUT' , path : ` ${ inboxBase } / ${ firstMessageId } /read` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( json . data ? . status , 'READ' , 'first notification status after read' )
assertPresent ( json . data ? . readAt , 'first notification readAt' )
}, ({ json }) => ({ messageId : json . data . id , status : json . data . status , readAt : json . data . readAt }))
await apiStep ( module , 'single-message read is idempotent' , {
method : 'PUT' , path : ` ${ inboxBase } / ${ firstMessageId } /read` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( json . data ? . status , 'READ' , 'idempotent notification status' )
}, ({ json }) => ({ messageId : json . data . id , status : json . data . status , readAt : json . data . readAt }))
await apiStep ( module , 'client filters first message as read' , {
method : 'GET' , path : ` ${ inboxBase } ?page=1&size=20&status=READ&category= ${ category } ` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertTrue ( pageRecords ( json . data ). some (( item ) => String ( item . id ) === String ( firstMessageId ) && item . status === 'READ' ),
'read filter must contain first notification' )
}, ({ json }) => ({ total : json . data . total , readIds : pageRecords ( json . data ). map (( item ) => item . id ) }))
await apiStep ( module , 'client unread count returns to zero after single read' , {
method : 'GET' , path : ` ${ inboxBase } /unread-count` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( Number ( json . data ? . unreadCount ), 0 , 'unread count after single read' )
}, ({ json }) => ({ unreadCount : json . data . unreadCount }))
await sendMessage ( 2 )
const secondUnreadPage = await apiStep ( module , 'client receives second unread message' , {
method : 'GET' , path : ` ${ inboxBase } ?page=1&size=20&status=UNREAD&category= ${ category } ` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
const message = pageRecords ( json . data ). find (( item ) => item . title === '客户消息验收 2' )
assertPresent ( message ? . id , 'second unread notification id' )
assertEqual ( message . status , 'UNREAD' , 'second notification status' )
}, ({ json }) => ({ total : json . data . total , unreadIds : pageRecords ( json . data ). map (( item ) => item . id ) }))
const secondMessage = pageRecords ( secondUnreadPage . json . data ). find (( item ) => item . title === '客户消息验收 2' )
const secondMessageId = secondMessage . id
report . resources . notificationMessageIds . push ( secondMessageId )
if ( existingClientToken ) {
await apiStep ( module , 'other client cannot see acceptance messages in inbox' , {
method : 'GET' , path : ` ${ inboxBase } ?page=1&size=200&category= ${ category } ` , token : existingClientToken ,
}, ({ json , status }) => {
assertEqual ( status , 200 , 'HTTP status' )
if ([ 'A0403' , 'A0404' ]. includes ( String ( json ? . code ))) return
requireSuccess ( json )
const foreignIds = new Set ( pageRecords ( json . data ). map (( item ) => String ( item . id )))
assertTrue ( ! foreignIds . has ( String ( firstMessageId )) && ! foreignIds . has ( String ( secondMessageId )),
'other client inbox must exclude acceptance messages' )
}, ({ json }) => ({ rejectionCode : json ? . code === '200' ? null : json ? . code ,
visibleCount : json ? . code === '200' ? pageRecords ( json . data ). length : null ,
excludedIds : [ firstMessageId , secondMessageId ] }))
} else {
recordSkipped ( module , 'other client cannot see acceptance messages in inbox' , 'Cross-client credentials were not supplied' )
}
await apiStep ( module , 'client unread count increments after second delivery' , {
method : 'GET' , path : ` ${ inboxBase } /unread-count` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( Number ( json . data ? . unreadCount ), 1 , 'unread count after second delivery' )
}, ({ json }) => ({ unreadCount : json . data . unreadCount }))
await apiStep ( module , 'client marks all messages read' , {
method : 'PUT' , path : ` ${ inboxBase } /read-all` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( Number ( json . data ? . updatedCount ), 1 , 'read-all updatedCount' )
}, ({ json }) => ({ updatedCount : json . data . updatedCount }))
await apiStep ( module , 'second message detail is read after read-all' , {
method : 'GET' , path : ` ${ inboxBase } / ${ secondMessageId } ` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( json . data ? . status , 'READ' , 'second notification status after read-all' )
assertPresent ( json . data ? . readAt , 'second notification readAt' )
}, ({ json }) => ({ messageId : json . data . id , status : json . data . status , readAt : json . data . readAt }))
await apiStep ( module , 'client unread count is zero after read-all' , {
method : 'GET' , path : ` ${ inboxBase } /unread-count` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( Number ( json . data ? . unreadCount ), 0 , 'unread count after read-all' )
}, ({ json }) => ({ unreadCount : json . data . unreadCount }))
} finally {
if ( merchantToken && report . resources . notificationMessageIds . length > 0 ) {
await softApiStep ( module , 'cleanup keeps acceptance messages read' , {
method : 'PUT' , path : ` ${ inboxBase } /read-all` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertTrue ( Number ( json . data ? . updatedCount ) >= 0 , 'cleanup updatedCount must be non-negative' )
}, ({ json }) => ({ updatedCount : json ? . data ? . updatedCount }))
await softApiStep ( module , 'cleanup leaves no unread acceptance messages' , {
method : 'GET' , path : ` ${ inboxBase } /unread-count` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( Number ( json . data ? . unreadCount ), 0 , 'cleanup unread count' )
}, ({ json }) => ({ unreadCount : json ? . data ? . unreadCount }))
}
if ( template ? . status === 'ACTIVE' ) {
const disabled = await softApiStep ( module , 'platform disables client inbox template' , {
method : 'PUT' , path : ` ${ templateBase } / ${ template . id } /status` , token : platformToken ,
body : { targetStatus : 'DISABLED' , version : template . version },
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( json . data ? . status , 'DISABLED' , 'notification template final status' )
}, ({ json }) => ({ templateId : json ? . data ? . id , status : json ? . data ? . status , version : json ? . data ? . version }))
if ( disabled ? . json ? . data ) template = disabled . json . data
}
report . notificationInbox = {
templateId : report . resources . notificationTemplateId ,
templateCode ,
templateStatus : template ? . status ?? null ,
messageIds : [... report . resources . notificationMessageIds ],
retainedAsRead : true ,
}
}
}
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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : username , password : clientPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : username , password : temporaryOwnerPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : username , password : clientPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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' , {
2026-09-01 21:55:27 +08:00
method : 'POST' , path : '/api/auth/login' , body : { loginId : username , password : temporaryOwnerPassword },
2026-08-08 10:11:04 +08:00
}, ({ 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 }))
2026-09-03 18:13:00 +08:00
const receivingCandidates = pageRecords ( receivingWarehouses . json . data )
2026-08-08 10:11:04 +08:00
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 ]
2026-09-03 18:13:00 +08:00
let receiving = null
let pricedChannels = []
for ( const candidate of receivingCandidates ) {
const response = await httpRequest ({
method : 'GET' , path : `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId= ${ candidate . id } ` , token ,
})
const candidates = String ( response ? . json ? . code ) === '200' ? arrayData ( response . json . data ) : []
const available = candidates . filter (( item ) => arrayData ( item . pricingTaxModes ). length > 0 )
if ( available . length > 0 ) {
receiving = candidate
pricedChannels = available
break
}
}
assertPresent ( receiving , 'active DOMESTIC warehouse with pricing channels' )
2026-08-08 10:11:04 +08:00
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 })) }))
2026-09-03 18:13:00 +08:00
const channel = pricedChannels . find (( item ) => arrayData ( item . pricingTaxModes ). length > 0 )
2026-08-08 10:11:04 +08:00
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 ,
2026-09-03 18:13:00 +08:00
channelCandidates : pricedChannels ,
2026-08-08 10:11:04 +08:00
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 } ` ,
2026-09-02 12:19:46 +08:00
pickupMethod : 'SELF_EXPRESS' ,
2026-08-08 10:11:04 +08:00
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 ,
2026-09-02 12:19:46 +08:00
woodenProduct : false ,
2026-08-08 10:11:04 +08:00
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 } `
2026-09-03 18:13:00 +08:00
let requestBody = body
if ( body != null && ! ( body instanceof FormData )) {
2026-08-08 10:11:04 +08:00
requestHeaders [ 'Content-Type' ] = 'application/json'
2026-09-03 18:13:00 +08:00
requestBody = JSON . stringify ( body )
2026-08-08 10:11:04 +08:00
}
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' ,
2026-08-14 10:09:46 +08:00
'merchant:notification:view' , 'merchant:notification:read' , 'merchant:notification:read-all' ,
2026-08-08 10:11:04 +08:00
]
}
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' )
2026-08-14 10:09:46 +08:00
if ( permissions . has ( 'merchant:notification:view' )) menus . push ( 'notification:inbox:view' )
2026-08-08 10:11:04 +08:00
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 ))
}