Files
datahub/frontend/src/pages/orders/__tests__/index.spec.ts
T
nz 9a78398cfe add order list and detail page
Implement P6.1: order management UI with list table, multi-dimension
filters (cascade + status + date ranges), detail drawer with order
items nested table, and 20 test cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 14:47:59 +08:00

520 lines
16 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 { useOrderStore, type OrderRecord } from '@/stores/order'
// 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 mockOrders: OrderRecord[] = [
{
id: 1,
company_id: 1,
platform_id: 1,
store_id: 1,
order_status_id: 3,
platform_order_id: 'ORD-20260101-001',
total_amount: '199.99',
total_paid: '189.99',
total_discount: '10.00',
created_date: '2026-01-01T10:00:00Z',
paid_date: '2026-01-01T10:05:00Z',
shipping_date: '2026-01-02T08:00:00Z',
},
{
id: 2,
company_id: 2,
platform_id: 1,
store_id: 2,
order_status_id: 1,
platform_order_id: 'ORD-20260201-002',
total_amount: '59.00',
total_paid: '0',
total_discount: '0',
created_date: '2026-02-01T14:00:00Z',
paid_date: null,
shipping_date: null,
},
]
const mockPaginatedResponse = {
items: mockOrders,
total: 2,
page: 1,
per_page: 15,
}
const mockOrderDetail = {
...mockOrders[0],
buyer_user_id: 'buyer_123',
payment_method_id: 1,
presale: false,
total_received: '189.99',
freight_fee: '0.00',
tax_fee: '0.00',
discount_fee: '10.00',
commission_fee: '5.00',
coupon_amount: '0.00',
voucher_amount: '0.00',
order_type_id: 1,
updated_date: '2026-01-02T08:00:00Z',
zipcode: '310000',
city: '杭州',
province: '浙江',
country: '中国',
ext: { remark: '加急发货', tags: ['vip'] },
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-02T08:00:00Z',
order_items: [
{
id: 101,
company_id: 1,
platform_id: 1,
store_id: 1,
order_id: 1,
platform_order_id: 'ORD-20260101-001',
sub_order_id: 'SUB-001',
sub_order_type_id: 1,
product_id: 500,
platform_product_id: 'PROD-001',
product_sku: 'SKU-ABC-001',
product_barcode: '6901234567890',
unit_price: '99.99',
quantity: 2,
discount: '10.00',
total: '189.98',
created_date: '2026-01-01T10:00:00Z',
ext: null,
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
},
],
}
const mockRawDetail = {
id: 1,
platform_order_id: 'ORD-20260101-001',
store_id: 1,
company_id: 1,
platform_id: 1,
order_status_id: 3,
raw: { tid: 'tmall_001', status: 'TRADE_FINISHED', orders: [{ oid: 'sub_1' }] },
hash: 'abc123',
ext: null,
created_date: '2026-01-01T10: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('useOrderStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
describe('initial state', () => {
it('starts with empty orders and default pagination', () => {
const store = useOrderStore()
expect(store.orders).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.order_status_id).toBeUndefined()
expect(store.filters.platform_order_id).toBe('')
expect(store.filters.created_date_range).toBeNull()
expect(store.filters.paid_date_range).toBeNull()
})
})
describe('fetchOrders', () => {
it('loads data and updates state', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse)
const store = useOrderStore()
await store.fetchOrders()
expect(api.get).toHaveBeenCalledWith(
'/api/v1/orders',
expect.objectContaining({
page: 1,
per_page: 15,
order_status_id: undefined,
platform_order_id: undefined,
created_date_from: undefined,
created_date_to: undefined,
paid_date_from: undefined,
paid_date_to: undefined,
}),
)
expect(store.orders).toEqual(mockOrders)
expect(store.pagination.total).toBe(2)
})
it('passes date range params correctly', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse)
const store = useOrderStore()
store.filters.order_status_id = 3
store.filters.platform_order_id = 'ORD-001'
store.filters.created_date_range = ['2026-01-01', '2026-01-31']
store.filters.paid_date_range = ['2026-02-01', '2026-02-28']
store.cascadeValue.company_id = 1
store.cascadeValue.platform_id = 2
store.cascadeValue.store_id = 3
await store.fetchOrders()
expect(api.get).toHaveBeenCalledWith(
'/api/v1/orders',
expect.objectContaining({
order_status_id: 3,
platform_order_id: 'ORD-001',
created_date_from: '2026-01-01',
created_date_to: '2026-01-31',
paid_date_from: '2026-02-01',
paid_date_to: '2026-02-28',
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 = useOrderStore()
await store.fetchOrders()
expect(errorSpy).toHaveBeenCalledWith('Network error')
expect(store.orders).toEqual([])
})
})
describe('resetFilters', () => {
it('clears all filters including date ranges and resets page to 1', () => {
const store = useOrderStore()
store.filters.order_status_id = 3
store.filters.platform_order_id = 'ORD-001'
store.filters.created_date_range = ['2026-01-01', '2026-01-31']
store.filters.paid_date_range = ['2026-02-01', '2026-02-28']
store.cascadeValue.company_id = 1
store.cascadeValue.platform_id = 2
store.cascadeValue.store_id = 3
store.pagination.page = 5
store.resetFilters()
expect(store.filters.order_status_id).toBeUndefined()
expect(store.filters.platform_order_id).toBe('')
expect(store.filters.created_date_range).toBeNull()
expect(store.filters.paid_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 = useOrderStore()
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 = useOrderStore()
await store.loadLookups()
expect(warnSpy).toHaveBeenCalledWith('加载查找表数据失败', expect.any(Error))
expect(store.companyMap.size).toBe(0)
warnSpy.mockRestore()
})
})
describe('fetchOrders error handling', () => {
it('clears orders on fetch failure', async () => {
const { api } = await import('@/utils/request')
const { message } = await import('ant-design-vue')
vi.spyOn(message, 'error')
const store = useOrderStore()
vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse)
await store.fetchOrders()
expect(store.orders.length).toBe(2)
vi.mocked(api.get).mockRejectedValueOnce(new Error('Server error'))
await store.fetchOrders()
expect(store.orders).toEqual([])
expect(store.pagination.total).toBe(0)
})
})
})
// ─── Page Component Tests ──────────────────────────────
describe('OrdersPage', () => {
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/orders') 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: OrdersPage } = await import('../index.vue')
wrapper = mount(OrdersPage, {
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 fetchOrders on mount', async () => {
const { api } = await mountPage()
expect(api.get).toHaveBeenCalledWith(
'/api/v1/orders',
expect.objectContaining({
page: 1,
per_page: 15,
}),
)
})
it('renders table with order data', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('ORD-20260101-001')
expect(html).toContain('199.99')
expect(html).toContain('189.99')
})
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('renders status tags with correct text', async () => {
await mountPage()
const tags = wrapper.findAll('.ant-tag')
const tagTexts = tags.map((t) => t.text())
expect(tagTexts).toContain('已支付')
expect(tagTexts).toContain('待付款')
})
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 = useOrderStore()
store.pagination.page = 3
store.filters.order_status_id = 3
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/orders',
expect.objectContaining({
page: 1,
order_status_id: 3,
}),
)
})
it('resets filters when clicking reset button', async () => {
await mountPage()
const store = useOrderStore()
store.filters.order_status_id = 3
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.order_status_id).toBeUndefined()
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(mockOrderDetail)
.mockResolvedValueOnce(mockRawDetail)
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 ext JSON in drawer when available', async () => {
const { api } = await mountPage()
vi.mocked(api.get)
.mockResolvedValueOnce(mockOrderDetail)
.mockResolvedValueOnce(mockRawDetail)
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('remark')
expect(drawerHtml).toContain('加急发货')
})
it('displays raw JSON in drawer when available', async () => {
const { api } = await mountPage()
vi.mocked(api.get)
.mockResolvedValueOnce(mockOrderDetail)
.mockResolvedValueOnce(mockRawDetail)
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('tmall_001')
expect(drawerHtml).toContain('TRADE_FINISHED')
})
it('shows placeholder when ext/raw is null', async () => {
const { api } = await mountPage()
vi.mocked(api.get)
.mockResolvedValueOnce({ ...mockOrderDetail, ext: null, order_items: [] })
.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()
const drawerHtml = document.body.querySelector('.ant-drawer')?.innerHTML || ''
expect(drawerHtml).toContain('暂无数据')
})
})