import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Link, MemoryRouter, Route, Routes } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // The completion modal waits for the loop-closing animation; there is none // to wait for in jsdom, and these tests are about the content, not the delay. vi.mock('@/lib/motion', async (importOriginal) => ({ ...(await importOriginal()), prefersReducedMotion: () => true, })); import type { ActorChainLink, MovieChainLink, ScoreBreakdown, } from '@/types'; import type { AsyncLeaderboardResponse, VersusMatch, AsyncAttemptResponse } from '@/api/versus'; /** * The async-versus game page: it starts a game as either the match's creator * (playing to set the benchmark) or a challenger (playing against a target * chain length), then submits the finished run once the loop closes. * * Requests are intercepted one level below the API modules, at `@/api/client` * - the seam `game-store.test.ts` established, since a second concurrent * dynamic import of an aliased module has previously resolved to the real * module and reached the dev backend. `@/api/games` and `@/api/movies` are * reached indirectly through `useGameStore.startGameFromVersus`. */ const getMovieDetails = vi.fn(); const createGameSession = vi.fn(); const completeGameSession = vi.fn(); const getMatch = vi.fn(); const startAsyncAttempt = vi.fn(); const submitCreatorScore = vi.fn(); const submitAsyncAttempt = vi.fn(); const getAsyncLeaderboard = vi.fn(); type Handler = (arg: unknown) => Promise; function route(method: string, url: string): Handler { let m: RegExpMatchArray | null; if (method === 'get' && (m = /^\/movies\/(\d+)$/.exec(url))) { return () => getMovieDetails(Number(m![1])); } if (method === 'post' && url === '/games') { return (body) => createGameSession(body); } if (method === 'post' && (m = /^\/games\/([^/]+)\/complete$/.exec(url))) { return () => completeGameSession(m![1]); } if (method === 'get' && (m = /^\/versus\/([^/]+)$/.exec(url))) { return (config) => getMatch(m![1], (config as { signal?: AbortSignal })?.signal); } if (method === 'get' && (m = /^\/versus\/([^/]+)\/leaderboard$/.exec(url))) { return () => getAsyncLeaderboard(m![1]); } if (method === 'post' && (m = /^\/versus\/([^/]+)\/async-attempt$/.exec(url))) { return (body) => startAsyncAttempt(m![1], body); } if (method === 'post' && (m = /^\/versus\/([^/]+)\/creator-score$/.exec(url))) { return (body) => submitCreatorScore(m![1], body); } if (method === 'post' && (m = /^\/versus\/([^/]+)\/async-attempt\/submit$/.exec(url))) { return (body) => submitAsyncAttempt(m![1], body); } throw new Error(`test reached an unrouted request: ${method} ${url}`); } vi.mock('@/api/client', () => { const call = (method: string) => async (url: string, arg?: unknown): Promise<{ data: unknown }> => ({ data: await route(method, url)(arg), }); return { default: { get: call('get'), post: call('post'), put: call('put'), delete: call('delete'), }, }; }); const { useAuthStore } = await import('@/stores/auth-store'); const { useGameStore } = await import('@/stores/game-store'); const { default: AsyncVersusGame } = await import('./AsyncVersusGame'); // --- Fixtures --------------------------------------------------------------- const USER = { id: 'user-1', email: 'me@example.com', username: 'Me', isAdmin: false }; const CREATOR = { id: 'creator-9', username: 'Creator' }; const movieDetails = (id: number, title: string) => ({ id, title, poster_path: `/p${id}.jpg`, release_date: '2000-01-01', popularity: 10, }); const actor = (id: number): ActorChainLink => ({ type: 'actor', id, name: `Actor ${id}`, profilePath: null, popularity: 5, }); const movie = (id: number, title = `Movie ${id}`): MovieChainLink => ({ type: 'movie', id, title, posterPath: null, releaseDate: '2000-01-01', popularity: 5, }); const SCORE: ScoreBreakdown = { baseScore: 1000, chainLength: 5, linkCount: 2, chainLengthBonus: 800, timeBonus: 500, obscurityBonus: 100, hintPenalty: 0, duplicatePenalty: 0, elapsedSeconds: 300, totalScore: 2400, }; function creatorMatch(overrides: Partial = {}): VersusMatch { return { id: 'match-1', movieAId: 100, movieATitle: 'Movie A', movieBId: 200, movieBTitle: 'Movie B', difficulty: 'medium', status: 'waiting', lobbyName: null, winnerId: null, player1: USER, player2: null, player1Score: null, player2Score: null, createdAt: '2024-01-01T00:00:00.000Z', startedAt: null, finishedAt: null, isAsync: true, ...overrides, }; } function challengerMatch(overrides: Partial = {}): VersusMatch { return creatorMatch({ player1: CREATOR, ...overrides }); } function attemptResponse(overrides: Partial = {}): AsyncAttemptResponse { return { attempt: { id: 'att-1', matchId: 'match-1', playerId: USER.id }, movieAId: 100, movieATitle: 'Movie A', movieBId: 200, movieBTitle: 'Movie B', chainLength: 7, ...overrides, }; } function leaderboardResponse( overrides: Partial = {}, ): AsyncLeaderboardResponse { return { match: { id: 'match-1', difficulty: 'medium', lobbyName: null, expiresAt: null, status: 'open', }, creator: { id: CREATOR.id, username: CREATOR.username }, leaderboard: [ { playerId: CREATOR.id, username: CREATOR.username, score: { totalScore: 1000 }, chainLength: 5, isCreator: true, rank: 1, }, { playerId: USER.id, username: USER.username, score: { totalScore: 2400 }, chainLength: 5, isCreator: false, rank: 1, }, ], ...overrides, }; } /** Lets fire-and-forget store work (dynamic `import()`s included) settle. */ const flush = async () => { for (let i = 0; i < 6; i++) { await new Promise((r) => setTimeout(r, 0)); } }; /** Drive the shortest legal loop directly through the store, bypassing the UI. */ function closeTheLoop() { useGameStore.getState().addActorToChain(actor(1)); useGameStore.getState().addMovieToChain(movie(200, 'Movie B')); useGameStore.getState().addActorToChain(actor(2)); useGameStore.getState().addMovieToChain(movie(100, 'Movie A')); } function renderPage(initialEntry = '/versus/async-game/match-1') { return render( } /> } /> versus route} /> leaderboard route} /> , ); } /** Both movie titles are duplicated on screen (target card + chain card, or * target card + target badge), so wait on the chain-length badge instead - * it renders exactly once, as soon as the game is ready to play. */ async function waitForReady() { await screen.findByText('0 links'); } beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ user: USER, isLoading: false, error: null }); useGameStore.getState().resetGame(); getMovieDetails.mockImplementation((id: number) => Promise.resolve(movieDetails(id, id === 100 ? 'Movie A' : 'Movie B')), ); createGameSession.mockResolvedValue({ id: 'session-1', startedAt: 'now' }); completeGameSession.mockResolvedValue({ ...SCORE, newAchievements: [] }); submitCreatorScore.mockResolvedValue({}); submitAsyncAttempt.mockResolvedValue({}); getAsyncLeaderboard.mockResolvedValue(leaderboardResponse()); }); afterEach(() => { useGameStore.getState().resetGame(); }); describe('guard clauses before loading', () => { it('redirects to /versus when nobody is signed in', async () => { useAuthStore.setState({ user: null, isLoading: false }); renderPage(); expect(await screen.findByText('versus route')).toBeInTheDocument(); expect(getMatch).not.toHaveBeenCalled(); }); it('redirects to /versus when the route has no match id', async () => { renderPage('/versus/async-game'); expect(await screen.findByText('versus route')).toBeInTheDocument(); expect(getMatch).not.toHaveBeenCalled(); }); it('does nothing while auth is still resolving', () => { useAuthStore.setState({ user: USER, isLoading: true }); renderPage(); expect(getMatch).not.toHaveBeenCalled(); expect(screen.queryByText('versus route')).toBeNull(); }); }); describe('loading the match', () => { it('redirects to /versus when the match is not an async challenge', async () => { getMatch.mockResolvedValue(creatorMatch({ isAsync: false })); renderPage(); expect(await screen.findByText('versus route')).toBeInTheDocument(); }); it('sends a signed-in creator straight to the leaderboard once they have already played', async () => { getMatch.mockResolvedValue(creatorMatch({ status: 'open' })); renderPage(); expect(await screen.findByText('leaderboard route')).toBeInTheDocument(); expect(createGameSession).not.toHaveBeenCalled(); }); it('shows the server error message when the match fails to load', async () => { getMatch.mockRejectedValue({ response: { data: { message: 'Match not found' } } }); renderPage(); expect(await screen.findByText('Match not found')).toBeInTheDocument(); }); it('falls back to a generic error when the server gives no message', async () => { getMatch.mockRejectedValue(new Error('network down')); renderPage(); expect(await screen.findByText('Failed to load match')).toBeInTheDocument(); }); it('surfaces a failed game start as a visible error, not a silent hang', async () => { getMatch.mockResolvedValue(creatorMatch()); createGameSession.mockRejectedValue(new Error('500')); renderPage(); expect( await screen.findByText('Could not start this match. Please try again.'), ).toBeInTheDocument(); const back = screen.getByRole('button', { name: 'Back to Versus' }); await userEvent.click(back); expect(await screen.findByText('versus route')).toBeInTheDocument(); }); }); describe('abort on unmount (WP cleanup)', () => { it('aborts the in-flight fetch and resets the game store when unmounted before it resolves', async () => { let rejectGetMatch: (e: unknown) => void = () => undefined; getMatch.mockImplementation( () => new Promise((_resolve, reject) => (rejectGetMatch = reject)), ); const { unmount } = renderPage(); unmount(); rejectGetMatch(new DOMException('aborted', 'AbortError')); await flush(); expect(useGameStore.getState().status).toBe('idle'); expect(useGameStore.getState().movieA).toBeNull(); }); it('does not let a stale creator start land after the match id changes', async () => { let releaseDetails: (v: unknown) => void = () => undefined; getMatch.mockImplementation((id: string) => Promise.resolve(creatorMatch({ id, movieAId: id === 'match-1' ? 100 : 300 })), ); getMovieDetails.mockImplementation((id: number) => { if (id === 100) { return new Promise((resolve) => (releaseDetails = resolve)); } return Promise.resolve(movieDetails(id, id === 300 ? 'Third' : 'Movie B')); }); render( {/* An in-app nav to a second match, same as clicking into it from a matches list, without unmounting this page. */} go to match 2 } /> , ); await flush(); // Switch to a different match before match-1's slow session finishes. await userEvent.click(screen.getByText('go to match 2')); await flush(); expect(await screen.findByText('0 links')).toBeInTheDocument(); expect(useGameStore.getState().movieA?.title).toBe('Third'); // match-1's delayed movie details now arrive - after being superseded. releaseDetails(movieDetails(100, 'Movie A')); await flush(); // The stale start must not have clobbered match-2's live game. expect(useGameStore.getState().movieA?.title).toBe('Third'); expect(useGameStore.getState().validationError).toBeNull(); }); }); describe('creator mode', () => { it('starts the game as the benchmark-setter and submits the score on completion', async () => { getMatch.mockResolvedValue(creatorMatch()); renderPage(); await waitForReady(); expect( screen.getByText('Your score will be the benchmark!'), ).toBeInTheDocument(); closeTheLoop(); await flush(); expect(submitCreatorScore).toHaveBeenCalledWith('match-1', { sessionId: 'session-1' }); expect(submitCreatorScore).toHaveBeenCalledTimes(1); expect(await screen.findByText('Score submitted')).toBeInTheDocument(); expect(screen.getByText('2,400')).toBeInTheDocument(); }); it('navigates from the completion modal to the leaderboard', async () => { getMatch.mockResolvedValue(creatorMatch()); renderPage(); await waitForReady(); closeTheLoop(); await screen.findByText('Score submitted'); await userEvent.click(screen.getByRole('button', { name: 'View leaderboard' })); expect(await screen.findByText('leaderboard route')).toBeInTheDocument(); }); it('reports a submission failure once and does not retry it (WP-1)', async () => { getMatch.mockResolvedValue(creatorMatch()); submitCreatorScore.mockRejectedValue(new Error('500')); renderPage(); await waitForReady(); closeTheLoop(); await flush(); expect(await screen.findByText('Failed to submit score')).toBeInTheDocument(); expect(submitCreatorScore).toHaveBeenCalledTimes(1); // Further ticks must not fire a second, third, ... identical request. await flush(); await flush(); expect(submitCreatorScore).toHaveBeenCalledTimes(1); }); }); describe('challenger mode', () => { it('starts an attempt against the target chain length and submits it on completion', async () => { getMatch.mockResolvedValue(challengerMatch()); startAsyncAttempt.mockResolvedValue(attemptResponse()); renderPage(); await waitForReady(); expect(startAsyncAttempt).toHaveBeenCalledWith('match-1', { password: undefined }); expect(screen.getByText('Target: 3 links')).toBeInTheDocument(); closeTheLoop(); await flush(); expect(submitAsyncAttempt).toHaveBeenCalledWith('match-1', { sessionId: 'session-1' }); expect(submitAsyncAttempt).toHaveBeenCalledTimes(1); expect(await screen.findByText('Challenge complete')).toBeInTheDocument(); expect(screen.getByText('You beat the creator')).toBeInTheDocument(); expect(screen.getByText('Your rank:')).toBeInTheDocument(); expect(screen.getByText('#1')).toBeInTheDocument(); }); it('shows an unknown target length before the attempt responds with one', async () => { getMatch.mockResolvedValue(challengerMatch()); startAsyncAttempt.mockResolvedValue(attemptResponse({ chainLength: null })); renderPage(); await waitForReady(); expect(screen.getByText('Target: ? links')).toBeInTheDocument(); }); it('still shows the completion modal when the leaderboard lookup fails (non-critical)', async () => { getMatch.mockResolvedValue(challengerMatch()); startAsyncAttempt.mockResolvedValue(attemptResponse()); getAsyncLeaderboard.mockRejectedValue(new Error('500')); renderPage(); await waitForReady(); closeTheLoop(); await flush(); expect(await screen.findByText('Challenge complete')).toBeInTheDocument(); expect(screen.queryByText('Your rank:')).toBeNull(); }); it('surfaces a failed attempt start as a generic load error', async () => { // startAsyncAttempt throws before startGameFromVersus is ever reached, so // this is caught by init()'s outer catch, not the started==='failed' path. getMatch.mockResolvedValue(challengerMatch()); startAsyncAttempt.mockRejectedValue(new Error('500')); renderPage(); expect(await screen.findByText('Failed to load match')).toBeInTheDocument(); }); it('surfaces a failed game start (after a successful attempt) as a visible error', async () => { getMatch.mockResolvedValue(challengerMatch()); startAsyncAttempt.mockResolvedValue(attemptResponse()); createGameSession.mockRejectedValue(new Error('500')); renderPage(); expect( await screen.findByText('Could not start this match. Please try again.'), ).toBeInTheDocument(); }); it('reports a submission failure once and does not retry it (WP-1)', async () => { getMatch.mockResolvedValue(challengerMatch()); startAsyncAttempt.mockResolvedValue(attemptResponse()); submitAsyncAttempt.mockRejectedValue(new Error('500')); renderPage(); await waitForReady(); closeTheLoop(); await flush(); expect(await screen.findByText('Failed to submit score')).toBeInTheDocument(); expect(submitAsyncAttempt).toHaveBeenCalledTimes(1); await flush(); await flush(); expect(submitAsyncAttempt).toHaveBeenCalledTimes(1); }); });