#!/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, deflateRawSync } 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: [], supplementalAcceptance: [{ script: 'client-phase2-test-env-acceptance.mjs', scope: ['measurement', 'ocr', 'mojia', 'billing'], reason: '当前入库测量与计费客户能力由专项入口验收;本报告不将其计为已验证。', }], } let merchantToken let merchantRefreshToken let platformToken let existingClientToken let orderAcceptanceFixture let ownerPasswordNeedsRestore = false let temporaryOwnerPassword try { await registrationModule() await platformReviewerLogin() await merchantBeforeApprovalModule() await qualificationAndReviewModule() await merchantAfterApprovalModule() await profileManagementModule() await loginIdentifierModule() await rbacAndMemberModule() await orderLifecycleModule() await isolationModule() await orderSubmissionBalanceGateModule() await orderMutationEvidenceModule() await orderExcelImportModule() await orderSubmissionChannelGateModule() await merchantFinanceBillsModule() await merchantOverseasWarehouseModule() await merchantCrossBorderModule() await merchantWarehouseOrdersModule() await merchantPricingCustomsModule() 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 })) const qualificationBefore = await ownedOrderSnapshot('order-before-approval', 'freeze pre-approval submit version', orderId) const qualificationFulfillmentBefore = await submissionGateFulfillmentSnapshot(qualificationBefore.order, 'before qualification rejection') await apiStep('order-before-approval', 'submit is blocked before approval', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, ...orderMutationCommand(qualificationBefore, {}, newIdempotencyKey('pre-approval-submit')), }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0403', 'business code') }, ({ json }) => ({ orderId, rejectionCode: json.code })) await ownedOrderSnapshot('order-before-approval', 'qualification rejection preserves complete order facts', orderId, qualificationBefore) assertOrderUnchanged(qualificationFulfillmentBefore, await submissionGateFulfillmentSnapshot(qualificationBefore.order, 'after qualification rejection')) report.qualificationRejectionEvidence = { orderId, orderSha256: orderDigest(qualificationBefore), fulfillmentSha256: orderDigest(qualificationFulfillmentBefore) } } async function platformReviewerLogin() { const platformLogin = await apiStep('qualification-review', 'platform reviewer login', { method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'platform accessToken') }, () => ({ username: platformUsername })) platformToken = platformLogin.json.data.accessToken } 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 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 merchantFinanceBillsModule() { const module = 'merchant-bills' const base = '/api/client/finance/v1' const customerId = String(report.resources.tenantId) const requireArray = (json, label) => { requireSuccess(json) assertTrue(Array.isArray(json.data), `${label} must be an array`) return json.data } const bills = await apiStep(module, 'customer bills ignore caller-supplied customer scope', { path: `${base}/bills?customerId=0&tenantId=0`, token: merchantToken, }, ({ json }) => { const rows = requireArray(json, 'bills') for (const row of rows) { assertEqual(String(row.customerId), customerId, 'bill owner must be the authenticated customer') assertPresent(row.billNo, 'billNo') assertPresent(row.customerName, 'customerName') assertTrue(['ISSUED', 'PARTIALLY_PAID', 'PAID'].includes(row.status), `unexpected visible bill status ${row.status}`) assertTrue(Number.isSafeInteger(Number(row.version)), 'bill version required') assertTrue(['NOT_REQUIRED', 'PENDING_CONFIRMATION', 'CONFIRMED'].includes(row.customerConfirmationStatus), 'customer confirmation status required') } }, ({ json }) => ({ customerId, count: json.data?.length ?? null, scope: 'authenticated-enterprise-only' })) const statements = await apiStep(module, 'customer statements expose the same visible bills', { path: `${base}/statements`, token: merchantToken, }, ({ json }) => { const rows = requireArray(json, 'statements') assertEqual(rows.length, bills.json.data.length, 'bills and statements must expose the same visible set') for (const row of rows) assertEqual(String(row.customerId), customerId, 'statement owner must be the authenticated customer') }, ({ json }) => ({ count: json.data?.length ?? null })) await apiStep(module, 'customer receipts are confirmed-only and customer-scoped', { path: `${base}/receipts`, token: merchantToken, }, ({ json }) => { const rows = requireArray(json, 'receipts') for (const row of rows) { assertPresent(row.receiptNo, 'receiptNo') assertTrue(Number.isFinite(Number(row.receivedAmount)), 'receipt amount required') assertEqual(row.status, 'CONFIRMED', 'only confirmed receipts are customer-visible') } }, ({ json }) => ({ count: json.data?.length ?? null })) await apiStep(module, 'customer receivable settlements are readable', { path: `${base}/receivable-settlements`, token: merchantToken, }, ({ json }) => { const rows = requireArray(json, 'receivable settlements') for (const row of rows) { assertPresent(row.settlementNo, 'settlementNo') assertEqual(String(row.customerId), customerId, 'settlement owner must be the authenticated customer') assertTrue(Number.isFinite(Number(row.receivableAmount)), 'receivable amount required') } }, ({ json }) => ({ count: json.data?.length ?? null })) await apiStep(module, 'unknown bill id is rejected without cross-customer disclosure', { path: `${base}/bills/999999999999999`, token: merchantToken, }, ({ json, status }) => { assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) assertTrue(String(json?.code) !== '200', `unknown bill must not succeed, code=${json?.code}`) }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) await apiStep(module, 'unknown statement confirmation is rejected with idempotency key', { method: 'POST', path: `${base}/statements/999999999999999/confirm`, token: merchantToken, headers: { 'Idempotency-Key': newIdempotencyKey('bill-confirm-negative') }, body: { expectedVersion: 1 }, }, ({ json, status }) => { assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) assertTrue(String(json?.code) !== '200', `unknown statement must not confirm, code=${json?.code}`) }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) if (existingClientToken) { await apiStep(module, 'cross-customer bill detail stays outside the other enterprise', { path: `${base}/bills/999999999999999`, token: existingClientToken, }, ({ json, status }) => { assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) assertTrue(String(json?.code) !== '200', 'another enterprise bill must not be readable') }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) } else { recordSkipped(module, 'cross-customer bill detail stays outside the other enterprise', 'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check') } report.merchantFinanceBills = { customerId, bills: bills.json.data.length, statements: statements.json.data.length, scope: 'read-only; statement confirmation with real issued bills is covered when Admin finance chain runs first', } } async function merchantOverseasWarehouseModule() { const module = 'merchant-overseas-warehouse' const base = '/api/merchant/v1/last-leg' const page = 'page=1&size=20' const listStep = async (label, path, validateRow) => { const response = await apiStep(module, label, { path, token: merchantToken }, ({ json }) => { requireSuccess(json) assertTrue(Array.isArray(json.data?.records), `${label} records must be an array`) assertTrue(Number.isFinite(Number(json.data?.total)), `${label} total required`) for (const row of json.data.records) validateRow?.(row) }, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null })) return response.json.data } const warehouseResponse = await apiStep(module, 'customer reads active overseas warehouses', { path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS', token: merchantToken, }, ({ json }) => { requireSuccess(json) assertTrue(Array.isArray(json.data?.records) && json.data.records.length > 0, 'at least one active overseas warehouse required') assertTrue(json.data.records.every((row) => row.status === 'ACTIVE' && row.warehouseType === 'OVERSEAS'), 'customer warehouse list must contain only active overseas warehouses') }, ({ json }) => ({ count: json.data?.records?.length ?? null })) const warehouse = warehouseResponse.json.data.records[0] await listStep('return forecasts are customer-scoped', `${base}/return-orders?${page}`, (row) => { assertPresent(row.returnNo, 'returnNo') assertPresent(row.status, 'return status') }) await listStep('relabel applications are customer-scoped', `${base}/relabel-applications?${page}`, (row) => { assertPresent(row.relabelNo, 'relabelNo') assertTrue(Number.isFinite(Number(row.totalPackageCount)), 'relabel package count required') }) await listStep('warehouse transfers are customer-scoped', `${base}/warehouse-transfers?${page}`, (row) => { assertPresent(row.transferNo, 'transferNo') assertPresent(row.status, 'transfer status') }) await listStep('cargo discards are customer-scoped', `${base}/cargo-discards?${page}`, (row) => { assertPresent(row.discardNo, 'discardNo') assertTrue(Number.isFinite(Number(row.totalPackages)), 'discard package count required') }) await listStep('return inventory is customer-scoped', `${base}/return-inventory?${page}`, (row) => { assertPresent(row.cartonMark, 'cartonMark') assertTrue(Number.isFinite(Number(row.quantity)), 'return inventory quantity required') }) await listStep('return inventory summary is customer-scoped', `${base}/return-inventory/summary?${page}`, (row) => { assertPresent(row.skuCode, 'summary skuCode') assertTrue(Number.isFinite(Number(row.availableQuantity)), 'available quantity required') }) await listStep('return cartons are customer-scoped', `${base}/return-inventory/cartons?${page}`, (row) => { assertPresent(row.cartonMark, 'cartonMark') assertPresent(row.status, 'carton status') }) const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-20) const sku = `ACCEPT-${suffix}` const createPayload = { overseasWarehouseId: String(warehouse.id), warehouseContactName: '验收联系人', warehouseContactPhone: '13800000000', customerReferenceNo: `RET-${suffix}`, needRelabel: false, remark: `${marker} client overseas self-service acceptance`, idempotencyKey: newIdempotencyKey('return-order'), items: [{ skuCode: sku, productNameZh: '验收测试货物', expectedQuantity: 1 }], } const created = await apiStep(module, 'create return forecast with marker-owned payload', { method: 'POST', path: `${base}/return-orders`, token: merchantToken, body: createPayload, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.order?.returnNo, 'returnNo') assertEqual(String(json.data.order.overseasWarehouseId), String(warehouse.id), 'return order warehouse') assertTrue(Array.isArray(json.data.items) && json.data.items.length === 1 && json.data.items[0].skuCode === sku && Number(json.data.items[0].expectedQuantity) === 1, 'return order items echo the marker payload') }, ({ json }) => ({ returnNo: json.data?.order?.returnNo ?? null, status: json.data?.order?.status ?? null })) const returnNo = created.json.data.order.returnNo report.resources.returnOrderNos = [returnNo] await apiStep(module, 'same idempotency key replays the same return order', { method: 'POST', path: `${base}/return-orders`, token: merchantToken, body: createPayload, }, ({ json }) => { requireSuccess(json) assertEqual(json.data?.order?.returnNo, returnNo, 'replayed returnNo must match the original') }, ({ json }) => ({ returnNo: json.data?.order?.returnNo ?? null })) await apiStep(module, 'return order detail keeps the marker payload', { path: `${base}/return-orders/${encodeURIComponent(returnNo)}`, token: merchantToken, }, ({ json }) => { requireSuccess(json) assertEqual(json.data?.order?.returnNo, returnNo, 'detail returnNo') assertTrue(Array.isArray(json.data.items) && json.data.items.some((item) => item.skuCode === sku), 'detail keeps the submitted sku') }, ({ json }) => ({ returnNo: json.data?.order?.returnNo ?? null, items: json.data?.items?.length ?? null })) await apiStep(module, 'return order appears in the customer list', { path: `${base}/return-orders?${page}`, token: merchantToken, }, ({ json }) => { requireSuccess(json) assertTrue(json.data.records.some((row) => String(row.returnNo) === String(returnNo)), 'new return order must appear in the first customer page') }, ({ json }) => ({ total: json.data?.total ?? null, returnNo })) await apiStep(module, 'unknown return order is rejected', { path: `${base}/return-orders/${encodeURIComponent(`RET-MISSING-${suffix}`)}`, token: merchantToken, }, ({ json, status }) => { assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) assertTrue(String(json?.code) !== '200', `unknown return order must not succeed, code=${json?.code}`) }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) if (existingClientToken) { await apiStep(module, 'cross-customer return order is rejected', { path: `${base}/return-orders/${encodeURIComponent(returnNo)}`, token: existingClientToken, }, ({ json, status }) => { assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`) assertTrue(String(json?.code) !== '200', 'another enterprise return order must not be readable') }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) } else { recordSkipped(module, 'cross-customer return order is rejected', 'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check') } report.overseasWarehouseReadOnlyGaps = [ 'Relabel, transfer and cargo-discard creation require inventory produced by the platform overseas PDA chain; this run only covers their customer-scoped lists and the return forecast write chain.', 'Return relabel creation, completion confirmation and document downloads require received return cartons produced by the PDA return receipt chain.', ] } async function merchantCrossBorderModule() { const module = 'merchant-cross-border' const truckPath = '/api/merchant/v1/last-leg/us-truck-orders' const looseboardPath = '/api/merchant/v1/last-leg/local-looseboard-orders' const procurementPath = '/api/merchant/v1/last-leg/overseas-procurements' const page = 'page=1&size=20' const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-18) const localTime = (days) => { const date = new Date(Date.now() + days * 86_400_000) return date.toISOString().slice(0, 19) } const listStep = async (label, path, validateRow) => apiStep(module, label, { path, token: merchantToken }, ({ json }) => { requireSuccess(json) assertTrue(Array.isArray(json.data?.records), `${label} records must be an array`) assertTrue(Number.isFinite(Number(json.data?.total)), `${label} total required`) for (const row of json.data.records) validateRow?.(row) }, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null })) await listStep('customer procurement orders are customer-scoped', `${procurementPath}?${page}`, (row) => { assertPresent(row.orderNo, 'procurement orderNo') assertPresent(row.status, 'procurement status') }) await listStep('customer US truck orders are customer-scoped', `${truckPath}?${page}`, (row) => { assertPresent(row.orderNo, 'truck orderNo') assertPresent(row.status, 'truck status') }) await listStep('customer local looseboard orders are customer-scoped', `${looseboardPath}?${page}`, (row) => { assertPresent(row.orderNo, 'looseboard orderNo') assertPresent(row.status, 'looseboard status') }) const truckPayload = { vehicleType: 'PALLET_15', origin: `验收起点-${suffix}`, destination: `验收终点-${suffix}`, packageCount: 2, totalWeightKg: 12.5, totalVolumeCbm: 0.8, budgetAmount: 100, currency: 'USD', originContactName: '验收发货联系人', originContactPhone: '13800000001', destinationContactName: '验收收货联系人', destinationContactPhone: '13800000002', pickupAt: localTime(2), deliveryAt: localTime(3), remark: `${marker} client US truck acceptance`, idempotencyKey: newIdempotencyKey('us-truck'), } const truck = await apiStep(module, 'customer creates a US truck order', { method: 'POST', path: truckPath, token: merchantToken, body: truckPayload, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.summary?.orderNo, 'truck orderNo') assertTrue(['PENDING_REVIEW', 'SUBMITTED', 'DRAFT'].includes(json.data.summary.status), `unexpected truck status ${json.data.summary.status}`) assertTrue(Number(json.data.summary.version) >= 0, 'truck version required') }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null, status: json.data?.summary?.status ?? null })) const truckNo = truck.json.data.summary.orderNo report.resources.crossBorderOrderNos = [truckNo] await apiStep(module, 'same idempotency key replays the same US truck order', { method: 'POST', path: truckPath, token: merchantToken, body: truckPayload, }, ({ json }) => { requireSuccess(json) assertEqual(json.data?.summary?.orderNo, truckNo, 'replayed truck orderNo') }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null })) await apiStep(module, 'US truck order detail keeps the marker payload', { path: `${truckPath}/${encodeURIComponent(truckNo)}`, token: merchantToken, }, ({ json }) => { requireSuccess(json) assertEqual(json.data?.summary?.orderNo, truckNo, 'truck detail orderNo') assertEqual(json.data?.summary?.vehicleType, 'PALLET_15', 'truck vehicle type') assertEqual(Number(json.data?.summary?.packageCount), 2, 'truck package count') }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null })) const looseboardPayload = { origin: `散板起点-${suffix}`, destination: `散板终点-${suffix}`, productName: '验收散板货物', estimatedPalletCount: 1, packageCount: 3, totalWeightKg: 20, totalVolumeCbm: 1.2, budgetAmount: 80, currency: 'USD', originContactName: '验收发货联系人', originContactPhone: '13800000001', destinationContactName: '验收收货联系人', destinationContactPhone: '13800000002', pickupAt: localTime(2), deliveryAt: localTime(4), remark: `${marker} client looseboard acceptance`, idempotencyKey: newIdempotencyKey('looseboard'), } const looseboard = await apiStep(module, 'customer creates a local looseboard order', { method: 'POST', path: looseboardPath, token: merchantToken, body: looseboardPayload, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.summary?.orderNo, 'looseboard orderNo') assertTrue(Number(json.data.summary.estimatedPalletCount) === 1, 'looseboard pallet count') }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null, status: json.data?.summary?.status ?? null })) const looseboardNo = looseboard.json.data.summary.orderNo report.resources.crossBorderOrderNos.push(looseboardNo) await apiStep(module, 'same idempotency key replays the same looseboard order', { method: 'POST', path: looseboardPath, token: merchantToken, body: looseboardPayload, }, ({ json }) => { requireSuccess(json) assertEqual(json.data?.summary?.orderNo, looseboardNo, 'replayed looseboard orderNo') }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null })) const procurementPayload = { customerName: companyName, purchaseCategory: '验收测试', productName: `验收采购样品-${suffix}`, productSpecification: '1 件/箱', purchaseQuantity: 1, budgetUnitPrice: 10, currency: 'CNY', receivingAddress: '广东省深圳市南山区验收路 1 号', contactName: '验收采购联系人', contactPhone: '13800000003', remark: `${marker} client procurement acceptance`, idempotencyKey: newIdempotencyKey('procurement'), } const procurement = await apiStep(module, 'customer creates an overseas procurement order', { method: 'POST', path: procurementPath, token: merchantToken, body: procurementPayload, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.order?.orderNo, 'procurement orderNo') assertTrue(Number(json.data.order.purchaseQuantity) === 1, 'procurement quantity') }, ({ json }) => ({ orderNo: json.data?.order?.orderNo ?? null, status: json.data?.order?.status ?? null })) const procurementNo = procurement.json.data.order.orderNo report.resources.crossBorderOrderNos.push(procurementNo) await apiStep(module, 'same idempotency key replays the same procurement order', { method: 'POST', path: procurementPath, token: merchantToken, body: procurementPayload, }, ({ json }) => { requireSuccess(json) assertEqual(json.data?.order?.orderNo, procurementNo, 'replayed procurement orderNo') }, ({ json }) => ({ orderNo: json.data?.order?.orderNo ?? null })) await apiStep(module, 'created cross-border orders appear in customer lists', { path: `${truckPath}?${page}`, token: merchantToken, }, ({ json }) => { requireSuccess(json) assertTrue(json.data.records.some((row) => String(row.orderNo) === String(truckNo)), 'new truck order must appear in the customer list') }, ({ json }) => ({ total: json.data?.total ?? null, truckNo })) for (const [label, path] of [['truck', truckPath], ['looseboard', looseboardPath], ['procurement', procurementPath]]) { await apiStep(module, `unknown ${label} order is rejected`, { path: `${path}/${encodeURIComponent(`CB-MISSING-${suffix}`)}`, token: merchantToken, }, ({ json, status }) => { assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) assertTrue(String(json?.code) !== '200', `unknown ${label} order must not succeed, code=${json?.code}`) }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) } if (existingClientToken) { await apiStep(module, 'cross-customer truck order is rejected', { path: `${truckPath}/${encodeURIComponent(truckNo)}`, token: existingClientToken, }, ({ json, status }) => { assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`) assertTrue(String(json?.code) !== '200', 'another enterprise truck order must not be readable') }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) } else { recordSkipped(module, 'cross-customer truck order is rejected', 'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check') } report.crossBorderLimitations = [ 'Customer creation stops at the submitted/pending platform review state; platform review, pricing, purchase execution, receipts and delivery are covered by Admin/fleet specialty entries.', ] } async function merchantWarehouseOrdersModule() { const module = 'merchant-warehouse-orders' const base = '/api/merchant/v1/last-leg' const page = 'page=1&size=20' const inbound = await apiStep(module, 'customer inbound plans are customer-scoped', { path: `${base}/inbound-plans?${page}`, token: merchantToken, }, ({ json }) => { requireSuccess(json) assertTrue(Array.isArray(json.data?.records) && Number.isFinite(Number(json.data?.total)), 'inbound plan page required') for (const row of json.data.records) { assertPresent(row.planNo, 'planNo') assertPresent(row.inboundCode, 'inboundCode') assertTrue(Number.isFinite(Number(row.packageCount)), 'inbound packageCount') assertPresent(row.status, 'inbound status') } }, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null })) const delivery = await apiStep(module, 'customer delivery orders are customer-scoped', { path: `${base}/delivery-orders?${page}`, token: merchantToken, }, ({ json }) => { requireSuccess(json) assertTrue(Array.isArray(json.data?.records) && Number.isFinite(Number(json.data?.total)), 'delivery page required') for (const row of json.data.records) { assertPresent(row.deliveryNo, 'deliveryNo') assertPresent(row.dispatchNo, 'dispatchNo') assertPresent(row.status, 'delivery status') assertTrue(Number.isFinite(Number(row.packageCount)), 'delivery packageCount') } }, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null })) const planNo = inbound.json.data.records[0]?.planNo if (planNo) { await apiStep(module, 'inbound plan detail keeps plan identity and lines', { path: `${base}/inbound-plans/${encodeURIComponent(planNo)}`, token: merchantToken, }, ({ json }) => { requireSuccess(json) assertEqual(json.data?.planNo, planNo, 'planNo') assertPresent(json.data?.status, 'plan status') assertTrue(Array.isArray(json.data?.lines) && Array.isArray(json.data?.pallets), 'plan lines and pallets required') }, ({ json }) => ({ planNo: json.data?.planNo ?? null, lines: json.data?.lines?.length ?? null })) } else { recordSkipped(module, 'inbound plan detail keeps plan identity and lines', 'this run customer has no inbound plan yet') } const deliveryNo = delivery.json.data.records[0]?.deliveryNo if (deliveryNo) { await apiStep(module, 'delivery order detail keeps customer-visible fees and skus', { path: `${base}/delivery-orders/${encodeURIComponent(deliveryNo)}?page=1&size=200`, token: merchantToken, }, ({ json }) => { requireSuccess(json) assertEqual(json.data?.order?.deliveryNo, deliveryNo, 'deliveryNo') assertTrue(Array.isArray(json.data?.skus) && Array.isArray(json.data?.fees), 'delivery skus and fees required') assertPresent(json.data?.order?.status, 'delivery status') }, ({ json }) => ({ deliveryNo: json.data?.order?.deliveryNo ?? null, skus: json.data?.skus?.length ?? null })) } else { recordSkipped(module, 'delivery order detail keeps customer-visible fees and skus', 'this run customer has no delivery order yet') } for (const [label, path] of [['inbound plan', `${base}/inbound-plans`], ['delivery order', `${base}/delivery-orders`]]) { await apiStep(module, `unknown ${label} is rejected`, { path: `${path}/${encodeURIComponent(`WO-MISSING-${marker}`)}`, token: merchantToken, }, ({ json, status }) => { assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) assertTrue(String(json?.code) !== '200', `unknown ${label} must not succeed, code=${json?.code}`) }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) } } async function merchantPricingCustomsModule() { const module = 'merchant-pricing-customs' const warehouseResponse = await apiStep(module, 'customer reads active warehouses for pricing', { path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE', token: merchantToken, }, ({ json }) => { requireSuccess(json) assertTrue(Array.isArray(json.data?.records) && json.data.records.length > 0, 'active warehouse required') assertTrue(json.data.records.every((row) => row.status === 'ACTIVE'), 'only active warehouses allowed') }, ({ json }) => ({ count: json.data?.records?.length ?? null })) const domestic = warehouseResponse.json.data.records.find((row) => row.warehouseType !== 'OVERSEAS') if (domestic) { await apiStep(module, 'customer reads active pricing channels', { path: `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(String(domestic.id))}`, token: merchantToken, }, ({ json }) => { requireSuccess(json) assertTrue(Array.isArray(json.data), 'pricing channels must be an array') for (const row of json.data) { assertPresent(row.channelCode, 'channelCode') assertPresent(row.priceModel, 'priceModel') assertTrue(Array.isArray(row.pricingTaxModes), 'pricingTaxModes required') assertTrue(Number.isFinite(Number(row.priceBookVersionId)), 'priceBookVersionId required') } }, ({ json }) => ({ count: json.data?.length ?? null, warehouseId: String(domestic.id) })) } else { recordSkipped(module, 'customer reads active pricing channels', 'this customer has no domestic origin warehouse') } await apiStep(module, 'customer customs pending summary is readable', { path: '/api/merchant/v1/customs/pending-summary', token: merchantToken, }, ({ json }) => { requireSuccess(json) assertTrue(Number.isFinite(Number(json.data?.pendingCount)), 'pendingCount required') assertTrue(Number(json.data.pendingCount) >= 0, 'pendingCount must be non-negative') }, ({ json }) => ({ pendingCount: json.data?.pendingCount ?? null })) const orderId = report.resources.orderIds[0] if (orderId) { await apiStep(module, 'order customs documents stay customer-scoped', { path: `/api/merchant/v1/customs/orders/${encodeURIComponent(String(orderId))}`, token: merchantToken, }, ({ json, status }) => { assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) if (String(json?.code) === '200') { assertTrue(json.data && typeof json.data === 'object', 'customs payload required') } else { assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `unexpected customs code ${json?.code}`) } }, ({ json, status }) => ({ http: status, code: json?.code ?? null, orderId: String(orderId) })) } else { recordSkipped(module, 'order customs documents stay customer-scoped', 'no current-run order id') } } 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, /\.(xml|rels)$/i.test(name) ? content.toString('utf8') : Buffer.from(content)) offset += 46 + nameLength + input.readUInt16LE(offset + 30) + input.readUInt16LE(offset + 32) } return entries } function orderCrc(bytes) { let crc = 0xffffffff for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0) } return (crc ^ 0xffffffff) >>> 0 } function orderZipWrite(entries, compress = false) { const local = [], directory = [] let offset = 0 for (const [name, text] of entries) { const filename = Buffer.from(name), data = Buffer.from(text), crc = orderCrc(data) const packed = compress ? deflateRawSync(data) : data const header = Buffer.alloc(30) header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4); header.writeUInt16LE(0x800, 6) header.writeUInt16LE(compress ? 8 : 0, 8) header.writeUInt32LE(crc, 14); header.writeUInt32LE(packed.length, 18); header.writeUInt32LE(data.length, 22); header.writeUInt16LE(filename.length, 26) const central = Buffer.alloc(46) central.writeUInt32LE(0x02014b50); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x800, 8) central.writeUInt16LE(compress ? 8 : 0, 10) central.writeUInt32LE(crc, 16); central.writeUInt32LE(packed.length, 20); central.writeUInt32LE(data.length, 24) central.writeUInt16LE(filename.length, 28); central.writeUInt32LE(offset, 42) local.push(header, filename, packed); directory.push(central, filename) offset += header.length + filename.length + packed.length } const central = Buffer.concat(directory), end = Buffer.alloc(22) end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.size, 8); end.writeUInt16LE(entries.size, 10) end.writeUInt32LE(central.length, 12); end.writeUInt32LE(offset, 16) return Buffer.concat([...local, central, end]) } function orderXmlDecode(text) { return text.replace(/&#(x[0-9a-f]+|[0-9]+);|&(lt|gt|quot|apos|amp);/gi, (match, code, named) => code ? String.fromCodePoint(code[0].toLowerCase() === 'x' ? parseInt(code.slice(1), 16) : Number(code)) : ({ lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[named.toLowerCase()]) } function orderXmlEncode(text) { return String(text).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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(/]*>([\s\S]*?)<\/si>/g)].map(item => [...item[1].matchAll(/]*>([\s\S]*?)<\/t>/g)].map(text => orderXmlDecode(text[1])).join('')) const sheet = entries.get(path) assertPresent(sheet, `template ${path}`) return [...sheet.matchAll(/]*\br="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)].map(row => ({ number: Number(row[1]), cells: [...row[2].matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)].map(cell => { const ref = cell[1].match(/\br="([A-Z]+)\d+"/)?.[1] const content = cell[2] ?? '' const value = content.match(/]*>([\s\S]*?)<\/v>/)?.[1] const text = /\bt="s"/.test(cell[1]) ? strings[Number(value)] : /\bt="inlineStr"/.test(cell[1]) ? [...content.matchAll(/]*>([\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(/]*\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.map((text, col) => `${orderXmlEncode(text)}`).join('')}`).join('') entries.set('xl/worksheets/sheet1.xml', sheet.replace(/]*>[\s\S]*?<\/sheetData>/, `${headers}${data}`)) return orderZipWrite(entries) } function importRollbackRows(validFirst, validSecond) { const invalid = [...validFirst]; invalid[10] = '' // mandatory cargo name: seed only the second row const changed = [...validSecond]; changed[30] += '-changed' // SaveRequest.customerRemark participates in CREATE_DRAFT hash return { seed: [invalid, validSecond], conflict: [validFirst, changed], retry: [validFirst, validSecond] } } function channelGateWorkbook(sourceBytes, reference, channelCode) { const matches = reference.channels.filter(channel => channel.channelCode === channelCode) assertEqual(matches.length, 1, 'one authoritative target channel') const sourceSheetName = matches[0].sourceSheetName assertPresent(sourceSheetName, 'target channel source sheet') const removedCodes = reference.channels.filter(channel => channel.sourceSheetName === sourceSheetName).map(channel => channel.channelCode) assertTrue(reference.channels.some(channel => !removedCodes.includes(channel.channelCode)), 'B must retain another real channel') const entries = orderZipRead(sourceBytes), workbook = entries.get('xl/workbook.xml') assertTrue(typeof workbook === 'string', 'source workbook XML') let removed = 0 const updated = workbook.replace(/]*\/>/g, element => { const name = element.match(/\bname="([^"]*)"/)?.[1] if (name !== undefined && orderXmlDecode(name) === sourceSheetName) { removed++; return '' } return element }) assertEqual(removed, 1, 'remove exactly the authoritative channel sheet from workbook directory') // Preserve all parts and relationships byte-for-byte. The SAX price importer enumerates only workbook-listed sheets. entries.set('xl/workbook.xml', updated) return { bytes: orderZipWrite(entries, true), sourceSheetName, removedCodes } } function channelGateMappings(detail, reference) { const mapping = (kind, keyField, idField) => { const keys = [...new Set(detail.rates.filter(rate => kind === 'origin' || rate.destinationType === 'OVERSEAS').map(rate => rate[keyField]))] return keys.map(key => { assertPresent(key, `price ${kind} source label`) const ids = [...new Set(reference.rates.filter(rate => (kind === 'origin' || rate.destinationType === 'OVERSEAS') && rate[keyField] === key).map(rate => String(rate[idField] ?? '')))] assertTrue(ids.length === 1 && /^[1-9]\d*$/.test(ids[0]), `unique published ${kind} mapping for ${key}`) return kind === 'origin' ? { originWarehouseCode: key, warehouseId: ids[0] } : { sourceDestinationName: key, warehouseId: ids[0] } }) } assertTrue(Array.isArray(detail.rates) && detail.rates.length > 0, 'nonempty parsed prices required') return { originMappings: mapping('origin','originWarehouseCode','originWarehouseId'), overseasMappings: mapping('overseas','destinationName','destinationWarehouseId') } } // END ORDER ACCEPTANCE HELPERS async function ownedOrderSnapshot(module, name, id, expected, validate = () => {}) { const response = await apiStep(module, name, { path: `/api/merchant/v1/orders/first-leg/${id}`, token: merchantToken, }, ({ json }) => { requireSuccess(json); assertOrderDetail(json.data, id) if (expected) assertOrderUnchanged(expected, json.data) validate(json.data) }, ({ json }) => ({ orderId: String(id), status: json.data?.order?.status, version: json.data?.order?.version, snapshotSha256: orderDigest(json.data) })) return response.json.data } function recordOrderMutationRequest(name, request, before) { report.orderMutationRequests ??= [] report.orderMutationRequests.push({ name, method: request.method, path: request.path, idempotencyKey: request.headers?.['Idempotency-Key'] ?? null, body: structuredClone(request.body), beforeVersion: before.order.version, beforeSnapshotSha256: orderDigest(before) }) } async function rejectOrderMutation(name, id, request, codes, statuses = [200]) { const before = await ownedOrderSnapshot('order-mutation-evidence', `${name}: before`, id) recordOrderMutationRequest(name, request, before) await apiStep('order-mutation-evidence', name, request, ({ json, status }) => { assertTrue(statuses.includes(status), `expected HTTP ${statuses}, actual=${status}`) assertTrue(codes.includes(String(json?.code)), `expected ${codes}, actual=${json?.code}`) }, ({ json }) => ({ orderId: String(id), rejectionCode: json?.code })) await ownedOrderSnapshot('order-mutation-evidence', `${name}: all facts unchanged`, id, before) } async function orderMutationEvidenceModule() { const module = 'order-mutation-evidence', base = '/api/merchant/v1/orders/first-leg' assertPresent(orderAcceptanceFixture, 'completed order lifecycle fixture') assertPresent(existingClientToken, 'independent client session') const payload = structuredClone(orderAcceptanceFixture.payload) payload.customerRemark = `mutation-${marker}` const created = await apiStep(module, 'create owned mutable draft for isolation and cancellation', { method: 'POST', path: base, token: merchantToken, body: payload, headers: { 'Idempotency-Key': newIdempotencyKey('mutation-draft') }, }, ({ json }) => { requireSuccess(json); assertPresent(json.data, 'draft ID') }, ({ json }) => ({ orderId: json.data })) const id = created.json.data report.resources.orderIds.push(id) const original = await ownedOrderSnapshot(module, 'freeze draft page version and request', id) const editPayload = structuredClone(payload); editPayload.customerRemark += '-edited' const editCommand = orderMutationCommand(original, { draft: editPayload }, newIdempotencyKey('versioned-edit')) const editRequest = { method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...editCommand } recordOrderMutationRequest('versioned draft edit', editRequest, original) await apiStep(module, 'versioned draft edit succeeds', editRequest, ({ json }) => requireSuccess(json), () => ({ orderId: id, expectedVersion: original.order.version })) const edited = await ownedOrderSnapshot(module, 'edited draft full snapshot', id, null, data => { assertEqual(data.order.status, 'DRAFT', 'edit stays draft') assertTrue(data.order.version > original.order.version, 'edit increments version') assertEqual(data.order.customerRemark, editPayload.customerRemark, 'edited source remark') for (const field of ['id', 'orderNo', 'inboundCode', 'tenantId', 'customerId']) assertEqual(data.order[field], original.order[field], `stable edit ${field}`) assertEqual(data.cargoes.length, editPayload.cargoes.length, 'complete edited cargo set') data.cargoes.forEach((cargo, index) => { for (const field of ['skuNo', 'packageCount', 'model', 'productImageFileId']) assertEqual(cargo[field], editPayload.cargoes[index][field], `edited cargo ${field}`) }) const oldIds = new Set(original.statusHistory.map(item => String(item.id))) const added = data.statusHistory.filter(item => !oldIds.has(String(item.id))) assertOrderUnchanged(original.statusHistory, data.statusHistory.filter(item => oldIds.has(String(item.id)))) assertEqual(added.length, 1, 'single edit audit'); assertEqual(added[0].action, 'UPDATE', 'edit audit action') }) recordOrderMutationRequest('original edit replay', editRequest, original) await apiStep(module, 'original edit key and frozen payload replay successfully', editRequest, ({ json }) => requireSuccess(json), () => ({ orderId: id, expectedVersion: original.order.version, replayed: true })) await ownedOrderSnapshot(module, 'edit replay adds no version cargo or history', id, edited) const changedEdit = structuredClone(editCommand); changedEdit.body.draft.customerRemark += '-conflict' await rejectOrderMutation('edit original key different payload conflicts', id, { method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...changedEdit, }, ['A0409']) await rejectOrderMutation('old page version cannot overwrite newer draft', id, { method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...orderMutationCommand(original, { draft: payload }, newIdempotencyKey('stale-edit')), }, ['A0409']) for (const operation of ['edit', 'cancel']) { const method = operation === 'edit' ? 'PUT' : 'POST', path = `${base}/${id}${operation === 'edit' ? '' : '/cancel'}` const command = orderMutationCommand(edited, operation === 'edit' ? { draft: editPayload } : { reason: 'missing protocol guard' }, newIdempotencyKey(`missing-${operation}`)) const withoutVersion = structuredClone(command); delete withoutVersion.body.expectedVersion await rejectOrderMutation(`${operation} missing version is rejected`, id, { method, path, token: merchantToken, ...withoutVersion }, ['A0422']) await rejectOrderMutation(`${operation} missing key is rejected`, id, { method, path, token: merchantToken, body: command.body }, ['A0400'], [200, 400]) } await rejectOrderMutation('other customer cannot edit owned draft', id, { method: 'PUT', path: `${base}/${id}`, token: existingClientToken, ...orderMutationCommand(edited, { draft: payload }, newIdempotencyKey('foreign-edit')), }, ['A0403', 'A0404']) await rejectOrderMutation('other customer cannot cancel owned draft', id, { method: 'POST', path: `${base}/${id}/cancel`, token: existingClientToken, ...orderMutationCommand(edited, { reason: `foreign-${marker}` }, newIdempotencyKey('foreign-cancel')), }, ['A0403', 'A0404']) const invalid = structuredClone(payload); invalid.cargoes[0].productImageFileId = -1 await rejectOrderMutation('invalid product image edit rolls back all facts', id, { method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...orderMutationCommand(edited, { draft: invalid }, newIdempotencyKey('invalid-edit')), }, ['A0422']) await rejectOrderMutation('submitted order cannot be overwritten as draft', orderAcceptanceFixture.orderId, { method: 'PUT', path: `${base}/${orderAcceptanceFixture.orderId}`, token: merchantToken, ...orderMutationCommand(await ownedOrderSnapshot(module, 'freeze submitted order edit request', orderAcceptanceFixture.orderId), { draft: payload }, newIdempotencyKey('submitted-edit-evidence')), }, ['A0409']) await rejectOrderMutation('blank cancellation reason preserves all facts', id, { method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...orderMutationCommand(edited, { reason: ' ' }, newIdempotencyKey('empty-reason')), }, ['A0422']) await rejectOrderMutation('old page version cannot cancel updated draft', id, { method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...orderMutationCommand(original, { reason: 'stale page' }, newIdempotencyKey('stale-cancel')), }, ['A0409']) const before = await ownedOrderSnapshot(module, 'before valid cancellation', id) const reason = `F06-cancel-${marker}` const cancelCommand = orderMutationCommand(before, { reason }, newIdempotencyKey('versioned-cancel')) const cancelRequest = { method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...cancelCommand } recordOrderMutationRequest('versioned cancellation', cancelRequest, before) await apiStep(module, 'cancel owned draft with reason', cancelRequest, ({ json }) => requireSuccess(json), () => ({ orderId: id, reason })) await apiStep(module, 'cancellation persists one attributed history and preserves cargo', { path: `${base}/${id}`, token: merchantToken, }, ({ json }) => { requireSuccess(json); assertOrderDetail(json.data, id) assertEqual(json.data.order.status, 'CANCELLED', 'cancelled status') assertTrue(json.data.order.version > before.order.version, 'version increases') for (const field of ['cargoes', 'parties', 'internalNotes']) assertOrderUnchanged(before[field], json.data[field]) for (const field of ['id', 'orderNo', 'inboundCode', 'tenantId', 'customerId']) assertEqual(json.data.order[field], before.order[field], `stable ${field}`) const oldIds = new Set(before.statusHistory.map(item => String(item.id))) const added = json.data.statusHistory.filter(item => !oldIds.has(String(item.id))) assertEqual(added.length, 1, 'one cancellation history') assertOrderUnchanged(before.statusHistory, json.data.statusHistory.filter(item => oldIds.has(String(item.id)))) assertEqual(added[0].action, 'CANCELLED', 'cancellation action'); assertEqual(added[0].reason, reason, 'cancellation reason') assertEqual(added[0].fromStatus, 'DRAFT', 'from draft'); assertEqual(added[0].toStatus, 'CANCELLED', 'to cancelled') assertPresent(added[0].operatorId, 'operator'); assertPresent(added[0].occurredAt, 'server operation time') }, ({ json }) => ({ orderId: id, status: json.data?.order?.status, snapshotSha256: orderDigest(json.data) })) const cancelled = await ownedOrderSnapshot(module, 'freeze cancelled facts before replay', id) recordOrderMutationRequest('original cancellation replay', cancelRequest, before) await apiStep(module, 'original cancellation key payload and version replay successfully', cancelRequest, ({ json }) => requireSuccess(json), () => ({ orderId: id, expectedVersion: before.order.version, replayed: true })) await ownedOrderSnapshot(module, 'cancel replay creates no second history or version', id, cancelled) const changedCancel = structuredClone(cancelCommand); changedCancel.body.reason += '-conflict' await rejectOrderMutation('cancellation original key different reason conflicts', id, { method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...changedCancel, }, ['A0409']) await rejectOrderMutation('repeat cancellation cannot add history', id, { method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...orderMutationCommand(cancelled, { reason }, newIdempotencyKey('new-key-repeat-cancel')), }, ['A0409']) await rejectOrderMutation('cancelled draft cannot be submitted', id, { method: 'POST', path: `${base}/${id}/submit`, token: merchantToken, ...orderMutationCommand(cancelled, {}, newIdempotencyKey('cancelled-submit-proof')), }, ['A0409']) report.orderMutationLimitations = [ 'Requires deployed Merchant edit/cancel versioned idempotent protocol; local contracts do not prove TEST deployment.', 'Irreversible-fulfillment cancellation is not exercised by these draft/submitted fixtures.', ] } async function submissionGateFulfillmentSnapshot(order, name) { const module = 'order-submission-gates', customerId = String(report.resources.tenantId) let task await apiStep(module, `${name}: exact fulfillment task`, { path: `/api/platform/v1/first-leg/tasks/by-order/${order.id}?tenantId=${customerId}`, token: platformToken, }, ({ json, status }) => { assertEqual(status, 200, 'task lookup HTTP') assertTrue(['200','A0404'].includes(String(json?.code)), 'task lookup must succeed or explicitly not exist') task = String(json.code) === 'A0404' ? null : json.data ?? null if (task) { assertEqual(String(task.orderId), String(order.id), 'task source order') assertEqual(String(task.tenantId), customerId, 'task customer') } }, () => ({ orderId: order.id, taskSha256: orderDigest(task) })) const inbounds = [] let total for (let page = 1; ; page++) { await apiStep(module, `${name}: inbound page ${page}`, { path: `/api/platform/inbounds?tenantId=${customerId}&orderNo=${encodeURIComponent(order.orderNo)}&page=${page}&size=200`, token: platformToken, }, ({ json }) => { requireSuccess(json) assertTrue(Array.isArray(json.data?.records), 'inbound records required') assertTrue(Number.isSafeInteger(Number(json.data.total)) && Number(json.data.total) >= 0, 'inbound total required') if (total === undefined) total = Number(json.data.total) assertEqual(Number(json.data.total), total, 'stable inbound total') assertEqual(Number(json.data.page), page, 'inbound page identity') for (const row of json.data.records) { assertEqual(row.orderNo, order.orderNo, 'exact inbound source') assertEqual(String(row.tenantId), customerId, 'inbound customer') assertTrue(!inbounds.some(before => String(before.id) === String(row.id)), 'no duplicate inbound across pages') inbounds.push(row) } assertEqual(json.data.records.length, Math.min(200, Math.max(0, total - (page - 1) * 200)), 'complete inbound page') }, () => ({ orderId: order.id, total, collected: inbounds.length, snapshotSha256: orderDigest(inbounds) })) if (page * 200 >= total) break } return { task, inbounds } } async function orderSubmissionBalanceGateModule() { const module = 'order-submission-gates', customerId = String(report.resources.tenantId) const proof = report.orderSubmissionGates = { customerId, balance: { status: 'PREPARING' }, remaining: [ 'Channel-offline requires this run customer-owned price-book switch excluding the draft channel; no public channel or public price-book is changed.', 'Finance/IAM unavailable-response rejection requires controlled isolated upstream fault evidence; not injected here.', ] } await apiStep(module, 'guard newly registered customer ownership before financial fixture', { path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, }, ({ json }) => { requireSuccess(json) assertEqual(String(json.data?.id), customerId, 'current primary client identity') assertEqual(json.data?.status, 'ACTIVE', 'approved active customer') assertTrue(report.steps.some(step => step.module === 'registration' && step.name === 'enterprise self-registration' && step.passed === true && String(step.evidence?.tenantId) === customerId), 'only this run newly registered customer may be adjusted') }, () => ({ customerId, scope: 'current-run-registration-only' })) const readWallet = async label => { const response = await apiStep(module, label, { path: '/api/client/finance/v1/account', token: merchantToken }, ({ json }) => { requireSuccess(json); assertTrue(Number.isSafeInteger(json.data?.version), 'wallet version required') assertEqual(json.data.currencyCode, 'CNY', 'isolated wallet currency') for (const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) { assertTrue(json.data[field] != null && Number.isFinite(Number(json.data[field])), `wallet ${field} required`) } assertEqual(json.data.creditActive, false, 'no active credit may be altered') }, ({ json }) => ({ customerId, account: json.data })) return response.json.data } const readLedger = async label => { const response = await apiStep(module, label, { path: '/api/client/finance/v1/account/ledger?page=1&size=200', token: merchantToken }, ({ json }) => { requireSuccess(json); assertTrue(Array.isArray(json.data?.records), 'ledger records required') assertEqual(Number(json.data.total), json.data.records.length, 'entire isolated ledger fits one page') }, ({ json }) => ({ total: json.data.total, ledgerSha256: orderDigest(json.data.records) })) return response.json.data.records } const baseline = await readWallet('freeze pristine account') for (const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) assertEqual(Number(baseline[field]), 0, `pristine ${field}`) assertEqual((await readLedger('require no prior ledger')).length, 0, 'never debit an existing funded account') const payload = { ...structuredClone(orderAcceptanceFixture.payload), customerRemark: `BALANCE-GATE-${marker}` } const created = await apiStep(module, 'create only one fresh balance-gate draft', { method:'POST', path:'/api/merchant/v1/orders/first-leg', token:merchantToken, body:payload, headers:{'Idempotency-Key':newIdempotencyKey('balance-gate-create')}, }, ({json})=>{requireSuccess(json);assertPresent(json.data,'gate order')}, ({json})=>({orderId:json.data})) const orderId=created.json.data report.resources.orderIds.push(orderId);proof.balance.orderId=orderId const before = await ownedOrderSnapshot(module,'freeze rejected-submit draft facts',orderId) assertEqual(before.order.status,'DRAFT','unfulfilled balance fixture') const fulfillmentBefore = await submissionGateFulfillmentSnapshot(before.order,'before balance rejection') const occurredDate = new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date()) const referenceNo = `BALANCE-GATE-${marker}`.slice(0,128) const adjust = async (type, account, suffix) => { const command={headers:{'Idempotency-Key':newIdempotencyKey(`balance-${suffix}`)},body:{type,amount:0.01,occurredDate,paymentMethod:'OTHER', referenceNo,remark:`${marker} isolated order gate ${suffix}`,expectedAccountVersion:account.version}} proof.balance[`${suffix}Command`]=command await apiStep(module,`isolated wallet ${suffix}`,{method:'POST',path:`/api/platform/finance/v1/accounts/${customerId}/adjustments`, token:platformToken,...command},({json})=>requireSuccess(json),({json})=>({customerId,ledger:json.data})) } let attempted=false try { attempted=true await adjust('DEDUCTION',baseline,'deduct') const negative=await readWallet('verify actual balance minus one cent') assertEqual(Number(negative.actualBalance),-0.01,'negative actual funds, not insufficient available credit') assertEqual(negative.version,baseline.version+1,'one intentional deduction') const negativeLedger=await readLedger('freeze deduction ledger') assertEqual(negativeLedger.length,1,'only one fixture deduction') for(const role of ['merchant','platform']) { const command=orderMutationCommand(before,{},newIdempotencyKey(`negative-balance-${role}`)) await apiStep(module,`${role} negative actual balance rejects submission`,{ method:'POST',path:`/api/${role}/v1/orders/first-leg/${orderId}/submit${role==='platform'?`?tenantId=${customerId}`:''}`, token:role==='merchant'?merchantToken:platformToken,...command, },({json,status})=>{assertEqual(status,200,'submit HTTP');assertEqual(String(json?.code),'A0403','balance gate code'); assertEqual(json?.message,'账户实际余额为负,请充值后再下单','balance-specific reason, not qualification rejection')},()=>({orderId,customerId,expectedVersion:command.body.expectedVersion})) await ownedOrderSnapshot(module,`${role} rejected order aggregate unchanged`,orderId,before) const after=await submissionGateFulfillmentSnapshot(before.order,`${role} rejection`) assertOrderUnchanged(fulfillmentBefore,after) assertOrderUnchanged(negative,await readWallet(`${role} rejection leaves wallet unchanged`)) assertOrderUnchanged(negativeLedger,await readLedger(`${role} rejection leaves ledger unchanged`)) } proof.balance.status='REJECTIONS_VERIFIED' } finally { if(attempted) { const current=await readWallet('resolve isolated deduction before restoring') const ledger=await readLedger('resolve exact fixture ledger before restoring') if(Number(current.actualBalance)===0 && current.version===baseline.version && ledger.length===0) proof.balance.restoration='DEDUCTION_NOT_APPLIED' else { assertEqual(Number(current.actualBalance),-0.01,'restore only exact isolated negative balance') assertEqual(current.version,baseline.version+1,'restore requires unchanged deduction version') assertTrue(ledger.length===1 && ledger[0].sourceType==='MANUAL_DEDUCTION' && ledger[0].referenceNo===referenceNo && Number(ledger[0].amount)===-0.01,'restore only this run proven deduction; no blind credit') await adjust('RECHARGE',current,'restore') const restored=await readWallet('verify restored zero balance') for(const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) assertEqual(Number(restored[field]),Number(baseline[field]),`restored ${field}`) assertEqual(restored.version,baseline.version+2,'one deduction and one restoring credit') const finalLedger=await readLedger('verify exact two fixture ledger entries') assertEqual(finalLedger.length,2,'no extra wallet movements') assertTrue(finalLedger.every(row=>row.referenceNo===referenceNo) && finalLedger.filter(row=>row.sourceType==='MANUAL_DEDUCTION'&&Number(row.amount)===-0.01).length===1 && finalLedger.filter(row=>row.sourceType==='MANUAL_RECHARGE'&&Number(row.amount)===0.01).length===1,'exact balanced fixture movements') proof.balance.restoration='RESTORED_WITH_AUDIT' } } } } async function orderSubmissionChannelGateModule() { const module='order-channel-gate', base='/api/platform/v1/fba-pricing/carrier-rates/price-books' const customerId=String(report.resources.tenantId) assertTrue(/^[1-9]\d*$/.test(customerId), 'customer-specific price book must never use platform scope zero') assertTrue(report.steps.some(step=>step.module==='registration' && step.name==='enterprise self-registration' && step.passed===true && String(step.evidence?.tenantId)===customerId), 'only current-run registered customer is eligible') assertEqual(report.orderSubmissionGates?.balance?.restoration,'RESTORED_WITH_AUDIT','negative funds must already be restored') const {masterData,payload}=orderAcceptanceFixture const proof=report.orderSubmissionGates.channel={status:'PREPARING',customerId,channelCode:payload.channelCode,versions:[]} await apiStep(module,'verify current customer before private pricing writes',{ path:'/api/merchant/v1/iam/tenant/profile',token:merchantToken, },({json})=>{requireSuccess(json);assertEqual(String(json.data?.id),customerId,'current customer');assertEqual(json.data.status,'ACTIVE','eligible customer')},()=>({customerId})) const list=async(scope,label)=>{ const response=await apiStep(module,label,{path:`${base}?customerId=${scope}`,token:platformToken},({json})=>{ requireSuccess(json);assertTrue(Array.isArray(json.data),'price version list required') assertTrue(json.data.every(version=>String(version.customerId)===String(scope)),'exact price-book customer scope') },({json})=>({customerId:scope,versionCount:json.data.length,versionsSha256:orderDigest(json.data), activeVersionIds:json.data.filter(version=>version.status==='ACTIVE').map(version=>version.id)})) return response.json.data } const publicBefore=await list('0','freeze public price-book versions without changing them') assertEqual((await list(customerId,'require no previous customer-specific price books')).length,0,'do not replace any existing customer private version') const active=publicBefore.filter(version=>version.status==='ACTIVE' && String(version.id)===String(masterData.channel.priceBookVersionId)) assertEqual(active.length,1,'selected route must originate from one active public source') let reference await apiStep(module,'read authoritative active price source and mappings',{ path:`${base}/${active[0].id}`,token:platformToken, },({json})=>{ requireSuccess(json);reference=json.data assertEqual(String(reference?.version?.customerId),'0','read-only source is public') assertEqual(String(reference.version.id),String(active[0].id),'exact active source') assertEqual(reference.version.status,'ACTIVE','valid reusable source') assertTrue(Array.isArray(reference.channels)&&reference.channels.length>1,'source must retain channels after removing target sheet') },({json})=>({versionId:json.data.version.id,sourceSha256:json.data.version.sourceSha256})) let sourceBytes await apiStep(module,'reuse existing valid XLSX source for private A',{ path:`${base}/${reference.version.id}/source`,token:platformToken,responseType:'bytes', },({status,bytes})=>{ assertEqual(status,200,'source download HTTP');sourceBytes=bytes assertEqual(createHash('sha256').update(bytes).digest('hex'),reference.version.sourceSha256,'source SHA-256 matches published version') },({bytes})=>({sizeBytes:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex')})) const replacement=channelGateWorkbook(sourceBytes,reference,payload.channelCode) proof.sourceVersionId=reference.version.id;proof.sourceSheetName=replacement.sourceSheetName;proof.removedChannelCodes=replacement.removedCodes const codesA=reference.channels.map(channel=>channel.channelCode).sort() const codesB=codesA.filter(code=>!replacement.removedCodes.includes(code)) const channelList=async(versionId,containsTarget,label)=>{ const response=await apiStep(module,label,{ path:`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${masterData.receiving.id}`,token:merchantToken, },({json})=>{ requireSuccess(json);assertTrue(Array.isArray(json.data),'client channel list must be a successful array') assertEqual(json.data.some(channel=>channel.channelCode===payload.channelCode),containsTarget,'target channel visibility') assertTrue(json.data.every(channel=>String(channel.priceBookVersionId)===String(versionId)),'client must read private version, never fall back to public') },({json})=>({customerId,versionId,channelCodes:json.data.map(channel=>channel.channelCode)})) return response.json.data } const publish=async(bytes,label,expectedCodes)=>{ const sha256=createHash('sha256').update(bytes).digest('hex'), form=new FormData() form.append('file',new Blob([bytes],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}),`gate-${marker}-${label}.xlsx`) let parsed await apiStep(module,`upload customer-only version ${label}`,{ method:'POST',path:`${base}?customerId=${customerId}`,token:platformToken,body:form, },({json})=>{ requireSuccess(json);parsed=json.data assertEqual(String(parsed?.version?.customerId),customerId,'uploaded version exclusively belongs to new customer') assertPresent(parsed.version.id,'uploaded version identity');assertEqual(parsed.version.status,'PENDING_CONFIRMATION','upload stays pending') assertEqual(parsed.version.sourceSha256,sha256,'uploaded source digest') assertOrderUnchanged(expectedCodes,[...parsed.channels.map(channel=>channel.channelCode)].sort()) },({json})=>({customerId,versionId:json.data?.version?.id,sourceSha256:sha256})) const entry={label,id:String(parsed.version.id),customerId,sourceSha256:sha256,status:'PENDING_CONFIRMATION'} proof.versions.push(entry) const mappings=channelGateMappings(parsed,reference) await apiStep(module,`recheck exact ownership immediately before publishing ${label}`,{ path:`${base}/${entry.id}`,token:platformToken, },({json})=>{ requireSuccess(json);assertEqual(String(json.data?.version?.customerId),customerId,'publication ownership') assertEqual(String(json.data.version.id),entry.id,'publication version');assertEqual(json.data.version.sourceSha256,sha256,'publication source') assertEqual(json.data.version.status,'PENDING_CONFIRMATION','publication precondition') },()=>({customerId,versionId:entry.id})) await apiStep(module,`publish private ${label} with source-verified warehouse mappings`,{ method:'POST',path:`${base}/${entry.id}/publish`,token:platformToken,body:mappings, },({json})=>{ requireSuccess(json);assertEqual(String(json.data?.version?.customerId),customerId,'published scope') assertEqual(String(json.data.version.id),entry.id,'published identity');assertEqual(json.data.version.status,'ACTIVE','published status') assertEqual(Number(json.data.version.blockingErrorCount),0,'no ignored publication blockers') assertOrderUnchanged(expectedCodes,[...json.data.channels.map(channel=>channel.channelCode)].sort()) assertTrue(Array.isArray(json.data.rates)&&json.data.rates.length>0,'remaining real rates') },()=>({customerId,versionId:entry.id,mappings})) entry.status='ACTIVE' return entry.id } try { const a=await publish(sourceBytes,'A',codesA) proof.clientChannelsA=await channelList(a,true,'client sees target channel in private A') const gatePayload={...structuredClone(payload),customerRemark:`CHANNEL-GATE-${marker}`} const created=await apiStep(module,'create fresh draft while target channel is available in A',{ method:'POST',path:'/api/merchant/v1/orders/first-leg',token:merchantToken,body:gatePayload, headers:{'Idempotency-Key':newIdempotencyKey('channel-gate-create')}, },({json})=>{requireSuccess(json);assertPresent(json.data,'channel-gate draft')},({json})=>({orderId:json.data})) const orderId=created.json.data;proof.orderId=orderId;report.resources.orderIds.push(orderId) const before=await ownedOrderSnapshot(module,'freeze original A draft version and full facts',orderId) assertEqual(before.order.status,'DRAFT','unfulfilled channel gate fixture') assertEqual(before.order.channelCode,payload.channelCode,'original draft channel') const fulfillment=await submissionGateFulfillmentSnapshot(before.order,'before private channel removal') const commands=Object.fromEntries(['merchant','platform'].map(role=>[role,orderMutationCommand(before,{},newIdempotencyKey(`removed-channel-${role}`))])) proof.commands=commands const b=await publish(replacement.bytes,'B',codesB) proof.clientChannelsB=await channelList(b,false,'client target channel is removed in private B') const own=await list(customerId,'verify A superseded only within this customer and B active') assertTrue(own.length===2 && own.some(version=>String(version.id)===a&&version.status==='SUPERSEDED') && own.some(version=>String(version.id)===b&&version.status==='ACTIVE'),'exact customer A to B replacement') for (const entry of proof.versions) { entry.statusAtPublication = entry.status entry.status = own.find(version => String(version.id) === entry.id).status } for(const role of ['merchant','platform']) { await apiStep(module,`${role} original draft rejects now-unavailable channel`,{ method:'POST',path:`/api/${role}/v1/orders/first-leg/${orderId}/submit${role==='platform'?`?tenantId=${customerId}`:''}`, token:role==='merchant'?merchantToken:platformToken,...commands[role], },({json,status})=>{ assertEqual(status,200,'submit HTTP');assertEqual(String(json?.code),'A0409','unavailable channel conflict') assertEqual(json?.message,'所选渠道已下线,请重新选择','channel-specific rejection, not version or qualification conflict') },()=>({customerId,orderId,expectedVersion:commands[role].body.expectedVersion,channelCode:payload.channelCode})) await ownedOrderSnapshot(module,`${role} channel rejection leaves original order unchanged`,orderId,before) assertOrderUnchanged(fulfillment,await submissionGateFulfillmentSnapshot(before.order,`${role} channel rejection`)) } proof.status='SUBMISSIONS_REJECTED' } finally { assertOrderUnchanged(publicBefore,await list('0','verify every public price-book version unchanged')) proof.publicVersionsUnchanged=true } proof.status='REJECTIONS_VERIFIED' report.orderSubmissionGates.remaining=report.orderSubmissionGates.remaining.filter(item=>!item.startsWith('Channel-offline requires')) } async function importOrderSet(name, expectedIds) { const ids = [], seen = new Set() let total = null for (let page = 1; ; page++) { await apiStep('order-excel-import', `${name}: complete order page ${page}`, { path: `/api/merchant/v1/orders/first-leg?page=${page}&size=200`, token: merchantToken, }, ({ json }) => { requireSuccess(json) assertTrue(Array.isArray(json.data?.records), 'order page records required') assertEqual(Number(json.data.page), page, 'order page identity') const count = Number(json.data.total) assertTrue(Number.isSafeInteger(count) && count >= 0, 'valid order total') if (total === null) total = count assertEqual(count, total, 'stable order page total') for (const row of json.data.records) { assertPresent(row.orderId, 'listed order ID') const id = String(row.orderId) assertTrue(!seen.has(id), 'no duplicate order across pages'); seen.add(id); ids.push(id) } assertTrue(ids.length <= total, 'order pages do not exceed total') if (page * 200 >= total) { assertEqual(ids.length, total, 'complete order set') if (expectedIds) assertOrderUnchanged([...expectedIds].sort(), [...ids].sort()) } else assertEqual(json.data.records.length, 200, 'full intermediate order page') }, () => ({ page, total, collected: ids.length, orderIdsSha256: orderDigest([...ids].sort()) })) if (page * 200 >= total) return ids } } async function orderExcelImportModule() { const module = 'order-excel-import', base = '/api/merchant/v1/orders/first-leg/batch-imports' const { masterData, payload } = orderAcceptanceFixture let template await apiStep(module, 'download and inspect actual Chinese customer template', { path: `${base}/template`, token: merchantToken, responseType: 'bytes', }, ({ status, bytes, headers }) => { assertEqual(status, 200, 'template HTTP status') assertTrue((headers.get('content-type') ?? '').includes('spreadsheetml'), 'XLSX content type') template = orderZipRead(bytes) const header = orderSheetRows(template).find(row => row.number === 2) assertEqual(header?.cells?.length, 31, 'current 31-column template') assertEqual(header.cells[0].text, '运输方式 *', 'Chinese first header') assertEqual(header.cells[30].text, '客户备注', 'Chinese final header') assertTrue(template.get('xl/workbook.xml').includes('选项数据'), 'server-generated option sheet') }, ({ bytes }) => ({ sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') })) const options = new Set(orderSheetRows(template, 'xl/worksheets/sheet2.xml').flatMap(row => row.cells.map(cell => cell.text))) const businessLabel = (name, code) => !name || name.toLowerCase() === code.toLowerCase() ? code : `${name}|${code}` const receiving = businessLabel(masterData.receiving.warehouseName, masterData.receiving.warehouseCode) const destination = businessLabel(masterData.destination.warehouseName, masterData.destination.warehouseCode) const channel = businessLabel(masterData.channel.channelName, masterData.channel.channelCode) for (const value of [receiving, destination, channel]) assertTrue(options.has(value), `authoritative template option ${value}`) const makeRow = suffix => [ '海运', '', '自行快递', receiving, channel, '亚马逊FBA', destination, '', '', '', `Excel-${suffix}`, '纸箱', '2', '20.5', '0.024', '塑料', '否', '否', `SKU-${suffix}`, '123456789012', `SHIP-${suffix}`, `REF-${suffix}`, '买单报关', payload.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税', '散货拼箱', '否', `SHIP-${suffix}`, `REF-${suffix}`, '普通头程', '', `${marker}-${suffix}`, ] const upload = async (name, mode, key, bytes, validate, filename = 'orders.xlsx') => { const form = new FormData() form.append('file', new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), filename) return apiStep(module, name, { method: 'POST', path: `${base}?mode=${mode}`, token: merchantToken, headers: { 'Idempotency-Key': key }, body: form, }, validate, ({ json }) => ({ batchNo: json?.data?.batchNo, mode, result: json?.data?.result, sourceFileId: json?.data?.sourceFileId, errorReportFileId: json?.data?.errorReportFileId, requestBytesSha256: createHash('sha256').update(bytes).digest('hex') })) } const rejection = code => ({ json, status }) => { assertEqual(status, 200, 'HTTP status'); assertEqual(String(json?.code), code, 'rejection business code') } const success = (mode, rows) => ({ json }) => { requireSuccess(json); assertImportResult(json.data, mode, rows) } const verifyDraft = async (id, row, name) => { await apiStep(module, name, { path: `/api/merchant/v1/orders/first-leg/${id}`, token: merchantToken }, ({ json }) => { requireSuccess(json); assertOrderDetail(json.data, id) assertEqual(json.data.order.status, 'DRAFT', 'import creates draft only') assertEqual(String(json.data.order.tenantId), String(report.resources.tenantId), 'authenticated customer scope') assertEqual(json.data.order.customerRemark, row[30], 'exact source remark') assertEqual(json.data.order.referenceId, row[27], 'exact source reference') assertEqual(String(json.data.order.receivingWarehouseId), String(masterData.receiving.id), 'authoritative receiving warehouse') assertEqual(String(json.data.order.destinationWarehouseId), String(masterData.destination.id), 'authoritative destination') assertEqual(json.data.order.channelCode, masterData.channel.channelCode, 'authoritative channel') assertEqual(json.data.cargoes.length, 1, 'one Excel row creates one order cargo') assertEqual(json.data.cargoes[0].skuNo, row[18], 'source SKU') assertEqual(Number(json.data.cargoes[0].packageCount), 2, 'source package count') assertTrue(!json.data.statusHistory.some(item => item.toStatus === 'SUBMITTED'), 'import never submits') }, ({ json }) => ({ orderId: String(id), status: json.data?.order?.status, snapshotSha256: orderDigest(json.data) })) } const initial = await importOrderSet('before import matrix') const rows = importRollbackRows(makeRow('rollback-first'), makeRow('rollback-second')) const key = newIdempotencyKey('excel-transaction') const seedBytes = orderWorkbookRows(template, rows.seed) const seed = await upload('PARTIAL preserves only valid second row', 'PARTIAL', key, seedBytes, success('PARTIAL', [false, true])) const retainedId = String(seed.json.data.result.items[1].orderId) report.resources.orderIds.push(retainedId) const seeded = [...initial, retainedId] await importOrderSet('PARTIAL exactly one new order', seeded) await verifyDraft(retainedId, rows.seed[1], 'PARTIAL source fields and customer match') const retained = await ownedOrderSnapshot(module, 'seed second row full snapshot', retainedId) const replay = await upload('PARTIAL same key bytes returns same order', 'PARTIAL', key, seedBytes, response => { success('PARTIAL', [false, true])(response) assertEqual(String(response.json.data.result.items[1].orderId), retainedId, 'replayed order identity') }) await importOrderSet('PARTIAL replay adds no orders', seeded) await ownedOrderSnapshot(module, 'PARTIAL replay preserves full existing order', retainedId, retained) const errorData = replay.json.data await apiStep(module, 'download actual row error workbook', { path: `/api/merchant/v1/files/order-batch-imports/${encodeURIComponent(errorData.batchNo)}/error-reports/${errorData.errorReportFileId}`, token: merchantToken, responseType: 'bytes', }, ({ status, bytes }) => { assertEqual(status, 200, 'error workbook download') const rows = orderSheetRows(orderZipRead(bytes)) assertEqual(rows.length, 2, 'header and exactly one failed row') assertEqual(Number(rows[1].cells[0].text), 3, 'actual Excel error row') assertEqual(rows[1].cells[1].text, 'A0422', 'error report code') assertEqual(rows[1].cells[2].text, errorData.result.items[0].errorMessage, 'error report exact reason') }, ({ bytes }) => ({ sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') })) await upload('ATOMIC parse rejection creates no orders', 'ATOMIC', newIdempotencyKey('excel-parse'), seedBytes, rejection('A0422')) await importOrderSet('ATOMIC parse failure has zero additions', seeded) // Same tenant/operator/key and physical row IDs; only row 4 customerRemark changes. // Row 3 passes validation and writes before row 4 CREATE_DRAFT fingerprint conflicts. await upload('ATOMIC later row idempotency conflict rolls back earlier row', 'ATOMIC', key, orderWorkbookRows(template, rows.conflict), rejection('A0409')) await importOrderSet('ATOMIC mid-write conflict leaves no first-row order', seeded) await ownedOrderSnapshot(module, 'ATOMIC conflict preserves seeded order and history', retainedId, retained) await upload('ATOMIC retry with original second-row hash succeeds', 'ATOMIC', key, orderWorkbookRows(template, rows.retry), response => { success('ATOMIC', [true, true])(response) assertEqual(String(response.json.data.result.items[1].orderId), retainedId, 'second row reuses original ID') assertTrue(String(response.json.data.result.items[0].orderId) !== retainedId, 'new first row is distinct') }).then(async response => { const firstId = String(response.json.data.result.items[0].orderId) report.resources.orderIds.push(firstId); seeded.push(firstId) await importOrderSet('ATOMIC retry adds exactly first row', seeded) await verifyDraft(firstId, rows.retry[0], 'ATOMIC first row source facts match') }) await ownedOrderSnapshot(module, 'successful ATOMIC replay preserves second row', retainedId, retained) const invalidCases = [ ['wrong extension', seedBytes, 'orders.csv'], ['corrupt xlsx', Buffer.from('not-an-xlsx'), 'orders.xlsx'], ['over five MiB', Buffer.alloc(5 * 1024 * 1024 + 1), 'orders.xlsx'], ['51 effective rows', orderWorkbookRows(template, Array.from({ length: 51 }, (_, index) => makeRow(`limit-${index}`))), 'orders.xlsx'], ] const missingHeader = orderZipRead(orderWorkbookRows(template, [makeRow('header')])) missingHeader.set('xl/worksheets/sheet1.xml', missingHeader.get('xl/worksheets/sheet1.xml').replace(/]*\br="2"[^>]*>[\s\S]*?<\/row>/, '')) 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('', `${orderXmlEncode(forged[3])}`)) 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)) }