fix: cover the frontend to the 90% bar and fix what the tests found (2.2.1)
WP-19. From 105 tests / 14% lines to 1,112 tests / 99% lines and 97% branches across 93 files, with every source file above the codified 90% thresholds on its own - no per-file exceptions. Written risk-first: real Zustand stores seeded via setState, the axios client mocked with an unrouted-request tripwire, the socket factory mocked with a controllable fake, so no test can reach a backend and the stores are covered by the pages that use them as well as directly. The tests found six defects, each pinned first with it.fails and then fixed: a failed join to an open Game Night room was still silent (the error only rendered inside the password prompt); the remove-friend button had no accessible name; signing out from Profile landed on /login because the page's auth guard re-ran; the sign-in page neither redirected a signed-in user nor read the ?expired=1 the API client has sent since WP-10; and Quick Play / Endless threw out of their click handlers on a TMDB failure while every start page navigated to /game whether or not the game had started. startGame now reports its outcome and the pages branch on it. One toolchain finding worth keeping: vi.useFakeTimers() combined with @testing-library/user-event deadlocks under this vitest/jsdom combination; tests that need both use real timers and waitFor.
This commit is contained in:
1 parent
5a7e8cebc3
commit
c9291243be
102 files changed
+17844
-79
No files matched your search
@@ -0,0 +1,508 @@
|
||||
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';
|
||||
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<unknown>;
|
||||
|
||||
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> = {}): 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> = {}): VersusMatch {
|
||||
return creatorMatch({ player1: CREATOR, ...overrides });
|
||||
}
|
||||
|
||||
function attemptResponse(overrides: Partial<AsyncAttemptResponse> = {}): 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> = {},
|
||||
): 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<void>((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(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/versus/async-game/:matchId" element={<AsyncVersusGame />} />
|
||||
<Route path="/versus/async-game" element={<AsyncVersusGame />} />
|
||||
<Route path="/versus" element={<div>versus route</div>} />
|
||||
<Route
|
||||
path="/versus/async/:matchId/leaderboard"
|
||||
element={<div>leaderboard route</div>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 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('Chain: 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(
|
||||
<MemoryRouter initialEntries={['/versus/async-game/match-1']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/versus/async-game/:matchId"
|
||||
element={
|
||||
<>
|
||||
{/* An in-app nav to a second match, same as clicking into it
|
||||
from a matches list, without unmounting this page. */}
|
||||
<Link to="/versus/async-game/match-2">go to match 2</Link>
|
||||
<AsyncVersusGame />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
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('Chain: 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('2400')).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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user