2782 lines
151 KiB
JavaScript
2782 lines
151 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* Client test-environment acceptance flow.
|
||
*
|
||
* Run directly and enter the platform/client test credentials when prompted.
|
||
* Optional environment variables for CI:
|
||
* ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
|
||
* MERCHANT_TEST_USERNAME, MERCHANT_TEST_PASSWORD, CLIENT_TEST_REPORT_DIR
|
||
*
|
||
* Example:
|
||
* node scripts/client-test-env-acceptance.mjs
|
||
*/
|
||
|
||
import { mkdir, writeFile } from 'node:fs/promises'
|
||
import { randomUUID, createHash } from 'node:crypto'
|
||
import { resolve } from 'node:path'
|
||
import { createInterface } from 'node:readline/promises'
|
||
import { inflateRawSync } from 'node:zlib'
|
||
|
||
const args = parseArgs(process.argv.slice(2))
|
||
if ((args.environment && args.environment !== 'test') || args['overseas-gateway'] || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Only one TEST gateway is supported; remove regional options')
|
||
const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY)
|
||
const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME
|
||
?? await promptText('平台审核账号', '')
|
||
const platformPassword = args['platform-password'] ?? process.env.ADMIN_TEST_PASSWORD
|
||
?? await promptSecret('平台审核密码')
|
||
const existingClientUsername = args['existing-client-username'] ?? process.env.MERCHANT_TEST_USERNAME
|
||
?? await promptText('跨客户隔离测试账号(必须由正式注册/建档产生)', '')
|
||
const existingClientPassword = args['existing-client-password'] ?? process.env.MERCHANT_TEST_PASSWORD
|
||
?? await promptSecret('跨客户隔离测试密码')
|
||
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports')
|
||
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
|
||
|
||
if (!gateway || !platformUsername || !platformPassword || !existingClientUsername || !existingClientPassword) {
|
||
console.error('ADMIN_TEST_GATEWAY, platform credentials, and an independently onboarded client account are required')
|
||
process.exit(2)
|
||
}
|
||
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
|
||
console.error('--timeout-ms must be at least 1000')
|
||
process.exit(2)
|
||
}
|
||
|
||
const now = new Date()
|
||
const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}`
|
||
const username = `client_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64)
|
||
const temporaryUsername = `renamed_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64)
|
||
const clientPassword = `Client${randomUUID().replaceAll('-', '').slice(0, 14)}9!`
|
||
const phoneSuffix = `${Date.now()}`.slice(-8)
|
||
const legalPhone = `139${phoneSuffix}`
|
||
const companyName = `客户端全链路测试企业-${marker}`.slice(0, 128)
|
||
const creditCode = `91${compactTimestamp(now)}${randomUUID().replaceAll('-', '').slice(0, 2).toUpperCase()}`.slice(0, 18)
|
||
const reportPath = resolve(reportDir, `client-test-env-${marker}.json`)
|
||
|
||
const report = {
|
||
marker,
|
||
gateway,
|
||
generatedAt: now.toISOString(),
|
||
completedAt: null,
|
||
success: false,
|
||
fatal: null,
|
||
testAccount: { username, companyName, legalPhone },
|
||
resources: {
|
||
tenantId: null,
|
||
membershipId: null,
|
||
reviewId: null,
|
||
businessLicenseQualificationId: null,
|
||
businessLicenseDocumentUrl: null,
|
||
productImageFileId: null,
|
||
enterpriseAddressId: null,
|
||
receivingWarehouseId: null,
|
||
destinationWarehouseId: null,
|
||
channelCode: null,
|
||
roleId: null,
|
||
testMembershipId: null,
|
||
notificationTemplateId: null,
|
||
notificationMessageIds: [],
|
||
orderIds: [],
|
||
},
|
||
steps: [],
|
||
futureModules: ['measurement', 'ocr', 'mojia', 'billing'],
|
||
}
|
||
|
||
let merchantToken
|
||
let merchantRefreshToken
|
||
let platformToken
|
||
let existingClientToken
|
||
let orderAcceptanceFixture
|
||
let ownerPasswordNeedsRestore = false
|
||
let temporaryOwnerPassword
|
||
|
||
try {
|
||
await registrationModule()
|
||
await merchantBeforeApprovalModule()
|
||
await qualificationAndReviewModule()
|
||
await merchantAfterApprovalModule()
|
||
await profileManagementModule()
|
||
await loginIdentifierModule()
|
||
await rbacAndMemberModule()
|
||
await orderLifecycleModule()
|
||
await isolationModule()
|
||
await orderMutationEvidenceModule()
|
||
await orderExcelImportModule()
|
||
await notificationInboxModule()
|
||
await accountSecurityModule()
|
||
report.success = report.steps.every((step) => step.passed)
|
||
} catch (error) {
|
||
report.fatal = sanitizeError(error)
|
||
report.success = false
|
||
} finally {
|
||
if (ownerPasswordNeedsRestore) await recoverOwnerPassword()
|
||
report.completedAt = new Date().toISOString()
|
||
await mkdir(reportDir, { recursive: true })
|
||
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||
printSummary()
|
||
}
|
||
|
||
process.exitCode = report.success ? 0 : 1
|
||
|
||
async function registrationModule() {
|
||
const registrationBody = {
|
||
username,
|
||
password: clientPassword,
|
||
displayName: `测试管理员-${marker}`.slice(0, 64),
|
||
companyName,
|
||
legalPerson: '测试法人',
|
||
phone: legalPhone,
|
||
contactEmail: `${username}@example.com`,
|
||
countryCode: 'CN',
|
||
address: '广东省深圳市南山区测试路 1 号',
|
||
}
|
||
|
||
const registration = await apiStep('registration', 'enterprise self-registration', {
|
||
method: 'POST', path: '/api/auth/register', body: registrationBody,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenantStatus')
|
||
assertEqual(json.data?.nextStep, 'LOGIN_AND_COMPLETE_QUALIFICATIONS', 'nextStep')
|
||
assertPresent(json.data?.tenantId, 'tenantId')
|
||
assertPresent(json.data?.membershipId, 'membershipId')
|
||
}, ({ json }) => ({
|
||
tenantId: json.data.tenantId,
|
||
membershipId: json.data.membershipId,
|
||
tenantStatus: json.data.tenantStatus,
|
||
nextStep: json.data.nextStep,
|
||
}))
|
||
report.resources.tenantId = registration.json.data.tenantId
|
||
report.resources.membershipId = registration.json.data.membershipId
|
||
|
||
await apiStep('registration', 'same credentials replay the incomplete registration without creating another customer', {
|
||
method: 'POST', path: '/api/auth/register', body: registrationBody,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
for (const field of ['accountId', 'tenantId', 'membershipId']) {
|
||
assertPresent(json.data?.[field], field)
|
||
assertEqual(String(json.data[field]), String(registration.json.data[field]), `replayed ${field}`)
|
||
}
|
||
assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'replayed tenantStatus')
|
||
})
|
||
|
||
await apiStep('registration', 'duplicate username with different credentials is rejected', {
|
||
method: 'POST', path: '/api/auth/register',
|
||
body: { ...registrationBody, password: `Different${randomUUID()}9!` },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0409', 'business code')
|
||
})
|
||
|
||
const login = await apiStep('registration', 'login before approval', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'accessToken')
|
||
}, () => ({ username }))
|
||
merchantToken = login.json.data.accessToken
|
||
merchantRefreshToken = login.json.data.refreshToken
|
||
|
||
await apiStep('registration', 'profile is incomplete before qualifications', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(String(json.data?.id), String(report.resources.tenantId), 'profile tenant id')
|
||
assertEqual(json.data?.status, 'PROFILE_REQUIRED', 'profile status')
|
||
}, ({ json }) => ({ id: json.data.id, status: json.data.status, tenantName: json.data.tenantName }))
|
||
}
|
||
|
||
async function merchantBeforeApprovalModule() {
|
||
const masterData = await loadOrderMasterData(merchantToken, 'pre-approval-master-data')
|
||
const productImageFileId = await uploadProductImage(merchantToken, 'pre-approval-product-image')
|
||
report.resources.productImageFileId = productImageFileId
|
||
const payload = buildOrderPayload(masterData, productImageFileId, 'PRE', 'draft before enterprise approval')
|
||
|
||
const invalid = structuredClone(payload)
|
||
delete invalid.pricingTaxMode
|
||
await apiStep('order-validation', 'missing pricingTaxMode is rejected', {
|
||
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
||
headers: { 'Idempotency-Key': newIdempotencyKey('invalid-create') }, body: invalid,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0422', 'business code')
|
||
})
|
||
|
||
const createKey = newIdempotencyKey('pre-approval-create')
|
||
const create = await apiStep('order-before-approval', 'create draft before approval', {
|
||
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
||
headers: { 'Idempotency-Key': createKey }, body: payload,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data, 'order id')
|
||
}, ({ json }) => ({ orderId: json.data, status: 'DRAFT' }))
|
||
const orderId = create.json.data
|
||
report.resources.orderIds.push(orderId)
|
||
|
||
await apiStep('order-before-approval', 'create draft idempotent replay', {
|
||
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
||
headers: { 'Idempotency-Key': createKey }, body: payload,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(String(json.data), String(orderId), 'replayed order id')
|
||
}, ({ json }) => ({ orderId: json.data, replayed: true }))
|
||
|
||
await apiStep('order-before-approval', 'submit is blocked before approval', {
|
||
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
|
||
headers: { 'Idempotency-Key': newIdempotencyKey('pre-approval-submit') },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0403', 'business code')
|
||
}, ({ json }) => ({ orderId, rejectionCode: json.code }))
|
||
}
|
||
|
||
async function qualificationAndReviewModule() {
|
||
const png = testPng()
|
||
const form = new FormData()
|
||
form.append('businessLicense', new Blob([png], { type: 'image/png' }), `business-license-${marker}.png`)
|
||
|
||
await apiStep('qualification-review', 'upload required qualification package', {
|
||
method: 'POST',
|
||
path: `/api/merchant/v1/files/qualifications/required-package?unifiedSocialCreditCode=${encodeURIComponent(creditCode)}`,
|
||
token: merchantToken,
|
||
body: form,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.businessLicenseQualificationId, 'business license qualification id')
|
||
assertTrue(json.data?.legalPersonIdQualificationId == null, 'legal person qualification id must stay optional')
|
||
assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenant status after upload')
|
||
}, ({ json }) => ({
|
||
businessLicenseQualificationId: json.data.businessLicenseQualificationId,
|
||
legalPersonIdQualificationId: json.data.legalPersonIdQualificationId,
|
||
tenantStatus: json.data.tenantStatus,
|
||
}))
|
||
|
||
for (const qualificationType of ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE']) {
|
||
const optionalForm = new FormData()
|
||
optionalForm.append('file', new Blob([png], { type: 'image/png' }), `${qualificationType.toLowerCase()}-${marker}.png`)
|
||
await apiStep('qualification-review', `upload optional qualification ${qualificationType}`, {
|
||
method: 'POST',
|
||
path: `/api/merchant/v1/files/qualifications?qualificationType=${qualificationType}&replace=false`,
|
||
token: merchantToken,
|
||
body: optionalForm,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.qualificationId, `${qualificationType} qualification id`)
|
||
assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenant status after optional upload')
|
||
}, ({ json }) => ({ qualificationType, qualificationId: json.data.qualificationId, tenantStatus: json.data.tenantStatus }))
|
||
}
|
||
|
||
const duplicateOptionalForm = new FormData()
|
||
duplicateOptionalForm.append('file', new Blob([png], { type: 'image/png' }), `duplicate-freight-${marker}.png`)
|
||
await softApiStep('qualification-review', 'duplicate optional qualification requires explicit replacement', {
|
||
method: 'POST',
|
||
path: '/api/merchant/v1/files/qualifications?qualificationType=FREIGHT_FORWARDER_CERTIFICATE&replace=false',
|
||
token: merchantToken,
|
||
body: duplicateOptionalForm,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0409', 'business code')
|
||
}, ({ json }) => ({ qualificationType: 'FREIGHT_FORWARDER_CERTIFICATE', rejectionCode: json?.code }))
|
||
|
||
const replacementOptionalForm = new FormData()
|
||
replacementOptionalForm.append('file', new Blob([png], { type: 'image/png' }), `replacement-freight-${marker}.png`)
|
||
await apiStep('qualification-review', 'replace optional qualification explicitly', {
|
||
method: 'POST',
|
||
path: '/api/merchant/v1/files/qualifications?qualificationType=FREIGHT_FORWARDER_CERTIFICATE&replace=true',
|
||
token: merchantToken,
|
||
body: replacementOptionalForm,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.qualificationId, 'replacement qualification id')
|
||
}, ({ json }) => ({ qualificationType: 'FREIGHT_FORWARDER_CERTIFICATE', qualificationId: json.data.qualificationId, replaced: true }))
|
||
|
||
await apiStep('qualification-review', 'required qualifications are pending', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'PENDING')
|
||
assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'),
|
||
'optional legal person ID must remain absent')
|
||
assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'PENDING')
|
||
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
|
||
|
||
await apiStep('qualification-review', 'submit enterprise review', {
|
||
method: 'POST', path: '/api/merchant/v1/iam/tenant/qualifications/submit-review', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'PENDING', 'tenant status')
|
||
}, ({ json }) => ({ tenantId: json.data.id, status: json.data.status }))
|
||
|
||
const platformLogin = await apiStep('qualification-review', 'platform reviewer login', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'platform accessToken')
|
||
}, () => ({ username: platformUsername }))
|
||
platformToken = platformLogin.json.data.accessToken
|
||
|
||
const dimensionPath = `/api/platform/v1/iam/tenants/${report.resources.tenantId}/dimension-initialization`
|
||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||
const statusResponse = await httpRequest({ method: 'GET', path: dimensionPath, token: platformToken })
|
||
if (String(statusResponse.json?.data?.status) === 'READY') break
|
||
if (attempt < 29) await new Promise((resolve) => setTimeout(resolve, 1_000))
|
||
}
|
||
await apiStep('qualification-review', 'customer default dimensions are ready before approval', {
|
||
method: 'GET', path: dimensionPath, token: platformToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'READY', 'dimension initialization status')
|
||
}, ({ json }) => ({ status: json.data.status, attemptCount: json.data.attemptCount }))
|
||
|
||
const reviews = await apiStep('qualification-review', 'platform finds pending review', {
|
||
method: 'GET', path: '/api/platform/v1/iam/reviews', token: platformToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
const review = arrayData(json.data).find((item) => String(item.tenantId) === String(report.resources.tenantId))
|
||
assertPresent(review?.id, 'pending review id')
|
||
}, ({ json }) => {
|
||
const review = arrayData(json.data).find((item) => String(item.tenantId) === String(report.resources.tenantId))
|
||
return { reviewId: review?.id, tenantId: review?.tenantId, status: review?.status }
|
||
})
|
||
const review = arrayData(reviews.json.data).find((item) => String(item.tenantId) === String(report.resources.tenantId))
|
||
report.resources.reviewId = review.id
|
||
|
||
await apiStep('qualification-review', 'platform sees required qualifications', {
|
||
method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/qualifications`, token: platformToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'PENDING')
|
||
assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'),
|
||
'platform must not require a legal person ID record')
|
||
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
|
||
|
||
const approval = await apiStep('qualification-review', 'platform approves enterprise', {
|
||
method: 'POST', path: `/api/platform/v1/iam/reviews/${review.id}/approve`, token: platformToken,
|
||
body: { comment: `客户端全链路自动审核通过 ${marker}` },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(String(json.data?.tenantId), String(report.resources.tenantId), 'approved tenant id')
|
||
assertEqual(json.data?.administratorCreated, false, 'administratorCreated')
|
||
assertTrue(json.data?.administratorUsername == null, 'administratorUsername must not be returned')
|
||
assertTrue(json.data?.temporaryPassword == null, 'temporaryPassword must not be returned')
|
||
}, ({ json }) => ({
|
||
tenantId: json.data.tenantId,
|
||
administratorCreated: json.data.administratorCreated,
|
||
generatedCredentialsReturned: Boolean(json.data.administratorUsername || json.data.temporaryPassword),
|
||
}))
|
||
|
||
await apiStep('qualification-review', 'registered account remains enterprise owner', {
|
||
method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/members`, token: platformToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
const owner = arrayData(json.data).find((item) => item.memberType === 'OWNER')
|
||
assertPresent(owner, 'OWNER member')
|
||
assertEqual(String(owner.membershipId), String(report.resources.membershipId), 'owner membership id')
|
||
assertEqual(owner.username, username, 'owner username')
|
||
assertEqual(owner.status, 'ACTIVE', 'owner status')
|
||
}, ({ json }) => {
|
||
const owner = arrayData(json.data).find((item) => item.memberType === 'OWNER')
|
||
return { membershipId: owner?.membershipId, username: owner?.username, memberType: owner?.memberType, status: owner?.status }
|
||
})
|
||
}
|
||
|
||
async function loginIdentifierModule() {
|
||
const staleToken = merchantToken
|
||
await apiStep('login-identifiers', 'change username with current-password verification', {
|
||
method: 'PUT', path: '/api/merchant/v1/iam/profile/login-identifiers', token: merchantToken,
|
||
body: { username: temporaryUsername, phone: legalPhone, currentPassword: clientPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.username, temporaryUsername, 'updated username')
|
||
assertEqual(json.data?.phone, legalPhone, 'updated phone')
|
||
}, ({ json }) => ({ username: json.data.username, phone: json.data.phone }))
|
||
|
||
await apiStep('login-identifiers', 'identifier change revokes the previous access token', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/profile', token: staleToken,
|
||
}, ({ status, json }) => {
|
||
assertEqual(status, 401, 'HTTP status')
|
||
assertTrue(String(json?.code || '').length > 0, 'authentication error code must be present')
|
||
}, ({ status, json }) => ({ status, rejectionCode: json?.code }))
|
||
|
||
const renamedLogin = await apiStep('login-identifiers', 'login with the changed username', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: temporaryUsername, password: clientPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'accessToken')
|
||
}, () => ({ username: temporaryUsername }))
|
||
merchantToken = renamedLogin.json.data.accessToken
|
||
merchantRefreshToken = renamedLogin.json.data.refreshToken
|
||
|
||
await apiStep('login-identifiers', 'restore the original username for the remaining acceptance flow', {
|
||
method: 'PUT', path: '/api/merchant/v1/iam/profile/login-identifiers', token: merchantToken,
|
||
body: { username, phone: legalPhone, currentPassword: clientPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.username, username, 'restored username')
|
||
}, ({ json }) => ({ username: json.data.username }))
|
||
|
||
const restoredLogin = await apiStep('login-identifiers', 'login after restoring the original username', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'accessToken')
|
||
}, () => ({ username }))
|
||
merchantToken = restoredLogin.json.data.accessToken
|
||
merchantRefreshToken = restoredLogin.json.data.refreshToken
|
||
}
|
||
|
||
async function merchantAfterApprovalModule() {
|
||
const login = await apiStep('merchant-session', 'client re-login after approval', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'accessToken')
|
||
}, () => ({ username }))
|
||
merchantToken = login.json.data.accessToken
|
||
merchantRefreshToken = login.json.data.refreshToken
|
||
|
||
await apiStep('merchant-session', 'profile is active after approval', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'ACTIVE', 'profile status')
|
||
assertEqual(json.data?.unifiedSocialCreditCode, creditCode, 'unified social credit code')
|
||
}, ({ json }) => ({ id: json.data.id, status: json.data.status, tenantName: json.data.tenantName }))
|
||
|
||
const approvedQualifications = await apiStep('merchant-session', 'required qualifications are approved', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'APPROVED')
|
||
assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'),
|
||
'approved enterprise must not gain a synthetic legal person ID record')
|
||
assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'APPROVED')
|
||
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
|
||
|
||
const businessLicense = arrayData(approvedQualifications.json.data)
|
||
.find((item) => item.qualificationType === 'BUSINESS_LICENSE')
|
||
assertPresent(businessLicense?.documentUrl, 'business license documentUrl')
|
||
report.resources.businessLicenseQualificationId = businessLicense.id ?? businessLicense.qualificationId
|
||
report.resources.businessLicenseDocumentUrl = businessLicense.documentUrl
|
||
await apiStep('merchant-session', 'preview approved qualification file', {
|
||
method: 'GET', path: apiPath(businessLicense.documentUrl), token: merchantToken, responseType: 'bytes',
|
||
}, ({ status, bytes, headers }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertTrue(bytes?.length > 0, 'qualification preview must not be empty')
|
||
assertTrue((headers.get('content-type') ?? '').startsWith('image/'), 'qualification preview must return an image')
|
||
}, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') }))
|
||
}
|
||
|
||
async function profileManagementModule() {
|
||
const tenantProfile = await apiStep('profile-management', 'load enterprise profile for editing', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(String(json.data?.id), String(report.resources.tenantId), 'enterprise id')
|
||
}, ({ json }) => ({ id: json.data.id, tenantName: json.data.tenantName, status: json.data.status }))
|
||
|
||
const editedTenantName = `${companyName}-已编辑`.slice(0, 128)
|
||
const tenantUpdate = {
|
||
tenantName: editedTenantName,
|
||
legalName: tenantProfile.json.data.legalName || companyName,
|
||
legalRepresentative: tenantProfile.json.data.legalRepresentative || '测试法人',
|
||
contactName: `企业联系人-${marker}`.slice(0, 64),
|
||
contactPhone: legalPhone,
|
||
contactEmail: `company_${marker.replaceAll('-', '')}@example.com`,
|
||
}
|
||
await apiStep('profile-management', 'edit enterprise profile', {
|
||
method: 'PUT', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, body: tenantUpdate,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.tenantName, tenantUpdate.tenantName, 'tenantName')
|
||
assertEqual(json.data?.contactName, tenantUpdate.contactName, 'contactName')
|
||
assertEqual(json.data?.contactEmail, tenantUpdate.contactEmail, 'contactEmail')
|
||
assertEqual(json.data?.status, 'ACTIVE', 'enterprise status')
|
||
}, ({ json }) => ({ id: json.data.id, tenantName: json.data.tenantName, contactName: json.data.contactName, status: json.data.status }))
|
||
|
||
await apiStep('profile-management', 'enterprise profile edit is persisted', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.tenantName, tenantUpdate.tenantName, 'tenantName')
|
||
assertEqual(json.data?.contactEmail, tenantUpdate.contactEmail, 'contactEmail')
|
||
}, ({ json }) => ({ id: json.data.id, tenantName: json.data.tenantName, contactEmail: json.data.contactEmail }))
|
||
|
||
await softApiStep('profile-validation', 'invalid enterprise contact email is rejected', {
|
||
method: 'PUT', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
|
||
body: { ...tenantUpdate, contactEmail: 'invalid-email' },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0422', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json?.code }))
|
||
|
||
await softApiStep('profile-validation', 'blank enterprise address detail is rejected', {
|
||
method: 'POST', path: '/api/merchant/v1/iam/tenant/addresses', token: merchantToken,
|
||
body: { addressType: 'OPERATING', countryCode: 'CN', detail: ' ', isDefault: true },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0422', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json?.code }))
|
||
|
||
const addressBody = {
|
||
addressType: 'OPERATING',
|
||
countryCode: 'CN',
|
||
province: '广东省',
|
||
city: '深圳市',
|
||
district: '南山区',
|
||
detail: `客户端验收地址 ${marker}`,
|
||
postalCode: '518000',
|
||
isDefault: true,
|
||
}
|
||
const createdAddress = await apiStep('profile-management', 'add enterprise operating address', {
|
||
method: 'POST', path: '/api/merchant/v1/iam/tenant/addresses', token: merchantToken, body: addressBody,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.id, 'enterprise address id')
|
||
assertEqual(json.data?.addressType, addressBody.addressType, 'addressType')
|
||
assertEqual(json.data?.detail, addressBody.detail, 'address detail')
|
||
assertEqual(json.data?.isDefault, true, 'address isDefault')
|
||
}, ({ json }) => ({ id: json.data.id, addressType: json.data.addressType, city: json.data.city, isDefault: json.data.isDefault }))
|
||
report.resources.enterpriseAddressId = createdAddress.json.data.id
|
||
|
||
await apiStep('profile-management', 'enterprise address list contains created address', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/tenant/addresses', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
const address = arrayData(json.data).find((item) => String(item.id) === String(report.resources.enterpriseAddressId))
|
||
assertPresent(address, 'created enterprise address')
|
||
assertEqual(address.detail, addressBody.detail, 'address detail')
|
||
assertEqual(address.postalCode, addressBody.postalCode, 'address postalCode')
|
||
}, ({ json }) => ({ addressCount: arrayData(json.data).length, addressId: report.resources.enterpriseAddressId }))
|
||
|
||
await apiStep('profile-management', 'platform can read created enterprise address', {
|
||
method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/addresses`, token: platformToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
const address = arrayData(json.data).find((item) => String(item.id) === String(report.resources.enterpriseAddressId))
|
||
assertPresent(address, 'platform enterprise address')
|
||
assertEqual(address.detail, addressBody.detail, 'platform address detail')
|
||
}, ({ json }) => ({ addressCount: arrayData(json.data).length, addressId: report.resources.enterpriseAddressId }))
|
||
|
||
const personal = await apiStep('profile-management', 'load personal profile', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/profile', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.username, username, 'personal username')
|
||
}, ({ json }) => ({ userId: json.data.userId, username: json.data.username, displayName: json.data.displayName }))
|
||
const personalUpdate = {
|
||
displayName: `测试企业管理员-${marker}`.slice(0, 64),
|
||
phone: legalPhone,
|
||
email: `owner_${marker.replaceAll('-', '')}@example.com`,
|
||
}
|
||
await softApiStep('profile-validation', 'invalid personal email is rejected', {
|
||
method: 'PUT', path: '/api/merchant/v1/iam/profile', token: merchantToken,
|
||
body: { ...personalUpdate, email: 'invalid-email' },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0422', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json?.code }))
|
||
await apiStep('profile-management', 'edit personal profile', {
|
||
method: 'PUT', path: '/api/merchant/v1/iam/profile', token: merchantToken, body: personalUpdate,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(String(json.data?.userId), String(personal.json.data.userId), 'personal user id')
|
||
assertEqual(json.data?.displayName, personalUpdate.displayName, 'displayName')
|
||
assertEqual(json.data?.email, personalUpdate.email, 'email')
|
||
}, ({ json }) => ({ userId: json.data.userId, username: json.data.username, displayName: json.data.displayName }))
|
||
|
||
const relogin = await apiStep('profile-management', 're-login after personal profile token revocation', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'accessToken')
|
||
}, () => ({ username }))
|
||
merchantToken = relogin.json.data.accessToken
|
||
merchantRefreshToken = relogin.json.data.refreshToken
|
||
|
||
await apiStep('profile-management', 'personal profile edit is persisted', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/profile', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.displayName, personalUpdate.displayName, 'displayName')
|
||
assertEqual(json.data?.email, personalUpdate.email, 'email')
|
||
}, ({ json }) => ({ userId: json.data.userId, username: json.data.username, displayName: json.data.displayName }))
|
||
}
|
||
|
||
async function rbacAndMemberModule() {
|
||
const permissions = await apiStep('rbac-member', 'load merchant permission resources', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/permissions', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertTrue(arrayData(json.data).length > 0, 'permission resources must not be empty')
|
||
}, ({ json }) => ({ permissionCount: arrayData(json.data).length }))
|
||
const desiredCodes = ['merchant:order:list', 'merchant:order:view']
|
||
const grantedPermissions = arrayData(permissions.json.data)
|
||
.filter((item) => item.status === 'ACTIVE' && desiredCodes.includes(item.permissionCode))
|
||
assertEqual(grantedPermissions.length, desiredCodes.length, 'order permission count')
|
||
const permissionIds = grantedPermissions.map((item) => item.id)
|
||
const orderListPermissionId = grantedPermissions
|
||
.find((item) => item.permissionCode === 'merchant:order:list')?.id
|
||
assertPresent(orderListPermissionId, 'merchant:order:list permission id')
|
||
|
||
const ownerPermissions = await apiStep('menu-permissions', 'load owner effective permissions', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
const values = arrayData(json.data)
|
||
for (const code of requiredOwnerBackendPermissions()) {
|
||
assertTrue(values.includes(code), `owner permission is missing: ${code}`)
|
||
}
|
||
}, ({ json }) => ({
|
||
backendPermissionCount: arrayData(json.data).length,
|
||
visibleClientMenus: visibleClientMenus(arrayData(json.data)),
|
||
}))
|
||
assertSetEqual(
|
||
visibleClientMenus(arrayData(ownerPermissions.json.data)),
|
||
['account:profile:view', 'company:member:list', 'company:permission:list', 'company:profile:view',
|
||
'company:role:list', 'dashboard:view', 'notification:inbox:view', 'order:first-leg:list'],
|
||
'owner visible Client menus',
|
||
)
|
||
|
||
await apiStep('rbac-member', 'load initial merchant roles', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/roles', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertTrue(Array.isArray(json.data), 'role list must be an array')
|
||
}, ({ json }) => ({ roleCount: arrayData(json.data).length }))
|
||
|
||
const roleCode = `E2E_${marker.replaceAll('-', '_')}`.slice(0, 64)
|
||
const createdRole = await apiStep('rbac-member', 'create custom role', {
|
||
method: 'POST', path: '/api/merchant/v1/iam/roles', token: merchantToken,
|
||
body: { roleCode, roleName: `订单只读-${marker}`.slice(0, 64), description: `Client E2E ${marker}` },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.id, 'role id')
|
||
assertEqual(json.data?.roleCode, roleCode, 'roleCode')
|
||
assertEqual(json.data?.roleType, 'CUSTOM', 'roleType')
|
||
}, ({ json }) => ({ roleId: json.data.id, roleCode: json.data.roleCode, roleType: json.data.roleType, status: json.data.status }))
|
||
const roleId = createdRole.json.data.id
|
||
report.resources.roleId = roleId
|
||
|
||
await apiStep('rbac-member', 'grant permissions to custom role', {
|
||
method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken,
|
||
body: { roleId, permissionIds },
|
||
}, ({ json }) => requireSuccess(json), () => ({ roleId, permissionIds, permissionCodes: desiredCodes }))
|
||
|
||
await apiStep('rbac-member', 'custom role permission detail is persisted', {
|
||
method: 'GET', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertSetEqual(arrayData(json.data?.permissionIds).map(String), permissionIds.map(String), 'role permissionIds')
|
||
}, ({ json }) => ({ roleId: json.data.id, roleName: json.data.roleName, permissionIds: json.data.permissionIds }))
|
||
|
||
await apiStep('rbac-member', 'edit custom role', {
|
||
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken,
|
||
body: { roleName: `订单查询员-${marker}`.slice(0, 64), description: `edited ${marker}` },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.description, `edited ${marker}`, 'role description')
|
||
}, ({ json }) => ({ roleId: json.data.id, roleName: json.data.roleName, description: json.data.description }))
|
||
|
||
await apiStep('rbac-member', 'disable custom role', {
|
||
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'DISABLED' },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'DISABLED', 'role status')
|
||
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
|
||
await apiStep('rbac-member', 'enable custom role', {
|
||
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'ACTIVE' },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'ACTIVE', 'role status')
|
||
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
|
||
|
||
const memberUsername = `member_${compactTimestamp(new Date())}_${randomUUID().slice(0, 5)}`.slice(0, 64)
|
||
const memberPassword = `Member${randomUUID().replaceAll('-', '').slice(0, 14)}8!`
|
||
const resetMemberPassword = `Reset${randomUUID().replaceAll('-', '').slice(0, 15)}7!`
|
||
const memberBody = {
|
||
username: memberUsername,
|
||
password: memberPassword,
|
||
displayName: `测试订单成员-${marker}`.slice(0, 64),
|
||
phone: `137${`${Date.now() + 1}`.slice(-8)}`,
|
||
email: `${memberUsername}@example.com`,
|
||
memberType: 'EMPLOYEE',
|
||
memberNo: `EMP-${marker}`.slice(0, 64),
|
||
roleIds: [roleId],
|
||
}
|
||
await softApiStep('member-validation', 'member with weak password and invalid email is rejected', {
|
||
method: 'POST', path: '/api/merchant/v1/iam/members', token: merchantToken,
|
||
body: { ...memberBody, username: `bad_${randomUUID().slice(0, 8)}`, password: 'password', email: 'invalid-email' },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0422', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json?.code }))
|
||
const createdMember = await apiStep('rbac-member', 'create enterprise member with role', {
|
||
method: 'POST', path: '/api/merchant/v1/iam/members', token: merchantToken, body: memberBody,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.membershipId, 'membership id')
|
||
assertEqual(json.data?.memberType, 'EMPLOYEE', 'memberType')
|
||
assertSetEqual(arrayData(json.data?.roleIds).map(String), [String(roleId)], 'member roleIds')
|
||
}, ({ json }) => ({ membershipId: json.data.membershipId, memberType: json.data.memberType, status: json.data.status, roleIds: json.data.roleIds }))
|
||
const membershipId = createdMember.json.data.membershipId
|
||
report.resources.testMembershipId = membershipId
|
||
|
||
await apiStep('rbac-member', 'enterprise member list contains created member and roles', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/members', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
const member = arrayData(json.data).find((item) => String(item.membershipId) === String(membershipId))
|
||
assertPresent(member, 'created member list row')
|
||
assertEqual(member.displayName, memberBody.displayName, 'member displayName')
|
||
assertEqual(member.status, 'ACTIVE', 'member status')
|
||
assertSetEqual(arrayData(member.roleIds).map(String), [String(roleId)], 'member list roleIds')
|
||
}, ({ json }) => ({ memberCount: arrayData(json.data).length, membershipId }))
|
||
|
||
await apiStep('rbac-member', 'enterprise member detail matches created fields and roles', {
|
||
method: 'GET', path: `/api/merchant/v1/iam/members/${membershipId}`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(String(json.data?.membershipId), String(membershipId), 'membership id')
|
||
assertEqual(json.data?.displayName, memberBody.displayName, 'member displayName')
|
||
assertEqual(json.data?.phone, memberBody.phone, 'member phone')
|
||
assertEqual(json.data?.email, memberBody.email, 'member email')
|
||
assertEqual(json.data?.memberNo, memberBody.memberNo, 'memberNo')
|
||
assertSetEqual(arrayData(json.data?.roleIds).map(String), [String(roleId)], 'member detail roleIds')
|
||
}, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status, roleIds: json.data.roleIds }))
|
||
|
||
await apiStep('rbac-member', 'duplicate member username is rejected', {
|
||
method: 'POST', path: '/api/merchant/v1/iam/members', token: merchantToken, body: memberBody,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0409', 'business code')
|
||
}, ({ json }) => ({ username: memberUsername, rejectionCode: json.code }))
|
||
await apiStep('rbac-member', 'assigned custom role cannot be deleted', {
|
||
method: 'DELETE', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0409', 'business code')
|
||
}, ({ json }) => ({ roleId, rejectionCode: json.code }))
|
||
await apiStep('rbac-member', 'enterprise owner cannot be disabled', {
|
||
method: 'PUT', path: `/api/merchant/v1/iam/members/${report.resources.membershipId}/status`, token: merchantToken,
|
||
body: { status: 'DISABLED' },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0403', 'business code')
|
||
}, ({ json }) => ({ membershipId: report.resources.membershipId, rejectionCode: json.code }))
|
||
await apiStep('rbac-member', 'enterprise owner cannot be edited through member API', {
|
||
method: 'PUT', path: `/api/merchant/v1/iam/members/${report.resources.membershipId}`, token: merchantToken,
|
||
body: {
|
||
displayName: '非法修改企业所有者', phone: legalPhone, email: `${username}@example.com`,
|
||
memberType: 'OWNER', memberNo: 'OWNER', roleIds: [],
|
||
},
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0403', 'business code')
|
||
}, ({ json }) => ({ membershipId: report.resources.membershipId, rejectionCode: json.code }))
|
||
|
||
const memberLogin = await apiStep('rbac-member', 'new enterprise member can login', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'member accessToken')
|
||
}, () => ({ username: memberUsername }))
|
||
let memberToken = memberLogin.json.data.accessToken
|
||
|
||
await apiStep('rbac-member', 'member can use granted order list permission', {
|
||
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
||
}, ({ json }) => requireSuccess(json), ({ json }) => ({ total: json.data?.total }))
|
||
await apiStep('rbac-member', 'member can view order with granted permission', {
|
||
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(String(json.data?.order?.id), String(report.resources.orderIds[0]), 'order id')
|
||
}, ({ json }) => ({ orderId: json.data.order.id, status: json.data.order.status }))
|
||
|
||
await apiStep('menu-permissions', 'read-only member effective permissions match role', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertSetEqual(arrayData(json.data), desiredCodes, 'member backend permissions')
|
||
assertSetEqual(
|
||
visibleClientMenus(arrayData(json.data)),
|
||
['account:profile:view', 'dashboard:view', 'order:first-leg:list'],
|
||
'member visible Client menus',
|
||
)
|
||
}, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) }))
|
||
|
||
const disabledRole = await softApiStep('menu-permissions', 'disable assigned role', {
|
||
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'DISABLED' },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'DISABLED', 'role status')
|
||
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
|
||
if (disabledRole) {
|
||
await softApiStep('menu-permissions', 'disabled role revokes stale token permission query', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 401, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0401', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
||
await softApiStep('menu-permissions', 'disabled role blocks stale token order access', {
|
||
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
||
}, ({ json, status }) => {
|
||
assertTrue([200, 401, 403].includes(status), `unexpected HTTP status ${status}`)
|
||
assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`)
|
||
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
||
|
||
const disabledRoleLogin = await apiStep('menu-permissions', 'member login succeeds while role is disabled', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'member accessToken')
|
||
}, () => ({ username: memberUsername }))
|
||
memberToken = disabledRoleLogin.json.data.accessToken
|
||
await apiStep('menu-permissions', 'disabled role removes member menus and permissions', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(arrayData(json.data).length, 0, 'effective backend permission count')
|
||
assertSetEqual(visibleClientMenus(arrayData(json.data)), ['account:profile:view', 'dashboard:view'], 'visible Client menus')
|
||
}, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) }))
|
||
await softApiStep('menu-permissions', 'disabled role blocks member order list', {
|
||
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
||
}, ({ json, status }) => {
|
||
assertTrue([200, 403].includes(status), `unexpected HTTP status ${status}`)
|
||
assertEqual(String(json?.code), 'A0403', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json?.code }))
|
||
|
||
await apiStep('menu-permissions', 're-enable assigned role', {
|
||
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'ACTIVE' },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'ACTIVE', 'role status')
|
||
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
|
||
const enabledRoleLogin = await apiStep('menu-permissions', 'member re-login after role is enabled', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'member accessToken')
|
||
}, () => ({ username: memberUsername }))
|
||
memberToken = enabledRoleLogin.json.data.accessToken
|
||
await apiStep('menu-permissions', 're-enabled role restores member menus', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertSetEqual(arrayData(json.data), desiredCodes, 'member backend permissions')
|
||
assertSetEqual(visibleClientMenus(arrayData(json.data)), ['account:profile:view', 'dashboard:view', 'order:first-leg:list'], 'visible Client menus')
|
||
}, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) }))
|
||
} else {
|
||
recordSkipped('menu-permissions', 'role disable dependent authorization checks',
|
||
'Role status change failed; dependent checks require a successfully disabled role')
|
||
}
|
||
|
||
const reducedPermissions = await softApiStep('menu-permissions', 'remove order view permission from assigned role', {
|
||
method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken,
|
||
body: { roleId, permissionIds: [orderListPermissionId] },
|
||
}, ({ json }) => requireSuccess(json), () => ({ roleId, permissionCodes: [desiredCodes[0]] }))
|
||
if (reducedPermissions) {
|
||
await softApiStep('menu-permissions', 'permission removal revokes stale member token', {
|
||
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
|
||
}, ({ json, status }) => {
|
||
assertTrue([200, 401, 403].includes(status), `unexpected HTTP status ${status}`)
|
||
assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`)
|
||
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
||
|
||
const reducedPermissionLogin = await apiStep('menu-permissions', 'member re-login after permission removal', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'member accessToken')
|
||
}, () => ({ username: memberUsername }))
|
||
memberToken = reducedPermissionLogin.json.data.accessToken
|
||
await apiStep('menu-permissions', 'permission removal is reflected after re-login', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertSetEqual(arrayData(json.data), [desiredCodes[0]], 'reduced member permissions')
|
||
}, ({ json }) => ({ backendPermissions: json.data }))
|
||
await apiStep('menu-permissions', 'member without view permission cannot read order detail', {
|
||
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
|
||
}, ({ json, status }) => {
|
||
assertTrue([200, 403].includes(status), `unexpected HTTP status ${status}`)
|
||
assertEqual(String(json?.code), 'A0403', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json?.code }))
|
||
|
||
await apiStep('menu-permissions', 'restore assigned role permissions', {
|
||
method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken,
|
||
body: { roleId, permissionIds },
|
||
}, ({ json }) => requireSuccess(json), () => ({ roleId, permissionCodes: desiredCodes }))
|
||
const restoredPermissionLogin = await apiStep('menu-permissions', 'member re-login after permission restoration', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'member accessToken')
|
||
}, () => ({ username: memberUsername }))
|
||
memberToken = restoredPermissionLogin.json.data.accessToken
|
||
await apiStep('menu-permissions', 'restored permission allows order detail again', {
|
||
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(String(json.data?.order?.id), String(report.resources.orderIds[0]), 'order id')
|
||
}, ({ json }) => ({ orderId: json.data.order.id }))
|
||
} else {
|
||
recordSkipped('menu-permissions', 'permission removal dependent authorization checks',
|
||
'Role permission update failed; dependent checks require a successfully reduced permission set')
|
||
}
|
||
|
||
const editedMemberBody = {
|
||
displayName: `已编辑订单成员-${marker}`.slice(0, 64),
|
||
phone: memberBody.phone,
|
||
email: `edited_${memberUsername}@example.com`,
|
||
memberType: 'EMPLOYEE', memberNo: memberBody.memberNo, roleIds: [roleId],
|
||
}
|
||
await apiStep('rbac-member', 'edit enterprise member', {
|
||
method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}`, token: merchantToken, body: editedMemberBody,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.displayName, editedMemberBody.displayName, 'member displayName')
|
||
assertEqual(json.data?.email, editedMemberBody.email, 'member email')
|
||
}, ({ json }) => ({ membershipId: json.data.membershipId, displayName: json.data.displayName, status: json.data.status }))
|
||
|
||
await apiStep('rbac-member', 'reset enterprise member password', {
|
||
method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/password`, token: merchantToken,
|
||
body: { newPassword: resetMemberPassword },
|
||
}, ({ json }) => requireSuccess(json), () => ({ membershipId }))
|
||
await apiStep('rbac-member', 'member old password is rejected after reset', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0401', 'business code')
|
||
}, ({ json }) => ({ username: memberUsername, rejectionCode: json.code }))
|
||
const resetLogin = await apiStep('rbac-member', 'member can login with reset password', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'member accessToken')
|
||
}, () => ({ username: memberUsername }))
|
||
memberToken = resetLogin.json.data.accessToken
|
||
|
||
await apiStep('rbac-member', 'disable enterprise member', {
|
||
method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/status`, token: merchantToken, body: { status: 'DISABLED' },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'DISABLED', 'member status')
|
||
}, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status }))
|
||
await softApiStep('rbac-member', 'disabled member stale token is rejected', {
|
||
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
||
}, ({ json, status }) => {
|
||
assertTrue([200, 401, 403].includes(status), `unexpected HTTP status ${status}`)
|
||
assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`)
|
||
}, ({ json }) => ({ membershipId, rejectionCode: json?.code }))
|
||
await apiStep('rbac-member', 'disabled member login is rejected', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`)
|
||
}, ({ json }) => ({ username: memberUsername, rejectionCode: json.code }))
|
||
await apiStep('rbac-member', 'enable enterprise member', {
|
||
method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/status`, token: merchantToken, body: { status: 'ACTIVE' },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'ACTIVE', 'member status')
|
||
}, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status }))
|
||
|
||
const enabledMemberLogin = await apiStep('rbac-member', 'member re-login after account is enabled', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'member accessToken')
|
||
}, () => ({ username: memberUsername }))
|
||
memberToken = enabledMemberLogin.json.data.accessToken
|
||
await apiStep('rbac-member', 're-enabled member can list orders before roles are cleared', {
|
||
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
||
}, ({ json }) => requireSuccess(json), ({ json }) => ({ total: json.data?.total }))
|
||
|
||
await apiStep('rbac-member', 'clear enterprise member roles', {
|
||
method: 'PUT', path: '/api/merchant/v1/iam/members/roles', token: merchantToken,
|
||
body: { membershipId, roleIds: [] },
|
||
}, ({ json }) => requireSuccess(json), () => ({ membershipId, roleIds: [] }))
|
||
await apiStep('rbac-member', 'member role clearing is persisted', {
|
||
method: 'GET', path: `/api/merchant/v1/iam/members/${membershipId}/roles`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(String(json.data?.membershipId), String(membershipId), 'membership id')
|
||
assertEqual(arrayData(json.data?.roleIds).length, 0, 'member role count')
|
||
}, ({ json }) => ({ membershipId: json.data.membershipId, roleIds: json.data.roleIds }))
|
||
await softApiStep('rbac-member', 'role clearing revokes stale member token', {
|
||
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 401, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0401', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json.code }))
|
||
const noRoleLogin = await apiStep('rbac-member', 'member re-login after role clearing', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'member accessToken')
|
||
}, () => ({ username: memberUsername }))
|
||
memberToken = noRoleLogin.json.data.accessToken
|
||
await apiStep('rbac-member', 'member without roles cannot list orders', {
|
||
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
|
||
}, ({ json, status }) => {
|
||
assertTrue([200, 403].includes(status), `unexpected HTTP status ${status}`)
|
||
assertEqual(String(json?.code), 'A0403', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json.code }))
|
||
|
||
await apiStep('rbac-member', 'disable test member after verification', {
|
||
method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/status`, token: merchantToken, body: { status: 'DISABLED' },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'DISABLED', 'member status')
|
||
}, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status }))
|
||
await apiStep('rbac-member', 'delete unassigned custom role', {
|
||
method: 'DELETE', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken,
|
||
}, ({ json }) => requireSuccess(json), () => ({ roleId }))
|
||
report.resources.roleId = null
|
||
}
|
||
|
||
async function orderLifecycleModule() {
|
||
const masterData = await loadOrderMasterData(merchantToken, 'order-master-data')
|
||
await estimateAvailablePrice(masterData)
|
||
const productImageFileId = await uploadProductImage(merchantToken, 'order-product-image')
|
||
report.resources.productImageFileId = productImageFileId
|
||
|
||
const invalidImageForm = new FormData()
|
||
invalidImageForm.append('file', new Blob(['not-an-image'], { type: 'text/plain' }), `invalid-product-${marker}.txt`)
|
||
await softApiStep('order-product-image', 'invalid product image MIME type is rejected', {
|
||
method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: invalidImageForm,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0400', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
||
|
||
const emptyImageForm = new FormData()
|
||
emptyImageForm.append('file', new Blob([], { type: 'image/png' }), `empty-product-${marker}.png`)
|
||
await softApiStep('order-product-image', 'empty product image is rejected', {
|
||
method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: emptyImageForm,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0400', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
||
|
||
const forgedImageForm = new FormData()
|
||
forgedImageForm.append('file', new Blob(['not-a-real-png'], { type: 'image/png' }), `forged-product-${marker}.png`)
|
||
await softApiStep('order-product-image', 'forged product image signature is rejected', {
|
||
method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: forgedImageForm,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0400', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
||
|
||
const oversizedImageForm = new FormData()
|
||
oversizedImageForm.append('file', new Blob([Buffer.alloc(5 * 1024 * 1024 + 1)], { type: 'image/png' }), `oversized-product-${marker}.png`)
|
||
await softApiStep('order-product-image', 'product image over 5MB is rejected', {
|
||
method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: oversizedImageForm,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0400', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
||
|
||
await apiStep('order-lifecycle', 'download own product image', {
|
||
method: 'GET', path: `/api/merchant/v1/files/product-images/${productImageFileId}`, token: merchantToken,
|
||
responseType: 'bytes',
|
||
}, ({ status, bytes, headers }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertTrue(bytes?.length > 0, 'downloaded image must not be empty')
|
||
assertTrue((headers.get('content-type') ?? '').startsWith('image/'), 'download must return an image')
|
||
}, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') }))
|
||
|
||
const payload = buildOrderPayload(masterData, productImageFileId, 'ACTIVE', 'fresh draft after approval')
|
||
const incompleteSubmitCases = [
|
||
{
|
||
name: 'FBA order missing Amazon identifiers',
|
||
mutate(value) {
|
||
value.amazonWarehouseCode = null
|
||
value.shipmentId = null
|
||
value.referenceId = null
|
||
},
|
||
},
|
||
{
|
||
name: 'FBA cargo missing SKU UPC shipment and reference',
|
||
mutate(value) {
|
||
value.cargoes[0].skuNo = null
|
||
value.cargoes[0].upcNo = null
|
||
value.cargoes[0].shipmentId = null
|
||
value.cargoes[0].referenceId = null
|
||
},
|
||
},
|
||
]
|
||
for (const testCase of incompleteSubmitCases) {
|
||
const incompletePayload = structuredClone(payload)
|
||
testCase.mutate(incompletePayload)
|
||
const draft = await apiStep('order-validation', `${testCase.name} can be saved as a draft`, {
|
||
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
|
||
headers: { 'Idempotency-Key': newIdempotencyKey('incomplete-draft') }, body: incompletePayload,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data, 'incomplete draft order id')
|
||
}, ({ json }) => ({ orderId: json.data, status: 'DRAFT' }))
|
||
report.resources.orderIds.push(draft.json.data)
|
||
await softApiStep('order-validation', `${testCase.name} is rejected on submit`, {
|
||
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${draft.json.data}/submit`, token: merchantToken,
|
||
headers: { 'Idempotency-Key': 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')
|
||
await apiStep('order-lifecycle', 'submit order', {
|
||
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
|
||
headers: { 'Idempotency-Key': submitKey },
|
||
}, ({ json }) => requireSuccess(json), () => ({ orderId }))
|
||
|
||
await apiStep('order-lifecycle', 'submit order idempotent replay', {
|
||
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
|
||
headers: { 'Idempotency-Key': submitKey },
|
||
}, ({ json }) => requireSuccess(json), () => ({ orderId, replayed: true }))
|
||
|
||
await apiStep('order-lifecycle', 'submitted status is persisted', {
|
||
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.order?.status, 'SUBMITTED', 'order status')
|
||
}, ({ json }) => ({ orderId, orderNo: json.data.order.orderNo, status: json.data.order.status }))
|
||
|
||
await apiStep('order-filters', 'SUBMITTED status filter contains submitted order', {
|
||
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=50&status=SUBMITTED', token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'submitted status-filtered order')
|
||
}, ({ json }) => ({ total: json.data?.total, orderId, status: 'SUBMITTED' }))
|
||
|
||
await 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,
|
||
headers: { 'Idempotency-Key': newIdempotencyKey('cancelled-submit') },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0409', 'business code')
|
||
}, ({ json }) => ({ orderId: cancelOrderId, rejectionCode: json.code, message: json.message }))
|
||
await softApiStep('order-boundary', 'invalid order page size is rejected', {
|
||
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=201', token: merchantToken,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0422', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json.code }))
|
||
}
|
||
|
||
async function isolationModule() {
|
||
if (!existingClientUsername || !existingClientPassword) {
|
||
recordSkipped('isolation', 'cross-client order access is rejected',
|
||
'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check')
|
||
return
|
||
}
|
||
const login = await apiStep('isolation', 'existing client login', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: existingClientUsername, password: existingClientPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'accessToken')
|
||
}, () => ({ username: existingClientUsername }))
|
||
existingClientToken = login.json.data.accessToken
|
||
const orderId = report.resources.orderIds[1]
|
||
await apiStep('isolation', 'cross-client order access is rejected', {
|
||
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: existingClientToken,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
|
||
}, ({ json }) => ({ orderId, rejectionCode: json.code }))
|
||
await softApiStep('isolation', 'cross-client product image access is rejected', {
|
||
method: 'GET', path: `/api/merchant/v1/files/product-images/${report.resources.productImageFileId}`,
|
||
token: existingClientToken,
|
||
}, ({ json, status }) => {
|
||
assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`)
|
||
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
|
||
}, ({ json }) => ({ fileId: report.resources.productImageFileId, rejectionCode: json?.code }))
|
||
await softApiStep('isolation', 'cross-client member detail access is rejected', {
|
||
method: 'GET', path: `/api/merchant/v1/iam/members/${report.resources.testMembershipId}`, token: existingClientToken,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
|
||
}, ({ json }) => ({ membershipId: report.resources.testMembershipId, rejectionCode: json?.code }))
|
||
await apiStep('isolation', 'existing client address list excludes new enterprise address', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/tenant/addresses', token: existingClientToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertTrue(!arrayData(json.data).some((item) => String(item.id) === String(report.resources.enterpriseAddressId)),
|
||
'cross-client address must not be visible')
|
||
}, ({ json }) => ({ addressCount: arrayData(json.data).length, excludedAddressId: report.resources.enterpriseAddressId }))
|
||
await apiStep('isolation', 'existing client qualification list excludes new enterprise qualification', {
|
||
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: existingClientToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertTrue(!arrayData(json.data).some((item) => String(item.id ?? item.qualificationId)
|
||
=== String(report.resources.businessLicenseQualificationId)), 'cross-client qualification must not be visible')
|
||
}, ({ json }) => ({ qualificationCount: arrayData(json.data).length,
|
||
excludedQualificationId: report.resources.businessLicenseQualificationId }))
|
||
await softApiStep('isolation', 'cross-client qualification file access is rejected', {
|
||
method: 'GET', path: apiPath(report.resources.businessLicenseDocumentUrl), token: existingClientToken,
|
||
}, ({ json, status }) => {
|
||
assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`)
|
||
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
|
||
}, ({ json }) => ({ qualificationId: report.resources.businessLicenseQualificationId, rejectionCode: json?.code }))
|
||
}
|
||
|
||
async function notificationInboxModule() {
|
||
const module = 'notification-inbox'
|
||
const templateBase = '/api/platform/v1/notification-templates'
|
||
const inboxBase = '/api/merchant/v1/notifications'
|
||
const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-24).toUpperCase()
|
||
const templateCode = `CLIENT_INBOX_${suffix}`.slice(0, 64)
|
||
const category = 'CLIENT_ACCEPTANCE'
|
||
let template = null
|
||
|
||
try {
|
||
const createTemplate = await apiStep(module, 'platform creates client inbox template', {
|
||
method: 'POST', path: templateBase, token: platformToken,
|
||
body: {
|
||
templateCode,
|
||
templateName: `客户消息闭环-${marker}`.slice(0, 128),
|
||
category,
|
||
titleTemplate: '客户消息验收 {{sequence}}',
|
||
bodyTemplate: '客户 {{marker}} 的第 {{sequence}} 条真实站内消息。',
|
||
variableWhitelist: ['marker', 'sequence'],
|
||
},
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.id, 'notification template id')
|
||
assertEqual(json.data?.status, 'DRAFT', 'notification template status')
|
||
assertEqual(json.data?.channel, 'IN_APP', 'notification template channel')
|
||
}, ({ json }) => ({ templateId: json.data.id, templateCode: json.data.templateCode, status: json.data.status }))
|
||
template = createTemplate.json.data
|
||
report.resources.notificationTemplateId = template.id
|
||
|
||
const activateTemplate = await apiStep(module, 'platform activates client inbox template', {
|
||
method: 'PUT', path: `${templateBase}/${template.id}/status`, token: platformToken,
|
||
body: { targetStatus: 'ACTIVE', version: template.version },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'ACTIVE', 'notification template status')
|
||
}, ({ json }) => ({ templateId: json.data.id, status: json.data.status, version: json.data.version }))
|
||
template = activateTemplate.json.data
|
||
|
||
await apiStep(module, 'client normalizes existing inbox messages to read', {
|
||
method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertTrue(Number(json.data?.updatedCount) >= 0, 'updatedCount must be non-negative')
|
||
}, ({ json }) => ({ updatedCount: json.data.updatedCount }))
|
||
|
||
await apiStep(module, 'client unread baseline is zero', {
|
||
method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(Number(json.data?.unreadCount), 0, 'unread baseline')
|
||
}, ({ json }) => ({ unreadCount: json.data.unreadCount }))
|
||
|
||
const sendMessage = async (sequence) => apiStep(module, `platform sends client message ${sequence}`, {
|
||
method: 'POST', path: '/api/platform/v1/notifications', token: platformToken,
|
||
body: {
|
||
tenantId: String(report.resources.tenantId),
|
||
templateCode,
|
||
recipientType: 'MEMBERSHIP',
|
||
recipientIds: [String(report.resources.membershipId)],
|
||
variables: { marker, sequence: String(sequence) },
|
||
scheduledAt: null,
|
||
idempotencyKey: newIdempotencyKey(`client-notification-${sequence}`),
|
||
sourceEventId: `client-e2e-notification-${sequence}-${marker}`.slice(0, 128),
|
||
},
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'SENT', 'notification task status')
|
||
assertEqual(Number(json.data?.recipientCount), 1, 'notification recipient count')
|
||
}, ({ json }) => ({ taskId: json.data.id, taskNo: json.data.taskNo, status: json.data.status, recipientCount: json.data.recipientCount }))
|
||
|
||
await sendMessage(1)
|
||
|
||
await apiStep(module, 'client unread count increments after first delivery', {
|
||
method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(Number(json.data?.unreadCount), 1, 'unread count after first delivery')
|
||
}, ({ json }) => ({ unreadCount: json.data.unreadCount }))
|
||
|
||
const firstUnreadPage = await apiStep(module, 'client filters first message as unread', {
|
||
method: 'GET', path: `${inboxBase}?page=1&size=20&status=UNREAD&category=${category}`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
const message = pageRecords(json.data).find((item) => item.title === `客户消息验收 1`)
|
||
assertPresent(message?.id, 'first unread notification id')
|
||
assertEqual(message.status, 'UNREAD', 'first notification status')
|
||
}, ({ json }) => ({ total: json.data.total, unreadIds: pageRecords(json.data).map((item) => item.id) }))
|
||
const firstMessage = pageRecords(firstUnreadPage.json.data).find((item) => item.title === '客户消息验收 1')
|
||
const firstMessageId = firstMessage.id
|
||
report.resources.notificationMessageIds.push(firstMessageId)
|
||
|
||
await apiStep(module, 'client reads first message detail while it is unread', {
|
||
method: 'GET', path: `${inboxBase}/${firstMessageId}`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(String(json.data?.id), String(firstMessageId), 'first notification id')
|
||
assertEqual(json.data?.status, 'UNREAD', 'first notification detail status')
|
||
assertTrue(String(json.data?.body).includes(marker), 'first notification body must contain marker')
|
||
}, ({ json }) => ({ messageId: json.data.id, status: json.data.status, category: json.data.category }))
|
||
|
||
if (existingClientToken) {
|
||
await apiStep(module, 'other client cannot read first message detail', {
|
||
method: 'GET', path: `${inboxBase}/${firstMessageId}`, token: existingClientToken,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
|
||
}, ({ json }) => ({ messageId: firstMessageId, rejectionCode: json.code }))
|
||
} else {
|
||
recordSkipped(module, 'other client cannot read first message detail', 'Cross-client credentials were not supplied')
|
||
}
|
||
|
||
await apiStep(module, 'client marks first message read', {
|
||
method: 'PUT', path: `${inboxBase}/${firstMessageId}/read`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'READ', 'first notification status after read')
|
||
assertPresent(json.data?.readAt, 'first notification readAt')
|
||
}, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt }))
|
||
|
||
await apiStep(module, 'single-message read is idempotent', {
|
||
method: 'PUT', path: `${inboxBase}/${firstMessageId}/read`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'READ', 'idempotent notification status')
|
||
}, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt }))
|
||
|
||
await apiStep(module, 'client filters first message as read', {
|
||
method: 'GET', path: `${inboxBase}?page=1&size=20&status=READ&category=${category}`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertTrue(pageRecords(json.data).some((item) => String(item.id) === String(firstMessageId) && item.status === 'READ'),
|
||
'read filter must contain first notification')
|
||
}, ({ json }) => ({ total: json.data.total, readIds: pageRecords(json.data).map((item) => item.id) }))
|
||
|
||
await apiStep(module, 'client unread count returns to zero after single read', {
|
||
method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(Number(json.data?.unreadCount), 0, 'unread count after single read')
|
||
}, ({ json }) => ({ unreadCount: json.data.unreadCount }))
|
||
|
||
await sendMessage(2)
|
||
const secondUnreadPage = await apiStep(module, 'client receives second unread message', {
|
||
method: 'GET', path: `${inboxBase}?page=1&size=20&status=UNREAD&category=${category}`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
const message = pageRecords(json.data).find((item) => item.title === '客户消息验收 2')
|
||
assertPresent(message?.id, 'second unread notification id')
|
||
assertEqual(message.status, 'UNREAD', 'second notification status')
|
||
}, ({ json }) => ({ total: json.data.total, unreadIds: pageRecords(json.data).map((item) => item.id) }))
|
||
const secondMessage = pageRecords(secondUnreadPage.json.data).find((item) => item.title === '客户消息验收 2')
|
||
const secondMessageId = secondMessage.id
|
||
report.resources.notificationMessageIds.push(secondMessageId)
|
||
|
||
if (existingClientToken) {
|
||
await apiStep(module, 'other client cannot see acceptance messages in inbox', {
|
||
method: 'GET', path: `${inboxBase}?page=1&size=200&category=${category}`, token: existingClientToken,
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
if (['A0403', 'A0404'].includes(String(json?.code))) return
|
||
requireSuccess(json)
|
||
const foreignIds = new Set(pageRecords(json.data).map((item) => String(item.id)))
|
||
assertTrue(!foreignIds.has(String(firstMessageId)) && !foreignIds.has(String(secondMessageId)),
|
||
'other client inbox must exclude acceptance messages')
|
||
}, ({ json }) => ({ rejectionCode: json?.code === '200' ? null : json?.code,
|
||
visibleCount: json?.code === '200' ? pageRecords(json.data).length : null,
|
||
excludedIds: [firstMessageId, secondMessageId] }))
|
||
} else {
|
||
recordSkipped(module, 'other client cannot see acceptance messages in inbox', 'Cross-client credentials were not supplied')
|
||
}
|
||
|
||
await apiStep(module, 'client unread count increments after second delivery', {
|
||
method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(Number(json.data?.unreadCount), 1, 'unread count after second delivery')
|
||
}, ({ json }) => ({ unreadCount: json.data.unreadCount }))
|
||
|
||
await apiStep(module, 'client marks all messages read', {
|
||
method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(Number(json.data?.updatedCount), 1, 'read-all updatedCount')
|
||
}, ({ json }) => ({ updatedCount: json.data.updatedCount }))
|
||
|
||
await apiStep(module, 'second message detail is read after read-all', {
|
||
method: 'GET', path: `${inboxBase}/${secondMessageId}`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'READ', 'second notification status after read-all')
|
||
assertPresent(json.data?.readAt, 'second notification readAt')
|
||
}, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt }))
|
||
|
||
await apiStep(module, 'client unread count is zero after read-all', {
|
||
method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(Number(json.data?.unreadCount), 0, 'unread count after read-all')
|
||
}, ({ json }) => ({ unreadCount: json.data.unreadCount }))
|
||
} finally {
|
||
if (merchantToken && report.resources.notificationMessageIds.length > 0) {
|
||
await softApiStep(module, 'cleanup keeps acceptance messages read', {
|
||
method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertTrue(Number(json.data?.updatedCount) >= 0, 'cleanup updatedCount must be non-negative')
|
||
}, ({ json }) => ({ updatedCount: json?.data?.updatedCount }))
|
||
await softApiStep(module, 'cleanup leaves no unread acceptance messages', {
|
||
method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(Number(json.data?.unreadCount), 0, 'cleanup unread count')
|
||
}, ({ json }) => ({ unreadCount: json?.data?.unreadCount }))
|
||
}
|
||
if (template?.status === 'ACTIVE') {
|
||
const disabled = await softApiStep(module, 'platform disables client inbox template', {
|
||
method: 'PUT', path: `${templateBase}/${template.id}/status`, token: platformToken,
|
||
body: { targetStatus: 'DISABLED', version: template.version },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.status, 'DISABLED', 'notification template final status')
|
||
}, ({ json }) => ({ templateId: json?.data?.id, status: json?.data?.status, version: json?.data?.version }))
|
||
if (disabled?.json?.data) template = disabled.json.data
|
||
}
|
||
report.notificationInbox = {
|
||
templateId: report.resources.notificationTemplateId,
|
||
templateCode,
|
||
templateStatus: template?.status ?? null,
|
||
messageIds: [...report.resources.notificationMessageIds],
|
||
retainedAsRead: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
async function accountSecurityModule() {
|
||
const consumedRefreshToken = merchantRefreshToken
|
||
const rotated = await apiStep('account-security', 'rotate refresh token', {
|
||
method: 'POST', path: '/api/auth/refresh', body: { refreshToken: merchantRefreshToken },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'rotated accessToken')
|
||
assertPresent(json.data?.refreshToken, 'rotated refreshToken')
|
||
}, () => ({ rotated: true }))
|
||
merchantToken = rotated.json.data.accessToken
|
||
merchantRefreshToken = rotated.json.data.refreshToken
|
||
await apiStep('account-security', 'consumed refresh token cannot be replayed', {
|
||
method: 'POST', path: '/api/auth/refresh', body: { refreshToken: consumedRefreshToken },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0401', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json?.code }))
|
||
|
||
temporaryOwnerPassword = `Temporary${randomUUID().replaceAll('-', '').slice(0, 14)}6!`
|
||
await apiStep('account-security', 'change owner password to temporary value', {
|
||
method: 'PUT', path: '/api/auth/password', token: merchantToken,
|
||
body: { oldPassword: clientPassword, newPassword: temporaryOwnerPassword },
|
||
}, ({ json }) => requireSuccess(json), () => ({ username, passwordChanged: true }))
|
||
ownerPasswordNeedsRestore = true
|
||
|
||
await apiStep('account-security', 'original password is rejected after change', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0401', 'business code')
|
||
}, ({ json }) => ({ username, rejectionCode: json.code }))
|
||
|
||
const temporaryLogin = await apiStep('account-security', 'temporary password login succeeds', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: username, password: temporaryOwnerPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'accessToken')
|
||
}, () => ({ username }))
|
||
merchantToken = temporaryLogin.json.data.accessToken
|
||
|
||
await apiStep('account-security', 'restore owner password', {
|
||
method: 'PUT', path: '/api/auth/password', token: merchantToken,
|
||
body: { oldPassword: temporaryOwnerPassword, newPassword: clientPassword },
|
||
}, ({ json }) => requireSuccess(json), () => ({ username, passwordRestored: true }))
|
||
ownerPasswordNeedsRestore = false
|
||
temporaryOwnerPassword = undefined
|
||
|
||
const restoredLogin = await apiStep('account-security', 'restored owner password login succeeds', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'accessToken')
|
||
assertPresent(json.data?.refreshToken, 'refreshToken')
|
||
}, () => ({ username, passwordRestored: true }))
|
||
merchantToken = restoredLogin.json.data.accessToken
|
||
merchantRefreshToken = restoredLogin.json.data.refreshToken
|
||
|
||
const loggedOutRefreshToken = merchantRefreshToken
|
||
await apiStep('account-security', 'logout current client session', {
|
||
method: 'POST', path: '/api/auth/logout', token: merchantToken, body: { refreshToken: loggedOutRefreshToken },
|
||
}, ({ json }) => requireSuccess(json), () => ({ username }))
|
||
await apiStep('account-security', 'logged-out refresh token is rejected', {
|
||
method: 'POST', path: '/api/auth/refresh', body: { refreshToken: loggedOutRefreshToken },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0401', 'business code')
|
||
}, ({ json }) => ({ rejectionCode: json.code }))
|
||
await apiStep('account-security', 'repeated logout with revoked session is rejected', {
|
||
method: 'POST', path: '/api/auth/logout', body: { refreshToken: loggedOutRefreshToken },
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 401, 'HTTP status')
|
||
assertEqual(String(json?.code), 'A0401', 'business code')
|
||
}, ({ json }) => ({ repeated: true, rejectionCode: json?.code }))
|
||
}
|
||
|
||
async function recoverOwnerPassword() {
|
||
try {
|
||
const login = await apiStep('account-security-recovery', 'login with temporary owner password for recovery', {
|
||
method: 'POST', path: '/api/auth/login', body: { loginId: username, password: temporaryOwnerPassword },
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.accessToken, 'recovery accessToken')
|
||
}, () => ({ username }))
|
||
await apiStep('account-security-recovery', 'restore owner password after interrupted test', {
|
||
method: 'PUT', path: '/api/auth/password', token: login.json.data.accessToken,
|
||
body: { oldPassword: temporaryOwnerPassword, newPassword: clientPassword },
|
||
}, ({ json }) => requireSuccess(json), () => ({ username, passwordRestored: true }))
|
||
ownerPasswordNeedsRestore = false
|
||
temporaryOwnerPassword = undefined
|
||
} catch (error) {
|
||
report.fatal ??= sanitizeError(error)
|
||
report.passwordRecovery = { success: false, error: sanitizeError(error) }
|
||
}
|
||
}
|
||
|
||
async function loadOrderMasterData(token, module) {
|
||
const receivingWarehouses = await apiStep(module, 'load active domestic receiving warehouses', {
|
||
method: 'GET', path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', token,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertTrue(pageRecords(json.data).length > 0, 'at least one active DOMESTIC warehouse is required')
|
||
}, ({ json }) => ({ total: json.data?.total, warehouseCount: pageRecords(json.data).length }))
|
||
const receivingCandidates = pageRecords(receivingWarehouses.json.data)
|
||
|
||
const destinationWarehouses = await apiStep(module, 'load active FBA destination warehouses', {
|
||
method: 'GET', path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', token,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertTrue(pageRecords(json.data).length > 0, 'at least one active FBA warehouse is required')
|
||
}, ({ json }) => ({ total: json.data?.total, warehouseCount: pageRecords(json.data).length }))
|
||
const destination = pageRecords(destinationWarehouses.json.data)[0]
|
||
let receiving = null
|
||
let pricedChannels = []
|
||
for (const candidate of receivingCandidates) {
|
||
const response = await httpRequest({
|
||
method: 'GET', path: `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${candidate.id}`, token,
|
||
})
|
||
const candidates = String(response?.json?.code) === '200' ? arrayData(response.json.data) : []
|
||
const available = candidates.filter((item) => arrayData(item.pricingTaxModes).length > 0)
|
||
if (available.length > 0) {
|
||
receiving = candidate
|
||
pricedChannels = available
|
||
break
|
||
}
|
||
}
|
||
assertPresent(receiving, 'active DOMESTIC warehouse with pricing channels')
|
||
report.resources.receivingWarehouseId = receiving.id
|
||
report.resources.destinationWarehouseId = destination.id
|
||
|
||
await apiStep(module, 'load receiving warehouse detail', {
|
||
method: 'GET', path: `/api/merchant/v1/warehouses/${receiving.id}`, token,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertEqual(json.data?.warehouse?.warehouseType, 'DOMESTIC', 'receiving warehouse type')
|
||
assertTrue(arrayData(json.data?.addresses).length > 0, 'receiving warehouse address is required')
|
||
}, ({ json }) => ({ id: json.data.warehouse.id, name: json.data.warehouse.warehouseName }))
|
||
|
||
const channels = await apiStep(module, 'load active pricing channels', {
|
||
method: 'GET', path: `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${receiving.id}`, token,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertTrue(arrayData(json.data).length > 0, 'at least one active pricing channel is required')
|
||
}, ({ json }) => ({ channels: arrayData(json.data).map((item) => ({ channelCode: item.channelCode, priceModel: item.priceModel, pricingTaxModes: item.pricingTaxModes })) }))
|
||
const channel = pricedChannels.find((item) => arrayData(item.pricingTaxModes).length > 0)
|
||
assertPresent(channel, 'pricing channel with pricingTaxModes')
|
||
report.resources.channelCode = channel.channelCode
|
||
|
||
const partners = await apiStep(module, 'load public shipper candidates', {
|
||
method: 'GET', path: '/api/merchant/v1/partners/candidates?type=SHIPPER', token,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
}, ({ json }) => ({ candidateCount: arrayData(json.data).length }))
|
||
const partner = arrayData(partners.json.data).find((item) => item.status === 'ACTIVE' && arrayData(item.types).includes('SHIPPER'))
|
||
|
||
return {
|
||
receiving,
|
||
destination,
|
||
destinationCandidates: pageRecords(destinationWarehouses.json.data),
|
||
channel,
|
||
channelCandidates: pricedChannels,
|
||
partner,
|
||
}
|
||
}
|
||
|
||
async function estimateAvailablePrice(masterData) {
|
||
const destinations = masterData.destinationCandidates.slice(0, 30)
|
||
const channels = masterData.channelCandidates
|
||
let attempt = 0
|
||
for (const channel of channels) {
|
||
for (const destination of destinations) {
|
||
attempt += 1
|
||
const pricingTaxMode = channel.pricingTaxModes[0]
|
||
const estimate = await apiStep('pricing-estimate', `probe configured price ${attempt}`, {
|
||
method: 'POST', path: '/api/merchant/v1/first-leg/pricing/estimate', token: merchantToken,
|
||
body: {
|
||
originWarehouseId: masterData.receiving.id,
|
||
channelCode: channel.channelCode,
|
||
destinationCode: destination.warehouseCode,
|
||
destinationType: 'FBA',
|
||
destinationWarehouseId: destination.id,
|
||
destinationName: destination.warehouseName,
|
||
destinationRegion: null,
|
||
pricingTaxMode,
|
||
actualWeightKg: 20.5,
|
||
actualVolumeCbm: 0.024,
|
||
},
|
||
}, ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status')
|
||
assertTrue(['200', 'A0404'].includes(String(json?.code)), `unexpected business code ${json?.code}`)
|
||
if (String(json?.code) === '200') {
|
||
assertEqual(json.data?.channelCode, channel.channelCode, 'estimate channelCode')
|
||
assertEqual(json.data?.pricingTaxMode, pricingTaxMode, 'estimate pricingTaxMode')
|
||
assertTrue(Number(json.data?.chargeableQuantity) > 0, 'chargeableQuantity must be positive')
|
||
assertTrue(Number(json.data?.unitPrice) >= 0, 'unitPrice must not be negative')
|
||
assertTrue(Number(json.data?.estimatedAmount) >= 0, 'estimatedAmount must not be negative')
|
||
}
|
||
}, ({ json }) => String(json?.code) === '200' ? {
|
||
matched: true,
|
||
destinationWarehouseId: destination.id,
|
||
destinationCode: destination.warehouseCode,
|
||
priceBookVersionId: json.data.priceBookVersionId,
|
||
channelCode: json.data.channelCode,
|
||
priceModel: json.data.priceModel,
|
||
pricingTaxMode: json.data.pricingTaxMode,
|
||
chargeableQuantity: json.data.chargeableQuantity,
|
||
chargeUnit: json.data.chargeUnit,
|
||
unitPrice: json.data.unitPrice,
|
||
estimatedAmount: json.data.estimatedAmount,
|
||
currency: json.data.currency,
|
||
} : {
|
||
matched: false,
|
||
destinationWarehouseId: destination.id,
|
||
destinationCode: destination.warehouseCode,
|
||
channelCode: channel.channelCode,
|
||
code: json?.code,
|
||
message: json?.message,
|
||
})
|
||
if (String(estimate.json?.code) === '200') return estimate.json.data
|
||
}
|
||
}
|
||
throw new Error(`No configured price matched ${attempt} visible channel/destination combinations`)
|
||
}
|
||
|
||
async function uploadProductImage(token, module) {
|
||
const form = new FormData()
|
||
form.append('file', new Blob([testPng()], { type: 'image/png' }), `product-${marker}.png`)
|
||
const upload = await apiStep(module, 'upload product image', {
|
||
method: 'POST', path: '/api/merchant/v1/files/product-images', token, body: form,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertPresent(json.data?.fileId, 'product image fileId')
|
||
assertEqual(json.data?.contentType, 'image/png', 'product image contentType')
|
||
}, ({ json }) => ({ fileId: json.data.fileId, fileName: json.data.fileName, contentType: json.data.contentType, sizeBytes: json.data.sizeBytes }))
|
||
return upload.json.data.fileId
|
||
}
|
||
|
||
function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
|
||
const cargoShipmentId = `SHIP-${suffix}-${marker}`.slice(0, 64)
|
||
const cargoReferenceId = `REF-${suffix}-${marker}`.slice(0, 64)
|
||
const unitsPerPackage = 12
|
||
const packageCount = 2
|
||
const totalQuantity = unitsPerPackage * packageCount
|
||
const unitDeclaredValue = 8.5
|
||
return {
|
||
customerId: masterData.partner?.id ?? null,
|
||
customerName: masterData.partner?.partnerName ?? companyName,
|
||
transportMode: 'SEA',
|
||
tradeTerm: 'FOB',
|
||
destinationCountryCode: masterData.destination.countryCode,
|
||
destinationWarehouseId: masterData.destination.id,
|
||
destinationWarehouseName: masterData.destination.warehouseName,
|
||
expectedShipDate: addDays(new Date(), 30),
|
||
customerRemark: `${remark} ${marker}`,
|
||
pickupMethod: 'SELF_EXPRESS',
|
||
receivingWarehouseId: masterData.receiving.id,
|
||
receivingWarehouseName: masterData.receiving.warehouseName,
|
||
channelCode: masterData.channel.channelCode,
|
||
channelName: masterData.channel.channelName,
|
||
carrierId: null,
|
||
carrierName: null,
|
||
taxMode: 'BUY_DECLARATION',
|
||
pricingTaxMode: masterData.channel.pricingTaxModes[0],
|
||
containerMode: 'LCL',
|
||
customsInspection: false,
|
||
destinationType: 'FBA',
|
||
amazonWarehouseCode: masterData.destination.warehouseCode,
|
||
shipmentId: cargoShipmentId,
|
||
referenceId: cargoReferenceId,
|
||
sender: {
|
||
companyName, contactName: '测试发货人', phone: legalPhone, email: `${username}@example.com`,
|
||
countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区',
|
||
addressLine: '测试路 1 号', postalCode: '518000',
|
||
},
|
||
receiver: null,
|
||
cargoes: [{
|
||
skuNo: `SKU-${suffix}-${marker}`.slice(0, 64),
|
||
cargoName: '蓝牙音箱',
|
||
cargoNameEn: 'Bluetooth Speaker',
|
||
hsCode: '8518210000',
|
||
customsHsCode: '8518210000',
|
||
packageType: 'CARTON',
|
||
lengthCm: 30,
|
||
widthCm: 20,
|
||
heightCm: 20,
|
||
unitsPerPackage,
|
||
packageCount,
|
||
grossWeightKg: 20.5,
|
||
volumeCbm: 0.024,
|
||
totalQuantity,
|
||
netWeightKg: 18,
|
||
unitDeclaredValue,
|
||
declaredValue: unitDeclaredValue * totalQuantity,
|
||
currency: 'USD',
|
||
material: '塑料 / Plastic',
|
||
usage: '音频播放 / Audio playback',
|
||
model: `MODEL-${suffix}`,
|
||
brand: 'TEST-NO-LOGO',
|
||
productLengthCm: 12,
|
||
productWidthCm: 8,
|
||
productHeightCm: 6,
|
||
shipmentId: cargoShipmentId,
|
||
referenceId: cargoReferenceId,
|
||
upcNo: `123456${`${Date.now()}`.slice(-6)}`.slice(0, 12),
|
||
productImageFileId,
|
||
dangerousGoods: false,
|
||
woodenProduct: false,
|
||
cargoRemark: `cargo-${remark} ${marker}`,
|
||
}],
|
||
}
|
||
}
|
||
|
||
// BEGIN ORDER ACCEPTANCE HELPERS — loaded without CLI side effects by the direct contract.
|
||
function orderCanonical(value) {
|
||
if (Array.isArray(value)) return value.map(orderCanonical)
|
||
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, orderCanonical(value[key])]))
|
||
return value
|
||
}
|
||
|
||
function orderDigest(value) { return createHash('sha256').update(JSON.stringify(orderCanonical(value))).digest('hex') }
|
||
function assertOrderUnchanged(before, after) {
|
||
assertEqual(orderDigest(after), orderDigest(before), 'order, cargo, parties, notes and history must remain unchanged')
|
||
}
|
||
function assertOrderDetail(data, id) {
|
||
assertEqual(String(data?.order?.id), String(id), 'owned order identity')
|
||
for (const field of ['cargoes', 'parties', 'internalNotes', 'statusHistory']) assertTrue(Array.isArray(data?.[field]), `complete ${field} snapshot required`)
|
||
assertPresent(data.order.version, 'order version')
|
||
}
|
||
function orderMutationCommand(snapshot, payload, key) {
|
||
assertTrue(Number.isInteger(snapshot?.order?.version) && snapshot.order.version >= 0, 'frozen order version required')
|
||
assertTrue(typeof key === 'string' && key.length > 0, 'frozen idempotency key required')
|
||
const command = structuredClone({ headers: { 'Idempotency-Key': key }, body: { ...payload, expectedVersion: snapshot.order.version } })
|
||
const freeze = value => { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value) } }
|
||
freeze(command)
|
||
return command
|
||
}
|
||
function assertImportResult(data, mode, expected) {
|
||
assertEqual(data?.mode, mode, 'import mode')
|
||
assertPresent(data?.batchNo, 'batch number')
|
||
assertPresent(data?.sourceFileId, 'source file')
|
||
const result = data?.result
|
||
assertEqual(result?.total, expected.length, 'import total')
|
||
assertEqual(result?.succeeded, expected.filter(Boolean).length, 'import succeeded')
|
||
assertEqual(result?.failed, expected.filter(value => !value).length, 'import failed')
|
||
assertEqual(result?.items?.length, expected.length, 'import item count')
|
||
result.items.forEach((item, index) => {
|
||
assertEqual(item.index, index, 'result index')
|
||
assertEqual(item.clientRowId, `excel-row-${index + 3}`, 'physical Excel row')
|
||
assertEqual(item.success, expected[index], 'row success')
|
||
if (item.success) { assertPresent(item.orderId, 'created order ID'); assertPresent(item.orderNo, 'created order number') }
|
||
else {
|
||
for (const field of ['orderId', 'orderNo', 'inboundCode']) {
|
||
assertTrue(item[field] === null || item[field] === undefined, `failed row has no ${field}`)
|
||
}
|
||
assertEqual(item.errorCode, 'A0422', 'invalid row code')
|
||
assertPresent(item.errorMessage, 'row error reason')
|
||
}
|
||
})
|
||
if (result.failed) assertPresent(data.errorReportFileId, 'downloadable error report')
|
||
}
|
||
|
||
// Read the downloaded POI template, preserving its headers, options and validations.
|
||
// ZIP64/encrypted archives are deliberately rejected; template fixtures are small XLSX files.
|
||
function orderZipRead(bytes) {
|
||
const input = Buffer.from(bytes)
|
||
let end = input.length - 22
|
||
while (end >= Math.max(0, input.length - 65557) && input.readUInt32LE(end) !== 0x06054b50) end--
|
||
assertTrue(end >= 0 && input.readUInt32LE(end) === 0x06054b50, 'valid XLSX ZIP directory')
|
||
const entries = new Map()
|
||
let offset = input.readUInt32LE(end + 16)
|
||
for (let index = 0; index < input.readUInt16LE(end + 10); index++) {
|
||
assertEqual(input.readUInt32LE(offset), 0x02014b50, 'ZIP entry signature')
|
||
const method = input.readUInt16LE(offset + 10), size = input.readUInt32LE(offset + 20)
|
||
const length = input.readUInt32LE(offset + 24), nameLength = input.readUInt16LE(offset + 28)
|
||
const name = input.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
|
||
const local = input.readUInt32LE(offset + 42)
|
||
assertTrue(!(input.readUInt16LE(offset + 8) & 1) && [0, 8].includes(method) && length <= 16 * 1024 * 1024, 'supported bounded ZIP entry')
|
||
assertEqual(input.readUInt32LE(local), 0x04034b50, 'ZIP local signature')
|
||
const start = local + 30 + input.readUInt16LE(local + 26) + input.readUInt16LE(local + 28)
|
||
const packed = input.subarray(start, start + size)
|
||
const content = method === 8 ? inflateRawSync(packed, { maxOutputLength: 16 * 1024 * 1024 }) : packed
|
||
assertEqual(content.length, length, 'ZIP uncompressed size')
|
||
assertEqual(orderCrc(content), input.readUInt32LE(offset + 16), 'ZIP checksum')
|
||
assertTrue(!entries.has(name), 'unique ZIP entry')
|
||
entries.set(name, content.toString('utf8'))
|
||
offset += 46 + nameLength + input.readUInt16LE(offset + 30) + input.readUInt16LE(offset + 32)
|
||
}
|
||
return entries
|
||
}
|
||
function orderCrc(bytes) {
|
||
let crc = 0xffffffff
|
||
for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0) }
|
||
return (crc ^ 0xffffffff) >>> 0
|
||
}
|
||
function orderZipWrite(entries) {
|
||
const local = [], directory = []
|
||
let offset = 0
|
||
for (const [name, text] of entries) {
|
||
const filename = Buffer.from(name), data = Buffer.from(text), crc = orderCrc(data)
|
||
const header = Buffer.alloc(30)
|
||
header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4); header.writeUInt16LE(0x800, 6)
|
||
header.writeUInt32LE(crc, 14); header.writeUInt32LE(data.length, 18); header.writeUInt32LE(data.length, 22); header.writeUInt16LE(filename.length, 26)
|
||
const central = Buffer.alloc(46)
|
||
central.writeUInt32LE(0x02014b50); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x800, 8)
|
||
central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24)
|
||
central.writeUInt16LE(filename.length, 28); central.writeUInt32LE(offset, 42)
|
||
local.push(header, filename, data); directory.push(central, filename)
|
||
offset += header.length + filename.length + data.length
|
||
}
|
||
const central = Buffer.concat(directory), end = Buffer.alloc(22)
|
||
end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.size, 8); end.writeUInt16LE(entries.size, 10)
|
||
end.writeUInt32LE(central.length, 12); end.writeUInt32LE(offset, 16)
|
||
return Buffer.concat([...local, central, end])
|
||
}
|
||
function orderXmlDecode(text) {
|
||
return text.replace(/&#(x[0-9a-f]+|[0-9]+);|&(lt|gt|quot|apos|amp);/gi, (match, code, named) => code
|
||
? String.fromCodePoint(code[0].toLowerCase() === 'x' ? parseInt(code.slice(1), 16) : Number(code))
|
||
: ({ lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[named.toLowerCase()])
|
||
}
|
||
function orderXmlEncode(text) { return String(text).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]) }
|
||
function orderColumn(index) {
|
||
let result = ''
|
||
for (let value = index + 1; value; value = Math.floor((value - 1) / 26)) result = String.fromCharCode(65 + (value - 1) % 26) + result
|
||
return result
|
||
}
|
||
function orderSheetRows(entries, path = 'xl/worksheets/sheet1.xml') {
|
||
const strings = [...(entries.get('xl/sharedStrings.xml') ?? '').matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map(item =>
|
||
[...item[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map(text => orderXmlDecode(text[1])).join(''))
|
||
const sheet = entries.get(path)
|
||
assertPresent(sheet, `template ${path}`)
|
||
return [...sheet.matchAll(/<row\b[^>]*\br="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)].map(row => ({ number: Number(row[1]), cells:
|
||
[...row[2].matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)].map(cell => {
|
||
const ref = cell[1].match(/\br="([A-Z]+)\d+"/)?.[1]
|
||
const content = cell[2] ?? ''
|
||
const value = content.match(/<v\b[^>]*>([\s\S]*?)<\/v>/)?.[1]
|
||
const text = /\bt="s"/.test(cell[1]) ? strings[Number(value)] : /\bt="inlineStr"/.test(cell[1])
|
||
? [...content.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map(item => orderXmlDecode(item[1])).join('') : orderXmlDecode(value ?? '')
|
||
return { ref, text }
|
||
}) }))
|
||
}
|
||
function orderWorkbookRows(template, rows) {
|
||
const entries = new Map(template), sheet = entries.get('xl/worksheets/sheet1.xml')
|
||
const headers = [...sheet.matchAll(/<row\b[^>]*\br="([12])"[^>]*>[\s\S]*?<\/row>/g)].map(row => row[0]).join('')
|
||
assertTrue(headers.includes('r="1"') && headers.includes('r="2"'), 'both original header rows preserved')
|
||
const data = rows.map((row, index) => `<row r="${index + 3}">${row.map((text, col) => `<c r="${orderColumn(col)}${index + 3}" t="inlineStr"><is><t xml:space="preserve">${orderXmlEncode(text)}</t></is></c>`).join('')}</row>`).join('')
|
||
entries.set('xl/worksheets/sheet1.xml', sheet.replace(/<sheetData\b[^>]*>[\s\S]*?<\/sheetData>/, `<sheetData>${headers}${data}</sheetData>`))
|
||
return orderZipWrite(entries)
|
||
}
|
||
function importRollbackRows(validFirst, validSecond) {
|
||
const invalid = [...validFirst]; invalid[10] = '' // mandatory cargo name: seed only the second row
|
||
const changed = [...validSecond]; changed[30] += '-changed' // SaveRequest.customerRemark participates in CREATE_DRAFT hash
|
||
return { seed: [invalid, validSecond], conflict: [validFirst, changed], retry: [validFirst, validSecond] }
|
||
}
|
||
// END ORDER ACCEPTANCE HELPERS
|
||
|
||
async function ownedOrderSnapshot(module, name, id, expected, validate = () => {}) {
|
||
const response = await apiStep(module, name, {
|
||
path: `/api/merchant/v1/orders/first-leg/${id}`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json); assertOrderDetail(json.data, id)
|
||
if (expected) assertOrderUnchanged(expected, json.data)
|
||
validate(json.data)
|
||
}, ({ json }) => ({ orderId: String(id), status: json.data?.order?.status, version: json.data?.order?.version, snapshotSha256: orderDigest(json.data) }))
|
||
return response.json.data
|
||
}
|
||
|
||
function recordOrderMutationRequest(name, request, before) {
|
||
report.orderMutationRequests ??= []
|
||
report.orderMutationRequests.push({ name, method: request.method, path: request.path,
|
||
idempotencyKey: request.headers?.['Idempotency-Key'] ?? null, body: structuredClone(request.body),
|
||
beforeVersion: before.order.version, beforeSnapshotSha256: orderDigest(before) })
|
||
}
|
||
|
||
async function rejectOrderMutation(name, id, request, codes, statuses = [200]) {
|
||
const before = await ownedOrderSnapshot('order-mutation-evidence', `${name}: before`, id)
|
||
recordOrderMutationRequest(name, request, before)
|
||
await apiStep('order-mutation-evidence', name, request, ({ json, status }) => {
|
||
assertTrue(statuses.includes(status), `expected HTTP ${statuses}, actual=${status}`)
|
||
assertTrue(codes.includes(String(json?.code)), `expected ${codes}, actual=${json?.code}`)
|
||
}, ({ json }) => ({ orderId: String(id), rejectionCode: json?.code }))
|
||
await ownedOrderSnapshot('order-mutation-evidence', `${name}: all facts unchanged`, id, before)
|
||
}
|
||
|
||
async function orderMutationEvidenceModule() {
|
||
const module = 'order-mutation-evidence', base = '/api/merchant/v1/orders/first-leg'
|
||
assertPresent(orderAcceptanceFixture, 'completed order lifecycle fixture')
|
||
assertPresent(existingClientToken, 'independent client session')
|
||
const payload = structuredClone(orderAcceptanceFixture.payload)
|
||
payload.customerRemark = `mutation-${marker}`
|
||
const created = await apiStep(module, 'create owned mutable draft for isolation and cancellation', {
|
||
method: 'POST', path: base, token: merchantToken, body: payload, headers: { 'Idempotency-Key': newIdempotencyKey('mutation-draft') },
|
||
}, ({ json }) => { requireSuccess(json); assertPresent(json.data, 'draft ID') }, ({ json }) => ({ orderId: json.data }))
|
||
const id = created.json.data
|
||
report.resources.orderIds.push(id)
|
||
const original = await ownedOrderSnapshot(module, 'freeze draft page version and request', id)
|
||
const editPayload = structuredClone(payload); editPayload.customerRemark += '-edited'
|
||
const editCommand = orderMutationCommand(original, { draft: editPayload }, newIdempotencyKey('versioned-edit'))
|
||
const editRequest = { method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...editCommand }
|
||
recordOrderMutationRequest('versioned draft edit', editRequest, original)
|
||
await apiStep(module, 'versioned draft edit succeeds', editRequest, ({ json }) => requireSuccess(json),
|
||
() => ({ orderId: id, expectedVersion: original.order.version }))
|
||
const edited = await ownedOrderSnapshot(module, 'edited draft full snapshot', id, null, data => {
|
||
assertEqual(data.order.status, 'DRAFT', 'edit stays draft')
|
||
assertTrue(data.order.version > original.order.version, 'edit increments version')
|
||
assertEqual(data.order.customerRemark, editPayload.customerRemark, 'edited source remark')
|
||
for (const field of ['id', 'orderNo', 'inboundCode', 'tenantId', 'customerId']) assertEqual(data.order[field], original.order[field], `stable edit ${field}`)
|
||
assertEqual(data.cargoes.length, editPayload.cargoes.length, 'complete edited cargo set')
|
||
data.cargoes.forEach((cargo, index) => {
|
||
for (const field of ['skuNo', 'packageCount', 'model', 'productImageFileId']) assertEqual(cargo[field], editPayload.cargoes[index][field], `edited cargo ${field}`)
|
||
})
|
||
const oldIds = new Set(original.statusHistory.map(item => String(item.id)))
|
||
const added = data.statusHistory.filter(item => !oldIds.has(String(item.id)))
|
||
assertOrderUnchanged(original.statusHistory, data.statusHistory.filter(item => oldIds.has(String(item.id))))
|
||
assertEqual(added.length, 1, 'single edit audit'); assertEqual(added[0].action, 'UPDATE', 'edit audit action')
|
||
})
|
||
recordOrderMutationRequest('original edit replay', editRequest, original)
|
||
await apiStep(module, 'original edit key and frozen payload replay successfully', editRequest, ({ json }) => requireSuccess(json),
|
||
() => ({ orderId: id, expectedVersion: original.order.version, replayed: true }))
|
||
await ownedOrderSnapshot(module, 'edit replay adds no version cargo or history', id, edited)
|
||
const changedEdit = structuredClone(editCommand); changedEdit.body.draft.customerRemark += '-conflict'
|
||
await rejectOrderMutation('edit original key different payload conflicts', id, {
|
||
method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...changedEdit,
|
||
}, ['A0409'])
|
||
await rejectOrderMutation('old page version cannot overwrite newer draft', id, {
|
||
method: 'PUT', path: `${base}/${id}`, token: merchantToken,
|
||
...orderMutationCommand(original, { draft: payload }, newIdempotencyKey('stale-edit')),
|
||
}, ['A0409'])
|
||
for (const operation of ['edit', 'cancel']) {
|
||
const method = operation === 'edit' ? 'PUT' : 'POST', path = `${base}/${id}${operation === 'edit' ? '' : '/cancel'}`
|
||
const command = orderMutationCommand(edited, operation === 'edit' ? { draft: editPayload } : { reason: 'missing protocol guard' }, newIdempotencyKey(`missing-${operation}`))
|
||
const withoutVersion = structuredClone(command); delete withoutVersion.body.expectedVersion
|
||
await rejectOrderMutation(`${operation} missing version is rejected`, id, { method, path, token: merchantToken, ...withoutVersion }, ['A0422'])
|
||
await rejectOrderMutation(`${operation} missing key is rejected`, id, { method, path, token: merchantToken, body: command.body }, ['A0400'], [200, 400])
|
||
}
|
||
await rejectOrderMutation('other customer cannot edit owned draft', id, {
|
||
method: 'PUT', path: `${base}/${id}`, token: existingClientToken,
|
||
...orderMutationCommand(edited, { draft: payload }, newIdempotencyKey('foreign-edit')),
|
||
}, ['A0403', 'A0404'])
|
||
await rejectOrderMutation('other customer cannot cancel owned draft', id, {
|
||
method: 'POST', path: `${base}/${id}/cancel`, token: existingClientToken,
|
||
...orderMutationCommand(edited, { reason: `foreign-${marker}` }, newIdempotencyKey('foreign-cancel')),
|
||
}, ['A0403', 'A0404'])
|
||
const invalid = structuredClone(payload); invalid.cargoes[0].productImageFileId = -1
|
||
await rejectOrderMutation('invalid product image edit rolls back all facts', id, {
|
||
method: 'PUT', path: `${base}/${id}`, token: merchantToken,
|
||
...orderMutationCommand(edited, { draft: invalid }, newIdempotencyKey('invalid-edit')),
|
||
}, ['A0422'])
|
||
await rejectOrderMutation('submitted order cannot be overwritten as draft', orderAcceptanceFixture.orderId, {
|
||
method: 'PUT', path: `${base}/${orderAcceptanceFixture.orderId}`, token: merchantToken,
|
||
...orderMutationCommand(await ownedOrderSnapshot(module, 'freeze submitted order edit request', orderAcceptanceFixture.orderId),
|
||
{ draft: payload }, newIdempotencyKey('submitted-edit-evidence')),
|
||
}, ['A0409'])
|
||
await rejectOrderMutation('blank cancellation reason preserves all facts', id, {
|
||
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken,
|
||
...orderMutationCommand(edited, { reason: ' ' }, newIdempotencyKey('empty-reason')),
|
||
}, ['A0422'])
|
||
await rejectOrderMutation('old page version cannot cancel updated draft', id, {
|
||
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken,
|
||
...orderMutationCommand(original, { reason: 'stale page' }, newIdempotencyKey('stale-cancel')),
|
||
}, ['A0409'])
|
||
const before = await ownedOrderSnapshot(module, 'before valid cancellation', id)
|
||
const reason = `F06-cancel-${marker}`
|
||
const cancelCommand = orderMutationCommand(before, { reason }, newIdempotencyKey('versioned-cancel'))
|
||
const cancelRequest = { method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...cancelCommand }
|
||
recordOrderMutationRequest('versioned cancellation', cancelRequest, before)
|
||
await apiStep(module, 'cancel owned draft with reason', cancelRequest, ({ json }) => requireSuccess(json), () => ({ orderId: id, reason }))
|
||
await apiStep(module, 'cancellation persists one attributed history and preserves cargo', {
|
||
path: `${base}/${id}`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json); assertOrderDetail(json.data, id)
|
||
assertEqual(json.data.order.status, 'CANCELLED', 'cancelled status')
|
||
assertTrue(json.data.order.version > before.order.version, 'version increases')
|
||
for (const field of ['cargoes', 'parties', 'internalNotes']) assertOrderUnchanged(before[field], json.data[field])
|
||
for (const field of ['id', 'orderNo', 'inboundCode', 'tenantId', 'customerId']) assertEqual(json.data.order[field], before.order[field], `stable ${field}`)
|
||
const oldIds = new Set(before.statusHistory.map(item => String(item.id)))
|
||
const added = json.data.statusHistory.filter(item => !oldIds.has(String(item.id)))
|
||
assertEqual(added.length, 1, 'one cancellation history')
|
||
assertOrderUnchanged(before.statusHistory, json.data.statusHistory.filter(item => oldIds.has(String(item.id))))
|
||
assertEqual(added[0].action, 'CANCELLED', 'cancellation action'); assertEqual(added[0].reason, reason, 'cancellation reason')
|
||
assertEqual(added[0].fromStatus, 'DRAFT', 'from draft'); assertEqual(added[0].toStatus, 'CANCELLED', 'to cancelled')
|
||
assertPresent(added[0].operatorId, 'operator'); assertPresent(added[0].occurredAt, 'server operation time')
|
||
}, ({ json }) => ({ orderId: id, status: json.data?.order?.status, snapshotSha256: orderDigest(json.data) }))
|
||
const cancelled = await ownedOrderSnapshot(module, 'freeze cancelled facts before replay', id)
|
||
recordOrderMutationRequest('original cancellation replay', cancelRequest, before)
|
||
await apiStep(module, 'original cancellation key payload and version replay successfully', cancelRequest,
|
||
({ json }) => requireSuccess(json), () => ({ orderId: id, expectedVersion: before.order.version, replayed: true }))
|
||
await ownedOrderSnapshot(module, 'cancel replay creates no second history or version', id, cancelled)
|
||
const changedCancel = structuredClone(cancelCommand); changedCancel.body.reason += '-conflict'
|
||
await rejectOrderMutation('cancellation original key different reason conflicts', id, {
|
||
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...changedCancel,
|
||
}, ['A0409'])
|
||
await rejectOrderMutation('repeat cancellation cannot add history', id, {
|
||
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken,
|
||
...orderMutationCommand(cancelled, { reason }, newIdempotencyKey('new-key-repeat-cancel')),
|
||
}, ['A0409'])
|
||
await rejectOrderMutation('cancelled draft cannot be submitted', id, {
|
||
method: 'POST', path: `${base}/${id}/submit`, token: merchantToken, headers: { 'Idempotency-Key': newIdempotencyKey('cancelled-submit-proof') },
|
||
}, ['A0409'])
|
||
report.orderMutationLimitations = [
|
||
'Requires deployed Merchant edit/cancel versioned idempotent protocol; local contracts do not prove TEST deployment.',
|
||
'Irreversible-fulfillment cancellation is not exercised by these draft/submitted fixtures.',
|
||
]
|
||
}
|
||
|
||
async function importOrderSet(name, expectedIds) {
|
||
const ids = [], seen = new Set()
|
||
let total = null
|
||
for (let page = 1; ; page++) {
|
||
await apiStep('order-excel-import', `${name}: complete order page ${page}`, {
|
||
path: `/api/merchant/v1/orders/first-leg?page=${page}&size=200`, token: merchantToken,
|
||
}, ({ json }) => {
|
||
requireSuccess(json)
|
||
assertTrue(Array.isArray(json.data?.records), 'order page records required')
|
||
assertEqual(Number(json.data.page), page, 'order page identity')
|
||
const count = Number(json.data.total)
|
||
assertTrue(Number.isSafeInteger(count) && count >= 0, 'valid order total')
|
||
if (total === null) total = count
|
||
assertEqual(count, total, 'stable order page total')
|
||
for (const row of json.data.records) {
|
||
assertPresent(row.orderId, 'listed order ID')
|
||
const id = String(row.orderId)
|
||
assertTrue(!seen.has(id), 'no duplicate order across pages'); seen.add(id); ids.push(id)
|
||
}
|
||
assertTrue(ids.length <= total, 'order pages do not exceed total')
|
||
if (page * 200 >= total) {
|
||
assertEqual(ids.length, total, 'complete order set')
|
||
if (expectedIds) assertOrderUnchanged([...expectedIds].sort(), [...ids].sort())
|
||
} else assertEqual(json.data.records.length, 200, 'full intermediate order page')
|
||
}, () => ({ page, total, collected: ids.length, orderIdsSha256: orderDigest([...ids].sort()) }))
|
||
if (page * 200 >= total) return ids
|
||
}
|
||
}
|
||
|
||
async function orderExcelImportModule() {
|
||
const module = 'order-excel-import', base = '/api/merchant/v1/orders/first-leg/batch-imports'
|
||
const { masterData, payload } = orderAcceptanceFixture
|
||
let template
|
||
await apiStep(module, 'download and inspect actual Chinese customer template', {
|
||
path: `${base}/template`, token: merchantToken, responseType: 'bytes',
|
||
}, ({ status, bytes, headers }) => {
|
||
assertEqual(status, 200, 'template HTTP status')
|
||
assertTrue((headers.get('content-type') ?? '').includes('spreadsheetml'), 'XLSX content type')
|
||
template = orderZipRead(bytes)
|
||
const header = orderSheetRows(template).find(row => row.number === 2)
|
||
assertEqual(header?.cells?.length, 31, 'current 31-column template')
|
||
assertEqual(header.cells[0].text, '运输方式 *', 'Chinese first header')
|
||
assertEqual(header.cells[30].text, '客户备注', 'Chinese final header')
|
||
assertTrue(template.get('xl/workbook.xml').includes('选项数据'), 'server-generated option sheet')
|
||
}, ({ bytes }) => ({ sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }))
|
||
const options = new Set(orderSheetRows(template, 'xl/worksheets/sheet2.xml').flatMap(row => row.cells.map(cell => cell.text)))
|
||
const businessLabel = (name, code) => !name || name.toLowerCase() === code.toLowerCase() ? code : `${name}|${code}`
|
||
const receiving = businessLabel(masterData.receiving.warehouseName, masterData.receiving.warehouseCode)
|
||
const destination = businessLabel(masterData.destination.warehouseName, masterData.destination.warehouseCode)
|
||
const channel = businessLabel(masterData.channel.channelName, masterData.channel.channelCode)
|
||
for (const value of [receiving, destination, channel]) assertTrue(options.has(value), `authoritative template option ${value}`)
|
||
const makeRow = suffix => [
|
||
'海运', '', '自行快递', receiving, channel, '亚马逊FBA', destination, '', '', '',
|
||
`Excel-${suffix}`, '纸箱', '2', '20.5', '0.024', '塑料', '否', '否', `SKU-${suffix}`, '123456789012',
|
||
`SHIP-${suffix}`, `REF-${suffix}`, '买单报关', payload.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税',
|
||
'散货拼箱', '否', `SHIP-${suffix}`, `REF-${suffix}`, '普通头程', '', `${marker}-${suffix}`,
|
||
]
|
||
const upload = async (name, mode, key, bytes, validate, filename = 'orders.xlsx') => {
|
||
const form = new FormData()
|
||
form.append('file', new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), filename)
|
||
return apiStep(module, name, {
|
||
method: 'POST', path: `${base}?mode=${mode}`, token: merchantToken, headers: { 'Idempotency-Key': key }, body: form,
|
||
}, validate, ({ json }) => ({ batchNo: json?.data?.batchNo, mode, result: json?.data?.result, sourceFileId: json?.data?.sourceFileId,
|
||
errorReportFileId: json?.data?.errorReportFileId, requestBytesSha256: createHash('sha256').update(bytes).digest('hex') }))
|
||
}
|
||
const rejection = code => ({ json, status }) => {
|
||
assertEqual(status, 200, 'HTTP status'); assertEqual(String(json?.code), code, 'rejection business code')
|
||
}
|
||
const success = (mode, rows) => ({ json }) => { requireSuccess(json); assertImportResult(json.data, mode, rows) }
|
||
const verifyDraft = async (id, row, name) => {
|
||
await apiStep(module, name, { path: `/api/merchant/v1/orders/first-leg/${id}`, token: merchantToken }, ({ json }) => {
|
||
requireSuccess(json); assertOrderDetail(json.data, id)
|
||
assertEqual(json.data.order.status, 'DRAFT', 'import creates draft only')
|
||
assertEqual(String(json.data.order.tenantId), String(report.resources.tenantId), 'authenticated customer scope')
|
||
assertEqual(json.data.order.customerRemark, row[30], 'exact source remark')
|
||
assertEqual(json.data.order.referenceId, row[27], 'exact source reference')
|
||
assertEqual(String(json.data.order.receivingWarehouseId), String(masterData.receiving.id), 'authoritative receiving warehouse')
|
||
assertEqual(String(json.data.order.destinationWarehouseId), String(masterData.destination.id), 'authoritative destination')
|
||
assertEqual(json.data.order.channelCode, masterData.channel.channelCode, 'authoritative channel')
|
||
assertEqual(json.data.cargoes.length, 1, 'one Excel row creates one order cargo')
|
||
assertEqual(json.data.cargoes[0].skuNo, row[18], 'source SKU')
|
||
assertEqual(Number(json.data.cargoes[0].packageCount), 2, 'source package count')
|
||
assertTrue(!json.data.statusHistory.some(item => item.toStatus === 'SUBMITTED'), 'import never submits')
|
||
}, ({ json }) => ({ orderId: String(id), status: json.data?.order?.status, snapshotSha256: orderDigest(json.data) }))
|
||
}
|
||
const initial = await importOrderSet('before import matrix')
|
||
const rows = importRollbackRows(makeRow('rollback-first'), makeRow('rollback-second'))
|
||
const key = newIdempotencyKey('excel-transaction')
|
||
const seedBytes = orderWorkbookRows(template, rows.seed)
|
||
const seed = await upload('PARTIAL preserves only valid second row', 'PARTIAL', key, seedBytes, success('PARTIAL', [false, true]))
|
||
const retainedId = String(seed.json.data.result.items[1].orderId)
|
||
report.resources.orderIds.push(retainedId)
|
||
const seeded = [...initial, retainedId]
|
||
await importOrderSet('PARTIAL exactly one new order', seeded)
|
||
await verifyDraft(retainedId, rows.seed[1], 'PARTIAL source fields and customer match')
|
||
const retained = await ownedOrderSnapshot(module, 'seed second row full snapshot', retainedId)
|
||
const replay = await upload('PARTIAL same key bytes returns same order', 'PARTIAL', key, seedBytes, response => {
|
||
success('PARTIAL', [false, true])(response)
|
||
assertEqual(String(response.json.data.result.items[1].orderId), retainedId, 'replayed order identity')
|
||
})
|
||
await importOrderSet('PARTIAL replay adds no orders', seeded)
|
||
await ownedOrderSnapshot(module, 'PARTIAL replay preserves full existing order', retainedId, retained)
|
||
const errorData = replay.json.data
|
||
await apiStep(module, 'download actual row error workbook', {
|
||
path: `/api/merchant/v1/files/order-batch-imports/${encodeURIComponent(errorData.batchNo)}/error-reports/${errorData.errorReportFileId}`,
|
||
token: merchantToken, responseType: 'bytes',
|
||
}, ({ status, bytes }) => {
|
||
assertEqual(status, 200, 'error workbook download')
|
||
const rows = orderSheetRows(orderZipRead(bytes))
|
||
assertEqual(rows.length, 2, 'header and exactly one failed row')
|
||
assertEqual(rows[1].cells[0].text, '3', 'actual Excel error row')
|
||
assertEqual(rows[1].cells[1].text, 'A0422', 'error report code')
|
||
assertEqual(rows[1].cells[2].text, errorData.result.items[0].errorMessage, 'error report exact reason')
|
||
}, ({ bytes }) => ({ sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }))
|
||
await upload('ATOMIC parse rejection creates no orders', 'ATOMIC', newIdempotencyKey('excel-parse'), seedBytes, rejection('A0422'))
|
||
await importOrderSet('ATOMIC parse failure has zero additions', seeded)
|
||
// Same tenant/operator/key and physical row IDs; only row 4 customerRemark changes.
|
||
// Row 3 passes validation and writes before row 4 CREATE_DRAFT fingerprint conflicts.
|
||
await upload('ATOMIC later row idempotency conflict rolls back earlier row', 'ATOMIC', key,
|
||
orderWorkbookRows(template, rows.conflict), rejection('A0409'))
|
||
await importOrderSet('ATOMIC mid-write conflict leaves no first-row order', seeded)
|
||
await ownedOrderSnapshot(module, 'ATOMIC conflict preserves seeded order and history', retainedId, retained)
|
||
await upload('ATOMIC retry with original second-row hash succeeds', 'ATOMIC', key,
|
||
orderWorkbookRows(template, rows.retry), response => {
|
||
success('ATOMIC', [true, true])(response)
|
||
assertEqual(String(response.json.data.result.items[1].orderId), retainedId, 'second row reuses original ID')
|
||
assertTrue(String(response.json.data.result.items[0].orderId) !== retainedId, 'new first row is distinct')
|
||
}).then(async response => {
|
||
const firstId = String(response.json.data.result.items[0].orderId)
|
||
report.resources.orderIds.push(firstId); seeded.push(firstId)
|
||
await importOrderSet('ATOMIC retry adds exactly first row', seeded)
|
||
await verifyDraft(firstId, rows.retry[0], 'ATOMIC first row source facts match')
|
||
})
|
||
await ownedOrderSnapshot(module, 'successful ATOMIC replay preserves second row', retainedId, retained)
|
||
const invalidCases = [
|
||
['wrong extension', seedBytes, 'orders.csv'], ['corrupt xlsx', Buffer.from('not-an-xlsx'), 'orders.xlsx'],
|
||
['over five MiB', Buffer.alloc(5 * 1024 * 1024 + 1), 'orders.xlsx'],
|
||
['51 effective rows', orderWorkbookRows(template, Array.from({ length: 51 }, (_, index) => makeRow(`limit-${index}`))), 'orders.xlsx'],
|
||
]
|
||
const missingHeader = orderZipRead(orderWorkbookRows(template, [makeRow('header')]))
|
||
missingHeader.set('xl/worksheets/sheet1.xml', missingHeader.get('xl/worksheets/sheet1.xml').replace(/<row\b[^>]*\br="2"[^>]*>[\s\S]*?<\/row>/, '<row r="2"/>'))
|
||
invalidCases.push(['missing Chinese headers', orderZipWrite(missingHeader), 'orders.xlsx'])
|
||
for (const [name, bytes, filename] of invalidCases) {
|
||
await upload(`reject ${name}`, 'ATOMIC', newIdempotencyKey('excel-invalid'), bytes, rejection('A0422'), filename)
|
||
await importOrderSet(`${name}: order set unchanged`, seeded)
|
||
}
|
||
const forged = makeRow('forged-catalog'); forged[3] = `FORGED-${marker}`
|
||
const tampered = new Map(template)
|
||
tampered.set('xl/worksheets/sheet2.xml', tampered.get('xl/worksheets/sheet2.xml').replace('</sheetData>', `<row r="9999"><c r="A9999" t="inlineStr"><is><t>${orderXmlEncode(forged[3])}</t></is></c></row></sheetData>`))
|
||
await upload('forged hidden option is rejected against authoritative catalog', 'ATOMIC', newIdempotencyKey('excel-forged'),
|
||
orderWorkbookRows(tampered, [forged]), rejection('A0422'))
|
||
await importOrderSet('forged option adds no orders', seeded)
|
||
report.orderImportLimitations = [
|
||
'ATOMIC assertions concern Order rows and their public facts; remote source uploads are not rolled back.',
|
||
'Template selected options are checked; exhaustive warehouse pagination and cross-customer private-address template comparison remain open.',
|
||
'This HTTP matrix does not prove browser import interaction or irreversible order fulfillment cancellation.',
|
||
]
|
||
}
|
||
|
||
async function apiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
|
||
const startedAt = new Date().toISOString()
|
||
const started = Date.now()
|
||
let response
|
||
try {
|
||
response = await httpRequest(request)
|
||
validate(response)
|
||
const step = makeStep(module, name, true, startedAt, started, response, evidenceBuilder(response))
|
||
report.steps.push(step)
|
||
console.log(`PASS [${module}] ${name}`)
|
||
return response
|
||
} catch (error) {
|
||
let evidence = null
|
||
if (response) {
|
||
try {
|
||
evidence = evidenceBuilder(response)
|
||
} catch {
|
||
evidence = defaultEvidence(response)
|
||
}
|
||
}
|
||
const step = makeStep(module, name, false, startedAt, started, response, evidence)
|
||
step.error = sanitizeError(error)
|
||
report.steps.push(step)
|
||
console.error(`FAIL [${module}] ${name}: ${error.message}`)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
async function softApiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
|
||
try {
|
||
return await apiStep(module, name, request, validate, evidenceBuilder)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) {
|
||
const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers }
|
||
if (token) requestHeaders.Authorization = `Bearer ${token}`
|
||
let requestBody = body
|
||
if (body != null && !(body instanceof FormData)) {
|
||
requestHeaders['Content-Type'] = 'application/json'
|
||
requestBody = JSON.stringify(body)
|
||
}
|
||
const response = await fetch(`${gateway}${path}`, {
|
||
method,
|
||
headers: requestHeaders,
|
||
body: requestBody,
|
||
signal: AbortSignal.timeout(timeoutMs),
|
||
})
|
||
const result = { status: response.status, headers: response.headers, json: null, bytes: null, text: null }
|
||
if (responseType === 'bytes') {
|
||
result.bytes = new Uint8Array(await response.arrayBuffer())
|
||
return result
|
||
}
|
||
const text = await response.text()
|
||
result.text = text
|
||
if (text) {
|
||
try {
|
||
result.json = JSON.parse(text)
|
||
} catch {
|
||
throw new Error(`Expected JSON but received ${text.slice(0, 200)}`)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
function makeStep(module, name, passed, startedAt, started, response, evidence) {
|
||
return {
|
||
module,
|
||
name,
|
||
passed,
|
||
skipped: false,
|
||
startedAt,
|
||
durationMs: Date.now() - started,
|
||
http: response?.status ?? null,
|
||
code: response?.json?.code == null ? null : String(response.json.code),
|
||
message: response?.json?.message ?? null,
|
||
requestId: headerOrBody(response, 'x-request-id', 'requestId'),
|
||
traceId: headerOrBody(response, 'x-trace-id', 'traceId'),
|
||
evidence,
|
||
}
|
||
}
|
||
|
||
function recordSkipped(module, name, reason) {
|
||
report.steps.push({
|
||
module, name, passed: true, skipped: true, startedAt: new Date().toISOString(), durationMs: 0,
|
||
http: null, code: null, message: reason, requestId: null, traceId: null, evidence: null,
|
||
})
|
||
console.log(`SKIP [${module}] ${name}: ${reason}`)
|
||
}
|
||
|
||
function requireSuccess(json) {
|
||
assertPresent(json, 'response body')
|
||
assertEqual(String(json.code), '200', 'business code')
|
||
}
|
||
|
||
function assertCargo(actual, expected) {
|
||
assertPresent(actual, 'cargo detail')
|
||
const exactFields = [
|
||
'skuNo', 'cargoName', 'cargoNameEn', 'hsCode', 'customsHsCode', 'packageType', 'unitsPerPackage',
|
||
'packageCount', 'totalQuantity', 'currency', 'material', 'model', 'brand', 'shipmentId',
|
||
'referenceId', 'upcNo', 'cargoRemark',
|
||
]
|
||
for (const field of exactFields) assertEqual(String(actual[field]), String(expected[field]), `cargo.${field}`)
|
||
assertEqual(String(actual.productUsage), String(expected.usage), 'cargo.productUsage')
|
||
const decimalFields = [
|
||
'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm', 'netWeightKg',
|
||
'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm',
|
||
]
|
||
for (const field of decimalFields) assertEqual(Number(actual[field]), Number(expected[field]), `cargo.${field}`)
|
||
assertEqual(String(actual.productImageFileId), String(expected.productImageFileId), 'cargo.productImageFileId')
|
||
}
|
||
|
||
function assertQualificationStatuses(values, requiredTypes, expectedStatus) {
|
||
const rows = arrayData(values)
|
||
for (const type of requiredTypes) {
|
||
const item = rows.find((row) => row.qualificationType === type && row.status === expectedStatus)
|
||
assertPresent(item, `${type} qualification`)
|
||
}
|
||
}
|
||
|
||
function qualificationEvidence(values) {
|
||
return arrayData(values).map((item) => ({
|
||
qualificationId: item.id ?? item.qualificationId,
|
||
qualificationType: item.qualificationType,
|
||
status: item.status,
|
||
}))
|
||
}
|
||
|
||
function cargoEvidence(cargo) {
|
||
if (!cargo) return null
|
||
return {
|
||
id: cargo.id,
|
||
skuNo: cargo.skuNo,
|
||
cargoName: cargo.cargoName,
|
||
cargoNameEn: cargo.cargoNameEn,
|
||
packageCount: cargo.packageCount,
|
||
totalQuantity: cargo.totalQuantity,
|
||
grossWeightKg: cargo.grossWeightKg,
|
||
volumeCbm: cargo.volumeCbm,
|
||
shipmentId: cargo.shipmentId,
|
||
referenceId: cargo.referenceId,
|
||
productImageFileId: cargo.productImageFileId,
|
||
}
|
||
}
|
||
|
||
function defaultEvidence({ json }) {
|
||
if (!json || json.data == null) return null
|
||
if (typeof json.data !== 'object') return { data: json.data }
|
||
return null
|
||
}
|
||
|
||
function headerOrBody(response, headerName, bodyName) {
|
||
return response?.headers?.get(headerName)
|
||
?? response?.json?.[bodyName]
|
||
?? response?.json?.data?.[bodyName]
|
||
?? null
|
||
}
|
||
|
||
function pageRecords(data) {
|
||
if (Array.isArray(data)) return data
|
||
return arrayData(data?.records ?? data?.items ?? data?.content)
|
||
}
|
||
|
||
function arrayData(value) {
|
||
return Array.isArray(value) ? value : []
|
||
}
|
||
|
||
function assertEqual(actual, expected, label) {
|
||
if (actual !== expected) throw new Error(`${label}: expected=${format(expected)} actual=${format(actual)}`)
|
||
}
|
||
|
||
function assertPresent(value, label) {
|
||
if (value == null || value === '') throw new Error(`${label} is required`)
|
||
}
|
||
|
||
function assertTrue(value, message) {
|
||
if (!value) throw new Error(message)
|
||
}
|
||
|
||
function assertSetEqual(actual, expected, label) {
|
||
const actualValues = [...new Set(actual)].sort()
|
||
const expectedValues = [...new Set(expected)].sort()
|
||
assertEqual(JSON.stringify(actualValues), JSON.stringify(expectedValues), label)
|
||
}
|
||
|
||
function requiredOwnerBackendPermissions() {
|
||
return [
|
||
'merchant:order:list', 'merchant:order:view', 'merchant:order:create', 'merchant:order:edit',
|
||
'merchant:order:submit', 'merchant:order:cancel', 'merchant:profile:view', 'merchant:profile:edit',
|
||
'merchant:qualification:upload', 'merchant:member:list', 'merchant:member:create',
|
||
'merchant:member:edit', 'merchant:member:grant-role', 'merchant:member:status',
|
||
'merchant:member:reset-password', 'merchant:role:list', 'merchant:role:create',
|
||
'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete', 'merchant:role:grant',
|
||
'merchant:notification:view', 'merchant:notification:read', 'merchant:notification:read-all',
|
||
]
|
||
}
|
||
|
||
function visibleClientMenus(backendPermissions) {
|
||
const permissions = new Set(backendPermissions)
|
||
const menus = ['dashboard:view', 'account:profile:view']
|
||
if (permissions.has('merchant:order:list') || permissions.has('merchant:order:view')) menus.push('order:first-leg:list')
|
||
if (permissions.has('merchant:profile:view')) menus.push('company:profile:view')
|
||
if (permissions.has('merchant:member:list')) menus.push('company:member:list')
|
||
if (permissions.has('merchant:role:list')) menus.push('company:role:list', 'company:permission:list')
|
||
if (permissions.has('merchant:notification:view')) menus.push('notification:inbox:view')
|
||
return menus.sort()
|
||
}
|
||
|
||
function newIdempotencyKey(operation) {
|
||
return `client-e2e-${operation}-${marker}-${randomUUID()}`.slice(0, 128)
|
||
}
|
||
|
||
function testPng() {
|
||
return Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
|
||
}
|
||
|
||
function addDays(value, days) {
|
||
const date = new Date(value)
|
||
date.setUTCDate(date.getUTCDate() + days)
|
||
return date.toISOString().slice(0, 10)
|
||
}
|
||
|
||
function compactTimestamp(value) {
|
||
return value.toISOString().replace(/[-:TZ.]/g, '').slice(0, 14)
|
||
}
|
||
|
||
function trimSlash(value) {
|
||
return String(value).replace(/\/+$/, '')
|
||
}
|
||
|
||
function apiPath(value) {
|
||
const url = new URL(String(value), `${gateway}/`)
|
||
return `${url.pathname}${url.search}`
|
||
}
|
||
|
||
function format(value) {
|
||
return typeof value === 'string' ? JSON.stringify(value) : String(value)
|
||
}
|
||
|
||
function sanitizeError(error) {
|
||
return { name: error?.name ?? 'Error', message: error?.message ?? String(error) }
|
||
}
|
||
|
||
async function promptText(label, defaultValue = '') {
|
||
if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultValue
|
||
const reader = createInterface({ input: process.stdin, output: process.stdout })
|
||
try {
|
||
const suffix = defaultValue ? ` [${defaultValue}]` : ''
|
||
const value = await reader.question(`${label}${suffix}: `)
|
||
return value.trim() || defaultValue
|
||
} finally {
|
||
reader.close()
|
||
}
|
||
}
|
||
|
||
async function promptSecret(label) {
|
||
if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined
|
||
const input = process.stdin
|
||
const output = process.stdout
|
||
const previousRawMode = input.isRaw
|
||
let value = ''
|
||
output.write(`${label}: `)
|
||
input.setEncoding('utf8')
|
||
input.setRawMode(true)
|
||
input.resume()
|
||
return new Promise((resolveSecret, rejectSecret) => {
|
||
const finish = (error) => {
|
||
input.off('data', onData)
|
||
input.setRawMode(Boolean(previousRawMode))
|
||
input.pause()
|
||
output.write('\n')
|
||
if (error) rejectSecret(error)
|
||
else resolveSecret(value)
|
||
}
|
||
const onData = (chunk) => {
|
||
for (const character of chunk) {
|
||
if (character === '\u0003') return finish(new Error('Credential input cancelled'))
|
||
if (character === '\r' || character === '\n') return finish()
|
||
if (character === '\u007f' || character === '\b') {
|
||
if (value.length > 0) {
|
||
value = value.slice(0, -1)
|
||
output.write('\b \b')
|
||
}
|
||
continue
|
||
}
|
||
if (character >= ' ' && character !== '\u007f') {
|
||
value += character
|
||
output.write('*')
|
||
}
|
||
}
|
||
}
|
||
input.on('data', onData)
|
||
})
|
||
}
|
||
|
||
function parseArgs(values) {
|
||
const parsed = {}
|
||
for (let index = 0; index < values.length; index += 1) {
|
||
const item = values[index]
|
||
if (!item.startsWith('--')) throw new Error(`Unknown argument: ${item}`)
|
||
const separator = item.indexOf('=')
|
||
if (separator > 2) {
|
||
parsed[item.slice(2, separator)] = item.slice(separator + 1)
|
||
continue
|
||
}
|
||
const key = item.slice(2)
|
||
const next = values[index + 1]
|
||
if (next == null || next.startsWith('--')) throw new Error(`Missing value for --${key}`)
|
||
parsed[key] = next
|
||
index += 1
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
function printSummary() {
|
||
const passed = report.steps.filter((step) => step.passed && !step.skipped).length
|
||
const failed = report.steps.filter((step) => !step.passed).length
|
||
const skipped = report.steps.filter((step) => step.skipped).length
|
||
console.log(JSON.stringify({ success: report.success, marker, passed, failed, skipped, reportPath }, null, 2))
|
||
}
|