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