feat: add message action hooks with TDD

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 <noreply@anthropic.com>
This commit is contained in:
imeepos 2026-01-21 14:48:27 +08:00
parent 21dcdc0be3
commit 83c3183be8
4 changed files with 314 additions and 0 deletions

View File

@ -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)
})
})
})

View File

@ -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<Error | null>(null)
const [batchMarkReadLoading, setBatchMarkReadLoading] = useState(false)
const [batchMarkReadError, setBatchMarkReadError] = useState<Error | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
const [deleteError, setDeleteError] = useState<Error | null>(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,
}
}

View File

@ -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)
})
})

View File

@ -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<Error | null>(null)
const [data, setData] = useState<UnreadCountResult>()
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,
}
}