import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import { FriendsPage } from './FriendsPage'; import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore'; import { useSpaceStore } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; import type { Friend, FriendRequest } from '@backspace/shared'; // Mock the mascot animation hook vi.mock('../../hooks/useMascotAnimation', () => ({ useMascotAnimation: vi.fn(), })); // Stub AudioManager to avoid AudioWorkletNode reference error in jsdom vi.mock('../../audio/AudioManager', () => ({ AudioManager: { getInstance: vi.fn().mockReturnValue({ setOutputDevice: vi.fn(), setVolume: vi.fn(), }), }, })); // Mock the api module vi.mock('../../api/client', () => ({ api: { dm: { create: vi.fn(), }, social: { friends: vi.fn().mockResolvedValue([]), requests: vi.fn().mockResolvedValue([]), sendRequest: vi.fn().mockResolvedValue({ success: true }), updateRequest: vi.fn().mockResolvedValue({ success: true }), cancelRequest: vi.fn().mockResolvedValue({ success: true }), removeFriend: vi.fn().mockResolvedValue({ success: true }), search: vi.fn().mockResolvedValue([]), discover: vi.fn().mockResolvedValue({ users: [], total: 0 }), }, }, })); // Mock the instanceStore (imported by socialStore) vi.mock('../../stores/instanceStore', () => ({ useInstanceStore: Object.assign( (selector: (s: any) => any) => selector({ instances: [], _autoConnectDone: true, }), { getState: () => ({ instances: [], _autoConnectDone: true }), setState: vi.fn(), subscribe: vi.fn(), } ), })); vi.mock('../../stores/discoverStore', () => ({ useDiscoverStore: Object.assign( (selector: (s: any) => any) => selector({ users: [], isLoading: false, searchQuery: '', setSearchQuery: vi.fn(), fetchUsers: vi.fn(), updateRelationship: vi.fn(), }), { getState: () => ({ users: [], isLoading: false, searchQuery: '', fetchUsers: vi.fn(), updateRelationship: vi.fn(), }), setState: vi.fn(), subscribe: vi.fn(), } ), })); vi.mock('../../stores/authStore', () => ({ useAuthStore: Object.assign( (selector: (s: any) => any) => selector({ user: { id: 'current-user' }, }), { getState: () => ({ user: { id: 'current-user' } }), setState: vi.fn(), subscribe: vi.fn(), } ), })); // Mock activityStore vi.mock('../../stores/activityStore', () => ({ useActivityStore: Object.assign( (selector: (s: any) => any) => selector({ userActivities: new Map(), }), { getState: () => ({ userActivities: new Map(), reset: vi.fn() }), setState: vi.fn(), subscribe: vi.fn(), } ), })); const mockNavigate = vi.fn(); vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); return { ...actual, useNavigate: () => mockNavigate, }; }); const makeFriend = (overrides: Partial = {}): TaggedFriend => ({ id: 'friend-1', username: 'testfriend', displayName: 'Test Friend', avatar: null, banner: null, accentColor: null, avatarColor: null, bio: null, status: 'online', customStatus: null, createdAt: Date.now(), addedAt: Date.now(), homeUserId: null, homeInstance: null, _instanceOrigin: '', ...overrides, }); const makeRequest = (overrides: Partial = {}): TaggedFriendRequest => ({ id: 'req-1', fromId: 'other-user', toId: 'current-user', status: 'pending', createdAt: Date.now(), _instanceOrigin: '', user: { id: 'other-user', username: 'otheruser', displayName: 'Other User', avatar: null, banner: null, accentColor: null, avatarColor: null, bio: null, status: 'online', customStatus: null, isAdmin: false, createdAt: Date.now(), homeInstance: null, homeUserId: null, replicatedInstances: [], }, ...overrides, }); function renderFriendsPage() { return render( ); } beforeEach(() => { mockNavigate.mockClear(); // Reset the social store with no-op loaders (we set state directly) useSocialStore.setState({ friends: [], requests: [], isLoading: false, error: null, loadFriends: vi.fn(), loadRequests: vi.fn(), searchUsers: vi.fn().mockResolvedValue([]), }); useSpaceStore.setState({ dmChannels: [], findExistingDmForUser: () => null, }); }); describe('FriendsPage', () => { describe('Add Friend tab', () => { it('renders the search input when Add Friend tab is clicked', async () => { const user = userEvent.setup(); renderFriendsPage(); const addFriendTab = screen.getByText('Add Friend'); await user.click(addFriendTab); expect(screen.getByPlaceholderText(/Search or add by username/)).toBeInTheDocument(); expect(screen.getByText('Find People')).toBeInTheDocument(); }); it('shows Direct Add row and sends request for user@domain input', async () => { const user = userEvent.setup(); const mockSendFriendRequest = vi.fn().mockResolvedValue('req-123'); useSocialStore.setState({ sendFriendRequest: mockSendFriendRequest, }); renderFriendsPage(); await user.click(screen.getByText('Add Friend')); const input = screen.getByPlaceholderText(/Search or add by username/); await user.type(input, 'newbuddy@remote.example.com'); // Direct Add row should appear expect(screen.getByText(/Send friend request to/)).toBeInTheDocument(); // Click Send Request await user.click(screen.getByText('Send Request')); await waitFor(() => { expect(mockSendFriendRequest).toHaveBeenCalledWith('newbuddy@remote.example.com'); }); }); it('shows toast when Direct Add request fails', async () => { const user = userEvent.setup(); const mockSendFriendRequest = vi.fn().mockRejectedValue(new Error('User not found')); const mockAddToast = vi.fn(); useSocialStore.setState({ sendFriendRequest: mockSendFriendRequest, }); useUIStore.setState({ addToast: mockAddToast, }); renderFriendsPage(); await user.click(screen.getByText('Add Friend')); const input = screen.getByPlaceholderText(/Search or add by username/); await user.type(input, 'ghost@remote.example.com'); await user.click(screen.getByText('Send Request')); await waitFor(() => { expect(mockAddToast).toHaveBeenCalledWith('User not found', 'warning'); }); }); it('shows Direct Add row with resolved-form display for plain usernames', async () => { const user = userEvent.setup(); renderFriendsPage(); await user.click(screen.getByText('Add Friend')); const input = screen.getByPlaceholderText(/Search or add by username/); await user.type(input, 'marc'); // Direct Add row appears with the resolved form `marc@`. // The exact host depends on jsdom (localhost:3000 by default), so match by prefix. expect(screen.getByText(/Send friend request to/)).toBeInTheDocument(); expect(screen.getByText(new RegExp(`marc@${window.location.host.replace(/[.+?^${}()|[\]\\]/g, '\\$&')}`))).toBeInTheDocument(); }); it('does not show Direct Add row for malformed @ shapes', async () => { const user = userEvent.setup(); renderFriendsPage(); await user.click(screen.getByText('Add Friend')); const input = screen.getByPlaceholderText(/Search or add by username/); // Lone @ await user.type(input, '@'); expect(screen.queryByText(/Send friend request to/)).not.toBeInTheDocument(); await user.clear(input); // Leading @ — only domain, no baseName await user.type(input, '@bob'); expect(screen.queryByText(/Send friend request to/)).not.toBeInTheDocument(); await user.clear(input); // Trailing @ — only baseName, no domain await user.type(input, 'bob@'); expect(screen.queryByText(/Send friend request to/)).not.toBeInTheDocument(); }); it('calls searchUsers when typing a non-@ query', async () => { const user = userEvent.setup(); const mockSearchUsers = vi.fn().mockResolvedValue([]); useSocialStore.setState({ searchUsers: mockSearchUsers, }); renderFriendsPage(); await user.click(screen.getByText('Add Friend')); const input = screen.getByPlaceholderText(/Search or add by username/); await user.type(input, 'marc'); // Wait for debounce await waitFor(() => { expect(mockSearchUsers).toHaveBeenCalledWith('marc'); }, { timeout: 500 }); }); }); describe('DM button on friend item', () => { it('calls api.dm.create and navigates when clicking the Message button', async () => { const user = userEvent.setup(); const friend = makeFriend({ id: 'friend-42', username: 'dmpal', displayName: 'DM Pal' }); const mockAddDmChannel = vi.fn(); useSocialStore.setState({ friends: [friend], requests: [], }); useSpaceStore.setState({ addDmChannel: mockAddDmChannel, findExistingDmForUser: () => null, }); // Mock the dm.create API const { api } = await import('../../api/client'); (api.dm.create as ReturnType).mockResolvedValue({ id: 'dm-channel-99', createdAt: Date.now(), members: [], }); renderFriendsPage(); // Switch to "All" tab to see the friend await user.click(screen.getByText('All')); // Find the Message button by title const dmButton = screen.getByTitle('Message'); await user.click(dmButton); await waitFor(() => { expect(api.dm.create).toHaveBeenCalledWith({ userId: 'friend-42' }); }); await waitFor(() => { expect(mockAddDmChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'dm-channel-99' })); }); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('/channels/@me/dm-channel-99'); }); }); }); describe('Cancel outgoing friend request', () => { it('calls cancelFriendRequest when clicking cancel on an outgoing request', async () => { const user = userEvent.setup(); const mockCancel = vi.fn().mockResolvedValue(undefined); // Outgoing request: user.id === toId means current user sent it (fromId is current user, user is the recipient) const outgoingRequest = makeRequest({ id: 'req-out-1', fromId: 'current-user', toId: 'other-user', user: { id: 'other-user', username: 'recipient', displayName: 'Recipient', avatar: null, banner: null, accentColor: null, avatarColor: null, bio: null, status: 'online', customStatus: null, isAdmin: false, createdAt: Date.now(), homeInstance: null, homeUserId: null, replicatedInstances: [], }, }); useSocialStore.setState({ friends: [], requests: [outgoingRequest], cancelFriendRequest: mockCancel, }); renderFriendsPage(); // Switch to Pending tab await user.click(screen.getByText('Pending')); // Should see the outgoing request expect(screen.getByText('Outgoing Friend Request')).toBeInTheDocument(); // Click the cancel button (the X icon button with title "Cancel Request") const cancelButton = screen.getByTitle('Cancel Request'); await user.click(cancelButton); await waitFor(() => { expect(mockCancel).toHaveBeenCalledWith('req-out-1'); }); }); }); describe('Accept/Decline incoming friend request', () => { it('calls updateFriendRequest with "accepted" when clicking accept', async () => { const user = userEvent.setup(); const mockUpdate = vi.fn().mockResolvedValue(undefined); const incomingRequest = makeRequest({ id: 'req-in-1', fromId: 'sender-id', toId: 'current-user', user: { id: 'sender-id', username: 'sender', displayName: 'Sender', avatar: null, banner: null, accentColor: null, avatarColor: null, bio: null, status: 'online', customStatus: null, isAdmin: false, createdAt: Date.now(), homeInstance: null, homeUserId: null, replicatedInstances: [], }, }); useSocialStore.setState({ friends: [], requests: [incomingRequest], updateFriendRequest: mockUpdate, }); renderFriendsPage(); await user.click(screen.getByText('Pending')); expect(screen.getByText('Incoming Friend Request')).toBeInTheDocument(); // Click accept button (title "Accept") const acceptButton = screen.getByTitle('Accept'); await user.click(acceptButton); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith('req-in-1', 'accepted'); }); }); it('calls updateFriendRequest with "declined" when clicking decline', async () => { const user = userEvent.setup(); const mockUpdate = vi.fn().mockResolvedValue(undefined); const incomingRequest = makeRequest({ id: 'req-in-2', fromId: 'sender-id', toId: 'current-user', user: { id: 'sender-id', username: 'sender2', displayName: 'Sender 2', avatar: null, banner: null, accentColor: null, avatarColor: null, bio: null, status: 'online', customStatus: null, isAdmin: false, createdAt: Date.now(), homeInstance: null, homeUserId: null, replicatedInstances: [], }, }); useSocialStore.setState({ friends: [], requests: [incomingRequest], updateFriendRequest: mockUpdate, }); renderFriendsPage(); await user.click(screen.getByText('Pending')); const declineButton = screen.getByTitle('Decline'); await user.click(declineButton); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith('req-in-2', 'declined'); }); }); }); describe('Empty state mascot messages', () => { it('shows "No one\'s online right now." in online tab when no friends online', () => { useSocialStore.setState({ friends: [], requests: [] }); renderFriendsPage(); expect(screen.getByText("No one's online right now.")).toBeInTheDocument(); expect(screen.queryByText(/Wumpus/)).not.toBeInTheDocument(); }); it('shows "No friends yet — add someone!" in all tab when no friends', async () => { const user = userEvent.setup(); useSocialStore.setState({ friends: [], requests: [] }); renderFriendsPage(); await user.click(screen.getByText('All')); expect(screen.getByText('No friends yet — add someone!')).toBeInTheDocument(); expect(screen.queryByText(/Wumpus/)).not.toBeInTheDocument(); }); it('shows "No pending requests — Nori is napping." in pending tab when empty', async () => { const user = userEvent.setup(); useSocialStore.setState({ friends: [], requests: [] }); renderFriendsPage(); await user.click(screen.getByText('Pending')); expect(screen.getByText('No pending requests — Nori is napping.')).toBeInTheDocument(); expect(screen.queryByText(/Wumpus/)).not.toBeInTheDocument(); }); }); });