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

2801 lines
152 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:
* ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
* MERCHANT_TEST_USERNAME, MERCHANT_TEST_PASSWORD, CLIENT_TEST_REPORT_DIR
*
* Example:
* node scripts/client-test-env-acceptance.mjs
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { randomUUID, createHash } from 'node:crypto'
import { resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
import { inflateRawSync } from 'node:zlib'
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')
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
?? await promptSecret('平台审核密码')
const existingClientUsername = args['existing-client-username'] ?? process.env.MERCHANT_TEST_USERNAME
?? await promptText('跨客户隔离测试账号(必须由正式注册/建档产生)', '')
const existingClientPassword = args['existing-client-password'] ?? process.env.MERCHANT_TEST_PASSWORD
?? await promptSecret('跨客户隔离测试密码')
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports')
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
if (!gateway || !platformUsername || !platformPassword || !existingClientUsername || !existingClientPassword) {
console.error('ADMIN_TEST_GATEWAY, platform credentials, and an independently onboarded client account are required')
process.exit(2)
}
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
console.error('--timeout-ms must be at least 1000')
process.exit(2)
}
const now = new Date()
const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}`
const username = `client_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64)
const temporaryUsername = `renamed_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64)
const clientPassword = `Client${randomUUID().replaceAll('-', '').slice(0, 14)}9!`
const phoneSuffix = `${Date.now()}`.slice(-8)
const legalPhone = `139${phoneSuffix}`
const companyName = `客户端全链路测试企业-${marker}`.slice(0, 128)
const creditCode = `91${compactTimestamp(now)}${randomUUID().replaceAll('-', '').slice(0, 2).toUpperCase()}`.slice(0, 18)
const reportPath = resolve(reportDir, `client-test-env-${marker}.json`)
const report = {
marker,
gateway,
generatedAt: now.toISOString(),
completedAt: null,
success: false,
fatal: null,
testAccount: { username, companyName, legalPhone },
resources: {
tenantId: null,
membershipId: null,
reviewId: null,
businessLicenseQualificationId: null,
businessLicenseDocumentUrl: null,
productImageFileId: null,
enterpriseAddressId: null,
receivingWarehouseId: null,
destinationWarehouseId: null,
channelCode: null,
roleId: null,
testMembershipId: null,
notificationTemplateId: null,
notificationMessageIds: [],
orderIds: [],
},
steps: [],
futureModules: ['measurement', 'ocr', 'mojia', 'billing'],
}
let merchantToken
let merchantRefreshToken
let platformToken
let existingClientToken
let orderAcceptanceFixture
let ownerPasswordNeedsRestore = false
let temporaryOwnerPassword
try {
await registrationModule()
await merchantBeforeApprovalModule()
await qualificationAndReviewModule()
await merchantAfterApprovalModule()
await profileManagementModule()
await loginIdentifierModule()
await rbacAndMemberModule()
await orderLifecycleModule()
await isolationModule()
await orderMutationEvidenceModule()
await orderExcelImportModule()
await notificationInboxModule()
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,
contactEmail: `${username}@example.com`,
countryCode: 'CN',
address: '广东省深圳市南山区测试路 1 号',
}
const registration = await apiStep('registration', 'enterprise self-registration', {
method: 'POST', path: '/api/auth/register', body: registrationBody,
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenantStatus')
assertEqual(json.data?.nextStep, 'LOGIN_AND_COMPLETE_QUALIFICATIONS', 'nextStep')
assertPresent(json.data?.tenantId, 'tenantId')
assertPresent(json.data?.membershipId, 'membershipId')
}, ({ json }) => ({
tenantId: json.data.tenantId,
membershipId: json.data.membershipId,
tenantStatus: json.data.tenantStatus,
nextStep: json.data.nextStep,
}))
report.resources.tenantId = registration.json.data.tenantId
report.resources.membershipId = registration.json.data.membershipId
await apiStep('registration', 'same credentials replay the incomplete registration without creating another customer', {
method: 'POST', path: '/api/auth/register', body: registrationBody,
}, ({ 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!` },
}, ({ 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 },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'accessToken')
}, () => ({ username }))
merchantToken = login.json.data.accessToken
merchantRefreshToken = login.json.data.refreshToken
await apiStep('registration', 'profile is incomplete before qualifications', {
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.id), String(report.resources.tenantId), 'profile tenant id')
assertEqual(json.data?.status, 'PROFILE_REQUIRED', 'profile status')
}, ({ json }) => ({ id: json.data.id, status: json.data.status, tenantName: json.data.tenantName }))
}
async function merchantBeforeApprovalModule() {
const masterData = await loadOrderMasterData(merchantToken, 'pre-approval-master-data')
const productImageFileId = await uploadProductImage(merchantToken, 'pre-approval-product-image')
report.resources.productImageFileId = productImageFileId
const payload = buildOrderPayload(masterData, productImageFileId, 'PRE', 'draft before enterprise approval')
const invalid = structuredClone(payload)
delete invalid.pricingTaxMode
await apiStep('order-validation', 'missing pricingTaxMode is rejected', {
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
headers: { 'Idempotency-Key': newIdempotencyKey('invalid-create') }, body: invalid,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0422', 'business code')
})
const createKey = newIdempotencyKey('pre-approval-create')
const create = await apiStep('order-before-approval', 'create draft before approval', {
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
headers: { 'Idempotency-Key': createKey }, body: payload,
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data, 'order id')
}, ({ json }) => ({ orderId: json.data, status: 'DRAFT' }))
const orderId = create.json.data
report.resources.orderIds.push(orderId)
await apiStep('order-before-approval', 'create draft idempotent replay', {
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
headers: { 'Idempotency-Key': createKey }, body: payload,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data), String(orderId), 'replayed order id')
}, ({ json }) => ({ orderId: json.data, replayed: true }))
await apiStep('order-before-approval', 'submit is blocked before approval', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
...orderMutationCommand(await ownedOrderSnapshot('order-before-approval', 'freeze pre-approval submit version', orderId), {}, newIdempotencyKey('pre-approval-submit')),
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0403', 'business code')
}, ({ json }) => ({ orderId, rejectionCode: json.code }))
}
async function qualificationAndReviewModule() {
const png = testPng()
const form = new FormData()
form.append('businessLicense', new Blob([png], { type: 'image/png' }), `business-license-${marker}.png`)
await apiStep('qualification-review', 'upload required qualification package', {
method: 'POST',
path: `/api/merchant/v1/files/qualifications/required-package?unifiedSocialCreditCode=${encodeURIComponent(creditCode)}`,
token: merchantToken,
body: form,
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.businessLicenseQualificationId, 'business license qualification id')
assertTrue(json.data?.legalPersonIdQualificationId == null, 'legal person qualification id must stay optional')
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')
assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'PENDING')
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
await apiStep('qualification-review', 'submit enterprise review', {
method: 'POST', path: '/api/merchant/v1/iam/tenant/qualifications/submit-review', token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.status, 'PENDING', 'tenant status')
}, ({ json }) => ({ tenantId: json.data.id, status: json.data.status }))
const platformLogin = await apiStep('qualification-review', 'platform reviewer login', {
method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'platform accessToken')
}, () => ({ username: platformUsername }))
platformToken = platformLogin.json.data.accessToken
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 }))
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')
}, ({ 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', {
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', {
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
}
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 },
}, ({ 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')
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')
report.resources.businessLicenseQualificationId = businessLicense.id ?? businessLicense.qualificationId
report.resources.businessLicenseDocumentUrl = businessLicense.documentUrl
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 },
}, ({ 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)
const orderListPermissionId = grantedPermissions
.find((item) => item.permissionCode === 'merchant:order:list')?.id
assertPresent(orderListPermissionId, 'merchant:order:list permission id')
const ownerPermissions = await apiStep('menu-permissions', 'load owner effective permissions', {
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
const values = arrayData(json.data)
for (const code of requiredOwnerBackendPermissions()) {
assertTrue(values.includes(code), `owner permission is missing: ${code}`)
}
}, ({ json }) => ({
backendPermissionCount: arrayData(json.data).length,
visibleClientMenus: visibleClientMenus(arrayData(json.data)),
}))
assertSetEqual(
visibleClientMenus(arrayData(ownerPermissions.json.data)),
['account:profile:view', 'company:member:list', 'company:permission:list', 'company:profile:view',
'company:role:list', 'dashboard:view', 'notification:inbox:view', 'order:first-leg:list'],
'owner visible Client menus',
)
await apiStep('rbac-member', 'load initial merchant roles', {
method: 'GET', path: '/api/merchant/v1/iam/roles', token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(Array.isArray(json.data), 'role list must be an array')
}, ({ json }) => ({ roleCount: arrayData(json.data).length }))
const roleCode = `E2E_${marker.replaceAll('-', '_')}`.slice(0, 64)
const createdRole = await apiStep('rbac-member', 'create custom role', {
method: 'POST', path: '/api/merchant/v1/iam/roles', token: merchantToken,
body: { roleCode, roleName: `订单只读-${marker}`.slice(0, 64), description: `Client E2E ${marker}` },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.id, 'role id')
assertEqual(json.data?.roleCode, roleCode, 'roleCode')
assertEqual(json.data?.roleType, 'CUSTOM', 'roleType')
}, ({ json }) => ({ roleId: json.data.id, roleCode: json.data.roleCode, roleType: json.data.roleType, status: json.data.status }))
const roleId = createdRole.json.data.id
report.resources.roleId = roleId
await apiStep('rbac-member', 'grant permissions to custom role', {
method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken,
body: { roleId, permissionIds },
}, ({ json }) => requireSuccess(json), () => ({ roleId, permissionIds, permissionCodes: desiredCodes }))
await apiStep('rbac-member', 'custom role permission detail is persisted', {
method: 'GET', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertSetEqual(arrayData(json.data?.permissionIds).map(String), permissionIds.map(String), 'role permissionIds')
}, ({ json }) => ({ roleId: json.data.id, roleName: json.data.roleName, permissionIds: json.data.permissionIds }))
await apiStep('rbac-member', 'edit custom role', {
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken,
body: { roleName: `订单查询员-${marker}`.slice(0, 64), description: `edited ${marker}` },
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.description, `edited ${marker}`, 'role description')
}, ({ json }) => ({ roleId: json.data.id, roleName: json.data.roleName, description: json.data.description }))
await apiStep('rbac-member', 'disable custom role', {
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'DISABLED' },
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.status, 'DISABLED', 'role status')
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
await apiStep('rbac-member', 'enable custom role', {
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'ACTIVE' },
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.status, 'ACTIVE', 'role status')
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
const memberUsername = `member_${compactTimestamp(new Date())}_${randomUUID().slice(0, 5)}`.slice(0, 64)
const memberPassword = `Member${randomUUID().replaceAll('-', '').slice(0, 14)}8!`
const resetMemberPassword = `Reset${randomUUID().replaceAll('-', '').slice(0, 15)}7!`
const memberBody = {
username: memberUsername,
password: memberPassword,
displayName: `测试订单成员-${marker}`.slice(0, 64),
phone: `137${`${Date.now() + 1}`.slice(-8)}`,
email: `${memberUsername}@example.com`,
memberType: 'EMPLOYEE',
memberNo: `EMP-${marker}`.slice(0, 64),
roleIds: [roleId],
}
await softApiStep('member-validation', 'member with weak password and invalid email is rejected', {
method: 'POST', path: '/api/merchant/v1/iam/members', token: merchantToken,
body: { ...memberBody, username: `bad_${randomUUID().slice(0, 8)}`, password: 'password', email: 'invalid-email' },
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0422', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code }))
const createdMember = await apiStep('rbac-member', 'create enterprise member with role', {
method: 'POST', path: '/api/merchant/v1/iam/members', token: merchantToken, body: memberBody,
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.membershipId, 'membership id')
assertEqual(json.data?.memberType, 'EMPLOYEE', 'memberType')
assertSetEqual(arrayData(json.data?.roleIds).map(String), [String(roleId)], 'member roleIds')
}, ({ json }) => ({ membershipId: json.data.membershipId, memberType: json.data.memberType, status: json.data.status, roleIds: json.data.roleIds }))
const membershipId = createdMember.json.data.membershipId
report.resources.testMembershipId = membershipId
await apiStep('rbac-member', 'enterprise member list contains created member and roles', {
method: 'GET', path: '/api/merchant/v1/iam/members', token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
const member = arrayData(json.data).find((item) => String(item.membershipId) === String(membershipId))
assertPresent(member, 'created member list row')
assertEqual(member.displayName, memberBody.displayName, 'member displayName')
assertEqual(member.status, 'ACTIVE', 'member status')
assertSetEqual(arrayData(member.roleIds).map(String), [String(roleId)], 'member list roleIds')
}, ({ json }) => ({ memberCount: arrayData(json.data).length, membershipId }))
await apiStep('rbac-member', 'enterprise member detail matches created fields and roles', {
method: 'GET', path: `/api/merchant/v1/iam/members/${membershipId}`, token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.membershipId), String(membershipId), 'membership id')
assertEqual(json.data?.displayName, memberBody.displayName, 'member displayName')
assertEqual(json.data?.phone, memberBody.phone, 'member phone')
assertEqual(json.data?.email, memberBody.email, 'member email')
assertEqual(json.data?.memberNo, memberBody.memberNo, 'memberNo')
assertSetEqual(arrayData(json.data?.roleIds).map(String), [String(roleId)], 'member detail roleIds')
}, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status, roleIds: json.data.roleIds }))
await apiStep('rbac-member', 'duplicate member username is rejected', {
method: 'POST', path: '/api/merchant/v1/iam/members', token: merchantToken, body: memberBody,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0409', 'business code')
}, ({ json }) => ({ username: memberUsername, rejectionCode: json.code }))
await apiStep('rbac-member', 'assigned custom role cannot be deleted', {
method: 'DELETE', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0409', 'business code')
}, ({ json }) => ({ roleId, rejectionCode: json.code }))
await apiStep('rbac-member', 'enterprise owner cannot be disabled', {
method: 'PUT', path: `/api/merchant/v1/iam/members/${report.resources.membershipId}/status`, token: merchantToken,
body: { status: 'DISABLED' },
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0403', 'business code')
}, ({ json }) => ({ membershipId: report.resources.membershipId, rejectionCode: json.code }))
await apiStep('rbac-member', 'enterprise owner cannot be edited through member API', {
method: 'PUT', path: `/api/merchant/v1/iam/members/${report.resources.membershipId}`, token: merchantToken,
body: {
displayName: '非法修改企业所有者', phone: legalPhone, email: `${username}@example.com`,
memberType: 'OWNER', memberNo: 'OWNER', roleIds: [],
},
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0403', 'business code')
}, ({ json }) => ({ membershipId: report.resources.membershipId, rejectionCode: json.code }))
const memberLogin = await apiStep('rbac-member', 'new enterprise member can login', {
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'member accessToken')
}, () => ({ username: memberUsername }))
let memberToken = memberLogin.json.data.accessToken
await apiStep('rbac-member', 'member can use granted order list permission', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
}, ({ json }) => requireSuccess(json), ({ json }) => ({ total: json.data?.total }))
await apiStep('rbac-member', 'member can view order with granted permission', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.order?.id), String(report.resources.orderIds[0]), 'order id')
}, ({ json }) => ({ orderId: json.data.order.id, status: json.data.order.status }))
await apiStep('menu-permissions', 'read-only member effective permissions match role', {
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
}, ({ json }) => {
requireSuccess(json)
assertSetEqual(arrayData(json.data), desiredCodes, 'member backend permissions')
assertSetEqual(
visibleClientMenus(arrayData(json.data)),
['account:profile:view', 'dashboard:view', 'order:first-leg:list'],
'member visible Client menus',
)
}, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) }))
const disabledRole = await softApiStep('menu-permissions', 'disable assigned 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 }))
if (disabledRole) {
await softApiStep('menu-permissions', 'disabled role revokes stale token permission query', {
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
}, ({ json, status }) => {
assertEqual(status, 401, 'HTTP status')
assertEqual(String(json?.code), 'A0401', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
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 },
}, ({ 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 },
}, ({ 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)) }))
} else {
recordSkipped('menu-permissions', 'role disable dependent authorization checks',
'Role status change failed; dependent checks require a successfully disabled role')
}
const reducedPermissions = await softApiStep('menu-permissions', 'remove order view permission from assigned role', {
method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken,
body: { roleId, permissionIds: [orderListPermissionId] },
}, ({ json }) => requireSuccess(json), () => ({ roleId, permissionCodes: [desiredCodes[0]] }))
if (reducedPermissions) {
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 },
}, ({ 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 },
}, ({ 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 }))
} else {
recordSkipped('menu-permissions', 'permission removal dependent authorization checks',
'Role permission update failed; dependent checks require a successfully reduced permission set')
}
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 },
}, ({ 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 },
}, ({ 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 }))
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 }))
await apiStep('rbac-member', 'disabled member login is rejected', {
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword },
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`)
}, ({ json }) => ({ username: memberUsername, rejectionCode: json.code }))
await apiStep('rbac-member', 'enable enterprise member', {
method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/status`, token: merchantToken, body: { status: 'ACTIVE' },
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.status, 'ACTIVE', 'member status')
}, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status }))
const enabledMemberLogin = await apiStep('rbac-member', 'member re-login after account is enabled', {
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'member accessToken')
}, () => ({ username: memberUsername }))
memberToken = enabledMemberLogin.json.data.accessToken
await apiStep('rbac-member', 're-enabled member can list orders before roles are cleared', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
}, ({ json }) => requireSuccess(json), ({ json }) => ({ total: json.data?.total }))
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 }))
await softApiStep('rbac-member', 'role clearing revokes stale member token', {
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 },
}, ({ 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 }))
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 }))
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 = [
{
name: 'FBA order missing Amazon identifiers',
mutate(value) {
value.amazonWarehouseCode = null
value.shipmentId = null
value.referenceId = null
},
},
{
name: 'FBA cargo missing SKU UPC shipment and reference',
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 = [
{
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
},
},
{
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 },
},
{
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) },
},
]
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 }))
}
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 }))
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)
await apiStep('order-lifecycle', 'edit draft', {
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, ...editCommand,
}, ({ 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 }
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)
await apiStep('order-lifecycle', 'submit order', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
...submitCommand,
}, ({ json }) => requireSuccess(json), () => ({ orderId }))
const submittedBeforeReplay = await ownedOrderSnapshot('order-lifecycle', 'freeze successful submission facts', orderId)
await apiStep('order-lifecycle', 'submit order idempotent replay', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
...submitCommand,
}, ({ 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'])
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' }))
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,
}, ({ json }) => {
requireSuccess(json)
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'customer-contact-filtered order')
}, ({ json }) => ({ total: json.data?.total, orderId }))
await apiStep('order-filters', 'unmatched keyword returns an empty order page', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&keyword=${randomUUID()}`, token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(pageRecords(json.data).length, 0, 'unmatched order count')
}, ({ json }) => ({ total: json.data?.total }))
await softApiStep('order-validation', 'invalid order status filter is rejected', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20&status=UNKNOWN', token: merchantToken,
}, ({ json, status }) => {
assertTrue([200, 400].includes(status), `unexpected HTTP status ${status}`)
assertEqual(String(json?.code), 'A0422', 'business code')
}, ({ 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 }))
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')),
}, ({ 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)
await apiStep('order-lifecycle', 'cancel second draft', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken,
...cancelCommand,
}, ({ 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')),
}, ({ 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')),
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0409', 'business code')
}, ({ json }) => ({ orderId: cancelOrderId, rejectionCode: json.code, message: json.message }))
await softApiStep('order-boundary', 'invalid order page size is rejected', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=201', token: merchantToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0422', 'business code')
}, ({ json }) => ({ rejectionCode: json.code }))
}
async function isolationModule() {
if (!existingClientUsername || !existingClientPassword) {
recordSkipped('isolation', 'cross-client order access is rejected',
'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check')
return
}
const login = await apiStep('isolation', 'existing client login', {
method: 'POST', path: '/api/auth/login', body: { loginId: existingClientUsername, password: existingClientPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'accessToken')
}, () => ({ username: existingClientUsername }))
existingClientToken = login.json.data.accessToken
const orderId = report.resources.orderIds[1]
await apiStep('isolation', 'cross-client order access is rejected', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: existingClientToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
}, ({ json }) => ({ orderId, rejectionCode: json.code }))
await softApiStep('isolation', 'cross-client product image access is rejected', {
method: 'GET', path: `/api/merchant/v1/files/product-images/${report.resources.productImageFileId}`,
token: existingClientToken,
}, ({ json, status }) => {
assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`)
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
}, ({ json }) => ({ fileId: report.resources.productImageFileId, rejectionCode: json?.code }))
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 }))
}
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,
}
}
}
async function accountSecurityModule() {
const consumedRefreshToken = merchantRefreshToken
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
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 }))
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 },
}, ({ 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 },
}, ({ 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 },
}, ({ 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 }))
await apiStep('account-security', 'repeated logout with revoked session is rejected', {
method: 'POST', path: '/api/auth/logout', body: { refreshToken: loggedOutRefreshToken },
}, ({ json, status }) => {
assertEqual(status, 401, 'HTTP status')
assertEqual(String(json?.code), 'A0401', 'business code')
}, ({ json }) => ({ repeated: true, rejectionCode: json?.code }))
}
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 },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'recovery accessToken')
}, () => ({ username }))
await apiStep('account-security-recovery', 'restore owner password after interrupted test', {
method: 'PUT', path: '/api/auth/password', token: login.json.data.accessToken,
body: { oldPassword: temporaryOwnerPassword, newPassword: clientPassword },
}, ({ json }) => requireSuccess(json), () => ({ username, passwordRestored: true }))
ownerPasswordNeedsRestore = false
temporaryOwnerPassword = undefined
} catch (error) {
report.fatal ??= sanitizeError(error)
report.passwordRecovery = { success: false, error: sanitizeError(error) }
}
}
async function loadOrderMasterData(token, module) {
const receivingWarehouses = await apiStep(module, 'load active domestic receiving warehouses', {
method: 'GET', path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', token,
}, ({ json }) => {
requireSuccess(json)
assertTrue(pageRecords(json.data).length > 0, 'at least one active DOMESTIC warehouse is required')
}, ({ json }) => ({ total: json.data?.total, warehouseCount: pageRecords(json.data).length }))
const receivingCandidates = pageRecords(receivingWarehouses.json.data)
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]
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')
report.resources.receivingWarehouseId = receiving.id
report.resources.destinationWarehouseId = destination.id
await apiStep(module, 'load receiving warehouse detail', {
method: 'GET', path: `/api/merchant/v1/warehouses/${receiving.id}`, token,
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.warehouse?.warehouseType, 'DOMESTIC', 'receiving warehouse type')
assertTrue(arrayData(json.data?.addresses).length > 0, 'receiving warehouse address is required')
}, ({ json }) => ({ id: json.data.warehouse.id, name: json.data.warehouse.warehouseName }))
const channels = await apiStep(module, 'load active pricing channels', {
method: 'GET', path: `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${receiving.id}`, token,
}, ({ json }) => {
requireSuccess(json)
assertTrue(arrayData(json.data).length > 0, 'at least one active pricing channel is required')
}, ({ json }) => ({ channels: arrayData(json.data).map((item) => ({ channelCode: item.channelCode, priceModel: item.priceModel, pricingTaxModes: item.pricingTaxModes })) }))
const channel = pricedChannels.find((item) => arrayData(item.pricingTaxModes).length > 0)
assertPresent(channel, 'pricing channel with pricingTaxModes')
report.resources.channelCode = channel.channelCode
const partners = await apiStep(module, 'load public shipper candidates', {
method: 'GET', path: '/api/merchant/v1/partners/candidates?type=SHIPPER', token,
}, ({ json }) => {
requireSuccess(json)
}, ({ json }) => ({ candidateCount: arrayData(json.data).length }))
const partner = arrayData(partners.json.data).find((item) => item.status === 'ACTIVE' && arrayData(item.types).includes('SHIPPER'))
return {
receiving,
destination,
destinationCandidates: pageRecords(destinationWarehouses.json.data),
channel,
channelCandidates: pricedChannels,
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',
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,
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, content.toString('utf8'))
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) {
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 header = Buffer.alloc(30)
header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4); header.writeUInt16LE(0x800, 6)
header.writeUInt32LE(crc, 14); header.writeUInt32LE(data.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.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24)
central.writeUInt16LE(filename.length, 28); central.writeUInt32LE(offset, 42)
local.push(header, filename, data); directory.push(central, filename)
offset += header.length + filename.length + data.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] }
}
// 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 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.',
]
}
async function apiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
const startedAt = new Date().toISOString()
const started = Date.now()
let response
try {
response = await httpRequest(request)
validate(response)
const step = makeStep(module, name, true, startedAt, started, response, evidenceBuilder(response))
report.steps.push(step)
console.log(`PASS [${module}] ${name}`)
return response
} catch (error) {
let evidence = null
if (response) {
try {
evidence = evidenceBuilder(response)
} catch {
evidence = defaultEvidence(response)
}
}
const step = makeStep(module, name, false, startedAt, started, response, evidence)
step.error = sanitizeError(error)
report.steps.push(step)
console.error(`FAIL [${module}] ${name}: ${error.message}`)
throw error
}
}
async function softApiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
try {
return await apiStep(module, name, request, validate, evidenceBuilder)
} catch {
return null
}
}
async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) {
const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers }
if (token) requestHeaders.Authorization = `Bearer ${token}`
let requestBody = body
if (body != null && !(body instanceof FormData)) {
requestHeaders['Content-Type'] = 'application/json'
requestBody = JSON.stringify(body)
}
const response = await fetch(`${gateway}${path}`, {
method,
headers: requestHeaders,
body: requestBody,
signal: AbortSignal.timeout(timeoutMs),
})
const result = { status: response.status, headers: response.headers, json: null, bytes: null, text: null }
if (responseType === 'bytes') {
result.bytes = new Uint8Array(await response.arrayBuffer())
return result
}
const text = await response.text()
result.text = text
if (text) {
try {
result.json = JSON.parse(text)
} catch {
throw new Error(`Expected JSON but received ${text.slice(0, 200)}`)
}
}
return result
}
function makeStep(module, name, passed, startedAt, started, response, evidence) {
return {
module,
name,
passed,
skipped: false,
startedAt,
durationMs: Date.now() - started,
http: response?.status ?? null,
code: response?.json?.code == null ? null : String(response.json.code),
message: response?.json?.message ?? null,
requestId: headerOrBody(response, 'x-request-id', 'requestId'),
traceId: headerOrBody(response, 'x-trace-id', 'traceId'),
evidence,
}
}
function recordSkipped(module, name, reason) {
report.steps.push({
module, name, passed: true, skipped: true, startedAt: new Date().toISOString(), durationMs: 0,
http: null, code: null, message: reason, requestId: null, traceId: null, evidence: null,
})
console.log(`SKIP [${module}] ${name}: ${reason}`)
}
function requireSuccess(json) {
assertPresent(json, 'response body')
assertEqual(String(json.code), '200', 'business code')
}
function assertCargo(actual, expected) {
assertPresent(actual, 'cargo detail')
const exactFields = [
'skuNo', 'cargoName', 'cargoNameEn', 'hsCode', 'customsHsCode', 'packageType', 'unitsPerPackage',
'packageCount', 'totalQuantity', 'currency', 'material', 'model', 'brand', 'shipmentId',
'referenceId', 'upcNo', 'cargoRemark',
]
for (const field of exactFields) assertEqual(String(actual[field]), String(expected[field]), `cargo.${field}`)
assertEqual(String(actual.productUsage), String(expected.usage), 'cargo.productUsage')
const decimalFields = [
'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm', 'netWeightKg',
'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm',
]
for (const field of decimalFields) assertEqual(Number(actual[field]), Number(expected[field]), `cargo.${field}`)
assertEqual(String(actual.productImageFileId), String(expected.productImageFileId), 'cargo.productImageFileId')
}
function assertQualificationStatuses(values, requiredTypes, expectedStatus) {
const rows = arrayData(values)
for (const type of requiredTypes) {
const item = rows.find((row) => row.qualificationType === type && row.status === expectedStatus)
assertPresent(item, `${type} qualification`)
}
}
function qualificationEvidence(values) {
return arrayData(values).map((item) => ({
qualificationId: item.id ?? item.qualificationId,
qualificationType: item.qualificationType,
status: item.status,
}))
}
function cargoEvidence(cargo) {
if (!cargo) return null
return {
id: cargo.id,
skuNo: cargo.skuNo,
cargoName: cargo.cargoName,
cargoNameEn: cargo.cargoNameEn,
packageCount: cargo.packageCount,
totalQuantity: cargo.totalQuantity,
grossWeightKg: cargo.grossWeightKg,
volumeCbm: cargo.volumeCbm,
shipmentId: cargo.shipmentId,
referenceId: cargo.referenceId,
productImageFileId: cargo.productImageFileId,
}
}
function defaultEvidence({ json }) {
if (!json || json.data == null) return null
if (typeof json.data !== 'object') return { data: json.data }
return null
}
function headerOrBody(response, headerName, bodyName) {
return response?.headers?.get(headerName)
?? response?.json?.[bodyName]
?? response?.json?.data?.[bodyName]
?? null
}
function pageRecords(data) {
if (Array.isArray(data)) return data
return arrayData(data?.records ?? data?.items ?? data?.content)
}
function arrayData(value) {
return Array.isArray(value) ? value : []
}
function assertEqual(actual, expected, label) {
if (actual !== expected) throw new Error(`${label}: expected=${format(expected)} actual=${format(actual)}`)
}
function assertPresent(value, label) {
if (value == null || value === '') throw new Error(`${label} is required`)
}
function assertTrue(value, message) {
if (!value) throw new Error(message)
}
function assertSetEqual(actual, expected, label) {
const actualValues = [...new Set(actual)].sort()
const expectedValues = [...new Set(expected)].sort()
assertEqual(JSON.stringify(actualValues), JSON.stringify(expectedValues), label)
}
function requiredOwnerBackendPermissions() {
return [
'merchant:order:list', 'merchant:order:view', 'merchant:order:create', 'merchant:order:edit',
'merchant:order:submit', 'merchant:order:cancel', 'merchant:profile:view', 'merchant:profile:edit',
'merchant:qualification:upload', 'merchant:member:list', 'merchant:member:create',
'merchant:member:edit', 'merchant:member:grant-role', 'merchant:member:status',
'merchant:member:reset-password', 'merchant:role:list', 'merchant:role:create',
'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete', 'merchant:role:grant',
'merchant:notification:view', 'merchant:notification:read', 'merchant:notification:read-all',
]
}
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')
if (permissions.has('merchant:notification:view')) menus.push('notification:inbox:view')
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))
}