Files
admin-web/src/modules/customers/pages/CustomersView.test.ts
T

399 lines
18 KiB
TypeScript
Raw Normal View History

2026-08-03 14:58:17 +08:00
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
2026-08-03 20:50:18 +08:00
approveEnterpriseReview,
2026-08-03 17:44:54 +08:00
downloadTenantQualification,
2026-08-03 14:58:17 +08:00
getPlatformTenant,
2026-08-03 20:50:18 +08:00
listEnterpriseReviews,
2026-08-03 14:58:17 +08:00
listPlatformTenants,
listTenantQualifications,
2026-08-03 20:50:18 +08:00
rejectEnterpriseReview,
2026-08-03 17:44:54 +08:00
updatePlatformTenant,
uploadPlatformTenantQualification,
2026-08-03 20:50:18 +08:00
type EnterpriseReview,
2026-08-03 14:58:17 +08:00
type PlatformTenantResource,
2026-08-03 18:49:18 +08:00
} from '@/modules/iam'
import { createPlatformExportTask } from '@/modules/downloads'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
2026-08-04 09:05:19 +08:00
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
2026-08-04 11:22:04 +08:00
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
2026-08-03 14:58:17 +08:00
import CustomersView from './CustomersView.vue'
2026-08-04 13:32:08 +08:00
const { routerPush, routerReplace } = vi.hoisted(() => ({ routerPush: vi.fn(), routerReplace: vi.fn() }))
2026-08-04 11:22:04 +08:00
vi.mock('vue-router', async (importOriginal) => ({
...await importOriginal<typeof import('vue-router')>(),
2026-08-04 13:32:08 +08:00
useRoute: () => ({ query: {} }),
useRouter: () => ({ push: routerPush, replace: routerReplace }),
2026-08-04 11:22:04 +08:00
}))
2026-08-03 18:49:18 +08:00
vi.mock('@/modules/iam', () => ({
2026-08-03 20:50:18 +08:00
approveEnterpriseReview: vi.fn(),
2026-08-03 14:58:17 +08:00
changePlatformTenantStatus: vi.fn(),
createPlatformTenant: vi.fn(),
2026-08-03 17:44:54 +08:00
downloadTenantQualification: vi.fn(),
2026-08-03 14:58:17 +08:00
getPlatformTenant: vi.fn(),
2026-08-03 20:50:18 +08:00
listEnterpriseReviews: vi.fn(),
2026-08-03 14:58:17 +08:00
listPlatformTenantOrderCounts: vi.fn(),
listPlatformTenants: vi.fn(),
listTenantQualifications: vi.fn(),
2026-08-03 17:44:54 +08:00
qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'),
2026-08-03 20:50:18 +08:00
rejectEnterpriseReview: vi.fn(),
2026-08-03 14:58:17 +08:00
submitPlatformTenantReview: vi.fn(),
updatePlatformTenant: vi.fn(),
uploadPlatformTenantQualification: vi.fn(),
}))
2026-08-03 18:49:18 +08:00
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
2026-08-04 11:22:04 +08:00
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
2026-08-03 18:15:41 +08:00
2026-08-03 14:58:17 +08:00
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
const fullTenant: PlatformTenantResource = {
id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'PROFILE_REQUIRED',
2026-08-04 09:05:19 +08:00
legalName: '柏悦物流有限公司', legalRepresentative: '张三', unifiedSocialCreditCode: '91440300MA5ABCDE12',
2026-08-03 14:58:17 +08:00
contactName: '张三', contactPhone: '13800138000', contactEmail: 'ops@example.com',
servicePackage: '基础版', contactWechat: 'baiyue-ops', salesOwnerName: '李四', paymentTermDays: 30,
enterpriseType: '有限责任公司', establishedDate: '2020-01-01', registeredCapital: 1000,
registeredCapitalCurrency: 'CNY', registeredAddress: '深圳市宝安区', internalRemark: '重点客户',
createdAt: '2026-08-01T10:00:00', approvedAt: null,
}
2026-08-03 20:50:18 +08:00
const pendingReview: EnterpriseReview = {
id: '190000000000000001',
tenantId: fullTenant.id,
tenantName: fullTenant.tenantName,
tenantCode: fullTenant.tenantCode,
status: 'PENDING',
}
2026-08-03 14:58:17 +08:00
function tableButtonByText(text: string) {
return [...document.querySelectorAll<HTMLButtonElement>('.el-table__body button')]
.find((button) => button.textContent?.trim() === text)
}
enableAutoUnmount(afterEach)
describe('CustomersView', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
2026-08-03 17:44:54 +08:00
vi.clearAllMocks()
2026-08-03 14:58:17 +08:00
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
2026-08-03 17:44:54 +08:00
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:qualification-preview') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
2026-08-03 14:58:17 +08:00
document.body.innerHTML = ''
pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
2026-08-04 09:05:19 +08:00
auth.permissions = ['platform:customer:list', 'platform:customer:view', 'platform:customer:edit', 'platform:customer:export', 'platform:download:list']
2026-08-03 14:58:17 +08:00
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [{ ...fullTenant, contactPhone: '138****8000', contactEmail: 'o***@example.com' }],
page: 1, size: 10, total: 1, pages: 1,
})
vi.mocked(getPlatformTenant).mockResolvedValue(fullTenant)
vi.mocked(listTenantQualifications).mockResolvedValue([])
2026-08-03 20:50:18 +08:00
vi.mocked(listEnterpriseReviews).mockResolvedValue([])
2026-08-03 17:44:54 +08:00
vi.mocked(updatePlatformTenant).mockResolvedValue(fullTenant)
vi.mocked(uploadPlatformTenantQualification).mockResolvedValue({
qualificationId: 'qualification-1', reviewId: null, tenantStatus: 'PROFILE_REQUIRED', documentUrl: '/files/license.png',
})
2026-08-04 11:22:04 +08:00
vi.mocked(createPlatformExportTask).mockResolvedValue({
id: 'export-1', taskName: '客户列表', taskNo: 'EXP2084', status: 'PENDING',
} as never)
vi.mocked(showExportTaskCreatedDialog).mockResolvedValue(false)
2026-08-03 14:58:17 +08:00
})
it('loads the complete customer detail before editing masked list fields', async () => {
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id)
const phone = document.querySelector<HTMLInputElement>('input[placeholder="请输入手机号或固定电话"]')
const email = document.querySelector<HTMLInputElement>('input[placeholder="如:contact@example.com"]')
expect(phone?.value).toBe(fullTenant.contactPhone)
expect(email?.value).toBe(fullTenant.contactEmail)
})
2026-08-03 17:44:54 +08:00
it('shows an authenticated image preview for an uploaded qualification', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-1', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png',
status: 'PENDING', reviewComment: null, validTo: null,
}])
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
await flushPromises()
expect(downloadTenantQualification).toHaveBeenCalledWith(fullTenant.id, '/files/license.png')
const preview = document.querySelector<HTMLImageElement>('img[alt="营业执照预览"]')
expect(preview?.src).toBe('blob:qualification-preview')
})
2026-08-04 14:15:32 +08:00
it('marks only the business license and legal-person ID as required qualifications', async () => {
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
const labels = [...document.querySelectorAll<HTMLElement>('.qualification-label')]
expect(labels.map((label) => label.textContent?.replace(/\s/g, ''))).toEqual([
'*营业执照',
'*法人身份证',
'货代资质证书选填',
'银行开户许可证选填',
])
})
2026-08-04 09:05:19 +08:00
it('does not count rejected or superseded history as a current qualification when resubmitting', async () => {
const rejectedTenant = { ...fullTenant, status: 'REJECTED' }
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [rejectedTenant], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getPlatformTenant).mockResolvedValue(rejectedTenant)
vi.mocked(listTenantQualifications).mockResolvedValue([
{
id: 'qualification-old', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/old-license.png',
status: 'SUPERSEDED', reviewComment: '已替换', validTo: null,
},
{
id: 'qualification-rejected', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/rejected-license.png',
status: 'REJECTED', reviewComment: '请重新上传', validTo: null,
},
])
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
expect(document.querySelector('.qualification-label [aria-label="已上传"]')).toBeNull()
expect(document.querySelector('.qualification-file-name')?.textContent).toContain('未选择文件')
expect(downloadTenantQualification).not.toHaveBeenCalled()
})
2026-08-04 13:32:08 +08:00
it('opens customer detail as an independent route', async () => {
2026-08-03 17:44:54 +08:00
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
2026-08-04 13:32:08 +08:00
2026-08-03 17:44:54 +08:00
tableButtonByText('详情')?.click()
2026-08-04 13:32:08 +08:00
expect(routerPush).toHaveBeenCalledWith({ name: 'customer-detail', params: { tenantId: String(fullTenant.id) } })
expect(getPlatformTenant).not.toHaveBeenCalled()
2026-08-04 09:05:19 +08:00
})
2026-08-03 17:44:54 +08:00
it('saves customer data and selected qualifications with one action', async () => {
2026-08-04 21:01:39 +08:00
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID',
certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null,
}])
2026-08-03 17:44:54 +08:00
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
const licenseFile = new File(['license'], 'license.png', { type: 'image/png' })
const licenseInput = document.querySelector<HTMLInputElement>('.qualification-file input[type="file"]')
Object.defineProperty(licenseInput, 'files', { configurable: true, value: [licenseFile] })
licenseInput?.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
const buttons = [...document.querySelectorAll<HTMLButtonElement>('button')]
expect(buttons.some((button) => button.textContent?.trim() === '上传已选资质')).toBe(false)
buttons.find((button) => button.textContent?.trim() === '保存资料和资质')?.click()
await flushPromises()
expect(updatePlatformTenant).toHaveBeenCalledWith(fullTenant.id, expect.objectContaining({ tenantName: fullTenant.tenantName }))
expect(uploadPlatformTenantQualification).toHaveBeenCalledWith(
fullTenant.id,
'BUSINESS_LICENSE',
licenseFile,
undefined,
fullTenant.unifiedSocialCreditCode,
false,
)
})
2026-08-03 18:15:41 +08:00
it('submits the current filters to the backend export service without loading every customer page', async () => {
2026-08-04 09:05:19 +08:00
const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
2026-08-03 18:15:41 +08:00
await flushPromises()
2026-08-04 09:05:19 +08:00
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', { keyword: ' Acme ', status: 'ACTIVE' })
await wrapper.vm.$nextTick()
2026-08-03 18:15:41 +08:00
const exportButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '导出Excel')
exportButton?.click()
await flushPromises()
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'CUSTOMER_LIST', format: 'XLSX', taskName: '客户列表',
2026-08-04 09:05:19 +08:00
parameters: { keyword: 'Acme', status: 'ACTIVE' },
2026-08-03 18:15:41 +08:00
})
2026-08-04 11:22:04 +08:00
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户列表', taskNo: 'EXP2084' })
2026-08-03 18:15:41 +08:00
expect(listPlatformTenants).toHaveBeenCalledTimes(1)
})
2026-08-03 20:50:18 +08:00
2026-08-04 11:22:04 +08:00
it('opens the download center when the export-created dialog action is confirmed', async () => {
vi.mocked(createPlatformExportTask).mockResolvedValue({
id: 'export-1', taskName: '客户列表', taskNo: 'EXP2084', status: 'PENDING',
} as never)
vi.mocked(showExportTaskCreatedDialog).mockResolvedValue(true)
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const exportButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '导出Excel')
exportButton?.click()
await flushPromises()
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户列表', taskNo: 'EXP2084' })
expect(routerPush).toHaveBeenCalledWith({ name: 'download-center' })
})
2026-08-04 09:05:19 +08:00
it('hides customer export when the stable export permission is absent', async () => {
const auth = useAuthStore()
auth.permissions = ['platform:customer:list', 'platform:customer:view']
useAccessPreviewStore().initializeAfterLogin()
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect([...document.querySelectorAll<HTMLButtonElement>('button')]
.some((button) => button.textContent?.trim() === '导出Excel')).toBe(false)
expect(createPlatformExportTask).not.toHaveBeenCalled()
})
it('does not create an unrecoverable export for a role without download-center access', async () => {
const auth = useAuthStore()
auth.permissions = ['platform:customer:list', 'platform:customer:export']
useAccessPreviewStore().initializeAfterLogin()
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect([...document.querySelectorAll<HTMLButtonElement>('button')]
.some((button) => button.textContent?.trim() === '导出Excel')).toBe(false)
expect(createPlatformExportTask).not.toHaveBeenCalled()
})
2026-08-03 20:50:18 +08:00
it('approves a pending enterprise directly from the customer list', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:review', 'platform:customer:approve']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [{ ...fullTenant, status: 'PENDING' }], page: 1, size: 10, total: 1, pages: 1,
})
vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview])
vi.mocked(approveEnterpriseReview).mockResolvedValue({
tenantId: fullTenant.id,
administratorCreated: true,
administratorUsername: 'merchant_8_owner',
temporaryPassword: 'Temporary#2026',
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('通过')?.click()
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '确认通过')?.click()
await flushPromises()
expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过')
expect(rejectEnterpriseReview).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('请立即保存以下一次性凭证')
expect(document.body.textContent).toContain('merchant_8_owner')
expect(document.body.textContent).toContain('Temporary#2026')
})
2026-08-04 09:05:19 +08:00
it('keeps the client registration account as administrator after approval', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:review', 'platform:customer:approve']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [{ ...fullTenant, status: 'PENDING' }], page: 1, size: 10, total: 1, pages: 1,
})
vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview])
vi.mocked(approveEnterpriseReview).mockResolvedValue({
tenantId: fullTenant.id,
administratorCreated: false,
administratorUsername: null,
temporaryPassword: null,
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('通过')?.click()
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '确认通过')?.click()
await flushPromises()
expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过')
expect(rejectEnterpriseReview).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('注册账号继续作为企业管理员')
expect(document.body.textContent).not.toContain('请立即保存以下一次性凭证')
})
2026-08-03 20:50:18 +08:00
it('keeps review-only roles on the merged customer page without calling the customer list endpoint', async () => {
const auth = useAuthStore()
auth.permissions = ['platform:customer:review', 'platform:customer:approve']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview])
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(listPlatformTenants).not.toHaveBeenCalled()
expect(document.body.textContent).toContain(fullTenant.tenantName)
expect(tableButtonByText('通过')).toBeDefined()
})
it('does not let an older review response restore stale actions', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:review', 'platform:customer:approve']
useAccessPreviewStore().initializeAfterLogin()
let resolveFirst!: (value: EnterpriseReview[]) => void
let resolveSecond!: (value: EnterpriseReview[]) => void
vi.mocked(listEnterpriseReviews)
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await Promise.resolve()
;[...document.querySelectorAll<HTMLButtonElement>('.query-toolbar__actions button')]
.find((button) => button.textContent?.trim() === '查询')?.click()
await Promise.resolve()
resolveSecond([pendingReview])
await flushPromises()
expect(tableButtonByText('通过')).toBeDefined()
resolveFirst([])
await flushPromises()
expect(tableButtonByText('通过')).toBeDefined()
})
it('keeps the first operation column unfixed so it cannot cover a 320px table viewport', async () => {
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(document.querySelector('.el-table__fixed')).toBeNull()
})
2026-08-03 14:58:17 +08:00
})