From 83c3183be8ea12d2c90ad450459ffe5ed5fc9a1a Mon Sep 17 00:00:00 2001 From: imeepos Date: Wed, 21 Jan 2026 14:48:27 +0800 Subject: [PATCH] feat: add message action hooks with TDD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement useMessageActions and useMessageUnreadCount hooks following strict TDD principles (RED → GREEN → REFACTOR). useMessageActions provides: - markRead(id) - mark single message as read - batchMarkRead(ids) - batch mark messages as read - deleteMessage(id) - delete message - Independent loading/error states for each operation useMessageUnreadCount provides: - Fetch total unread count and counts by message type - refetch() method for manual refresh - loading and error state management All operations use MessageController from SDK with proper error handling. Co-Authored-By: Claude Opus 4.5 --- hooks/use-message-actions.test.ts | 121 +++++++++++++++++++++++++ hooks/use-message-actions.ts | 63 +++++++++++++ hooks/use-message-unread-count.test.ts | 100 ++++++++++++++++++++ hooks/use-message-unread-count.ts | 30 ++++++ 4 files changed, 314 insertions(+) create mode 100644 hooks/use-message-actions.test.ts create mode 100644 hooks/use-message-actions.ts create mode 100644 hooks/use-message-unread-count.test.ts create mode 100644 hooks/use-message-unread-count.ts diff --git a/hooks/use-message-actions.test.ts b/hooks/use-message-actions.test.ts new file mode 100644 index 0000000..050b13b --- /dev/null +++ b/hooks/use-message-actions.test.ts @@ -0,0 +1,121 @@ +import { renderHook, act } from '@testing-library/react-native' +import { useMessageActions } from './use-message-actions' +import { root } from '@repo/core' +import { MessageController } from '@repo/sdk' + +jest.mock('@repo/core', () => ({ + root: { + get: jest.fn(), + }, +})) + +describe('useMessageActions', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('markRead', () => { + it('should mark message as read successfully', async () => { + const mockMessageController = { + markRead: jest.fn().mockResolvedValue({ message: 'success' }), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessageActions()) + + await act(async () => { + await result.current.markRead('msg-1') + }) + + expect(mockMessageController.markRead).toHaveBeenCalledWith({ id: 'msg-1' }) + expect(result.current.markReadLoading).toBe(false) + expect(result.current.markReadError).toBeNull() + }) + + it('should handle markRead error', async () => { + const mockError = new Error('Failed to mark read') + const mockMessageController = { + markRead: jest.fn().mockRejectedValue(mockError), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessageActions()) + + await act(async () => { + await result.current.markRead('msg-1') + }) + + expect(result.current.markReadError).toEqual(mockError) + }) + }) + + describe('batchMarkRead', () => { + it('should batch mark messages as read successfully', async () => { + const mockMessageController = { + batchMarkRead: jest.fn().mockResolvedValue({ message: 'success' }), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessageActions()) + + await act(async () => { + await result.current.batchMarkRead(['msg-1', 'msg-2']) + }) + + expect(mockMessageController.batchMarkRead).toHaveBeenCalledWith({ ids: ['msg-1', 'msg-2'] }) + expect(result.current.batchMarkReadLoading).toBe(false) + expect(result.current.batchMarkReadError).toBeNull() + }) + + it('should handle batchMarkRead error', async () => { + const mockError = new Error('Failed to batch mark read') + const mockMessageController = { + batchMarkRead: jest.fn().mockRejectedValue(mockError), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessageActions()) + + await act(async () => { + await result.current.batchMarkRead(['msg-1', 'msg-2']) + }) + + expect(result.current.batchMarkReadError).toEqual(mockError) + }) + }) + + describe('deleteMessage', () => { + it('should delete message successfully', async () => { + const mockMessageController = { + delete: jest.fn().mockResolvedValue({ message: 'success' }), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessageActions()) + + await act(async () => { + await result.current.deleteMessage('msg-1') + }) + + expect(mockMessageController.delete).toHaveBeenCalledWith({ id: 'msg-1' }) + expect(result.current.deleteLoading).toBe(false) + expect(result.current.deleteError).toBeNull() + }) + + it('should handle deleteMessage error', async () => { + const mockError = new Error('Failed to delete') + const mockMessageController = { + delete: jest.fn().mockRejectedValue(mockError), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessageActions()) + + await act(async () => { + await result.current.deleteMessage('msg-1') + }) + + expect(result.current.deleteError).toEqual(mockError) + }) + }) +}) diff --git a/hooks/use-message-actions.ts b/hooks/use-message-actions.ts new file mode 100644 index 0000000..93563c0 --- /dev/null +++ b/hooks/use-message-actions.ts @@ -0,0 +1,63 @@ +import { root } from '@repo/core' +import { MessageController } from '@repo/sdk' +import { useState } from 'react' + +export const useMessageActions = () => { + const [markReadLoading, setMarkReadLoading] = useState(false) + const [markReadError, setMarkReadError] = useState(null) + const [batchMarkReadLoading, setBatchMarkReadLoading] = useState(false) + const [batchMarkReadError, setBatchMarkReadError] = useState(null) + const [deleteLoading, setDeleteLoading] = useState(false) + const [deleteError, setDeleteError] = useState(null) + + const markRead = async (id: string) => { + setMarkReadLoading(true) + setMarkReadError(null) + try { + const messageController = root.get(MessageController) + await messageController.markRead({ id }) + } catch (error) { + setMarkReadError(error as Error) + } finally { + setMarkReadLoading(false) + } + } + + const batchMarkRead = async (ids: string[]) => { + setBatchMarkReadLoading(true) + setBatchMarkReadError(null) + try { + const messageController = root.get(MessageController) + await messageController.batchMarkRead({ ids }) + } catch (error) { + setBatchMarkReadError(error as Error) + } finally { + setBatchMarkReadLoading(false) + } + } + + const deleteMessage = async (id: string) => { + setDeleteLoading(true) + setDeleteError(null) + try { + const messageController = root.get(MessageController) + await messageController.delete({ id }) + } catch (error) { + setDeleteError(error as Error) + } finally { + setDeleteLoading(false) + } + } + + return { + markRead, + markReadLoading, + markReadError, + batchMarkRead, + batchMarkReadLoading, + batchMarkReadError, + deleteMessage, + deleteLoading, + deleteError, + } +} diff --git a/hooks/use-message-unread-count.test.ts b/hooks/use-message-unread-count.test.ts new file mode 100644 index 0000000..762cf20 --- /dev/null +++ b/hooks/use-message-unread-count.test.ts @@ -0,0 +1,100 @@ +import { renderHook, act } from '@testing-library/react-native' +import { useMessageUnreadCount } from './use-message-unread-count' +import { root } from '@repo/core' +import { MessageController } from '@repo/sdk' + +jest.mock('@repo/core', () => ({ + root: { + get: jest.fn(), + }, +})) + +describe('useMessageUnreadCount', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return initial state', () => { + const mockMessageController = { + getUnreadCount: jest.fn(), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessageUnreadCount()) + + expect(result.current.data).toBeUndefined() + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('should fetch unread count successfully', async () => { + const mockData = { + total: 5, + byType: { + SYSTEM: 2, + ACTIVITY: 1, + BILLING: 1, + MARKETING: 1, + }, + } + + const mockMessageController = { + getUnreadCount: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessageUnreadCount()) + + await act(async () => { + await result.current.refetch() + }) + + expect(result.current.data).toEqual(mockData) + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('should handle fetch error', async () => { + const mockError = new Error('Failed to fetch unread count') + const mockMessageController = { + getUnreadCount: jest.fn().mockRejectedValue(mockError), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessageUnreadCount()) + + await act(async () => { + await result.current.refetch() + }) + + 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 = { + getUnreadCount: jest.fn().mockReturnValue(fetchPromise), + } + ;(root.get as jest.Mock).mockReturnValue(mockMessageController) + + const { result } = renderHook(() => useMessageUnreadCount()) + + act(() => { + result.current.refetch() + }) + + expect(result.current.loading).toBe(true) + + await act(async () => { + resolveFetch!({ total: 0, byType: { SYSTEM: 0, ACTIVITY: 0, BILLING: 0, MARKETING: 0 } }) + await fetchPromise + }) + + expect(result.current.loading).toBe(false) + }) +}) diff --git a/hooks/use-message-unread-count.ts b/hooks/use-message-unread-count.ts new file mode 100644 index 0000000..f7b5138 --- /dev/null +++ b/hooks/use-message-unread-count.ts @@ -0,0 +1,30 @@ +import { root } from '@repo/core' +import { MessageController, type UnreadCountResult } from '@repo/sdk' +import { useState } from 'react' + +export const useMessageUnreadCount = () => { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [data, setData] = useState() + + const refetch = async () => { + setLoading(true) + setError(null) + try { + const messageController = root.get(MessageController) + const result = await messageController.getUnreadCount() + setData(result) + } catch (err) { + setError(err as Error) + } finally { + setLoading(false) + } + } + + return { + data, + loading, + error, + refetch, + } +}