diff --git a/src/__tests__/CreateLobbyModal.test.tsx b/src/__tests__/CreateLobbyModal.test.tsx new file mode 100644 index 0000000..254cb7b --- /dev/null +++ b/src/__tests__/CreateLobbyModal.test.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const mockPush = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); + mockPush.mockReset(); +}); + +function game(slug: string, name: string, maxPlayers = 2) { + return { + slug, name, summary: '', rulesSummary: '', category: 'strategy', tags: [], difficulty: 'Medium', + estimatedDurationMinMinutes: 5, estimatedDurationMaxMinutes: 10, minPlayers: 2, maxPlayers, + lifecycle: 'Available', capabilities: [], featuredRank: null, + artToken: slug, artColorA: '#F0394B', artColorB: '#111', artAltText: `${name} art`, + entryActions: [], + }; +} + +function jsonResponse(status: number, body: unknown) { + return Promise.resolve({ ok: status >= 200 && status < 300, status, json: () => Promise.resolve(body) }); +} + +function setupFetch(handler: (url: string) => Promise | null) { + mockFetch.mockImplementation((url: string) => { + const result = handler(String(url)); + if (result) return result; + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + }); +} + +const capabilityProfile = { + gameSlug: 'chess-lite', capabilityVersion: 1, minPlayers: 2, maxPlayers: 2, + allowedModes: ['multiplayer'], timeControls: ['blitz-5'], tieBreakRules: ['sudden-death'], + spectatorPolicies: ['Open'], ratedEligible: true, aiFillEligible: false, +}; + +async function renderModal(props: { preselectedGameSlug?: string } = {}) { + const { CreateLobbyModal } = await import('@/components/lobby/CreateLobbyModal'); + const onClose = vi.fn(); + render(); + return { onClose }; +} + +describe('CreateLobbyModal — preselectedGameSlug', () => { + it('preselects the matching eligible game when preselectedGameSlug is given', async () => { + setupFetch(u => { + if (u.includes('/api/games') && !u.includes('capabilities')) { + return jsonResponse(200, { items: [game('chess-lite', 'Chess Lite'), game('checkers', 'Checkers')], nextCursor: null }); + } + if (u.includes('/api/lobbies/capabilities/chess-lite')) return jsonResponse(200, capabilityProfile); + return null; + }); + await renderModal({ preselectedGameSlug: 'chess-lite' }); + await waitFor(() => expect(mockFetch.mock.calls.some(c => String(c[0]).includes('/capabilities/chess-lite'))).toBe(true)); + }); + + it('falls back to the first eligible game when preselectedGameSlug is not multiplayer-eligible', async () => { + setupFetch(u => { + if (u.includes('/api/games') && !u.includes('capabilities')) { + // 'solo-only' isn't in the eligible (maxPlayers > 1) list at all. + return jsonResponse(200, { items: [game('chess-lite', 'Chess Lite'), game('checkers', 'Checkers')], nextCursor: null }); + } + if (u.includes('/api/lobbies/capabilities/chess-lite')) return jsonResponse(200, capabilityProfile); + return null; + }); + await renderModal({ preselectedGameSlug: 'solo-only' }); + await waitFor(() => expect(mockFetch.mock.calls.some(c => String(c[0]).includes('/capabilities/chess-lite'))).toBe(true)); + }); + + it('filters out single-player games from the picker entirely', async () => { + setupFetch(u => { + if (u.includes('/api/games') && !u.includes('capabilities')) { + return jsonResponse(200, { items: [game('falling-blocks', 'Falling Blocks', 1), game('chess-lite', 'Chess Lite', 2)], nextCursor: null }); + } + if (u.includes('/api/lobbies/capabilities/chess-lite')) return jsonResponse(200, capabilityProfile); + return null; + }); + await renderModal(); + await waitFor(() => expect(screen.getAllByText('Chess Lite').length).toBeGreaterThan(0)); + expect(screen.queryByText('Falling Blocks')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/DashboardFriends.test.tsx b/src/__tests__/DashboardFriends.test.tsx index 0b5aa85..24698a9 100644 --- a/src/__tests__/DashboardFriends.test.tsx +++ b/src/__tests__/DashboardFriends.test.tsx @@ -78,7 +78,8 @@ describe('DashboardPage friends panel', () => { let resolve!: (v: unknown) => void; mockFetch.mockReturnValueOnce(new Promise(r => { resolve = r; })); await renderDashboard(); - expect(screen.getByRole('status')).toBeInTheDocument(); + // Multiple panels (friends, lobby invites) show their own "status" live region while loading. + expect(screen.getAllByRole('status').length).toBeGreaterThan(0); resolve(friendsOk([])); }); diff --git a/src/__tests__/GameDetailPage.test.tsx b/src/__tests__/GameDetailPage.test.tsx index 9c6ad46..388b101 100644 --- a/src/__tests__/GameDetailPage.test.tsx +++ b/src/__tests__/GameDetailPage.test.tsx @@ -14,6 +14,8 @@ vi.mock('@/features/auth/AuthProvider', () => ({ useAuth: () => ({ status: mockAuth.status }), })); +vi.mock('@/components/ui/Toast', () => ({ useToast: () => ({ push: vi.fn() }) })); + const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); @@ -180,4 +182,57 @@ describe('GameDetailPage', () => { favoriteBtn.focus(); expect(favoriteBtn).toHaveFocus(); }); + + // ── M6 entry actions: enabled branch + per-game client gating ────────────── + + it('an enabled action for a multiplayer game opens its real modal (create lobby)', async () => { + mockAuth.status = 'authenticated'; + setupDetailFetch(u => { + if (u.includes('/api/games/chess-lite')) { + return jsonResponse(200, game({ + slug: 'chess-lite', name: 'Chess Lite', minPlayers: 2, maxPlayers: 2, capabilities: ['multiplayer', 'ranked'], + entryActions: [ + { action: 'quick-match', status: 'enabled', reasonCode: 'Games.EntryDeferred.QuickMatch', ownerModule: 6 }, + { action: 'create-lobby', status: 'enabled', reasonCode: 'Games.EntryDeferred.Lobby', ownerModule: 6 }, + { action: 'invite-friend', status: 'enabled', reasonCode: 'Games.EntryDeferred.Invite', ownerModule: 6 }, + ], + })); + } + return null; + }); + await renderPage('chess-lite'); + await waitFor(() => expect(screen.getAllByText('Chess Lite').length).toBeGreaterThan(0)); + + // quick-match is enabled backend-side but this game doesn't declare the 'quick-match' capability — + // the client re-checks per-game and still gates it. + expect(screen.getByText('Not supported for this game.')).toBeInTheDocument(); + const quickMatchBtn = screen.getByText('Quick match').closest('button'); + expect(quickMatchBtn).toBeDisabled(); + + // create-lobby is genuinely enabled for this 2-player game — clicking opens CreateLobbyModal. + const createLobbyBtn = screen.getByText('Create lobby').closest('button'); + expect(createLobbyBtn).not.toBeDisabled(); + await act(async () => { fireEvent.click(createLobbyBtn!); }); + await waitFor(() => expect(screen.getByText('Create a lobby')).toBeInTheDocument()); + }); + + it('create-lobby/invite-friend are client-gated with "This is a solo game." for a single-player game', async () => { + setupDetailFetch(u => { + if (u.includes('/api/games/falling-blocks')) { + return jsonResponse(200, game({ + minPlayers: 1, maxPlayers: 1, + entryActions: [ + { action: 'create-lobby', status: 'enabled', reasonCode: 'Games.EntryDeferred.Lobby', ownerModule: 6 }, + { action: 'invite-friend', status: 'enabled', reasonCode: 'Games.EntryDeferred.Invite', ownerModule: 6 }, + ], + })); + } + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getAllByText('Falling Blocks').length).toBeGreaterThan(0)); + expect(screen.getAllByText('This is a solo game.').length).toBe(2); + expect(screen.getByText('Create lobby').closest('button')).toBeDisabled(); + expect(screen.getByText('Invite friend').closest('button')).toBeDisabled(); + }); }); diff --git a/src/__tests__/InviteFriendModal.test.tsx b/src/__tests__/InviteFriendModal.test.tsx index 6c6a040..eb1ee43 100644 --- a/src/__tests__/InviteFriendModal.test.tsx +++ b/src/__tests__/InviteFriendModal.test.tsx @@ -27,7 +27,7 @@ beforeEach(() => { async function renderModal(open = true) { const { InviteFriendModal } = await import('@/components/friends/InviteFriendModal'); const onClose = vi.fn(); - render(); + render(); return { onClose }; } @@ -102,7 +102,7 @@ describe('InviteFriendModal', () => { // First open: Alice + Bob mockFetch.mockResolvedValueOnce(pagedFriends([makeFriend('f-1', 'Alice'), makeFriend('f-2', 'Bob')])); const onClose = vi.fn(); - const { rerender } = render(); + const { rerender } = render(); await waitFor(() => screen.getByText('Alice')); // Pick Alice by clicking her row @@ -116,9 +116,9 @@ describe('InviteFriendModal', () => { }); // Close then reopen — state should reset (picked = 0) - rerender(); + rerender(); mockFetch.mockResolvedValueOnce(pagedFriends([makeFriend('f-2', 'Bob')])); - rerender(); + rerender(); await waitFor(() => { expect(screen.getByRole('button', { name: /Send invite \(0\)/i })).toBeInTheDocument(); }); diff --git a/src/__tests__/LobbyPage.test.tsx b/src/__tests__/LobbyPage.test.tsx new file mode 100644 index 0000000..e298e01 --- /dev/null +++ b/src/__tests__/LobbyPage.test.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const mockPush = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})); + +const mockUser: { id: string; username: string; displayName: string } | null = { id: 'u-1', username: 'me', displayName: 'Me' }; +vi.mock('@/features/auth/AuthProvider', () => ({ + useAuth: () => ({ user: mockUser, status: mockUser ? 'authenticated' : 'anonymous' }), +})); + +vi.mock('@/components/ui/Toast', () => ({ useToast: () => ({ push: vi.fn() }) })); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); + mockPush.mockReset(); +}); + +function identity(userId: string, name: string) { + return { userId, username: name.toLowerCase(), displayName: name, initials: name.slice(0, 2).toUpperCase(), color: '#F0394B', avatarUrl: null, profileType: 'Standard' }; +} + +function seat(userId: string, name: string, overrides: Partial> = {}) { + return { identity: identity(userId, name), isHost: false, isReady: false, joinedAtUtc: '2026-01-01T00:00:00Z', ...overrides }; +} + +function lobby(overrides: Partial> = {}) { + return { + lobbyId: 'lobby-1', gameSlug: 'chess-lite', capabilityVersion: 1, + privacy: 'Private', maxPlayers: 2, timeControlId: 'blitz-5', rated: false, + resolvedRegion: 'NA', spectatorPolicy: 'Open', tieBreakRuleId: 'sudden-death', + aiFillRequested: false, state: 'Open', revision: 1, expiresAtUtc: '2026-01-01T01:00:00Z', + closedReason: null, hostUserId: 'u-host', seats: [seat('u-host', 'Host', { isHost: true })], + allowedActions: [], dependencyReadiness: { chat: false, matchRuntime: false, aiParticipants: false }, + ...overrides, + }; +} + +function jsonResponse(status: number, body: unknown) { + return Promise.resolve({ ok: status >= 200 && status < 300, status, json: () => Promise.resolve(body) }); +} + +function setupFetch(handler: (url: string, opts?: { method?: string; body?: string }) => Promise | null) { + mockFetch.mockImplementation((url: string, opts?: { method?: string; body?: string }) => { + const result = handler(String(url), opts); + if (result) return result; + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({ error: { code: 'Lobbies.NotFound', message: 'Not found' } }) }); + }); +} + +async function renderPage(lobbyId = 'lobby-1') { + const { LobbyPage } = await import('@/features/lobby/LobbyPage'); + return render(); +} + +describe('LobbyPage', () => { + it('renders a not-found state on a 404', async () => { + setupFetch(u => { + if (u.includes('/api/lobbies/lobby-1') && !u.includes('capabilities')) return jsonResponse(404, { error: { code: 'Lobbies.NotFound', message: 'Not found' } }); + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getByText('Lobby not found.')).toBeInTheDocument()); + }); + + it('renders seats and the Ready toggle for a joined member', async () => { + setupFetch(u => { + if (u.endsWith('/api/lobbies/lobby-1')) { + return jsonResponse(200, lobby({ + hostUserId: 'u-1', + seats: [seat('u-1', 'Me', { isHost: true })], + allowedActions: ['ready', 'leave'], + })); + } + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getAllByText('Not ready').length).toBeGreaterThan(0)); + expect(screen.queryByText('Join lobby')).not.toBeInTheDocument(); + }); + + it('shows a Join lobby CTA for a non-member browsing a Public+Open lobby', async () => { + setupFetch(u => { + if (u.endsWith('/api/lobbies/lobby-1')) { + return jsonResponse(200, lobby({ privacy: 'Public', state: 'Open', allowedActions: [] })); + } + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Join lobby' })).toBeInTheDocument()); + }); + + it('does not show a Join lobby CTA for a non-member on a Private lobby', async () => { + setupFetch(u => { + if (u.endsWith('/api/lobbies/lobby-1')) { + return jsonResponse(200, lobby({ privacy: 'Private', state: 'Open', allowedActions: [] })); + } + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getByText('Players · 1 / 2')).toBeInTheDocument()); + expect(screen.queryByRole('button', { name: 'Join lobby' })).not.toBeInTheDocument(); + }); + + it('shows a disabled "Lobby full" state instead of Join lobby when seats are full', async () => { + setupFetch(u => { + if (u.endsWith('/api/lobbies/lobby-1')) { + return jsonResponse(200, lobby({ + privacy: 'Public', state: 'Open', maxPlayers: 2, + seats: [seat('u-host', 'Host', { isHost: true }), seat('u-2', 'Other')], + })); + } + return null; + }); + await renderPage(); + const btn = await screen.findByRole('button', { name: 'Lobby full' }); + expect(btn).toBeDisabled(); + }); + + it('clicking Join lobby calls join-by-lobbyId and re-renders as a member', async () => { + let joined = false; + setupFetch((u, opts) => { + if (u.endsWith('/api/lobbies/join') && opts?.method === 'POST') { + joined = true; + return jsonResponse(200, lobby({ + privacy: 'Public', state: 'Open', allowedActions: ['ready', 'leave'], + seats: [seat('u-host', 'Host', { isHost: true }), seat('u-1', 'Me')], + })); + } + if (u.endsWith('/api/lobbies/lobby-1')) { + return jsonResponse(200, joined + ? lobby({ privacy: 'Public', state: 'Open', allowedActions: ['ready', 'leave'], seats: [seat('u-host', 'Host', { isHost: true }), seat('u-1', 'Me')] }) + : lobby({ privacy: 'Public', state: 'Open', allowedActions: [] })); + } + return null; + }); + await renderPage(); + const joinBtn = await screen.findByRole('button', { name: 'Join lobby' }); + await act(async () => { fireEvent.click(joinBtn); }); + + const joinCall = mockFetch.mock.calls.find(c => String(c[0]).endsWith('/api/lobbies/join')); + expect(joinCall).toBeDefined(); + expect(JSON.parse(String((joinCall![1] as { body: string }).body))).toEqual({ lobbyId: 'lobby-1' }); + + await waitFor(() => expect(screen.queryByRole('button', { name: 'Join lobby' })).not.toBeInTheDocument()); + expect(screen.getAllByText('Not ready').length).toBeGreaterThan(0); + }); + + it('a 404 on join (lobby closed/filled in the race) flips to the not-found state', async () => { + setupFetch((u, opts) => { + if (u.endsWith('/api/lobbies/join') && opts?.method === 'POST') { + return jsonResponse(404, { error: { code: 'Lobbies.NotFound', message: 'Not found' } }); + } + if (u.endsWith('/api/lobbies/lobby-1')) { + return jsonResponse(200, lobby({ privacy: 'Public', state: 'Open', allowedActions: [] })); + } + return null; + }); + await renderPage(); + const joinBtn = await screen.findByRole('button', { name: 'Join lobby' }); + await act(async () => { fireEvent.click(joinBtn); }); + await waitFor(() => expect(screen.getByText('Lobby not found.')).toBeInTheDocument()); + }); +}); diff --git a/src/__tests__/SearchResultsPage.test.tsx b/src/__tests__/SearchResultsPage.test.tsx new file mode 100644 index 0000000..efcf873 --- /dev/null +++ b/src/__tests__/SearchResultsPage.test.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('next/link', () => ({ + default: ({ href, children }: { href: string; children: React.ReactNode }) => {children}, +})); + +const mockPush = vi.fn(); +const mockReplace = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush, replace: mockReplace }), +})); + +const mockToastPush = vi.fn(); +vi.mock('@/components/ui/Toast', () => ({ useToast: () => ({ push: mockToastPush }) })); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); + mockPush.mockReset(); + mockReplace.mockReset(); + mockToastPush.mockReset(); +}); + +function hostIdentity(name = 'Priya') { + return { userId: 'u-host', username: name.toLowerCase(), displayName: name, initials: name.slice(0, 2).toUpperCase(), color: '#F0394B', avatarUrl: null, profileType: 'Standard' }; +} + +function lobbySummary(overrides: Partial> = {}) { + return { + lobbyId: 'lobby-1', gameSlug: 'chess-lite', maxPlayers: 2, joinedCount: 1, + timeControlId: 'blitz-5', rated: false, resolvedRegion: 'NA', spectatorPolicy: 'Open', + host: hostIdentity(), createdAt: '2026-01-01T00:00:00Z', expiresAtUtc: '2026-01-01T01:00:00Z', + ...overrides, + }; +} + +function jsonResponse(status: number, body: unknown) { + return Promise.resolve({ ok: status >= 200 && status < 300, status, json: () => Promise.resolve(body) }); +} + +function setupFetch(handler: (url: string, opts?: { method?: string }) => Promise | null) { + mockFetch.mockImplementation((url: string, opts?: { method?: string }) => { + const result = handler(String(url), opts); + if (result) return result; + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({ error: { code: 'NotFound', message: 'Not found' } }) }); + }); +} + +async function renderPage(initialType = 'lobbies') { + const { SearchResultsPage } = await import('@/features/search/SearchResultsPage'); + return render(); +} + +describe('SearchResultsPage — Public Lobbies tab', () => { + it('loads public lobbies on mount when initialType=lobbies', async () => { + setupFetch(u => { + if (u.includes('/api/lobbies')) return jsonResponse(200, { items: [lobbySummary()], nextCursor: null }); + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getByText('chess-lite')).toBeInTheDocument()); + expect(screen.getByText(/Hosted by Priya/)).toBeInTheDocument(); + }); + + it('shows an empty state when there are no public lobbies', async () => { + setupFetch(u => { + if (u.includes('/api/lobbies')) return jsonResponse(200, { items: [], nextCursor: null }); + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getByText('No public lobbies right now')).toBeInTheDocument()); + }); + + it('shows an error state with retry on failure', async () => { + let calls = 0; + setupFetch(u => { + if (u.includes('/api/lobbies')) { + calls++; + return calls === 1 + ? jsonResponse(500, { error: { code: 'Server.Error', message: 'Server broke' } }) + : jsonResponse(200, { items: [lobbySummary()], nextCursor: null }); + } + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getByText('Server broke')).toBeInTheDocument()); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Retry' })); }); + await waitFor(() => expect(screen.getByText('chess-lite')).toBeInTheDocument()); + }); + + it('clicking Join calls join-by-lobbyId and navigates to the lobby', async () => { + setupFetch((u, opts) => { + if (u.endsWith('/api/lobbies/join') && opts?.method === 'POST') { + return jsonResponse(200, { lobbyId: 'lobby-1' }); + } + if (u.includes('/api/lobbies')) return jsonResponse(200, { items: [lobbySummary()], nextCursor: null }); + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getByText('chess-lite')).toBeInTheDocument()); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Join' })); }); + await waitFor(() => expect(mockPush).toHaveBeenCalledWith(expect.stringContaining('lobby-1'))); + }); + + it('a 404 on join removes the stale lobby and shows a toast, without navigating', async () => { + setupFetch((u, opts) => { + if (u.endsWith('/api/lobbies/join') && opts?.method === 'POST') { + return jsonResponse(404, { error: { code: 'Lobbies.NotFound', message: 'Not found' } }); + } + if (u.includes('/api/lobbies')) return jsonResponse(200, { items: [lobbySummary()], nextCursor: null }); + return null; + }); + await renderPage(); + await waitFor(() => expect(screen.getByText('chess-lite')).toBeInTheDocument()); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Join' })); }); + await waitFor(() => expect(screen.getByText('No public lobbies right now')).toBeInTheDocument()); + expect(mockPush).not.toHaveBeenCalled(); + expect(mockToastPush).toHaveBeenCalledWith(expect.objectContaining({ title: 'This lobby is no longer available.' })); + }); + + it('shows a disabled "Full" button instead of Join when the lobby is at capacity', async () => { + setupFetch(u => { + if (u.includes('/api/lobbies')) return jsonResponse(200, { items: [lobbySummary({ joinedCount: 2, maxPlayers: 2 })], nextCursor: null }); + return null; + }); + await renderPage(); + const btn = await screen.findByRole('button', { name: 'Full' }); + expect(btn).toBeDisabled(); + }); + + it('Load more traverses via the cursor', async () => { + setupFetch(u => { + if (u.includes('cursor=c-2')) return jsonResponse(200, { items: [lobbySummary({ lobbyId: 'lobby-2', host: hostIdentity('Bob') })], nextCursor: null }); + if (u.includes('/api/lobbies')) return jsonResponse(200, { items: [lobbySummary()], nextCursor: 'c-2' }); + return null; + }); + await renderPage(); + await waitFor(() => screen.getByText('Load more')); + await act(async () => { fireEvent.click(screen.getByText('Load more')); }); + await waitFor(() => expect(screen.getByText(/Hosted by Bob/)).toBeInTheDocument()); + }); + + it('switching from People to the Lobbies tab loads lobbies exactly once', async () => { + setupFetch(u => { + if (u.includes('/api/lobbies')) return jsonResponse(200, { items: [lobbySummary()], nextCursor: null }); + return null; + }); + await renderPage('people'); + expect(mockFetch).not.toHaveBeenCalled(); + await act(async () => { fireEvent.click(screen.getByRole('tab', { name: /Public Lobbies/i })); }); + await waitFor(() => expect(screen.getByText('chess-lite')).toBeInTheDocument()); + expect(mockFetch.mock.calls.filter(c => String(c[0]).includes('/api/lobbies')).length).toBe(1); + }); +}); diff --git a/src/components/friends/InviteFriendModal.tsx b/src/components/friends/InviteFriendModal.tsx index 8f90fd8..12954b5 100644 --- a/src/components/friends/InviteFriendModal.tsx +++ b/src/components/friends/InviteFriendModal.tsx @@ -7,22 +7,33 @@ import { Avatar } from '@/components/ui/Avatar'; import { Icon } from '@/components/ui/Icons'; import { EmptyState } from '@/components/ui/EmptyState'; import { useToast } from '@/components/ui/Toast'; -import { GAMES } from '@/mock/games'; import { friendsApi } from '@/features/friends/friendsApi'; import type { FriendDto } from '@/features/friends/types'; +import { lobbyApi } from '@/features/lobby/lobbyApi'; +import { lobbyErrorMessage } from '@/features/lobby/lobbyErrors'; +import { LobbyActions } from '@/features/lobby/types'; interface Props { open: boolean; onClose: () => void; + /** Already-known target lobby (e.g. from LobbyPage). Omit to invite to the viewer's current active lobby. */ + lobbyId?: string; preselectedGameId?: string; + /** Seeds the friend search (e.g. a specific friend's display name from ProfilePage) instead of listing all friends. */ + initialQuery?: string; } -export function InviteFriendModal({ open, onClose, preselectedGameId }: Props) { +export function InviteFriendModal({ open, onClose, lobbyId, preselectedGameId, initialQuery }: Props) { const toast = useToast(); const [q, setQ] = useState(''); const [picked, setPicked] = useState>(new Set()); - const [game, setGame] = useState(preselectedGameId ?? GAMES[3].id); + + const [resolvedLobbyId, setResolvedLobbyId] = useState(null); + const [gameSlug, setGameSlug] = useState(null); + const [linkToken, setLinkToken] = useState(null); + const [lobbyLoading, setLobbyLoading] = useState(true); + const [lobbyError, setLobbyError] = useState(null); const [allFriends, setAllFriends] = useState([]); const [friendsCursor, setFriendsCursor] = useState(null); @@ -33,6 +44,9 @@ export function InviteFriendModal({ open, onClose, preselectedGameId }: Props) { const activeQueryRef = useRef(''); const [activeQuery, setActiveQuery] = useState(''); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); + const loadFriendsSearch = useCallback(async (query: string, cursor: string | null, append: boolean) => { const seq = ++friendsSeq.current; if (append) { setLoadingMoreFriends(true); } else { setLoadingFriends(true); } @@ -55,22 +69,66 @@ export function InviteFriendModal({ open, onClose, preselectedGameId }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Resolve which lobby we're inviting to: the one we're already viewing, or the viewer's active hosted lobby. + useEffect(() => { + if (!open) return; + let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect + setSendError(null); + if (lobbyId) { + setResolvedLobbyId(lobbyId); + setGameSlug(preselectedGameId ?? null); + setLobbyLoading(false); + setLobbyError(null); + return; + } + setLobbyLoading(true); + setLobbyError(null); + lobbyApi.getMyActive() + .then(ctx => { + if (cancelled) return; + if (ctx.lobby && ctx.lobby.allowedActions.includes(LobbyActions.Invite)) { + setResolvedLobbyId(ctx.lobby.lobbyId); + setGameSlug(ctx.lobby.gameSlug); + } else { + setResolvedLobbyId(null); + setGameSlug(null); + } + }) + .catch(e => { if (!cancelled) setLobbyError(lobbyErrorMessage(e)); }) + .finally(() => { if (!cancelled) setLobbyLoading(false); }); + return () => { cancelled = true; }; + }, [open, lobbyId, preselectedGameId]); + + // Reveal an already-known invite link (host must have revealed/rotated it from the lobby page first — + // a join credential is never minted or displayed from this modal, only read back). + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + if (!open || !resolvedLobbyId) { setLinkToken(null); return; } + try { + const raw = sessionStorage.getItem(`lobby-credential:${resolvedLobbyId}`); + setLinkToken(raw ? JSON.parse(raw).linkToken ?? null : null); + } catch { + setLinkToken(null); + } + }, [open, resolvedLobbyId]); + // Reset and initial load when modal opens useEffect(() => { if (!open) return; + const seedQuery = initialQuery ?? ''; // eslint-disable-next-line react-hooks/set-state-in-effect - setQ(''); - + setQ(seedQuery); + setPicked(new Set()); - + setAllFriends([]); - + setFriendsError(null); - activeQueryRef.current = ''; - if (preselectedGameId) setGame(preselectedGameId); - loadFriendsSearch('', null, false); + activeQueryRef.current = seedQuery; + loadFriendsSearch(seedQuery, null, false); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, preselectedGameId]); + }, [open]); // Debounced search (skip initial render within this effect) const didMountSearchRef = useRef(false); @@ -95,99 +153,119 @@ export function InviteFriendModal({ open, onClose, preselectedGameId }: Props) { return next; }); - const send = () => { - const g = GAMES.find(x => x.id === game); - const pickedFriends = allFriends.filter(f => picked.has(f.userId)); - const names = pickedFriends.map(f => f.displayName).join(', '); - toast.push({ kind: 'success', title: `Invite sent to ${picked.size} friend${picked.size === 1 ? '' : 's'}`, body: `${g?.name} · ${names}` }); - onClose(); + const send = async () => { + if (!resolvedLobbyId || sending || picked.size === 0) return; + setSending(true); + setSendError(null); + const targets = allFriends.filter(f => picked.has(f.userId)); + try { + await Promise.all(targets.map(f => lobbyApi.createInvite(resolvedLobbyId, { inviteeUserId: f.userId }))); + toast.push({ + kind: 'success', + title: `Invite sent to ${targets.length} friend${targets.length === 1 ? '' : 's'}`, + body: targets.map(f => f.displayName).join(', '), + }); + onClose(); + } catch (e) { + setSendError(lobbyErrorMessage(e)); + } finally { + setSending(false); + } + }; + + const copyLink = () => { + if (!linkToken) return; + navigator.clipboard.writeText(`https://simple.gg/j/${linkToken}`).catch(() => {}); + toast.push({ kind: 'info', title: 'Link copied' }); }; return ( - -
- - - + resolvedLobbyId ? ( + <> + {linkToken && } +
+ + + + ) : ( + + ) }> -
- + {lobbyLoading ? ( +
Loading your lobby…
+ ) : lobbyError ? ( +
{lobbyError}
+ ) : !resolvedLobbyId ? ( + + ) : ( +
+ {gameSlug &&
Inviting to your {gameSlug} lobby.
} -