+vi.mock('@/components/UI/ReportModal', () => ({
+ ReportModal: ({
+ userId,
+ username,
+ onClose,
+ onSpamSelected,
+ }: {
+ userId: string
+ username: string
+ onClose: () => void
+ onSpamSelected: () => void
+ }) => (
+
- )
+ ),
}))
-vi.mock('@/components/UI/Tooltip', () => ({
- default: ({ children, text }: any) => (
-
- {children}
+vi.mock('@/components/UI/SpamModal', () => ({
+ SpamModal: ({
+ userId,
+ username,
+ onClose,
+ }: {
+ userId: string
+ username: string
+ onClose: () => void
+ }) => (
+
+ Close
- )
+ ),
}))
-vi.mock('@/services/api/messaging/conversationApi', () => ({
- markMessageReadState: vi.fn(),
- unblockUser: vi.fn(),
- fetchFollowStatus: vi.fn()
+vi.mock('@/components/UI/Tooltip', () => ({
+ default: ({ children }: { children: React.ReactNode }) => <>{children}>,
}))
-const mockNavigate = vi.fn()
+import ConversationHeader from '../ConversationHeader'
-describe('ConversationHeader', () => {
- const defaultProps = {
- reciepiantId: 'user123',
- conversationId: 'conv456',
- recipientName: 'john_doe',
- lastMessageId: 'msg789',
- onReadStateChange: vi.fn(),
- onDeleted: vi.fn(),
- onBack: vi.fn()
- }
+const defaultProps = {
+ reciepiantId: 'user-2',
+ conversationId: 'conv-1',
+ recipientName: 'John',
+ lastMessageId: 'msg-1',
+ onReadStateChange: vi.fn(),
+ onDeleted: vi.fn(),
+ onBack: vi.fn(),
+}
+
+function renderHeader(props = {}) {
+ return render(
+
+
+
+ )
+}
+describe('ConversationHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
- vi.mocked(conversationApi.fetchFollowStatus).mockResolvedValue({
- data: { is_blocking: false }
- } as any)
- vi.mocked(conversationApi.markMessageReadState).mockResolvedValue({} as any)
- vi.mocked(conversationApi.unblockUser).mockResolvedValue({} as any)
+ mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } })
+ mockMarkMessageReadState.mockResolvedValue({})
+ mockUnblockUser.mockResolvedValue({})
})
- const renderWithRouter = (props = {}) => {
- return render(
-
-
-
- )
- }
-
- describe('Rendering', () => {
- it('should render recipient name', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- expect(screen.getByTestId('conversation-profile-button')).toBeInTheDocument()
- expect(screen.getByText('john_doe')).toBeInTheDocument()
- })
- })
-
- it('should render back button when onBack is provided', async () => {
- renderWithRouter({ onBack: vi.fn() })
-
- await waitFor(() => {
- expect(screen.getByTestId('conversation-back-button')).toBeInTheDocument()
- })
- })
-
- it('should not render back button when onBack is not provided', async () => {
- renderWithRouter({ onBack: undefined })
-
- await waitFor(() => {
- expect(screen.queryByTestId('conversation-back-button')).not.toBeInTheDocument()
- })
- })
-
- it('should render delete conversation button', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- expect(screen.getByTestId('delete-conversation-button')).toBeInTheDocument()
- })
- })
-
- it('should render block button on desktop', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- expect(screen.getByTestId('conversation-block-button')).toBeInTheDocument()
- })
- })
-
- it('should render report button on desktop', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- expect(screen.getByTestId('conversation-report-button')).toBeInTheDocument()
- })
- })
-
- it('should render mark as read button on desktop', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- expect(screen.getByTestId('conversation-toggle-read-button')).toBeInTheDocument()
- })
- })
-
- it('should render mobile menu button', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- expect(screen.getByTestId('conversation-mobile-menu-btn')).toBeInTheDocument()
- })
- })
-
- it('should show loading state while fetching block status', async () => {
- vi.mocked(conversationApi.fetchFollowStatus).mockImplementation(
- () => new Promise(() => {})
- )
-
- renderWithRouter()
-
- await waitFor(() => {
- expect(screen.getByText('...')).toBeInTheDocument()
- })
+ describe('rendering', () => {
+ it('renders the header container', async () => {
+ renderHeader()
+ expect(screen.getByTestId('conversation-header')).toBeInTheDocument()
+ })
+
+ it('renders the recipient name button', async () => {
+ renderHeader()
+ expect(screen.getByTestId('conversation-profile-button')).toHaveTextContent('John')
+ })
+
+ it('renders the back button when onBack is provided', async () => {
+ renderHeader()
+ expect(screen.getByTestId('conversation-back-button')).toBeInTheDocument()
+ })
+
+ it('does not render back button when onBack is not provided', async () => {
+ renderHeader({ onBack: undefined })
+ expect(screen.queryByTestId('conversation-back-button')).not.toBeInTheDocument()
+ })
+
+ it('renders the delete button', async () => {
+ renderHeader()
+ expect(screen.getByTestId('delete-conversation-button')).toBeInTheDocument()
+ })
+
+ it('renders mobile menu button', async () => {
+ renderHeader()
+ expect(screen.getByTestId('conversation-mobile-menu-btn')).toBeInTheDocument()
+ })
+
+ it('initially shows "Mark as unread" on desktop button', async () => {
+ renderHeader()
+ expect(screen.getByTestId('conversation-toggle-read-button')).toHaveTextContent('Mark as unread')
+ })
+
+ it('loads block status on mount', async () => {
+ renderHeader()
+ await waitFor(() => expect(mockFetchFollowStatus).toHaveBeenCalledWith('user-2'))
+ })
+
+ it('shows Block button when user is not blocked', async () => {
+ mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } })
+ renderHeader()
+ await waitFor(() => expect(screen.getByTestId('conversation-block-button')).toHaveTextContent('Block'))
+ })
+
+ it('shows Unblock button when user is blocked', async () => {
+ mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: true } })
+ renderHeader()
+ await waitFor(() => expect(screen.getByTestId('conversation-block-button')).toHaveTextContent('Unblock'))
})
})
- describe('Navigation', () => {
- it('should navigate to user profile when clicking profile button', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- const profileButton = screen.getByTestId('conversation-profile-button')
- fireEvent.click(profileButton)
- expect(mockNavigate).toHaveBeenCalledWith('/john_doe')
- })
+ describe('navigation', () => {
+ it('navigates to recipient profile on name click', async () => {
+ renderHeader({ recipientName: 'John' })
+ await userEvent.click(screen.getByTestId('conversation-profile-button'))
+ expect(mockNavigate).toHaveBeenCalledWith('/John')
})
- it('should call onBack when back button is clicked', async () => {
+ it('calls onBack when back button is clicked', async () => {
const onBack = vi.fn()
- renderWithRouter({ onBack })
-
- await waitFor(() => {
- const backButton = screen.getByTestId('conversation-back-button')
- fireEvent.click(backButton)
- expect(onBack).toHaveBeenCalledTimes(1)
- })
+ renderHeader({ onBack })
+ await userEvent.click(screen.getByTestId('conversation-back-button'))
+ expect(onBack).toHaveBeenCalled()
})
})
- describe('Block functionality', () => {
- it('should open block modal when block button is clicked', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- const blockButton = screen.getByTestId('conversation-block-button')
- fireEvent.click(blockButton)
- expect(screen.getByTestId('block-modal')).toBeInTheDocument()
- })
- })
-
- it('should show unblock button when user is blocked', async () => {
- vi.mocked(conversationApi.fetchFollowStatus).mockResolvedValue({
- data: { is_blocking: true }
- } as any)
-
- renderWithRouter()
-
- await waitFor(() => {
- expect(screen.getByText('Unblock')).toBeInTheDocument()
- })
- })
-
- it('should call unblockUser when unblock button is clicked', async () => {
- vi.mocked(conversationApi.fetchFollowStatus).mockResolvedValue({
- data: { is_blocking: true }
- } as any)
-
- renderWithRouter()
-
- await waitFor(async () => {
- const unblockButton = screen.getByTestId('conversation-block-button')
- fireEvent.click(unblockButton)
-
- await waitFor(() => {
- expect(conversationApi.unblockUser).toHaveBeenCalledWith('user123')
- })
- })
- })
-
- it('should handle unblock error gracefully', async () => {
- vi.mocked(conversationApi.fetchFollowStatus).mockResolvedValue({
- data: { is_blocking: true }
- } as any)
- vi.mocked(conversationApi.unblockUser).mockRejectedValue(new Error('Unblock failed'))
-
- renderWithRouter()
-
- await waitFor(async () => {
- const unblockButton = screen.getByTestId('conversation-block-button')
- fireEvent.click(unblockButton)
-
- await waitFor(() => {
- expect(conversationApi.unblockUser).toHaveBeenCalled()
- })
- })
- })
-
- it('should update block status after blocking', async () => {
- renderWithRouter()
-
- await waitFor(async () => {
- const blockButton = screen.getByTestId('conversation-block-button')
- fireEvent.click(blockButton)
-
- const blockModalBlockButton = screen.getByTestId('block-modal-block')
- fireEvent.click(blockModalBlockButton)
-
- await waitFor(() => {
- expect(screen.queryByTestId('block-modal')).not.toBeInTheDocument()
- })
- })
+ describe('read state toggle', () => {
+ it('calls markMessageReadState when toggle read button is clicked', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-toggle-read-button'))
+ await waitFor(() => expect(mockMarkMessageReadState).toHaveBeenCalledWith('conv-1', 'msg-1', false))
})
- })
- describe('Report functionality', () => {
- it('should open report modal when report button is clicked', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- const reportButton = screen.getByTestId('conversation-report-button')
- fireEvent.click(reportButton)
- expect(screen.getByTestId('report-modal')).toBeInTheDocument()
- })
- })
-
- it('should open spam modal when spam is selected from report modal', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- const reportButton = screen.getByTestId('conversation-report-button')
- fireEvent.click(reportButton)
-
- const spamButton = screen.getByTestId('report-modal-spam')
- fireEvent.click(spamButton)
-
- expect(screen.getByTestId('spam-modal')).toBeInTheDocument()
- })
- })
-
- it('should close modals properly', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- const reportButton = screen.getByTestId('conversation-report-button')
- fireEvent.click(reportButton)
-
- const closeButton = screen.getByTestId('report-modal-close')
- fireEvent.click(closeButton)
-
- expect(screen.queryByTestId('report-modal')).not.toBeInTheDocument()
- })
+ it('toggles button text to "Mark as read" after marking unread', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-toggle-read-button'))
+ await waitFor(() => expect(screen.getByTestId('conversation-toggle-read-button')).toHaveTextContent('Mark as read'))
})
- })
- describe('Read/Unread functionality', () => {
- it('should call markMessageReadState when mark as read button is clicked', async () => {
- renderWithRouter()
-
- await waitFor(async () => {
- const toggleReadButton = screen.getByTestId('conversation-toggle-read-button')
- fireEvent.click(toggleReadButton)
-
- await waitFor(() => {
- expect(conversationApi.markMessageReadState).toHaveBeenCalledWith(
- 'conv456',
- 'msg789',
- false
- )
- })
- })
- })
-
- it('should disable toggle read button when no lastMessageId', async () => {
- renderWithRouter({ lastMessageId: null })
-
- await waitFor(() => {
- const toggleReadButton = screen.getByTestId('conversation-toggle-read-button')
- expect(toggleReadButton).toBeDisabled()
- })
- })
-
- it('should update isUnread state after toggling', async () => {
- renderWithRouter()
-
- await waitFor(async () => {
- const toggleReadButton = screen.getByTestId('conversation-toggle-read-button')
- expect(toggleReadButton).toHaveTextContent('Mark as unread')
-
- fireEvent.click(toggleReadButton)
-
- await waitFor(() => {
- expect(toggleReadButton).toHaveTextContent('Mark as read')
- })
- })
- })
-
- it('should call onReadStateChange when read state toggles', async () => {
- renderWithRouter()
-
- await waitFor(async () => {
- const toggleReadButton = screen.getByTestId('conversation-toggle-read-button')
- fireEvent.click(toggleReadButton)
-
- await waitFor(() => {
- expect(defaultProps.onReadStateChange).toHaveBeenCalledWith(true)
- })
- })
- })
-
- it('should handle errors in markMessageReadState', async () => {
- vi.mocked(conversationApi.markMessageReadState).mockRejectedValue(new Error('API Error'))
-
- renderWithRouter()
-
- await waitFor(async () => {
- const toggleReadButton = screen.getByTestId('conversation-toggle-read-button')
- fireEvent.click(toggleReadButton)
-
- await waitFor(() => {
- expect(conversationApi.markMessageReadState).toHaveBeenCalled()
- })
- })
+ it('calls onReadStateChange after toggling', async () => {
+ const onReadStateChange = vi.fn()
+ renderHeader({ onReadStateChange })
+ await userEvent.click(screen.getByTestId('conversation-toggle-read-button'))
+ await waitFor(() => expect(onReadStateChange).toHaveBeenCalledWith(true))
+ })
+
+ it('disables toggle read button when lastMessageId is null', async () => {
+ renderHeader({ lastMessageId: null })
+ expect(screen.getByTestId('conversation-toggle-read-button')).toBeDisabled()
})
- })
- describe('Mobile menu', () => {
- it('should open mobile menu when menu button is clicked', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- const menuButton = screen.getByTestId('conversation-mobile-menu-btn')
- fireEvent.click(menuButton)
-
- expect(screen.getByTestId('conversation-block-button-mobile')).toBeInTheDocument()
- expect(screen.getByTestId('conversation-report-button-mobile')).toBeInTheDocument()
- expect(screen.getByTestId('conversation-toggle-read-button-mobile')).toBeInTheDocument()
- })
- })
-
- it('should close mobile menu after action', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- const menuButton = screen.getByTestId('conversation-mobile-menu-btn')
- fireEvent.click(menuButton)
-
- const blockButton = screen.getByTestId('conversation-block-button-mobile')
- fireEvent.click(blockButton)
-
- expect(screen.queryByTestId('conversation-block-button-mobile')).not.toBeInTheDocument()
- })
- })
-
- it('should open block modal from mobile menu', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- const menuButton = screen.getByTestId('conversation-mobile-menu-btn')
- fireEvent.click(menuButton)
-
- const blockButton = screen.getByTestId('conversation-block-button-mobile')
- fireEvent.click(blockButton)
-
- expect(screen.getByTestId('block-modal')).toBeInTheDocument()
- })
- })
-
- it('should open report modal from mobile menu', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- const menuButton = screen.getByTestId('conversation-mobile-menu-btn')
- fireEvent.click(menuButton)
-
- const reportButton = screen.getByTestId('conversation-report-button-mobile')
- fireEvent.click(reportButton)
-
- expect(screen.getByTestId('report-modal')).toBeInTheDocument()
- })
- })
-
- it('should toggle read state from mobile menu', async () => {
- renderWithRouter()
-
- await waitFor(async () => {
- const menuButton = screen.getByTestId('conversation-mobile-menu-btn')
- fireEvent.click(menuButton)
-
- const toggleReadButton = screen.getByTestId('conversation-toggle-read-button-mobile')
- fireEvent.click(toggleReadButton)
-
- await waitFor(() => {
- expect(conversationApi.markMessageReadState).toHaveBeenCalled()
- })
- })
- })
-
- it('should show unblock option in mobile menu when blocked', async () => {
- vi.mocked(conversationApi.fetchFollowStatus).mockResolvedValue({
- data: { is_blocking: true }
- } as any)
-
- renderWithRouter()
-
- await waitFor(() => {
- const menuButton = screen.getByTestId('conversation-mobile-menu-btn')
- fireEvent.click(menuButton)
-
- const blockButton = screen.getByTestId('conversation-block-button-mobile')
- expect(blockButton).toHaveTextContent('Unblock john_doe')
- })
- })
-
- it('should disable mobile toggle read button when no lastMessageId', async () => {
- renderWithRouter({ lastMessageId: null })
-
- await waitFor(() => {
- const menuButton = screen.getByTestId('conversation-mobile-menu-btn')
- fireEvent.click(menuButton)
-
- const toggleReadButton = screen.getByTestId('conversation-toggle-read-button-mobile')
- expect(toggleReadButton).toBeDisabled()
- })
+ it('resets isUnread to false when conversationId changes', async () => {
+ const { rerender } = renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-toggle-read-button'))
+ await waitFor(() => expect(screen.getByTestId('conversation-toggle-read-button')).toHaveTextContent('Mark as read'))
+ rerender(
+
+
+
+ )
+ await waitFor(() => expect(screen.getByTestId('conversation-toggle-read-button')).toHaveTextContent('Mark as unread'))
})
})
- describe('Tooltips', () => {
- it('should render tooltips for desktop buttons', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- const tooltips = screen.getAllByTestId('tooltip')
- expect(tooltips.length).toBeGreaterThan(0)
- })
+ describe('block flow', () => {
+ it('opens block modal when Block button is clicked (not blocked)', async () => {
+ mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } })
+ renderHeader()
+ await waitFor(() => screen.getByTestId('conversation-block-button'))
+ await userEvent.click(screen.getByTestId('conversation-block-button'))
+ expect(screen.getByTestId('block-modal')).toBeInTheDocument()
+ })
+
+ it('passes recipient props to BlockUserModal', async () => {
+ mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } })
+ renderHeader()
+ await waitFor(() => screen.getByTestId('conversation-block-button'))
+ await userEvent.click(screen.getByTestId('conversation-block-button'))
+ expect(screen.getByTestId('block-modal')).toHaveAttribute('data-user-id', 'user-2')
+ expect(screen.getByTestId('block-modal')).toHaveAttribute('data-username', 'John')
+ })
+
+ it('closes block modal from BlockUserModal onClose', async () => {
+ mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } })
+ renderHeader()
+ await waitFor(() => screen.getByTestId('conversation-block-button'))
+ await userEvent.click(screen.getByTestId('conversation-block-button'))
+ await userEvent.click(screen.getByTestId('block-close'))
+ expect(screen.queryByTestId('block-modal')).not.toBeInTheDocument()
+ })
+
+ it('closes block modal from the Modal onClose callback', async () => {
+ mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } })
+ renderHeader()
+ await waitFor(() => screen.getByTestId('conversation-block-button'))
+ await userEvent.click(screen.getByTestId('conversation-block-button'))
+ await userEvent.click(screen.getByTestId('modal-close'))
+ expect(screen.queryByTestId('block-modal')).not.toBeInTheDocument()
+ })
+
+ it('calls unblockUser when Unblock is clicked', async () => {
+ mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: true } })
+ renderHeader()
+ await waitFor(() => expect(screen.getByTestId('conversation-block-button')).toHaveTextContent('Unblock'))
+ await userEvent.click(screen.getByTestId('conversation-block-button'))
+ await waitFor(() => expect(mockUnblockUser).toHaveBeenCalledWith('user-2'))
+ })
+
+ it('sets isBlocked to true after onBlocked callback from BlockUserModal', async () => {
+ mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } })
+ renderHeader()
+ await waitFor(() => screen.getByTestId('conversation-block-button'))
+ await userEvent.click(screen.getByTestId('conversation-block-button'))
+ await userEvent.click(screen.getByTestId('block-confirm'))
+ await waitFor(() => expect(screen.getByTestId('conversation-block-button')).toHaveTextContent('Unblock'))
})
})
- describe('Delete conversation', () => {
- it('should call onDeleted when delete button is clicked', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- const deleteButton = screen.getByTestId('delete-conversation-button')
- fireEvent.click(deleteButton)
-
- expect(defaultProps.onDeleted).toHaveBeenCalledWith('conv456')
- })
+ describe('report flow', () => {
+ it('opens report modal when Report button is clicked', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-report-button'))
+ expect(screen.getByTestId('report-modal')).toBeInTheDocument()
+ })
+
+ it('passes recipient props to ReportModal', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-report-button'))
+ expect(screen.getByTestId('report-modal')).toHaveAttribute('data-user-id', 'user-2')
+ expect(screen.getByTestId('report-modal')).toHaveAttribute('data-username', 'John')
+ })
+
+ it('closes report modal from ReportModal onClose', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-report-button'))
+ await userEvent.click(screen.getByTestId('report-close'))
+ expect(screen.queryByTestId('report-modal')).not.toBeInTheDocument()
+ })
+
+ it('closes report modal from the Modal onClose callback', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-report-button'))
+ await userEvent.click(screen.getByTestId('modal-close'))
+ expect(screen.queryByTestId('report-modal')).not.toBeInTheDocument()
+ })
+
+ it('opens spam modal when spam is selected in report modal', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-report-button'))
+ await userEvent.click(screen.getByTestId('report-spam'))
+ await waitFor(() => expect(screen.getByTestId('spam-modal')).toBeInTheDocument())
+ })
+
+ it('passes recipient props to SpamModal', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-report-button'))
+ await userEvent.click(screen.getByTestId('report-spam'))
+ await waitFor(() => expect(screen.getByTestId('spam-modal')).toBeInTheDocument())
+ expect(screen.getByTestId('spam-modal')).toHaveAttribute('data-user-id', 'user-2')
+ expect(screen.getByTestId('spam-modal')).toHaveAttribute('data-username', 'John')
+ })
+
+ it('closes spam modal from SpamModal onClose', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-report-button'))
+ await userEvent.click(screen.getByTestId('report-spam'))
+ await waitFor(() => expect(screen.getByTestId('spam-modal')).toBeInTheDocument())
+ await userEvent.click(screen.getByTestId('spam-close'))
+ expect(screen.queryByTestId('spam-modal')).not.toBeInTheDocument()
+ })
+
+ it('closes spam modal from the Modal onClose callback', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-report-button'))
+ await userEvent.click(screen.getByTestId('report-spam'))
+ await waitFor(() => expect(screen.getByTestId('spam-modal')).toBeInTheDocument())
+ await userEvent.click(screen.getByTestId('modal-close'))
+ expect(screen.queryByTestId('spam-modal')).not.toBeInTheDocument()
})
})
- describe('Error handling', () => {
- it('should handle fetchFollowStatus error', async () => {
- vi.mocked(conversationApi.fetchFollowStatus).mockRejectedValue(new Error('Network error'))
-
- renderWithRouter()
-
- await waitFor(() => {
- const blockButton = screen.getByTestId('conversation-block-button')
- expect(blockButton).toHaveTextContent('Block')
- })
- })
-
- it('should handle missing reciepiantId', async () => {
- renderWithRouter({ reciepiantId: '' })
-
- await waitFor(() => {
- expect(conversationApi.fetchFollowStatus).not.toHaveBeenCalled()
- })
+ describe('mobile menu', () => {
+ it('opens mobile menu when ... button is clicked', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-mobile-menu-btn'))
+ expect(screen.getByTestId('conversation-block-button-mobile')).toBeInTheDocument()
+ })
+
+ it('closes mobile menu after clicking block in mobile menu', async () => {
+ mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } })
+ renderHeader()
+ await waitFor(() => screen.getByTestId('conversation-mobile-menu-btn'))
+ await userEvent.click(screen.getByTestId('conversation-mobile-menu-btn'))
+ await userEvent.click(screen.getByTestId('conversation-block-button-mobile'))
+ expect(screen.queryByTestId('conversation-block-button-mobile')).not.toBeInTheDocument()
+ })
+
+ it('opens report modal from mobile menu', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-mobile-menu-btn'))
+ await userEvent.click(screen.getByTestId('conversation-report-button-mobile'))
+ expect(screen.getByTestId('report-modal')).toBeInTheDocument()
+ })
+
+ it('calls markMessageReadState from mobile menu toggle', async () => {
+ renderHeader()
+ await userEvent.click(screen.getByTestId('conversation-mobile-menu-btn'))
+ await userEvent.click(screen.getByTestId('conversation-toggle-read-button-mobile'))
+ await waitFor(() => expect(mockMarkMessageReadState).toHaveBeenCalled())
+ })
+
+ it('calls unblockUser when Unblock clicked in mobile menu', async () => {
+ mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: true } })
+ renderHeader()
+ await waitFor(() => screen.getByTestId('conversation-mobile-menu-btn'))
+ await userEvent.click(screen.getByTestId('conversation-mobile-menu-btn'))
+ await userEvent.click(screen.getByTestId('conversation-block-button-mobile'))
+ await waitFor(() => expect(mockUnblockUser).toHaveBeenCalled())
})
})
- describe('Cleanup and effects', () => {
- it('should reset unread state when conversationId changes', async () => {
- const { rerender } = renderWithRouter()
-
- await waitFor(() => {
- const toggleReadButton = screen.getByTestId('conversation-toggle-read-button')
- expect(toggleReadButton).toHaveTextContent('Mark as unread')
- })
-
- rerender(
-
-
-
- )
-
- await waitFor(() => {
- const toggleReadButton = screen.getByTestId('conversation-toggle-read-button')
- expect(toggleReadButton).toHaveTextContent('Mark as unread')
- })
+ describe('delete', () => {
+ it('calls onDeleted when delete button fires', async () => {
+ const onDeleted = vi.fn()
+ renderHeader({ onDeleted })
+ await userEvent.click(screen.getByTestId('delete-conversation-button'))
+ expect(onDeleted).toHaveBeenCalledWith('conv-1')
})
})
- describe('Responsive behavior', () => {
- it('should show desktop buttons on large screens', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- expect(screen.getByTestId('conversation-block-button')).toBeVisible()
- expect(screen.getByTestId('conversation-report-button')).toBeVisible()
- expect(screen.getByTestId('conversation-toggle-read-button')).toBeVisible()
- })
+ describe('error handling', () => {
+ it('falls back to isBlocked=false when fetchFollowStatus throws', async () => {
+ mockFetchFollowStatus.mockRejectedValue(new Error('Network error'))
+ renderHeader()
+ await waitFor(() => expect(screen.getByTestId('conversation-block-button')).toHaveTextContent('Block'))
})
- it('should show mobile menu on small screens', async () => {
- renderWithRouter()
-
- await waitFor(() => {
- expect(screen.getByTestId('conversation-mobile-menu-btn')).toBeVisible()
- })
+ it('silently handles unblockUser failure', async () => {
+ mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: true } })
+ mockUnblockUser.mockRejectedValue(new Error('fail'))
+ renderHeader()
+ await waitFor(() => expect(screen.getByTestId('conversation-block-button')).toHaveTextContent('Unblock'))
+ await userEvent.click(screen.getByTestId('conversation-block-button'))
+ await waitFor(() => expect(mockUnblockUser).toHaveBeenCalled())
+ // no crash
})
})
-})
\ No newline at end of file
+})
diff --git a/src/components/MessagingComponents/tests/DeleteConversationButton.test.tsx b/src/components/MessagingComponents/tests/DeleteConversationButton.test.tsx
index 86bb2164..d35d5853 100644
--- a/src/components/MessagingComponents/tests/DeleteConversationButton.test.tsx
+++ b/src/components/MessagingComponents/tests/DeleteConversationButton.test.tsx
@@ -1,93 +1,101 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import DeleteConversationButton from "../DeleteConversationButton";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
-// Mock path is relative to THIS file (inside /tests/), so modal is one level up ("../")
-// Button labels match the real modal: "Cancel" to close, "Archive" to confirm
-vi.mock("../DeleteConversationModal", () => ({
+vi.mock('../DeleteConversationModal', () => ({
default: ({
+ conversationId,
+ participantId,
onClose,
onDeleted,
- conversationId,
}: {
- onClose: () => void;
- onDeleted?: (id: string) => void;
- conversationId: string;
+ conversationId: string
+ participantId: string
+ onClose: () => void
+ onDeleted?: (id: string) => void
}) => (
-
-
Cancel
-
onDeleted?.(conversationId)}>Archive
+
+ Close
+ onDeleted?.(conversationId)}>Confirm
),
-}));
+}))
+
+import DeleteConversationButton from '../DeleteConversationButton'
+
+describe('DeleteConversationButton', () => {
+ const defaultProps = {
+ conversationId: 'conv-1',
+ participantId: 'user-2',
+ onDeleted: vi.fn(),
+ }
-const renderButton = (
- props: {
- conversationId?: string;
- participantId?: string;
- onDeleted?: (id: string) => void;
- } = {}
-) =>
- render(
-
- );
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
-describe("DeleteConversationButton", () => {
- // ── Rendering ──────────────────────────────────────────────────────────────
+ describe('rendering', () => {
+ it('renders the delete button', () => {
+ render(
)
+ expect(screen.getByTestId('delete-conversation-button')).toBeInTheDocument()
+ })
- it("renders a button with aria-label", () => {
- renderButton();
- expect(
- screen.getByRole("button", { name: /delete conversation/i })
- ).toBeInTheDocument();
- });
+ it('renders the trash SVG icon', () => {
+ render(
)
+ expect(screen.getByTestId('delete-conversation-button').querySelector('svg')).toBeInTheDocument()
+ })
- it("does not render modal initially", () => {
- renderButton();
- expect(screen.queryByTestId("delete-modal")).not.toBeInTheDocument();
- });
+ it('does not render modal initially', () => {
+ render(
)
+ expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument()
+ })
- it("renders a trash SVG icon inside the button", () => {
- const { container } = renderButton();
- expect(container.querySelector("svg")).toBeInTheDocument();
- });
+ it('has aria-label "Delete conversation"', () => {
+ render(
)
+ expect(screen.getByTestId('delete-conversation-button')).toHaveAttribute('aria-label', 'Delete conversation')
+ })
+ })
- // ── Open modal ─────────────────────────────────────────────────────────────
+ describe('interactions', () => {
+ it('shows modal when delete button is clicked', async () => {
+ render(
)
+ await userEvent.click(screen.getByTestId('delete-conversation-button'))
+ expect(screen.getByTestId('delete-modal')).toBeInTheDocument()
+ })
- it("opens the modal when the button is clicked", async () => {
- renderButton();
- await userEvent.click(
- screen.getByRole("button", { name: /delete conversation/i })
- );
- expect(screen.getByTestId("delete-modal")).toBeInTheDocument();
- });
+ it('passes correct conversationId to modal', async () => {
+ render(
)
+ await userEvent.click(screen.getByTestId('delete-conversation-button'))
+ expect(screen.getByTestId('delete-modal')).toHaveAttribute('data-conv-id', 'conv-99')
+ })
- // ── Close modal ────────────────────────────────────────────────────────────
+ it('passes correct participantId to modal', async () => {
+ render(
)
+ await userEvent.click(screen.getByTestId('delete-conversation-button'))
+ expect(screen.getByTestId('delete-modal')).toHaveAttribute('data-part-id', 'user-77')
+ })
- it("closes the modal when Cancel is clicked", async () => {
- renderButton();
- await userEvent.click(
- screen.getByRole("button", { name: /delete conversation/i })
- );
- await userEvent.click(screen.getByText("Cancel"));
- expect(screen.queryByTestId("delete-modal")).not.toBeInTheDocument();
- });
+ it('hides modal when modal onClose is called', async () => {
+ render(
)
+ await userEvent.click(screen.getByTestId('delete-conversation-button'))
+ expect(screen.getByTestId('delete-modal')).toBeInTheDocument()
+ await userEvent.click(screen.getByTestId('modal-close'))
+ expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument()
+ })
- // ── onDeleted propagation ──────────────────────────────────────────────────
+ it('calls onDeleted when modal confirms deletion', async () => {
+ const onDeleted = vi.fn()
+ render(
)
+ await userEvent.click(screen.getByTestId('delete-conversation-button'))
+ await userEvent.click(screen.getByTestId('modal-confirm'))
+ expect(onDeleted).toHaveBeenCalledWith('conv-1')
+ })
- it("calls onDeleted with conversationId when Archive is clicked", async () => {
- const onDeleted = vi.fn();
- renderButton({ onDeleted, conversationId: "conv-42" });
- await userEvent.click(
- screen.getByRole("button", { name: /delete conversation/i })
- );
- await userEvent.click(screen.getByText("Archive"));
- expect(onDeleted).toHaveBeenCalledWith("conv-42");
- });
-});
\ No newline at end of file
+ it('works without onDeleted prop', async () => {
+ render(
)
+ await userEvent.click(screen.getByTestId('delete-conversation-button'))
+ await userEvent.click(screen.getByTestId('modal-confirm'))
+ // no error
+ })
+ })
+})
diff --git a/src/components/MessagingComponents/tests/DeleteConversationModal.test.tsx b/src/components/MessagingComponents/tests/DeleteConversationModal.test.tsx
index 53ca935c..7cdb0bdc 100644
--- a/src/components/MessagingComponents/tests/DeleteConversationModal.test.tsx
+++ b/src/components/MessagingComponents/tests/DeleteConversationModal.test.tsx
@@ -1,186 +1,173 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import DeleteConversationModal from "../DeleteConversationModal";
-
-vi.mock("@/services/api/messaging/conversationApi", () => ({
- deleteConversation: vi.fn(),
- submitReport: vi.fn(),
-}));
-
-import {
- deleteConversation,
- submitReport,
-} from "@/services/api/messaging/conversationApi";
-
-const renderModal = (
- props: {
- conversationId?: string;
- participantId?: string;
- onClose?: () => void;
- onDeleted?: (id: string) => void;
- } = {}
-) =>
- render(
-
- );
-
-describe("DeleteConversationModal", () => {
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+const mockDeleteConversation = vi.fn()
+const mockSubmitReport = vi.fn()
+
+vi.mock('@/services/api/messaging/conversationApi', () => ({
+ deleteConversation: (...args: unknown[]) => mockDeleteConversation(...args),
+ submitReport: (...args: unknown[]) => mockSubmitReport(...args),
+}))
+
+vi.mock('../CheckBox', () => ({
+ default: ({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) => (
+
+ onChange(e.target.checked)}
+ data-test="checkbox-toggle"
+ />
+ {label}
+
+ ),
+}))
+
+import DeleteConversationModal from '../DeleteConversationModal'
+
+const defaultProps = {
+ conversationId: 'conv-1',
+ participantId: 'user-2',
+ onClose: vi.fn(),
+ onDeleted: vi.fn(),
+}
+
+describe('DeleteConversationModal', () => {
beforeEach(() => {
- vi.clearAllMocks();
- });
-
- // ── Rendering ──────────────────────────────────────────────────────────────
-
- it("renders the confirmation heading", () => {
- renderModal();
- expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
- });
-
- it("renders the description text", () => {
- renderModal();
- expect(screen.getByText(/archiving a conversation removes it/i)).toBeInTheDocument();
- });
-
- it("renders the spam checkbox", () => {
- renderModal();
- expect(
- screen.getByText(/also report conversation as spam/i)
- ).toBeInTheDocument();
- });
-
- it("renders Cancel and Archive buttons", () => {
- renderModal();
- expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: /archive/i })).toBeInTheDocument();
- });
-
- // ── Cancel ─────────────────────────────────────────────────────────────────
-
- it("calls onClose when Cancel is clicked", async () => {
- const onClose = vi.fn();
- renderModal({ onClose });
- await userEvent.click(screen.getByRole("button", { name: /cancel/i }));
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("calls onClose when backdrop is clicked", async () => {
- const onClose = vi.fn();
- renderModal({ onClose });
- const backdrop = screen.getByRole("dialog");
- await userEvent.click(backdrop);
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("does not call onClose when modal content is clicked", async () => {
- const onClose = vi.fn();
- renderModal({ onClose });
- await userEvent.click(screen.getByText(/are you sure/i));
- expect(onClose).not.toHaveBeenCalled();
- });
-
- // ── Archive flow ───────────────────────────────────────────────────────────
-
- it("calls deleteConversation with conversationId on archive", async () => {
- (deleteConversation as ReturnType
).mockResolvedValue({});
- renderModal({ conversationId: "conv-1" });
- await userEvent.click(screen.getByRole("button", { name: /archive/i }));
- expect(deleteConversation).toHaveBeenCalledWith("conv-1");
- });
-
- it("calls onDeleted with conversationId on success", async () => {
- (deleteConversation as ReturnType).mockResolvedValue({});
- const onDeleted = vi.fn();
- renderModal({ onDeleted, conversationId: "conv-1" });
- await userEvent.click(screen.getByRole("button", { name: /archive/i }));
- expect(onDeleted).toHaveBeenCalledWith("conv-1");
- });
-
- it("calls onClose after successful delete", async () => {
- (deleteConversation as ReturnType).mockResolvedValue({});
- const onClose = vi.fn();
- renderModal({ onClose });
- await userEvent.click(screen.getByRole("button", { name: /archive/i }));
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- // ── With spam report ───────────────────────────────────────────────────────
-
- it("calls submitReport before deleteConversation when spam checkbox is checked", async () => {
- const callOrder: string[] = [];
- (submitReport as ReturnType).mockImplementation(async () =>
- callOrder.push("report")
- );
- (deleteConversation as ReturnType).mockImplementation(async () =>
- callOrder.push("delete")
- );
-
- renderModal({ participantId: "user-1" });
- const checkboxContainer = screen
- .getByText(/also report conversation as spam/i)
- .closest("label")!.querySelector("div[class*='border']")!;
- await userEvent.click(checkboxContainer);
- await userEvent.click(screen.getByRole("button", { name: /archive/i }));
-
- expect(callOrder).toEqual(["report", "delete"]);
- expect(submitReport).toHaveBeenCalledWith({
- resource_type: "user",
- resource_id: "user-1",
- reason: "spam",
- });
- });
-
- it("does not call submitReport when spam checkbox is unchecked", async () => {
- (deleteConversation as ReturnType).mockResolvedValue({});
- renderModal();
- await userEvent.click(screen.getByRole("button", { name: /archive/i }));
- expect(submitReport).not.toHaveBeenCalled();
- });
-
- // ── Error handling ─────────────────────────────────────────────────────────
-
- it("shows error message on failure", async () => {
- (deleteConversation as ReturnType).mockRejectedValue(
- new Error("Server error")
- );
- renderModal();
- await userEvent.click(screen.getByRole("button", { name: /archive/i }));
- expect(
- screen.getByText(/something went wrong/i)
- ).toBeInTheDocument();
- });
-
- it("does not show error initially", () => {
- renderModal();
- expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument();
- });
-
- it("disables buttons while deleting", async () => {
- let resolve!: (value: unknown) => void;
- (deleteConversation as ReturnType).mockImplementation(
- () => new Promise((res) => { resolve = res; })
- );
- renderModal();
- await userEvent.click(screen.getByRole("button", { name: /archive/i }));
- expect(screen.getByRole("button", { name: /archiving/i })).toBeDisabled();
- expect(screen.getByRole("button", { name: /cancel/i })).toBeDisabled();
- resolve(undefined);
- });
-
- it("shows 'Archiving…' text while in progress", async () => {
- let resolve!: (value: unknown) => void;
- (deleteConversation as ReturnType).mockImplementation(
- () => new Promise((res) => { resolve = res; })
- );
- renderModal();
- await userEvent.click(screen.getByRole("button", { name: /archive/i }));
- expect(screen.getByText(/archiving…/i)).toBeInTheDocument();
- resolve(undefined);
- });
-});
\ No newline at end of file
+ vi.clearAllMocks()
+ mockDeleteConversation.mockResolvedValue({})
+ mockSubmitReport.mockResolvedValue({})
+ })
+
+ describe('rendering', () => {
+ it('renders the modal container', () => {
+ render( )
+ expect(screen.getByTestId('delete-conversation-modal')).toBeInTheDocument()
+ })
+
+ it('renders the title "Are you sure?"', () => {
+ render( )
+ expect(screen.getByText('Are you sure?')).toBeInTheDocument()
+ })
+
+ it('renders the description text', () => {
+ render( )
+ expect(screen.getByText(/Archiving a conversation/)).toBeInTheDocument()
+ })
+
+ it('renders the CheckBox', () => {
+ render( )
+ expect(screen.getByTestId('checkbox-also-report-conversation-as-spam')).toBeInTheDocument()
+ })
+
+ it('renders the Cancel button', () => {
+ render( )
+ expect(screen.getByTestId('delete-conversation-cancel')).toBeInTheDocument()
+ })
+
+ it('renders the Archive button', () => {
+ render( )
+ expect(screen.getByTestId('delete-conversation-confirm')).toHaveTextContent('Archive')
+ })
+
+ it('does not show error initially', () => {
+ render( )
+ expect(screen.queryByTestId('delete-conversation-error')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('backdrop click', () => {
+ it('calls onClose when backdrop is clicked directly', () => {
+ const onClose = vi.fn()
+ render( )
+ const modal = screen.getByTestId('delete-conversation-modal')
+ fireEvent.click(modal)
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('does not call onClose when inner content is clicked', async () => {
+ const onClose = vi.fn()
+ render( )
+ await userEvent.click(screen.getByText('Are you sure?'))
+ expect(onClose).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('cancel button', () => {
+ it('calls onClose when Cancel is clicked', async () => {
+ const onClose = vi.fn()
+ render( )
+ await userEvent.click(screen.getByTestId('delete-conversation-cancel'))
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('archive (delete) flow', () => {
+ it('calls deleteConversation with the conversationId', async () => {
+ render( )
+ await userEvent.click(screen.getByTestId('delete-conversation-confirm'))
+ await waitFor(() => expect(mockDeleteConversation).toHaveBeenCalledWith('conv-1'))
+ })
+
+ it('calls onDeleted with conversationId after successful delete', async () => {
+ const onDeleted = vi.fn()
+ render( )
+ await userEvent.click(screen.getByTestId('delete-conversation-confirm'))
+ await waitFor(() => expect(onDeleted).toHaveBeenCalledWith('conv-1'))
+ })
+
+ it('calls onClose after successful delete', async () => {
+ const onClose = vi.fn()
+ render( )
+ await userEvent.click(screen.getByTestId('delete-conversation-confirm'))
+ await waitFor(() => expect(onClose).toHaveBeenCalled())
+ })
+
+ it('shows "Archiving…" text on the button while deleting', async () => {
+ mockDeleteConversation.mockReturnValue(new Promise(() => {})) // never resolves
+ render( )
+ await userEvent.click(screen.getByTestId('delete-conversation-confirm'))
+ expect(screen.getByTestId('delete-conversation-confirm')).toHaveTextContent('Archiving…')
+ })
+
+ it('disables Cancel and Archive buttons while deleting', async () => {
+ mockDeleteConversation.mockReturnValue(new Promise(() => {}))
+ render( )
+ await userEvent.click(screen.getByTestId('delete-conversation-confirm'))
+ expect(screen.getByTestId('delete-conversation-cancel')).toBeDisabled()
+ expect(screen.getByTestId('delete-conversation-confirm')).toBeDisabled()
+ })
+
+ it('shows error message when deleteConversation throws', async () => {
+ mockDeleteConversation.mockRejectedValue(new Error('fail'))
+ render( )
+ await userEvent.click(screen.getByTestId('delete-conversation-confirm'))
+ await waitFor(() => expect(screen.getByTestId('delete-conversation-error')).toBeInTheDocument())
+ })
+ })
+
+ describe('spam report flow', () => {
+ it('calls submitReport when spam checkbox is checked before archiving', async () => {
+ render( )
+ const checkbox = screen.getByTestId('checkbox-toggle')
+ fireEvent.click(checkbox)
+ expect(checkbox).toBeChecked()
+ await userEvent.click(screen.getByTestId('delete-conversation-confirm'))
+ await waitFor(() =>
+ expect(mockSubmitReport).toHaveBeenCalledWith({
+ resource_type: 'user',
+ resource_id: 'user-2',
+ reason: 'spam',
+ })
+ )
+ })
+
+ it('does not call submitReport when spam checkbox is not checked', async () => {
+ render( )
+ await userEvent.click(screen.getByTestId('delete-conversation-confirm'))
+ await waitFor(() => expect(mockDeleteConversation).toHaveBeenCalled())
+ expect(mockSubmitReport).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/src/components/MessagingComponents/tests/EmptyMessagesPage.test.tsx b/src/components/MessagingComponents/tests/EmptyMessagesPage.test.tsx
new file mode 100644
index 00000000..6f9f67c4
--- /dev/null
+++ b/src/components/MessagingComponents/tests/EmptyMessagesPage.test.tsx
@@ -0,0 +1,125 @@
+import { render, screen, fireEvent } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import MessagesPage from '@/pages/social/messages/emptyMessagesPage'
+
+// Mock child components
+vi.mock('@/components/MessagingComponents/Modal', () => ({
+ Modal: ({ isOpen, onClose, children }: { isOpen: boolean; onClose: () => void; children: React.ReactNode }) =>
+ isOpen ? (
+
+ Close
+ {children}
+
+ ) : null,
+}))
+
+vi.mock('@/components/MessagingComponents/MessagingHeader', () => ({
+ default: () =>
,
+}))
+
+vi.mock('./ModalNewMessageBody', () => ({
+ default: ({ onClose }: { onClose: () => void }) => (
+
+ Close Body
+
+ ),
+}))
+
+describe('MessagesPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('renders without crashing', () => {
+ render( )
+ expect(screen.getByTestId('empty-messages-page')).toBeInTheDocument()
+ })
+
+ it('renders the MessagingHeader', () => {
+ render( )
+ expect(screen.getByTestId('messaging-header')).toBeInTheDocument()
+ })
+
+ it('renders the empty state container', () => {
+ render( )
+ expect(screen.getByTestId('empty-messages-page')).toBeInTheDocument()
+ })
+
+ it('renders the empty messages title', () => {
+ render( )
+ expect(screen.getByTestId('empty-messages-title')).toBeInTheDocument()
+ expect(screen.getByTestId('empty-messages-title')).toHaveTextContent('You have no messages')
+ })
+
+ it('renders the write button', () => {
+ render( )
+ expect(screen.getByTestId('empty-messages-write-button')).toBeInTheDocument()
+ expect(screen.getByTestId('empty-messages-write-button')).toHaveTextContent('Write one')
+ })
+
+ it('renders the descriptive message text', () => {
+ render( )
+ expect(screen.getByText(/Send someone a message and make their day/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Modal initial state', () => {
+ it('does not show the modal by default', () => {
+ render( )
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
+ })
+
+ it('does not render ModalNewMessageBody when modal is closed', () => {
+ render( )
+ expect(screen.queryByTestId('modal-new-message-body')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Modal open behavior', () => {
+ it('opens the modal when "Write one" button is clicked', () => {
+ render( )
+ fireEvent.click(screen.getByTestId('empty-messages-write-button'))
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+
+ it('renders ModalNewMessageBody inside the modal when open', () => {
+ render( )
+ fireEvent.click(screen.getByTestId('empty-messages-write-button'))
+ expect(screen.getByTestId('modal-new-message-body')).toBeInTheDocument()
+ })
+ })
+
+ describe('Modal close behavior', () => {
+ it('closes the modal when onClose is called via Modal close button', () => {
+ render( )
+ fireEvent.click(screen.getByTestId('empty-messages-write-button'))
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('modal-close'))
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
+ })
+
+ it('closes the modal when onClose is called via ModalNewMessageBody', () => {
+ render( )
+ fireEvent.click(screen.getByTestId('empty-messages-write-button'))
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('modal-body-close'))
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
+ })
+
+ it('can reopen the modal after closing', () => {
+ render( )
+
+ fireEvent.click(screen.getByTestId('empty-messages-write-button'))
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('modal-close'))
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('empty-messages-write-button'))
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/components/MessagingComponents/tests/MessageBox.test.tsx b/src/components/MessagingComponents/tests/MessageBox.test.tsx
index 21917e92..5caa3a95 100644
--- a/src/components/MessagingComponents/tests/MessageBox.test.tsx
+++ b/src/components/MessagingComponents/tests/MessageBox.test.tsx
@@ -1,239 +1,946 @@
-// import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-// import { render, screen, fireEvent, act } from "@testing-library/react";
-// import userEvent from "@testing-library/user-event";
-// import { MessageBox } from "../MessageBox";
-
-// vi.mock("@/services/api/messaging/conversationApi", () => ({
-// resolvePermalink: vi.fn(),
-// fetchTrack: vi.fn(),
-// fetchPlaylist: vi.fn(),
-// }));
-
-// import {
-// resolvePermalink,
-// fetchTrack,
-// fetchPlaylist,
-// } from "@/services/api/messaging/conversationApi";
-
-// const renderBox = (props = {}) =>
-// render(
-//
-// );
-
-// // Synchronously sets the textarea value and fires onChange.
-// // Used in debounce tests so fake timers control ALL async work —
-// // userEvent.type uses its own internal setTimeout delays which
-// // conflict with vi.useFakeTimers() and cause indefinite hangs.
-// const fireType = (element: HTMLElement, value: string) =>
-// fireEvent.change(element, { target: { value } });
-
-// // Fires all pending timers AND flushes resulting Promise microtasks,
-// // then wraps everything in act() so React commits all setState calls to the DOM.
-// const runAllTimers = () => act(async () => { await vi.runAllTimersAsync(); });
-
-// describe("MessageBox", () => {
-// beforeEach(() => {
-// vi.clearAllMocks();
-// });
-
-// // ── Rendering ──────────────────────────────────────────────────────────────
-
-// it("renders the textarea", () => {
-// renderBox();
-// expect(screen.getByTestId("message-input")).toBeInTheDocument();
-// });
-
-// it("does not render title initially", () => {
-// renderBox();
-// expect(screen.queryByTestId("message-box-title")).not.toBeInTheDocument();
-// });
-
-// it("has correct data-test on wrapper", () => {
-// renderBox();
-// expect(screen.getByTestId("message-box")).toBeInTheDocument();
-// });
-
-// // ── Typing (real timers + userEvent) ───────────────────────────────────────
-
-// it("calls onValueChange when user types", async () => {
-// const user = userEvent.setup();
-// const onValueChange = vi.fn();
-// renderBox({ onValueChange });
-// await user.type(screen.getByTestId("message-input"), "Hello");
-// expect(onValueChange).toHaveBeenCalledWith("Hello");
-// });
-
-// it("calls onIsEmptyChange(false) when text is entered", async () => {
-// const user = userEvent.setup();
-// const onIsEmptyChange = vi.fn();
-// renderBox({ onIsEmptyChange });
-// await user.type(screen.getByTestId("message-input"), "H");
-// expect(onIsEmptyChange).toHaveBeenCalledWith(false);
-// });
-
-// it("calls onIsEmptyChange(true) when text is cleared", async () => {
-// const user = userEvent.setup();
-// const onIsEmptyChange = vi.fn();
-// renderBox({ onIsEmptyChange });
-// const input = screen.getByTestId("message-input");
-// await user.type(input, "H");
-// await user.clear(input);
-// expect(onIsEmptyChange).toHaveBeenCalledWith(true);
-// });
-
-// it("calls onEmbedResolved(null) when no URL is in text", async () => {
-// const user = userEvent.setup();
-// const onEmbedResolved = vi.fn();
-// renderBox({ onEmbedResolved });
-// await user.type(screen.getByTestId("message-input"), "Just text");
-// expect(onEmbedResolved).toHaveBeenCalledWith(null);
-// });
-
-// // ── Error styling ──────────────────────────────────────────────────────────
-
-// it("applies error border class when hasError is true", () => {
-// renderBox({ hasError: true });
-// expect(screen.getByTestId("message-input")).toHaveClass("border-red-500");
-// });
-
-// it("applies normal border class when hasError is false", () => {
-// renderBox({ hasError: false });
-// expect(screen.getByTestId("message-input")).not.toHaveClass("border-red-500");
-// });
-
-// // ── Debounced URL embed detection ──────────────────────────────────────────
-// //
-// // PROBLEM (the last 2 failures):
-// // vi.runAllTimersAsync() fires the debounce setTimeout and drains Promise
-// // microtasks (resolvePermalink, fetchTrack chains), but React's setTitle()
-// // calls happen INSIDE those async callbacks. React batches setState and
-// // only commits them to the DOM when the update is flushed inside act().
-// // Without act(), the DOM never updates — so getByTestId("message-box-title")
-// // finds nothing even though setTitle("Awesome Track") was called.
-// //
-// // FIX:
-// // Wrap vi.runAllTimersAsync() inside act(async () => { ... }).
-// // act() tells React "flush everything now" — timers fire, promises drain,
-// // and all resulting setState calls are committed to the DOM before we assert.
-// //
-// // Extracted into the runAllTimers() helper above for readability.
-
-// describe("debounced URL embed detection", () => {
-// beforeEach(() => {
-// vi.useFakeTimers();
-// });
-
-// afterEach(() => {
-// vi.useRealTimers();
-// });
-
-// it("does not call resolvePermalink immediately on URL input (debounced)", () => {
-// renderBox();
-// fireType(
-// screen.getByTestId("message-input"),
-// "https://rythmify.com/tracks/123"
-// );
-// expect(resolvePermalink).not.toHaveBeenCalled();
-// });
-
-// it("calls resolvePermalink after debounce when URL is typed", async () => {
-// (resolvePermalink as ReturnType).mockResolvedValue({
-// data: { type: "track", id: "t-1" },
-// });
-// (fetchTrack as ReturnType).mockResolvedValue({
-// data: { title: "My Track", id: "t-1" },
-// });
-
-// renderBox();
-// fireType(
-// screen.getByTestId("message-input"),
-// "https://rythmify.com/tracks/t-1"
-// );
-
-// await runAllTimers();
-
-// expect(resolvePermalink).toHaveBeenCalled();
-// });
-
-// it("renders track title after successful track embed resolution", async () => {
-// (resolvePermalink as ReturnType).mockResolvedValue({
-// data: { type: "track", id: "t-1" },
-// });
-// (fetchTrack as ReturnType).mockResolvedValue({
-// data: { title: "Awesome Track", id: "t-1" },
-// });
-
-// renderBox();
-// fireType(
-// screen.getByTestId("message-input"),
-// "https://rythmify.com/tracks/t-1"
-// );
-
-// await runAllTimers();
-
-// expect(screen.getByTestId("message-box-title")).toHaveTextContent("Awesome Track");
-// });
-
-// it("renders playlist title after successful playlist embed resolution", async () => {
-// (resolvePermalink as ReturnType).mockResolvedValue({
-// data: { type: "playlist", id: "p-1" },
-// });
-// (fetchPlaylist as ReturnType).mockResolvedValue({
-// data: { title: "My Playlist", id: "p-1" },
-// });
-
-// renderBox();
-// fireType(
-// screen.getByTestId("message-input"),
-// "https://rythmify.com/playlists/p-1"
-// );
-
-// await runAllTimers();
-
-// expect(screen.getByTestId("message-box-title")).toHaveTextContent("My Playlist");
-// });
-
-// it("clears title when embed resolution fails", async () => {
-// (resolvePermalink as ReturnType).mockRejectedValue(
-// new Error("Not found")
-// );
-
-// renderBox();
-// fireType(
-// screen.getByTestId("message-input"),
-// "https://rythmify.com/tracks/bad"
-// );
-
-// await runAllTimers();
-
-// expect(screen.queryByTestId("message-box-title")).not.toBeInTheDocument();
-// });
-
-// it("calls onEmbedResolved with track embed data on success", async () => {
-// const onEmbedResolved = vi.fn();
-// (resolvePermalink as ReturnType).mockResolvedValue({
-// data: { type: "track", id: "t-99" },
-// });
-// (fetchTrack as ReturnType).mockResolvedValue({
-// data: { title: "Track 99", id: "t-99" },
-// });
-
-// renderBox({ onEmbedResolved });
-// fireType(
-// screen.getByTestId("message-input"),
-// "https://rythmify.com/tracks/t-99"
-// );
-
-// await runAllTimers();
-
-// expect(onEmbedResolved).toHaveBeenCalledWith(
-// expect.objectContaining({ type: "track", id: "t-99" })
-// );
-// });
-// });
-// });
\ No newline at end of file
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { act, render, fireEvent } from '@testing-library/react'
+
+const mockResolvePermalink = vi.fn()
+const mockFetchTrack = vi.fn()
+const mockFetchPlaylist = vi.fn()
+
+vi.mock('../../../services/api/messaging/conversationApi', () => ({
+ resolvePermalink: (...args: unknown[]) => mockResolvePermalink(...args),
+ fetchTrack: (...args: unknown[]) => mockFetchTrack(...args),
+ fetchPlaylist: (...args: unknown[]) => mockFetchPlaylist(...args),
+}))
+
+vi.mock('../MiniPlayer', () => ({
+ default: ({ trackName, onClose }: { trackName: string; onClose: () => void }) => (
+
+ ×
+
+ ),
+}))
+
+import { MessageBox } from '../MessageBox'
+
+// Helpers that query by data-test instead of data-testid
+const getByTest = (container: HTMLElement, value: string) => {
+ const el = container.querySelector(`[data-test="${value}"]`)
+ if (!el) throw new Error(`Unable to find element with data-test="${value}"`)
+ return el as HTMLElement
+}
+const getAllByTest = (container: HTMLElement, value: string) => {
+ const els = container.querySelectorAll(`[data-test="${value}"]`)
+ if (!els.length) throw new Error(`Unable to find any element with data-test="${value}"`)
+ return Array.from(els) as HTMLElement[]
+}
+const queryByTest = (container: HTMLElement, value: string) =>
+ container.querySelector(`[data-test="${value}"]`) as HTMLElement | null
+
+const runDebounce = async () => {
+ await act(async () => {
+ vi.advanceTimersByTime(600)
+ for (let i = 0; i < 5; i += 1) {
+ await Promise.resolve()
+ }
+ })
+}
+
+const defaultProps = {
+ onValueChange: vi.fn(),
+ onIsEmptyChange: vi.fn(),
+ onEmbedsResolved: vi.fn(),
+ hasError: false,
+}
+
+const mockTrack = {
+ id: 'track-1',
+ title: 'My Track',
+ artist_name: 'Artist',
+ cover_image: null,
+ description: null,
+ genre: null,
+ duration: null,
+ bitrate: null,
+ status: 'public',
+ is_public: true,
+ is_hidden: false,
+ user_id: 'u1',
+ play_count: 0,
+ like_count: 0,
+ stream_url: null,
+ preview_url: null,
+ waveform_url: null,
+ artists: null,
+ created_at: '',
+ updated_at: '',
+}
+
+const mockPlaylist = {
+ playlist_id: 'pl-1',
+ name: 'My Playlist',
+ cover_image: null,
+ track_count: 3,
+ tracks: [],
+ owner_user_id: 'u1',
+ slug: null,
+ description: null,
+ is_public: true,
+ like_count: 0,
+ repost_count: 0,
+ created_at: '',
+ updated_at: '',
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('MessageBox', () => {
+ describe('rendering', () => {
+ it('renders the message-box container', () => {
+ const { container } = render( )
+ expect(getByTest(container, 'message-box')).toBeInTheDocument()
+ })
+
+ it('renders the textarea', () => {
+ const { container } = render( )
+ expect(getByTest(container, 'message-input')).toBeInTheDocument()
+ })
+
+ it('textarea starts empty', () => {
+ const { container } = render( )
+ expect(getByTest(container, 'message-input')).toHaveValue('')
+ })
+
+ it('applies error border class when hasError is true', () => {
+ const { container } = render( )
+ expect(getByTest(container, 'message-input')).toHaveClass('border-red-500')
+ })
+
+ it('applies normal border class when hasError is false', () => {
+ const { container } = render( )
+ expect(getByTest(container, 'message-input')).not.toHaveClass('border-red-500')
+ })
+
+ it('does not render MiniPlayer when no embeds', () => {
+ const { container } = render( )
+ expect(queryByTest(container, 'mini-player')).not.toBeInTheDocument()
+ })
+
+ it('renders MiniPlayer for external track embed', () => {
+ const externalEmbeds = [{ type: 'track' as const, id: 't1', resource: mockTrack, sourceUrl: '' }]
+ const { container } = render( )
+ expect(getByTest(container, 'mini-player')).toBeInTheDocument()
+ })
+
+ it('renders MiniPlayer for external playlist embed', () => {
+ const externalEmbeds = [{ type: 'playlist' as const, id: 'pl1', resource: mockPlaylist as any, sourceUrl: '' }]
+ const { container } = render( )
+ expect(getByTest(container, 'mini-player')).toBeInTheDocument()
+ })
+
+ it('renders multiple MiniPlayers for multiple embeds', () => {
+ const externalEmbeds = [
+ { type: 'track' as const, id: 't1', resource: mockTrack, sourceUrl: '' },
+ { type: 'track' as const, id: 't2', resource: { ...mockTrack, id: 't2', title: 'Track 2' }, sourceUrl: '' },
+ ]
+ const { container } = render( )
+ expect(getAllByTest(container, 'mini-player')).toHaveLength(2)
+ })
+ })
+
+ describe('typing', () => {
+ it('calls onValueChange when user types', () => {
+ const onValueChange = vi.fn()
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: 'hello' } })
+ expect(onValueChange).toHaveBeenCalledWith('hello')
+ })
+
+ it('calls onIsEmptyChange with false when text is entered', () => {
+ const onIsEmptyChange = vi.fn()
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: 'hi' } })
+ expect(onIsEmptyChange).toHaveBeenCalledWith(false)
+ })
+
+ it('calls onIsEmptyChange with true when text is cleared', () => {
+ const onIsEmptyChange = vi.fn()
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: 'hi' } })
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: '' } })
+ expect(onIsEmptyChange).toHaveBeenCalledWith(true)
+ })
+
+ it('calls onEmbedsResolved with empty array when no URL in text', () => {
+ const onEmbedsResolved = vi.fn()
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: 'just text' } })
+ expect(onEmbedsResolved).toHaveBeenCalled()
+ })
+ })
+
+ describe('keyboard submit', () => {
+ it('calls onSubmit when Enter is pressed without Shift', () => {
+ const onSubmit = vi.fn()
+ const { container } = render( )
+ fireEvent.keyDown(getByTest(container, 'message-input'), { key: 'Enter', shiftKey: false })
+ expect(onSubmit).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not call onSubmit when Shift+Enter is pressed', () => {
+ const onSubmit = vi.fn()
+ const { container } = render( )
+ fireEvent.keyDown(getByTest(container, 'message-input'), { key: 'Enter', shiftKey: true })
+ expect(onSubmit).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('URL resolution (debounced)', () => {
+ it('calls resolvePermalink for URLs in text after debounce', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/track' },
+ })
+ await runDebounce()
+ expect(mockResolvePermalink).toHaveBeenCalledWith('https://soundcloud.com/artist/track')
+ })
+
+ it('fetches track data when permalink resolves to track type', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/track' },
+ })
+ await runDebounce()
+ expect(mockFetchTrack).toHaveBeenCalledWith('track-1')
+ })
+
+ it('fetches playlist data when permalink resolves to playlist type', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: mockPlaylist })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/playlist' },
+ })
+ await runDebounce()
+ expect(mockFetchPlaylist).toHaveBeenCalledWith('pl-1')
+ })
+
+ it('ignores user type from resolvePermalink', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'user', id: 'u1' } })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist' },
+ })
+ await runDebounce()
+ expect(mockResolvePermalink).toHaveBeenCalled()
+ expect(mockFetchTrack).not.toHaveBeenCalled()
+ })
+
+ it('handles resolvePermalink failure gracefully', async () => {
+ mockResolvePermalink.mockRejectedValue(new Error('fail'))
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/track' },
+ })
+ await runDebounce()
+ expect(mockResolvePermalink).toHaveBeenCalled()
+ // no crash; test passes if we get here
+ })
+ })
+
+ describe('embed removal', () => {
+ it('removes MiniPlayer when close button is clicked (external embeds)', async () => {
+ const onEmbedsResolved = vi.fn()
+ const externalEmbeds = [{ type: 'track' as const, id: 't1', resource: mockTrack, sourceUrl: '' }]
+ const { container } = render( )
+ fireEvent.click(getByTest(container, 'mini-player-close'))
+ expect(onEmbedsResolved).toHaveBeenCalledWith([])
+ })
+
+ it('removes only the clicked embed when multiple embeds exist', async () => {
+ const onEmbedsResolved = vi.fn()
+ const externalEmbeds = [
+ { type: 'track' as const, id: 't1', resource: mockTrack, sourceUrl: '' },
+ { type: 'track' as const, id: 't2', resource: { ...mockTrack, id: 't2', title: 'Track 2' }, sourceUrl: '' },
+ ]
+ const { container } = render( )
+ const closeBtns = getAllByTest(container, 'mini-player-close')
+ fireEvent.click(closeBtns[0])
+ const lastCall = onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0]
+ expect(lastCall).toHaveLength(1)
+ expect(lastCall[0].id).toBe('t2')
+ })
+ })
+})
+describe('mergeEmbeds (controlled mode deduplication)', () => {
+ it('merges new resolved embeds with existing external embeds without duplicates', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const onEmbedsResolved = vi.fn()
+ // Start with 'track-1' already present as an external embed
+ const externalEmbeds = [
+ { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: 'https://soundcloud.com/artist/track' },
+ ]
+ const { container } = render(
+
+ )
+
+ // Type the same URL — resolves to the same embed id
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/track' },
+ })
+ await runDebounce()
+
+ // onEmbedsResolved should be called with exactly 1 embed (no duplicate)
+ const lastCall = onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0]
+ expect(lastCall).toHaveLength(1)
+ expect(lastCall[0].id).toBe('track-1')
+ })
+ })
+
+ describe('stripEmbedUrls duplicate URL handling', () => {
+ it('keeps surplus duplicate URLs when there are fewer active embeds', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const url = 'https://soundcloud.com/artist/track'
+ const onValueChange = vi.fn()
+ const externalEmbeds = [
+ { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: url },
+ ]
+ const { container } = render(
+
+ )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: `${url} hello ${url}` },
+ })
+ await runDebounce()
+
+ // One active embed removes one URL occurrence; the surplus duplicate remains.
+ const lastClean = onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0]
+ expect(lastClean).toBe(`hello ${url}`)
+ })
+ })
+
+ describe('removeEmbedAt with sourceUrl', () => {
+ it('removes the embed URL from the textarea value when closing', async () => {
+ // Pre-wire a URL resolve so the embed has a sourceUrl
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const url = 'https://soundcloud.com/artist/track'
+ const onValueChange = vi.fn()
+ const externalEmbeds = [
+ { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: url },
+ ]
+ const { container } = render(
+
+ )
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } })
+ await runDebounce()
+
+ // Now close the mini-player
+ fireEvent.click(getByTest(container, 'mini-player-close'))
+
+ // The URL should no longer appear in the value passed upstream
+ const lastClean = onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0]
+ expect(lastClean).not.toContain(url)
+ })
+
+ it('calls onIsEmptyChange(true) when last embed is removed and text is empty', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const onIsEmptyChange = vi.fn()
+ const { container } = render( )
+
+ const url = 'https://soundcloud.com/artist/track'
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } })
+ await runDebounce()
+
+ fireEvent.click(getByTest(container, 'mini-player-close'))
+
+ // After removing the only embed with no other text, isEmpty should be true
+ const calls = onIsEmptyChange.mock.calls
+ expect(calls[calls.length - 1][0]).toBe(true)
+ })
+ })
+
+ describe('resolvedUrlsRef cache hit', () => {
+ it('calls resolvePermalink only once when the same URL is typed twice', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const { container } = render( )
+ const url = 'https://soundcloud.com/artist/track'
+
+ // First type
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } })
+ await runDebounce()
+
+ const firstCallCount = mockResolvePermalink.mock.calls.length
+
+ // Second type with same URL — should hit cache
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } })
+ await runDebounce()
+
+ expect(mockResolvePermalink.mock.calls.length).toBe(firstCallCount)
+ })
+
+ it('returns null from cache when a previously failed URL is typed again', async () => {
+ mockResolvePermalink.mockRejectedValue(new Error('fail'))
+
+ const onEmbedsResolved = vi.fn()
+ const { container } = render( )
+ const url = 'https://soundcloud.com/artist/track'
+
+ // First attempt — fails and caches null
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } })
+ await runDebounce()
+
+ const firstCallCount = mockResolvePermalink.mock.calls.length
+
+ // Second attempt — should hit the null cache, not call resolvePermalink again
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } })
+ await runDebounce()
+
+ expect(mockResolvePermalink.mock.calls.length).toBe(firstCallCount)
+ const lastResolved = onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0]
+ expect(lastResolved).toEqual([])
+ })
+ })
+
+ describe('playlist track hydration (lines 147–162)', () => {
+ it('calls fetchTrack for each track in a playlist when tracks have an id field', async () => {
+ const playlistWithTracks = {
+ ...mockPlaylist,
+ track_count: 2,
+ tracks: [
+ { id: 'tr-a', title: 'Track A' },
+ { id: 'tr-b', title: 'Track B' },
+ ],
+ }
+
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/playlist' },
+ })
+ await runDebounce()
+
+ expect(mockFetchTrack).toHaveBeenCalledWith('tr-a')
+ expect(mockFetchTrack).toHaveBeenCalledWith('tr-b')
+ })
+
+ it('calls fetchTrack using track_id field when id is absent', async () => {
+ const playlistWithTracks = {
+ ...mockPlaylist,
+ tracks: [{ track_id: 'tr-c' }],
+ }
+
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/playlist' },
+ })
+ await runDebounce()
+
+ expect(mockFetchTrack).toHaveBeenCalledWith('tr-c')
+ })
+
+ it('skips fetchTrack for tracks with undefined or empty id', async () => {
+ const playlistWithTracks = {
+ ...mockPlaylist,
+ tracks: [{ id: 'undefined' }, { id: '' }, { id: undefined }],
+ }
+
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/playlist' },
+ })
+ await runDebounce()
+
+ expect(mockFetchTrack).not.toHaveBeenCalled()
+ })
+
+ it('handles fetchTrack failure inside playlist hydration gracefully', async () => {
+ const playlistWithTracks = {
+ ...mockPlaylist,
+ tracks: [{ id: 'tr-d' }],
+ }
+
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks })
+ mockFetchTrack.mockRejectedValue(new Error('track fetch failed'))
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/playlist' },
+ })
+ await runDebounce()
+
+ // Should not crash; playlist embed still resolves (with original track stubs)
+ expect(getByTest(container, 'message-box')).toBeInTheDocument()
+ })
+
+ it('handles string track entries in a playlist', async () => {
+ const playlistWithTracks = {
+ ...mockPlaylist,
+ tracks: ['tr-e'],
+ }
+
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/playlist' },
+ })
+ await runDebounce()
+
+ expect(mockFetchTrack).toHaveBeenCalledWith('tr-e')
+ })
+ })
+// ─── Additional tests to close the remaining coverage gaps ───────────────────
+//
+// Drop these describe blocks inside the existing describe('MessageBox', ...)
+// in MessageBox.test.tsx, after the existing 'embed removal' block.
+//
+// Gaps targeted:
+// Lines 37-44 → mergeEmbeds (controlled mode + URL resolves)
+// Line 60 → stripEmbedUrls "return match" branch (more occurrences than embeds)
+// Lines 72-77 → removeEmbedAt sourceUrl branch
+// Line 90 → setInternalEmbeds inside removeEmbedAt (uncontrolled mode)
+// Lines 123-125 → cache hit returning a non-null cached embed
+// Lines 147-162 → playlist track hydration (tracks array, track_id, string, skip, error)
+// ─────────────────────────────────────────────────────────────────────────────
+
+ // ── Lines 37-44: mergeEmbeds ─────────────────────────────────────────────
+ // mergeEmbeds is only called when controlled===true (externalEmbeds prop is
+ // provided) AND a URL resolves successfully after debounce.
+ describe('mergeEmbeds — controlled mode', () => {
+ it('deduplicates when the resolved embed id already exists in externalEmbeds', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const onEmbedsResolved = vi.fn()
+ // Provide the same embed id that the URL will resolve to
+ const externalEmbeds = [
+ { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: '' },
+ ]
+ const { container } = render(
+
+ )
+
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/track' },
+ })
+ await runDebounce()
+
+ // mergeEmbeds should deduplicate — still only one embed with id 'track-1'
+ const lastCall = onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0]
+ expect(lastCall).toHaveLength(1)
+ expect(lastCall[0].id).toBe('track-1')
+ })
+
+ it('adds a newly resolved embed to existing externalEmbeds without losing originals', async () => {
+ const track2 = { ...mockTrack, id: 'track-2', title: 'Second Track' }
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-2' } })
+ mockFetchTrack.mockResolvedValue({ data: track2 })
+
+ const onEmbedsResolved = vi.fn()
+ const externalEmbeds = [
+ { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: '' },
+ ]
+ const { container } = render(
+
+ )
+
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/track2' },
+ })
+ await runDebounce()
+
+ // mergeEmbeds should produce both embeds
+ const lastCall = onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0]
+ expect(lastCall).toHaveLength(2)
+ const ids = lastCall.map((e: any) => e.id)
+ expect(ids).toContain('track-1')
+ expect(ids).toContain('track-2')
+ })
+ })
+
+ // ── Line 60: stripEmbedUrls "return match" branch ────────────────────────
+ // This branch executes when the URL appears MORE times in the text than
+ // there are embeds for it. The regex callback fires for every occurrence;
+ // once `removed >= count` the surplus occurrences are returned unchanged.
+ describe('stripEmbedUrls surplus-occurrence branch (line 60)', () => {
+ it('leaves extra URL occurrences in the clean text when they exceed embed count', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const url = 'https://soundcloud.com/artist/track'
+ const onValueChange = vi.fn()
+ const externalEmbeds = [
+ { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: url },
+ ]
+ const { container } = render(
+
+ )
+ // Three occurrences of the URL but only one embed will be created,
+ // so stripEmbedUrls removes 1 occurrence and keeps the other 2 →
+ // the "return match" branch on line 60 fires for occurrences 2 and 3.
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: `${url} ${url} ${url}` },
+ })
+ await runDebounce()
+
+ const lastClean: string =
+ onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0]
+ // One occurrence stripped, two kept → url still present in output
+ expect(lastClean).toContain(url)
+ })
+ })
+
+ // ── Lines 72-77: removeEmbedAt sourceUrl branch ──────────────────────────
+ // Existing remove tests pass externalEmbeds with sourceUrl:'', so the
+ // `if (embedToRemove.sourceUrl)` guard is never truthy. We need to close
+ // an embed that actually has a sourceUrl so the regex replacement runs.
+ describe('removeEmbedAt — sourceUrl branch (lines 72-77)', () => {
+ it('strips the embed sourceUrl from the textarea value when closing', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const onValueChange = vi.fn()
+ // Uncontrolled mode so the embed is stored internally with the real sourceUrl
+ const { container } = render( )
+
+ const url = 'https://soundcloud.com/artist/track'
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } })
+ await runDebounce()
+
+ // The mini-player close button is now rendered
+ fireEvent.click(getByTest(container, 'mini-player-close'))
+
+ // After closing, the URL must have been removed from the value passed upstream
+ const lastClean: string =
+ onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0]
+ expect(lastClean).not.toContain(url)
+ })
+
+ it('keeps a second occurrence of the URL when only one embed is removed', async () => {
+ // Two resolves with different ids so we get two embeds, same URL base
+ const url = 'https://soundcloud.com/artist/track'
+ const onValueChange = vi.fn()
+ const externalEmbeds = [
+ { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: url },
+ ]
+ const { container } = render(
+
+ )
+ // Two copies of the same URL in the text → one embed resolved (cache hit for second)
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: `${url} ${url}` },
+ })
+ // Close the single embed; line 76 ("return match" inside removeEmbedAt)
+ // fires for the second occurrence and keeps it
+ fireEvent.click(getByTest(container, 'mini-player-close'))
+
+ const lastClean: string =
+ onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0]
+ // Second occurrence should survive
+ expect(lastClean).toContain(url)
+ })
+ })
+
+ // ── Line 90: setInternalEmbeds inside removeEmbedAt (uncontrolled) ────────
+ // Existing remove tests always pass externalEmbeds (controlled mode), so
+ // `if (!controlled)` is always false there. We need an uncontrolled removal.
+ describe('removeEmbedAt — uncontrolled mode (line 90)', () => {
+ it('removes the mini-player from the DOM when closed in uncontrolled mode', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ // No externalEmbeds prop → uncontrolled
+ const { container } = render( )
+
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/track' },
+ })
+ await runDebounce()
+
+ expect(queryByTest(container, 'mini-player')).toBeInTheDocument()
+
+ fireEvent.click(getByTest(container, 'mini-player-close'))
+
+ // setInternalEmbeds([]) causes a re-render with no embeds → no mini-player
+ expect(queryByTest(container, 'mini-player')).not.toBeInTheDocument()
+ })
+
+ it('calls onEmbedsResolved with empty array after closing last embed in uncontrolled mode', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const onEmbedsResolved = vi.fn()
+ const { container } = render(
+
+ )
+
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/track' },
+ })
+ await runDebounce()
+
+ fireEvent.click(getByTest(container, 'mini-player-close'))
+
+ const lastCall =
+ onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0]
+ expect(lastCall).toEqual([])
+ })
+ })
+
+ // ── Lines 123-125: cache hit returning a non-null cached embed ────────────
+ // resolvedUrlsRef stores the result after the first resolve. On a second
+ // change event with the same URL the `has()` check is true and the cached
+ // embed (non-null) is spread and returned — lines 124-125.
+ describe('resolvedUrlsRef cache hit (lines 123-125)', () => {
+ it('returns the cached embed without calling resolvePermalink again', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const onEmbedsResolved = vi.fn()
+ const { container } = render(
+
+ )
+
+ const url = 'https://soundcloud.com/artist/track'
+
+ // First type — populates cache
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } })
+ await runDebounce()
+
+ const callsAfterFirst = mockResolvePermalink.mock.calls.length
+ expect(callsAfterFirst).toBe(1)
+
+ // Second type with the same URL — must hit cache, not call API again
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: `${url} ` } })
+ await runDebounce()
+
+ expect(mockResolvePermalink.mock.calls.length).toBe(callsAfterFirst)
+
+ // The embed is still resolved and reported
+ const lastCall =
+ onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0]
+ expect(lastCall[0].id).toBe('track-1')
+ })
+
+ it('does not call fetchTrack again when cache returns a previously resolved track', async () => {
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const { container } = render( )
+ const url = 'https://soundcloud.com/artist/track'
+
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } })
+ await runDebounce()
+
+ const fetchCallsAfterFirst = mockFetchTrack.mock.calls.length
+
+ // Same URL again — full cache hit, no fetchTrack
+ fireEvent.change(getByTest(container, 'message-input'), { target: { value: `${url} ` } })
+ await runDebounce()
+
+ expect(mockFetchTrack.mock.calls.length).toBe(fetchCallsAfterFirst)
+ })
+ })
+
+ // ── Lines 147-162: playlist track hydration ───────────────────────────────
+ describe('playlist track hydration (lines 147-162)', () => {
+ it('calls fetchTrack for each track object that has an id field', async () => {
+ const playlistWithTracks = {
+ ...mockPlaylist,
+ tracks: [
+ { id: 'tr-a', title: 'Track A' },
+ { id: 'tr-b', title: 'Track B' },
+ ],
+ }
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/my-playlist' },
+ })
+ await runDebounce()
+
+ expect(mockFetchTrack).toHaveBeenCalledWith('tr-a')
+ expect(mockFetchTrack).toHaveBeenCalledWith('tr-b')
+ })
+
+ it('falls back to track_id when the id field is absent', async () => {
+ const playlistWithTracks = {
+ ...mockPlaylist,
+ tracks: [{ track_id: 'tr-c' }],
+ }
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/my-playlist' },
+ })
+ await runDebounce()
+
+ expect(mockFetchTrack).toHaveBeenCalledWith('tr-c')
+ })
+
+ it('uses the string itself as trackId when the track entry is a plain string', async () => {
+ const playlistWithTracks = {
+ ...mockPlaylist,
+ tracks: ['tr-d'],
+ }
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/my-playlist' },
+ })
+ await runDebounce()
+
+ expect(mockFetchTrack).toHaveBeenCalledWith('tr-d')
+ })
+
+ it('skips fetchTrack when trackId is the literal string "undefined"', async () => {
+ const playlistWithTracks = {
+ ...mockPlaylist,
+ tracks: [{ id: 'undefined' }],
+ }
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/my-playlist' },
+ })
+ await runDebounce()
+
+ expect(mockFetchTrack).not.toHaveBeenCalled()
+ })
+
+ it('skips fetchTrack when trackId is an empty string', async () => {
+ const playlistWithTracks = {
+ ...mockPlaylist,
+ tracks: [{ id: '' }],
+ }
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks })
+
+ const { container } = render( )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/my-playlist' },
+ })
+ await runDebounce()
+
+ expect(mockFetchTrack).not.toHaveBeenCalled()
+ })
+
+ it('still resolves the playlist embed when fetchTrack throws for a track', async () => {
+ const playlistWithTracks = {
+ ...mockPlaylist,
+ tracks: [{ id: 'tr-e' }],
+ }
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks })
+ mockFetchTrack.mockRejectedValue(new Error('track fetch failed'))
+
+ const onEmbedsResolved = vi.fn()
+ const { container } = render(
+
+ )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/my-playlist' },
+ })
+ await runDebounce()
+
+ // The catch on line 162 swallows the error; playlist embed is still emitted
+ const lastCall =
+ onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0]
+ expect(lastCall).toHaveLength(1)
+ expect(lastCall[0].type).toBe('playlist')
+ })
+
+ it('resolves album type the same way as playlist type', async () => {
+ const playlistWithTracks = { ...mockPlaylist, tracks: [{ id: 'tr-f' }] }
+ mockResolvePermalink.mockResolvedValue({ data: { type: 'album', id: 'pl-1' } })
+ mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks })
+ mockFetchTrack.mockResolvedValue({ data: mockTrack })
+
+ const onEmbedsResolved = vi.fn()
+ const { container } = render(
+
+ )
+ fireEvent.change(getByTest(container, 'message-input'), {
+ target: { value: 'https://soundcloud.com/artist/sets/my-album' },
+ })
+ await runDebounce()
+
+ const lastCall =
+ onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0]
+ expect(lastCall[0].type).toBe('playlist')
+ expect(mockFetchTrack).toHaveBeenCalledWith('tr-f')
+ })
+ })
diff --git a/src/components/MessagingComponents/tests/MessageIdPage.test.tsx b/src/components/MessagingComponents/tests/MessageIdPage.test.tsx
deleted file mode 100644
index e8fada16..00000000
--- a/src/components/MessagingComponents/tests/MessageIdPage.test.tsx
+++ /dev/null
@@ -1,460 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { MemoryRouter } from "react-router-dom";
-import MessageIdPage from "@/pages/social/messages/[messageId]/MessageIdPage";
-import type { Conversation, Message } from "@/services/api/messaging/conversationApi";
-
-// ── Mock API ───────────────────────────────────────────────────────────────
-vi.mock("@/services/api/messaging/conversationApi", () => ({
- fetchConversations: vi.fn(),
- fetchConversation: vi.fn(),
- markMessageReadState: vi.fn(),
-}));
-
-vi.mock("@/services/api/messaging/socketService", () => ({
- joinConversation: vi.fn(),
- leaveConversation: vi.fn(),
- emitMessageRead: vi.fn(),
- getSocket: vi.fn(() => ({
- on: vi.fn(),
- off: vi.fn(),
- })),
-}));
-
-vi.mock("@/stores/messaging.store", () => {
- const useMessagingStoreMock = vi.fn(() => ({
- refreshUnreadCount: vi.fn(),
- }));
- (useMessagingStoreMock as any).setState = vi.fn();
- return { useMessagingStore: useMessagingStoreMock };
-});
-
-const { mockNavigate } = vi.hoisted(() => ({
- mockNavigate: vi.fn(),
-}));
-
-vi.mock("react-router-dom", async () => {
- const actual = await vi.importActual("react-router-dom");
- return { ...actual, useNavigate: () => mockNavigate };
-});
-
-// ── Mock child components ──────────────────────────────────────────────────
-// All elements use data-test — this project's testIdAttribute is 'data-test'.
-
-vi.mock("@/components/MessagingComponents/Chats", () => ({
- Chats: ({
- conversations,
- onSelect,
- activeConversationId,
- error,
- }: {
- conversations: Conversation[];
- loading: boolean;
- error: string | null;
- activeConversationId: string | null;
- onSelect: (c: Conversation) => void;
- }) => (
-
- {error &&
{error}
}
- {conversations.map((c) => (
-
onSelect(c)}
- >
- {c.participant.display_name}
-
- ))}
-
- ),
-}));
-
-vi.mock("@/components/MessagingComponents/MessagingHeader", () => ({
- default: () => Header
,
-}));
-
-vi.mock("@/components/MessagingComponents/ConversationHeader", () => ({
- default: ({
- recipientName,
- onReadStateChange,
- onDeleted,
- conversationId,
- }: {
- recipientName: string;
- onReadStateChange: (v: boolean) => void;
- onDeleted?: (id: string) => void;
- conversationId: string;
- reciepiantId: string;
- lastMessageId: string | null;
- }) => (
-
- {recipientName}
- onReadStateChange(true)}>
- Mark unread
-
- onDeleted?.(conversationId)}>
- Delete
-
-
- ),
-}));
-
-vi.mock("@/components/MessagingComponents/SendMessageForm", () => ({
- default: ({
- onMessageSent,
- }: {
- conversationId: string;
- existingMessages: Message[];
- loadingMessages: boolean;
- onMessageSent: (msg: unknown) => void;
- ParticipantInfo: { display_name: string; profile_picture?: string | null };
- }) => (
-
-
- onMessageSent({
- id: "new-msg",
- body: "New message",
- created_at: new Date().toISOString(),
- sender_id: "me",
- is_read: true,
- })
- }
- >
- Send
-
-
- ),
-}));
-
-import {
- fetchConversations,
- fetchConversation,
- markMessageReadState,
-} from "@/services/api/messaging/conversationApi";
-
-// ── Helpers ────────────────────────────────────────────────────────────────
-
-const makeConv = (id: string, participantId = `p-${id}`): Conversation => ({
- id,
- participant: {
- id: participantId,
- display_name: `User ${id}`,
- profile_picture: null,
- username: `user${id}`,
- },
- last_message: {
- id: `lm-${id}`,
- body: `Last from ${id}`,
- created_at: new Date().toISOString(),
- sender_id: participantId,
- is_read: true,
- },
- unread_count: 0,
- updated_at: new Date().toISOString(),
-} as unknown as Conversation);
-
-const makeMessage = (senderId: string, isRead = true): Message => ({
- id: `msg-${Math.random()}`,
- body: "Hello",
- created_at: new Date().toISOString(),
- sender_id: senderId,
- is_read: isRead,
-} as unknown as Message);
-
-const renderPage = () =>
- render(
-
-
-
- );
-
-describe("MessageIdPage", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- (markMessageReadState as ReturnType).mockResolvedValue({});
- (fetchConversation as ReturnType).mockResolvedValue({
- data: {
- messages: [],
- pagination: { total_items: 0 }
- },
- });
- });
-
- // ── Loading / initial render ───────────────────────────────────────────────
-
- it("renders the MessagingHeader", async () => {
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [] },
- });
- renderPage();
- expect(screen.getByTestId("messaging-header")).toBeInTheDocument();
- });
-
- it("renders the Chats panel", async () => {
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [] },
- });
- renderPage();
- expect(screen.getByTestId("chats")).toBeInTheDocument();
- });
-
- it("shows 'Select a conversation' when no conversations exist", async () => {
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [] },
- });
- renderPage();
- await waitFor(() =>
- expect(
- screen.getByText(/select a conversation to start messaging/i)
- ).toBeInTheDocument()
- );
- });
-
- // ── Auto-open first conversation ───────────────────────────────────────────
-
- it("auto-opens the first conversation after loading", async () => {
- const conv1 = makeConv("1", "p1");
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [conv1] },
- });
- (fetchConversation as ReturnType).mockResolvedValue({
- data: {
- messages: [],
- pagination: { total_items: 0 }
- },
- });
-
- renderPage();
- await waitFor(() =>
- expect(screen.getByTestId("conversation-header")).toBeInTheDocument()
- );
- expect(screen.getByText("User 1")).toBeInTheDocument();
- });
-
- it("navigates to participant URL when user selects a conversation", async () => {
- const conv1 = makeConv("1", "p1");
- const conv2 = makeConv("2", "p2");
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [conv1, conv2] },
- });
- (fetchConversation as ReturnType).mockResolvedValue({
- data: {
- messages: [],
- pagination: { total_items: 0 }
- },
- });
-
- renderPage();
- await waitFor(() => screen.getByTestId("conv-2"));
- await userEvent.click(screen.getByTestId("conv-2"));
-
- await waitFor(() =>
- expect(mockNavigate).toHaveBeenCalledWith("/messages/2")
- );
- });
-
- it("marks unread messages as read on conversation open", async () => {
- const conv1 = makeConv("1", "p1");
- const unreadMsg = makeMessage("p1", false);
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [conv1] },
- });
- (fetchConversation as ReturnType).mockResolvedValue({
- data: {
- messages: [unreadMsg],
- pagination: { total_items: 1 }
- },
- });
-
- renderPage();
- await waitFor(() =>
- expect(markMessageReadState).toHaveBeenCalledWith("1", unreadMsg.id, true)
- );
- });
-
- it("does not call markMessageReadState for already-read messages", async () => {
- const conv1 = makeConv("1", "p1");
- const readMsg = makeMessage("p1", true);
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [conv1] },
- });
- (fetchConversation as ReturnType).mockResolvedValue({
- data: {
- messages: [readMsg],
- pagination: { total_items: 1 }
- },
- });
-
- renderPage();
- await waitFor(() => screen.getByTestId("conversation-header"));
- expect(markMessageReadState).not.toHaveBeenCalled();
- });
-
- // ── Switching conversations ────────────────────────────────────────────────
-
- it("switches active conversation when another is clicked", async () => {
- const conv1 = makeConv("1", "p1");
- const conv2 = makeConv("2", "p2");
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [conv1, conv2] },
- });
- (fetchConversation as ReturnType).mockResolvedValue({
- data: {
- messages: [],
- pagination: { total_items: 0 }
- },
- });
-
- renderPage();
- await waitFor(() => screen.getByTestId("conv-1"));
- await userEvent.click(screen.getByTestId("conv-2"));
-
- await waitFor(() =>
- expect(mockNavigate).toHaveBeenCalledWith("/messages/2")
- );
- expect(screen.getByText("User 2")).toBeInTheDocument();
- });
-
- // ── Message sent ───────────────────────────────────────────────────────────
-
- it("renders SendMessageForm when conversation is active", async () => {
- const conv1 = makeConv("1", "p1");
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [conv1] },
- });
- (fetchConversation as ReturnType).mockResolvedValue({
- data: {
- messages: [],
- pagination: { total_items: 0 }
- },
- });
-
- renderPage();
- await waitFor(() =>
- expect(screen.getByTestId("send-message-form")).toBeInTheDocument()
- );
- });
-
- it("updates conversation list when a message is sent", async () => {
- const conv1 = makeConv("1", "p1");
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [conv1] },
- });
- (fetchConversation as ReturnType).mockResolvedValue({
- data: {
- messages: [],
- pagination: { total_items: 0 }
- },
- });
-
- renderPage();
- await waitFor(() => screen.getByTestId("send-msg"));
- await userEvent.click(screen.getByTestId("send-msg"));
- });
-
- // ── Conversation deleted ───────────────────────────────────────────────────
-
- it("removes deleted conversation and auto-selects next", async () => {
- const conv1 = makeConv("1", "p1");
- const conv2 = makeConv("2", "p2");
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [conv1, conv2] },
- });
- (fetchConversation as ReturnType).mockResolvedValue({
- data: {
- messages: [],
- pagination: { total_items: 0 }
- },
- });
-
- renderPage();
- await waitFor(() => screen.getByTestId("delete-conv"));
- await userEvent.click(screen.getByTestId("delete-conv"));
-
- await waitFor(() =>
- expect(mockNavigate).toHaveBeenCalledWith("/messages/2")
- );
- });
-
- it("navigates to /messages when last conversation is deleted", async () => {
- const conv1 = makeConv("1", "p1");
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [conv1] },
- });
- (fetchConversation as ReturnType).mockResolvedValue({
- data: {
- messages: [],
- pagination: { total_items: 0 }
- },
- });
-
- renderPage();
- await waitFor(() => screen.getByTestId("delete-conv"));
- await userEvent.click(screen.getByTestId("delete-conv"));
-
- await waitFor(() =>
- expect(mockNavigate).toHaveBeenCalledWith("/messages")
- );
- });
-
- // ── Read state toggle ──────────────────────────────────────────────────────
-
- it("updates unread_count when read state changes to unread", async () => {
- const conv1 = makeConv("1", "p1");
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [conv1] },
- });
- (fetchConversation as ReturnType).mockResolvedValue({
- data: {
- messages: [],
- pagination: { total_items: 0 }
- },
- });
-
- renderPage();
- await waitFor(() => screen.getByTestId("mark-unread"));
- await userEvent.click(screen.getByTestId("mark-unread"));
- });
-
- // ── Error handling ─────────────────────────────────────────────────────────
-
- it("shows error message when fetchConversations fails", async () => {
- (fetchConversations as ReturnType).mockRejectedValue(
- new Error("Network error")
- );
- renderPage();
- await waitFor(() =>
- expect(
- screen.getByText(/could not load conversations/i)
- ).toBeInTheDocument()
- );
- });
-
- it("shows error message when fetchConversation fails", async () => {
- const conv1 = makeConv("1", "p1");
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [conv1] },
- });
- (fetchConversation as ReturnType).mockRejectedValue(
- new Error("Not found")
- );
- renderPage();
- await waitFor(() =>
- expect(screen.getByText(/could not load messages/i)).toBeInTheDocument()
- );
- });
-
- // ── data-test attribute ────────────────────────────────────────────────────
-
- it("renders the page with correct data-test", () => {
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [] },
- });
- renderPage();
- expect(screen.getByTestId("message-id-page")).toBeInTheDocument();
- });
-});
\ No newline at end of file
diff --git a/src/components/MessagingComponents/tests/MessagesPage.test.tsx b/src/components/MessagingComponents/tests/MessagesPage.test.tsx
index 54e030db..67b1cd8c 100644
--- a/src/components/MessagingComponents/tests/MessagesPage.test.tsx
+++ b/src/components/MessagingComponents/tests/MessagesPage.test.tsx
@@ -1,137 +1,207 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, waitFor } from "@testing-library/react";
-import { MemoryRouter } from "react-router-dom";
-import MessagesPage from "@/pages/social/messages/MessagesPage";
+import { render, screen, waitFor, act } from "@testing-library/react"
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { MemoryRouter } from "react-router-dom"
+import MessagesPage from "@/pages/social/messages/MessagesPage"
+import * as conversationApi from "../../../services/api/messaging/conversationApi"
-// Use alias paths so vitest intercepts the same module instance the component loads.
-// Relative paths from the test file ("/tests/") don't match what the component
-// imports from its own directory, so the real module loads instead of the mock.
+// ─── Module mocks ─────────────────────────────────────────────────────────────
-vi.mock("@/services/api/messaging/conversationApi", () => ({
+vi.mock("../../../services/api/messaging/conversationApi", () => ({
fetchConversations: vi.fn(),
-}));
+}))
-vi.mock("@/components/UI/Spinner", () => ({
- // data-test not data-testid — project testIdAttribute is 'data-test'
- default: () => Loading...
,
-}));
+vi.mock("../../../components/UI/Spinner", () => ({
+ default: (props: Record) => (
+ Loading...
+ ),
+}))
-vi.mock("@/pages/social/messages/emptyMessagesPage", () => ({
- // data-test not data-testid
- default: () => Empty Page
,
-}));
+vi.mock("./emptyMessagesPage", () => ({
+ default: () => No messages
,
+}))
-// Stable navigate reference declared before vi.mock so the hoisted factory
-// captures the real function, not undefined.
-const mockNavigate = vi.fn();
+const mockNavigate = vi.fn()
vi.mock("react-router-dom", async () => {
- const actual = await vi.importActual("react-router-dom");
- return { ...actual, useNavigate: () => mockNavigate };
-});
-
-import { fetchConversations } from "@/services/api/messaging/conversationApi";
-
-const renderPage = () =>
- render(
+ const actual = await vi.importActual("react-router-dom")
+ return { ...actual, useNavigate: () => mockNavigate }
+})
+
+// Mock axios module so isAxiosError is fully controllable per-test
+const mockIsAxiosError = vi.fn((_err: unknown): boolean => false)
+vi.mock("axios", () => ({
+ default: { isAxiosError: (err: unknown) => mockIsAxiosError(err) },
+ isAxiosError: (err: unknown) => mockIsAxiosError(err),
+}))
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+const mockFetchConversations = vi.mocked(conversationApi.fetchConversations)
+
+const makeConversation = (id: string) => ({
+ id,
+ created_at: "2024-01-01T00:00:00Z",
+ last_message: null,
+ participants: [],
+})
+
+function renderPage() {
+ return render(
- );
+ )
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsAxiosError.mockImplementation(() => false)
+})
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
describe("MessagesPage", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
// ── Loading state ──────────────────────────────────────────────────────────
it("shows spinner while loading", () => {
- (fetchConversations as ReturnType).mockImplementation(
- () => new Promise(() => {})
- );
- renderPage();
- expect(screen.getByTestId("spinner")).toBeInTheDocument();
- });
-
- // ── Empty state ────────────────────────────────────────────────────────────
-
- it("renders EmptyMessagesPage when no conversations", async () => {
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [] },
- });
- renderPage();
+ mockFetchConversations.mockReturnValueOnce(new Promise(() => {}))
+ renderPage()
+ expect(screen.getByTestId("messages-loading")).toBeInTheDocument()
+ })
+
+ it("does not render EmptyMessagesPage or error while loading", () => {
+ mockFetchConversations.mockReturnValueOnce(new Promise(() => {}))
+ renderPage()
+ expect(screen.queryByTestId("empty-messages-page")).not.toBeInTheDocument()
+ expect(screen.queryByTestId("messages-error")).not.toBeInTheDocument()
+ })
+
+ // ── Success: empty ─────────────────────────────────────────────────────────
+
+ it("renders EmptyMessagesPage when there are no conversations", async () => {
+ mockFetchConversations.mockResolvedValueOnce({ data: { items: [] } } as any)
+ renderPage()
+ await waitFor(() =>
+ expect(screen.getByTestId("empty-messages-page")).toBeInTheDocument()
+ )
+ })
+
+ it("does not navigate when conversations list is empty", async () => {
+ mockFetchConversations.mockResolvedValueOnce({ data: { items: [] } } as any)
+ renderPage()
await waitFor(() =>
- expect(screen.getByTestId("empty-page")).toBeInTheDocument()
- );
- });
-
- it("does not navigate when no conversations", async () => {
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [] },
- });
- renderPage();
- await waitFor(() => screen.getByTestId("empty-page"));
- expect(mockNavigate).not.toHaveBeenCalled();
- });
-
- // ── Redirect ───────────────────────────────────────────────────────────────
-
- it("navigates to the first conversation when conversations exist", async () => {
- (fetchConversations as ReturnType).mockResolvedValue({
- data: {
- items: [
- { id: "conv-1", participant: { id: "user-1" } },
- { id: "conv-2", participant: { id: "user-2" } },
- ],
- },
- });
- renderPage();
+ expect(screen.getByTestId("empty-messages-page")).toBeInTheDocument()
+ )
+ expect(mockNavigate).not.toHaveBeenCalled()
+ })
+
+ // ── Success: with conversations ────────────────────────────────────────────
+
+ it("redirects to conversations[0] with replace:true", async () => {
+ mockFetchConversations.mockResolvedValueOnce({
+ data: { items: [makeConversation("conv-1"), makeConversation("conv-2")] },
+ } as any)
+ renderPage()
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith("/messages/conv-1", { replace: true })
+ )
+ })
+
+ it("calls fetchConversations with page 1 and limit 20", async () => {
+ mockFetchConversations.mockResolvedValueOnce({ data: { items: [] } } as any)
+ renderPage()
+ await waitFor(() =>
+ expect(mockFetchConversations).toHaveBeenCalledWith(1, 20)
+ )
+ })
+
+ // ── Error: axios with response message ────────────────────────────────────
+
+ it("shows the response message from an axios error", async () => {
+ mockIsAxiosError.mockImplementation(() => true)
+ mockFetchConversations.mockRejectedValueOnce({
+ response: { data: { message: "Unauthorized access" } },
+ })
+ renderPage()
+ await waitFor(() =>
+ expect(screen.getByTestId("messages-error")).toHaveTextContent("Unauthorized access")
+ )
+ })
+
+ // ── Error: axios with no message field ────────────────────────────────────
+
+ it("falls back to default message when axios error has no response message", async () => {
+ mockIsAxiosError.mockImplementation(() => true)
+ mockFetchConversations.mockRejectedValueOnce({ response: { data: {} } })
+ renderPage()
await waitFor(() =>
- expect(mockNavigate).toHaveBeenCalledWith("/messages/conv-1", {
- replace: true,
- })
- );
- });
-
- // ── Error state ────────────────────────────────────────────────────────────
-
- it("shows error message when fetch fails with axios error", async () => {
- const axiosError = {
- isAxiosError: true,
- response: { data: { message: "Unauthorized" } },
- };
- vi.doMock("axios", () => ({
- default: { isAxiosError: (e: unknown) => e === axiosError },
- isAxiosError: (e: unknown) => e === axiosError,
- }));
-
- (fetchConversations as ReturnType).mockRejectedValue(
- axiosError
- );
- renderPage();
+ expect(screen.getByTestId("messages-error")).toHaveTextContent(
+ "Failed to load conversations."
+ )
+ )
+ })
+
+ // ── Error: axios with no response at all ──────────────────────────────────
+
+ it("falls back to default message when axios error has no response object", async () => {
+ mockIsAxiosError.mockImplementation(() => true)
+ mockFetchConversations.mockRejectedValueOnce({})
+ renderPage()
await waitFor(() =>
- expect(screen.getByText(/unauthorized/i)).toBeInTheDocument()
- );
- });
-
- it("shows generic error when fetch fails with non-axios error", async () => {
- (fetchConversations as ReturnType).mockRejectedValue(
- new Error("Network error")
- );
- renderPage();
+ expect(screen.getByTestId("messages-error")).toHaveTextContent(
+ "Failed to load conversations."
+ )
+ )
+ })
+
+ // ── Error: non-axios ──────────────────────────────────────────────────────
+
+ it("shows generic error for non-axios errors", async () => {
+ mockIsAxiosError.mockImplementation(() => false)
+ mockFetchConversations.mockRejectedValueOnce(new Error("Network failure"))
+ renderPage()
await waitFor(() =>
- expect(
- screen.getByText(/an unexpected error occurred/i)
- ).toBeInTheDocument()
- );
- });
-
- it("calls fetchConversations with page and limit", async () => {
- (fetchConversations as ReturnType).mockResolvedValue({
- data: { items: [] },
- });
- renderPage();
- await waitFor(() => screen.getByTestId("empty-page"));
- expect(fetchConversations).toHaveBeenCalledWith(1, 20);
- });
-});
\ No newline at end of file
+ expect(screen.getByTestId("messages-error")).toHaveTextContent(
+ "An unexpected error occurred."
+ )
+ )
+ })
+
+ // ── Cancellation: unmount before resolve ──────────────────────────────────
+
+ it("does not set state or navigate when component unmounts before fetch resolves", async () => {
+ let resolvePromise!: (v: any) => void
+ mockFetchConversations.mockReturnValueOnce(
+ new Promise((res) => { resolvePromise = res })
+ )
+
+ const { unmount } = renderPage()
+
+ // Unmount triggers cancelled = true in the cleanup function
+ unmount()
+
+ // Resolve after unmount — the cancelled guard should prevent all state updates
+ await act(async () => {
+ resolvePromise({ data: { items: [makeConversation("conv-unmounted")] } })
+ })
+
+ expect(mockNavigate).not.toHaveBeenCalled()
+ })
+
+ it("does not set error state when component unmounts before fetch rejects", async () => {
+ let rejectPromise!: (reason?: any) => void
+ mockFetchConversations.mockReturnValueOnce(
+ new Promise((_, rej) => { rejectPromise = rej })
+ )
+
+ const { unmount } = renderPage()
+ unmount()
+
+ // Reject after unmount — cancelled guard should swallow the branch
+ await act(async () => {
+ rejectPromise(new Error("late error"))
+ })
+
+ expect(screen.queryByTestId("messages-error")).not.toBeInTheDocument()
+ })
+})
\ No newline at end of file
diff --git a/src/components/MessagingComponents/tests/MessagingHeader.test.tsx b/src/components/MessagingComponents/tests/MessagingHeader.test.tsx
index bbe52556..cfcf7d28 100644
--- a/src/components/MessagingComponents/tests/MessagingHeader.test.tsx
+++ b/src/components/MessagingComponents/tests/MessagingHeader.test.tsx
@@ -1,77 +1,86 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen, configure } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { MemoryRouter } from "react-router-dom";
-import MessagingHeader from "../MessagingHeader";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
-configure({ testIdAttribute: "data-testid" });
-
-vi.mock("@/components/MessagingComponents/Modal", () => ({
- Modal: ({ isOpen, children, onClose }: any) =>
+vi.mock('@/components/MessagingComponents/Modal', () => ({
+ Modal: ({ isOpen, onClose, children }: { isOpen: boolean; onClose: () => void; children: React.ReactNode }) =>
isOpen ? (
-
+
+ Close
{children}
- Close modal
) : null,
-}));
+}))
-vi.mock("@/pages/social/messages/ModalNewMessageBody", () => ({
- default: ({ onClose }: any) => (
-
-
Close body
+vi.mock('@/pages/social/messages/ModalNewMessageBody', () => ({
+ default: ({ onClose }: { onClose: () => void }) => (
+
+ Close Modal
),
-}));
+}))
+
+import MessagingHeader from '../MessagingHeader'
+
+describe('MessagingHeader', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('rendering', () => {
+ it('renders the messaging-header container', () => {
+ render(
)
+ expect(screen.getByTestId('messaging-header')).toBeInTheDocument()
+ })
-const renderHeader = () =>
- render(
-
-
-
- );
+ it('renders the Messages title', () => {
+ render(
)
+ expect(screen.getByText('Messages')).toBeInTheDocument()
+ })
-describe("MessagingHeader", () => {
- it("renders the Messages heading", () => {
- renderHeader();
- expect(
- screen.getByRole("heading", { name: /messages/i })
- ).toBeInTheDocument();
- });
+ it('renders the New button', () => {
+ render(
)
+ expect(screen.getByTestId('new-message-button')).toBeInTheDocument()
+ })
- it("renders the New button", () => {
- renderHeader();
- expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument();
- });
+ it('does not show modal initially', () => {
+ render(
)
+ expect(screen.queryByTestId('modal-backdrop')).not.toBeInTheDocument()
+ })
+ })
- it("does not show modal initially", () => {
- renderHeader();
- expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
- });
+ describe('interactions', () => {
+ it('opens the modal when New button is clicked', async () => {
+ render(
)
+ await userEvent.click(screen.getByTestId('new-message-button'))
+ expect(screen.getByTestId('modal-backdrop')).toBeInTheDocument()
+ })
- it("opens the modal when New is clicked", async () => {
- renderHeader();
- await userEvent.click(screen.getByRole("button", { name: /new/i }));
- expect(await screen.findByTestId("modal")).toBeInTheDocument();
- });
+ it('renders ModalNewMessageBody inside the modal', async () => {
+ render(
)
+ await userEvent.click(screen.getByTestId('new-message-button'))
+ expect(screen.getByTestId('modal-new-message-body')).toBeInTheDocument()
+ })
- it("renders ModalNewMessageBody inside the modal", async () => {
- renderHeader();
- await userEvent.click(screen.getByRole("button", { name: /new/i }));
- expect(await screen.findByTestId("new-message-body")).toBeInTheDocument();
- });
+ it('closes the modal when modal close button is clicked', async () => {
+ render(
)
+ await userEvent.click(screen.getByTestId('new-message-button'))
+ expect(screen.getByTestId('modal-backdrop')).toBeInTheDocument()
+ await userEvent.click(screen.getByTestId('modal-close-button'))
+ expect(screen.queryByTestId('modal-backdrop')).not.toBeInTheDocument()
+ })
- it("closes the modal when onClose is called from Modal", async () => {
- renderHeader();
- await userEvent.click(screen.getByRole("button", { name: /new/i }));
- await userEvent.click(await screen.findByText("Close modal"));
- expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
- });
+ it('closes the modal when onClose is called from ModalNewMessageBody', async () => {
+ render(
)
+ await userEvent.click(screen.getByTestId('new-message-button'))
+ await userEvent.click(screen.getByText('Close Modal'))
+ expect(screen.queryByTestId('modal-backdrop')).not.toBeInTheDocument()
+ })
- it("closes the modal when onClose is called from ModalNewMessageBody", async () => {
- renderHeader();
- await userEvent.click(screen.getByRole("button", { name: /new/i }));
- await userEvent.click(await screen.findByText("Close body"));
- expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
- });
-});
\ No newline at end of file
+ it('accepts optional onConversationCreated prop without crashing', () => {
+ const onCreated = vi.fn()
+ render(
)
+ expect(screen.getByTestId('messaging-header')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/components/MessagingComponents/tests/MiniPlayer.test.tsx b/src/components/MessagingComponents/tests/MiniPlayer.test.tsx
new file mode 100644
index 00000000..9cf2a502
--- /dev/null
+++ b/src/components/MessagingComponents/tests/MiniPlayer.test.tsx
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+vi.mock('@/components/UI/CoverImage', () => ({
+ default: ({ src, alt, className }: { src: string | null; alt: string; className?: string }) => (
+
+ ),
+}))
+
+import MiniPlayer from '../MiniPlayer'
+
+describe('MiniPlayer', () => {
+ const defaultProps = {
+ coverImage: 'https://example.com/cover.jpg',
+ trackName: 'My Track',
+ artistName: 'My Artist',
+ onClose: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('rendering', () => {
+ it('renders the mini-player container', () => {
+ render(
)
+ expect(screen.getByTestId('mini-player')).toBeInTheDocument()
+ })
+
+ it('renders the track name', () => {
+ render(
)
+ expect(screen.getByText('Cool Song')).toBeInTheDocument()
+ })
+
+ it('renders the artist name', () => {
+ render(
)
+ expect(screen.getByText('Cool Artist')).toBeInTheDocument()
+ })
+
+ it('renders the close button', () => {
+ render(
)
+ expect(screen.getByTestId('mini-player-close')).toBeInTheDocument()
+ })
+
+ it('renders CoverImage with correct src', () => {
+ render(
)
+ expect(screen.getByTestId('cover-image')).toHaveAttribute('src', 'https://img.com/art.jpg')
+ })
+
+ it('renders CoverImage without crashing when coverImage is null', () => {
+ render(
)
+ expect(screen.getByTestId('cover-image')).toBeInTheDocument()
+ })
+
+ it('renders CoverImage with correct alt text', () => {
+ render(
)
+ expect(screen.getByTestId('cover-image')).toHaveAttribute('alt', 'My Song')
+ })
+
+ it('renders the dot separator between track name and artist', () => {
+ render(
)
+ expect(screen.getByText('·')).toBeInTheDocument()
+ })
+ })
+
+ describe('interactions', () => {
+ it('calls onClose when close button is clicked', async () => {
+ const onClose = vi.fn()
+ render(
)
+ await userEvent.click(screen.getByTestId('mini-player-close'))
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('calls onClose exactly once per click', async () => {
+ const onClose = vi.fn()
+ render(
)
+ await userEvent.click(screen.getByTestId('mini-player-close'))
+ await userEvent.click(screen.getByTestId('mini-player-close'))
+ expect(onClose).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ describe('edge cases', () => {
+ it('renders with empty strings for track and artist names', () => {
+ render(
)
+ expect(screen.getByTestId('mini-player')).toBeInTheDocument()
+ })
+
+ it('renders long track names without crashing', () => {
+ render(
)
+ expect(screen.getByTestId('mini-player')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/components/MessagingComponents/tests/Modal.test.tsx b/src/components/MessagingComponents/tests/Modal.test.tsx
index fdc67201..0dc86572 100644
--- a/src/components/MessagingComponents/tests/Modal.test.tsx
+++ b/src/components/MessagingComponents/tests/Modal.test.tsx
@@ -1,115 +1,111 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { render, screen, fireEvent, act } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { Modal } from "../Modal";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Modal } from '../Modal'
-const renderModal = (props: Partial
> = {}) =>
- render(
-
- Modal content
-
- );
-
-describe("Modal", () => {
+describe('Modal', () => {
beforeEach(() => {
- vi.clearAllMocks();
- });
-
- // ── Rendering ──────────────────────────────────────────────────────────────
-
- it("renders the backdrop when isOpen is true", () => {
- renderModal();
- expect(screen.getByTestId("modal-backdrop")).toBeInTheDocument();
- });
-
- it("does not render when isOpen is false", () => {
- renderModal({ isOpen: false });
- expect(screen.queryByTestId("modal-backdrop")).not.toBeInTheDocument();
- });
-
- it("renders children inside the modal", () => {
- renderModal();
- expect(screen.getByText("Modal content")).toBeInTheDocument();
- });
-
- it("renders the close button", () => {
- renderModal();
- expect(screen.getByTestId("modal-close-button")).toBeInTheDocument();
- });
-
- // ── Closing behaviour ──────────────────────────────────────────────────────
-
- it("calls onClose when the close button is clicked", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- renderModal({ onClose });
- await user.click(screen.getByTestId("modal-close-button"));
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("calls onClose when the backdrop is clicked", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- renderModal({ onClose });
- await user.click(screen.getByTestId("modal-backdrop"));
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("does not call onClose when the modal content area is clicked", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- renderModal({ onClose });
- await user.click(screen.getByText("Modal content"));
- expect(onClose).not.toHaveBeenCalled();
- });
-
- // ── Escape key ─────────────────────────────────────────────────────────────
-
- it("calls onClose when Escape is pressed and modal is open", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- renderModal({ isOpen: true, onClose });
- await user.keyboard("{Escape}");
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("does not call onClose when Escape is pressed and modal is closed", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- renderModal({ isOpen: false, onClose });
- await user.keyboard("{Escape}");
- expect(onClose).not.toHaveBeenCalled();
- });
-
- it("does not call onClose when a non-Escape key is pressed", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- renderModal({ isOpen: true, onClose });
- await user.keyboard("{Enter}");
- expect(onClose).not.toHaveBeenCalled();
- });
-
- // ── Event listener lifecycle ───────────────────────────────────────────────
-
- it("removes the keydown listener when modal closes", () => {
- const removeEventListener = vi.spyOn(document, "removeEventListener");
- const { rerender } = renderModal({ isOpen: true });
- rerender(
-
- Modal content
-
- );
- expect(removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
- });
-
- it("removes the keydown listener on unmount", () => {
- const removeEventListener = vi.spyOn(document, "removeEventListener");
- const { unmount } = renderModal({ isOpen: true });
- unmount();
- expect(removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
- });
-});
\ No newline at end of file
+ vi.clearAllMocks()
+ })
+
+ describe('rendering', () => {
+ it('renders nothing when isOpen is false', () => {
+ render(Content
)
+ expect(screen.queryByTestId('modal-backdrop')).not.toBeInTheDocument()
+ })
+
+ it('renders the backdrop when isOpen is true', () => {
+ render(Content
)
+ expect(screen.getByTestId('modal-backdrop')).toBeInTheDocument()
+ })
+
+ it('renders children when open', () => {
+ render(Hello
)
+ expect(screen.getByTestId('child')).toBeInTheDocument()
+ })
+
+ it('renders the close button when open', () => {
+ render(Content
)
+ expect(screen.getByTestId('modal-close-button')).toBeInTheDocument()
+ })
+
+ it('renders into document.body via portal', () => {
+ render(Content
)
+ expect(document.body.contains(screen.getByTestId('modal-backdrop'))).toBe(true)
+ })
+ })
+
+ describe('interactions', () => {
+ it('calls onClose when close button is clicked', async () => {
+ const onClose = vi.fn()
+ render(Content
)
+ await userEvent.click(screen.getByTestId('modal-close-button'))
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('calls onClose when Escape key is pressed', () => {
+ const onClose = vi.fn()
+ render(Content
)
+ fireEvent.keyDown(document, { key: 'Escape' })
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not call onClose on other key presses', () => {
+ const onClose = vi.fn()
+ render(Content
)
+ fireEvent.keyDown(document, { key: 'Enter' })
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
+ it('does not attach keydown listener when isOpen is false', () => {
+ const onClose = vi.fn()
+ render(Content
)
+ fireEvent.keyDown(document, { key: 'Escape' })
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
+ it('removes keydown listener when closed', () => {
+ const onClose = vi.fn()
+ const { rerender } = render(Content
)
+ rerender(Content
)
+ fireEvent.keyDown(document, { key: 'Escape' })
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
+ it('backdrop click stops propagation (does not bubble to parent)', () => {
+ const outerClick = vi.fn()
+ render(
+
+ )
+ fireEvent.click(screen.getByTestId('modal-backdrop'))
+ expect(outerClick).not.toHaveBeenCalled()
+ })
+
+ it('clicking the backdrop does not close the modal', () => {
+ const onClose = vi.fn()
+ render(Content
)
+ fireEvent.click(screen.getByTestId('modal-backdrop'))
+ expect(onClose).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('lifecycle', () => {
+ it('re-renders correctly when isOpen changes from false to true', () => {
+ const onClose = vi.fn()
+ const { rerender } = render(Content
)
+ expect(screen.queryByTestId('modal-backdrop')).not.toBeInTheDocument()
+ rerender(Content
)
+ expect(screen.getByTestId('modal-backdrop')).toBeInTheDocument()
+ })
+
+ it('re-renders correctly when isOpen changes from true to false', () => {
+ const onClose = vi.fn()
+ const { rerender } = render(Content
)
+ expect(screen.getByTestId('modal-backdrop')).toBeInTheDocument()
+ rerender(Content
)
+ expect(screen.queryByTestId('modal-backdrop')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/components/MessagingComponents/tests/ModalNewMessageBody.test.tsx b/src/components/MessagingComponents/tests/ModalNewMessageBody.test.tsx
index d1152c2e..4e71af58 100644
--- a/src/components/MessagingComponents/tests/ModalNewMessageBody.test.tsx
+++ b/src/components/MessagingComponents/tests/ModalNewMessageBody.test.tsx
@@ -1,270 +1,862 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { MemoryRouter } from "react-router-dom";
-import ModalNewMessageBody from "@/pages/social/messages/ModalNewMessageBody";
-
-// ── Module mocks ───────────────────────────────────────────────────────────────
-//
-// NOTE: this project sets testIdAttribute: "data-test" in its Testing Library
-// config, so getByTestId() queries for data-test (not the default data-testid).
-// All attributes in the mocks below use data-test to match that convention.
-
-vi.mock("@/services/api/messaging/conversationApi", () => ({
- startConversation: vi.fn(),
- sendMessage: vi.fn(),
-}));
-
-vi.mock("@/components/MessagingComponents/MessageBox", () => ({
- MessageBox: ({
- onValueChange,
- onSubmit,
- hasError,
- }: {
- onValueChange: (v: string) => void;
- onIsEmptyChange: (empty: boolean) => void;
- onEmbedsResolved: (embeds: unknown[]) => void;
- onSubmit?: () => void;
- hasError?: boolean;
- }) => (
-
- ),
-}));
-
-vi.mock("@/components/MessagingComponents/RecipientInputBox", () => ({
- RecipientInputBox: ({
- onSelect,
- onClear,
- error,
- }: {
- onSelect: (user: { id: string; display_name: string }) => void;
- onClear: () => void;
- error?: string | null;
- }) => (
-
-
onSelect({ id: "user-1", display_name: "Alice" })}
- >
- Select Alice
-
-
- Clear
-
- {error &&
{error}
}
-
- ),
-}));
-
-const mockNavigate = vi.fn();
-vi.mock("react-router-dom", async () => {
- const actual = await vi.importActual("react-router-dom");
- return { ...actual, useNavigate: () => mockNavigate };
-});
-
-import { startConversation } from "@/services/api/messaging/conversationApi";
-
-// ── Helpers ────────────────────────────────────────────────────────────────────
-
-const renderModal = (onClose = vi.fn()) =>
- render(
-
-
-
- );
-
-// Query by role so we're decoupled from whichever data attribute the Send
-// button uses, and to avoid ambiguity during the "Sending…" state change.
-const getSendButton = () => screen.getByRole("button", { name: /^send$/i });
-
-describe("ModalNewMessageBody", () => {
+ )
+ },
+}))
+
+// ── RecipientInputBox mock ───────────────────────────────────────────────────
+
+let latestRecipientProps: {
+ onSelect: (user: { id: string; username: string; display_name: string }) => void
+ onClear: () => void
+ error: string | null
+} | null = null
+
+vi.mock('@/components/MessagingComponents/RecipientInputBox', () => ({
+ RecipientInputBox: (props: typeof latestRecipientProps) => {
+ latestRecipientProps = props as typeof latestRecipientProps
+ return (
+
+ )
+ },
+}))
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+const mockOnClose = vi.fn()
+const mockOnConversationCreated = vi.fn()
+
+const defaultProps = {
+ onClose: mockOnClose,
+ onConversationCreated: mockOnConversationCreated,
+}
+
+const RECIPIENT: RecipientResult = {
+ id: 'user-1',
+ username: 'johndoe',
+ display_name: 'John Doe',
+ profile_picture: null,
+}
+
+/** Build a well-formed API success response */
+function makeResponse(overrides: {
+ conversation?: { id: string } | null
+ conversation_id?: string
+ message?: { id: string; conversation_id?: string; conversationId?: string } | null
+} = {}) {
+ return {
+ data: {
+ conversation: { id: 'conv-123' },
+ conversation_id: 'conv-123',
+ message: { id: 'msg-1', conversation_id: 'conv-123' },
+ ...overrides,
+ },
+ }
+}
+
+function renderComponent(
+ props: Partial & { prefilledRecipient?: RecipientResult } = {}
+) {
+ latestMessageBoxProps = null
+ latestRecipientProps = null
+ return render( )
+}
+
+/** Simulate the user selecting a recipient via the RecipientInputBox callback */
+function selectRecipient(user = RECIPIENT) {
+ act(() => {
+ latestRecipientProps!.onSelect(user)
+ })
+}
+
+/** Simulate typing into MessageBox */
+function typeMessage(text: string) {
+ act(() => {
+ latestMessageBoxProps!.onValueChange(text)
+ })
+}
+
+/**
+ * Simulate embeds being resolved in MessageBox.
+ * Must be awaited so the setEmbeds state update is flushed before clickSend()
+ * fires handleSend — otherwise embeds.length is still 0 and lines 70–87 are
+ * never entered.
+ */
+async function resolveEmbeds(embeds: { type: string; id: string }[]) {
+ await act(async () => {
+ latestMessageBoxProps!.onEmbedsResolved(embeds)
+ })
+}
+
+/** Simulate the MessageBox firing onIsEmptyChange */
+function fireIsEmptyChange(empty: boolean) {
+ act(() => {
+ latestMessageBoxProps!.onIsEmptyChange(empty)
+ })
+}
+
+function clickSend() {
+ const btn = document.querySelector('[data-test="send-message-button"]') as HTMLElement
+ fireEvent.click(btn)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('ModalNewMessageBody', () => {
beforeEach(() => {
- vi.clearAllMocks();
- (startConversation as ReturnType).mockResolvedValue({
- data: {
- conversation: { id: "conv-1" },
- message: { id: "msg-1", conversation_id: "conv-1" },
- },
- });
- });
-
- // ── Rendering ────────────────────────────────────────────────────────────────
-
- it("renders the heading 'New message'", () => {
- renderModal();
- expect(screen.getByText(/new message/i)).toBeInTheDocument();
- });
-
- it("renders 'To' label", () => {
- renderModal();
- expect(screen.getByText(/^To/)).toBeInTheDocument();
- });
-
- it("renders 'Write your message' label", () => {
- renderModal();
- expect(
- screen.getByText(/write your message and add tracks or playlists/i)
- ).toBeInTheDocument();
- });
-
- it("renders the RecipientInputBox", () => {
- renderModal();
- expect(screen.getByTestId("recipient-input-box")).toBeInTheDocument();
- });
-
- it("renders the MessageBox", () => {
- renderModal();
- expect(screen.getByTestId("message-box-input")).toBeInTheDocument();
- });
-
- it("renders Send button", () => {
- renderModal();
- expect(getSendButton()).toBeInTheDocument();
- });
-
- // ── Validation ───────────────────────────────────────────────────────────────
-
- it("shows recipient error when Send is clicked without recipient", async () => {
- renderModal();
- await userEvent.type(screen.getByTestId("message-box-input"), "Hello");
- await userEvent.click(getSendButton());
- expect(screen.getByTestId("recipient-error")).toHaveTextContent(
- "Enter a recipient."
- );
- });
-
- it("shows message error when Send is clicked without message", async () => {
- renderModal();
- await userEvent.click(screen.getByTestId("select-user"));
- await userEvent.click(getSendButton());
- expect(screen.getByText(/enter a message/i)).toBeInTheDocument();
- });
-
- it("shows both errors when neither recipient nor message is provided", async () => {
- renderModal();
- await userEvent.click(getSendButton());
- expect(screen.getByTestId("recipient-error")).toBeInTheDocument();
- expect(screen.getByText(/enter a message/i)).toBeInTheDocument();
- });
-
- // ── Success flow ─────────────────────────────────────────────────────────────
-
- it("calls startConversation with correct payload on valid send", async () => {
- renderModal();
- await userEvent.click(screen.getByTestId("select-user"));
- await userEvent.type(screen.getByTestId("message-box-input"), "Hello Alice");
- await userEvent.click(getSendButton());
- await waitFor(() =>
- expect(startConversation).toHaveBeenCalledWith(
- expect.objectContaining({
- recipient_id: "user-1",
- body: "Hello Alice",
+ vi.clearAllMocks()
+ mockStartConversation.mockResolvedValue(makeResponse())
+ mockSendMessage.mockResolvedValue({})
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ // ── Initial rendering ────────────────────────────────────────────────────
+
+ describe('initial rendering', () => {
+ it('renders the heading', () => {
+ renderComponent()
+ expect(screen.getByText('New message')).toBeInTheDocument()
+ })
+
+ it('renders the To label', () => {
+ renderComponent()
+ expect(screen.getByText('To')).toBeInTheDocument()
+ })
+
+ it('renders RecipientInputBox when no prefilledRecipient', () => {
+ renderComponent()
+ expect(screen.getByTestId('recipient-input-box')).toBeInTheDocument()
+ })
+
+ it('renders MessageBox', () => {
+ renderComponent()
+ expect(screen.getByTestId('message-box')).toBeInTheDocument()
+ })
+
+ it('renders the Send button', () => {
+ renderComponent()
+ expect(document.querySelector('[data-test="send-message-button"]')).toBeInTheDocument()
+ })
+
+ it('Send button shows "Send" text initially', () => {
+ renderComponent()
+ expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument()
+ })
+
+ it('MessageBox receives hasError=false initially', () => {
+ renderComponent()
+ expect(screen.getByTestId('message-box')).toHaveAttribute('data-has-error', 'false')
+ })
+ })
+
+ // ── prefilledRecipient ───────────────────────────────────────────────────
+
+ describe('prefilledRecipient prop', () => {
+ const prefilled: RecipientResult = {
+ id: 'user-99',
+ username: 'janedoe',
+ display_name: 'Jane Doe',
+ profile_picture: null,
+ }
+
+ it('shows display_name when set', () => {
+ renderComponent({ prefilledRecipient: prefilled })
+ expect(screen.getByText('Jane Doe')).toBeInTheDocument()
+ })
+
+ it('shows username as fallback when display_name is empty', () => {
+ renderComponent({ prefilledRecipient: { ...prefilled, display_name: '' } })
+ expect(screen.getByText('janedoe')).toBeInTheDocument()
+ })
+
+ it('does not render RecipientInputBox when prefilledRecipient is set', () => {
+ renderComponent({ prefilledRecipient: prefilled })
+ expect(screen.queryByTestId('recipient-input-box')).not.toBeInTheDocument()
+ })
+
+ it('uses the prefilled recipient id in startConversation', async () => {
+ renderComponent({ prefilledRecipient: prefilled })
+ typeMessage('Hello!')
+ clickSend()
+ await waitFor(() =>
+ expect(mockStartConversation).toHaveBeenCalledWith({
+ recipient_id: 'user-99',
+ body: 'Hello!',
})
)
- );
- });
-
- it("calls onClose after successful send", async () => {
- const onClose = vi.fn();
- renderModal(onClose);
- await userEvent.click(screen.getByTestId("select-user"));
- await userEvent.type(screen.getByTestId("message-box-input"), "Hello");
- await userEvent.click(getSendButton());
- await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
- });
-
- it("navigates to the returned conversation after successful send", async () => {
- renderModal();
- await userEvent.click(screen.getByTestId("select-user"));
- await userEvent.type(screen.getByTestId("message-box-input"), "Hello");
- await userEvent.click(getSendButton());
- await waitFor(() =>
- expect(mockNavigate).toHaveBeenCalledWith("/messages/conv-1")
- );
- });
-
- it("does nothing when the API does not return a conversation id", async () => {
- (startConversation as ReturnType).mockResolvedValue({
- data: {
- conversation: {},
- message: { id: "msg-1" },
- },
- });
- const onClose = vi.fn();
- renderModal(onClose);
- await userEvent.click(screen.getByTestId("select-user"));
- await userEvent.type(screen.getByTestId("message-box-input"), "Hello");
- await userEvent.click(getSendButton());
- await waitFor(() => expect(startConversation).toHaveBeenCalled());
- expect(onClose).not.toHaveBeenCalled();
- expect(mockNavigate).not.toHaveBeenCalled();
- expect(screen.getByTestId("message-box-input")).toHaveValue("Hello");
- });
-
- it("pressing Enter performs the same action as the send button", async () => {
- renderModal();
- await userEvent.click(screen.getByTestId("select-user"));
- await userEvent.type(screen.getByTestId("message-box-input"), "Hello{enter}");
- await waitFor(() =>
- expect(startConversation).toHaveBeenCalledWith(
- expect.objectContaining({
- recipient_id: "user-1",
- body: "Hello",
+ })
+ })
+
+ // ── Validation ───────────────────────────────────────────────────────────
+
+ describe('validation', () => {
+ it('shows recipient error when no recipient is selected', async () => {
+ renderComponent()
+ clickSend()
+ await waitFor(() =>
+ expect(screen.getByTestId('recipient-input-box')).toHaveAttribute(
+ 'data-error',
+ 'Enter a recipient.'
+ )
+ )
+ })
+
+ it('shows message error when no message and no embeds', async () => {
+ renderComponent()
+ selectRecipient()
+ clickSend()
+ await waitFor(() =>
+ expect(
+ screen.getByText('Enter a message or paste a track/playlist link.')
+ ).toBeInTheDocument()
+ )
+ })
+
+ it('shows both errors simultaneously', async () => {
+ renderComponent()
+ clickSend()
+ await waitFor(() => {
+ expect(screen.getByTestId('recipient-input-box')).toHaveAttribute(
+ 'data-error',
+ 'Enter a recipient.'
+ )
+ expect(
+ screen.getByText('Enter a message or paste a track/playlist link.')
+ ).toBeInTheDocument()
+ })
+ })
+
+ it('clears recipient error immediately when a recipient is selected', async () => {
+ renderComponent()
+ clickSend()
+ await waitFor(() =>
+ expect(screen.getByTestId('recipient-input-box')).toHaveAttribute(
+ 'data-error',
+ 'Enter a recipient.'
+ )
+ )
+ selectRecipient()
+ await waitFor(() =>
+ expect(screen.getByTestId('recipient-input-box')).toHaveAttribute('data-error', '')
+ )
+ })
+
+ it('clears message error when user types text', async () => {
+ renderComponent()
+ selectRecipient()
+ clickSend()
+ await waitFor(() =>
+ expect(
+ screen.getByText('Enter a message or paste a track/playlist link.')
+ ).toBeInTheDocument()
+ )
+ typeMessage('hello')
+ await waitFor(() =>
+ expect(
+ screen.queryByText('Enter a message or paste a track/playlist link.')
+ ).not.toBeInTheDocument()
+ )
+ })
+
+ it('passes hasError=true to MessageBox when message error is active', async () => {
+ renderComponent()
+ selectRecipient()
+ clickSend()
+ await waitFor(() =>
+ expect(screen.getByTestId('message-box')).toHaveAttribute('data-has-error', 'true')
+ )
+ })
+
+ it('allows send when no text but embeds are present', async () => {
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds([{ type: 'track', id: 'track-1' }])
+ clickSend()
+ await waitFor(() => expect(mockStartConversation).toHaveBeenCalled())
+ })
+ })
+
+ // ── onIsEmptyChange — line 161 ───────────────────────────────────────────
+ //
+ // Source: onIsEmptyChange={(empty) => { if (empty) setMessageError(null) }}
+ // The `if (empty)` true-branch (line 161) was previously uncovered.
+
+ describe('MessageBox onIsEmptyChange callback — line 161', () => {
+ it('clears messageError when the box becomes empty (empty=true)', async () => {
+ renderComponent()
+ selectRecipient()
+ clickSend()
+ await waitFor(() =>
+ expect(
+ screen.getByText('Enter a message or paste a track/playlist link.')
+ ).toBeInTheDocument()
+ )
+ // Fires the `if (empty)` true-branch → setMessageError(null)
+ fireIsEmptyChange(true)
+ await waitFor(() =>
+ expect(
+ screen.queryByText('Enter a message or paste a track/playlist link.')
+ ).not.toBeInTheDocument()
+ )
+ })
+
+ it('does NOT clear messageError when empty=false', async () => {
+ renderComponent()
+ selectRecipient()
+ clickSend()
+ await waitFor(() =>
+ expect(
+ screen.getByText('Enter a message or paste a track/playlist link.')
+ ).toBeInTheDocument()
+ )
+ // empty=false → condition is false → no state change
+ fireIsEmptyChange(false)
+ expect(
+ screen.getByText('Enter a message or paste a track/playlist link.')
+ ).toBeInTheDocument()
+ })
+
+ it('clears send-failure error when box becomes empty', async () => {
+ mockStartConversation.mockRejectedValue({ response: { status: 500 } })
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ await waitFor(() =>
+ expect(
+ screen.getByText('Failed to send message. Please try again.')
+ ).toBeInTheDocument()
+ )
+ fireIsEmptyChange(true)
+ await waitFor(() =>
+ expect(
+ screen.queryByText('Failed to send message. Please try again.')
+ ).not.toBeInTheDocument()
+ )
+ })
+ })
+
+ // ── Sending – plain text (no embeds) ────────────────────────────────────
+
+ describe('sending a plain-text message', () => {
+ async function setupAndSend(text = 'Hello world') {
+ renderComponent()
+ selectRecipient()
+ typeMessage(text)
+ clickSend()
+ }
+
+ it('calls startConversation with recipient_id and trimmed body', async () => {
+ await setupAndSend(' Hello world ')
+ await waitFor(() =>
+ expect(mockStartConversation).toHaveBeenCalledWith({
+ recipient_id: 'user-1',
+ body: 'Hello world',
})
)
- );
- expect(mockNavigate).toHaveBeenCalledWith("/messages/conv-1");
- });
-
- // ── Error flow ───────────────────────────────────────────────────────────────
-
- it("shows error message when startConversation fails", async () => {
- (startConversation as ReturnType).mockRejectedValue(
- new Error("Server error")
- );
- renderModal();
- await userEvent.click(screen.getByTestId("select-user"));
- await userEvent.type(screen.getByTestId("message-box-input"), "Hello");
- await userEvent.click(getSendButton());
- await waitFor(() =>
- expect(screen.getByText(/failed to send message/i)).toBeInTheDocument()
- );
- });
-
- it("shows 'Sending…' text while in progress", async () => {
- let resolve!: (value: unknown) => void;
- (startConversation as ReturnType).mockImplementation(
- () => new Promise((res) => { resolve = res; })
- );
- renderModal();
- await userEvent.click(screen.getByTestId("select-user"));
- await userEvent.type(screen.getByTestId("message-box-input"), "Hello");
- await userEvent.click(getSendButton());
- expect(screen.getByText(/sending…/i)).toBeInTheDocument();
- resolve({ data: { conversation: { id: "conv-1" } } });
- });
-
- // ── Clearing recipient ────────────────────────────────────────────────────────
-
- it("clears selected recipient when onClear is called", async () => {
- renderModal();
- await userEvent.click(screen.getByTestId("select-user"));
- await userEvent.click(screen.getByTestId("clear-user"));
- await userEvent.type(screen.getByTestId("message-box-input"), "Hello");
- await userEvent.click(getSendButton());
- expect(screen.getByTestId("recipient-error")).toBeInTheDocument();
- expect(startConversation).not.toHaveBeenCalled();
- });
-});
+ })
+
+ it('navigates to the conversation after send', async () => {
+ await setupAndSend()
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/messages/conv-123'))
+ })
+
+ it('calls onClose after successful send', async () => {
+ await setupAndSend()
+ await waitFor(() => expect(mockOnClose).toHaveBeenCalled())
+ })
+
+ it('calls onConversationCreated with conversation and message', async () => {
+ await setupAndSend()
+ await waitFor(() =>
+ expect(mockOnConversationCreated).toHaveBeenCalledWith(
+ { id: 'conv-123' },
+ { id: 'msg-1', conversation_id: 'conv-123' }
+ )
+ )
+ })
+
+ it('does not call onConversationCreated when conversation is null', async () => {
+ mockStartConversation.mockResolvedValue(
+ makeResponse({ conversation: null, conversation_id: 'conv-999' })
+ )
+ renderComponent()
+ selectRecipient()
+ typeMessage('hello')
+ clickSend()
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/messages/conv-999'))
+ expect(mockOnConversationCreated).not.toHaveBeenCalled()
+ })
+
+ it('shows 403-specific error — line 118', async () => {
+ mockStartConversation.mockRejectedValue({ response: { status: 403 } })
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ await waitFor(() =>
+ expect(screen.getByText('Unable to send message to this user.')).toBeInTheDocument()
+ )
+ })
+
+ it('shows generic error for non-403 failures — line 120', async () => {
+ mockStartConversation.mockRejectedValue({ response: { status: 500 } })
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ await waitFor(() =>
+ expect(
+ screen.getByText('Failed to send message. Please try again.')
+ ).toBeInTheDocument()
+ )
+ })
+
+ it('shows generic error when rejection has no response object', async () => {
+ mockStartConversation.mockRejectedValue(new Error('Network error'))
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ await waitFor(() =>
+ expect(
+ screen.getByText('Failed to send message. Please try again.')
+ ).toBeInTheDocument()
+ )
+ })
+
+ it('re-enables send button after an error', async () => {
+ mockStartConversation.mockRejectedValue({ response: { status: 500 } })
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ await waitFor(() =>
+ expect(document.querySelector('[data-test="send-message-button"]')).not.toBeDisabled()
+ )
+ })
+
+ it('does not navigate or close on error', async () => {
+ mockStartConversation.mockRejectedValue({ response: { status: 500 } })
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ await waitFor(() =>
+ expect(
+ screen.getByText('Failed to send message. Please try again.')
+ ).toBeInTheDocument()
+ )
+ expect(mockNavigate).not.toHaveBeenCalled()
+ expect(mockOnClose).not.toHaveBeenCalled()
+ })
+
+ it('shows Sending… while the request is in flight', async () => {
+ let resolve!: (v: unknown) => void
+ mockStartConversation.mockReturnValue(new Promise((r) => (resolve = r)))
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ expect(screen.getByRole('button', { name: 'Sending…' })).toBeInTheDocument()
+ act(() => resolve(makeResponse()))
+ await waitFor(() => expect(screen.queryByText('Sending…')).not.toBeInTheDocument())
+ })
+
+ it('disables send button while sending', async () => {
+ let resolve!: (v: unknown) => void
+ mockStartConversation.mockReturnValue(new Promise((r) => (resolve = r)))
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ expect(document.querySelector('[data-test="send-message-button"]')).toBeDisabled()
+ act(() => resolve(makeResponse()))
+ await waitFor(() =>
+ expect(document.querySelector('[data-test="send-message-button"]')).not.toBeDisabled()
+ )
+ })
+
+ it('ignores duplicate clicks while isSending=true', async () => {
+ let resolve!: (v: unknown) => void
+ mockStartConversation.mockReturnValue(new Promise((r) => (resolve = r)))
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ clickSend()
+ act(() => resolve(makeResponse()))
+ await waitFor(() => expect(mockStartConversation).toHaveBeenCalledTimes(1))
+ })
+
+ it('works without onConversationCreated prop — optional chaining', async () => {
+ render( )
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/messages/conv-123'))
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+
+ it('returns early without navigating when conversationId is missing from response', async () => {
+ mockStartConversation.mockResolvedValue({ data: {} })
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ await waitFor(() => expect(mockStartConversation).toHaveBeenCalled())
+ expect(mockNavigate).not.toHaveBeenCalled()
+ expect(mockOnClose).not.toHaveBeenCalled()
+ })
+ })
+
+ // ── conversationId resolution priority (plain-text branch) ──────────────
+
+ describe('conversationId resolution – plain-text branch', () => {
+ it('prefers conversation.id', async () => {
+ mockStartConversation.mockResolvedValue(
+ makeResponse({ conversation: { id: 'pref-conv' }, conversation_id: 'other' })
+ )
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/messages/pref-conv'))
+ })
+
+ it('falls back to conversation_id when conversation is null', async () => {
+ mockStartConversation.mockResolvedValue(
+ makeResponse({ conversation: null, conversation_id: 'fallback-field' })
+ )
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith('/messages/fallback-field')
+ )
+ })
+
+ it('falls back to message.conversation_id', async () => {
+ mockStartConversation.mockResolvedValue({
+ data: {
+ conversation: null,
+ conversation_id: undefined,
+ message: { id: 'msg-1', conversation_id: 'msg-conv' },
+ },
+ })
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/messages/msg-conv'))
+ })
+
+ it('falls back to message.conversationId (camelCase)', async () => {
+ mockStartConversation.mockResolvedValue({
+ data: {
+ conversation: null,
+ conversation_id: undefined,
+ message: { id: 'msg-1', conversationId: 'camel-conv' },
+ },
+ })
+ renderComponent()
+ selectRecipient()
+ typeMessage('hi')
+ clickSend()
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/messages/camel-conv'))
+ })
+ })
+
+ // ── Sending with embeds — lines 70–87 ───────────────────────────────────
+ //
+ // KEY FIX: resolveEmbeds() is now `async` and uses `await act(async () => …)`
+ // so the setEmbeds state update is fully committed before clickSend() runs.
+ // Without the await, embeds.length === 0 inside handleSend and the else-branch
+ // (lines 70–87) is never entered, leaving those lines uncovered.
+
+ describe('sending with embeds — lines 70–87', () => {
+ const single = [{ type: 'track', id: 'track-1' }]
+ const multi = [
+ { type: 'track', id: 'track-1' },
+ { type: 'playlist', id: 'pl-1' },
+ { type: 'track', id: 'track-2' },
+ ]
+
+ it('enters embed branch and calls startConversation with resource — lines 70–79', async () => {
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(single)
+ clickSend()
+ await waitFor(() =>
+ expect(mockStartConversation).toHaveBeenCalledWith({
+ recipient_id: 'user-1',
+ resource: { type: 'track', id: 'track-1' },
+ })
+ )
+ })
+
+ it('includes body text when both embed and message are present', async () => {
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(single)
+ typeMessage('Listen to this')
+ clickSend()
+ await waitFor(() =>
+ expect(mockStartConversation).toHaveBeenCalledWith({
+ recipient_id: 'user-1',
+ body: 'Listen to this',
+ resource: { type: 'track', id: 'track-1' },
+ })
+ )
+ })
+
+ it('calls sendMessage for each embed after the first — loop body lines 86–90', async () => {
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(multi)
+ clickSend()
+ await waitFor(() => expect(mockSendMessage).toHaveBeenCalledTimes(2))
+ expect(mockSendMessage).toHaveBeenCalledWith('conv-123', {
+ resource: { type: 'playlist', id: 'pl-1' },
+ })
+ expect(mockSendMessage).toHaveBeenCalledWith('conv-123', {
+ resource: { type: 'track', id: 'track-2' },
+ })
+ })
+
+ it('skips sendMessage loop when only one embed — false branch of line 85', async () => {
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(single)
+ clickSend()
+ await waitFor(() => expect(mockStartConversation).toHaveBeenCalled())
+ expect(mockSendMessage).not.toHaveBeenCalled()
+ })
+
+ it('skips sendMessage loop when inner conversationId is null — line 85 guard=false', async () => {
+ mockStartConversation.mockResolvedValue({ data: {} })
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(multi)
+ clickSend()
+ await waitFor(() => expect(mockStartConversation).toHaveBeenCalled())
+ expect(mockSendMessage).not.toHaveBeenCalled()
+ })
+
+ // ── Inner conversationId resolution sub-branches (lines 80–84) ──────
+
+ it('uses conversation.id for inner conversationId', async () => {
+ mockStartConversation.mockResolvedValue(
+ makeResponse({ conversation: { id: 'inner-conv' }, conversation_id: 'other' })
+ )
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(multi)
+ clickSend()
+ await waitFor(() => expect(mockSendMessage).toHaveBeenCalledTimes(2))
+ expect(mockSendMessage).toHaveBeenCalledWith('inner-conv', expect.any(Object))
+ })
+
+ it('falls back to typed.data.conversation_id for inner conversationId', async () => {
+ mockStartConversation.mockResolvedValue(
+ makeResponse({ conversation: null, conversation_id: 'inner-field' })
+ )
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(multi)
+ clickSend()
+ await waitFor(() => expect(mockSendMessage).toHaveBeenCalledTimes(2))
+ expect(mockSendMessage).toHaveBeenCalledWith('inner-field', expect.any(Object))
+ })
+
+ it('falls back to firstMessage.conversation_id for inner conversationId', async () => {
+ mockStartConversation.mockResolvedValue({
+ data: {
+ conversation: null,
+ conversation_id: undefined,
+ message: { id: 'msg-1', conversation_id: 'inner-msg-conv' },
+ },
+ })
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(multi)
+ clickSend()
+ await waitFor(() => expect(mockSendMessage).toHaveBeenCalledTimes(2))
+ expect(mockSendMessage).toHaveBeenCalledWith('inner-msg-conv', expect.any(Object))
+ })
+
+ it('falls back to firstMessage.conversationId (camelCase) for inner conversationId', async () => {
+ mockStartConversation.mockResolvedValue({
+ data: {
+ conversation: null,
+ conversation_id: undefined,
+ message: { id: 'msg-1', conversationId: 'inner-camel' },
+ },
+ })
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(multi)
+ clickSend()
+ await waitFor(() => expect(mockSendMessage).toHaveBeenCalledTimes(2))
+ expect(mockSendMessage).toHaveBeenCalledWith('inner-camel', expect.any(Object))
+ })
+
+ it('navigates after embed send', async () => {
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(single)
+ clickSend()
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/messages/conv-123'))
+ })
+
+ it('calls onClose after embed send', async () => {
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(single)
+ clickSend()
+ await waitFor(() => expect(mockOnClose).toHaveBeenCalled())
+ })
+
+ it('calls onConversationCreated after embed send when conversation exists', async () => {
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(single)
+ clickSend()
+ await waitFor(() =>
+ expect(mockOnConversationCreated).toHaveBeenCalledWith(
+ { id: 'conv-123' },
+ { id: 'msg-1', conversation_id: 'conv-123' }
+ )
+ )
+ })
+
+ it('does not call onConversationCreated when conversation is null in embed path', async () => {
+ mockStartConversation.mockResolvedValue(
+ makeResponse({ conversation: null, conversation_id: 'embed-999' })
+ )
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(single)
+ clickSend()
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/messages/embed-999'))
+ expect(mockOnConversationCreated).not.toHaveBeenCalled()
+ })
+
+ it('returns early without navigating when embed conversationId is missing', async () => {
+ mockStartConversation.mockResolvedValue({ data: {} })
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(single)
+ clickSend()
+ await waitFor(() => expect(mockStartConversation).toHaveBeenCalled())
+ expect(mockNavigate).not.toHaveBeenCalled()
+ expect(mockOnClose).not.toHaveBeenCalled()
+ })
+
+ it('shows 403 error on embed send failure — line 118 via embed path', async () => {
+ mockStartConversation.mockRejectedValue({ response: { status: 403 } })
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(single)
+ clickSend()
+ await waitFor(() =>
+ expect(screen.getByText('Unable to send message to this user.')).toBeInTheDocument()
+ )
+ })
+
+ it('shows generic error on embed send failure', async () => {
+ mockStartConversation.mockRejectedValue({ response: { status: 500 } })
+ renderComponent()
+ selectRecipient()
+ await resolveEmbeds(single)
+ clickSend()
+ await waitFor(() =>
+ expect(
+ screen.getByText('Failed to send message. Please try again.')
+ ).toBeInTheDocument()
+ )
+ })
+ })
+
+ // ── Recipient clear ──────────────────────────────────────────────────────
+
+ describe('RecipientInputBox onClear', () => {
+ it('clears selected so the next send shows recipient error', async () => {
+ renderComponent()
+ selectRecipient()
+ act(() => {
+ latestRecipientProps!.onClear()
+ })
+ typeMessage('hello')
+ clickSend()
+ await waitFor(() =>
+ expect(screen.getByTestId('recipient-input-box')).toHaveAttribute(
+ 'data-error',
+ 'Enter a recipient.'
+ )
+ )
+ })
+ })
+
+ // ── MessageBox onSubmit ──────────────────────────────────────────────────
+
+ describe('MessageBox onSubmit callback', () => {
+ it('triggers handleSend from keyboard submit', async () => {
+ renderComponent()
+ selectRecipient()
+ typeMessage('keyboard submit')
+ act(() => {
+ latestMessageBoxProps!.onSubmit()
+ })
+ await waitFor(() => expect(mockStartConversation).toHaveBeenCalled())
+ })
+ })
+})
\ No newline at end of file
diff --git a/src/components/MessagingComponents/tests/RecipientInputBox.test.tsx b/src/components/MessagingComponents/tests/RecipientInputBox.test.tsx
index e5808bc6..8abae4c7 100644
--- a/src/components/MessagingComponents/tests/RecipientInputBox.test.tsx
+++ b/src/components/MessagingComponents/tests/RecipientInputBox.test.tsx
@@ -1,259 +1,282 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { fireEvent, render, screen, waitFor, act } from "@testing-library/react";
-import { RecipientInputBox } from "../RecipientInputBox";
-
-vi.mock("@/services/api/messaging/conversationApi", () => ({
- getSuggestions: vi.fn(),
- globalSearch: vi.fn(),
-}));
-
-import {
- getSuggestions,
- globalSearch,
-} from "@/services/api/messaging/conversationApi";
-
-const mockUser = {
- id: "user-1",
- username: "alice",
- display_name: "Alice",
- profile_picture: "https://example.com/alice.jpg",
-};
-
-const renderInput = (props = {}) =>
- render(
-
- );
-
-const typeIntoInput = (value: string) => {
- fireEvent.change(screen.getByTestId("recipient-input"), {
- target: { value },
- });
-};
-
-const clearInput = () => typeIntoInput("");
-
-describe("RecipientInputBox", () => {
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { act, render, screen, fireEvent } from '@testing-library/react'
+
+const mockGlobalSearch = vi.fn()
+const mockGetSuggestions = vi.fn()
+
+vi.mock('@/services/api/messaging/conversationApi', () => ({
+ globalSearch: (...args: unknown[]) => mockGlobalSearch(...args),
+ getSuggestions: (...args: unknown[]) => mockGetSuggestions(...args),
+}))
+
+vi.mock('@/components/UI/UserAvatar', () => ({
+ default: ({ name }: { name: string }) =>
,
+}))
+
+import { RecipientInputBox } from '../RecipientInputBox'
+
+const defaultProps = {
+ onSelect: vi.fn(),
+ onClear: vi.fn(),
+ error: null,
+}
+
+function makeUser(id = 'u1', display_name = 'Alice') {
+ return { id, username: display_name.toLowerCase(), display_name, profile_picture: null }
+}
+
+async function runDebounce() {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(400)
+ })
+}
+
+describe('RecipientInputBox', () => {
beforeEach(() => {
- vi.clearAllMocks();
- vi.useFakeTimers();
- (getSuggestions as ReturnType).mockResolvedValue({
- users: [],
- });
- (globalSearch as ReturnType).mockResolvedValue({
- data: { users: [] },
- });
- });
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+ mockGetSuggestions.mockResolvedValue({ users: [] })
+ mockGlobalSearch.mockResolvedValue({ data: { users: [] } })
+ })
afterEach(() => {
- vi.useRealTimers();
- });
-
- // ── Rendering ──────────────────────────────────────────────────────────────
-
- it("renders the input", () => {
- renderInput();
- expect(screen.getByTestId("recipient-input")).toBeInTheDocument();
- });
-
- it("renders the container with correct data-test", () => {
- renderInput();
- expect(screen.getByTestId("recipient-input-box")).toBeInTheDocument();
- });
-
- it("does not show dropdown initially", () => {
- renderInput();
- expect(screen.queryByTestId("recipient-dropdown")).not.toBeInTheDocument();
- });
-
- // ── Error display ──────────────────────────────────────────────────────────
-
- it("shows error text when error prop is provided", () => {
- renderInput({ error: "Enter a recipient." });
- expect(screen.getByText("Enter a recipient.")).toBeInTheDocument();
- });
-
- it("applies red border when error is set", () => {
- renderInput({ error: "Error!" });
- expect(screen.getByTestId("recipient-input")).toHaveClass("border-red-500");
- });
-
- it("does not show error when error prop is null", () => {
- renderInput({ error: null });
- expect(screen.queryByText(/enter a recipient/i)).not.toBeInTheDocument();
- });
-
- // ── Typing ─────────────────────────────────────────────────────────────────
-
- it("calls onClear when user types", async () => {
- const onClear = vi.fn();
- renderInput({ onClear });
- typeIntoInput("a");
- expect(onClear).toHaveBeenCalled();
- });
-
- it("does not search immediately (debounced)", async () => {
- renderInput();
- typeIntoInput("alice");
- expect(getSuggestions).not.toHaveBeenCalled();
- });
-
- it("calls search APIs after debounce", async () => {
- renderInput();
- typeIntoInput("alice");
- act(() => vi.advanceTimersByTime(400));
- await waitFor(() =>
- expect(getSuggestions).toHaveBeenCalledWith("alice", expect.any(AbortSignal))
- );
- expect(globalSearch).toHaveBeenCalledWith("alice", {
- type: "users",
- limit: 10,
- });
- });
-
- // ── Dropdown ───────────────────────────────────────────────────────────────
-
- it("shows dropdown with results after search", async () => {
- (getSuggestions as ReturnType).mockResolvedValue({
- users: [mockUser],
- });
- renderInput();
- typeIntoInput("al");
- act(() => vi.advanceTimersByTime(400));
- await waitFor(() =>
- expect(screen.getByTestId("recipient-dropdown")).toBeInTheDocument()
- );
- expect(screen.getByText("Alice")).toBeInTheDocument();
- });
-
- it("renders profile picture in dropdown when available", async () => {
- (getSuggestions as ReturnType).mockResolvedValue({
- users: [mockUser],
- });
- renderInput();
- typeIntoInput("al");
- act(() => vi.advanceTimersByTime(400));
- await waitFor(() => screen.getByTestId("recipient-dropdown"));
- expect(screen.getByRole("img")).toHaveAttribute(
- "src",
- "https://example.com/alice.jpg"
- );
- });
-
- it("renders initials when profile picture is null", async () => {
- (getSuggestions as ReturnType).mockResolvedValue({
- users: [{ ...mockUser, profile_picture: null }],
- });
- renderInput();
- typeIntoInput("al");
- act(() => vi.advanceTimersByTime(400));
- await waitFor(() => screen.getByTestId("recipient-dropdown"));
- expect(screen.getByText("AL")).toBeInTheDocument();
- });
-
- it("shows 'user not found' when no results returned", async () => {
- renderInput();
- typeIntoInput("xyz");
- act(() => vi.advanceTimersByTime(400));
- await waitFor(() =>
- expect(
- screen.getByText(/soundcloud user not found/i)
- ).toBeInTheDocument()
- );
- });
-
- it("auto-selects when validation returns exactly one user", async () => {
- const onSelect = vi.fn();
- (getSuggestions as ReturnType).mockResolvedValue({
- users: [mockUser],
- });
- (globalSearch as ReturnType).mockResolvedValue({
- data: { users: [mockUser] },
- });
- renderInput({ onSelect });
- typeIntoInput("alice");
- act(() => vi.advanceTimersByTime(400));
- await waitFor(() => expect(onSelect).toHaveBeenCalledWith(mockUser));
- expect(screen.queryByTestId("recipient-dropdown")).not.toBeInTheDocument();
- });
-
- // ── Selection ──────────────────────────────────────────────────────────────
-
- it("calls onSelect with user when a result is clicked", async () => {
- const onSelect = vi.fn();
- (getSuggestions as ReturnType).mockResolvedValue({
- users: [mockUser],
- });
- renderInput({ onSelect });
- typeIntoInput("al");
- act(() => vi.advanceTimersByTime(400));
- await waitFor(() => screen.getByTestId("recipient-dropdown"));
- fireEvent.click(
- screen.getByTestId(`recipient-dropdown-item-${mockUser.id}`)
- );
- expect(onSelect).toHaveBeenCalledWith(mockUser);
- });
-
- it("fills input with selected user's display name", async () => {
- (getSuggestions as ReturnType).mockResolvedValue({
- users: [mockUser],
- });
- renderInput();
- typeIntoInput("al");
- act(() => vi.advanceTimersByTime(400));
- await waitFor(() => screen.getByTestId("recipient-dropdown"));
- fireEvent.click(
- screen.getByTestId(`recipient-dropdown-item-${mockUser.id}`)
- );
- expect(screen.getByTestId("recipient-input")).toHaveValue("Alice");
- });
-
- it("closes dropdown after selection", async () => {
- (getSuggestions as ReturnType).mockResolvedValue({
- users: [mockUser],
- });
- renderInput();
- typeIntoInput("al");
- act(() => vi.advanceTimersByTime(400));
- await waitFor(() => screen.getByTestId("recipient-dropdown"));
- fireEvent.click(
- screen.getByTestId(`recipient-dropdown-item-${mockUser.id}`)
- );
- expect(screen.queryByTestId("recipient-dropdown")).not.toBeInTheDocument();
- });
-
- // ── Error on search failure ────────────────────────────────────────────────
-
- it("shows user not found when search throws", async () => {
- (getSuggestions as ReturnType).mockRejectedValue(
- new Error("Network error")
- );
- renderInput();
- typeIntoInput("fail");
- act(() => vi.advanceTimersByTime(400));
- await waitFor(() =>
- expect(
- screen.getByText(/soundcloud user not found/i)
- ).toBeInTheDocument()
- );
- });
-
- // ── Empty query ────────────────────────────────────────────────────────────
-
- it("hides dropdown when query is cleared", async () => {
- (getSuggestions as ReturnType).mockResolvedValue({
- users: [mockUser],
- });
- renderInput();
- typeIntoInput("al");
- act(() => vi.advanceTimersByTime(400));
- await waitFor(() => screen.getByTestId("recipient-dropdown"));
- clearInput();
- act(() => vi.advanceTimersByTime(400));
- expect(screen.queryByTestId("recipient-dropdown")).not.toBeInTheDocument();
- });
-});
+ vi.clearAllTimers()
+ vi.useRealTimers()
+ })
+
+ describe('rendering', () => {
+ it('renders the container', () => {
+ render( )
+ expect(screen.getByTestId('recipient-input-box')).toBeInTheDocument()
+ })
+
+ it('renders the input field', () => {
+ render( )
+ expect(screen.getByTestId('recipient-input')).toBeInTheDocument()
+ })
+
+ it('has autofocus on the input', () => {
+ render( )
+ expect(screen.getByTestId('recipient-input')).toHaveFocus()
+ })
+
+ it('has placeholder text', () => {
+ render( )
+ expect(screen.getByTestId('recipient-input')).toHaveAttribute('placeholder', 'Search for a user...')
+ })
+
+ it('shows external error when provided', () => {
+ render( )
+ expect(screen.getByText('User not found')).toBeInTheDocument()
+ })
+
+ it('does not show dropdown initially', () => {
+ render( )
+ expect(screen.queryByTestId('recipient-dropdown')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('typing and debounce', () => {
+ it('does not call globalSearch before debounce completes', async () => {
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'ali' } })
+ expect(mockGlobalSearch).not.toHaveBeenCalled()
+ })
+
+ it('calls globalSearch after debounce (400ms)', async () => {
+ mockGlobalSearch.mockResolvedValue({ data: { users: [] } })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'alice' } })
+ await runDebounce()
+ expect(mockGlobalSearch).toHaveBeenCalledWith('alice', { type: 'users', limit: 10 })
+ })
+
+ it('calls getSuggestions after debounce', async () => {
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'alice' } })
+ await runDebounce()
+ expect(mockGetSuggestions).toHaveBeenCalled()
+ })
+
+ it('clears state when input is cleared', async () => {
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'alice' } })
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: '' } })
+ await runDebounce()
+ expect(screen.queryByTestId('recipient-dropdown')).not.toBeInTheDocument()
+ })
+
+ it('calls onClear when user types after a selection', async () => {
+ const onClear = vi.fn()
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'a' } })
+ expect(onClear).toHaveBeenCalled()
+ })
+
+ it('does not search when input is only whitespace', async () => {
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: ' ' } })
+ await runDebounce()
+ expect(mockGetSuggestions).not.toHaveBeenCalled()
+ expect(mockGlobalSearch).not.toHaveBeenCalled()
+ expect(screen.queryByTestId('recipient-dropdown')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('suggestions dropdown', () => {
+ it('shows dropdown when suggestions are returned', async () => {
+ mockGetSuggestions.mockResolvedValue({ users: [makeUser()] })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'ali' } })
+ await runDebounce()
+ expect(screen.getByTestId('recipient-dropdown')).toBeInTheDocument()
+ })
+
+ it('renders a dropdown item for each suggestion', async () => {
+ mockGetSuggestions.mockResolvedValue({ users: [makeUser('u1', 'Alice'), makeUser('u2', 'Bob')] })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'ali' } })
+ await runDebounce()
+ expect(screen.getByTestId('recipient-dropdown-item-u1')).toBeInTheDocument()
+ expect(screen.getByTestId('recipient-dropdown-item-u2')).toBeInTheDocument()
+ })
+
+ it('shows dropdown on focus when suggestions exist', async () => {
+ mockGetSuggestions.mockResolvedValue({ users: [makeUser()] })
+ render( )
+ const input = screen.getByTestId('recipient-input')
+ fireEvent.change(input, { target: { value: 'ali' } })
+ await runDebounce()
+ screen.getByTestId('recipient-dropdown')
+ fireEvent.blur(input)
+ fireEvent.focus(input)
+ expect(screen.getByTestId('recipient-dropdown')).toBeInTheDocument()
+ })
+
+ it('prevents input blur when pressing a suggestion', async () => {
+ mockGetSuggestions.mockResolvedValue({ users: [makeUser('u1', 'Alice')] })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'ali' } })
+ await runDebounce()
+
+ const mouseDownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
+ fireEvent(screen.getByTestId('recipient-dropdown-item-u1'), mouseDownEvent)
+
+ expect(mouseDownEvent.defaultPrevented).toBe(true)
+ })
+
+ it('ignores suggestion lookup failures and keeps the dropdown hidden', async () => {
+ mockGetSuggestions.mockRejectedValue(new Error('suggestions failed'))
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'ali' } })
+ await runDebounce()
+
+ expect(mockGetSuggestions).toHaveBeenCalled()
+ expect(screen.queryByTestId('recipient-dropdown')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('user selection', () => {
+ it('selects a user from dropdown and calls onSelect', async () => {
+ const onSelect = vi.fn()
+ mockGetSuggestions.mockResolvedValue({ users: [makeUser('u1', 'Alice')] })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'ali' } })
+ await runDebounce()
+ screen.getByTestId('recipient-dropdown-item-u1')
+ fireEvent.click(screen.getByTestId('recipient-dropdown-item-u1'))
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'u1', display_name: 'Alice' }))
+ })
+
+ it('sets input value to display_name after selection', async () => {
+ mockGetSuggestions.mockResolvedValue({ users: [makeUser('u1', 'Alice')] })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'ali' } })
+ await runDebounce()
+ screen.getByTestId('recipient-dropdown-item-u1')
+ fireEvent.click(screen.getByTestId('recipient-dropdown-item-u1'))
+ expect(screen.getByTestId('recipient-input')).toHaveValue('Alice')
+ })
+
+ it('hides dropdown after selection', async () => {
+ mockGetSuggestions.mockResolvedValue({ users: [makeUser('u1', 'Alice')] })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'ali' } })
+ await runDebounce()
+ screen.getByTestId('recipient-dropdown-item-u1')
+ fireEvent.click(screen.getByTestId('recipient-dropdown-item-u1'))
+ expect(screen.queryByTestId('recipient-dropdown')).not.toBeInTheDocument()
+ })
+
+ it('passes the full selected suggestion payload to onSelect', async () => {
+ const onSelect = vi.fn()
+ const suggestion = {
+ id: 'u7',
+ username: 'casey',
+ display_name: 'Casey',
+ profile_picture: 'https://example.com/casey.jpg',
+ }
+ mockGetSuggestions.mockResolvedValue({ users: [suggestion] })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'case' } })
+ await runDebounce()
+ fireEvent.click(screen.getByTestId('recipient-dropdown-item-u7'))
+ expect(onSelect).toHaveBeenCalledWith(suggestion)
+ })
+
+ it('auto-selects when globalSearch returns exactly 1 user', async () => {
+ const onSelect = vi.fn()
+ mockGlobalSearch.mockResolvedValue({ data: { users: [makeUser('u1', 'Alice')] } })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'alice' } })
+ await runDebounce()
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'u1' }))
+ })
+ })
+
+ describe('validation errors', () => {
+ it('keeps external error visible even when validation would clear', async () => {
+ mockGlobalSearch.mockResolvedValue({ data: { users: [makeUser('u1', 'Alice')] } })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'alice' } })
+ await runDebounce()
+ expect(screen.getByText('Pick someone first')).toBeInTheDocument()
+ expect(screen.queryByText('SoundCloud user not found.')).not.toBeInTheDocument()
+ })
+
+ it('shows "SoundCloud user not found." when globalSearch returns 0 users', async () => {
+ mockGlobalSearch.mockResolvedValue({ data: { users: [] } })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'nobody' } })
+ await runDebounce()
+ expect(screen.getByText('SoundCloud user not found.')).toBeInTheDocument()
+ })
+
+ it('shows "SoundCloud user not found." when globalSearch returns multiple users', async () => {
+ mockGlobalSearch.mockResolvedValue({ data: { users: [makeUser('u1'), makeUser('u2')] } })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'a' } })
+ await runDebounce()
+ expect(screen.getByText('SoundCloud user not found.')).toBeInTheDocument()
+ })
+
+ it('shows "SoundCloud user not found." when globalSearch throws', async () => {
+ mockGlobalSearch.mockRejectedValue(new Error('Network error'))
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'fail' } })
+ await runDebounce()
+ expect(screen.getByText('SoundCloud user not found.')).toBeInTheDocument()
+ })
+
+ it('hides validation error after a user is explicitly selected', async () => {
+ mockGetSuggestions.mockResolvedValue({ users: [makeUser('u1', 'Alice')] })
+ mockGlobalSearch.mockResolvedValue({ data: { users: [] } })
+ render( )
+ fireEvent.change(screen.getByTestId('recipient-input'), { target: { value: 'ali' } })
+ await runDebounce()
+ screen.getByTestId('recipient-dropdown-item-u1')
+ fireEvent.click(screen.getByTestId('recipient-dropdown-item-u1'))
+ expect(screen.queryByText('SoundCloud user not found.')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/components/MessagingComponents/tests/ReportModal.test.tsx b/src/components/MessagingComponents/tests/ReportModal.test.tsx
index 1a5af4e6..cdea05cb 100644
--- a/src/components/MessagingComponents/tests/ReportModal.test.tsx
+++ b/src/components/MessagingComponents/tests/ReportModal.test.tsx
@@ -1,129 +1,116 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { ReportModal } from "../../UI/ReportModal";
-
-// Mock the external handler module
-vi.mock("../externalhandler", () => ({
- handleExternalAbuse: vi.fn(),
+import { render, screen, fireEvent } from "@testing-library/react"
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { ReportModal } from "@/components/UI/ReportModal"
+import * as externalHandler from "@/components/MessagingComponents/externalhandler"
+
+vi.mock("@/components/MessagingComponents/externalhandler", () => ({
+ handleExternalAbuse: vi.fn(),
handleExternalImpersonation: vi.fn(),
- handleExternalTrademark: vi.fn(),
- handleExternalOther: vi.fn(),
-}));
-
-import {
- handleExternalAbuse,
- handleExternalImpersonation,
- handleExternalTrademark,
- handleExternalOther,
-} from "../externalhandler";
-
-const renderReport = (
- props: {
- username?: string;
- userId?: string;
- onClose?: () => void;
- onSpamSelected?: () => void;
- } = {}
-) =>
- render(
-
- );
+ handleExternalOther: vi.fn(),
+ handleExternalTrademark: vi.fn(),
+}))
+
+const defaultProps = {
+ username: "reportuser",
+ userId: "user-789",
+ onClose: vi.fn(),
+ onSpamSelected: vi.fn(),
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
describe("ReportModal", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- // ── Rendering ──────────────────────────────────────────────────────────────
-
- it("renders the modal heading", () => {
- renderReport();
- expect(screen.getByText(/report account for/i)).toBeInTheDocument();
- });
-
- it("renders all five report options", () => {
- renderReport();
- expect(screen.getByTestId("report-spam-button")).toBeInTheDocument();
- expect(screen.getByTestId("report-impersonation-button")).toBeInTheDocument();
- expect(screen.getByTestId("report-abuse-button")).toBeInTheDocument();
- expect(screen.getByTestId("report-trademark-button")).toBeInTheDocument();
- expect(screen.getByTestId("report-other-button")).toBeInTheDocument();
- });
+ it("renders the modal with correct test id", () => {
+ render( )
+ expect(screen.getByTestId("report-account-modal")).toBeInTheDocument()
+ })
+
+ it("renders the title", () => {
+ render( )
+ expect(screen.getByText("Report account for")).toBeInTheDocument()
+ })
+
+ it("renders all report reason buttons", () => {
+ render( )
+ expect(screen.getByTestId("report-spam-button")).toBeInTheDocument()
+ expect(screen.getByTestId("report-impersonation-button")).toBeInTheDocument()
+ expect(screen.getByTestId("report-abuse-button")).toBeInTheDocument()
+ expect(screen.getByTestId("report-trademark-button")).toBeInTheDocument()
+ expect(screen.getByTestId("report-other-button")).toBeInTheDocument()
+ })
+
+ it("renders correct label text on each button", () => {
+ render( )
+ expect(screen.getByTestId("report-spam-button")).toHaveTextContent("Spam")
+ expect(screen.getByTestId("report-impersonation-button")).toHaveTextContent("Impersonation")
+ expect(screen.getByTestId("report-abuse-button")).toHaveTextContent("Abuse")
+ expect(screen.getByTestId("report-trademark-button")).toHaveTextContent("Trademark infringement")
+ expect(screen.getByTestId("report-other-button")).toHaveTextContent("Other")
+ })
+
+ it("calls onClose and onSpamSelected when Spam is clicked", () => {
+ render( )
+ fireEvent.click(screen.getByTestId("report-spam-button"))
+ expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
+ expect(defaultProps.onSpamSelected).toHaveBeenCalledTimes(1)
+ })
+
+ it("calls handleExternalImpersonation when Impersonation is clicked", () => {
+ render( )
+ fireEvent.click(screen.getByTestId("report-impersonation-button"))
+ expect(externalHandler.handleExternalImpersonation).toHaveBeenCalledTimes(1)
+ })
+
+ it("calls handleExternalAbuse when Abuse is clicked", () => {
+ render( )
+ fireEvent.click(screen.getByTestId("report-abuse-button"))
+ expect(externalHandler.handleExternalAbuse).toHaveBeenCalledTimes(1)
+ })
+
+ it("calls handleExternalTrademark when Trademark infringement is clicked", () => {
+ render( )
+ fireEvent.click(screen.getByTestId("report-trademark-button"))
+ expect(externalHandler.handleExternalTrademark).toHaveBeenCalledTimes(1)
+ })
+
+ it("calls handleExternalOther when Other is clicked", () => {
+ render( )
+ fireEvent.click(screen.getByTestId("report-other-button"))
+ expect(externalHandler.handleExternalOther).toHaveBeenCalledTimes(1)
+ })
it("renders the Disclaimer section", () => {
- renderReport();
- expect(screen.getByText(/disclaimer/i)).toBeInTheDocument();
- });
-
- it("renders disclaimer text with Guidelines and Terms links", () => {
- renderReport();
- expect(screen.getByRole("link", { name: /guidelines/i })).toBeInTheDocument();
- expect(screen.getByRole("link", { name: /terms/i })).toBeInTheDocument();
- });
-
- it("renders correct data-test on modal container", () => {
- renderReport();
- expect(screen.getByTestId("report-account-modal")).toBeInTheDocument();
- });
-
- // ── Spam interaction ───────────────────────────────────────────────────────
-
- it("calls onClose and onSpamSelected when Spam is clicked", async () => {
- const onClose = vi.fn();
- const onSpamSelected = vi.fn();
- renderReport({ onClose, onSpamSelected });
- await userEvent.click(screen.getByTestId("report-spam-button"));
- expect(onClose).toHaveBeenCalledTimes(1);
- expect(onSpamSelected).toHaveBeenCalledTimes(1);
- });
-
- it("calls onClose before onSpamSelected", async () => {
- const callOrder: string[] = [];
- const onClose = vi.fn(() => callOrder.push("close"));
- const onSpamSelected = vi.fn(() => callOrder.push("spam"));
- renderReport({ onClose, onSpamSelected });
- await userEvent.click(screen.getByTestId("report-spam-button"));
- expect(callOrder).toEqual(["close", "spam"]);
- });
-
- // ── External handlers ──────────────────────────────────────────────────────
-
- it("calls handleExternalImpersonation when Impersonation is clicked", async () => {
- renderReport();
- await userEvent.click(screen.getByTestId("report-impersonation-button"));
- expect(handleExternalImpersonation).toHaveBeenCalledTimes(1);
- });
-
- it("calls handleExternalAbuse when Abuse is clicked", async () => {
- renderReport();
- await userEvent.click(screen.getByTestId("report-abuse-button"));
- expect(handleExternalAbuse).toHaveBeenCalledTimes(1);
- });
-
- it("calls handleExternalTrademark when Trademark infringement is clicked", async () => {
- renderReport();
- await userEvent.click(screen.getByTestId("report-trademark-button"));
- expect(handleExternalTrademark).toHaveBeenCalledTimes(1);
- });
-
- it("calls handleExternalOther when Other is clicked", async () => {
- renderReport();
- await userEvent.click(screen.getByTestId("report-other-button"));
- expect(handleExternalOther).toHaveBeenCalledTimes(1);
- });
-
- // ── Optional props ─────────────────────────────────────────────────────────
-
- it("works without onClose and onSpamSelected (no crash)", async () => {
- render( );
- await userEvent.click(screen.getByTestId("report-spam-button"));
- // no error thrown
- });
-});
+ render( )
+ expect(screen.getByText("Disclaimer")).toBeInTheDocument()
+ })
+
+ it("renders the Guidelines link pointing to the correct URL", () => {
+ render( )
+ const guidelinesLink = screen.getByRole("link", { name: "Guidelines" })
+ expect(guidelinesLink).toHaveAttribute("href", "https://soundcloud.com/pages/privacy")
+ expect(guidelinesLink).toHaveAttribute("target", "_blank")
+ expect(guidelinesLink).toHaveAttribute("rel", "noopener noreferrer")
+ })
+
+ it("renders the Terms link pointing to the correct URL", () => {
+ render( )
+ const termsLink = screen.getByRole("link", { name: "Terms" })
+ expect(termsLink).toHaveAttribute("href", "https://soundcloud.com/terms-of-use")
+ expect(termsLink).toHaveAttribute("target", "_blank")
+ expect(termsLink).toHaveAttribute("rel", "noopener noreferrer")
+ })
+
+ it("does not call onClose when non-spam buttons are clicked", () => {
+ render( )
+ fireEvent.click(screen.getByTestId("report-abuse-button"))
+ expect(defaultProps.onClose).not.toHaveBeenCalled()
+ })
+
+ it("does not call onSpamSelected when non-spam buttons are clicked", () => {
+ render( )
+ fireEvent.click(screen.getByTestId("report-other-button"))
+ expect(defaultProps.onSpamSelected).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/components/MessagingComponents/tests/SendMessageForm.test.tsx b/src/components/MessagingComponents/tests/SendMessageForm.test.tsx
index bf92115c..b9e19225 100644
--- a/src/components/MessagingComponents/tests/SendMessageForm.test.tsx
+++ b/src/components/MessagingComponents/tests/SendMessageForm.test.tsx
@@ -1,279 +1,474 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, waitFor, fireEvent } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import SendMessageForm from "../SendMessageForm";
-import type { Message } from "@/services/api/messaging/conversationApi";
-
-// ── Mock API ───────────────────────────────────────────────────────────────────
-//
-// Vite resolves path aliases before producing module IDs, so
-// '@/services/api/messaging/conversationApi' and the component's relative
-// '../../services/api/messaging/conversationApi' both resolve to the same
-// absolute path. Using the @/ alias here is safe and avoids fragility around
-// how many '../' levels the test file sits below the source root.
-
-vi.mock("@/services/api/messaging/conversationApi", () => ({
- sendMessage: vi.fn(),
-}));
-
-// ── Mock auth store ────────────────────────────────────────────────────────────
-
-vi.mock("@/stores/auth.store", () => ({
- useAuthStore: (selector: (s: { user: { id: string; avatar: string } }) => unknown) =>
- selector({ user: { id: "me", avatar: "https://example.com/me.jpg" } }),
-}));
-
-// ── Mock MessageBox ────────────────────────────────────────────────────────────
-//
-// Same alias rule applies: '@/components/MessagingComponents/MessageBox' resolves
-// to the same file as the component's relative './MessageBox'.
-
-vi.mock("@/components/MessagingComponents/MessageBox", () => ({
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+const mockSendMessage = vi.fn()
+const mockEmitMessageSent = vi.fn()
+const mockEmitStopTyping = vi.fn()
+const mockUseAuthStore = vi.fn()
+let mockObserver: { observe: ReturnType; disconnect: ReturnType; unobserve: ReturnType }
+let intersectionCallback: IntersectionObserverCallback = () => {}
+
+vi.mock('@/services/api/messaging/conversationApi', () => ({
+ sendMessage: (...args: unknown[]) => mockSendMessage(...args),
+}))
+
+vi.mock('@/services/api/messaging/socketService', () => ({
+ emitMessageSent: (...args: unknown[]) => mockEmitMessageSent(...args),
+ emitStopTyping: (...args: unknown[]) => mockEmitStopTyping(...args),
+}))
+
+vi.mock('@/stores/auth.store', () => ({
+ useAuthStore: (selector: (state: { user: { id: string; displayName: string; username: string; avatar: string | null; role: string } | null }) => unknown) =>
+ mockUseAuthStore(selector),
+}))
+
+vi.mock('../MessageBox', () => ({
MessageBox: ({
onValueChange,
onIsEmptyChange,
+ onEmbedsResolved,
+ hasError,
}: {
- onValueChange: (v: string) => void;
- onIsEmptyChange: (empty: boolean) => void;
- onEmbedResolved: (embed: unknown) => void;
+ onValueChange?: (v: string) => void
+ onIsEmptyChange?: (e: boolean) => void
+ onEmbedsResolved?: (e: unknown[]) => void
+ hasError?: boolean
}) => (
-