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