import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { mount, flushPromises } from '@vue/test-utils' import { nextTick } from 'vue' import { useRefundStore } from '@/stores/refund' import type { RefundRecord } from '@/stores/refund' // jsdom 不支持 matchMedia,Ant Design Vue 响应式布局需要 Object.defineProperty(window, 'matchMedia', { writable: true, value: (query: string) => ({ matches: false, media: query, onchange: null, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, }), }) vi.mock('@/utils/request', () => ({ api: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), }, })) const mockRefunds: RefundRecord[] = [ { id: 201, company_id: 1, platform_id: 1, store_id: 1, platform_refund_id: 'RF-20260101-001', platform_order_id: 'ORD-20260101-001', refund_status_id: 1, refund_type_id: 1, refund_amount: '99.99', freight_refund: '10.00', refund_total: '109.99', currency: 'CNY', created_date: '2026-01-01T10:00:00Z', completed_date: null, }, { id: 202, company_id: 2, platform_id: 1, store_id: 2, platform_refund_id: 'RF-20260201-002', platform_order_id: 'ORD-20260201-002', refund_status_id: 5, refund_type_id: 2, refund_amount: '59.00', freight_refund: '0', refund_total: '59.00', currency: 'USD', created_date: '2026-02-01T14:00:00Z', completed_date: '2026-02-03T09:00:00Z', }, ] const mockPaginatedResponse = { items: mockRefunds, total: 2, page: 1, per_page: 15, } const mockRefundDetail = { ...mockRefunds[0], order_id: 1, buyer_user_id: 'buyer-001', reason: '商品质量问题', order_created_date: '2025-12-30T08:00:00Z', order_paid_date: '2025-12-30T08:05:00Z', updated_date: '2026-01-02T10:00:00Z', ext: null, created_at: '2026-01-01T10:00:00Z', updated_at: '2026-01-02T10:00:00Z', } const mockLookupCompanies = [ { id: 1, name: 'Company A', label: '公司A' }, { id: 2, name: 'Company B', label: '公司B' }, ] const mockLookupPlatforms = [{ id: 1, developer_id: 1 }] const mockLookupStores = [ { id: 1, company_id: 1, platform_id: 1, name: 'Store 1', label: '店铺1' }, { id: 2, company_id: 2, platform_id: 1, name: 'Store 2', label: '店铺2' }, ] // ─── Store Tests ─────────────────────────────────────── describe('useRefundStore', () => { beforeEach(() => { setActivePinia(createPinia()) vi.restoreAllMocks() }) describe('initial state', () => { it('starts with empty refunds and default pagination', () => { const store = useRefundStore() expect(store.refunds).toEqual([]) expect(store.loading).toBe(false) expect(store.pagination.page).toBe(1) expect(store.pagination.per_page).toBe(15) expect(store.pagination.total).toBe(0) expect(store.filters.refund_status_id).toBeUndefined() expect(store.filters.refund_type_id).toBeUndefined() expect(store.filters.platform_refund_id).toBe('') expect(store.filters.platform_order_id).toBe('') expect(store.filters.created_date_range).toBeNull() }) }) describe('fetchRefunds', () => { it('loads data and updates state', async () => { const { api } = await import('@/utils/request') vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) const store = useRefundStore() await store.fetchRefunds() expect(api.get).toHaveBeenCalledWith( '/api/v1/refunds', expect.objectContaining({ page: 1, per_page: 15, refund_status_id: undefined, refund_type_id: undefined, platform_refund_id: undefined, platform_order_id: undefined, created_date_from: undefined, created_date_to: undefined, }), ) expect(store.refunds).toEqual(mockRefunds) expect(store.pagination.total).toBe(2) }) it('passes filter params correctly', async () => { const { api } = await import('@/utils/request') vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) const store = useRefundStore() store.filters.refund_status_id = 1 store.filters.refund_type_id = 2 store.filters.platform_refund_id = 'RF-001' store.filters.platform_order_id = 'ORD-001' store.filters.created_date_range = ['2026-01-01', '2026-01-31'] store.cascadeValue.company_id = 1 store.cascadeValue.platform_id = 2 store.cascadeValue.store_id = 3 await store.fetchRefunds() expect(api.get).toHaveBeenCalledWith( '/api/v1/refunds', expect.objectContaining({ refund_status_id: 1, refund_type_id: 2, platform_refund_id: 'RF-001', platform_order_id: 'ORD-001', created_date_from: '2026-01-01', created_date_to: '2026-01-31', company_id: 1, platform_id: 2, store_id: 3, }), ) }) it('shows error message on failure', async () => { const { api } = await import('@/utils/request') const { message } = await import('ant-design-vue') const errorSpy = vi.spyOn(message, 'error') vi.mocked(api.get).mockRejectedValueOnce(new Error('Network error')) const store = useRefundStore() await store.fetchRefunds() expect(errorSpy).toHaveBeenCalledWith('Network error') expect(store.refunds).toEqual([]) }) it('clears refunds on fetch failure', async () => { const { api } = await import('@/utils/request') const { message } = await import('ant-design-vue') vi.spyOn(message, 'error') const store = useRefundStore() vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse) await store.fetchRefunds() expect(store.refunds.length).toBe(2) vi.mocked(api.get).mockRejectedValueOnce(new Error('Server error')) await store.fetchRefunds() expect(store.refunds).toEqual([]) expect(store.pagination.total).toBe(0) }) }) describe('resetFilters', () => { it('clears all filters and resets page to 1', () => { const store = useRefundStore() store.filters.refund_status_id = 1 store.filters.refund_type_id = 2 store.filters.platform_refund_id = 'RF-001' store.filters.platform_order_id = 'ORD-001' store.filters.created_date_range = ['2026-01-01', '2026-01-31'] store.cascadeValue.company_id = 1 store.cascadeValue.platform_id = 2 store.cascadeValue.store_id = 3 store.pagination.page = 5 store.resetFilters() expect(store.filters.refund_status_id).toBeUndefined() expect(store.filters.refund_type_id).toBeUndefined() expect(store.filters.platform_refund_id).toBe('') expect(store.filters.platform_order_id).toBe('') expect(store.filters.created_date_range).toBeNull() expect(store.cascadeValue.company_id).toBeUndefined() expect(store.cascadeValue.platform_id).toBeUndefined() expect(store.cascadeValue.store_id).toBeUndefined() expect(store.pagination.page).toBe(1) }) }) describe('name maps', () => { it('builds lookup maps from loaded data', async () => { const { api } = await import('@/utils/request') vi.mocked(api.get) .mockResolvedValueOnce(mockLookupCompanies) .mockResolvedValueOnce(mockLookupPlatforms) .mockResolvedValueOnce(mockLookupStores) const store = useRefundStore() await store.loadLookups() expect(store.companyMap.get(1)).toBe('公司A') expect(store.platformMap.get(1)).toBe('平台 #1') expect(store.storeMap.get(1)).toBe('店铺1') }) it('handles loadLookups failure gracefully', async () => { const { api } = await import('@/utils/request') const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.mocked(api.get).mockRejectedValueOnce(new Error('Network error')) const store = useRefundStore() await store.loadLookups() expect(warnSpy).toHaveBeenCalledWith('加载查找表数据失败', expect.any(Error)) expect(store.companyMap.size).toBe(0) warnSpy.mockRestore() }) }) }) // ─── Page Component Tests ────────────────────────────── describe('RefundsPage', () => { let wrapper: ReturnType beforeEach(() => { setActivePinia(createPinia()) vi.restoreAllMocks() document.body.innerHTML = '' }) afterEach(() => { wrapper?.unmount() document.body.innerHTML = '' }) async function mountPage() { const { api } = await import('@/utils/request') vi.mocked(api.get).mockImplementation((url: string) => { if (url === '/api/v1/refunds') return Promise.resolve(mockPaginatedResponse) as never if (url === '/api/v1/companies') return Promise.resolve(mockLookupCompanies) as never if (url === '/api/v1/platforms') return Promise.resolve(mockLookupPlatforms) as never if (url === '/api/v1/stores') return Promise.resolve(mockLookupStores) as never return Promise.resolve([]) as never }) const { default: RefundsPage } = await import('../index.vue') wrapper = mount(RefundsPage, { attachTo: document.body, global: { stubs: { SearchOutlined: { template: '' }, ReloadOutlined: { template: '' }, EyeOutlined: { template: '' }, CopyOutlined: { template: '' }, CascadeFilter: { template: '
' }, }, }, }) await flushPromises() await nextTick() return { wrapper, api } } it('renders page title and action buttons', async () => { await mountPage() expect(wrapper.text()).toContain('退款管理') const buttons = wrapper.findAll('.ant-btn') const buttonTexts = buttons.map((b) => b.text()) expect(buttonTexts.some((t) => t.includes('搜索'))).toBe(true) expect(buttonTexts.some((t) => t.includes('重置'))).toBe(true) }, 15000) it('calls fetchRefunds and loadLookups on mount', async () => { const { api } = await mountPage() expect(api.get).toHaveBeenCalledWith( '/api/v1/refunds', expect.objectContaining({ page: 1, per_page: 15, }), ) expect(api.get).toHaveBeenCalledWith('/api/v1/companies') expect(api.get).toHaveBeenCalledWith('/api/v1/platforms') expect(api.get).toHaveBeenCalledWith('/api/v1/stores') }) it('renders table with refund data', async () => { await mountPage() const html = wrapper.html() expect(html).toContain('RF-20260101-001') expect(html).toContain('ORD-20260101-001') expect(html).toContain('99.99') expect(html).toContain('109.99') }) it('renders refund status tags correctly', async () => { await mountPage() const html = wrapper.html() expect(html).toContain('退款申请中') expect(html).toContain('退款成功') }) it('renders refund type tags correctly', async () => { await mountPage() const html = wrapper.html() expect(html).toContain('未发货前退款') expect(html).toContain('退货退款') }) it('renders company/platform/store names from lookup maps', async () => { await mountPage() const html = wrapper.html() expect(html).toContain('公司A') expect(html).toContain('店铺1') }) it('formats amounts correctly (dash for zero values)', async () => { await mountPage() // mockRefunds[1] has freight_refund '0' which should render as '-' const html = wrapper.html() expect(html).toContain('59.00') }) it('renders CascadeFilter component', async () => { await mountPage() expect(wrapper.find('.cascade-filter-stub').exists()).toBe(true) }) it('search button resets page to 1 and fetches', async () => { const { api } = await mountPage() const store = useRefundStore() store.pagination.page = 3 store.filters.platform_refund_id = 'RF-001' vi.mocked(api.get).mockClear() vi.mocked(api.get).mockResolvedValue(mockPaginatedResponse) const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) const searchBtn = buttons.find((b) => b.textContent?.trim().includes('搜索')) searchBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) await flushPromises() await nextTick() expect(store.pagination.page).toBe(1) expect(api.get).toHaveBeenCalledWith( '/api/v1/refunds', expect.objectContaining({ page: 1, platform_refund_id: 'RF-001', }), ) }) it('resets filters when clicking reset button', async () => { await mountPage() const store = useRefundStore() store.filters.refund_status_id = 1 store.filters.refund_type_id = 2 store.filters.platform_refund_id = 'RF-001' store.filters.platform_order_id = 'ORD-001' store.filters.created_date_range = ['2026-01-01', '2026-01-31'] const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) const resetBtn = buttons.find((b) => b.textContent?.trim().includes('重置')) expect(resetBtn).toBeDefined() resetBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) await flushPromises() await nextTick() expect(store.filters.refund_status_id).toBeUndefined() expect(store.filters.refund_type_id).toBeUndefined() expect(store.filters.platform_refund_id).toBe('') expect(store.filters.platform_order_id).toBe('') expect(store.filters.created_date_range).toBeNull() }) it('opens detail drawer when clicking view button', async () => { const { api } = await mountPage() vi.mocked(api.get).mockResolvedValueOnce(mockRefundDetail) const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看') expect(viewBtn).toBeDefined() viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) await flushPromises() await nextTick() const drawer = document.body.querySelector('.ant-drawer') expect(drawer).toBeTruthy() }) it('displays refund detail info in drawer', async () => { const { api } = await mountPage() vi.mocked(api.get).mockResolvedValueOnce(mockRefundDetail) const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看') viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) await flushPromises() await nextTick() const drawerHtml = document.body.querySelector('.ant-drawer')?.innerHTML || '' expect(drawerHtml).toContain('退款详情') expect(drawerHtml).toContain('RF-20260101-001') expect(drawerHtml).toContain('商品质量问题') expect(drawerHtml).toContain('buyer-001') expect(drawerHtml).toContain('退款申请中') }) it('displays ext JSON in drawer when available', async () => { const { api } = await mountPage() const detailWithExt = { ...mockRefundDetail, ext: { return_logistics: 'SF1234567890', warehouse: 'WH-01' }, } vi.mocked(api.get).mockResolvedValueOnce(detailWithExt) const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看') viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) await flushPromises() await nextTick() const drawerHtml = document.body.querySelector('.ant-drawer')?.innerHTML || '' expect(drawerHtml).toContain('return_logistics') expect(drawerHtml).toContain('SF1234567890') }) it('shows placeholder when ext is null', async () => { const { api } = await mountPage() vi.mocked(api.get).mockResolvedValueOnce({ ...mockRefundDetail, ext: null }) const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看') viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) await flushPromises() await nextTick() const drawerHtml = document.body.querySelector('.ant-drawer')?.innerHTML || '' expect(drawerHtml).toContain('暂无数据') }) it('shows error message when detail request fails', async () => { const { api } = await mountPage() const { message } = await import('ant-design-vue') const errorSpy = vi.spyOn(message, 'error') vi.mocked(api.get).mockRejectedValueOnce(new Error('Not found')) const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) const viewBtn = buttons.find((b) => b.textContent?.trim() === '查看') viewBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) await flushPromises() await nextTick() expect(errorSpy).toHaveBeenCalledWith('获取退款详情失败') }) })