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 { approveEnterpriseReview, archivePlatformTenant, downloadTenantQualification, createPlatformTenantImpersonation, getPlatformTenant, listEnterpriseReviews, listPlatformTenants, listTenantQualifications, rejectEnterpriseReview, resetPlatformTenantOwnerPassword, updatePlatformTenant, uploadPlatformTenantQualification, uploadPlatformTenantRequiredQualificationPackage, type EnterpriseReview, type PlatformTenantResource, } from '@/modules/iam' import { createPlatformExportTask } from '@/modules/downloads' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import QueryToolbar from '@/shared/components/QueryToolbar.vue' import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' import CustomersView from './CustomersView.vue' const { routerPush, routerReplace, routeControl } = vi.hoisted(() => ({ routerPush: vi.fn(), routerReplace: vi.fn(), routeControl: { setEditId: (_editId: string) => {} }, })) vi.mock('vue-router', async (importOriginal) => ({ ...await importOriginal(), ...await (async () => { const { reactive } = await import('vue') const route = reactive<{ query: { edit?: string } }>({ query: {} }) routeControl.setEditId = (editId: string) => { if (editId) route.query.edit = editId else delete route.query.edit } return { useRoute: () => route } })(), useRouter: () => ({ push: routerPush, replace: routerReplace }), })) vi.mock('@/modules/iam', () => ({ approveEnterpriseReview: vi.fn(), archivePlatformTenant: vi.fn(), changePlatformTenantStatus: vi.fn(), checkPlatformTenantContactPhone: vi.fn().mockResolvedValue(true), createPlatformTenant: vi.fn(), createPlatformTenantImpersonation: vi.fn(), downloadTenantQualification: vi.fn(), getPlatformTenant: vi.fn(), listEnterpriseReviews: vi.fn(), listPlatformTenantOrderCounts: vi.fn(), listPlatformTenants: vi.fn(), listTenantQualifications: vi.fn(), qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'), rejectEnterpriseReview: vi.fn(), resetPlatformTenantOwnerPassword: vi.fn(), submitPlatformTenantReview: vi.fn(), updatePlatformTenant: vi.fn(), uploadPlatformTenantQualification: vi.fn(), uploadPlatformTenantRequiredQualificationPackage: vi.fn(), })) vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() })) vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() })) class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } const fullTenant: PlatformTenantResource = { id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'PROFILE_REQUIRED', legalName: '柏悦物流有限公司', legalRepresentative: '张三', unifiedSocialCreditCode: '91440300MA5ABCDE12', 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, } const pendingReview: EnterpriseReview = { id: '190000000000000001', tenantId: fullTenant.id, tenantName: fullTenant.tenantName, tenantCode: fullTenant.tenantCode, status: 'PENDING', } function tableButtonByText(text: string) { return [...document.querySelectorAll('.el-table__body button')] .find((button) => button.textContent?.trim() === text) } async function clickMoreAction(text: string) { document.querySelector('.el-table__body button[aria-label="更多客户操作"]')?.click() await flushPromises() const item = [...document.querySelectorAll('.el-dropdown-menu__item')] .find((candidate) => candidate.textContent?.trim() === text && !candidate.classList.contains('is-disabled')) expect(item).toBeDefined() item!.dispatchEvent(new MouseEvent('click', { bubbles: true })) await flushPromises() } enableAutoUnmount(afterEach) describe('CustomersView', () => { let pinia: ReturnType beforeEach(() => { vi.clearAllMocks() routeControl.setEditId('') vi.stubGlobal('ResizeObserver', ResizeObserverStub) Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:qualification-preview') }) Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) document.body.innerHTML = '' pinia = createPinia() setActivePinia(pinia) const auth = useAuthStore() auth.token = 'token' auth.permissions = ['platform:customer:list', 'platform:customer:view', 'platform:customer:edit', 'platform:customer:export', 'platform:download:list'] 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([]) vi.mocked(listEnterpriseReviews).mockResolvedValue([]) vi.mocked(updatePlatformTenant).mockResolvedValue(fullTenant) vi.mocked(uploadPlatformTenantQualification).mockResolvedValue({ qualificationId: 'qualification-1', reviewId: null, tenantStatus: 'PROFILE_REQUIRED', documentUrl: '/files/license.png', }) vi.mocked(uploadPlatformTenantRequiredQualificationPackage).mockResolvedValue({ businessLicenseQualificationId: 'qualification-business', legalPersonIdQualificationId: 'qualification-legal', tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/files/license.png', legalPersonIdDocumentUrl: '/files/legal-person-new.png', }) vi.mocked(createPlatformExportTask).mockResolvedValue({ id: 'export-1', taskName: '客户列表', taskNo: 'EXP2084', status: 'PENDING', } as never) vi.mocked(showExportTaskCreatedDialog).mockResolvedValue(false) }) 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('input[placeholder="请输入手机号或固定电话"]') const email = document.querySelector('input[placeholder="如:contact@example.com"]') expect(phone?.value).toBe(fullTenant.contactPhone) expect(email?.value).toBe(fullTenant.contactEmail) }) 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('img[alt="营业执照预览"]') expect(preview?.src).toBe('blob:qualification-preview') }) 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('.qualification-label')] expect(labels.map((label) => label.textContent?.replace(/\s/g, ''))).toEqual([ '*营业执照', '*法人身份证', '货代资质证书选填', '银行开户许可证选填', ]) }) 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() }) it('opens customer detail as an independent route', async () => { mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() tableButtonByText('详情')?.click() expect(routerPush).toHaveBeenCalledWith({ name: 'customer-detail', params: { tenantId: String(fullTenant.id) } }) expect(getPlatformTenant).not.toHaveBeenCalled() }) it('opens the requested editor when a cached list receives an edit query', async () => { mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() routeControl.setEditId(String(fullTenant.id)) await flushPromises() expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id) expect(document.querySelector('input[placeholder="请输入手机号或固定电话"]')?.value).toBe(fullTenant.contactPhone) expect(routerReplace).toHaveBeenCalledWith({ name: 'customers' }) }) it('saves both required files and the credit code through one package action', async () => { 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, }]) 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 legalPersonFile = new File(['legal-person'], 'legal-person.png', { type: 'image/png' }) const qualificationInputs = document.querySelectorAll('.qualification-file input[type="file"]') const licenseInput = qualificationInputs[0] const legalPersonInput = qualificationInputs[1] Object.defineProperty(licenseInput, 'files', { configurable: true, value: [licenseFile] }) licenseInput?.dispatchEvent(new Event('change', { bubbles: true })) Object.defineProperty(legalPersonInput, 'files', { configurable: true, value: [legalPersonFile] }) legalPersonInput?.dispatchEvent(new Event('change', { bubbles: true })) await flushPromises() const buttons = [...document.querySelectorAll('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(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith( fullTenant.id, licenseFile, legalPersonFile, fullTenant.unifiedSocialCreditCode, false, true, ) expect(uploadPlatformTenantQualification).not.toHaveBeenCalled() }) it('submits the current filters to the backend export service without loading every customer page', async () => { const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', { keyword: ' Acme ', status: 'ACTIVE' }) await wrapper.vm.$nextTick() const exportButton = [...document.querySelectorAll('button')] .find((button) => button.textContent?.trim() === '导出Excel') exportButton?.click() await flushPromises() expect(createPlatformExportTask).toHaveBeenCalledWith({ exportType: 'CUSTOMER_LIST', format: 'XLSX', taskName: '客户列表', parameters: { keyword: 'Acme', status: 'ACTIVE' }, }) expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户列表', taskNo: 'EXP2084' }) expect(listPlatformTenants).toHaveBeenCalledTimes(1) }) 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('button')] .find((button) => button.textContent?.trim() === '导出Excel') exportButton?.click() await flushPromises() expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户列表', taskNo: 'EXP2084' }) expect(routerPush).toHaveBeenCalledWith({ name: 'download-center' }) }) 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('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('button')] .some((button) => button.textContent?.trim() === '导出Excel')).toBe(false) expect(createPlatformExportTask).not.toHaveBeenCalled() }) 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('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') }) 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('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('请立即保存以下一次性凭证') }) 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('.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('resets only the customer owner password through the tenant contract', async () => { const auth = useAuthStore() auth.permissions = [...auth.permissions, 'platform:customer:reset-password'] useAccessPreviewStore().initializeAfterLogin() vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ ...fullTenant, status: 'ACTIVE' }], page: 1, size: 10, total: 1, pages: 1 }) vi.mocked(resetPlatformTenantOwnerPassword).mockResolvedValue() mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() await clickMoreAction('重置密码') const password = document.querySelector('input[autocomplete="new-password"]') const confirmation = document.querySelector('input[placeholder="请再次输入新密码"]') password!.value = 'NewPassword#2026' password!.dispatchEvent(new Event('input', { bubbles: true })) confirmation!.value = 'NewPassword#2026' confirmation!.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() ;[...document.querySelectorAll('button')] .find((button) => button.textContent?.trim() === '确认重置')?.click() await flushPromises() expect(resetPlatformTenantOwnerPassword).toHaveBeenCalledWith(fullTenant.id, { newPassword: 'NewPassword#2026', forceChangePassword: true, }) }) it('archives a customer only after its customer ID is explicitly confirmed', async () => { const auth = useAuthStore() auth.permissions = [...auth.permissions, 'platform:customer:delete'] useAccessPreviewStore().initializeAfterLogin() vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'DISABLED' }) const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() const dropdowns = wrapper.findAllComponents({ name: 'ElDropdown' }) expect(dropdowns.length).toBeGreaterThan(0) dropdowns.at(-1)!.vm.$emit('command', 'delete') await flushPromises() const confirmation = document.querySelector('input[placeholder="TEN8"]') expect(confirmation).toBeTruthy() expect([...document.querySelectorAll('button')] .find((button) => button.textContent?.trim() === '确认删除')?.disabled).toBe(true) confirmation!.value = 'TEN8' confirmation!.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() ;[...document.querySelectorAll('button')] .find((button) => button.textContent?.trim() === '确认删除')?.click() await flushPromises() expect(archivePlatformTenant).toHaveBeenCalledWith(fullTenant.id) }) 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() }) it('keeps frequent actions visible and groups secondary customer actions under more', async () => { const auth = useAuthStore() auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete'] useAccessPreviewStore().initializeAfterLogin() vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ ...fullTenant, status: 'ACTIVE' }], page: 1, size: 10, total: 1, pages: 1 }) mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() expect(tableButtonByText('详情')).toBeDefined() expect(tableButtonByText('编辑')).toBeDefined() expect(document.querySelector('.el-table__body button[aria-label="更多客户操作"]')).toBeTruthy() expect(tableButtonByText('重置密码')).toBeUndefined() await clickMoreAction('重置密码') expect(document.querySelector('input[autocomplete="new-password"]')).toBeTruthy() }) })