import React, { act } from 'react' import type { Channel } from 'stream-chat' import { describe, expect, it, vi, beforeEach } from 'vitest' import { renderWithProviders, screen, waitFor } from '../../test/utils' import { MessagingShell } from './index' const queryChannelsMock = vi.fn() const startChannelWithParticipantMock = vi.fn() const baseClient = { userID: 'me-1', queryChannels: queryChannelsMock, } const baseService = { startChannelWithParticipant: startChannelWithParticipantMock, } let useMessagingReturn: Record = { client: baseClient, isConnected: true, isLoading: false, error: null, refreshConnection: vi.fn(), service: baseService, debug: false, } vi.mock('../../hooks/useMessaging', () => ({ useMessaging: () => useMessagingReturn, })) let capturedChannelViewProps: Record = {} vi.mock('../ChannelView', () => ({ ChannelView: (props: Record) => { capturedChannelViewProps = props return
}, })) vi.mock('./LoadingState', () => ({ LoadingState: () =>
, })) vi.mock('./ErrorState', () => ({ ErrorState: ({ message }: { message: string }) => (
{message}
), })) const makeChannel = (id: string): Channel => ({ id, cid: `messaging:${id}` }) as unknown as Channel describe('MessagingShell', () => { beforeEach(() => { queryChannelsMock.mockReset() startChannelWithParticipantMock.mockReset() capturedChannelViewProps = {} useMessagingReturn = { client: baseClient, isConnected: true, isLoading: false, error: null, refreshConnection: vi.fn(), service: baseService, debug: false, } }) it('does not refire queryChannels when initialParticipantData and onChannelSelect get fresh references on re-render', async () => { queryChannelsMock.mockResolvedValue([makeChannel('existing-1')]) const { rerender } = renderWithProviders( void channel} /> ) await waitFor(() => { expect(screen.getByTestId('channel-view')).toBeInTheDocument() }) expect(queryChannelsMock).toHaveBeenCalledTimes(1) // Re-render with new inline references for the same logical values — this // is the README-documented usage pattern that previously re-fired the // effect on every render. rerender( void channel} /> ) rerender( void channel} /> ) expect(queryChannelsMock).toHaveBeenCalledTimes(1) }) it('refires queryChannels when initialParticipantFilter actually changes', async () => { queryChannelsMock.mockResolvedValue([makeChannel('existing-1')]) const { rerender } = renderWithProviders( ) await waitFor(() => { expect(queryChannelsMock).toHaveBeenCalledTimes(1) }) rerender() await waitFor(() => { expect(queryChannelsMock).toHaveBeenCalledTimes(2) }) }) it('forwards attachmentPreviewList to the selected channel view', async () => { queryChannelsMock.mockResolvedValue([makeChannel('existing-1')]) const AttachmentPreviewList = () =>
staged attachment
renderWithProviders( ) await waitFor(() => { expect(screen.getByTestId('channel-view')).toBeInTheDocument() }) expect(capturedChannelViewProps.attachmentPreviewList).toBe( AttachmentPreviewList ) }) it('calls onExitConversation and renders "Conversation ended" when the user leaves the conversation', async () => { queryChannelsMock.mockResolvedValue([makeChannel('existing-1')]) const onExit = vi.fn() renderWithProviders( ) await waitFor(() => { expect(screen.getByTestId('channel-view')).toBeInTheDocument() }) const onLeave = capturedChannelViewProps.onLeaveConversation as () => void act(() => onLeave()) expect(onExit).toHaveBeenCalledTimes(1) expect(screen.getByTestId('error-state')).toHaveTextContent( 'Conversation ended' ) expect(screen.queryByTestId('loading-state')).not.toBeInTheDocument() }) it('renders the "Conversation ended" state even when no onExitConversation callback is provided (no permanent spinner)', async () => { queryChannelsMock.mockResolvedValue([makeChannel('existing-1')]) renderWithProviders() await waitFor(() => { expect(screen.getByTestId('channel-view')).toBeInTheDocument() }) const onBlock = capturedChannelViewProps.onBlockParticipant as ( participantId?: string ) => void act(() => onBlock('other-1')) expect(screen.getByTestId('error-state')).toHaveTextContent( 'Conversation ended' ) }) it('does not create the channel again when the load effect re-runs for the same pair (e.g. service identity settles during connect)', async () => { queryChannelsMock.mockResolvedValue([]) startChannelWithParticipantMock.mockResolvedValue(makeChannel('created-1')) const { rerender } = renderWithProviders( ) await waitFor(() => expect(startChannelWithParticipantMock).toHaveBeenCalledTimes(1) ) // A fresh `service` reference (as happens while the connection settles) // re-runs the load effect. The once-per-pair guard must prevent a second // create for the same viewer/participant pair before the new channel is // indexed by queryChannels. useMessagingReturn = { ...useMessagingReturn, service: { startChannelWithParticipant: startChannelWithParticipantMock }, } rerender( ) await new Promise((resolve) => setTimeout(resolve, 50)) expect(startChannelWithParticipantMock).toHaveBeenCalledTimes(1) }) it('does not wipe a newer pair load guard when an earlier in-flight load fails', async () => { const PAIR_A = 'pair-A' const PAIR_B = 'pair-B' // Pair A's query stays pending until we reject it, simulating a load still // in flight when the participant changes. Pair B resolves with no channel. let rejectA: (reason?: unknown) => void = () => {} queryChannelsMock.mockImplementation( (query: { members?: { $eq?: string[] } }) => { if (query?.members?.$eq?.includes(PAIR_A)) { return new Promise((_resolve, reject) => { rejectA = reject }) } return Promise.resolve([]) } ) startChannelWithParticipantMock.mockResolvedValue(makeChannel('created-b')) const { rerender } = renderWithProviders( ) // Switch to pair B while A's load is still pending; B creates its channel. rerender( ) await waitFor(() => expect(startChannelWithParticipantMock).toHaveBeenCalledTimes(1) ) // A's stale load now fails. Its catch must not clear pair B's guard. rejectA(new Error('transient')) await new Promise((resolve) => setTimeout(resolve, 0)) // Re-run the load effect for pair B (a fresh `service` reference). With B's // guard intact this must not create a second channel. useMessagingReturn = { ...useMessagingReturn, service: { startChannelWithParticipant: startChannelWithParticipantMock }, } rerender( ) await new Promise((resolve) => setTimeout(resolve, 50)) expect(startChannelWithParticipantMock).toHaveBeenCalledTimes(1) }) })