feat: 添加测试框架和优化项目结构
- 添加 Jest 配置和测试设置 - 添加 use-categories hook 的单元测试 - 更新 CLAUDE.md 添加包管理工具说明 - 优化首页、视频页和频道页的组件结构 - 添加 .claude 命令配置文件 - 移除 bun.lock 和 package-lock.json,统一使用 bun - 更新 package.json 依赖 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
417
hooks/use-categories.test.ts
Normal file
417
hooks/use-categories.test.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useCategories } from './use-categories'
|
||||
import { useCategoriesStore } from './use-categories'
|
||||
import { root } from '@repo/core'
|
||||
import { CategoryController } from '@repo/sdk'
|
||||
import { handleError } from './use-error'
|
||||
import { OWNER_ID } from '@/lib/auth'
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@repo/core', () => ({
|
||||
root: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('./use-error', () => ({
|
||||
handleError: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
OWNER_ID: 'test-owner-id',
|
||||
}))
|
||||
|
||||
describe('useCategories', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
// Reset the store state before each test
|
||||
useCategoriesStore.setState({
|
||||
data: undefined,
|
||||
loading: false,
|
||||
error: null,
|
||||
hasLoaded: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should return initial state with no data loaded', () => {
|
||||
const mockCategoryController = {
|
||||
list: jest.fn(),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockResolvedValue({
|
||||
data: undefined,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategories())
|
||||
|
||||
expect(result.current.data).toBeUndefined()
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should have loading state initially false', () => {
|
||||
const mockCategoryController = {
|
||||
list: jest.fn(),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockResolvedValue({
|
||||
data: undefined,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategories())
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('load function', () => {
|
||||
it('should load categories successfully', async () => {
|
||||
const mockData = {
|
||||
categories: [
|
||||
{ id: '1', name: 'Category 1' },
|
||||
{ id: '2', name: 'Category 2' },
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
}
|
||||
|
||||
const mockCategoryController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockResolvedValue({
|
||||
data: mockData,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategories())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load()
|
||||
})
|
||||
|
||||
expect(mockCategoryController.list).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
isActive: true,
|
||||
orderBy: 'sortOrder',
|
||||
order: 'desc',
|
||||
withChildren: true,
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
const mockError = {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
message: 'Failed to load categories',
|
||||
}
|
||||
|
||||
const mockCategoryController = {
|
||||
list: jest.fn(),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockResolvedValue({
|
||||
data: null,
|
||||
error: mockError,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategories())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load()
|
||||
})
|
||||
|
||||
expect(result.current.error).toEqual(mockError)
|
||||
expect(result.current.data).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle unexpected errors', async () => {
|
||||
const mockError = new Error('Network error')
|
||||
|
||||
const mockCategoryController = {
|
||||
list: jest.fn().mockRejectedValue(mockError),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockResolvedValue({
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategories())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load()
|
||||
})
|
||||
|
||||
// When handleError returns no error but an exception occurs
|
||||
expect(result.current.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('should merge custom params with default params', async () => {
|
||||
const mockData = { categories: [], total: 0, page: 1, limit: 1000 }
|
||||
const customParams = { page: 2, limit: 20 }
|
||||
|
||||
const mockCategoryController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockResolvedValue({
|
||||
data: mockData,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategories())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load(customParams)
|
||||
})
|
||||
|
||||
expect(mockCategoryController.list).toHaveBeenCalledWith({
|
||||
page: 2,
|
||||
limit: 20,
|
||||
isActive: true,
|
||||
orderBy: 'sortOrder',
|
||||
order: 'desc',
|
||||
withChildren: true,
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not load if already loading', async () => {
|
||||
const mockCategoryController = {
|
||||
list: jest.fn().mockResolvedValue({ categories: [], total: 0 }),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ data: { categories: [], total: 0 }, error: null }), 100)
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useCategories())
|
||||
|
||||
// Start first load
|
||||
const firstLoad = act(async () => {
|
||||
await result.current.load()
|
||||
})
|
||||
|
||||
// Try to load again while first is still loading
|
||||
act(() => {
|
||||
result.current.load()
|
||||
})
|
||||
|
||||
await firstLoad
|
||||
|
||||
// Should only call list once due to loading check
|
||||
expect(mockCategoryController.list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should set loading to true during fetch', async () => {
|
||||
let resolveFetch: (value: any) => void
|
||||
const fetchPromise = new Promise((resolve) => {
|
||||
resolveFetch = resolve
|
||||
})
|
||||
|
||||
const mockCategoryController = {
|
||||
list: jest.fn().mockReturnValue(fetchPromise),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategories())
|
||||
|
||||
act(() => {
|
||||
result.current.load()
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch!({ categories: [], total: 0 })
|
||||
await fetchPromise
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('should set loading to false after error', async () => {
|
||||
const mockError = {
|
||||
status: 500,
|
||||
statusText: 'Error',
|
||||
message: 'Test error',
|
||||
}
|
||||
|
||||
const mockCategoryController = {
|
||||
list: jest.fn(),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockResolvedValue({
|
||||
data: null,
|
||||
error: mockError,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategories())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load()
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initial load on mount', () => {
|
||||
it('should auto-load on mount if not loaded', async () => {
|
||||
const mockData = { categories: [{ id: '1' }], total: 1, page: 1, limit: 1000 }
|
||||
|
||||
const mockCategoryController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockResolvedValue({
|
||||
data: mockData,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategories())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
})
|
||||
|
||||
expect(mockCategoryController.list).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not auto-load if already loaded', async () => {
|
||||
const mockData = { categories: [{ id: '1' }], total: 1, page: 1, limit: 1000 }
|
||||
|
||||
const mockCategoryController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockResolvedValue({
|
||||
data: mockData,
|
||||
error: null,
|
||||
})
|
||||
|
||||
// Set store to already loaded state
|
||||
useCategoriesStore.setState({
|
||||
data: mockData,
|
||||
loading: false,
|
||||
error: null,
|
||||
hasLoaded: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategories())
|
||||
|
||||
// Should not call list again since hasLoaded is true
|
||||
expect(mockCategoryController.list).not.toHaveBeenCalled()
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state persistence', () => {
|
||||
it('should share state across hook instances', async () => {
|
||||
const mockData = { categories: [{ id: '1' }], total: 1, page: 1, limit: 1000 }
|
||||
|
||||
const mockCategoryController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockResolvedValue({
|
||||
data: mockData,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { result: result1 } = renderHook(() => useCategories())
|
||||
const { result: result2 } = renderHook(() => useCategories())
|
||||
|
||||
await act(async () => {
|
||||
await result1.current.load()
|
||||
})
|
||||
|
||||
// Both hook instances should share the same store state
|
||||
expect(result2.current.data).toEqual(mockData)
|
||||
expect(result1.current.data).toBe(result2.current.data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('params handling', () => {
|
||||
it('should use initial params when calling load without arguments', async () => {
|
||||
const mockData = { categories: [], total: 0, page: 1, limit: 1000 }
|
||||
const initialParams = { isActive: false }
|
||||
|
||||
const mockCategoryController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockResolvedValue({
|
||||
data: mockData,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategories(initialParams))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load()
|
||||
})
|
||||
|
||||
expect(mockCategoryController.list).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
isActive: false, // Initial params should override default
|
||||
orderBy: 'sortOrder',
|
||||
order: 'desc',
|
||||
withChildren: true,
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
})
|
||||
|
||||
it('should override initial params when load is called with arguments', async () => {
|
||||
const mockData = { categories: [], total: 0, page: 1, limit: 1000 }
|
||||
const initialParams = { isActive: true }
|
||||
|
||||
const mockCategoryController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
;(handleError as jest.Mock).mockResolvedValue({
|
||||
data: mockData,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategories(initialParams))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load({ isActive: false })
|
||||
})
|
||||
|
||||
expect(mockCategoryController.list).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
isActive: false, // Load params should override initial
|
||||
orderBy: 'sortOrder',
|
||||
order: 'desc',
|
||||
withChildren: true,
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,42 @@
|
||||
import { root } from '@repo/core'
|
||||
import { CategoryController, type ListCategoriesInput, type ListCategoriesResult } from '@repo/sdk'
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { create } from 'zustand'
|
||||
|
||||
import { type ApiError } from '@/lib/types'
|
||||
|
||||
import { OWNER_ID } from '@/lib/auth'
|
||||
import { handleError } from './use-error'
|
||||
|
||||
interface CategoriesState {
|
||||
data: ListCategoriesResult | undefined
|
||||
loading: boolean
|
||||
error: ApiError | null
|
||||
hasLoaded: boolean
|
||||
}
|
||||
|
||||
export const useCategories = () => {
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<ApiError | null>(null)
|
||||
const [data, setData] = useState<ListCategoriesResult>()
|
||||
const useCategoriesStore = create<CategoriesState>((set) => ({
|
||||
data: undefined,
|
||||
loading: false,
|
||||
error: null,
|
||||
hasLoaded: false,
|
||||
}))
|
||||
|
||||
export const useCategories = (params?: ListCategoriesInput) => {
|
||||
const store = useCategoriesStore()
|
||||
const [localLoading, setLocalLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!store.hasLoaded && !store.loading && !localLoading) {
|
||||
load()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const load = async (inputParams?: ListCategoriesInput) => {
|
||||
if (store.loading || localLoading) return
|
||||
|
||||
const load = async (params?: ListCategoriesInput) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setLocalLoading(true)
|
||||
const category = root.get(CategoryController)
|
||||
const { data, error } = await handleError(
|
||||
async () =>
|
||||
@@ -27,26 +48,26 @@ export const useCategories = () => {
|
||||
order: 'desc',
|
||||
withChildren: true,
|
||||
ownerId: OWNER_ID,
|
||||
...params,
|
||||
...(inputParams ?? params),
|
||||
}),
|
||||
)
|
||||
if (error) {
|
||||
setError(error)
|
||||
useCategoriesStore.setState({ error, loading: false, hasLoaded: true })
|
||||
return
|
||||
}
|
||||
setData(data)
|
||||
useCategoriesStore.setState({ data, loading: false, error: null, hasLoaded: true })
|
||||
} catch (e) {
|
||||
setError(e as ApiError)
|
||||
useCategoriesStore.setState({ error: e as ApiError, loading: false, hasLoaded: true })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLocalLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
loading: store.loading || localLoading,
|
||||
error: store.error,
|
||||
data: store.data,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user