import React from 'react' import type { Channel, ChannelMemberResponse } from 'stream-chat' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithProviders, screen, userEvent, waitFor, } from '../../test/utils' import { ChannelActionsMenu } from './index' const { getBlockedUsersMock, blockUserMock, unBlockUserMock } = vi.hoisted( () => ({ getBlockedUsersMock: vi.fn().mockResolvedValue([]), blockUserMock: vi.fn().mockResolvedValue(undefined), unBlockUserMock: vi.fn().mockResolvedValue(undefined), }) ) // Stable service + context references — production MessagingProvider keeps // the service in useState and memoizes the context value, so the hook's // service-keyed lookup invalidation only fires on real swaps. Returning a // new object literal each call would re-trigger the lookup every render and // loop forever. const mockService = { getBlockedUsers: getBlockedUsersMock, blockUser: blockUserMock, unBlockUser: unBlockUserMock, } const mockContext = { service: mockService, debug: false } vi.mock('../../providers/MessagingProvider', () => ({ useMessagingContext: () => mockContext, })) vi.mock('../ActionButton', () => ({ default: ({ children, onClick, disabled, }: { children: React.ReactNode onClick?: () => void disabled?: boolean }) => ( ), })) vi.mock('@phosphor-icons/react', () => ({ DotsThreeIcon: () => , FlagIcon: () => , ProhibitInsetIcon: () => , SignOutIcon: () => , SpinnerGapIcon: () => , })) const createChannel = () => ({ id: 'channel-1', cid: 'messaging:channel-1', data: {}, _client: { userID: 'visitor-1' }, state: { members: {}, membership: {}, messages: [], }, hide: vi.fn(), }) as unknown as Channel const createParticipant = () => ({ user: { id: 'linker-1', name: 'Linker', }, role: 'member', }) as unknown as ChannelMemberResponse const defaultProps = () => ({ channel: createChannel(), participant: createParticipant(), }) const openMenu = async () => userEvent.click(screen.getByRole('button', { name: 'More options' })) describe('ChannelActionsMenu', () => { beforeEach(() => { vi.clearAllMocks() getBlockedUsersMock.mockResolvedValue([]) }) it('renders the trigger but keeps options hidden until opened', () => { renderWithProviders() expect( screen.getByRole('button', { name: 'More options' }) ).toBeInTheDocument() expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument() expect(screen.queryByText('Block')).not.toBeInTheDocument() expect(screen.queryByText('Report')).not.toBeInTheDocument() }) it('reflects open state via aria-expanded and shows the options inline', async () => { renderWithProviders() const trigger = screen.getByRole('button', { name: 'More options' }) expect(trigger).toHaveAttribute('aria-expanded', 'false') await openMenu() expect(trigger).toHaveAttribute('aria-expanded', 'true') expect(screen.getByText('Delete Conversation')).toBeInTheDocument() expect(screen.getByText('Block')).toBeInTheDocument() expect(screen.getByText('Report')).toBeInTheDocument() // Each option renders its icon inline. expect(screen.getByTestId('signout-icon')).toBeInTheDocument() expect(screen.getByTestId('prohibit-icon')).toBeInTheDocument() expect(screen.getByTestId('flag-icon')).toBeInTheDocument() }) it('closes the popover when Escape is pressed', async () => { renderWithProviders() await openMenu() expect(screen.getByText('Report')).toBeInTheDocument() await userEvent.keyboard('{Escape}') await waitFor(() => { expect(screen.queryByText('Report')).not.toBeInTheDocument() }) }) it('closes the popover when clicking outside', async () => { renderWithProviders(
outside
) await openMenu() expect(screen.getByText('Report')).toBeInTheDocument() await userEvent.click(screen.getByTestId('outside')) await waitFor(() => { expect(screen.queryByText('Report')).not.toBeInTheDocument() }) }) it('calls onDeleteConversationClick and closes after deleting', async () => { const onDeleteConversationClick = vi.fn() renderWithProviders( ) await openMenu() await userEvent.click(screen.getByText('Delete Conversation')) await waitFor(() => { expect(onDeleteConversationClick).toHaveBeenCalledOnce() }) await waitFor(() => { expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument() }) }) it('calls onBlockParticipantClick and blocks the user', async () => { const onBlockParticipantClick = vi.fn() renderWithProviders( ) await openMenu() await userEvent.click(screen.getByText('Block')) await waitFor(() => { expect(onBlockParticipantClick).toHaveBeenCalledOnce() expect(blockUserMock).toHaveBeenCalledWith('linker-1') }) }) it('shows Unblock when the participant is already blocked', async () => { getBlockedUsersMock.mockResolvedValue([{ blocked_user_id: 'linker-1' }]) renderWithProviders() await openMenu() await waitFor(() => { expect(screen.getByText('Unblock')).toBeInTheDocument() }) expect(screen.queryByText('Block')).not.toBeInTheDocument() }) it('disables the block action until the blocked-status lookup resolves', async () => { let resolveBlockedUsers: ( value: Array<{ blocked_user_id: string }> ) => void = () => {} getBlockedUsersMock.mockImplementation( () => new Promise((resolve) => { resolveBlockedUsers = resolve }) ) renderWithProviders() await openMenu() // While the lookup is pending the action is disabled so a premature click // can't act on a stale block state. const pendingBlock = screen.getByText('Block').closest('button') expect(pendingBlock).toBeDisabled() await userEvent.click(pendingBlock as HTMLElement) expect(blockUserMock).not.toHaveBeenCalled() // Once the lookup resolves (already blocked) the Unblock action is offered. resolveBlockedUsers([{ blocked_user_id: 'linker-1' }]) await waitFor(() => { expect(screen.getByText('Unblock')).toBeInTheDocument() }) }) it('recovers from a blocked-status lookup failure', async () => { // Suppress the expected console.error for the rejected lookup so the // test output stays clean. const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}) getBlockedUsersMock.mockRejectedValueOnce(new Error('network down')) renderWithProviders() await openMenu() // The lookup rejected, but the action recovers — Block becomes actionable // rather than staying stuck in the disabled spinner state. await waitFor(() => { const block = screen.getByText('Block').closest('button') expect(block).not.toBeDisabled() }) consoleErrorSpy.mockRestore() }) it('calls onReportParticipantClick and opens the report page', async () => { const onReportParticipantClick = vi.fn() const windowOpenSpy = vi .spyOn(window, 'open') .mockImplementation(() => null) renderWithProviders( ) await openMenu() await userEvent.click(screen.getByText('Report')) await waitFor(() => { expect(onReportParticipantClick).toHaveBeenCalledOnce() expect(windowOpenSpy).toHaveBeenCalled() }) windowOpenSpy.mockRestore() }) it('hides actions based on the show* flags', async () => { renderWithProviders( Custom} /> ) await openMenu() expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument() expect(screen.queryByText('Block')).not.toBeInTheDocument() expect(screen.queryByText('Report')).not.toBeInTheDocument() expect(screen.getByTestId('custom-action')).toBeInTheDocument() }) it('does not fetch blocked users until the menu is opened', async () => { renderWithProviders() expect(getBlockedUsersMock).not.toHaveBeenCalled() await openMenu() await waitFor(() => { expect(getBlockedUsersMock).toHaveBeenCalled() }) }) it('renders nothing when there is no participant', () => { const { container } = renderWithProviders( ) expect(container).toBeEmptyDOMElement() }) it('renders nothing when no actions are available', () => { const { container } = renderWithProviders( ) expect(container).toBeEmptyDOMElement() }) })