#!/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' import { normalizeFixedCustomerId } from './admin-test-env-modules/merchant-qualification-fixture.mjs' const args = parseArgs(process.argv.slice(2)) const fixedCustomerId = normalizeFixedCustomerId(process.env.ADMIN_TEST_FIXED_CUSTOMER_ID) 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 = existingClientUsername const temporaryUsername = `renamed_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64) const clientPassword = existingClientPassword const phoneSuffix = `${Date.now()}`.slice(-8) const legalPhone = `139${phoneSuffix}` let companyName = '' 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, fullCoverage: 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 reuseFixedClient() await platformReviewerLogin() for (const module of ['registration', 'qualification-review', 'profile-management', 'login-identifiers', 'rbac-members', 'isolation', 'order-submission-balance', 'order-mutation-evidence', 'order-submission-channel', 'notification-inbox', 'account-security']) { recordSkipped(module, '固定客户模式排除生命周期或共享配置写入', '不注册客户、不修改固定账号资料或余额、不发布通知;跨客户场景需第二个固定客户') } await orderLifecycleModule() await orderExcelImportModule() await merchantFinanceBillsModule() await merchantOverseasWarehouseModule() await merchantCrossBorderModule() await merchantWarehouseOrdersModule() await merchantPricingCustomsModule() report.success = report.steps.every((step) => step.passed) report.fullCoverage = !report.steps.some((step) => step.skipped) } catch (error) { report.fatal = sanitizeError(error) report.success = false } finally { if (ownerPasswordNeedsRestore) await recoverOwnerPassword() if (merchantToken) { const logout = await softApiStep('fixed-customer', '退出固定客户测试会话', { method: 'POST', path: '/api/auth/logout', token: merchantToken, body: { refreshToken: merchantRefreshToken }, }, ({ json }) => requireSuccess(json)) if (!logout) report.success = false } 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 reuseFixedClient() { const login = await apiStep('fixed-customer', '登录固定客户', { method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, }, ({ json }) => { requireSuccess(json); assertPresent(json.data?.accessToken, 'accessToken') }) merchantToken = login.json.data.accessToken merchantRefreshToken = login.json.data.refreshToken const profile = await apiStep('fixed-customer', '校验固定客户归属及状态', { method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, }, ({ json }) => { requireSuccess(json) assertEqual(String(json.data?.id), fixedCustomerId, 'fixed customer identity') assertEqual(json.data?.status, 'ACTIVE', 'fixed customer status') }) report.resources.tenantId = fixedCustomerId companyName = profile.json.data.tenantName report.testAccount = { username, reused: true } } 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' }) { if (String(method).toUpperCase() === 'POST' && /^\/api\/(platform|merchant)\/v1\/iam\/(roles|users|members|permissions)$/.test(new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, ''))) throw new Error('固定资源验收禁止新增角色、账号或权限,请复用已配置身份') if (String(method).toUpperCase() === 'POST' && ['/api/auth/register', '/api/platform/v1/iam/tenants'].includes(new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, ''))) { throw new Error('固定客户模式禁止注册或创建客户') } 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)) }