diff --git a/hooks/use-messages.test.ts b/hooks/use-messages.test.ts new file mode 100644 index 0000000..b777558 --- /dev/null +++ b/hooks/use-messages.test.ts @@ -0,0 +1,279 @@ +import { renderHook, act, waitFor } from '@testing-library/react-native' +import { useMessages } from './use-messages' +import { root } from '@repo/core' +import { MessageController } from '@repo/sdk' +import { handleError } from './use-error' + +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 } + } + }), +})) + +describe('useMessages', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('initial state', () => { + it('should return initial state', () => { + const mockMessageController = { + list: jest.fn(), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessages()) + + expect(result.current.data).toBeUndefined() + expect(result.current.messages).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 messages successfully', async () => { + const mockData = { + messages: [ + { id: '1', content: 'Message 1', createdAt: new Date() }, + { id: '2', content: 'Message 2', createdAt: new Date() }, + ], + total: 2, + unreadCount: 0, + page: 1, + limit: 20, + totalPages: 1, + } + + const mockMessageController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessages()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.data).toEqual(mockData) + expect(result.current.messages).toEqual(mockData.messages) + expect(result.current.error).toBeNull() + expect(result.current.loading).toBe(false) + }) + + it('should handle API errors', async () => { + const mockError = { + status: 500, + statusText: 'Internal Server Error', + message: 'Failed to load messages', + } + + const mockMessageController = { + list: jest.fn().mockRejectedValue(mockError), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessages()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.error).toEqual(mockError) + expect(result.current.data).toBeUndefined() + }) + + it('should set loading state during fetch', async () => { + let resolveFetch: (value: any) => void + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve + }) + + const mockMessageController = { + list: jest.fn().mockReturnValue(fetchPromise), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessages()) + + act(() => { + result.current.execute() + }) + + expect(result.current.loading).toBe(true) + + await act(async () => { + resolveFetch!({ messages: [], total: 0, unreadCount: 0, page: 1, limit: 20, totalPages: 1 }) + await fetchPromise + }) + + expect(result.current.loading).toBe(false) + }) + }) + + describe('loadMore function', () => { + it('should load more messages and append to existing', async () => { + const mockFirstPage = { + messages: [{ id: '1', content: 'Message 1', createdAt: new Date() }], + total: 3, + unreadCount: 1, + page: 1, + limit: 20, + totalPages: 2, + } + + const mockSecondPage = { + messages: [{ id: '2', content: 'Message 2', createdAt: new Date() }], + total: 3, + unreadCount: 1, + page: 2, + limit: 20, + totalPages: 2, + } + + const mockMessageController = { + list: jest.fn() + .mockResolvedValueOnce(mockFirstPage) + .mockResolvedValueOnce(mockSecondPage), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessages()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.messages).toHaveLength(1) + expect(result.current.hasMore).toBe(true) + + await act(async () => { + await result.current.loadMore() + }) + + expect(result.current.messages).toHaveLength(2) + 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 mockMessageController = { + list: jest.fn().mockReturnValue(fetchPromise), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessages()) + + act(() => { + result.current.execute() + }) + + act(() => { + result.current.loadMore() + }) + + await act(async () => { + resolveFetch!({ messages: [], total: 0, unreadCount: 0, page: 1, limit: 20, totalPages: 1 }) + await fetchPromise + }) + + expect(mockMessageController.list).toHaveBeenCalledTimes(1) + }) + + it('should not load more if no more pages', async () => { + const mockData = { + messages: [{ id: '1', content: 'Message 1', createdAt: new Date() }], + total: 1, + unreadCount: 0, + page: 1, + limit: 20, + totalPages: 1, + } + + const mockMessageController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessages()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.hasMore).toBe(false) + + await act(async () => { + await result.current.loadMore() + }) + + expect(mockMessageController.list).toHaveBeenCalledTimes(1) + }) + }) + + describe('refetch function', () => { + it('should reset and reload messages', async () => { + const mockFirstData = { + messages: [{ id: '1', content: 'Message 1', createdAt: new Date() }], + total: 2, + unreadCount: 1, + page: 1, + limit: 20, + totalPages: 2, + } + + const mockSecondData = { + messages: [{ id: '2', content: 'Message 2', createdAt: new Date() }], + total: 2, + unreadCount: 1, + page: 1, + limit: 20, + totalPages: 2, + } + + const mockMessageController = { + list: jest.fn() + .mockResolvedValueOnce(mockFirstData) + .mockResolvedValueOnce(mockSecondData), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessages()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.hasMore).toBe(true) + + await act(async () => { + await result.current.refetch() + }) + + expect(mockMessageController.list).toHaveBeenCalledTimes(2) + expect(result.current.hasMore).toBe(true) + }) + }) +}) diff --git a/hooks/use-messages.ts b/hooks/use-messages.ts new file mode 100644 index 0000000..719dec2 --- /dev/null +++ b/hooks/use-messages.ts @@ -0,0 +1,110 @@ +import { root } from '@repo/core' +import { MessageController, type Message, type ListMessagesResult } from '@repo/sdk' +import { useCallback, useRef, useState } from 'react' + +import { type ApiError } from '@/lib/types' + +import { handleError } from './use-error' + +interface ListMessagesParams { + page?: number + limit?: number +} + +const DEFAULT_PARAMS = { + limit: 20, +} + +export const useMessages = (initialParams?: ListMessagesParams) => { + 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?: ListMessagesParams) => { + setLoading(true) + setError(null) + currentPageRef.current = params?.page || 1 + + const messageController = root.get(MessageController) + const requestParams = { + ...DEFAULT_PARAMS, + ...initialParams, + ...params, + page: params?.page ?? initialParams?.page ?? 1, + } + + const { data, error } = await handleError( + async () => await messageController.list(requestParams), + ) + + if (error) { + setError(error) + setLoading(false) + return { data: undefined, error } + } + + const currentPage = requestParams.page || 1 + const totalPages = data?.totalPages || 1 + 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 messageController = root.get(MessageController) + const requestParams = { + ...DEFAULT_PARAMS, + ...initialParams, + page: nextPage, + } + + const { data: newData, error } = await handleError( + async () => await messageController.list(requestParams), + ) + + if (error) { + setLoadingMore(false) + return { data: undefined, error } + } + + const newMessages = newData?.messages || [] + const totalPages = newData?.totalPages || 1 + hasMoreRef.current = nextPage < totalPages + currentPageRef.current = nextPage + + setData((prev) => ({ + ...newData!, + messages: [...(prev?.messages || []), ...newMessages], + })) + setLoadingMore(false) + return { data: newData, error: null } + }, [loading, loadingMore, initialParams]) + + const refetch = useCallback(() => { + hasMoreRef.current = true + return execute() + }, [execute]) + + return { + data, + messages: data?.messages || [], + loading, + loadingMore, + error, + execute, + refetch, + loadMore, + hasMore: hasMoreRef.current, + } +} + +export type { Message }