diff --git a/hooks/use-announcements.test.ts b/hooks/use-announcements.test.ts new file mode 100644 index 0000000..b4ec2a9 --- /dev/null +++ b/hooks/use-announcements.test.ts @@ -0,0 +1,279 @@ +import { renderHook, act } from '@testing-library/react-native' +import { useAnnouncements } from './use-announcements' +import { root } from '@repo/core' +import { AnnouncementController } 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('useAnnouncements', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('initial state', () => { + it('should return initial state', () => { + const mockAnnouncementController = { + list: jest.fn(), + } + ;(root.get as jest.Mock).mockReturnValue(mockAnnouncementController) + + const { result } = renderHook(() => useAnnouncements()) + + expect(result.current.data).toBeUndefined() + expect(result.current.announcements).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 announcements successfully', async () => { + const mockData = { + announcements: [ + { id: '1', title: 'Announcement 1', content: 'Content 1', createdAt: new Date() }, + { id: '2', title: 'Announcement 2', content: 'Content 2', createdAt: new Date() }, + ], + total: 2, + unreadCount: 0, + page: 1, + limit: 20, + totalPages: 1, + } + + const mockAnnouncementController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockAnnouncementController) + + const { result } = renderHook(() => useAnnouncements()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.data).toEqual(mockData) + expect(result.current.announcements).toEqual(mockData.announcements) + 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 announcements', + } + + const mockAnnouncementController = { + list: jest.fn().mockRejectedValue(mockError), + } + ;(root.get as jest.Mock).mockReturnValue(mockAnnouncementController) + + const { result } = renderHook(() => useAnnouncements()) + + 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 mockAnnouncementController = { + list: jest.fn().mockReturnValue(fetchPromise), + } + ;(root.get as jest.Mock).mockReturnValue(mockAnnouncementController) + + const { result } = renderHook(() => useAnnouncements()) + + act(() => { + result.current.execute() + }) + + expect(result.current.loading).toBe(true) + + await act(async () => { + resolveFetch!({ announcements: [], 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 announcements and append to existing', async () => { + const mockFirstPage = { + announcements: [{ id: '1', title: 'Announcement 1', content: 'Content 1', createdAt: new Date() }], + total: 3, + unreadCount: 1, + page: 1, + limit: 20, + totalPages: 2, + } + + const mockSecondPage = { + announcements: [{ id: '2', title: 'Announcement 2', content: 'Content 2', createdAt: new Date() }], + total: 3, + unreadCount: 1, + page: 2, + limit: 20, + totalPages: 2, + } + + const mockAnnouncementController = { + list: jest.fn() + .mockResolvedValueOnce(mockFirstPage) + .mockResolvedValueOnce(mockSecondPage), + } + ;(root.get as jest.Mock).mockReturnValue(mockAnnouncementController) + + const { result } = renderHook(() => useAnnouncements()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.announcements).toHaveLength(1) + expect(result.current.hasMore).toBe(true) + + await act(async () => { + await result.current.loadMore() + }) + + expect(result.current.announcements).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 mockAnnouncementController = { + list: jest.fn().mockReturnValue(fetchPromise), + } + ;(root.get as jest.Mock).mockReturnValue(mockAnnouncementController) + + const { result } = renderHook(() => useAnnouncements()) + + act(() => { + result.current.execute() + }) + + act(() => { + result.current.loadMore() + }) + + await act(async () => { + resolveFetch!({ announcements: [], total: 0, unreadCount: 0, page: 1, limit: 20, totalPages: 1 }) + await fetchPromise + }) + + expect(mockAnnouncementController.list).toHaveBeenCalledTimes(1) + }) + + it('should not load more if no more pages', async () => { + const mockData = { + announcements: [{ id: '1', title: 'Announcement 1', content: 'Content 1', createdAt: new Date() }], + total: 1, + unreadCount: 0, + page: 1, + limit: 20, + totalPages: 1, + } + + const mockAnnouncementController = { + list: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockAnnouncementController) + + const { result } = renderHook(() => useAnnouncements()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.hasMore).toBe(false) + + await act(async () => { + await result.current.loadMore() + }) + + expect(mockAnnouncementController.list).toHaveBeenCalledTimes(1) + }) + }) + + describe('refetch function', () => { + it('should reset and reload announcements', async () => { + const mockFirstData = { + announcements: [{ id: '1', title: 'Announcement 1', content: 'Content 1', createdAt: new Date() }], + total: 2, + unreadCount: 1, + page: 1, + limit: 20, + totalPages: 2, + } + + const mockSecondData = { + announcements: [{ id: '2', title: 'Announcement 2', content: 'Content 2', createdAt: new Date() }], + total: 2, + unreadCount: 1, + page: 1, + limit: 20, + totalPages: 2, + } + + const mockAnnouncementController = { + list: jest.fn() + .mockResolvedValueOnce(mockFirstData) + .mockResolvedValueOnce(mockSecondData), + } + ;(root.get as jest.Mock).mockReturnValue(mockAnnouncementController) + + const { result } = renderHook(() => useAnnouncements()) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.hasMore).toBe(true) + + await act(async () => { + await result.current.refetch() + }) + + expect(mockAnnouncementController.list).toHaveBeenCalledTimes(2) + expect(result.current.hasMore).toBe(true) + }) + }) +}) diff --git a/hooks/use-announcements.ts b/hooks/use-announcements.ts new file mode 100644 index 0000000..93b49a3 --- /dev/null +++ b/hooks/use-announcements.ts @@ -0,0 +1,109 @@ +import { root } from '@repo/core' +import { type ListAnnouncementsInput, type ListAnnouncementsResult, AnnouncementController, type Announcement } from '@repo/sdk' +import { useCallback, useRef, useState } from 'react' + +import { type ApiError } from '@/lib/types' + +import { handleError } from './use-error' + +type ListAnnouncementsParams = ListAnnouncementsInput + +const DEFAULT_PARAMS = { + limit: 20, + sortBy: 'createdAt' as const, + sortOrder: 'desc' as const, +} + +export const useAnnouncements = (initialParams?: ListAnnouncementsParams) => { + 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?: ListAnnouncementsParams) => { + setLoading(true) + setError(null) + currentPageRef.current = params?.page || 1 + + const announcement = root.get(AnnouncementController) + const requestParams: ListAnnouncementsInput = { + ...DEFAULT_PARAMS, + ...initialParams, + ...params, + page: params?.page ?? initialParams?.page ?? 1, + } + + const { data, error } = await handleError( + async () => await announcement.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 announcement = root.get(AnnouncementController) + const requestParams: ListAnnouncementsInput = { + ...DEFAULT_PARAMS, + ...initialParams, + page: nextPage, + } + + const { data: newData, error } = await handleError( + async () => await announcement.list(requestParams), + ) + + if (error) { + setLoadingMore(false) + return { data: undefined, error } + } + + const newAnnouncements = newData?.announcements || [] + const totalPages = newData?.totalPages || 1 + hasMoreRef.current = nextPage < totalPages + currentPageRef.current = nextPage + + setData((prev) => ({ + ...newData, + announcements: [...(prev?.announcements || []), ...newAnnouncements], + })) + setLoadingMore(false) + return { data: newData, error: null } + }, [loading, loadingMore, initialParams]) + + const refetch = useCallback(() => { + hasMoreRef.current = true + return execute() + }, [execute]) + + return { + data, + announcements: data?.announcements || [], + loading, + loadingMore, + error, + execute, + refetch, + loadMore, + hasMore: hasMoreRef.current, + } +} + +export type { Announcement }