Files
baiyue-firstleg/scripts/client-test-env-acceptance.mjs
T

3129 lines
177 KiB
JavaScript
Raw Normal View History

2026-08-08 10:11:04 +08:00
#!/usr/bin/env node
/**
* Client test-environment acceptance flow.
*
* Run directly and enter the platform/client test credentials when prompted.
* Optional environment variables for CI:
2026-09-01 11:38:42 +08:00
* ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
* MERCHANT_TEST_USERNAME, MERCHANT_TEST_PASSWORD, CLIENT_TEST_REPORT_DIR
2026-08-08 10:11:04 +08:00
*
* Example:
* node scripts/client-test-env-acceptance.mjs
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { randomUUID, createHash } from 'node:crypto'
2026-08-08 10:11:04 +08:00
import { resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
import { inflateRawSync, deflateRawSync } from 'node:zlib'
2026-08-08 10:11:04 +08:00
const args = parseArgs(process.argv.slice(2))
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)
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
let orderAcceptanceFixture
2026-08-08 10:11:04 +08:00
let ownerPasswordNeedsRestore = false
let temporaryOwnerPassword
try {
await registrationModule()
await platformReviewerLogin()
2026-08-08 10:11:04 +08:00
await merchantBeforeApprovalModule()
await qualificationAndReviewModule()
await merchantAfterApprovalModule()
await profileManagementModule()
await loginIdentifierModule()
2026-08-08 10:11:04 +08:00
await rbacAndMemberModule()
await orderLifecycleModule()
await isolationModule()
await orderSubmissionBalanceGateModule()
await orderMutationEvidenceModule()
await orderExcelImportModule()
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: '测试法人',
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', {
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 }))
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,
...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 }))
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')
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)
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'PENDING')
assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'),
'optional legal person ID must remain absent')
2026-08-08 10:11:04 +08:00
assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'PENDING')
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
await apiStep('qualification-review', 'submit enterprise review', {
method: 'POST', path: '/api/merchant/v1/iam/tenant/qualifications/submit-review', token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.status, 'PENDING', 'tenant status')
}, ({ json }) => ({ tenantId: json.data.id, status: json.data.status }))
const 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)
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 }
})
}
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 },
}, ({ 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 },
}, ({ 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', {
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)
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', {
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', {
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', {
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', {
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', {
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', {
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', {
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', {
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', {
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', {
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', {
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')
const incompleteSubmitCases = [
2026-08-08 10:11:04 +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
},
},
{
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
},
},
]
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,
...orderMutationCommand(await ownedOrderSnapshot('order-validation', 'freeze incomplete submit version', draft.json.data), {}, newIdempotencyKey('incomplete-submit')),
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0422', 'business code')
}, ({ json }) => ({ orderId: draft.json.data, rejectionCode: json?.code, message: json?.message }))
}
const invalidOrderCases = [
2026-08-08 10:11:04 +08:00
{
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)
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', {
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
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')
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,
...submitCommand,
2026-08-08 10:11:04 +08:00
}, ({ json }) => requireSuccess(json), () => ({ orderId }))
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,
...submitCommand,
2026-08-08 10:11:04 +08:00
}, ({ json }) => requireSuccess(json), () => ({ orderId, replayed: true }))
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 }))
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)
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'customer-contact-filtered order')
2026-08-08 10:17:43 +08:00
}, ({ json }) => ({ total: json.data?.total, orderId }))
await apiStep('order-filters', 'unmatched keyword returns an empty order page', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&keyword=${randomUUID()}`, token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(pageRecords(json.data).length, 0, 'unmatched order count')
}, ({ json }) => ({ total: json.data?.total }))
await softApiStep('order-validation', 'invalid order status filter is rejected', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20&status=UNKNOWN', token: merchantToken,
}, ({ json, status }) => {
assertTrue([200, 400].includes(status), `unexpected HTTP status ${status}`)
2026-08-08 10:31:44 +08:00
assertEqual(String(json?.code), 'A0422', 'business code')
2026-08-08 10:17:43 +08:00
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
await apiStep('order-boundary', 'unknown order detail is rejected', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg/1', token: merchantToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0404', 'business code')
}, ({ json }) => ({ orderId: 1, rejectionCode: json?.code }))
2026-08-08 10:11:04 +08:00
await softApiStep('order-boundary', 'submitted order cannot be edited by client', {
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken,
...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)
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,
...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,
...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,
...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', {
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', {
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', {
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', {
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', {
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}`,
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,
woodenProduct: false,
2026-08-08 10:11:04 +08:00
cargoRemark: `cargo-${remark} ${marker}`,
}],
}
}
// 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 {
for (const field of ['orderId', 'orderNo', 'inboundCode']) {
assertTrue(item[field] === null || item[field] === undefined, `failed row has no ${field}`)
}
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')
entries.set(name, /\.(xml|rels)$/i.test(name) ? content.toString('utf8') : Buffer.from(content))
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
}
function orderZipWrite(entries, compress = false) {
const local = [], directory = []
let offset = 0
for (const [name, text] of entries) {
const filename = Buffer.from(name), data = Buffer.from(text), crc = orderCrc(data)
const packed = compress ? deflateRawSync(data) : data
const header = Buffer.alloc(30)
header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4); header.writeUInt16LE(0x800, 6)
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)
const central = Buffer.alloc(46)
central.writeUInt32LE(0x02014b50); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x800, 8)
central.writeUInt16LE(compress ? 8 : 0, 10)
central.writeUInt32LE(crc, 16); central.writeUInt32LE(packed.length, 20); central.writeUInt32LE(data.length, 24)
central.writeUInt16LE(filename.length, 28); central.writeUInt32LE(offset, 42)
local.push(header, filename, packed); directory.push(central, filename)
offset += header.length + filename.length + packed.length
}
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[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] }
}
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') }
}
// 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, {
method: 'POST', path: `${base}/${id}/submit`, token: merchantToken,
...orderMutationCommand(cancelled, {}, newIdempotencyKey('cancelled-submit-proof')),
}, ['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.',
]
}
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'
}
}
}
}
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')
for (const entry of proof.versions) {
entry.statusAtPublication = entry.status
entry.status = own.find(version => String(version.id) === entry.id).status
}
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'))
}
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')
assertEqual(Number(rows[1].cells[0].text), 3, 'actual Excel error row')
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) {
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)
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))
}