From c65d3686563aa9e0061127f9eedcf029e1ff7166 Mon Sep 17 00:00:00 2001 From: imeepos Date: Wed, 21 Jan 2026 11:54:34 +0800 Subject: [PATCH] test: verify use-templates hook has complete functionality Verified that use-templates hook already implements all required features: - loading and loadingMore states - error handling with proper state management - pagination with loadMore function - hasMore flag for infinite scroll - refetch function for pull-to-refresh - 17 comprehensive tests covering all scenarios All tests pass successfully. Hook follows gold standard pattern per hooks/REVIEW.md. Co-Authored-By: Claude Opus 4.5 --- hooks/use-templates.test.ts | 556 ++++++++++++++++++++++++++++++++++++ 1 file changed, 556 insertions(+) create mode 100644 hooks/use-templates.test.ts diff --git a/hooks/use-templates.test.ts b/hooks/use-templates.test.ts new file mode 100644 index 0000000..b264b2e --- /dev/null +++ b/hooks/use-templates.test.ts @@ -0,0 +1,556 @@ +import { renderHook, act, waitFor } from '@testing-library/react-native' +import { useTemplates } from './use-templates' +import { root } from '@repo/core' +import { TemplateController } from '@repo/sdk' +import { handleError } from './use-error' +import { OWNER_ID } from '@/lib/auth' + +jest.mock('@repo/core', () => ({ + root: { + get: jest.fn(), + }, +})) + +jest.mock('./use-error', () => ({ + handleError: jest.fn(async (cb) => { + try { + const data = await cb() + return { data, error: null } + } catch (e) { + return { data: null, error: e } + } + }), +})) + +jest.mock('@/lib/auth', () => ({ + OWNER_ID: 'test-owner-id', +})) + +describe('useTemplates', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('initial state', () => { + it('should return initial state with no data', () => { + const { result } = renderHook(() => useTemplates()) + + expect(result.current.data).toBeUndefined() + expect(result.current.templates).toEqual([]) + expect(result.current.loading).toBe(false) + expect(result.current.loadingMore).toBe(false) + expect(result.current.error).toBeNull() + expect(result.current.hasMore).toBe(true) + }) + }) + + describe('execute function', () => { + it('should load templates successfully', async () => { + const mockData = { + templates: [ + { id: '1', name: 'Template 1' }, + { id: '2', name: 'Template 2' }, + ], + total: 2, + page: 1, + limit: 20, + totalPages: 1, + } + + const mockController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates()) + + await act(async () => { + await result.current.execute() + }) + + expect(mockController.list).toHaveBeenCalledWith({ + limit: 20, + sortBy: 'createdAt', + sortOrder: 'desc', + page: 1, + ownerId: OWNER_ID, + }) + expect(result.current.data).toEqual(mockData) + expect(result.current.templates).toEqual(mockData.templates) + expect(result.current.error).toBeNull() + expect(result.current.hasMore).toBe(false) + }) + + it('should handle API errors', async () => { + const mockError = { + status: 500, + statusText: 'Internal Server Error', + message: 'Failed to load templates', + } + + const mockController = { + list: jest.fn().mockRejectedValue(mockError), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.error).toEqual(mockError) + expect(result.current.data).toBeUndefined() + }) + + it('should merge custom params with defaults', async () => { + const mockData = { + templates: [], + total: 0, + page: 2, + limit: 10, + totalPages: 1, + } + + const mockController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates()) + + await act(async () => { + await result.current.execute({ page: 2, limit: 10 }) + }) + + expect(mockController.list).toHaveBeenCalledWith({ + limit: 10, + sortBy: 'createdAt', + sortOrder: 'desc', + page: 2, + ownerId: OWNER_ID, + }) + }) + + it('should use initial params', async () => { + const mockData = { + templates: [], + total: 0, + page: 1, + limit: 50, + totalPages: 1, + } + + const mockController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates({ limit: 50 })) + + await act(async () => { + await result.current.execute() + }) + + expect(mockController.list).toHaveBeenCalledWith({ + limit: 50, + sortBy: 'createdAt', + sortOrder: 'desc', + page: 1, + ownerId: OWNER_ID, + }) + }) + }) + + 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 mockController = { + list: jest.fn().mockReturnValue(fetchPromise), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates()) + + act(() => { + result.current.execute() + }) + + expect(result.current.loading).toBe(true) + + await act(async () => { + resolveFetch!({ templates: [], total: 0, page: 1, limit: 20, totalPages: 1 }) + await fetchPromise + }) + + expect(result.current.loading).toBe(false) + }) + + it('should set loading to false after error', async () => { + const mockError = { status: 500, message: 'Error' } + + const mockController = { + list: jest.fn().mockRejectedValue(mockError), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.loading).toBe(false) + }) + }) + + describe('pagination - loadMore', () => { + it('should load more templates and append to existing data', async () => { + const page1Data = { + templates: [ + { id: '1', name: 'Template 1' }, + { id: '2', name: 'Template 2' }, + ], + total: 4, + page: 1, + limit: 2, + totalPages: 2, + } + + const page2Data = { + templates: [ + { id: '3', name: 'Template 3' }, + { id: '4', name: 'Template 4' }, + ], + total: 4, + page: 2, + limit: 2, + totalPages: 2, + } + + const mockController = { + list: jest.fn() + .mockResolvedValueOnce(page1Data) + .mockResolvedValueOnce(page2Data), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates({ limit: 2 })) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.templates).toHaveLength(2) + expect(result.current.hasMore).toBe(true) + + await act(async () => { + await result.current.loadMore() + }) + + expect(result.current.templates).toHaveLength(4) + expect(result.current.templates).toEqual([ + ...page1Data.templates, + ...page2Data.templates, + ]) + expect(result.current.hasMore).toBe(false) + }) + + it('should not load more if already loading', async () => { + let resolveFetch: (value: any) => void + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve + }) + + const mockController = { + list: jest.fn().mockReturnValue(fetchPromise), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates()) + + act(() => { + result.current.execute() + }) + + act(() => { + result.current.loadMore() + }) + + await act(async () => { + resolveFetch!({ templates: [], total: 0, page: 1, limit: 20, totalPages: 1 }) + await fetchPromise + }) + + expect(mockController.list).toHaveBeenCalledTimes(1) + }) + + it('should not load more if no more data', async () => { + const mockData = { + templates: [{ id: '1', name: 'Template 1' }], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + } + + const mockController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.hasMore).toBe(false) + + await act(async () => { + await result.current.loadMore() + }) + + expect(mockController.list).toHaveBeenCalledTimes(1) + }) + + it('should set loadingMore state correctly', async () => { + const page1Data = { + templates: [{ id: '1', name: 'Template 1' }], + total: 2, + page: 1, + limit: 1, + totalPages: 2, + } + + let resolveLoadMore: (value: any) => void + const loadMorePromise = new Promise((resolve) => { + resolveLoadMore = resolve + }) + + const mockController = { + list: jest.fn() + .mockResolvedValueOnce(page1Data) + .mockReturnValueOnce(loadMorePromise), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates({ limit: 1 })) + + await act(async () => { + await result.current.execute() + }) + + act(() => { + result.current.loadMore() + }) + + expect(result.current.loadingMore).toBe(true) + + await act(async () => { + resolveLoadMore!({ templates: [{ id: '2', name: 'Template 2' }], total: 2, page: 2, limit: 1, totalPages: 2 }) + await loadMorePromise + }) + + expect(result.current.loadingMore).toBe(false) + }) + }) + + describe('refetch function', () => { + it('should reset and reload data from page 1', async () => { + const initialData = { + templates: [{ id: '1', name: 'Template 1' }], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + } + + const refreshedData = { + templates: [ + { id: '1', name: 'Template 1' }, + { id: '2', name: 'Template 2' }, + ], + total: 2, + page: 1, + limit: 20, + totalPages: 1, + } + + const mockController = { + list: jest.fn() + .mockResolvedValueOnce(initialData) + .mockResolvedValueOnce(refreshedData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.templates).toHaveLength(1) + + await act(async () => { + await result.current.refetch() + }) + + expect(result.current.templates).toHaveLength(2) + expect(mockController.list).toHaveBeenCalledTimes(2) + }) + + it('should reset hasMore flag', async () => { + const mockData = { + templates: [{ id: '1', name: 'Template 1' }], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + } + + const mockController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.hasMore).toBe(false) + + await act(async () => { + await result.current.refetch() + }) + + expect(result.current.hasMore).toBe(false) + }) + }) + + describe('hasMore flag', () => { + it('should set hasMore to true when more pages exist', async () => { + const mockData = { + templates: [{ id: '1', name: 'Template 1' }], + total: 40, + page: 1, + limit: 20, + totalPages: 2, + } + + const mockController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.hasMore).toBe(true) + }) + + it('should set hasMore to false on last page', async () => { + const mockData = { + templates: [{ id: '1', name: 'Template 1' }], + total: 20, + page: 1, + limit: 20, + totalPages: 1, + } + + const mockController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.hasMore).toBe(false) + }) + }) + + describe('error handling', () => { + it('should clear error on successful execute', async () => { + const mockError = { status: 500, message: 'Error' } + const mockData = { + templates: [{ id: '1', name: 'Template 1' }], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + } + + const mockController = { + list: jest.fn() + .mockRejectedValueOnce(mockError) + .mockResolvedValueOnce(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.error).toEqual(mockError) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.error).toBeNull() + }) + + it('should handle loadMore errors without affecting existing data', async () => { + const page1Data = { + templates: [{ id: '1', name: 'Template 1' }], + total: 2, + page: 1, + limit: 1, + totalPages: 2, + } + + const mockError = { status: 500, message: 'Error loading more' } + + const mockController = { + list: jest.fn() + .mockResolvedValueOnce(page1Data) + .mockRejectedValueOnce(mockError), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplates({ limit: 1 })) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.templates).toHaveLength(1) + + await act(async () => { + await result.current.loadMore() + }) + + expect(result.current.templates).toHaveLength(1) + expect(result.current.loadingMore).toBe(false) + }) + }) +})