import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { User, DmChannel } from '@backspace/shared'; // ── Stubs / mocks ────────────────────────────────────────────────────────── // AudioManager is pulled in transitively via spaceStore — avoid AudioWorkletNode. vi.mock('../../audio/AudioManager', () => ({ AudioManager: { getInstance: vi.fn().mockReturnValue({ setOutputDevice: vi.fn(), setVolume: vi.fn(), }), }, })); // Mutable selector-state holders so individual tests can flip caller / channel // shape without rebuilding the whole module mock graph. type ChatStateShape = { currentChannelId: string | null }; type SpaceStateShape = { dmChannels: DmChannel[]; currentSpaceId: string | null; userViews: Map }; type UIStateShape = { memberListOpen: boolean; showDms: boolean; isMobile: boolean; openUserProfile: ReturnType; addToast: ReturnType; }; type AuthStateShape = { user: User | null }; type SocialStateShape = { friends: { id: string }[]; removeFriend: ReturnType }; const chatState: ChatStateShape = { currentChannelId: 'dm-1' }; const spaceState: SpaceStateShape = { dmChannels: [], currentSpaceId: null, userViews: new Map(), }; const uiState: UIStateShape = { memberListOpen: true, showDms: true, isMobile: false, openUserProfile: vi.fn(), addToast: vi.fn(), }; const authState: AuthStateShape = { user: null }; const socialState: SocialStateShape = { friends: [], removeFriend: vi.fn() }; vi.mock('../../stores/chatStore', () => ({ useChatStore: Object.assign( (selector: (s: ChatStateShape) => unknown) => selector(chatState), { getState: () => chatState, setState: vi.fn(), subscribe: vi.fn(), }, ), })); vi.mock('../../stores/spaceStore', () => ({ useSpaceStore: Object.assign( (selector: (s: SpaceStateShape) => unknown) => selector(spaceState), { getState: () => spaceState, setState: vi.fn(), subscribe: vi.fn(), }, ), })); vi.mock('../../stores/uiStore', () => ({ useUIStore: Object.assign( (selector: (s: UIStateShape) => unknown) => selector(uiState), { getState: () => uiState, setState: vi.fn(), subscribe: vi.fn(), }, ), })); vi.mock('../../stores/authStore', () => ({ useAuthStore: Object.assign( (selector: (s: AuthStateShape) => unknown) => selector(authState), { getState: () => authState, setState: vi.fn(), subscribe: vi.fn(), }, ), })); vi.mock('../../stores/socialStore', () => ({ useSocialStore: Object.assign( (selector: (s: SocialStateShape) => unknown) => selector(socialState), { getState: () => socialState, setState: vi.fn(), subscribe: vi.fn(), }, ), })); // API client — both owner-only methods are stubbed; calls return success by default. const apiKickMember = vi.fn().mockResolvedValue({ success: true }); const apiTransferOwnership = vi.fn().mockResolvedValue({}); vi.mock('../../api/client', () => ({ api: { dm: { kickMember: (channelId: string, userId: string) => apiKickMember(channelId, userId), transferOwnership: (channelId: string, userId: string) => apiTransferOwnership(channelId, userId), }, }, })); // useCanonicalUserView falls back to the input on cache miss; that's exactly // what we want here. // ── Imports under test ───────────────────────────────────────────────────── import { DmRosterPanel } from './DmRosterPanel'; import { ContextMenuRenderer } from '../ui/ContextMenuRenderer'; import { useContextMenuStore } from '../../stores/contextMenuStore'; // ── Helpers ──────────────────────────────────────────────────────────────── function makeUser(overrides: Partial = {}): User { return { id: 'u-default', username: 'someone', displayName: null, avatar: null, banner: null, accentColor: null, avatarColor: null, bio: null, status: 'online', customStatus: null, isAdmin: false, createdAt: 0, homeInstance: null, homeUserId: null, replicatedInstances: [], ...overrides, }; } function makeGroupDm(members: User[], ownerId: string): DmChannel { return { id: 'dm-1', federatedId: null, ownerId, ownerHomeUserId: null, ownerHomeInstance: null, createdAt: 0, members, lastMessage: null, name: null, icon: null, metadataUpdatedAt: 0, }; } function setScenario(opts: { caller: User; members: User[]; ownerId: string; isGroupDm?: boolean; memberListOpen?: boolean; showDms?: boolean; currentSpaceId?: string | null; friends?: { id: string }[]; channelId?: string | null; }) { authState.user = opts.caller; uiState.memberListOpen = opts.memberListOpen ?? true; uiState.showDms = opts.showDms ?? true; spaceState.currentSpaceId = opts.currentSpaceId ?? null; spaceState.dmChannels = opts.isGroupDm === false ? [] : [makeGroupDm(opts.members, opts.ownerId)]; // Also support 1-on-1 case: caller passes isGroupDm=false → no channel. if (opts.isGroupDm === false) { spaceState.dmChannels = [{ ...makeGroupDm(opts.members, opts.ownerId), ownerId: null, }]; } chatState.currentChannelId = opts.channelId === undefined ? 'dm-1' : opts.channelId; socialState.friends = opts.friends ?? []; } beforeEach(() => { // Reset shared state before every test. authState.user = null; spaceState.dmChannels = []; spaceState.currentSpaceId = null; spaceState.userViews = new Map(); uiState.memberListOpen = true; uiState.showDms = true; uiState.isMobile = false; uiState.openUserProfile = vi.fn(); uiState.addToast = vi.fn(); chatState.currentChannelId = 'dm-1'; socialState.friends = []; socialState.removeFriend = vi.fn().mockResolvedValue(undefined); apiKickMember.mockClear().mockResolvedValue({ success: true }); apiTransferOwnership.mockClear().mockResolvedValue({}); act(() => { useContextMenuStore.getState().close(); }); }); function renderPanel() { return render( <> , ); } function getMenuLabels(): string[] { const buttons = document.querySelectorAll('div.fixed.z-\\[200\\] button'); return Array.from(buttons).map((b) => b.textContent?.trim() ?? ''); } // ── Tests ────────────────────────────────────────────────────────────────── describe('DmRosterPanel — visibility gates', () => { it('renders nothing when memberListOpen is false', () => { const owner = makeUser({ id: 'owner-1', username: 'owner' }); setScenario({ caller: owner, members: [owner], ownerId: 'owner-1', memberListOpen: false }); const { container } = renderPanel(); expect(container.querySelector('[data-dm-roster-panel]')).toBeFalsy(); }); it('renders nothing when current channel is not a group DM', () => { const caller = makeUser({ id: 'me' }); setScenario({ caller, members: [caller], ownerId: 'me', isGroupDm: false, }); const { container } = renderPanel(); expect(container.querySelector('[data-dm-roster-panel]')).toBeFalsy(); }); it('renders nothing when not in DM view (currentSpaceId set + showDms=false)', () => { const owner = makeUser({ id: 'owner-1' }); setScenario({ caller: owner, members: [owner], ownerId: 'owner-1', showDms: false, currentSpaceId: 'space-1', }); const { container } = renderPanel(); expect(container.querySelector('[data-dm-roster-panel]')).toBeFalsy(); }); it('renders the panel for a group DM with member list open', () => { const owner = makeUser({ id: 'owner-1', username: 'owner', displayName: 'Owner' }); setScenario({ caller: owner, members: [owner], ownerId: 'owner-1' }); const { container } = renderPanel(); expect(container.querySelector('[data-dm-roster-panel]')).toBeTruthy(); expect(container.querySelector('[data-dm-roster-header]')?.textContent).toContain('Members — 1'); }); }); describe('DmRosterPanel — section grouping', () => { it('groups members into OWNER + ONLINE + OFFLINE sections', () => { const owner = makeUser({ id: 'o', username: 'owner', displayName: 'Owner', status: 'online' }); const onlineA = makeUser({ id: 'a', username: 'alice', displayName: 'Alice', status: 'online' }); const onlineB = makeUser({ id: 'b', username: 'bob', displayName: 'Bob', status: 'online' }); const offline = makeUser({ id: 'z', username: 'zoe', displayName: 'Zoe', status: 'offline' }); setScenario({ caller: makeUser({ id: 'me' }), members: [owner, onlineB, onlineA, offline], ownerId: 'o', }); const { container } = renderPanel(); expect(container.querySelector('[data-dm-roster-section="owner"]')).toBeTruthy(); expect(container.querySelector('[data-dm-roster-section="online"]')).toBeTruthy(); expect(container.querySelector('[data-dm-roster-section="offline"]')).toBeTruthy(); expect(container.querySelector('[data-dm-roster-header]')?.textContent).toContain('Members — 4'); }); it('sorts ONLINE and OFFLINE alphabetically by displayName', () => { const owner = makeUser({ id: 'o', username: 'zzz-owner', displayName: 'Owner' }); const charlie = makeUser({ id: 'c', username: 'c', displayName: 'Charlie', status: 'online' }); const alice = makeUser({ id: 'a', username: 'a', displayName: 'Alice', status: 'online' }); const bob = makeUser({ id: 'b', username: 'b', displayName: 'Bob', status: 'online' }); setScenario({ caller: makeUser({ id: 'me' }), members: [owner, charlie, alice, bob], ownerId: 'o', }); const { container } = renderPanel(); const onlineSection = container.querySelector('[data-dm-roster-section="online"]')!; const rows = onlineSection.querySelectorAll('[data-dm-member-row]'); const names = Array.from(rows).map((r) => r.getAttribute('data-user-id')); expect(names).toEqual(['a', 'b', 'c']); }); it('hides empty ONLINE / OFFLINE sections', () => { const owner = makeUser({ id: 'o', displayName: 'Solo' }); setScenario({ caller: owner, members: [owner], ownerId: 'o' }); const { container } = renderPanel(); expect(container.querySelector('[data-dm-roster-section="online"]')).toBeFalsy(); expect(container.querySelector('[data-dm-roster-section="offline"]')).toBeFalsy(); }); }); describe('DmRosterPanel — action wiring', () => { it('kick action: opens confirm dialog, then calls api.dm.kickMember on confirm', async () => { const u = userEvent.setup(); const owner = makeUser({ id: 'me', username: 'me', displayName: 'Me' }); const target = makeUser({ id: 'tgt', username: 'tgt', displayName: 'Tgt' }); setScenario({ caller: owner, members: [owner, target], ownerId: 'me' }); const { container } = renderPanel(); // Open menu via right-click on the target's row. const targetRow = container.querySelector( '[data-dm-roster-section="online"] [data-dm-member-row]', ) as HTMLElement; expect(targetRow).toBeTruthy(); targetRow.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 })); const kickBtn = await screen.findByText('Remove from Group'); await u.click(kickBtn); // Confirm dialog visible. const confirmBtn = await screen.findByRole('button', { name: 'Remove' }); await u.click(confirmBtn); await waitFor(() => { expect(apiKickMember).toHaveBeenCalledTimes(1); }); expect(apiKickMember).toHaveBeenCalledWith('dm-1', 'tgt'); }); it('transfer action: opens confirm dialog, then calls api.dm.transferOwnership on confirm', async () => { const u = userEvent.setup(); const owner = makeUser({ id: 'me', username: 'me', displayName: 'Me' }); const target = makeUser({ id: 'tgt', username: 'tgt', displayName: 'Tgt' }); setScenario({ caller: owner, members: [owner, target], ownerId: 'me' }); const { container } = renderPanel(); const targetRow = container.querySelector( '[data-dm-roster-section="online"] [data-dm-member-row]', ) as HTMLElement; targetRow.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 })); const transferBtn = await screen.findByText('Transfer Ownership'); await u.click(transferBtn); const confirmBtn = await screen.findByRole('button', { name: 'Transfer' }); await u.click(confirmBtn); await waitFor(() => { expect(apiTransferOwnership).toHaveBeenCalledTimes(1); }); expect(apiTransferOwnership).toHaveBeenCalledWith('dm-1', 'tgt'); }); it('remove-friend action: calls socialStore.removeFriend with the row user id', async () => { const u = userEvent.setup(); const me = makeUser({ id: 'me' }); const friend = makeUser({ id: 'friend-1', username: 'friend', displayName: 'Friend' }); setScenario({ caller: me, members: [me, friend], ownerId: 'someone-else', // caller is not owner friends: [{ id: 'friend-1' }], }); const { container } = renderPanel(); // Find the friend's row in the online section. const onlineSection = container.querySelector('[data-dm-roster-section="online"]')!; const rows = onlineSection.querySelectorAll('[data-dm-member-row]'); const friendRow = Array.from(rows).find( (r) => r.getAttribute('data-user-id') === 'friend-1', ) as HTMLElement; expect(friendRow).toBeTruthy(); friendRow.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 })); const removeBtn = await screen.findByText('Remove Friend'); await u.click(removeBtn); await waitFor(() => { expect(socialState.removeFriend).toHaveBeenCalledWith('friend-1'); }); }); it('profile action: calls openUserProfile with the row user', async () => { const u = userEvent.setup(); const me = makeUser({ id: 'me' }); const other = makeUser({ id: 'other', username: 'other', displayName: 'Other' }); setScenario({ caller: me, members: [me, other], ownerId: 'me' }); const { container } = renderPanel(); const targetRow = container.querySelector( '[data-dm-roster-section="online"] [data-dm-member-row]', ) as HTMLElement; targetRow.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 })); const profileBtn = await screen.findByText('View Profile'); await u.click(profileBtn); expect(uiState.openUserProfile).toHaveBeenCalledTimes(1); expect(uiState.openUserProfile.mock.calls[0]![0]).toEqual(expect.objectContaining({ id: 'other' })); }); }); describe('DmRosterPanel — caller permissions wiring', () => { it('non-owner caller does NOT see kick/transfer in any row menu', async () => { const me = makeUser({ id: 'me' }); const owner = makeUser({ id: 'o', username: 'owner', displayName: 'Owner' }); const other = makeUser({ id: 'o2', username: 'o2', displayName: 'O2' }); setScenario({ caller: me, members: [me, owner, other], ownerId: 'o', }); const { container } = renderPanel(); // Pick "other" row in the online section. const otherRow = Array.from( container.querySelectorAll('[data-dm-roster-section="online"] [data-dm-member-row]'), ).find((r) => r.getAttribute('data-user-id') === 'o2') as HTMLElement; act(() => { otherRow.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 }), ); }); // Wait for the portal to render the menu, then assert on its label set. await screen.findByText('View Profile'); const labels = getMenuLabels(); expect(labels).not.toContain('Transfer Ownership'); expect(labels).not.toContain('Remove from Group'); expect(labels).toContain('View Profile'); }); it('owner caller sees kick + transfer for non-self rows', async () => { const me = makeUser({ id: 'me', username: 'me', displayName: 'Me' }); const target = makeUser({ id: 'tgt', username: 'tgt', displayName: 'Tgt' }); setScenario({ caller: me, members: [me, target], ownerId: 'me' }); const { container } = renderPanel(); const targetRow = container.querySelector( '[data-dm-roster-section="online"] [data-dm-member-row]', ) as HTMLElement; act(() => { targetRow.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 }), ); }); await screen.findByText('Transfer Ownership'); const labels = getMenuLabels(); expect(labels).toContain('Transfer Ownership'); expect(labels).toContain('Remove from Group'); }); });