Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java
#	firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java
#	scripts/admin-test-env-modules/ocean-bookings.mjs
#	scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs
#	scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
#	scripts/admin-test-env-modules/phase3-customs.mjs
#	scripts/admin-test-env-modules/phase3-loading-finalize.mjs
#	scripts/admin-test-env-modules/phase3-loading.mjs
#	scripts/admin-test-env-modules/phase3-port-schedules.mjs
#	scripts/admin-test-env-modules/service-center-flow.mjs
This commit is contained in:
2026-09-02 14:00:44 +08:00
36 changed files with 1383 additions and 523 deletions
+113 -18
View File
@@ -42,6 +42,7 @@ if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
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}`
@@ -91,6 +92,7 @@ try {
await qualificationAndReviewModule()
await merchantAfterApprovalModule()
await profileManagementModule()
await loginIdentifierModule()
await rbacAndMemberModule()
await orderLifecycleModule()
await isolationModule()
@@ -117,7 +119,7 @@ async function registrationModule() {
displayName: `测试管理员-${marker}`.slice(0, 64),
companyName,
legalPerson: '测试法人',
legalPhone,
phone: legalPhone,
contactEmail: `${username}@example.com`,
countryCode: 'CN',
address: '广东省深圳市南山区测试路 1 号',
@@ -213,7 +215,6 @@ async function qualificationAndReviewModule() {
const png = testPng()
const form = new FormData()
form.append('businessLicense', new Blob([png], { type: 'image/png' }), `business-license-${marker}.png`)
form.append('legalPersonId', new Blob([png], { type: 'image/png' }), `legal-person-id-${marker}.png`)
await apiStep('qualification-review', 'upload required qualification package', {
method: 'POST',
@@ -223,7 +224,7 @@ async function qualificationAndReviewModule() {
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.businessLicenseQualificationId, 'business license qualification id')
assertPresent(json.data?.legalPersonIdQualificationId, 'legal person 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,
@@ -274,7 +275,9 @@ async function qualificationAndReviewModule() {
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'], 'PENDING')
assertQualificationStatuses(json.data, ['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) }))
@@ -293,6 +296,19 @@ async function qualificationAndReviewModule() {
}, () => ({ 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 }) => {
@@ -310,7 +326,9 @@ async function qualificationAndReviewModule() {
method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/qualifications`, token: platformToken,
}, ({ json }) => {
requireSuccess(json)
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'], 'PENDING')
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', {
@@ -343,6 +361,51 @@ async function qualificationAndReviewModule() {
})
}
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: { username: 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: { 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 },
@@ -365,7 +428,9 @@ async function merchantAfterApprovalModule() {
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'], 'APPROVED')
assertQualificationStatuses(json.data, ['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) }))
@@ -1004,9 +1069,9 @@ async function orderLifecycleModule() {
}, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') }))
const payload = buildOrderPayload(masterData, productImageFileId, 'ACTIVE', 'fresh draft after approval')
const invalidOrderCases = [
const incompleteSubmitCases = [
{
name: 'FBA order missing Amazon identifiers is rejected',
name: 'FBA order missing Amazon identifiers',
mutate(value) {
value.amazonWarehouseCode = null
value.shipmentId = null
@@ -1014,7 +1079,7 @@ async function orderLifecycleModule() {
},
},
{
name: 'FBA cargo missing SKU UPC shipment and reference is rejected',
name: 'FBA cargo missing SKU UPC shipment and reference',
mutate(value) {
value.cargoes[0].skuNo = null
value.cargoes[0].upcNo = null
@@ -1022,9 +1087,35 @@ async function orderLifecycleModule() {
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 sender is rejected',
mutate(value) { value.sender = null },
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',
@@ -1191,11 +1282,11 @@ async function orderLifecycleModule() {
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', 'sender phone filter contains submitted order', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&customerContact=${encodeURIComponent(legalPhone)}`, token: merchantToken,
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)), 'sender-phone-filtered order')
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,
@@ -1815,7 +1906,7 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
destinationWarehouseName: masterData.destination.warehouseName,
expectedShipDate: addDays(new Date(), 30),
customerRemark: `${remark} ${marker}`,
pickupMethod: 'PICKUP',
pickupMethod: 'SELF_EXPRESS',
receivingWarehouseId: masterData.receiving.id,
receivingWarehouseName: masterData.receiving.warehouseName,
channelCode: masterData.channel.channelCode,
@@ -1867,6 +1958,7 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
upcNo: `123456${`${Date.now()}`.slice(-6)}`.slice(0, 12),
productImageFileId,
dangerousGoods: false,
woodenProduct: false,
cargoRemark: `cargo-${remark} ${marker}`,
}],
}
@@ -1911,10 +2003,13 @@ async function softApiStep(module, name, request, validate, evidenceBuilder = de
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)) {
const normalizedBody = path === '/api/auth/login' && body?.username && !body?.loginId
? { ...body, loginId: body.username, username: undefined }
: body
let requestBody = normalizedBody
if (normalizedBody != null && !(normalizedBody instanceof FormData)) {
requestHeaders['Content-Type'] = 'application/json'
requestBody = JSON.stringify(body)
requestBody = JSON.stringify(normalizedBody)
}
const response = await fetch(`${gateway}${path}`, {
method,