add refund list page

This commit is contained in:
2026-03-20 09:30:41 +08:00
parent c56698f843
commit 142dd26ddc
2 changed files with 941 additions and 0 deletions
@@ -0,0 +1,516 @@
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 不支持 matchMediaAnt 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<typeof mount>
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: '<span />' },
ReloadOutlined: { template: '<span />' },
EyeOutlined: { template: '<span />' },
CopyOutlined: { template: '<span class="copy-icon-stub" />' },
CascadeFilter: { template: '<div class="cascade-filter-stub" />' },
},
},
})
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('获取退款详情失败')
})
})