diff --git a/hooks/use-videos.test.ts b/hooks/use-videos.test.ts new file mode 100644 index 0000000..f3a815a --- /dev/null +++ b/hooks/use-videos.test.ts @@ -0,0 +1,373 @@ +import { renderHook, act, waitFor } from '@testing-library/react-native' +import { useVideos } from './use-videos' +import { root } from '@repo/core' +import { ProjectController } 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('useVideos', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('initial state', () => { + it('should return initial state', () => { + const mockProjectController = { + list: jest.fn().mockResolvedValue({ + projects: [], + total: 0, + page: 1, + limit: 20, + }), + } + ;(root.get as jest.Mock).mockReturnValue(mockProjectController) + + const { result } = renderHook(() => useVideos()) + + expect(result.current.loading).toBe(false) + expect(result.current.loadingMore).toBe(false) + expect(result.current.error).toBeNull() + expect(result.current.data).toBeUndefined() + expect(result.current.projects).toEqual([]) + expect(result.current.hasMore).toBe(true) + }) + }) + + describe('execute function', () => { + it('should load projects successfully', async () => { + const mockData = { + projects: [ + { + id: '1', + title: 'Video 1', + userId: 'user-1', + isDeleted: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: '2', + title: 'Video 2', + userId: 'user-1', + isDeleted: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + total: 2, + page: 1, + limit: 20, + } + + const mockProjectController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockProjectController) + + const { result } = renderHook(() => useVideos()) + + await act(async () => { + await result.current.execute() + }) + + expect(mockProjectController.list).toHaveBeenCalledWith({ + page: 1, + limit: 20, + }) + expect(result.current.data).toEqual(mockData) + expect(result.current.projects).toEqual(mockData.projects) + expect(result.current.error).toBeNull() + }) + + it('should handle API errors', async () => { + const mockError = { + status: 500, + statusText: 'Internal Server Error', + message: 'Failed to load projects', + } + + const mockProjectController = { + list: jest.fn().mockRejectedValue(mockError), + } + ;(root.get as jest.Mock).mockReturnValue(mockProjectController) + + const { result } = renderHook(() => useVideos()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.error).toEqual(mockError) + expect(result.current.data).toBeUndefined() + }) + + it('should merge custom params with default params', async () => { + const mockData = { projects: [], total: 0, page: 2, limit: 10 } + + const mockProjectController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockProjectController) + + const { result } = renderHook(() => useVideos({ limit: 10 })) + + await act(async () => { + await result.current.execute({ page: 2 }) + }) + + expect(mockProjectController.list).toHaveBeenCalledWith({ + page: 2, + limit: 10, + }) + }) + + it('should calculate hasMore correctly', async () => { + const mockData = { + projects: [], + total: 50, + page: 1, + limit: 20, + } + + const mockProjectController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockProjectController) + + const { result } = renderHook(() => useVideos()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.hasMore).toBe(true) + }) + }) + + describe('loadMore function', () => { + it('should load next page and append data', async () => { + const mockFirstPage = { + projects: [{ id: '1', title: 'Video 1' }], + total: 30, + page: 1, + limit: 20, + } + + const mockSecondPage = { + projects: [{ id: '2', title: 'Video 2' }], + total: 30, + page: 2, + limit: 20, + } + + const mockProjectController = { + list: jest.fn() + .mockResolvedValueOnce(mockFirstPage) + .mockResolvedValueOnce(mockSecondPage), + } + ;(root.get as jest.Mock).mockReturnValue(mockProjectController) + + const { result } = renderHook(() => useVideos()) + + await act(async () => { + await result.current.execute() + }) + + await act(async () => { + await result.current.loadMore() + }) + + expect(mockProjectController.list).toHaveBeenCalledTimes(2) + expect(result.current.projects).toHaveLength(2) + expect(result.current.projects[0].id).toBe('1') + expect(result.current.projects[1].id).toBe('2') + }) + + it('should not load if already loading', async () => { + const mockProjectController = { + list: jest.fn().mockResolvedValue({ projects: [], total: 0, page: 1, limit: 20 }), + } + ;(root.get as jest.Mock).mockReturnValue(mockProjectController) + + const { result } = renderHook(() => useVideos()) + + await act(async () => { + await result.current.execute() + }) + + const firstLoadMore = act(async () => { + await result.current.loadMore() + }) + + act(() => { + result.current.loadMore() + }) + + await firstLoadMore + + expect(mockProjectController.list).toHaveBeenCalledTimes(1) + }) + + it('should not load if no more data', async () => { + const mockData = { + projects: [], + total: 20, + page: 1, + limit: 20, + } + + const mockProjectController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockProjectController) + + const { result } = renderHook(() => useVideos()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.hasMore).toBe(false) + + await act(async () => { + await result.current.loadMore() + }) + + expect(mockProjectController.list).toHaveBeenCalledTimes(1) + }) + }) + + describe('refetch function', () => { + it('should reset and reload data', async () => { + const mockFirstData = { + projects: [{ id: '1', title: 'Video 1' }], + total: 50, + page: 1, + limit: 20, + } + + const mockRefetchData = { + projects: [{ id: '2', title: 'Video 2' }], + total: 50, + page: 1, + limit: 20, + } + + const mockProjectController = { + list: jest.fn() + .mockResolvedValueOnce(mockFirstData) + .mockResolvedValueOnce(mockRefetchData), + } + ;(root.get as jest.Mock).mockReturnValue(mockProjectController) + + const { result } = renderHook(() => useVideos()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.projects[0].id).toBe('1') + + await act(async () => { + await result.current.refetch() + }) + + expect(result.current.projects[0].id).toBe('2') + expect(result.current.hasMore).toBe(true) + }) + }) + + describe('loading states', () => { + it('should set loading to true during execute', async () => { + let resolveFetch: (value: any) => void + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve + }) + + const mockProjectController = { + list: jest.fn().mockReturnValue(fetchPromise), + } + ;(root.get as jest.Mock).mockReturnValue(mockProjectController) + + const { result } = renderHook(() => useVideos()) + + act(() => { + result.current.execute() + }) + + expect(result.current.loading).toBe(true) + + await act(async () => { + resolveFetch!({ projects: [], total: 0, page: 1, limit: 20 }) + await fetchPromise + }) + + expect(result.current.loading).toBe(false) + }) + + it('should set loadingMore to true during loadMore', async () => { + const mockFirstPage = { + projects: [], + total: 30, + page: 1, + limit: 20, + } + + let resolveFetch: (value: any) => void + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve + }) + + const mockProjectController = { + list: jest.fn() + .mockResolvedValueOnce(mockFirstPage) + .mockReturnValueOnce(fetchPromise), + } + ;(root.get as jest.Mock).mockReturnValue(mockProjectController) + + const { result } = renderHook(() => useVideos()) + + await act(async () => { + await result.current.execute() + }) + + act(() => { + result.current.loadMore() + }) + + expect(result.current.loadingMore).toBe(true) + + await act(async () => { + resolveFetch!({ projects: [], total: 30, page: 2, limit: 20 }) + await fetchPromise + }) + + expect(result.current.loadingMore).toBe(false) + }) + }) +}) diff --git a/hooks/use-videos.ts b/hooks/use-videos.ts new file mode 100644 index 0000000..9b906e3 --- /dev/null +++ b/hooks/use-videos.ts @@ -0,0 +1,109 @@ +import { root } from '@repo/core' +import { type ListProjectsInput, type ListProjectsResult, ProjectController, type Project } from '@repo/sdk' +import { useCallback, useRef, useState } from 'react' + +import { type ApiError } from '@/lib/types' + +import { handleError } from './use-error' + + +type ListProjectsParams = ListProjectsInput + +const DEFAULT_PARAMS = { + limit: 20, +} + +export const useVideos = (initialParams?: ListProjectsParams) => { + const [loading, setLoading] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState(null) + const [data, setData] = useState() + const currentPageRef = useRef(1) + const hasMoreRef = useRef(true) + + const execute = useCallback(async (params?: ListProjectsParams) => { + setLoading(true) + setError(null) + currentPageRef.current = params?.page || 1 + + const project = root.get(ProjectController) + const requestParams: ListProjectsInput = { + ...DEFAULT_PARAMS, + ...initialParams, + ...params, + page: params?.page ?? initialParams?.page ?? 1, + } + + const { data, error } = await handleError( + async () => await project.list(requestParams), + ) + + if (error) { + setError(error) + setLoading(false) + return { data: undefined, error } + } + + const projects = data?.projects || [] + const currentPage = requestParams.page || 1 + const totalPages = Math.ceil((data?.total || 0) / (requestParams.limit || 20)) + hasMoreRef.current = currentPage < totalPages + setData(data) + setLoading(false) + return { data, error: null } + }, [initialParams]) + + const loadMore = useCallback(async () => { + if (loadingMore || loading || !hasMoreRef.current) return { data: undefined, error: null } + + setLoadingMore(true) + const nextPage = currentPageRef.current + 1 + + const project = root.get(ProjectController) + const requestParams: ListProjectsInput = { + ...DEFAULT_PARAMS, + ...initialParams, + page: nextPage, + } + + const { data: newData, error } = await handleError( + async () => await project.list(requestParams), + ) + + if (error) { + setLoadingMore(false) + return { data: undefined, error } + } + + const newProjects = newData?.projects || [] + const totalPages = Math.ceil((newData?.total || 0) / (requestParams.limit || 20)) + hasMoreRef.current = nextPage < totalPages + currentPageRef.current = nextPage + + setData((prev) => ({ + ...newData, + projects: [...(prev?.projects || []), ...newProjects], + })) + setLoadingMore(false) + return { data: newData, error: null } + }, [loading, loadingMore, initialParams]) + + const refetch = useCallback(() => { + hasMoreRef.current = true + return execute() + }, [execute]) + + return { + data, + projects: data?.projects || [], + loading, + loadingMore, + error, + execute, + refetch, + loadMore, + hasMore: hasMoreRef.current, + } +} + +export type { Project }