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

519 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 { useOrderItemStore } from '@/stores/order-item'
import type { OrderItemRecord } 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 mockOrderItems: OrderItemRecord[] = [
{
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',
},
{
id: 102,
company_id: 2,
platform_id: 1,
store_id: 2,
order_id: 2,
platform_order_id: 'ORD-20260201-002',
sub_order_id: null,
sub_order_type_id: 1,
product_id: 501,
platform_product_id: 'PROD-002',
product_sku: null,
product_barcode: null,
unit_price: '59.00',
quantity: 1,
discount: '0',
total: '59.00',
created_date: '2026-02-01T14:00:00Z',
ext: { color: 'red' },
created_at: '2026-02-01T14:00:00Z',
updated_at: '2026-02-01T14:00:00Z',
},
]
const mockPaginatedResponse = {
items: mockOrderItems,
total: 2,
page: 1,
per_page: 15,
}
const mockItemDetail = {
...mockOrderItems[0],
parent_order: {
id: 1,
platform_order_id: 'ORD-20260101-001',
order_status_id: 3,
total_amount: '199.99',
total_paid: '189.99',
created_date: '2026-01-01T10:00:00Z',
paid_date: '2026-01-01T10:05: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('useOrderItemStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
describe('initial state', () => {
it('starts with empty orderItems and default pagination', () => {
const store = useOrderItemStore()
expect(store.orderItems).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.platform_order_id).toBe('')
expect(store.filters.platform_product_id).toBe('')
expect(store.filters.product_sku).toBe('')
expect(store.filters.created_date_range).toBeNull()
})
})
describe('fetchOrderItems', () => {
it('loads data and updates state', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse)
const store = useOrderItemStore()
await store.fetchOrderItems()
expect(api.get).toHaveBeenCalledWith(
'/api/v1/order-items',
expect.objectContaining({
page: 1,
per_page: 15,
platform_order_id: undefined,
platform_product_id: undefined,
product_sku: undefined,
created_date_from: undefined,
created_date_to: undefined,
}),
)
expect(store.orderItems).toEqual(mockOrderItems)
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 = useOrderItemStore()
store.filters.platform_order_id = 'ORD-001'
store.filters.platform_product_id = 'PROD-001'
store.filters.product_sku = 'SKU-ABC'
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.fetchOrderItems()
expect(api.get).toHaveBeenCalledWith(
'/api/v1/order-items',
expect.objectContaining({
platform_order_id: 'ORD-001',
platform_product_id: 'PROD-001',
product_sku: 'SKU-ABC',
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 = useOrderItemStore()
await store.fetchOrderItems()
expect(errorSpy).toHaveBeenCalledWith('Network error')
expect(store.orderItems).toEqual([])
})
it('clears orderItems on fetch failure', async () => {
const { api } = await import('@/utils/request')
const { message } = await import('ant-design-vue')
vi.spyOn(message, 'error')
const store = useOrderItemStore()
vi.mocked(api.get).mockResolvedValueOnce(mockPaginatedResponse)
await store.fetchOrderItems()
expect(store.orderItems.length).toBe(2)
vi.mocked(api.get).mockRejectedValueOnce(new Error('Server error'))
await store.fetchOrderItems()
expect(store.orderItems).toEqual([])
expect(store.pagination.total).toBe(0)
})
})
describe('resetFilters', () => {
it('clears all filters and resets page to 1', () => {
const store = useOrderItemStore()
store.filters.platform_order_id = 'ORD-001'
store.filters.platform_product_id = 'PROD-001'
store.filters.product_sku = 'SKU-ABC'
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.platform_order_id).toBe('')
expect(store.filters.platform_product_id).toBe('')
expect(store.filters.product_sku).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 = useOrderItemStore()
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 = useOrderItemStore()
await store.loadLookups()
expect(warnSpy).toHaveBeenCalledWith('加载查找表数据失败', expect.any(Error))
expect(store.companyMap.size).toBe(0)
warnSpy.mockRestore()
})
})
})
// ─── Page Component Tests ──────────────────────────────
describe('OrderItemsPage', () => {
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/order-items') 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: OrderItemsPage } = await import('../index.vue')
wrapper = mount(OrderItemsPage, {
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 fetchOrderItems and loadLookups on mount', async () => {
const { api } = await mountPage()
expect(api.get).toHaveBeenCalledWith(
'/api/v1/order-items',
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 order item data', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('ORD-20260101-001')
expect(html).toContain('SKU-ABC-001')
expect(html).toContain('99.99')
expect(html).toContain('189.98')
})
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()
// mockOrderItems[1] has discount '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 = useOrderItemStore()
store.pagination.page = 3
store.filters.platform_order_id = 'ORD-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/order-items',
expect.objectContaining({
page: 1,
platform_order_id: 'ORD-001',
}),
)
})
it('resets filters when clicking reset button', async () => {
await mountPage()
const store = useOrderItemStore()
store.filters.platform_order_id = 'ORD-001'
store.filters.platform_product_id = 'PROD-001'
store.filters.product_sku = 'SKU-ABC'
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.platform_order_id).toBe('')
expect(store.filters.platform_product_id).toBe('')
expect(store.filters.product_sku).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(mockItemDetail)
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 parent order summary in drawer', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockResolvedValueOnce(mockItemDetail)
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('ORD-20260101-001')
expect(drawerHtml).toContain('199.99')
expect(drawerHtml).toContain('189.99')
expect(drawerHtml).toContain('已支付')
})
it('displays ext JSON in drawer when available', async () => {
const { api } = await mountPage()
const detailWithExt = {
...mockItemDetail,
ext: { color: 'red', size: 'XL' },
}
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('color')
expect(drawerHtml).toContain('red')
})
it('shows placeholder when ext is null', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockResolvedValueOnce({ ...mockItemDetail, 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 placeholder when parent_order is null', async () => {
const { api } = await mountPage()
vi.mocked(api.get).mockResolvedValueOnce({ ...mockItemDetail, parent_order: 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('获取订单项详情失败')
})
})