import React from 'react' import type { Channel } from 'stream-chat' import { describe, expect, it, vi, beforeEach } from 'vitest' import { renderWithProviders } from '../../test/utils' import { ChannelList } from '.' const streamChannelListMock = vi.fn() const { ChannelListProviderMock, CustomChannelPreviewMock, InfiniteScrollMock, restorePendingMessagesMock, streamMountMock, messagingContextMock, } = vi.hoisted(() => ({ ChannelListProviderMock: vi.fn( ({ children }: { children: React.ReactNode; value: unknown }) => children ), CustomChannelPreviewMock: () => null, InfiniteScrollMock: () => null, restorePendingMessagesMock: vi.fn(), streamMountMock: vi.fn(), messagingContextMock: { current: { debug: false } }, })) vi.mock('stream-chat-react', () => ({ ChannelList: (props: unknown) => { React.useEffect(() => { streamMountMock() }, []) streamChannelListMock(props) return
}, InfiniteScroll: InfiniteScrollMock, })) vi.mock('../../providers/MessagingProvider', () => ({ useMessagingContext: () => messagingContextMock.current, })) vi.mock('../../hooks/useRestorePendingMessages', () => ({ restorePendingMessages: restorePendingMessagesMock, })) vi.mock('./ChannelListContext', () => ({ ChannelListProvider: ChannelListProviderMock, })) vi.mock('./CustomChannelPreview', () => ({ default: CustomChannelPreviewMock, })) describe('ChannelList', () => { const defaultProps = { filters: { type: 'messaging' }, onChannelSelect: vi.fn(), } beforeEach(() => { ChannelListProviderMock.mockClear() streamChannelListMock.mockClear() restorePendingMessagesMock.mockClear() streamMountMock.mockClear() messagingContextMock.current = { debug: false } }) it('defaults unseen-channel promotion to false', () => { renderWithProviders() expect(streamChannelListMock).toHaveBeenCalledOnce() expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({ allowNewMessagesFromUnfilteredChannels: false, }) }) it('uses InfiniteScroll as the channel list paginator', () => { renderWithProviders() expect(streamChannelListMock).toHaveBeenCalledOnce() expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({ Paginator: InfiniteScrollMock, }) }) it('uses a custom channel list paginator when provided', () => { const ChannelListPaginator = () => null renderWithProviders( ) expect(streamChannelListMock).toHaveBeenCalledOnce() expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({ Paginator: ChannelListPaginator, }) }) it('uses the built-in channel preview by default', () => { renderWithProviders() expect(streamChannelListMock).toHaveBeenCalledOnce() expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({ Preview: CustomChannelPreviewMock, }) }) it('keeps the built-in channel preview bridge when a custom preview is provided', () => { const ChannelPreview = () => null renderWithProviders( ) expect(streamChannelListMock).toHaveBeenCalledOnce() const contextValue = ChannelListProviderMock.mock.calls[0][0].value as { channelPreview?: unknown } expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({ Preview: CustomChannelPreviewMock, }) expect(contextValue.channelPreview).toBe(ChannelPreview) }) it('passes allowNewMessagesFromUnfilteredChannels through to Stream ChannelList', () => { const props: React.ComponentProps = { ...defaultProps, allowNewMessagesFromUnfilteredChannels: true, } renderWithProviders(React.createElement(ChannelList, props)) expect(streamChannelListMock).toHaveBeenCalledOnce() expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({ allowNewMessagesFromUnfilteredChannels: true, }) }) it('leaves live-reorder controls undefined by default so Stream keeps its default ordering', () => { renderWithProviders() expect(streamChannelListMock).toHaveBeenCalledOnce() const streamProps = streamChannelListMock.mock.calls[0][0] as Record< string, unknown > expect(streamProps.lockChannelOrder).toBeUndefined() expect(streamProps.onMessageNewHandler).toBeUndefined() expect(streamProps.onMessageNew).toBeUndefined() expect(streamProps.onChannelVisible).toBeUndefined() expect(streamProps.onAddedToChannel).toBeUndefined() expect(streamProps.onChannelUpdated).toBeUndefined() }) it('forwards live-reorder controls to Stream ChannelList when provided', () => { const onMessageNewHandler = vi.fn() const onMessageNew = vi.fn() const onChannelVisible = vi.fn() const onAddedToChannel = vi.fn() const onChannelUpdated = vi.fn() renderWithProviders( ) expect(streamChannelListMock).toHaveBeenCalledOnce() expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({ lockChannelOrder: true, onMessageNewHandler, onMessageNew, onChannelVisible, onAddedToChannel, onChannelUpdated, }) }) it('wraps channelRenderFilterFn to restore pending messages and delegates to consumer filter', () => { const filterFn = vi.fn((channels: Channel[]) => channels) renderWithProviders( React.createElement(ChannelList, { ...defaultProps, channelRenderFilterFn: filterFn, }) ) expect(streamChannelListMock).toHaveBeenCalledOnce() const streamProps = streamChannelListMock.mock.calls[0][0] as { channelRenderFilterFn?: (channels: unknown[]) => unknown[] } // The wrapper should not be the same reference as the original filter expect(streamProps.channelRenderFilterFn).not.toBe(filterFn) expect(typeof streamProps.channelRenderFilterFn).toBe('function') // When the wrapper is called, it should delegate to the consumer's filter const mockChannels = [ { cid: 'ch-1', state: { pending_messages: [], addMessageSorted: vi.fn() }, }, ] streamProps.channelRenderFilterFn!(mockChannels) expect(filterFn).toHaveBeenCalledWith(mockChannels) }) it('restores pending messages for every channel and preserves channels without a filter', () => { const channels = [ { cid: 'ch-1', state: { pending_messages: [] } }, { cid: 'ch-2', state: { pending_messages: [{ id: 'pending-2' }] } }, ] as unknown as Channel[] renderWithProviders() const streamProps = streamChannelListMock.mock.calls[0][0] as { channelRenderFilterFn: (channels: Channel[]) => Channel[] } expect(streamProps.channelRenderFilterFn(channels)).toBe(channels) expect(restorePendingMessagesMock).toHaveBeenCalledTimes(2) expect(restorePendingMessagesMock).toHaveBeenNthCalledWith(1, channels[0]) expect(restorePendingMessagesMock).toHaveBeenNthCalledWith(2, channels[1]) }) it('remounts Stream ChannelList when filters change but not when they stay the same', () => { const { rerender } = renderWithProviders() expect(streamMountMock).toHaveBeenCalledOnce() rerender() expect(streamMountMock).toHaveBeenCalledOnce() rerender( ) expect(streamMountMock).toHaveBeenCalledTimes(2) }) it('forwards the default and explicit sort values and the channel limit', () => { renderWithProviders() expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({ sort: { last_message_at: -1 }, options: { limit: 30 }, }) const explicitSort = { updated_at: 1 as const } renderWithProviders() expect(streamChannelListMock.mock.calls[1][0]).toMatchObject({ sort: explicitSort, options: { limit: 30 }, }) }) it('provides channel list context values from props and messaging context', () => { const selectedChannel = { id: 'selected-channel' } as Channel const onChannelSelect = vi.fn() const renderMessagePreview = vi.fn() messagingContextMock.current = { debug: true } renderWithProviders( ) const contextValue = ChannelListProviderMock.mock.calls[0][0].value expect(contextValue).toMatchObject({ selectedChannel, onChannelSelect, debug: true, renderMessagePreview, viewerLanguage: 'fr', }) }) it('logs the channel list render when messaging debug is enabled', () => { const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) messagingContextMock.current = { debug: true } renderWithProviders() expect(consoleLog).toHaveBeenCalledWith( '📺 [ChannelList] 🔄 RENDER START', expect.objectContaining({ renderCount: 1, selectedChannelId: undefined, filters: defaultProps.filters, }) ) }) it('passes wrapper classes and the custom empty state indicator through', () => { const EmptyStateIndicator = () =>
Empty state
const { container } = renderWithProviders( ) expect(container.firstElementChild).toHaveClass( 'messaging-channel-list', 'h-full', 'flex', 'flex-col', 'min-w-0', 'overflow-hidden', 'custom-channel-list' ) expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({ EmptyStateIndicator, }) }) })