Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions src/__tests__/CreateLobbyModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown> | 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(<CreateLobbyModal open={true} onClose={onClose} {...props} />);
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();
});
});
3 changes: 2 additions & 1 deletion src/__tests__/DashboardFriends.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([]));
});

Expand Down
55 changes: 55 additions & 0 deletions src/__tests__/GameDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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();
});
});
8 changes: 4 additions & 4 deletions src/__tests__/InviteFriendModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ beforeEach(() => {
async function renderModal(open = true) {
const { InviteFriendModal } = await import('@/components/friends/InviteFriendModal');
const onClose = vi.fn();
render(<InviteFriendModal open={open} onClose={onClose} />);
render(<InviteFriendModal open={open} onClose={onClose} lobbyId="lobby-1" />);
return { onClose };
}

Expand Down Expand Up @@ -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(<InviteFriendModal open={true} onClose={onClose} />);
const { rerender } = render(<InviteFriendModal open={true} onClose={onClose} lobbyId="lobby-1" />);
await waitFor(() => screen.getByText('Alice'));

// Pick Alice by clicking her row
Expand All @@ -116,9 +116,9 @@ describe('InviteFriendModal', () => {
});

// Close then reopen — state should reset (picked = 0)
rerender(<InviteFriendModal open={false} onClose={onClose} />);
rerender(<InviteFriendModal open={false} onClose={onClose} lobbyId="lobby-1" />);
mockFetch.mockResolvedValueOnce(pagedFriends([makeFriend('f-2', 'Bob')]));
rerender(<InviteFriendModal open={true} onClose={onClose} />);
rerender(<InviteFriendModal open={true} onClose={onClose} lobbyId="lobby-1" />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /Send invite \(0\)/i })).toBeInTheDocument();
});
Expand Down
172 changes: 172 additions & 0 deletions src/__tests__/LobbyPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> = {}) {
return { identity: identity(userId, name), isHost: false, isReady: false, joinedAtUtc: '2026-01-01T00:00:00Z', ...overrides };
}

function lobby(overrides: Partial<Record<string, unknown>> = {}) {
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<unknown> | 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(<LobbyPage lobbyId={lobbyId} />);
}

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