feat: 实现 API 接口对接功能 (删除作品、作品搜索、修改密码)
按照 TDD 规范完成三个核心功能的接口对接: ## 新增功能 ### 1. 删除作品功能 (app/generationRecord.tsx) - 新增 use-template-generation-actions.ts hook - 支持单个删除和批量删除作品 - 删除确认对话框 - 删除成功后自动刷新列表 - 完整的错误处理和加载状态 ### 2. 作品搜索功能 (app/searchWorksResults.tsx) - 新增 use-works-search.ts hook - 替换模拟数据为真实 SDK 接口 - 支持关键词搜索和分类筛选 - 支持分页加载 - 完整的加载、错误、空结果状态处理 ### 3. 修改密码功能 (app/changePassword.tsx) - 新增 use-change-password.ts hook - 使用 Better Auth 的 changePassword API - 客户端表单验证(密码长度、确认密码匹配等) - 成功后自动返回并提示 ## 技术实现 - 严格遵循 TDD 规范(先写测试,后写实现) - 新增 3 个 hooks 和对应的单元测试 - 更新中英文翻译文件 - 更新 jest.setup.js 添加必要的 mock ## 文档 - 新增 api_integration_report.md - API 对接分析报告 - 新增 api_integration_development_plan.md - 开发计划和完成汇总 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
156
tests/hooks/use-change-password.test.ts
Normal file
156
tests/hooks/use-change-password.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react-native'
|
||||
import { useChangePassword } from '@/hooks/use-change-password'
|
||||
import { authClient } from '@/lib/auth'
|
||||
|
||||
// Mock authClient
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authClient: {
|
||||
changePassword: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useChangePassword', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should change password successfully', async () => {
|
||||
const mockChangePassword = authClient.changePassword as jest.MockedFunction<typeof authClient.changePassword>
|
||||
mockChangePassword.mockResolvedValue({ error: null })
|
||||
|
||||
const { result } = renderHook(() => useChangePassword())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.changePassword({
|
||||
oldPassword: 'oldPass123',
|
||||
newPassword: 'newPass123',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockChangePassword).toHaveBeenCalledWith({
|
||||
oldPassword: 'oldPass123',
|
||||
newPassword: 'newPass123',
|
||||
revokeOtherSessions: true,
|
||||
})
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should validate old password is not empty', async () => {
|
||||
const { result } = renderHook(() => useChangePassword())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.changePassword({
|
||||
oldPassword: '',
|
||||
newPassword: 'newPass123',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.error).toEqual({
|
||||
message: '旧密码不能为空',
|
||||
})
|
||||
expect(authClient.changePassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should validate new password and confirm password match', async () => {
|
||||
const { result } = renderHook(() => useChangePassword())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.changePassword({
|
||||
oldPassword: 'oldPass123',
|
||||
newPassword: 'newPass123',
|
||||
confirmPassword: 'differentPass123',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.error).toEqual({
|
||||
message: '新密码和确认密码不一致',
|
||||
})
|
||||
expect(authClient.changePassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should validate new password length (minimum 6 characters)', async () => {
|
||||
const { result } = renderHook(() => useChangePassword())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.changePassword({
|
||||
oldPassword: 'oldPass123',
|
||||
newPassword: '12345',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.error).toEqual({
|
||||
message: '新密码长度至少为6位',
|
||||
})
|
||||
expect(authClient.changePassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should validate new password is different from old password', async () => {
|
||||
const { result } = renderHook(() => useChangePassword())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.changePassword({
|
||||
oldPassword: 'samePass123',
|
||||
newPassword: 'samePass123',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.error).toEqual({
|
||||
message: '新密码不能与当前密码相同',
|
||||
})
|
||||
expect(authClient.changePassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
const mockError = {
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
message: '旧密码错误',
|
||||
}
|
||||
|
||||
const mockChangePassword = authClient.changePassword as jest.MockedFunction<typeof authClient.changePassword>
|
||||
mockChangePassword.mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useChangePassword())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.changePassword({
|
||||
oldPassword: 'wrongPass',
|
||||
newPassword: 'newPass123',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.error).toEqual(mockError)
|
||||
})
|
||||
|
||||
it('should set loading state during password change', async () => {
|
||||
let resolveChangePassword: (value: any) => void
|
||||
const changePasswordPromise = new Promise((resolve) => {
|
||||
resolveChangePassword = resolve
|
||||
})
|
||||
|
||||
const mockChangePassword = authClient.changePassword as jest.MockedFunction<typeof authClient.changePassword>
|
||||
mockChangePassword.mockReturnValue(changePasswordPromise)
|
||||
|
||||
const { result } = renderHook(() => useChangePassword())
|
||||
|
||||
act(() => {
|
||||
result.current.changePassword({
|
||||
oldPassword: 'oldPass123',
|
||||
newPassword: 'newPass123',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
resolveChangePassword!({ error: null })
|
||||
await changePasswordPromise
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
359
tests/hooks/use-template-generation-actions.test.ts
Normal file
359
tests/hooks/use-template-generation-actions.test.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* use-template-generation-actions.test.ts
|
||||
*
|
||||
* TDD 测试文件 - 测试模板生成记录的删除功能
|
||||
*
|
||||
* 测试覆盖:
|
||||
* 1. 单个删除成功场景
|
||||
* 2. 单个删除失败场景
|
||||
* 3. 批量删除成功场景(可选)
|
||||
* 4. 批量删除失败场景(可选)
|
||||
*/
|
||||
|
||||
import { renderHook, act, waitFor } from '@testing-library/react-native'
|
||||
import { root } from '@repo/core'
|
||||
import { TemplateGenerationController } from '@repo/sdk'
|
||||
import { useDeleteGeneration, useBatchDeleteGenerations } from '@/hooks/use-template-generation-actions'
|
||||
|
||||
// Mock SDK
|
||||
jest.mock('@repo/core', () => ({
|
||||
root: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('use-template-generation-actions', () => {
|
||||
// 在每个测试前重置所有 mocks
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useDeleteGeneration', () => {
|
||||
it('应该成功删除单个生成记录', async () => {
|
||||
// Arrange: 准备测试数据
|
||||
const mockDelete = jest.fn().mockResolvedValue({ message: '删除成功' })
|
||||
const mockController = {
|
||||
delete: mockDelete,
|
||||
} as unknown as TemplateGenerationController
|
||||
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
// Act: 渲染 hook 并执行删除
|
||||
const { result } = renderHook(() => useDeleteGeneration())
|
||||
|
||||
await act(async () => {
|
||||
const response = await result.current.deleteGeneration('test-id-123')
|
||||
// Assert: 验证返回值
|
||||
expect(response).toEqual({
|
||||
data: { message: '删除成功' },
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
// Assert: 验证控制器被正确调用
|
||||
expect(root.get).toHaveBeenCalledWith(TemplateGenerationController)
|
||||
expect(mockDelete).toHaveBeenCalledWith({ id: 'test-id-123' })
|
||||
expect(mockDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('删除失败时应该返回错误信息', async () => {
|
||||
// Arrange: 准备测试数据和模拟错误
|
||||
const mockError = {
|
||||
message: '删除失败:记录不存在',
|
||||
code: 'NOT_FOUND',
|
||||
}
|
||||
const mockDelete = jest.fn().mockRejectedValue(mockError)
|
||||
const mockController = {
|
||||
delete: mockDelete,
|
||||
} as unknown as TemplateGenerationController
|
||||
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
// Act: 渲染 hook 并执行删除
|
||||
const { result } = renderHook(() => useDeleteGeneration())
|
||||
|
||||
await act(async () => {
|
||||
const response = await result.current.deleteGeneration('non-existent-id')
|
||||
// Assert: 验证返回的错误信息
|
||||
expect(response).toEqual({
|
||||
data: null,
|
||||
error: mockError,
|
||||
})
|
||||
})
|
||||
|
||||
// Assert: 验证控制器被调用
|
||||
expect(mockDelete).toHaveBeenCalledWith({ id: 'non-existent-id' })
|
||||
})
|
||||
|
||||
it('删除过程中应该设置正确的加载状态', async () => {
|
||||
// Arrange: 准备异步测试
|
||||
const mockDelete = jest.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ message: '删除成功' }), 100)
|
||||
}),
|
||||
)
|
||||
const mockController = {
|
||||
delete: mockDelete,
|
||||
} as unknown as TemplateGenerationController
|
||||
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
// Act: 渲染 hook
|
||||
const { result } = renderHook(() => useDeleteGeneration())
|
||||
|
||||
// Assert: 初始状态应该是未加载
|
||||
expect(result.current.loading).toBe(false)
|
||||
|
||||
// Act: 开始删除
|
||||
const deletePromise = act(async () => {
|
||||
await result.current.deleteGeneration('test-id')
|
||||
})
|
||||
|
||||
// Assert: 删除过程中应该是加载状态
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(true)
|
||||
})
|
||||
|
||||
await deletePromise
|
||||
|
||||
// Assert: 删除完成后应该重置加载状态
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('删除失败后应该更新错误状态', async () => {
|
||||
// Arrange
|
||||
const mockError = {
|
||||
message: '网络错误',
|
||||
code: 'NETWORK_ERROR',
|
||||
}
|
||||
const mockDelete = jest.fn().mockRejectedValue(mockError)
|
||||
const mockController = {
|
||||
delete: mockDelete,
|
||||
} as unknown as TemplateGenerationController
|
||||
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useDeleteGeneration())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteGeneration('test-id')
|
||||
})
|
||||
|
||||
// Assert: 验证错误状态
|
||||
expect(result.current.error).toEqual(mockError)
|
||||
})
|
||||
|
||||
it('连续调用多次删除应该正确处理', async () => {
|
||||
// Arrange
|
||||
const mockDelete = jest.fn()
|
||||
.mockResolvedValueOnce({ message: '第一次删除成功' })
|
||||
.mockResolvedValueOnce({ message: '第二次删除成功' })
|
||||
const mockController = {
|
||||
delete: mockDelete,
|
||||
} as unknown as TemplateGenerationController
|
||||
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useDeleteGeneration())
|
||||
|
||||
await act(async () => {
|
||||
const response1 = await result.current.deleteGeneration('id-1')
|
||||
const response2 = await result.current.deleteGeneration('id-2')
|
||||
|
||||
// Assert
|
||||
expect(response1.data?.message).toBe('第一次删除成功')
|
||||
expect(response2.data?.message).toBe('第二次删除成功')
|
||||
})
|
||||
|
||||
// Assert: 验证调用次数
|
||||
expect(mockDelete).toHaveBeenCalledTimes(2)
|
||||
expect(mockDelete).toHaveBeenNthCalledWith(1, { id: 'id-1' })
|
||||
expect(mockDelete).toHaveBeenNthCalledWith(2, { id: 'id-2' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBatchDeleteGenerations', () => {
|
||||
it('应该成功批量删除生成记录', async () => {
|
||||
// Arrange
|
||||
const mockBatchDelete = jest.fn().mockResolvedValue({
|
||||
message: '批量删除成功',
|
||||
deletedCount: 3,
|
||||
})
|
||||
const mockController = {
|
||||
batchDelete: mockBatchDelete,
|
||||
} as unknown as TemplateGenerationController
|
||||
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useBatchDeleteGenerations())
|
||||
|
||||
await act(async () => {
|
||||
const response = await result.current.batchDeleteGenerations([
|
||||
'id-1',
|
||||
'id-2',
|
||||
'id-3',
|
||||
])
|
||||
|
||||
// Assert
|
||||
expect(response).toEqual({
|
||||
data: {
|
||||
message: '批量删除成功',
|
||||
deletedCount: 3,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
// Assert: 验证控制器被正确调用
|
||||
expect(root.get).toHaveBeenCalledWith(TemplateGenerationController)
|
||||
expect(mockBatchDelete).toHaveBeenCalledWith({
|
||||
ids: ['id-1', 'id-2', 'id-3'],
|
||||
})
|
||||
})
|
||||
|
||||
it('批量删除失败时应该返回错误信息', async () => {
|
||||
// Arrange
|
||||
const mockError = {
|
||||
message: '批量删除失败:部分记录不存在',
|
||||
code: 'PARTIAL_FAILURE',
|
||||
}
|
||||
const mockBatchDelete = jest.fn().mockRejectedValue(mockError)
|
||||
const mockController = {
|
||||
batchDelete: mockBatchDelete,
|
||||
} as unknown as TemplateGenerationController
|
||||
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useBatchDeleteGenerations())
|
||||
|
||||
await act(async () => {
|
||||
const response = await result.current.batchDeleteGenerations([
|
||||
'id-1',
|
||||
'id-2',
|
||||
])
|
||||
|
||||
// Assert
|
||||
expect(response).toEqual({
|
||||
data: null,
|
||||
error: mockError,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockBatchDelete).toHaveBeenCalledWith({
|
||||
ids: ['id-1', 'id-2'],
|
||||
})
|
||||
})
|
||||
|
||||
it('批量删除时应该设置正确的加载状态', async () => {
|
||||
// Arrange
|
||||
const mockBatchDelete = jest.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ message: '批量删除成功' }), 100)
|
||||
}),
|
||||
)
|
||||
const mockController = {
|
||||
batchDelete: mockBatchDelete,
|
||||
} as unknown as TemplateGenerationController
|
||||
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useBatchDeleteGenerations())
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
|
||||
// Act
|
||||
const deletePromise = act(async () => {
|
||||
await result.current.batchDeleteGenerations(['id-1', 'id-2'])
|
||||
})
|
||||
|
||||
// Assert: 删除过程中
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(true)
|
||||
})
|
||||
|
||||
await deletePromise
|
||||
|
||||
// Assert: 删除完成后
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('传入空数组时应该正常处理', async () => {
|
||||
// Arrange
|
||||
const mockBatchDelete = jest.fn().mockResolvedValue({
|
||||
message: '批量删除成功',
|
||||
deletedCount: 0,
|
||||
})
|
||||
const mockController = {
|
||||
batchDelete: mockBatchDelete,
|
||||
} as unknown as TemplateGenerationController
|
||||
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useBatchDeleteGenerations())
|
||||
|
||||
await act(async () => {
|
||||
const response = await result.current.batchDeleteGenerations([])
|
||||
|
||||
// Assert
|
||||
expect(response.data?.deletedCount).toBe(0)
|
||||
})
|
||||
|
||||
expect(mockBatchDelete).toHaveBeenCalledWith({ ids: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('应该处理 ID 为空字符串的情况', async () => {
|
||||
// Arrange
|
||||
const mockDelete = jest.fn().mockResolvedValue({ message: '删除成功' })
|
||||
const mockController = {
|
||||
delete: mockDelete,
|
||||
} as unknown as TemplateGenerationController
|
||||
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useDeleteGeneration())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteGeneration('')
|
||||
})
|
||||
|
||||
// Assert: 即使是空字符串,也应该调用 API
|
||||
expect(mockDelete).toHaveBeenCalledWith({ id: '' })
|
||||
})
|
||||
|
||||
it('应该处理超长 ID 的情况', async () => {
|
||||
// Arrange
|
||||
const longId = 'a'.repeat(1000)
|
||||
const mockDelete = jest.fn().mockResolvedValue({ message: '删除成功' })
|
||||
const mockController = {
|
||||
delete: mockDelete,
|
||||
} as unknown as TemplateGenerationController
|
||||
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useDeleteGeneration())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteGeneration(longId)
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(mockDelete).toHaveBeenCalledWith({ id: longId })
|
||||
})
|
||||
})
|
||||
})
|
||||
416
tests/hooks/use-works-search.test.ts
Normal file
416
tests/hooks/use-works-search.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* TDD Phase 1: RED - Write failing tests first
|
||||
*
|
||||
* This test file follows TDD principles:
|
||||
* 1. Tests are written BEFORE implementation
|
||||
* 2. Tests describe desired behavior, not implementation
|
||||
* 3. Tests should fail initially because Hook doesn't exist
|
||||
*/
|
||||
|
||||
import { renderHook, waitFor, act } from '@testing-library/react-native'
|
||||
import { useWorksSearch } from '@/hooks/use-works-search'
|
||||
import { root } from '@repo/core'
|
||||
import { TemplateGenerationController } from '@repo/sdk'
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@repo/core', () => ({
|
||||
root: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock @tanstack/react-query before importing the hook
|
||||
const mockRefetch = jest.fn()
|
||||
const mockUseQuery = jest.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
}))
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn((args) => mockUseQuery(args)),
|
||||
}))
|
||||
|
||||
describe('useWorksSearch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should return initial state with no data when no keyword provided', () => {
|
||||
// This test will FAIL initially because useWorksSearch doesn't exist yet
|
||||
const { result } = renderHook(() => useWorksSearch({ keyword: '' }))
|
||||
|
||||
expect(result.current.data).toBeUndefined()
|
||||
expect(result.current.works).toEqual([])
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should not execute query when keyword is empty', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
renderHook(() => useWorksSearch({ keyword: '' }))
|
||||
|
||||
// Verify useQuery was called with enabled: false
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: false,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should not execute query when keyword is only whitespace', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
renderHook(() => useWorksSearch({ keyword: ' ' }))
|
||||
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: false,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyword search', () => {
|
||||
it('should search works by keyword successfully', async () => {
|
||||
const mockData = {
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
createdAt: new Date('2025-01-15'),
|
||||
template: { id: 'template-1', name: 'Test Template' },
|
||||
status: 'completed',
|
||||
duration: 5,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
createdAt: new Date('2025-01-14'),
|
||||
template: { id: 'template-2', name: 'Test Template 2' },
|
||||
status: 'completed',
|
||||
duration: 10,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: mockData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorksSearch({ keyword: '测试' }))
|
||||
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
expect(result.current.works).toEqual(mockData.data)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
|
||||
// Verify queryKey includes keyword
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['worksSearch', '测试', undefined, 1, 20],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle search with special characters in keyword', () => {
|
||||
const mockData = {
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 0,
|
||||
}
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: mockData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorksSearch({ keyword: '测试@#$%' }))
|
||||
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['worksSearch', '测试@#$%', undefined, 1, 20],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute query when keyword has content', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: { data: [], total: 0, page: 1, limit: 20, totalPages: 0 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
renderHook(() => useWorksSearch({ keyword: 'test' }))
|
||||
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('category filter', () => {
|
||||
it('should filter works by category', () => {
|
||||
const mockData = {
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
createdAt: new Date('2025-01-15'),
|
||||
template: { id: 'template-1', name: 'Test Template' },
|
||||
status: 'completed',
|
||||
duration: 5,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: mockData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWorksSearch({ keyword: '测试', category: '萌宠' })
|
||||
)
|
||||
|
||||
expect(result.current.works).toEqual(mockData.data)
|
||||
|
||||
// Verify queryKey includes category
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['worksSearch', '测试', '萌宠', 1, 20],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle "全部" category by not passing category parameter', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: { data: [], total: 0, page: 1, limit: 20, totalPages: 0 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
renderHook(() => useWorksSearch({ keyword: '测试', category: '全部' }))
|
||||
|
||||
// "全部" should be excluded from queryKey
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['worksSearch', '测试', undefined, 1, 20],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should refetch when category changes', () => {
|
||||
const mockData1 = {
|
||||
data: [{ id: '1', createdAt: new Date(), template: { id: 't1', name: 'T1' }, status: 'completed', duration: 5 }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const mockData2 = {
|
||||
data: [{ id: '2', createdAt: new Date(), template: { id: 't2', name: 'T2' }, status: 'completed', duration: 10 }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
mockUseQuery
|
||||
.mockReturnValueOnce({
|
||||
data: mockData1,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
data: mockData2,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ keyword, category }) => useWorksSearch({ keyword, category }),
|
||||
{
|
||||
initialProps: { keyword: '测试', category: '萌宠' as const },
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.current.works).toEqual(mockData1.data)
|
||||
|
||||
// Switch category
|
||||
rerender({ keyword: '测试', category: '写真' as const })
|
||||
|
||||
expect(result.current.works).toEqual(mockData2.data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pagination', () => {
|
||||
it('should load works with custom page and limit', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: { data: [], total: 0, page: 2, limit: 10, totalPages: 0 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWorksSearch({ keyword: '测试', page: 2, limit: 10 })
|
||||
)
|
||||
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['worksSearch', '测试', undefined, 2, 10],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should use default page 1 and limit 20 when not specified', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: { data: [], total: 0, page: 1, limit: 20, totalPages: 0 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
renderHook(() => useWorksSearch({ keyword: '测试' }))
|
||||
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['worksSearch', '测试', undefined, 1, 20],
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle API errors', () => {
|
||||
const mockError = {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
message: 'Failed to search works',
|
||||
}
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: mockError,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorksSearch({ keyword: '测试' }))
|
||||
|
||||
expect(result.current.error).toEqual(mockError)
|
||||
expect(result.current.data).toBeUndefined()
|
||||
expect(result.current.works).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle network errors', () => {
|
||||
const networkError = new Error('Network Error')
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: networkError,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorksSearch({ keyword: '测试' }))
|
||||
|
||||
expect(result.current.error).toEqual(networkError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should set isLoading to true during fetch', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorksSearch({ keyword: '测试' }))
|
||||
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it('should set isLoading to false after error', () => {
|
||||
const mockError = { status: 500, message: 'Error' }
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: mockError,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorksSearch({ keyword: '测试' }))
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toEqual(mockError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty results', () => {
|
||||
it('should handle empty search results', () => {
|
||||
const mockData = {
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 0,
|
||||
}
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: mockData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorksSearch({ keyword: '不存在的关键词' }))
|
||||
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
expect(result.current.works).toEqual([])
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user