import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; // Stub AudioManager to avoid AudioWorkletNode reference error in jsdom. // Reached transitively via spaceStore → chatStore → useWebSocket → voiceStore. vi.mock('../../audio/AudioManager', () => ({ AudioManager: { getInstance: vi.fn().mockReturnValue({ setOutputDevice: vi.fn(), setVolume: vi.fn(), }), }, })); // Mock the API client so we can assert spaceInvite calls. const mockSpaceInvite = vi.fn(); vi.mock('../../api/client', () => ({ api: { dm: { spaceInvite: (...args: unknown[]) => mockSpaceInvite(...args), }, }, })); import { InviteModal } from './InviteModal'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore } from '../../stores/spaceStore'; import { useSocialStore } from '../../stores/socialStore'; import { useAuthStore } from '../../stores/authStore'; // Helpers — minimal fixtures for store state. function makeFriend(overrides: Partial = {}) { return { id: 'friend-1', username: 'alex', displayName: 'Alex', avatar: null, banner: null, accentColor: null, avatarColor: null, bio: null, status: 'online' as const, customStatus: null, createdAt: 0, addedAt: 0, homeUserId: null, homeInstance: null, _instanceOrigin: '', ...overrides, }; } function makeMember(overrides: Partial = {}) { return { spaceId: 'space-1', userId: 'member-1', nickname: null, joinedAt: 0, roles: [], user: { id: 'member-1', username: 'memberA', displayName: null, avatar: null, banner: null, accentColor: null, avatarColor: null, bio: null, status: 'offline', customStatus: null, isAdmin: false, createdAt: 0, homeInstance: null, homeUserId: null, replicatedInstances: [], ...(overrides.user ?? {}), }, ...overrides, }; } function makeSpace(overrides: Partial = {}) { return { id: 'space-1', name: 'Test Space', icon: null, avatarColor: null, description: null, ownerId: 'owner-1', public: false, discoverable: false, joinPolicy: 'invite', createdAt: 0, _instanceOrigin: '', ...overrides, }; } function setUpStore({ friends = [], members = [], myUser = { id: 'me', username: 'me' } as any, generateInvite = vi.fn().mockResolvedValue('test-code'), }: { friends?: any[]; members?: any[]; myUser?: any; generateInvite?: ReturnType; } = {}) { useUIStore.setState({ activeModal: 'invite', modalData: {} }); useSpaceStore.setState({ currentSpaceId: 'space-1', spaces: [makeSpace()] as any, members, generateInvite, } as any); useSocialStore.setState({ friends } as any); useAuthStore.setState({ user: myUser } as any); return { generateInvite }; } beforeEach(() => { mockSpaceInvite.mockReset(); useUIStore.setState({ activeModal: null, modalData: {} }); useSpaceStore.setState({ currentSpaceId: null, spaces: [], members: [], } as any); useSocialStore.setState({ friends: [] } as any); useAuthStore.setState({ user: null } as any); // Mock clipboard. Object.defineProperty(navigator, 'clipboard', { value: { writeText: vi.fn().mockResolvedValue(undefined) }, writable: true, configurable: true, }); }); describe('InviteModal', () => { it('does not render when activeModal is not "invite"', () => { render(); expect(screen.queryByText('Invite Friends')).not.toBeInTheDocument(); }); it('renders friend list, excluding self', async () => { setUpStore({ friends: [ makeFriend({ id: 'me', username: 'me', displayName: 'Me' }), makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }), makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }), ], myUser: { id: 'me', username: 'me' }, }); render(); expect(screen.getByText('Invite Friends')).toBeInTheDocument(); await waitFor(() => { expect(screen.getByText('Alex')).toBeInTheDocument(); }); expect(screen.getByText('Sam')).toBeInTheDocument(); // Self is filtered out. expect(screen.queryByText('Me')).not.toBeInTheDocument(); }); it('grays out friends already in the space using federated identity', async () => { // Friend on remote instance orbit.ddns.net, member entry replicated // locally with user.homeUserId+homeInstance matching that identity. setUpStore({ friends: [ makeFriend({ id: 'local-shadow-id', username: 'remoteFriend', displayName: 'Remote Friend', homeUserId: 'remote-uid-7', homeInstance: 'https://orbit.ddns.net', }), ], members: [ makeMember({ userId: 'local-replicated-id', user: { id: 'local-replicated-id', username: 'remoteFriend', homeUserId: 'remote-uid-7', homeInstance: 'https://orbit.ddns.net', }, }), ], }); render(); await waitFor(() => { expect(screen.getByText('Already in space')).toBeInTheDocument(); }); // Clicking the disabled row does not select the friend. const button = screen.getByRole('button', { name: /Remote Friend/ }); expect(button).toBeDisabled(); }); it('selecting friends and submitting calls api.dm.spaceInvite once per friend', async () => { const user = userEvent.setup(); mockSpaceInvite.mockResolvedValue({}); setUpStore({ friends: [ makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }), makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }), ], }); render(); await waitFor(() => { expect(screen.getByText('Alex')).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /Alex/ })); await user.click(screen.getByRole('button', { name: /Sam/ })); const submit = screen.getByRole('button', { name: /Send 2 Invites/ }); await user.click(submit); await waitFor(() => { expect(mockSpaceInvite).toHaveBeenCalledTimes(2); }); expect(mockSpaceInvite).toHaveBeenCalledWith( expect.objectContaining({ spaceId: 'space-1', inviteCode: 'test-code', target: { userId: 'f1' }, }), ); expect(mockSpaceInvite).toHaveBeenCalledWith( expect.objectContaining({ spaceId: 'space-1', inviteCode: 'test-code', target: { userId: 'f2' }, }), ); }); it('closes the modal after a fully successful send', async () => { const user = userEvent.setup(); mockSpaceInvite.mockResolvedValue({}); setUpStore({ friends: [makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' })], }); render(); await waitFor(() => { expect(screen.getByText('Alex')).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /Alex/ })); await user.click(screen.getByRole('button', { name: /Send 1 Invite/ })); await waitFor(() => { expect(useUIStore.getState().activeModal).toBeNull(); }); }); it('shows the results view with reason text on partial failure', async () => { const user = userEvent.setup(); // f1 succeeds, f2 fails with `not_a_friend`. mockSpaceInvite.mockImplementation(({ target }: any) => { if (target.userId === 'f1') return Promise.resolve({}); return Promise.reject(new Error('not_a_friend')); }); setUpStore({ friends: [ makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }), makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }), ], }); render(); await waitFor(() => { expect(screen.getByText('Alex')).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /Alex/ })); await user.click(screen.getByRole('button', { name: /Sam/ })); await user.click(screen.getByRole('button', { name: /Send 2 Invites/ })); await waitFor(() => { expect(screen.getByText('✓ Sent')).toBeInTheDocument(); }); expect(screen.getByText('✗ Not a friend')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Retry failed' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument(); // Modal must remain open while results are visible. expect(useUIStore.getState().activeModal).toBe('invite'); }); it('Retry failed re-runs only the failed subset', async () => { const user = userEvent.setup(); mockSpaceInvite.mockImplementation(({ target }: any) => { if (target.userId === 'f1') return Promise.resolve({}); return Promise.reject(new Error('upstream')); }); setUpStore({ friends: [ makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }), makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }), ], }); render(); await waitFor(() => { expect(screen.getByText('Alex')).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /Alex/ })); await user.click(screen.getByRole('button', { name: /Sam/ })); await user.click(screen.getByRole('button', { name: /Send 2 Invites/ })); await waitFor(() => { expect(screen.getByRole('button', { name: 'Retry failed' })).toBeInTheDocument(); }); expect(mockSpaceInvite).toHaveBeenCalledTimes(2); // Second pass: Sam succeeds. mockSpaceInvite.mockReset(); mockSpaceInvite.mockResolvedValue({}); await user.click(screen.getByRole('button', { name: 'Retry failed' })); await waitFor(() => { expect(mockSpaceInvite).toHaveBeenCalledTimes(1); }); expect(mockSpaceInvite).toHaveBeenCalledWith( expect.objectContaining({ target: { userId: 'f2' } }), ); }); it('share-link footer shows the invite URL and Copy writes to clipboard', async () => { setUpStore({ friends: [], generateInvite: vi.fn().mockResolvedValue('abc123'), }); render(); await waitFor(() => { expect(screen.getByDisplayValue(/\/join\/abc123/)).toBeInTheDocument(); }); // Re-install clipboard spy after render. userEvent.setup() may have replaced // navigator.clipboard during initialization; we need a fresh spy that // matches what the component will call directly. const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, 'clipboard', { value: { writeText }, writable: true, configurable: true, }); const copyBtn = screen.getByRole('button', { name: 'Copy' }); copyBtn.click(); await waitFor(() => { expect(writeText).toHaveBeenCalledWith( expect.stringContaining('/join/abc123'), ); }); expect(await screen.findByText('Copied!')).toBeInTheDocument(); }); it('Copy button is disabled while the invite code is loading', () => { setUpStore({ friends: [], generateInvite: vi.fn().mockReturnValue(new Promise(() => {})), }); render(); const copyButton = screen.getByRole('button', { name: 'Copy' }); expect(copyButton).toBeDisabled(); }); it('shows an error in the share-link footer when generateInvite fails', async () => { setUpStore({ friends: [], generateInvite: vi.fn().mockRejectedValue(new Error('Not authorized')), }); render(); await waitFor(() => { expect(screen.getByText('Not authorized')).toBeInTheDocument(); }); }); it('search filters the friend list', async () => { const user = userEvent.setup(); setUpStore({ friends: [ makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }), makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }), ], }); render(); await waitFor(() => { expect(screen.getByText('Alex')).toBeInTheDocument(); }); const search = screen.getByPlaceholderText('Search friends...'); await user.type(search, 'sam'); await waitFor(() => { expect(screen.queryByText('Alex')).not.toBeInTheDocument(); }); expect(screen.getByText('Sam')).toBeInTheDocument(); }); it('shows an approval-required notice and hides invite affordances for request-only spaces', async () => { const generateInvite = vi.fn().mockResolvedValue('should-not-be-used'); useUIStore.setState({ activeModal: 'invite', modalData: {} }); useSpaceStore.setState({ currentSpaceId: 'space-1', spaces: [makeSpace({ visibility: 'request' })] as any, members: [], generateInvite, } as any); useSocialStore.setState({ friends: [makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' })], } as any); useAuthStore.setState({ user: { id: 'me', username: 'me' } } as any); render(); // Explanatory copy replaces the invite UI. expect(screen.getByText(/join request/i)).toBeInTheDocument(); // None of the invite affordances render. expect(screen.queryByPlaceholderText('Search friends...')).not.toBeInTheDocument(); expect(screen.queryByText('Or share a link')).not.toBeInTheDocument(); expect(screen.queryByText('Alex')).not.toBeInTheDocument(); // No invite code is requested for a request-only space (the endpoint 403s). expect(generateInvite).not.toHaveBeenCalled(); }); it('passes federated target shape for remote friends', async () => { const user = userEvent.setup(); mockSpaceInvite.mockResolvedValue({}); setUpStore({ friends: [ makeFriend({ id: 'local-shadow', username: 'remoteAlex', displayName: 'Remote Alex', homeUserId: 'home-uid-9', homeInstance: 'https://orbit.ddns.net', }), ], }); render(); await waitFor(() => { expect(screen.getByText('Remote Alex')).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: /Remote Alex/ })); await user.click(screen.getByRole('button', { name: /Send 1 Invite/ })); await waitFor(() => { expect(mockSpaceInvite).toHaveBeenCalledWith( expect.objectContaining({ target: { homeUserId: 'home-uid-9', homeInstance: 'https://orbit.ddns.net', }, }), ); }); }); });