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'
2026-10-02 21:33:39 +08:00
import { randomUUID , createHash } from 'node:crypto'
2026-08-08 10:11:04 +08:00
import { resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
2026-10-03 00:46:00 +08:00
import { inflateRawSync , deflateRawSync } from 'node:zlib'
2026-08-08 10:11:04 +08:00
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 : [],
2026-10-08 17:07:57 +08:00
supplementalAcceptance : [{
script : 'client-phase2-test-env-acceptance.mjs' ,
scope : [ 'measurement' , 'ocr' , 'mojia' , 'billing' ],
reason : '当前入库测量与计费客户能力由专项入口验收;本报告不将其计为已验证。' ,
}],
2026-08-08 10:11:04 +08:00
}
let merchantToken
let merchantRefreshToken
let platformToken
let existingClientToken
2026-10-02 21:33:39 +08:00
let orderAcceptanceFixture
2026-08-08 10:11:04 +08:00
let ownerPasswordNeedsRestore = false
let temporaryOwnerPassword
try {
await registrationModule ()
2026-10-03 00:27:45 +08:00
await platformReviewerLogin ()
2026-08-08 10:11:04 +08:00
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-10-03 00:27:45 +08:00
await orderSubmissionBalanceGateModule ()
2026-10-02 21:33:39 +08:00
await orderMutationEvidenceModule ()
await orderExcelImportModule ()
2026-10-03 00:46:00 +08:00
await orderSubmissionChannelGateModule ()
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 }))
2026-10-03 00:27:45 +08:00
const qualificationBefore = await ownedOrderSnapshot ( 'order-before-approval' , 'freeze pre-approval submit version' , orderId )
const qualificationFulfillmentBefore = await submissionGateFulfillmentSnapshot ( qualificationBefore . order , 'before qualification rejection' )
2026-08-08 10:11:04 +08:00
await apiStep ( 'order-before-approval' , 'submit is blocked before approval' , {
method : 'POST' , path : `/api/merchant/v1/orders/first-leg/ ${ orderId } /submit` , token : merchantToken ,
2026-10-03 00:27:45 +08:00
... orderMutationCommand ( qualificationBefore , {}, newIdempotencyKey ( 'pre-approval-submit' )),
2026-08-08 10:11:04 +08:00
}, ({ json , status }) => {
assertEqual ( status , 200 , 'HTTP status' )
assertEqual ( String ( json ? . code ), 'A0403' , 'business code' )
}, ({ json }) => ({ orderId , rejectionCode : json . code }))
2026-10-03 00:27:45 +08:00
await ownedOrderSnapshot ( 'order-before-approval' , 'qualification rejection preserves complete order facts' , orderId , qualificationBefore )
assertOrderUnchanged ( qualificationFulfillmentBefore ,
await submissionGateFulfillmentSnapshot ( qualificationBefore . order , 'after qualification rejection' ))
report . qualificationRejectionEvidence = { orderId , orderSha256 : orderDigest ( qualificationBefore ), fulfillmentSha256 : orderDigest ( qualificationFulfillmentBefore ) }
}
async function platformReviewerLogin () {
const platformLogin = await apiStep ( 'qualification-review' , 'platform reviewer login' , {
method : 'POST' , path : '/api/auth/login' , body : { loginId : platformUsername , password : platformPassword },
}, ({ json }) => {
requireSuccess ( json )
assertPresent ( json . data ? . accessToken , 'platform accessToken' )
}, () => ({ username : platformUsername }))
platformToken = platformLogin . json . data . accessToken
2026-08-08 10:11:04 +08:00
}
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 }))
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 ,
2026-10-02 23:14:24 +08:00
... orderMutationCommand ( await ownedOrderSnapshot ( 'order-validation' , 'freeze incomplete submit version' , draft . json . data ), {}, newIdempotencyKey ( 'incomplete-submit' )),
2026-09-02 12:19:46 +08:00
}, ({ 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 )
2026-10-02 21:33:39 +08:00
const editBefore = await ownedOrderSnapshot ( 'order-lifecycle' , 'freeze original draft edit version' , orderId )
const editCommand = orderMutationCommand ( editBefore , { draft : editedPayload }, newIdempotencyKey ( 'edit-draft' ))
recordOrderMutationRequest ( 'initial lifecycle edit' , { method : 'PUT' , path : `/api/merchant/v1/orders/first-leg/ ${ orderId } ` , ... editCommand }, editBefore )
2026-08-08 10:11:04 +08:00
await apiStep ( 'order-lifecycle' , 'edit draft' , {
2026-10-02 21:33:39 +08:00
method : 'PUT' , path : `/api/merchant/v1/orders/first-leg/ ${ orderId } ` , token : merchantToken , ... editCommand ,
2026-08-08 10:11:04 +08:00
}, ({ 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
2026-10-02 21:33:39 +08:00
orderAcceptanceFixture = { masterData , payload : editedPayload , orderId }
2026-08-08 10:11:04 +08:00
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' )
2026-10-02 23:14:24 +08:00
const submitBefore = await ownedOrderSnapshot ( 'order-lifecycle' , 'freeze submission page version' , orderId )
const submitCommand = orderMutationCommand ( submitBefore , {}, submitKey )
recordOrderMutationRequest ( 'initial lifecycle submit' , { method : 'POST' , path : `/api/merchant/v1/orders/first-leg/ ${ orderId } /submit` ,... submitCommand }, submitBefore )
2026-08-08 10:11:04 +08:00
await apiStep ( 'order-lifecycle' , 'submit order' , {
method : 'POST' , path : `/api/merchant/v1/orders/first-leg/ ${ orderId } /submit` , token : merchantToken ,
2026-10-02 23:14:24 +08:00
... submitCommand ,
2026-08-08 10:11:04 +08:00
}, ({ json }) => requireSuccess ( json ), () => ({ orderId }))
2026-10-02 23:14:24 +08:00
const submittedBeforeReplay = await ownedOrderSnapshot ( 'order-lifecycle' , 'freeze successful submission facts' , orderId )
2026-08-08 10:11:04 +08:00
await apiStep ( 'order-lifecycle' , 'submit order idempotent replay' , {
method : 'POST' , path : `/api/merchant/v1/orders/first-leg/ ${ orderId } /submit` , token : merchantToken ,
2026-10-02 23:14:24 +08:00
... submitCommand ,
2026-08-08 10:11:04 +08:00
}, ({ json }) => requireSuccess ( json ), () => ({ orderId , replayed : true }))
2026-10-02 23:14:24 +08:00
await ownedOrderSnapshot ( 'order-lifecycle' , 'submit replay keeps version and history unchanged' , orderId , submittedBeforeReplay )
const submitPath = `/api/merchant/v1/orders/first-leg/ ${ orderId } /submit`
await rejectOrderMutation ( 'submit same key different version conflicts' , orderId , {
method : 'POST' , path : submitPath , token : merchantToken , headers : submitCommand . headers ,
body : { expectedVersion : submittedBeforeReplay . order . version },
}, [ 'A0409' ])
await rejectOrderMutation ( 'submit new key cannot submit already submitted order' , orderId , {
method : 'POST' , path : submitPath , token : merchantToken ,
... orderMutationCommand ( submittedBeforeReplay , {}, newIdempotencyKey ( 'new-key-submit' )),
}, [ 'A0409' ])
2026-08-08 10:11:04 +08:00
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' , {
2026-10-02 21:33:39 +08:00
method : 'PUT' , path : `/api/merchant/v1/orders/first-leg/ ${ orderId } ` , token : merchantToken ,
... orderMutationCommand ( await ownedOrderSnapshot ( 'order-boundary' , 'freeze submitted edit attempt' , orderId ),
{ draft : editedPayload }, newIdempotencyKey ( 'submitted-edit' )),
2026-08-08 10:11:04 +08:00
}, ({ 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 )
2026-10-02 21:33:39 +08:00
const cancelBefore = await ownedOrderSnapshot ( 'order-lifecycle' , 'freeze cancellation version' , cancelOrderId )
const cancelCommand = orderMutationCommand ( cancelBefore , { reason : `客户端自动化取消 ${ marker } ` }, newIdempotencyKey ( 'cancel-draft' ))
recordOrderMutationRequest ( 'initial lifecycle cancellation' , { method : 'POST' , path : `/api/merchant/v1/orders/first-leg/ ${ cancelOrderId } /cancel` , ... cancelCommand }, cancelBefore )
2026-08-08 10:11:04 +08:00
await apiStep ( 'order-lifecycle' , 'cancel second draft' , {
method : 'POST' , path : `/api/merchant/v1/orders/first-leg/ ${ cancelOrderId } /cancel` , token : merchantToken ,
2026-10-02 21:33:39 +08:00
... cancelCommand ,
2026-08-08 10:11:04 +08:00
}, ({ 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 ,
2026-10-02 21:33:39 +08:00
... orderMutationCommand ( await ownedOrderSnapshot ( 'order-boundary' , 'freeze repeat cancellation attempt' , cancelOrderId ),
{ reason : `重复取消 ${ marker } ` }, newIdempotencyKey ( 'repeat-cancel' )),
2026-08-08 10:11:04 +08:00
}, ({ 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 ,
2026-10-02 23:14:24 +08:00
... orderMutationCommand ( await ownedOrderSnapshot ( 'order-boundary' , 'freeze cancelled submit version' , cancelOrderId ), {}, newIdempotencyKey ( 'cancelled-submit' )),
2026-08-08 10:11:04 +08:00
}, ({ 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 } ` ,
}],
}
}
2026-10-02 21:33:39 +08:00
// BEGIN ORDER ACCEPTANCE HELPERS — loaded without CLI side effects by the direct contract.
function orderCanonical ( value ) {
if ( Array . isArray ( value )) return value . map ( orderCanonical )
if ( value && typeof value === 'object' ) return Object . fromEntries ( Object . keys ( value ). sort (). map ( key => [ key , orderCanonical ( value [ key ])]))
return value
}
function orderDigest ( value ) { return createHash ( 'sha256' ). update ( JSON . stringify ( orderCanonical ( value ))). digest ( 'hex' ) }
function assertOrderUnchanged ( before , after ) {
assertEqual ( orderDigest ( after ), orderDigest ( before ), 'order, cargo, parties, notes and history must remain unchanged' )
}
function assertOrderDetail ( data , id ) {
assertEqual ( String ( data ? . order ? . id ), String ( id ), 'owned order identity' )
for ( const field of [ 'cargoes' , 'parties' , 'internalNotes' , 'statusHistory' ]) assertTrue ( Array . isArray ( data ? .[ field ]), `complete ${ field } snapshot required` )
assertPresent ( data . order . version , 'order version' )
}
function orderMutationCommand ( snapshot , payload , key ) {
assertTrue ( Number . isInteger ( snapshot ? . order ? . version ) && snapshot . order . version >= 0 , 'frozen order version required' )
assertTrue ( typeof key === 'string' && key . length > 0 , 'frozen idempotency key required' )
const command = structuredClone ({ headers : { 'Idempotency-Key' : key }, body : { ... payload , expectedVersion : snapshot . order . version } })
const freeze = value => { if ( value && typeof value === 'object' ) { Object . values ( value ). forEach ( freeze ); Object . freeze ( value ) } }
freeze ( command )
return command
}
function assertImportResult ( data , mode , expected ) {
assertEqual ( data ? . mode , mode , 'import mode' )
assertPresent ( data ? . batchNo , 'batch number' )
assertPresent ( data ? . sourceFileId , 'source file' )
const result = data ? . result
assertEqual ( result ? . total , expected . length , 'import total' )
assertEqual ( result ? . succeeded , expected . filter ( Boolean ). length , 'import succeeded' )
assertEqual ( result ? . failed , expected . filter ( value => ! value ). length , 'import failed' )
assertEqual ( result ? . items ? . length , expected . length , 'import item count' )
result . items . forEach (( item , index ) => {
assertEqual ( item . index , index , 'result index' )
assertEqual ( item . clientRowId , `excel-row- ${ index + 3 } ` , 'physical Excel row' )
assertEqual ( item . success , expected [ index ], 'row success' )
if ( item . success ) { assertPresent ( item . orderId , 'created order ID' ); assertPresent ( item . orderNo , 'created order number' ) }
else {
2026-10-02 21:46:39 +08:00
for ( const field of [ 'orderId' , 'orderNo' , 'inboundCode' ]) {
assertTrue ( item [ field ] === null || item [ field ] === undefined , `failed row has no ${ field } ` )
}
2026-10-02 21:33:39 +08:00
assertEqual ( item . errorCode , 'A0422' , 'invalid row code' )
assertPresent ( item . errorMessage , 'row error reason' )
}
})
if ( result . failed ) assertPresent ( data . errorReportFileId , 'downloadable error report' )
}
// Read the downloaded POI template, preserving its headers, options and validations.
// ZIP64/encrypted archives are deliberately rejected; template fixtures are small XLSX files.
function orderZipRead ( bytes ) {
const input = Buffer . from ( bytes )
let end = input . length - 22
while ( end >= Math . max ( 0 , input . length - 65557 ) && input . readUInt32LE ( end ) !== 0x06054b50 ) end --
assertTrue ( end >= 0 && input . readUInt32LE ( end ) === 0x06054b50 , 'valid XLSX ZIP directory' )
const entries = new Map ()
let offset = input . readUInt32LE ( end + 16 )
for ( let index = 0 ; index < input . readUInt16LE ( end + 10 ); index ++ ) {
assertEqual ( input . readUInt32LE ( offset ), 0x02014b50 , 'ZIP entry signature' )
const method = input . readUInt16LE ( offset + 10 ), size = input . readUInt32LE ( offset + 20 )
const length = input . readUInt32LE ( offset + 24 ), nameLength = input . readUInt16LE ( offset + 28 )
const name = input . subarray ( offset + 46 , offset + 46 + nameLength ). toString ( 'utf8' )
const local = input . readUInt32LE ( offset + 42 )
assertTrue ( ! ( input . readUInt16LE ( offset + 8 ) & 1 ) && [ 0 , 8 ]. includes ( method ) && length <= 16 * 1024 * 1024 , 'supported bounded ZIP entry' )
assertEqual ( input . readUInt32LE ( local ), 0x04034b50 , 'ZIP local signature' )
const start = local + 30 + input . readUInt16LE ( local + 26 ) + input . readUInt16LE ( local + 28 )
const packed = input . subarray ( start , start + size )
const content = method === 8 ? inflateRawSync ( packed , { maxOutputLength : 16 * 1024 * 1024 }) : packed
assertEqual ( content . length , length , 'ZIP uncompressed size' )
assertEqual ( orderCrc ( content ), input . readUInt32LE ( offset + 16 ), 'ZIP checksum' )
assertTrue ( ! entries . has ( name ), 'unique ZIP entry' )
2026-10-03 00:46:00 +08:00
entries . set ( name , /\.(xml|rels)$/i . test ( name ) ? content . toString ( 'utf8' ) : Buffer . from ( content ))
2026-10-02 21:33:39 +08:00
offset += 46 + nameLength + input . readUInt16LE ( offset + 30 ) + input . readUInt16LE ( offset + 32 )
}
return entries
}
function orderCrc ( bytes ) {
let crc = 0xffffffff
for ( const byte of bytes ) { crc ^= byte ; for ( let bit = 0 ; bit < 8 ; bit ++ ) crc = ( crc >>> 1 ) ^ ( crc & 1 ? 0xedb88320 : 0 ) }
return ( crc ^ 0xffffffff ) >>> 0
}
2026-10-03 00:46:00 +08:00
function orderZipWrite ( entries , compress = false ) {
2026-10-02 21:33:39 +08:00
const local = [], directory = []
let offset = 0
for ( const [ name , text ] of entries ) {
const filename = Buffer . from ( name ), data = Buffer . from ( text ), crc = orderCrc ( data )
2026-10-03 00:46:00 +08:00
const packed = compress ? deflateRawSync ( data ) : data
2026-10-02 21:33:39 +08:00
const header = Buffer . alloc ( 30 )
header . writeUInt32LE ( 0x04034b50 ); header . writeUInt16LE ( 20 , 4 ); header . writeUInt16LE ( 0x800 , 6 )
2026-10-03 00:46:00 +08:00
header . writeUInt16LE ( compress ? 8 : 0 , 8 )
header . writeUInt32LE ( crc , 14 ); header . writeUInt32LE ( packed . length , 18 ); header . writeUInt32LE ( data . length , 22 ); header . writeUInt16LE ( filename . length , 26 )
2026-10-02 21:33:39 +08:00
const central = Buffer . alloc ( 46 )
central . writeUInt32LE ( 0x02014b50 ); central . writeUInt16LE ( 20 , 4 ); central . writeUInt16LE ( 20 , 6 ); central . writeUInt16LE ( 0x800 , 8 )
2026-10-03 00:46:00 +08:00
central . writeUInt16LE ( compress ? 8 : 0 , 10 )
central . writeUInt32LE ( crc , 16 ); central . writeUInt32LE ( packed . length , 20 ); central . writeUInt32LE ( data . length , 24 )
2026-10-02 21:33:39 +08:00
central . writeUInt16LE ( filename . length , 28 ); central . writeUInt32LE ( offset , 42 )
2026-10-03 00:46:00 +08:00
local . push ( header , filename , packed ); directory . push ( central , filename )
offset += header . length + filename . length + packed . length
2026-10-02 21:33:39 +08:00
}
const central = Buffer . concat ( directory ), end = Buffer . alloc ( 22 )
end . writeUInt32LE ( 0x06054b50 ); end . writeUInt16LE ( entries . size , 8 ); end . writeUInt16LE ( entries . size , 10 )
end . writeUInt32LE ( central . length , 12 ); end . writeUInt32LE ( offset , 16 )
return Buffer . concat ([... local , central , end ])
}
function orderXmlDecode ( text ) {
return text . replace ( /&#(x[0-9a-f]+|[0-9]+);|&(lt|gt|quot|apos|amp);/gi , ( match , code , named ) => code
? String . fromCodePoint ( code [ 0 ]. toLowerCase () === 'x' ? parseInt ( code . slice ( 1 ), 16 ) : Number ( code ))
: ({ lt : '<' , gt : '>' , quot : '"' , apos : "'" , amp : '&' })[ named . toLowerCase ()])
}
function orderXmlEncode ( text ) { return String ( text ). replace ( /[&<>"']/g , char => ({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' })[ char ]) }
function orderColumn ( index ) {
let result = ''
for ( let value = index + 1 ; value ; value = Math . floor (( value - 1 ) / 26 )) result = String . fromCharCode ( 65 + ( value - 1 ) % 26 ) + result
return result
}
function orderSheetRows ( entries , path = 'xl/worksheets/sheet1.xml' ) {
const strings = [...( entries . get ( 'xl/sharedStrings.xml' ) ?? '' ). matchAll ( /<si\b[^>]*>([\s\S]*?)<\/si>/g )]. map ( item =>
[... item [ 1 ]. matchAll ( /<t\b[^>]*>([\s\S]*?)<\/t>/g )]. map ( text => orderXmlDecode ( text [ 1 ])). join ( '' ))
const sheet = entries . get ( path )
assertPresent ( sheet , `template ${ path } ` )
return [... sheet . matchAll ( /<row\b[^>]*\br="(\d+)"[^>]*>([\s\S]*?)<\/row>/g )]. map ( row => ({ number : Number ( row [ 1 ]), cells :
[... row [ 2 ]. matchAll ( /<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g )]. map ( cell => {
const ref = cell [ 1 ]. match ( /\br="([A-Z]+)\d+"/ ) ? .[ 1 ]
const content = cell [ 2 ] ?? ''
const value = content . match ( /<v\b[^>]*>([\s\S]*?)<\/v>/ ) ? .[ 1 ]
const text = /\bt="s"/ . test ( cell [ 1 ]) ? strings [ Number ( value )] : /\bt="inlineStr"/ . test ( cell [ 1 ])
? [... content . matchAll ( /<t\b[^>]*>([\s\S]*?)<\/t>/g )]. map ( item => orderXmlDecode ( item [ 1 ])). join ( '' ) : orderXmlDecode ( value ?? '' )
return { ref , text }
}) }))
}
function orderWorkbookRows ( template , rows ) {
const entries = new Map ( template ), sheet = entries . get ( 'xl/worksheets/sheet1.xml' )
const headers = [... sheet . matchAll ( /<row\b[^>]*\br="([12])"[^>]*>[\s\S]*?<\/row>/g )]. map ( row => row [ 0 ]). join ( '' )
assertTrue ( headers . includes ( 'r="1"' ) && headers . includes ( 'r="2"' ), 'both original header rows preserved' )
const data = rows . map (( row , index ) => `<row r=" ${ index + 3 } "> ${ row . map (( text , col ) => `<c r=" ${ orderColumn ( col ) }${ index + 3 } " t="inlineStr"><is><t xml:space="preserve"> ${ orderXmlEncode ( text ) } </t></is></c>` ). join ( '' ) } </row>` ). join ( '' )
entries . set ( 'xl/worksheets/sheet1.xml' , sheet . replace ( /<sheetData\b[^>]*>[\s\S]*?<\/sheetData>/ , `<sheetData> ${ headers }${ data } </sheetData>` ))
return orderZipWrite ( entries )
}
function importRollbackRows ( validFirst , validSecond ) {
const invalid = [... validFirst ]; invalid [ 10 ] = '' // mandatory cargo name: seed only the second row
const changed = [... validSecond ]; changed [ 30 ] += '-changed' // SaveRequest.customerRemark participates in CREATE_DRAFT hash
return { seed : [ invalid , validSecond ], conflict : [ validFirst , changed ], retry : [ validFirst , validSecond ] }
}
2026-10-03 00:46:00 +08:00
function channelGateWorkbook ( sourceBytes , reference , channelCode ) {
const matches = reference . channels . filter ( channel => channel . channelCode === channelCode )
assertEqual ( matches . length , 1 , 'one authoritative target channel' )
const sourceSheetName = matches [ 0 ]. sourceSheetName
assertPresent ( sourceSheetName , 'target channel source sheet' )
const removedCodes = reference . channels . filter ( channel => channel . sourceSheetName === sourceSheetName ). map ( channel => channel . channelCode )
assertTrue ( reference . channels . some ( channel => ! removedCodes . includes ( channel . channelCode )), 'B must retain another real channel' )
const entries = orderZipRead ( sourceBytes ), workbook = entries . get ( 'xl/workbook.xml' )
assertTrue ( typeof workbook === 'string' , 'source workbook XML' )
let removed = 0
const updated = workbook . replace ( /<sheet\b[^>]*\/>/g , element => {
const name = element . match ( /\bname="([^"]*)"/ ) ? .[ 1 ]
if ( name !== undefined && orderXmlDecode ( name ) === sourceSheetName ) { removed ++ ; return '' }
return element
})
assertEqual ( removed , 1 , 'remove exactly the authoritative channel sheet from workbook directory' )
// Preserve all parts and relationships byte-for-byte. The SAX price importer enumerates only workbook-listed sheets.
entries . set ( 'xl/workbook.xml' , updated )
return { bytes : orderZipWrite ( entries , true ), sourceSheetName , removedCodes }
}
function channelGateMappings ( detail , reference ) {
const mapping = ( kind , keyField , idField ) => {
const keys = [... new Set ( detail . rates . filter ( rate => kind === 'origin' || rate . destinationType === 'OVERSEAS' ). map ( rate => rate [ keyField ]))]
return keys . map ( key => {
assertPresent ( key , `price ${ kind } source label` )
const ids = [... new Set ( reference . rates . filter ( rate => ( kind === 'origin' || rate . destinationType === 'OVERSEAS' )
&& rate [ keyField ] === key ). map ( rate => String ( rate [ idField ] ?? '' )))]
assertTrue ( ids . length === 1 && /^[1-9]\d*$/ . test ( ids [ 0 ]), `unique published ${ kind } mapping for ${ key } ` )
return kind === 'origin' ? { originWarehouseCode : key , warehouseId : ids [ 0 ] } : { sourceDestinationName : key , warehouseId : ids [ 0 ] }
})
}
assertTrue ( Array . isArray ( detail . rates ) && detail . rates . length > 0 , 'nonempty parsed prices required' )
return { originMappings : mapping ( 'origin' , 'originWarehouseCode' , 'originWarehouseId' ),
overseasMappings : mapping ( 'overseas' , 'destinationName' , 'destinationWarehouseId' ) }
}
2026-10-02 21:33:39 +08:00
// END ORDER ACCEPTANCE HELPERS
async function ownedOrderSnapshot ( module , name , id , expected , validate = () => {}) {
const response = await apiStep ( module , name , {
path : `/api/merchant/v1/orders/first-leg/ ${ id } ` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json ); assertOrderDetail ( json . data , id )
if ( expected ) assertOrderUnchanged ( expected , json . data )
validate ( json . data )
}, ({ json }) => ({ orderId : String ( id ), status : json . data ? . order ? . status , version : json . data ? . order ? . version , snapshotSha256 : orderDigest ( json . data ) }))
return response . json . data
}
function recordOrderMutationRequest ( name , request , before ) {
report . orderMutationRequests ??= []
report . orderMutationRequests . push ({ name , method : request . method , path : request . path ,
idempotencyKey : request . headers ? .[ 'Idempotency-Key' ] ?? null , body : structuredClone ( request . body ),
beforeVersion : before . order . version , beforeSnapshotSha256 : orderDigest ( before ) })
}
async function rejectOrderMutation ( name , id , request , codes , statuses = [ 200 ]) {
const before = await ownedOrderSnapshot ( 'order-mutation-evidence' , ` ${ name } : before` , id )
recordOrderMutationRequest ( name , request , before )
await apiStep ( 'order-mutation-evidence' , name , request , ({ json , status }) => {
assertTrue ( statuses . includes ( status ), `expected HTTP ${ statuses } , actual= ${ status } ` )
assertTrue ( codes . includes ( String ( json ? . code )), `expected ${ codes } , actual= ${ json ? . code } ` )
}, ({ json }) => ({ orderId : String ( id ), rejectionCode : json ? . code }))
await ownedOrderSnapshot ( 'order-mutation-evidence' , ` ${ name } : all facts unchanged` , id , before )
}
async function orderMutationEvidenceModule () {
const module = 'order-mutation-evidence' , base = '/api/merchant/v1/orders/first-leg'
assertPresent ( orderAcceptanceFixture , 'completed order lifecycle fixture' )
assertPresent ( existingClientToken , 'independent client session' )
const payload = structuredClone ( orderAcceptanceFixture . payload )
payload . customerRemark = `mutation- ${ marker } `
const created = await apiStep ( module , 'create owned mutable draft for isolation and cancellation' , {
method : 'POST' , path : base , token : merchantToken , body : payload , headers : { 'Idempotency-Key' : newIdempotencyKey ( 'mutation-draft' ) },
}, ({ json }) => { requireSuccess ( json ); assertPresent ( json . data , 'draft ID' ) }, ({ json }) => ({ orderId : json . data }))
const id = created . json . data
report . resources . orderIds . push ( id )
const original = await ownedOrderSnapshot ( module , 'freeze draft page version and request' , id )
const editPayload = structuredClone ( payload ); editPayload . customerRemark += '-edited'
const editCommand = orderMutationCommand ( original , { draft : editPayload }, newIdempotencyKey ( 'versioned-edit' ))
const editRequest = { method : 'PUT' , path : ` ${ base } / ${ id } ` , token : merchantToken , ... editCommand }
recordOrderMutationRequest ( 'versioned draft edit' , editRequest , original )
await apiStep ( module , 'versioned draft edit succeeds' , editRequest , ({ json }) => requireSuccess ( json ),
() => ({ orderId : id , expectedVersion : original . order . version }))
const edited = await ownedOrderSnapshot ( module , 'edited draft full snapshot' , id , null , data => {
assertEqual ( data . order . status , 'DRAFT' , 'edit stays draft' )
assertTrue ( data . order . version > original . order . version , 'edit increments version' )
assertEqual ( data . order . customerRemark , editPayload . customerRemark , 'edited source remark' )
for ( const field of [ 'id' , 'orderNo' , 'inboundCode' , 'tenantId' , 'customerId' ]) assertEqual ( data . order [ field ], original . order [ field ], `stable edit ${ field } ` )
assertEqual ( data . cargoes . length , editPayload . cargoes . length , 'complete edited cargo set' )
data . cargoes . forEach (( cargo , index ) => {
for ( const field of [ 'skuNo' , 'packageCount' , 'model' , 'productImageFileId' ]) assertEqual ( cargo [ field ], editPayload . cargoes [ index ][ field ], `edited cargo ${ field } ` )
})
const oldIds = new Set ( original . statusHistory . map ( item => String ( item . id )))
const added = data . statusHistory . filter ( item => ! oldIds . has ( String ( item . id )))
assertOrderUnchanged ( original . statusHistory , data . statusHistory . filter ( item => oldIds . has ( String ( item . id ))))
assertEqual ( added . length , 1 , 'single edit audit' ); assertEqual ( added [ 0 ]. action , 'UPDATE' , 'edit audit action' )
})
recordOrderMutationRequest ( 'original edit replay' , editRequest , original )
await apiStep ( module , 'original edit key and frozen payload replay successfully' , editRequest , ({ json }) => requireSuccess ( json ),
() => ({ orderId : id , expectedVersion : original . order . version , replayed : true }))
await ownedOrderSnapshot ( module , 'edit replay adds no version cargo or history' , id , edited )
const changedEdit = structuredClone ( editCommand ); changedEdit . body . draft . customerRemark += '-conflict'
await rejectOrderMutation ( 'edit original key different payload conflicts' , id , {
method : 'PUT' , path : ` ${ base } / ${ id } ` , token : merchantToken , ... changedEdit ,
}, [ 'A0409' ])
await rejectOrderMutation ( 'old page version cannot overwrite newer draft' , id , {
method : 'PUT' , path : ` ${ base } / ${ id } ` , token : merchantToken ,
... orderMutationCommand ( original , { draft : payload }, newIdempotencyKey ( 'stale-edit' )),
}, [ 'A0409' ])
for ( const operation of [ 'edit' , 'cancel' ]) {
const method = operation === 'edit' ? 'PUT' : 'POST' , path = ` ${ base } / ${ id }${ operation === 'edit' ? '' : '/cancel' } `
const command = orderMutationCommand ( edited , operation === 'edit' ? { draft : editPayload } : { reason : 'missing protocol guard' }, newIdempotencyKey ( `missing- ${ operation } ` ))
const withoutVersion = structuredClone ( command ); delete withoutVersion . body . expectedVersion
await rejectOrderMutation ( ` ${ operation } missing version is rejected` , id , { method , path , token : merchantToken , ... withoutVersion }, [ 'A0422' ])
await rejectOrderMutation ( ` ${ operation } missing key is rejected` , id , { method , path , token : merchantToken , body : command . body }, [ 'A0400' ], [ 200 , 400 ])
}
await rejectOrderMutation ( 'other customer cannot edit owned draft' , id , {
method : 'PUT' , path : ` ${ base } / ${ id } ` , token : existingClientToken ,
... orderMutationCommand ( edited , { draft : payload }, newIdempotencyKey ( 'foreign-edit' )),
}, [ 'A0403' , 'A0404' ])
await rejectOrderMutation ( 'other customer cannot cancel owned draft' , id , {
method : 'POST' , path : ` ${ base } / ${ id } /cancel` , token : existingClientToken ,
... orderMutationCommand ( edited , { reason : `foreign- ${ marker } ` }, newIdempotencyKey ( 'foreign-cancel' )),
}, [ 'A0403' , 'A0404' ])
const invalid = structuredClone ( payload ); invalid . cargoes [ 0 ]. productImageFileId = - 1
await rejectOrderMutation ( 'invalid product image edit rolls back all facts' , id , {
method : 'PUT' , path : ` ${ base } / ${ id } ` , token : merchantToken ,
... orderMutationCommand ( edited , { draft : invalid }, newIdempotencyKey ( 'invalid-edit' )),
}, [ 'A0422' ])
await rejectOrderMutation ( 'submitted order cannot be overwritten as draft' , orderAcceptanceFixture . orderId , {
method : 'PUT' , path : ` ${ base } / ${ orderAcceptanceFixture . orderId } ` , token : merchantToken ,
... orderMutationCommand ( await ownedOrderSnapshot ( module , 'freeze submitted order edit request' , orderAcceptanceFixture . orderId ),
{ draft : payload }, newIdempotencyKey ( 'submitted-edit-evidence' )),
}, [ 'A0409' ])
await rejectOrderMutation ( 'blank cancellation reason preserves all facts' , id , {
method : 'POST' , path : ` ${ base } / ${ id } /cancel` , token : merchantToken ,
... orderMutationCommand ( edited , { reason : ' ' }, newIdempotencyKey ( 'empty-reason' )),
}, [ 'A0422' ])
await rejectOrderMutation ( 'old page version cannot cancel updated draft' , id , {
method : 'POST' , path : ` ${ base } / ${ id } /cancel` , token : merchantToken ,
... orderMutationCommand ( original , { reason : 'stale page' }, newIdempotencyKey ( 'stale-cancel' )),
}, [ 'A0409' ])
const before = await ownedOrderSnapshot ( module , 'before valid cancellation' , id )
const reason = `F06-cancel- ${ marker } `
const cancelCommand = orderMutationCommand ( before , { reason }, newIdempotencyKey ( 'versioned-cancel' ))
const cancelRequest = { method : 'POST' , path : ` ${ base } / ${ id } /cancel` , token : merchantToken , ... cancelCommand }
recordOrderMutationRequest ( 'versioned cancellation' , cancelRequest , before )
await apiStep ( module , 'cancel owned draft with reason' , cancelRequest , ({ json }) => requireSuccess ( json ), () => ({ orderId : id , reason }))
await apiStep ( module , 'cancellation persists one attributed history and preserves cargo' , {
path : ` ${ base } / ${ id } ` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json ); assertOrderDetail ( json . data , id )
assertEqual ( json . data . order . status , 'CANCELLED' , 'cancelled status' )
assertTrue ( json . data . order . version > before . order . version , 'version increases' )
for ( const field of [ 'cargoes' , 'parties' , 'internalNotes' ]) assertOrderUnchanged ( before [ field ], json . data [ field ])
for ( const field of [ 'id' , 'orderNo' , 'inboundCode' , 'tenantId' , 'customerId' ]) assertEqual ( json . data . order [ field ], before . order [ field ], `stable ${ field } ` )
const oldIds = new Set ( before . statusHistory . map ( item => String ( item . id )))
const added = json . data . statusHistory . filter ( item => ! oldIds . has ( String ( item . id )))
assertEqual ( added . length , 1 , 'one cancellation history' )
assertOrderUnchanged ( before . statusHistory , json . data . statusHistory . filter ( item => oldIds . has ( String ( item . id ))))
assertEqual ( added [ 0 ]. action , 'CANCELLED' , 'cancellation action' ); assertEqual ( added [ 0 ]. reason , reason , 'cancellation reason' )
assertEqual ( added [ 0 ]. fromStatus , 'DRAFT' , 'from draft' ); assertEqual ( added [ 0 ]. toStatus , 'CANCELLED' , 'to cancelled' )
assertPresent ( added [ 0 ]. operatorId , 'operator' ); assertPresent ( added [ 0 ]. occurredAt , 'server operation time' )
}, ({ json }) => ({ orderId : id , status : json . data ? . order ? . status , snapshotSha256 : orderDigest ( json . data ) }))
const cancelled = await ownedOrderSnapshot ( module , 'freeze cancelled facts before replay' , id )
recordOrderMutationRequest ( 'original cancellation replay' , cancelRequest , before )
await apiStep ( module , 'original cancellation key payload and version replay successfully' , cancelRequest ,
({ json }) => requireSuccess ( json ), () => ({ orderId : id , expectedVersion : before . order . version , replayed : true }))
await ownedOrderSnapshot ( module , 'cancel replay creates no second history or version' , id , cancelled )
const changedCancel = structuredClone ( cancelCommand ); changedCancel . body . reason += '-conflict'
await rejectOrderMutation ( 'cancellation original key different reason conflicts' , id , {
method : 'POST' , path : ` ${ base } / ${ id } /cancel` , token : merchantToken , ... changedCancel ,
}, [ 'A0409' ])
await rejectOrderMutation ( 'repeat cancellation cannot add history' , id , {
method : 'POST' , path : ` ${ base } / ${ id } /cancel` , token : merchantToken ,
... orderMutationCommand ( cancelled , { reason }, newIdempotencyKey ( 'new-key-repeat-cancel' )),
}, [ 'A0409' ])
await rejectOrderMutation ( 'cancelled draft cannot be submitted' , id , {
2026-10-02 23:14:24 +08:00
method : 'POST' , path : ` ${ base } / ${ id } /submit` , token : merchantToken ,
... orderMutationCommand ( cancelled , {}, newIdempotencyKey ( 'cancelled-submit-proof' )),
2026-10-02 21:33:39 +08:00
}, [ 'A0409' ])
report . orderMutationLimitations = [
'Requires deployed Merchant edit/cancel versioned idempotent protocol; local contracts do not prove TEST deployment.' ,
'Irreversible-fulfillment cancellation is not exercised by these draft/submitted fixtures.' ,
]
}
2026-10-03 00:27:45 +08:00
async function submissionGateFulfillmentSnapshot ( order , name ) {
const module = 'order-submission-gates' , customerId = String ( report . resources . tenantId )
let task
await apiStep ( module , ` ${ name } : exact fulfillment task` , {
path : `/api/platform/v1/first-leg/tasks/by-order/ ${ order . id } ?tenantId= ${ customerId } ` , token : platformToken ,
}, ({ json , status }) => {
assertEqual ( status , 200 , 'task lookup HTTP' )
assertTrue ([ '200' , 'A0404' ]. includes ( String ( json ? . code )), 'task lookup must succeed or explicitly not exist' )
task = String ( json . code ) === 'A0404' ? null : json . data ?? null
if ( task ) {
assertEqual ( String ( task . orderId ), String ( order . id ), 'task source order' )
assertEqual ( String ( task . tenantId ), customerId , 'task customer' )
}
}, () => ({ orderId : order . id , taskSha256 : orderDigest ( task ) }))
const inbounds = []
let total
for ( let page = 1 ; ; page ++ ) {
await apiStep ( module , ` ${ name } : inbound page ${ page } ` , {
path : `/api/platform/inbounds?tenantId= ${ customerId } &orderNo= ${ encodeURIComponent ( order . orderNo ) } &page= ${ page } &size=200` , token : platformToken ,
}, ({ json }) => {
requireSuccess ( json )
assertTrue ( Array . isArray ( json . data ? . records ), 'inbound records required' )
assertTrue ( Number . isSafeInteger ( Number ( json . data . total )) && Number ( json . data . total ) >= 0 , 'inbound total required' )
if ( total === undefined ) total = Number ( json . data . total )
assertEqual ( Number ( json . data . total ), total , 'stable inbound total' )
assertEqual ( Number ( json . data . page ), page , 'inbound page identity' )
for ( const row of json . data . records ) {
assertEqual ( row . orderNo , order . orderNo , 'exact inbound source' )
assertEqual ( String ( row . tenantId ), customerId , 'inbound customer' )
assertTrue ( ! inbounds . some ( before => String ( before . id ) === String ( row . id )), 'no duplicate inbound across pages' )
inbounds . push ( row )
}
assertEqual ( json . data . records . length , Math . min ( 200 , Math . max ( 0 , total - ( page - 1 ) * 200 )), 'complete inbound page' )
}, () => ({ orderId : order . id , total , collected : inbounds . length , snapshotSha256 : orderDigest ( inbounds ) }))
if ( page * 200 >= total ) break
}
return { task , inbounds }
}
async function orderSubmissionBalanceGateModule () {
const module = 'order-submission-gates' , customerId = String ( report . resources . tenantId )
const proof = report . orderSubmissionGates = { customerId , balance : { status : 'PREPARING' }, remaining : [
'Channel-offline requires this run customer-owned price-book switch excluding the draft channel; no public channel or public price-book is changed.' ,
'Finance/IAM unavailable-response rejection requires controlled isolated upstream fault evidence; not injected here.' ,
] }
await apiStep ( module , 'guard newly registered customer ownership before financial fixture' , {
path : '/api/merchant/v1/iam/tenant/profile' , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertEqual ( String ( json . data ? . id ), customerId , 'current primary client identity' )
assertEqual ( json . data ? . status , 'ACTIVE' , 'approved active customer' )
assertTrue ( report . steps . some ( step => step . module === 'registration' && step . name === 'enterprise self-registration'
&& step . passed === true && String ( step . evidence ? . tenantId ) === customerId ), 'only this run newly registered customer may be adjusted' )
}, () => ({ customerId , scope : 'current-run-registration-only' }))
const readWallet = async label => {
const response = await apiStep ( module , label , { path : '/api/client/finance/v1/account' , token : merchantToken }, ({ json }) => {
requireSuccess ( json ); assertTrue ( Number . isSafeInteger ( json . data ? . version ), 'wallet version required' )
assertEqual ( json . data . currencyCode , 'CNY' , 'isolated wallet currency' )
for ( const field of [ 'actualBalance' , 'availableBalance' , 'frozenBalance' , 'creditLimit' , 'creditUsed' , 'creditFrozen' , 'availableCredit' ]) {
assertTrue ( json . data [ field ] != null && Number . isFinite ( Number ( json . data [ field ])), `wallet ${ field } required` )
}
assertEqual ( json . data . creditActive , false , 'no active credit may be altered' )
}, ({ json }) => ({ customerId , account : json . data }))
return response . json . data
}
const readLedger = async label => {
const response = await apiStep ( module , label , { path : '/api/client/finance/v1/account/ledger?page=1&size=200' , token : merchantToken }, ({ json }) => {
requireSuccess ( json ); assertTrue ( Array . isArray ( json . data ? . records ), 'ledger records required' )
assertEqual ( Number ( json . data . total ), json . data . records . length , 'entire isolated ledger fits one page' )
}, ({ json }) => ({ total : json . data . total , ledgerSha256 : orderDigest ( json . data . records ) }))
return response . json . data . records
}
const baseline = await readWallet ( 'freeze pristine account' )
for ( const field of [ 'actualBalance' , 'availableBalance' , 'frozenBalance' , 'creditLimit' , 'creditUsed' , 'creditFrozen' , 'availableCredit' ]) assertEqual ( Number ( baseline [ field ]), 0 , `pristine ${ field } ` )
assertEqual (( await readLedger ( 'require no prior ledger' )). length , 0 , 'never debit an existing funded account' )
const payload = { ... structuredClone ( orderAcceptanceFixture . payload ), customerRemark : `BALANCE-GATE- ${ marker } ` }
const created = await apiStep ( module , 'create only one fresh balance-gate draft' , {
method : 'POST' , path : '/api/merchant/v1/orders/first-leg' , token : merchantToken , body : payload ,
headers : { 'Idempotency-Key' : newIdempotencyKey ( 'balance-gate-create' )},
}, ({ json })=>{ requireSuccess ( json ); assertPresent ( json . data , 'gate order' )}, ({ json })=>({ orderId : json . data }))
const orderId = created . json . data
report . resources . orderIds . push ( orderId ); proof . balance . orderId = orderId
const before = await ownedOrderSnapshot ( module , 'freeze rejected-submit draft facts' , orderId )
assertEqual ( before . order . status , 'DRAFT' , 'unfulfilled balance fixture' )
const fulfillmentBefore = await submissionGateFulfillmentSnapshot ( before . order , 'before balance rejection' )
const occurredDate = new Intl . DateTimeFormat ( 'en-CA' ,{ timeZone : 'Asia/Shanghai' , year : 'numeric' , month : '2-digit' , day : '2-digit' }). format ( new Date ())
const referenceNo = `BALANCE-GATE- ${ marker } ` . slice ( 0 , 128 )
const adjust = async ( type , account , suffix ) => {
const command = { headers : { 'Idempotency-Key' : newIdempotencyKey ( `balance- ${ suffix } ` )}, body : { type , amount : 0.01 , occurredDate , paymentMethod : 'OTHER' ,
referenceNo , remark : ` ${ marker } isolated order gate ${ suffix } ` , expectedAccountVersion : account . version }}
proof . balance [ ` ${ suffix } Command` ] = command
await apiStep ( module , `isolated wallet ${ suffix } ` ,{ method : 'POST' , path : `/api/platform/finance/v1/accounts/ ${ customerId } /adjustments` ,
token : platformToken ,... command },({ json })=> requireSuccess ( json ),({ json })=>({ customerId , ledger : json . data }))
}
let attempted = false
try {
attempted = true
await adjust ( 'DEDUCTION' , baseline , 'deduct' )
const negative = await readWallet ( 'verify actual balance minus one cent' )
assertEqual ( Number ( negative . actualBalance ), - 0.01 , 'negative actual funds, not insufficient available credit' )
assertEqual ( negative . version , baseline . version + 1 , 'one intentional deduction' )
const negativeLedger = await readLedger ( 'freeze deduction ledger' )
assertEqual ( negativeLedger . length , 1 , 'only one fixture deduction' )
for ( const role of [ 'merchant' , 'platform' ]) {
const command = orderMutationCommand ( before ,{}, newIdempotencyKey ( `negative-balance- ${ role } ` ))
await apiStep ( module , ` ${ role } negative actual balance rejects submission` ,{
method : 'POST' , path : `/api/ ${ role } /v1/orders/first-leg/ ${ orderId } /submit ${ role === 'platform' ? `?tenantId= ${ customerId } ` : '' } ` ,
token : role === 'merchant' ? merchantToken : platformToken ,... command ,
},({ json , status })=>{ assertEqual ( status , 200 , 'submit HTTP' ); assertEqual ( String ( json ? . code ), 'A0403' , 'balance gate code' );
assertEqual ( json ? . message , '账户实际余额为负,请充值后再下单' , 'balance-specific reason, not qualification rejection' )},()=>({ orderId , customerId , expectedVersion : command . body . expectedVersion }))
await ownedOrderSnapshot ( module , ` ${ role } rejected order aggregate unchanged` , orderId , before )
const after = await submissionGateFulfillmentSnapshot ( before . order , ` ${ role } rejection` )
assertOrderUnchanged ( fulfillmentBefore , after )
assertOrderUnchanged ( negative , await readWallet ( ` ${ role } rejection leaves wallet unchanged` ))
assertOrderUnchanged ( negativeLedger , await readLedger ( ` ${ role } rejection leaves ledger unchanged` ))
}
proof . balance . status = 'REJECTIONS_VERIFIED'
} finally {
if ( attempted ) {
const current = await readWallet ( 'resolve isolated deduction before restoring' )
const ledger = await readLedger ( 'resolve exact fixture ledger before restoring' )
if ( Number ( current . actualBalance ) === 0 && current . version === baseline . version && ledger . length === 0 ) proof . balance . restoration = 'DEDUCTION_NOT_APPLIED'
else {
assertEqual ( Number ( current . actualBalance ), - 0.01 , 'restore only exact isolated negative balance' )
assertEqual ( current . version , baseline . version + 1 , 'restore requires unchanged deduction version' )
assertTrue ( ledger . length === 1 && ledger [ 0 ]. sourceType === 'MANUAL_DEDUCTION' && ledger [ 0 ]. referenceNo === referenceNo
&& Number ( ledger [ 0 ]. amount ) ===- 0.01 , 'restore only this run proven deduction; no blind credit' )
await adjust ( 'RECHARGE' , current , 'restore' )
const restored = await readWallet ( 'verify restored zero balance' )
for ( const field of [ 'actualBalance' , 'availableBalance' , 'frozenBalance' , 'creditLimit' , 'creditUsed' , 'creditFrozen' , 'availableCredit' ]) assertEqual ( Number ( restored [ field ]), Number ( baseline [ field ]), `restored ${ field } ` )
assertEqual ( restored . version , baseline . version + 2 , 'one deduction and one restoring credit' )
const finalLedger = await readLedger ( 'verify exact two fixture ledger entries' )
assertEqual ( finalLedger . length , 2 , 'no extra wallet movements' )
assertTrue ( finalLedger . every ( row => row . referenceNo === referenceNo ) && finalLedger . filter ( row => row . sourceType === 'MANUAL_DEDUCTION' && Number ( row . amount ) ===- 0.01 ). length === 1
&& finalLedger . filter ( row => row . sourceType === 'MANUAL_RECHARGE' && Number ( row . amount ) === 0.01 ). length === 1 , 'exact balanced fixture movements' )
proof . balance . restoration = 'RESTORED_WITH_AUDIT'
}
}
}
}
2026-10-03 00:46:00 +08:00
async function orderSubmissionChannelGateModule () {
const module = 'order-channel-gate' , base = '/api/platform/v1/fba-pricing/carrier-rates/price-books'
const customerId = String ( report . resources . tenantId )
assertTrue ( /^[1-9]\d*$/ . test ( customerId ), 'customer-specific price book must never use platform scope zero' )
assertTrue ( report . steps . some ( step => step . module === 'registration' && step . name === 'enterprise self-registration'
&& step . passed === true && String ( step . evidence ? . tenantId ) === customerId ), 'only current-run registered customer is eligible' )
assertEqual ( report . orderSubmissionGates ? . balance ? . restoration , 'RESTORED_WITH_AUDIT' , 'negative funds must already be restored' )
const { masterData , payload } = orderAcceptanceFixture
const proof = report . orderSubmissionGates . channel = { status : 'PREPARING' , customerId , channelCode : payload . channelCode , versions : []}
await apiStep ( module , 'verify current customer before private pricing writes' ,{
path : '/api/merchant/v1/iam/tenant/profile' , token : merchantToken ,
},({ json })=>{ requireSuccess ( json ); assertEqual ( String ( json . data ? . id ), customerId , 'current customer' ); assertEqual ( json . data . status , 'ACTIVE' , 'eligible customer' )},()=>({ customerId }))
const list = async ( scope , label )=>{
const response = await apiStep ( module , label ,{ path : ` ${ base } ?customerId= ${ scope } ` , token : platformToken },({ json })=>{
requireSuccess ( json ); assertTrue ( Array . isArray ( json . data ), 'price version list required' )
assertTrue ( json . data . every ( version => String ( version . customerId ) === String ( scope )), 'exact price-book customer scope' )
},({ json })=>({ customerId : scope , versionCount : json . data . length , versionsSha256 : orderDigest ( json . data ),
activeVersionIds : json . data . filter ( version => version . status === 'ACTIVE' ). map ( version => version . id )}))
return response . json . data
}
const publicBefore = await list ( '0' , 'freeze public price-book versions without changing them' )
assertEqual (( await list ( customerId , 'require no previous customer-specific price books' )). length , 0 , 'do not replace any existing customer private version' )
const active = publicBefore . filter ( version => version . status === 'ACTIVE' && String ( version . id ) === String ( masterData . channel . priceBookVersionId ))
assertEqual ( active . length , 1 , 'selected route must originate from one active public source' )
let reference
await apiStep ( module , 'read authoritative active price source and mappings' ,{
path : ` ${ base } / ${ active [ 0 ]. id } ` , token : platformToken ,
},({ json })=>{
requireSuccess ( json ); reference = json . data
assertEqual ( String ( reference ? . version ? . customerId ), '0' , 'read-only source is public' )
assertEqual ( String ( reference . version . id ), String ( active [ 0 ]. id ), 'exact active source' )
assertEqual ( reference . version . status , 'ACTIVE' , 'valid reusable source' )
assertTrue ( Array . isArray ( reference . channels ) && reference . channels . length > 1 , 'source must retain channels after removing target sheet' )
},({ json })=>({ versionId : json . data . version . id , sourceSha256 : json . data . version . sourceSha256 }))
let sourceBytes
await apiStep ( module , 'reuse existing valid XLSX source for private A' ,{
path : ` ${ base } / ${ reference . version . id } /source` , token : platformToken , responseType : 'bytes' ,
},({ status , bytes })=>{
assertEqual ( status , 200 , 'source download HTTP' ); sourceBytes = bytes
assertEqual ( createHash ( 'sha256' ). update ( bytes ). digest ( 'hex' ), reference . version . sourceSha256 , 'source SHA-256 matches published version' )
},({ bytes })=>({ sizeBytes : bytes . length , sha256 : createHash ( 'sha256' ). update ( bytes ). digest ( 'hex' )}))
const replacement = channelGateWorkbook ( sourceBytes , reference , payload . channelCode )
proof . sourceVersionId = reference . version . id ; proof . sourceSheetName = replacement . sourceSheetName ; proof . removedChannelCodes = replacement . removedCodes
const codesA = reference . channels . map ( channel => channel . channelCode ). sort ()
const codesB = codesA . filter ( code => ! replacement . removedCodes . includes ( code ))
const channelList = async ( versionId , containsTarget , label )=>{
const response = await apiStep ( module , label ,{
path : `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId= ${ masterData . receiving . id } ` , token : merchantToken ,
},({ json })=>{
requireSuccess ( json ); assertTrue ( Array . isArray ( json . data ), 'client channel list must be a successful array' )
assertEqual ( json . data . some ( channel => channel . channelCode === payload . channelCode ), containsTarget , 'target channel visibility' )
assertTrue ( json . data . every ( channel => String ( channel . priceBookVersionId ) === String ( versionId )), 'client must read private version, never fall back to public' )
},({ json })=>({ customerId , versionId , channelCodes : json . data . map ( channel => channel . channelCode )}))
return response . json . data
}
const publish = async ( bytes , label , expectedCodes )=>{
const sha256 = createHash ( 'sha256' ). update ( bytes ). digest ( 'hex' ), form = new FormData ()
form . append ( 'file' , new Blob ([ bytes ],{ type : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `gate- ${ marker } - ${ label } .xlsx` )
let parsed
await apiStep ( module , `upload customer-only version ${ label } ` ,{
method : 'POST' , path : ` ${ base } ?customerId= ${ customerId } ` , token : platformToken , body : form ,
},({ json })=>{
requireSuccess ( json ); parsed = json . data
assertEqual ( String ( parsed ? . version ? . customerId ), customerId , 'uploaded version exclusively belongs to new customer' )
assertPresent ( parsed . version . id , 'uploaded version identity' ); assertEqual ( parsed . version . status , 'PENDING_CONFIRMATION' , 'upload stays pending' )
assertEqual ( parsed . version . sourceSha256 , sha256 , 'uploaded source digest' )
assertOrderUnchanged ( expectedCodes ,[... parsed . channels . map ( channel => channel . channelCode )]. sort ())
},({ json })=>({ customerId , versionId : json . data ? . version ? . id , sourceSha256 : sha256 }))
const entry = { label , id : String ( parsed . version . id ), customerId , sourceSha256 : sha256 , status : 'PENDING_CONFIRMATION' }
proof . versions . push ( entry )
const mappings = channelGateMappings ( parsed , reference )
await apiStep ( module , `recheck exact ownership immediately before publishing ${ label } ` ,{
path : ` ${ base } / ${ entry . id } ` , token : platformToken ,
},({ json })=>{
requireSuccess ( json ); assertEqual ( String ( json . data ? . version ? . customerId ), customerId , 'publication ownership' )
assertEqual ( String ( json . data . version . id ), entry . id , 'publication version' ); assertEqual ( json . data . version . sourceSha256 , sha256 , 'publication source' )
assertEqual ( json . data . version . status , 'PENDING_CONFIRMATION' , 'publication precondition' )
},()=>({ customerId , versionId : entry . id }))
await apiStep ( module , `publish private ${ label } with source-verified warehouse mappings` ,{
method : 'POST' , path : ` ${ base } / ${ entry . id } /publish` , token : platformToken , body : mappings ,
},({ json })=>{
requireSuccess ( json ); assertEqual ( String ( json . data ? . version ? . customerId ), customerId , 'published scope' )
assertEqual ( String ( json . data . version . id ), entry . id , 'published identity' ); assertEqual ( json . data . version . status , 'ACTIVE' , 'published status' )
assertEqual ( Number ( json . data . version . blockingErrorCount ), 0 , 'no ignored publication blockers' )
assertOrderUnchanged ( expectedCodes ,[... json . data . channels . map ( channel => channel . channelCode )]. sort ())
assertTrue ( Array . isArray ( json . data . rates ) && json . data . rates . length > 0 , 'remaining real rates' )
},()=>({ customerId , versionId : entry . id , mappings }))
entry . status = 'ACTIVE'
return entry . id
}
try {
const a = await publish ( sourceBytes , 'A' , codesA )
proof . clientChannelsA = await channelList ( a , true , 'client sees target channel in private A' )
const gatePayload = {... structuredClone ( payload ), customerRemark : `CHANNEL-GATE- ${ marker } ` }
const created = await apiStep ( module , 'create fresh draft while target channel is available in A' ,{
method : 'POST' , path : '/api/merchant/v1/orders/first-leg' , token : merchantToken , body : gatePayload ,
headers : { 'Idempotency-Key' : newIdempotencyKey ( 'channel-gate-create' )},
},({ json })=>{ requireSuccess ( json ); assertPresent ( json . data , 'channel-gate draft' )},({ json })=>({ orderId : json . data }))
const orderId = created . json . data ; proof . orderId = orderId ; report . resources . orderIds . push ( orderId )
const before = await ownedOrderSnapshot ( module , 'freeze original A draft version and full facts' , orderId )
assertEqual ( before . order . status , 'DRAFT' , 'unfulfilled channel gate fixture' )
assertEqual ( before . order . channelCode , payload . channelCode , 'original draft channel' )
const fulfillment = await submissionGateFulfillmentSnapshot ( before . order , 'before private channel removal' )
const commands = Object . fromEntries ([ 'merchant' , 'platform' ]. map ( role =>[ role , orderMutationCommand ( before ,{}, newIdempotencyKey ( `removed-channel- ${ role } ` ))]))
proof . commands = commands
const b = await publish ( replacement . bytes , 'B' , codesB )
proof . clientChannelsB = await channelList ( b , false , 'client target channel is removed in private B' )
const own = await list ( customerId , 'verify A superseded only within this customer and B active' )
assertTrue ( own . length === 2 && own . some ( version => String ( version . id ) === a && version . status === 'SUPERSEDED' )
&& own . some ( version => String ( version . id ) === b && version . status === 'ACTIVE' ), 'exact customer A to B replacement' )
2026-10-03 00:50:52 +08:00
for ( const entry of proof . versions ) {
entry . statusAtPublication = entry . status
entry . status = own . find ( version => String ( version . id ) === entry . id ). status
}
2026-10-03 00:46:00 +08:00
for ( const role of [ 'merchant' , 'platform' ]) {
await apiStep ( module , ` ${ role } original draft rejects now-unavailable channel` ,{
method : 'POST' , path : `/api/ ${ role } /v1/orders/first-leg/ ${ orderId } /submit ${ role === 'platform' ? `?tenantId= ${ customerId } ` : '' } ` ,
token : role === 'merchant' ? merchantToken : platformToken ,... commands [ role ],
},({ json , status })=>{
assertEqual ( status , 200 , 'submit HTTP' ); assertEqual ( String ( json ? . code ), 'A0409' , 'unavailable channel conflict' )
assertEqual ( json ? . message , '所选渠道已下线,请重新选择' , 'channel-specific rejection, not version or qualification conflict' )
},()=>({ customerId , orderId , expectedVersion : commands [ role ]. body . expectedVersion , channelCode : payload . channelCode }))
await ownedOrderSnapshot ( module , ` ${ role } channel rejection leaves original order unchanged` , orderId , before )
assertOrderUnchanged ( fulfillment , await submissionGateFulfillmentSnapshot ( before . order , ` ${ role } channel rejection` ))
}
proof . status = 'SUBMISSIONS_REJECTED'
} finally {
assertOrderUnchanged ( publicBefore , await list ( '0' , 'verify every public price-book version unchanged' ))
proof . publicVersionsUnchanged = true
}
proof . status = 'REJECTIONS_VERIFIED'
report . orderSubmissionGates . remaining = report . orderSubmissionGates . remaining . filter ( item => ! item . startsWith ( 'Channel-offline requires' ))
}
2026-10-02 21:33:39 +08:00
async function importOrderSet ( name , expectedIds ) {
const ids = [], seen = new Set ()
let total = null
for ( let page = 1 ; ; page ++ ) {
await apiStep ( 'order-excel-import' , ` ${ name } : complete order page ${ page } ` , {
path : `/api/merchant/v1/orders/first-leg?page= ${ page } &size=200` , token : merchantToken ,
}, ({ json }) => {
requireSuccess ( json )
assertTrue ( Array . isArray ( json . data ? . records ), 'order page records required' )
assertEqual ( Number ( json . data . page ), page , 'order page identity' )
const count = Number ( json . data . total )
assertTrue ( Number . isSafeInteger ( count ) && count >= 0 , 'valid order total' )
if ( total === null ) total = count
assertEqual ( count , total , 'stable order page total' )
for ( const row of json . data . records ) {
assertPresent ( row . orderId , 'listed order ID' )
const id = String ( row . orderId )
assertTrue ( ! seen . has ( id ), 'no duplicate order across pages' ); seen . add ( id ); ids . push ( id )
}
assertTrue ( ids . length <= total , 'order pages do not exceed total' )
if ( page * 200 >= total ) {
assertEqual ( ids . length , total , 'complete order set' )
if ( expectedIds ) assertOrderUnchanged ([... expectedIds ]. sort (), [... ids ]. sort ())
} else assertEqual ( json . data . records . length , 200 , 'full intermediate order page' )
}, () => ({ page , total , collected : ids . length , orderIdsSha256 : orderDigest ([... ids ]. sort ()) }))
if ( page * 200 >= total ) return ids
}
}
async function orderExcelImportModule () {
const module = 'order-excel-import' , base = '/api/merchant/v1/orders/first-leg/batch-imports'
const { masterData , payload } = orderAcceptanceFixture
let template
await apiStep ( module , 'download and inspect actual Chinese customer template' , {
path : ` ${ base } /template` , token : merchantToken , responseType : 'bytes' ,
}, ({ status , bytes , headers }) => {
assertEqual ( status , 200 , 'template HTTP status' )
assertTrue (( headers . get ( 'content-type' ) ?? '' ). includes ( 'spreadsheetml' ), 'XLSX content type' )
template = orderZipRead ( bytes )
const header = orderSheetRows ( template ). find ( row => row . number === 2 )
assertEqual ( header ? . cells ? . length , 31 , 'current 31-column template' )
assertEqual ( header . cells [ 0 ]. text , '运输方式 *' , 'Chinese first header' )
assertEqual ( header . cells [ 30 ]. text , '客户备注' , 'Chinese final header' )
assertTrue ( template . get ( 'xl/workbook.xml' ). includes ( '选项数据' ), 'server-generated option sheet' )
}, ({ bytes }) => ({ sizeBytes : bytes . length , sha256 : createHash ( 'sha256' ). update ( bytes ). digest ( 'hex' ) }))
const options = new Set ( orderSheetRows ( template , 'xl/worksheets/sheet2.xml' ). flatMap ( row => row . cells . map ( cell => cell . text )))
const businessLabel = ( name , code ) => ! name || name . toLowerCase () === code . toLowerCase () ? code : ` ${ name } | ${ code } `
const receiving = businessLabel ( masterData . receiving . warehouseName , masterData . receiving . warehouseCode )
const destination = businessLabel ( masterData . destination . warehouseName , masterData . destination . warehouseCode )
const channel = businessLabel ( masterData . channel . channelName , masterData . channel . channelCode )
for ( const value of [ receiving , destination , channel ]) assertTrue ( options . has ( value ), `authoritative template option ${ value } ` )
const makeRow = suffix => [
'海运' , '' , '自行快递' , receiving , channel , '亚马逊FBA' , destination , '' , '' , '' ,
`Excel- ${ suffix } ` , '纸箱' , '2' , '20.5' , '0.024' , '塑料' , '否' , '否' , `SKU- ${ suffix } ` , '123456789012' ,
`SHIP- ${ suffix } ` , `REF- ${ suffix } ` , '买单报关' , payload . pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税' ,
'散货拼箱' , '否' , `SHIP- ${ suffix } ` , `REF- ${ suffix } ` , '普通头程' , '' , ` ${ marker } - ${ suffix } ` ,
]
const upload = async ( name , mode , key , bytes , validate , filename = 'orders.xlsx' ) => {
const form = new FormData ()
form . append ( 'file' , new Blob ([ bytes ], { type : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), filename )
return apiStep ( module , name , {
method : 'POST' , path : ` ${ base } ?mode= ${ mode } ` , token : merchantToken , headers : { 'Idempotency-Key' : key }, body : form ,
}, validate , ({ json }) => ({ batchNo : json ? . data ? . batchNo , mode , result : json ? . data ? . result , sourceFileId : json ? . data ? . sourceFileId ,
errorReportFileId : json ? . data ? . errorReportFileId , requestBytesSha256 : createHash ( 'sha256' ). update ( bytes ). digest ( 'hex' ) }))
}
const rejection = code => ({ json , status }) => {
assertEqual ( status , 200 , 'HTTP status' ); assertEqual ( String ( json ? . code ), code , 'rejection business code' )
}
const success = ( mode , rows ) => ({ json }) => { requireSuccess ( json ); assertImportResult ( json . data , mode , rows ) }
const verifyDraft = async ( id , row , name ) => {
await apiStep ( module , name , { path : `/api/merchant/v1/orders/first-leg/ ${ id } ` , token : merchantToken }, ({ json }) => {
requireSuccess ( json ); assertOrderDetail ( json . data , id )
assertEqual ( json . data . order . status , 'DRAFT' , 'import creates draft only' )
assertEqual ( String ( json . data . order . tenantId ), String ( report . resources . tenantId ), 'authenticated customer scope' )
assertEqual ( json . data . order . customerRemark , row [ 30 ], 'exact source remark' )
assertEqual ( json . data . order . referenceId , row [ 27 ], 'exact source reference' )
assertEqual ( String ( json . data . order . receivingWarehouseId ), String ( masterData . receiving . id ), 'authoritative receiving warehouse' )
assertEqual ( String ( json . data . order . destinationWarehouseId ), String ( masterData . destination . id ), 'authoritative destination' )
assertEqual ( json . data . order . channelCode , masterData . channel . channelCode , 'authoritative channel' )
assertEqual ( json . data . cargoes . length , 1 , 'one Excel row creates one order cargo' )
assertEqual ( json . data . cargoes [ 0 ]. skuNo , row [ 18 ], 'source SKU' )
assertEqual ( Number ( json . data . cargoes [ 0 ]. packageCount ), 2 , 'source package count' )
assertTrue ( ! json . data . statusHistory . some ( item => item . toStatus === 'SUBMITTED' ), 'import never submits' )
}, ({ json }) => ({ orderId : String ( id ), status : json . data ? . order ? . status , snapshotSha256 : orderDigest ( json . data ) }))
}
const initial = await importOrderSet ( 'before import matrix' )
const rows = importRollbackRows ( makeRow ( 'rollback-first' ), makeRow ( 'rollback-second' ))
const key = newIdempotencyKey ( 'excel-transaction' )
const seedBytes = orderWorkbookRows ( template , rows . seed )
const seed = await upload ( 'PARTIAL preserves only valid second row' , 'PARTIAL' , key , seedBytes , success ( 'PARTIAL' , [ false , true ]))
const retainedId = String ( seed . json . data . result . items [ 1 ]. orderId )
report . resources . orderIds . push ( retainedId )
const seeded = [... initial , retainedId ]
await importOrderSet ( 'PARTIAL exactly one new order' , seeded )
await verifyDraft ( retainedId , rows . seed [ 1 ], 'PARTIAL source fields and customer match' )
const retained = await ownedOrderSnapshot ( module , 'seed second row full snapshot' , retainedId )
const replay = await upload ( 'PARTIAL same key bytes returns same order' , 'PARTIAL' , key , seedBytes , response => {
success ( 'PARTIAL' , [ false , true ])( response )
assertEqual ( String ( response . json . data . result . items [ 1 ]. orderId ), retainedId , 'replayed order identity' )
})
await importOrderSet ( 'PARTIAL replay adds no orders' , seeded )
await ownedOrderSnapshot ( module , 'PARTIAL replay preserves full existing order' , retainedId , retained )
const errorData = replay . json . data
await apiStep ( module , 'download actual row error workbook' , {
path : `/api/merchant/v1/files/order-batch-imports/ ${ encodeURIComponent ( errorData . batchNo ) } /error-reports/ ${ errorData . errorReportFileId } ` ,
token : merchantToken , responseType : 'bytes' ,
}, ({ status , bytes }) => {
assertEqual ( status , 200 , 'error workbook download' )
const rows = orderSheetRows ( orderZipRead ( bytes ))
assertEqual ( rows . length , 2 , 'header and exactly one failed row' )
2026-10-02 21:49:48 +08:00
assertEqual ( Number ( rows [ 1 ]. cells [ 0 ]. text ), 3 , 'actual Excel error row' )
2026-10-02 21:33:39 +08:00
assertEqual ( rows [ 1 ]. cells [ 1 ]. text , 'A0422' , 'error report code' )
assertEqual ( rows [ 1 ]. cells [ 2 ]. text , errorData . result . items [ 0 ]. errorMessage , 'error report exact reason' )
}, ({ bytes }) => ({ sizeBytes : bytes . length , sha256 : createHash ( 'sha256' ). update ( bytes ). digest ( 'hex' ) }))
await upload ( 'ATOMIC parse rejection creates no orders' , 'ATOMIC' , newIdempotencyKey ( 'excel-parse' ), seedBytes , rejection ( 'A0422' ))
await importOrderSet ( 'ATOMIC parse failure has zero additions' , seeded )
// Same tenant/operator/key and physical row IDs; only row 4 customerRemark changes.
// Row 3 passes validation and writes before row 4 CREATE_DRAFT fingerprint conflicts.
await upload ( 'ATOMIC later row idempotency conflict rolls back earlier row' , 'ATOMIC' , key ,
orderWorkbookRows ( template , rows . conflict ), rejection ( 'A0409' ))
await importOrderSet ( 'ATOMIC mid-write conflict leaves no first-row order' , seeded )
await ownedOrderSnapshot ( module , 'ATOMIC conflict preserves seeded order and history' , retainedId , retained )
await upload ( 'ATOMIC retry with original second-row hash succeeds' , 'ATOMIC' , key ,
orderWorkbookRows ( template , rows . retry ), response => {
success ( 'ATOMIC' , [ true , true ])( response )
assertEqual ( String ( response . json . data . result . items [ 1 ]. orderId ), retainedId , 'second row reuses original ID' )
assertTrue ( String ( response . json . data . result . items [ 0 ]. orderId ) !== retainedId , 'new first row is distinct' )
}). then ( async response => {
const firstId = String ( response . json . data . result . items [ 0 ]. orderId )
report . resources . orderIds . push ( firstId ); seeded . push ( firstId )
await importOrderSet ( 'ATOMIC retry adds exactly first row' , seeded )
await verifyDraft ( firstId , rows . retry [ 0 ], 'ATOMIC first row source facts match' )
})
await ownedOrderSnapshot ( module , 'successful ATOMIC replay preserves second row' , retainedId , retained )
const invalidCases = [
[ 'wrong extension' , seedBytes , 'orders.csv' ], [ 'corrupt xlsx' , Buffer . from ( 'not-an-xlsx' ), 'orders.xlsx' ],
[ 'over five MiB' , Buffer . alloc ( 5 * 1024 * 1024 + 1 ), 'orders.xlsx' ],
[ '51 effective rows' , orderWorkbookRows ( template , Array . from ({ length : 51 }, ( _ , index ) => makeRow ( `limit- ${ index } ` ))), 'orders.xlsx' ],
]
const missingHeader = orderZipRead ( orderWorkbookRows ( template , [ makeRow ( 'header' )]))
missingHeader . set ( 'xl/worksheets/sheet1.xml' , missingHeader . get ( 'xl/worksheets/sheet1.xml' ). replace ( /<row\b[^>]*\br="2"[^>]*>[\s\S]*?<\/row>/ , '<row r="2"/>' ))
invalidCases . push ([ 'missing Chinese headers' , orderZipWrite ( missingHeader ), 'orders.xlsx' ])
for ( const [ name , bytes , filename ] of invalidCases ) {
2026-10-02 21:53:29 +08:00
await upload ( `reject ${ name } ` , 'ATOMIC' , newIdempotencyKey ( 'excel-invalid' ), bytes , response => {
// Multipart rejects the oversized file before the import service validates its contents.
rejection ( name === 'over five MiB' ? 'A0400' : 'A0422' )( response )
if ( name === 'over five MiB' ) assertTrue ( /上传文件.*(?:超过|大小)/ . test ( response . json . message ), 'explicit upload size rejection' )
}, filename )
2026-10-02 21:33:39 +08:00
await importOrderSet ( ` ${ name } : order set unchanged` , seeded )
}
const forged = makeRow ( 'forged-catalog' ); forged [ 3 ] = `FORGED- ${ marker } `
const tampered = new Map ( template )
tampered . set ( 'xl/worksheets/sheet2.xml' , tampered . get ( 'xl/worksheets/sheet2.xml' ). replace ( '</sheetData>' , `<row r="9999"><c r="A9999" t="inlineStr"><is><t> ${ orderXmlEncode ( forged [ 3 ]) } </t></is></c></row></sheetData>` ))
await upload ( 'forged hidden option is rejected against authoritative catalog' , 'ATOMIC' , newIdempotencyKey ( 'excel-forged' ),
orderWorkbookRows ( tampered , [ forged ]), rejection ( 'A0422' ))
await importOrderSet ( 'forged option adds no orders' , seeded )
report . orderImportLimitations = [
'ATOMIC assertions concern Order rows and their public facts; remote source uploads are not rolled back.' ,
'Template selected options are checked; exhaustive warehouse pagination and cross-customer private-address template comparison remain open.' ,
'This HTTP matrix does not prove browser import interaction or irreversible order fulfillment cancellation.' ,
]
}
2026-08-08 10:11:04 +08:00
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 ))
}