Files
datahub/frontend/src/pages/refunds/__tests__/index.spec.ts
T
2026-03-20 09:30:41 +08:00

517 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('获取退款详情失败')
})
})