test: data router, one render helper, fetchRoutes in every test

This commit is contained in:
lobo committed 2026-09-29 16:15:50 +02:00
1 parent d0ea9e900d
commit 2a22789435
37 files changed
+454 -425

No files matched your search

+1
View File
@@ -0,0 +1 @@
{"numTotalTestSuites":23,"numPassedTestSuites":23,"numFailedTestSuites":0,"numPendingTestSuites":0,"numTotalTests":56,"numPassedTests":56,"numFailedTests":0,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1790690796160,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["App"],"fullName":"App ends the session on a 401 that the refresh cannot fix","status":"passed","title":"ends the session on a 401 that the refresh cannot fix","duration":931.6336369999999,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797891,"endTime":1790690798822.6335,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/App.test.jsx"},{"assertionResults":[{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel does not search below two characters","status":"passed","title":"does not search below two characters","duration":769.245433,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel searches once after typing stops and blocks games already in the library","status":"passed","title":"searches once after typing stops and blocks games already in the library","duration":433.3170190000003,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel adds the picked game with the favorite platform and the hours, then opens its page","status":"passed","title":"adds the picked game with the favorite platform and the hours, then opens its page","duration":529.4851979999999,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel preselects no platform without a favorite among several, so Add waits for a pick","status":"passed","title":"preselects no platform without a favorite among several, so Add waits for a pick","duration":444.57771,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel sends several checked games to a search import and opens its review","status":"passed","title":"sends several checked games to a search import and opens its review","duration":428.44052999999985,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel says so when the game is already in the library","status":"passed","title":"says so when the game is already in the library","duration":447.7203089999998,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel shows that adding is off when Core has no IGDB app","status":"passed","title":"shows that adding is off when Core has no IGDB app","duration":408.3551859999998,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797326,"endTime":1790690800787.3552,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/AddGamePanel.test.jsx"},{"assertionResults":[{"ancestorTitles":["AppHeader"],"fullName":"AppHeader says so when Auth refuses the language switch","status":"passed","title":"says so when Auth refuses the language switch","duration":364.1882109999999,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AppHeader"],"fullName":"AppHeader keeps a name on the home link when a narrow phone hides the app name","status":"passed","title":"keeps a name on the home link when a narrow phone hides the app name","duration":18.29670699999997,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AppHeader"],"fullName":"AppHeader offers the admin page and \"view as\" to an admin","status":"passed","title":"offers the admin page and \"view as\" to an admin","duration":54.321174000000156,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AppHeader"],"fullName":"AppHeader shows the badge and hides the admin page while an admin views as a user","status":"passed","title":"shows the badge and hides the admin page while an admin views as a user","duration":53.448589000000084,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AppHeader"],"fullName":"AppHeader offers nothing of that to a user","status":"passed","title":"offers nothing of that to a user","duration":39.23294800000008,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797351,"endTime":1790690797881.233,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/AppHeader.test.jsx"},{"assertionResults":[{"ancestorTitles":["BacklogCard"],"fullName":"BacklogCard enables saving only after a change","status":"passed","title":"enables saving only after a change","duration":227.31609400000002,"faLine truncated
+16 -19
View File
@@ -1,9 +1,9 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, useLocation} from 'react-router-dom';
import App from './App.jsx'; import App from './App.jsx';
import {PreferencesProvider} from './context/PreferencesContext.jsx'; import {fetchRoutes, json} from './test/fetchRoutes.js';
import {renderWithProviders} from './test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('./test/i18n-mock.js')); vi.mock('react-i18next', () => import('./test/i18n-mock.js'));
vi.mock('./hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})})); vi.mock('./hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
@@ -14,16 +14,8 @@ const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, st
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
const current = {location: null};
function Spy() {
current.location = useLocation();
return null;
}
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page}));
}); });
afterEach(() => { afterEach(() => {
@@ -33,19 +25,24 @@ afterEach(() => {
describe('App', () => { describe('App', () => {
it('ends the session on a 401 that the refresh cannot fix', async () => { it('ends the session on a 401 that the refresh cannot fix', async () => {
render( let expired = false;
<MemoryRouter initialEntries={['/library']}> const library = () => (expired ? json(401, {}) : json(200, page));
<PreferencesProvider><App/><Spy/></PreferencesProvider> fetchRoutes({
</MemoryRouter>, 'GET /api/users/me': json(200, {settings: {}}),
); 'GET /api/library/games': library,
'GET /api/library/games-count': json(200, {hasSteamId: true}),
'GET /api/library/steam-profile': json(200, null),
'POST /api/auth/refresh': json(401, {}),
});
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy()); await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
fetch.mockImplementation(async () => ({ok: false, status: 401, json: async () => ({})})); expired = true;
fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'half'}}); fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'half'}});
await wait(DEBOUNCE_WAIT_MS); await wait(DEBOUNCE_WAIT_MS);
await waitFor(() => expect(current.location.pathname).toBe('/login')); await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
expect(current.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'}); expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'});
expect(localStorage.getItem('tbp_session')).toBeNull(); expect(localStorage.getItem('tbp_session')).toBeNull();
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/refresh'), expect.objectContaining({method: 'POST'})); expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/refresh'), expect.objectContaining({method: 'POST'}));
}); });
+13 -20
View File
@@ -1,11 +1,10 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {toast} from 'sonner'; import {toast} from 'sonner';
import AddGamePanel from './AddGamePanel.jsx'; import AddGamePanel from './AddGamePanel.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {json} from '../test/fetchRoutes.js'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -17,20 +16,16 @@ const zelda = {
const portal = {...zelda, appid: 400, igdbId: 71, name: 'Portal', inLibrary: true}; const portal = {...zelda, appid: 400, igdbId: 71, name: 'Portal', inLibrary: true};
function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch') { function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch',
globalThis.fetch = vi.fn(async (url, init) => { found = json(200, {available: true, results: [zelda, portal]})) {
if (url.includes('/api/users/me')) return json(200, {role: 'USER', settings: {favoritePlatform}}); fetchRoutes({
return init?.method === 'POST' ? post : json(200, {available: true, results: [zelda, portal]}); 'GET /api/users/me': json(200, {role: 'USER', settings: {favoritePlatform}}),
'GET /api/library/game-search': found,
'POST /api/library/games': post,
'POST /api/library/imports': post,
}); });
render( renderWithProviders(<AddGamePanel onClose={() => {}}/>, {path: '/library', entries: ['/library'],
<MemoryRouter initialEntries={['/library']}><CurrentUserProvider> routes: {'/library/:appid': <p>detail page</p>, '/import': <p>import page</p>}});
<Routes>
<Route path="/library" element={<AddGamePanel onClose={() => {}}/>}/>
<Route path="/library/:appid" element={<p>detail page</p>}/>
<Route path="/import" element={<p>import page</p>}/>
</Routes>
</CurrentUserProvider></MemoryRouter>,
);
} }
const searchCalls = () => fetch.mock.calls.filter(([url]) => url.includes('game-search')); const searchCalls = () => fetch.mock.calls.filter(([url]) => url.includes('game-search'));
@@ -119,9 +114,7 @@ describe('AddGamePanel', () => {
}); });
it('shows that adding is off when Core has no IGDB app', async () => { it('shows that adding is off when Core has no IGDB app', async () => {
renderPanel(); renderPanel(undefined, undefined, json(200, {available: false, results: []}));
fetch.mockImplementation(async (url) => (url.includes('/api/users/me')
? json(200, {settings: {}}) : json(200, {available: false, results: []})));
search('zelda'); search('zelda');
expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy(); expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy();
+10 -22
View File
@@ -1,12 +1,11 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {cleanup, fireEvent, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import AppHeader from './AppHeader.jsx'; import AppHeader from './AppHeader.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -17,26 +16,18 @@ afterEach(() => {
useTranslation().i18n.language = 'de'; useTranslation().i18n.language = 'de';
}); });
const loggedInAs = (me) => { const loggedInAs = (me, routes = {}) => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url) => ({ fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}), ...routes});
ok: true, status: 200, json: async () => (url.includes('/api/users/me') ? me : {}), renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>);
}));
render(
<MemoryRouter>
<PreferencesProvider><CurrentUserProvider><AppHeader isAuthenticated onLogout={() => {}}/></CurrentUserProvider></PreferencesProvider>
</MemoryRouter>,
);
}; };
const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'}); const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'});
describe('AppHeader', () => { describe('AppHeader', () => {
it('says so when Auth refuses the language switch', async () => { it('says so when Auth refuses the language switch', async () => {
loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}}); loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}},
fetch.mockImplementation(async (url, init) => (init?.method === 'PUT' {'PUT /api/users/settings': json(500, {})});
? {ok: false, status: 500, json: async () => ({})}
: {ok: true, status: 200, json: async () => ({role: 'USER', activeRole: 'USER', settings: {language: 'de'}})}));
await screen.findByRole('button', {name: 'NAV.MENU'}); await screen.findByRole('button', {name: 'NAV.MENU'});
openMenu(); openMenu();
@@ -47,11 +38,8 @@ describe('AppHeader', () => {
// jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does. // jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does.
it('keeps a name on the home link when a narrow phone hides the app name', () => { it('keeps a name on the home link when a narrow phone hides the app name', () => {
render( fetchRoutes({});
<MemoryRouter> renderWithProviders(<AppHeader isAuthenticated={false} onLogout={() => {}}/>);
<PreferencesProvider><CurrentUserProvider><AppHeader isAuthenticated={false} onLogout={() => {}}/></CurrentUserProvider></PreferencesProvider>
</MemoryRouter>,
);
screen.getByText('ToBePlayed').style.display = 'none'; screen.getByText('ToBePlayed').style.display = 'none';
expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy(); expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy();
+2 -1
View File
@@ -2,6 +2,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import BacklogCard from './BacklogCard.jsx'; import BacklogCard from './BacklogCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -13,7 +14,7 @@ const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
describe('BacklogCard', () => { describe('BacklogCard', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)})); fetchRoutes({'PUT /api/library/games/220/backlog': (url, init) => json(200, JSON.parse(init.body))});
}); });
afterEach(() => { afterEach(() => {
+3 -3
View File
@@ -2,6 +2,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import CompletedHint from './CompletedHint.jsx'; import CompletedHint from './CompletedHint.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -13,9 +14,8 @@ const renderHint = (props) => render(<MemoryRouter><CompletedHint game={game} on
describe('CompletedHint', () => { describe('CompletedHint', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => ({ fetchRoutes({'PUT /api/library/games/220/status': (url, init) =>
ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null}), json(200, {...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null})});
}));
}); });
afterEach(() => { afterEach(() => {
+7 -4
View File
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx'; import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx';
import {hintText} from '../utils/hints.js'; import {hintText} from '../utils/hints.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {error: vi.fn()}})); vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
@@ -11,11 +12,13 @@ vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
const DISMISS = {id: 2, textEn: 'Server update tonight', textDe: 'Heute Nacht Update', kind: 'DISMISS'}; const DISMISS = {id: 2, textEn: 'Server update tonight', textDe: 'Heute Nacht Update', kind: 'DISMISS'};
const ACCEPT = {id: 1, textEn: 'Link your Steam account', textDe: null, kind: 'ACCEPT'}; const ACCEPT = {id: 1, textEn: 'Link your Steam account', textDe: null, kind: 'ACCEPT'};
const answer = (status, body) => ({ok: status < 300, status, headers: new Headers(), json: async () => body}); const hintRoutes = (list, done = json(204)) => fetchRoutes({
'GET /api/hints': list, 'PUT /api/hints/1/done': done, 'PUT /api/hints/2/done': done,
});
const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url); const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url);
const showHints = async (...hints) => { const showHints = async (...hints) => {
globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(204) : answer(200, {results: hints}))); hintRoutes(json(200, {results: hints}));
render(<DashboardHint/>); render(<DashboardHint/>);
await act(async () => undefined); await act(async () => undefined);
}; };
@@ -88,7 +91,7 @@ describe('DashboardHint', () => {
}); });
it('shows an error when the answer is not stored', async () => { it('shows an error when the answer is not stored', async () => {
globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(500) : answer(200, {results: [ACCEPT]}))); hintRoutes(json(200, {results: [ACCEPT]}), json(500));
render(<DashboardHint/>); render(<DashboardHint/>);
await act(async () => undefined); await act(async () => undefined);
@@ -109,7 +112,7 @@ describe('DashboardHint', () => {
}); });
it('shows nothing when the hints do not load', async () => { it('shows nothing when the hints do not load', async () => {
globalThis.fetch = vi.fn(async () => answer(500)); hintRoutes(json(500));
render(<DashboardHint/>); render(<DashboardHint/>);
await act(async () => undefined); await act(async () => undefined);
+9 -6
View File
@@ -5,13 +5,14 @@ import {MemoryRouter, Route, Routes} from 'react-router-dom';
import RequireSession from './RequireSession.jsx'; import RequireSession from './RequireSession.jsx';
import {toast} from 'sonner'; import {toast} from 'sonner';
import DeleteAccountCard from './DeleteAccountCard.jsx'; import DeleteAccountCard from './DeleteAccountCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const EMAIL = 'player@example.com'; const EMAIL = 'player@example.com';
const answer = (status, headers = {}) => ({ok: status < 300, status, headers: new Headers(headers), json: async () => ({})}); const deleteAnswers = (answer, routes = {}) => fetchRoutes({'DELETE /api/users/me': answer, ...routes});
const renderCard = () => render( const renderCard = () => render(
<MemoryRouter initialEntries={['/settings']}> <MemoryRouter initialEntries={['/settings']}>
@@ -33,7 +34,7 @@ const confirmButton = () => screen.getByRole('button', {name: 'SETTINGS.DELETE_A
describe('DeleteAccountCard', () => { describe('DeleteAccountCard', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => answer(204)); deleteAnswers(json(204, {}));
}); });
afterEach(() => { afterEach(() => {
@@ -68,7 +69,7 @@ describe('DeleteAccountCard', () => {
}); });
it('keeps the dialog open on a wrong password', async () => { it('keeps the dialog open on a wrong password', async () => {
fetch.mockResolvedValue(answer(403)); deleteAnswers(json(403, {}));
renderCard(); renderCard();
openAndFill(EMAIL, 'wrong'); openAndFill(EMAIL, 'wrong');
@@ -80,7 +81,7 @@ describe('DeleteAccountCard', () => {
}); });
it('shows the lock message after too many wrong passwords', async () => { it('shows the lock message after too many wrong passwords', async () => {
fetch.mockResolvedValue(answer(429, {'Retry-After': '900'})); deleteAnswers({...json(429, {}), headers: new Headers({'Retry-After': '900'})});
renderCard(); renderCard();
openAndFill(EMAIL, 'wrong'); openAndFill(EMAIL, 'wrong');
@@ -90,7 +91,7 @@ describe('DeleteAccountCard', () => {
}); });
it('opens the login page when the session is gone', async () => { it('opens the login page when the session is gone', async () => {
fetch.mockResolvedValue(answer(401)); deleteAnswers(json(401, {}), {'POST /api/auth/refresh': json(401, {})});
renderCard(); renderCard();
openAndFill(EMAIL, 'secret'); openAndFill(EMAIL, 'secret');
@@ -100,7 +101,9 @@ describe('DeleteAccountCard', () => {
}); });
it('shows the network error when Auth does not answer', async () => { it('shows the network error when Auth does not answer', async () => {
fetch.mockRejectedValue(new TypeError('Failed to fetch')); deleteAnswers(() => {
throw new TypeError('Failed to fetch');
});
renderCard(); renderCard();
openAndFill(EMAIL, 'secret'); openAndFill(EMAIL, 'secret');
+10 -4
View File
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {MemoryRouter, Route, Routes} from 'react-router-dom';
import RequireSession from './RequireSession.jsx'; import RequireSession from './RequireSession.jsx';
import FinishedHint from './FinishedHint.jsx'; import FinishedHint from './FinishedHint.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -24,9 +25,11 @@ const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')
describe('FinishedHint', () => { describe('FinishedHint', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => (String(url).endsWith('/finished-hint') fetchRoutes({
? {ok: true, status: 204} 'PUT /api/library/games/322110/status': (url, init) =>
: {ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: null, notes: null, priority: null})})); json(200, {...JSON.parse(init.body), rating: null, notes: null, priority: null}),
'PUT /api/library/games/322110/finished-hint': json(204),
});
}); });
afterEach(() => { afterEach(() => {
@@ -83,7 +86,10 @@ describe('FinishedHint', () => {
}); });
it('ends the session on a 401', async () => { it('ends the session on a 401', async () => {
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401})); fetchRoutes({
'PUT /api/library/games/322110/finished-hint': json(401, {}),
'POST /api/auth/refresh': json(401, {}),
});
renderHint(); renderHint();
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.NOT_YET'})); fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.NOT_YET'}));
+8 -3
View File
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner'; import {toast} from 'sonner';
import GameCorrectionDialog from './GameCorrectionDialog.jsx'; import GameCorrectionDialog from './GameCorrectionDialog.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -14,7 +15,11 @@ const game = {
corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}], corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}],
}; };
const answer = (status, body = {}) => ({ok: status < 300, status, json: async () => body}); const GAME_URL = '/api/library/games/620/corrections';
const corrections = (answer) => fetchRoutes({
[`PUT ${GAME_URL}/name`]: answer, [`PUT ${GAME_URL}/image`]: answer, [`PUT ${GAME_URL}/speedrun`]: answer,
[`PUT ${GAME_URL}/moods`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
});
const renderDialog = (onChanged = vi.fn()) => { const renderDialog = (onChanged = vi.fn()) => {
render(<MemoryRouter><GameCorrectionDialog game={game} open onOpenChange={vi.fn()} onChanged={onChanged}/></MemoryRouter>); render(<MemoryRouter><GameCorrectionDialog game={game} open onOpenChange={vi.fn()} onChanged={onChanged}/></MemoryRouter>);
@@ -28,7 +33,7 @@ const sent = () => fetch.mock.calls.map(([url, init]) => ({url, method: init?.me
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => answer(204)); corrections(json(204, {}));
}); });
afterEach(() => { afterEach(() => {
@@ -75,7 +80,7 @@ describe('GameCorrectionDialog', () => {
}); });
it('shows why Core refused a value', async () => { it('shows why Core refused a value', async () => {
globalThis.fetch = vi.fn(async () => answer(400, {code: 'INVALID_CORRECTION', message: 'image must be https'})); corrections(json(400, {code: 'INVALID_CORRECTION', message: 'image must be https'}));
const onChanged = renderDialog(); const onChanged = renderDialog();
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.IMAGE'), {target: {value: 'http://x'}}); fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.IMAGE'), {target: {value: 'http://x'}});
+5 -5
View File
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner'; import {toast} from 'sonner';
import GameMergeDialog from './GameMergeDialog.jsx'; import GameMergeDialog from './GameMergeDialog.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -12,12 +13,11 @@ const hades = {appid: 1145360, name: 'Hades'};
const hit = (appid, name) => ({appid, igdbId: Math.abs(appid), name, year: 2020, imageUrl: null, const hit = (appid, name) => ({appid, igdbId: Math.abs(appid), name, year: 2020, imageUrl: null,
platforms: ['Switch'], inLibrary: false, inCatalog: true}); platforms: ['Switch'], inLibrary: false, inCatalog: true});
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
function renderDialog(game, post, results) { function renderDialog(game, post, results) {
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST' fetchRoutes({
? post 'GET /api/library/game-search': json(200, {available: true, results}),
: json(200, {available: true, results})); [`POST /api/library/games/${game.appid}/merge`]: post,
});
const onMerged = vi.fn(); const onMerged = vi.fn();
render(<MemoryRouter><GameMergeDialog game={game} open onOpenChange={vi.fn()} onMerged={onMerged}/></MemoryRouter>); render(<MemoryRouter><GameMergeDialog game={game} open onOpenChange={vi.fn()} onMerged={onMerged}/></MemoryRouter>);
return onMerged; return onMerged;
+2 -1
View File
@@ -2,6 +2,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import PlayingHint from './PlayingHint.jsx'; import PlayingHint from './PlayingHint.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -13,7 +14,7 @@ const renderHint = (props) => render(<MemoryRouter><PlayingHint game={game} onCh
describe('PlayingHint', () => { describe('PlayingHint', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)})); fetchRoutes({'PUT /api/library/games/220/backlog': (url, init) => json(200, JSON.parse(init.body))});
}); });
afterEach(() => { afterEach(() => {
+5 -5
View File
@@ -2,6 +2,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, render} from '@testing-library/react'; import {act, cleanup, render} from '@testing-library/react';
import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx'; import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
const i18n = vi.hoisted(() => ({language: 'de'})); const i18n = vi.hoisted(() => ({language: 'de'}));
vi.mock('react-i18next', async () => { vi.mock('react-i18next', async () => {
@@ -31,11 +32,10 @@ afterEach(() => {
localStorage.clear(); localStorage.clear();
}); });
const answerMe = (settings) => { const answerMe = (settings) => fetchRoutes({
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' 'GET /api/users/me': json(200, {displayName: 'Lobo', settings}),
? {ok: true, status: 204} 'PUT /api/users/settings': json(204),
: {ok: true, status: 200, json: async () => ({displayName: 'Lobo', settings})})); });
};
describe('PreferencesProvider language', () => { describe('PreferencesProvider language', () => {
it('takes the language saved in Auth', async () => { it('takes the language saved in Auth', async () => {
+27 -25
View File
@@ -7,6 +7,7 @@ import {PreferencesProvider, usePreferences} from '../context/PreferencesContext
import useSaveAppearance from './useSaveAppearance.js'; import useSaveAppearance from './useSaveAppearance.js';
import RequireSession from '../components/RequireSession.jsx'; import RequireSession from '../components/RequireSession.jsx';
import {DEFAULT_APPEARANCE} from '../utils/appearance.js'; import {DEFAULT_APPEARANCE} from '../utils/appearance.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -18,6 +19,9 @@ const wrapper = ({children}) => (
</PreferencesProvider></MemoryRouter> </PreferencesProvider></MemoryRouter>
); );
const PUT = 'PUT /api/users/settings/appearance';
const ME = 'GET /api/users/me';
const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper}); const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper});
beforeEach(() => { beforeEach(() => {
@@ -32,7 +36,7 @@ afterEach(() => {
describe('useSaveAppearance', () => { describe('useSaveAppearance', () => {
it('applies the change at once and sends the whole appearance', async () => { it('applies the change at once and sends the whole appearance', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 204})); fetchRoutes({[PUT]: json(204)});
const {result} = renderSave(); const {result} = renderSave();
let saved; let saved;
@@ -50,7 +54,7 @@ describe('useSaveAppearance', () => {
}); });
it('puts the old value back when Auth refuses', async () => { it('puts the old value back when Auth refuses', async () => {
globalThis.fetch = vi.fn(async () => ({ok: false, status: 400})); fetchRoutes({[PUT]: json(400, {})});
const {result} = renderSave(); const {result} = renderSave();
await act(async () => { await act(async () => {
@@ -63,11 +67,9 @@ describe('useSaveAppearance', () => {
it('keeps a later change when an earlier save fails', async () => { it('keeps a later change when an earlier save fails', async () => {
let failFirst; let failFirst;
globalThis.fetch = vi.fn() fetchRoutes({[PUT]: () => (failFirst ? json(204) : new Promise((resolve) => {
.mockImplementationOnce(() => new Promise((resolve) => { failFirst = () => resolve(json(500, {}));
failFirst = () => resolve({ok: false, status: 500}); }))});
}))
.mockImplementation(async () => ({ok: true, status: 204}));
const {result} = renderSave(); const {result} = renderSave();
let first; let first;
@@ -89,7 +91,7 @@ describe('useSaveAppearance', () => {
}); });
it('ends the session on a 401', async () => { it('ends the session on a 401', async () => {
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401})); fetchRoutes({[PUT]: json(401, {}), 'POST /api/auth/refresh': json(401, {})});
const {result} = renderSave(); const {result} = renderSave();
await act(async () => { await act(async () => {
@@ -101,7 +103,7 @@ describe('useSaveAppearance', () => {
it('ends the session with the default look on a 401, so the next user does not get this one', async () => { it('ends the session with the default look on a 401, so the next user does not get this one', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: 620})); localStorage.setItem('tbp_appearance', JSON.stringify({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: 620}));
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401})); fetchRoutes({[PUT]: json(401, {}), 'POST /api/auth/refresh': json(401, {})});
const {result} = renderSave(); const {result} = renderSave();
await act(async () => { await act(async () => {
@@ -115,11 +117,12 @@ describe('useSaveAppearance', () => {
it('waits for a running load from Auth, then keeps its values for the fields not changed here', async () => { it('waits for a running load from Auth, then keeps its values for the fields not changed here', async () => {
const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false}; const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false};
let answerMe; let answerMe;
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT' fetchRoutes({
? Promise.resolve({ok: true, status: 204}) [PUT]: json(204),
: new Promise((resolve) => { [ME]: () => new Promise((resolve) => {
answerMe = () => resolve({ok: true, status: 200, json: async () => ({settings: {appearance: fromServer}})}); answerMe = () => resolve(json(200, {settings: {appearance: fromServer}}));
}))); }),
});
const {result} = renderSave(); const {result} = renderSave();
let sync; let sync;
@@ -146,9 +149,9 @@ describe('useSaveAppearance', () => {
it('does not bring back the look of the user before after a reset', async () => { it('does not bring back the look of the user before after a reset', async () => {
let answerMe; let answerMe;
globalThis.fetch = vi.fn(() => new Promise((resolve) => { fetchRoutes({[ME]: () => new Promise((resolve) => {
answerMe = () => resolve({ok: true, status: 200, json: async () => ({settings: {appearance: {accent: 'red', corners: 'sharp'}}})}); answerMe = () => resolve(json(200, {settings: {appearance: {accent: 'red', corners: 'sharp'}}}));
})); })});
const {result} = renderSave(); const {result} = renderSave();
let sync; let sync;
@@ -166,9 +169,7 @@ describe('useSaveAppearance', () => {
it('sends saves one at a time, so a later save never carries an older value', async () => { it('sends saves one at a time, so a later save never carries an older value', async () => {
const answers = []; const answers = [];
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT' fetchRoutes({[PUT]: () => new Promise((resolve) => answers.push(() => resolve(json(204))))});
? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204})))
: Promise.resolve({ok: true, status: 200, json: async () => ({})})));
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body)); const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
const {result} = renderSave(); const {result} = renderSave();
@@ -205,11 +206,12 @@ describe('useSaveAppearance', () => {
it('sends nothing for a change that waited for the load when a reset came first', async () => { it('sends nothing for a change that waited for the load when a reset came first', async () => {
let answerMe; let answerMe;
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT' fetchRoutes({
? Promise.resolve({ok: true, status: 204}) [PUT]: json(204),
: new Promise((resolve) => { [ME]: () => new Promise((resolve) => {
answerMe = () => resolve({ok: true, status: 200, json: async () => ({})}); answerMe = () => resolve(json(200, {}));
}))); }),
});
const {result} = renderSave(); const {result} = renderSave();
act(() => { act(() => {
+5 -5
View File
@@ -1,9 +1,8 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it} from 'vitest';
import {cleanup, renderHook, waitFor} from '@testing-library/react'; import {cleanup, renderHook, waitFor} from '@testing-library/react';
import useSteamProfile from './useSteamProfile.js'; import useSteamProfile from './useSteamProfile.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
const ok = (body) => ({ok: true, status: 200, json: async () => body});
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', 'first-login'); localStorage.setItem('tbp_session', 'first-login');
@@ -17,7 +16,8 @@ afterEach(() => {
describe('useSteamProfile', () => { describe('useSteamProfile', () => {
it('loads again after the session stamp changed, e.g. with a new Steam ID', async () => { it('loads again after the session stamp changed, e.g. with a new Steam ID', async () => {
let steamName = 'old'; let steamName = 'old';
globalThis.fetch = vi.fn(async () => ok({personaName: steamName, avatarUrl: `https://avatars.steamstatic.com/${steamName}_full.jpg`})); fetchRoutes({'GET /api/library/steam-profile': () =>
json(200, {personaName: steamName, avatarUrl: `https://avatars.steamstatic.com/${steamName}_full.jpg`})});
const {result, rerender} = renderHook(() => useSteamProfile(true)); const {result, rerender} = renderHook(() => useSteamProfile(true));
await waitFor(() => expect(result.current?.avatarUrl).toContain('old_full')); await waitFor(() => expect(result.current?.avatarUrl).toContain('old_full'));
@@ -30,7 +30,7 @@ describe('useSteamProfile', () => {
it('sends nothing without a session, e.g. right after it expired', async () => { it('sends nothing without a session, e.g. right after it expired', async () => {
localStorage.removeItem('tbp_session'); localStorage.removeItem('tbp_session');
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401, json: async () => ({})})); fetchRoutes({});
renderHook(() => useSteamProfile(true)); renderHook(() => useSteamProfile(true));
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
+15 -9
View File
@@ -1,20 +1,26 @@
import {StrictMode} from 'react'; import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client'; import {createRoot} from 'react-dom/client';
import {BrowserRouter} from 'react-router-dom'; import {createBrowserRouter, RouterProvider} from 'react-router-dom';
import App from './App.jsx'; import App from './App.jsx';
import {PreferencesProvider} from './context/PreferencesContext.jsx'; import {PreferencesProvider} from './context/PreferencesContext.jsx';
import {CurrentUserProvider} from './context/CurrentUserContext.jsx'; import {CurrentUserProvider} from './context/CurrentUserContext.jsx';
import './i18n'; import './i18n';
import './style.css'; import './style.css';
// A data router for useBlocker; App keeps its own <Routes> below the one splat route.
const router = createBrowserRouter([{
path: '*',
element: (
<PreferencesProvider>
<CurrentUserProvider>
<App/>
</CurrentUserProvider>
</PreferencesProvider>
),
}]);
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<BrowserRouter> <RouterProvider router={router}/>
<PreferencesProvider>
<CurrentUserProvider>
<App/>
</CurrentUserProvider>
</PreferencesProvider>
</BrowserRouter>
</StrictMode> </StrictMode>
); );
+11 -10
View File
@@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner'; import {toast} from 'sonner';
import AdminHintsPage from './AdminHintsPage.jsx'; import AdminHintsPage from './AdminHintsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -15,7 +16,12 @@ const ACTIVE = {id: 2, textEn: 'Server update tonight', textDe: null, kind: 'DIS
createdBy: 1, createdAt: at(-1), doneCount: 3}; createdBy: 1, createdAt: at(-1), doneCount: 3};
const ENDED = {...ACTIVE, id: 1, textEn: 'Old news', startsAt: at(-3), endsAt: at(-2), status: 'ENDED', doneCount: 0}; const ENDED = {...ACTIVE, id: 1, textEn: 'Old news', startsAt: at(-3), endsAt: at(-2), status: 'ENDED', doneCount: 0};
const answer = (status, body) => ({ok: status < 300, status, headers: new Headers(), json: async () => body}); const withRoutes = (routes) => fetchRoutes({
'GET /api/admin/hints': json(200, {results: [ACTIVE, ENDED]}),
'POST /api/admin/hints': json(201, {id: 3}),
'PUT /api/admin/hints/2/end': json(204),
...routes,
});
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method); const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminHintsPage/></PreferencesProvider></MemoryRouter>); const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminHintsPage/></PreferencesProvider></MemoryRouter>);
@@ -23,11 +29,7 @@ const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminHintsPa
describe('AdminHintsPage', () => { describe('AdminHintsPage', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => { withRoutes({});
if (init?.method === 'POST') return answer(201, {id: 3});
if (init?.method === 'PUT') return answer(204);
return answer(200, {results: [ACTIVE, ENDED]});
});
}); });
afterEach(() => { afterEach(() => {
@@ -68,8 +70,7 @@ describe('AdminHintsPage', () => {
}); });
it('explains an invalid time range', async () => { it('explains an invalid time range', async () => {
fetch.mockImplementation(async (url, init) => withRoutes({'GET /api/admin/hints': json(200, {results: []}), 'POST /api/admin/hints': json(400, {code: 'INVALID_HINT'})});
(init?.method === 'POST' ? answer(400, {code: 'INVALID_HINT'}) : answer(200, {results: []})));
renderPage(); renderPage();
await screen.findByText('ADMIN_HINTS.EMPTY'); await screen.findByText('ADMIN_HINTS.EMPTY');
@@ -82,7 +83,7 @@ describe('AdminHintsPage', () => {
it('takes the status from the server, not from the browser clock', async () => { it('takes the status from the server, not from the browser clock', async () => {
// The server ended the hint by its own clock; by the browser clock the end is still 5 s away. // The server ended the hint by its own clock; by the browser clock the end is still 5 s away.
const endedOnServer = {...ACTIVE, endsAt: new Date(Date.now() + 5_000).toISOString(), status: 'ENDED'}; const endedOnServer = {...ACTIVE, endsAt: new Date(Date.now() + 5_000).toISOString(), status: 'ENDED'};
fetch.mockImplementation(async () => answer(200, {results: [endedOnServer]})); withRoutes({'GET /api/admin/hints': json(200, {results: [endedOnServer]})});
renderPage(); renderPage();
await screen.findByText('Server update tonight'); await screen.findByText('Server update tonight');
@@ -103,7 +104,7 @@ describe('AdminHintsPage', () => {
}); });
it('shows "no access" to a non-admin', async () => { it('shows "no access" to a non-admin', async () => {
fetch.mockImplementation(async () => answer(403)); withRoutes({'GET /api/admin/hints': json(403, {})});
renderPage(); renderPage();
expect(await screen.findByText('ADMIN_HINTS.NO_ACCESS')).toBeTruthy(); expect(await screen.findByText('ADMIN_HINTS.NO_ACCESS')).toBeTruthy();
+13 -5
View File
@@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner'; import {toast} from 'sonner';
import AdminUsersPage from './AdminUsersPage.jsx'; import AdminUsersPage from './AdminUsersPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -16,7 +17,12 @@ const PLAYER = {
const ADMIN = {...PLAYER, id: 1, email: 'admin@example.com', role: 'PLATFORM_ADMIN', steamLinked: false}; const ADMIN = {...PLAYER, id: 1, email: 'admin@example.com', role: 'PLATFORM_ADMIN', steamLinked: false};
const page = (content, totalPages = 1) => ({content, page: 0, size: 20, totalElements: content.length, totalPages}); const page = (content, totalPages = 1) => ({content, page: 0, size: 20, totalElements: content.length, totalPages});
const answer = (status, body) => ({ok: status < 300, status, headers: new Headers(), json: async () => body}); const withRoutes = (routes) => fetchRoutes({
'GET /api/admin/users': json(200, page([ADMIN, PLAYER])),
'PUT /api/admin/users/7/blocked': json(204),
'DELETE /api/admin/users/7': json(204),
...routes,
});
const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminUsersPage/></PreferencesProvider></MemoryRouter>); const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminUsersPage/></PreferencesProvider></MemoryRouter>);
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method); const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
@@ -24,7 +30,7 @@ const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ??
describe('AdminUsersPage', () => { describe('AdminUsersPage', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(204) : answer(200, page([ADMIN, PLAYER])))); withRoutes({});
}); });
afterEach(() => { afterEach(() => {
@@ -78,8 +84,10 @@ describe('AdminUsersPage', () => {
}); });
it('shows the reason when Auth refuses', async () => { it('shows the reason when Auth refuses', async () => {
fetch.mockImplementation(async (url, init) => (init?.method withRoutes({
? answer(409, {code: 'TARGET_IS_ADMIN'}) : answer(200, page([PLAYER])))); 'GET /api/admin/users': json(200, page([PLAYER])),
'PUT /api/admin/users/7/blocked': json(409, {code: 'TARGET_IS_ADMIN'}),
});
renderPage(); renderPage();
fireEvent.click(await screen.findByRole('button', {name: 'ADMIN.BLOCK'})); fireEvent.click(await screen.findByRole('button', {name: 'ADMIN.BLOCK'}));
@@ -89,7 +97,7 @@ describe('AdminUsersPage', () => {
}); });
it('says no access to a user without the admin role', async () => { it('says no access to a user without the admin role', async () => {
fetch.mockResolvedValue(answer(403, {})); withRoutes({'GET /api/admin/users': json(403, {})});
renderPage(); renderPage();
expect(await screen.findByText('ADMIN.NO_ACCESS')).toBeTruthy(); expect(await screen.findByText('ADMIN.NO_ACCESS')).toBeTruthy();
+46 -56
View File
@@ -1,25 +1,39 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import RequireSession from '../components/RequireSession.jsx';
import Dashboard from './Dashboard.jsx'; import Dashboard from './Dashboard.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})})); vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})})); vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
const current = {location: null}; const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
const games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i}));
const empty = {content: [], total: 0, byStatus: {}};
const mostPlayed = (url) => url.includes('sort=playtimeMinutes') && !url.includes('status=');
const quietRoutes = {
'GET /api/library/summary': json(200, empty),
'GET /api/hints': json(200, {results: []}),
};
function Spy() { // Every request of the page with a Steam ID; the test of the 401 answers all but the user with 401.
current.location = useLocation(); const dashboardRoutes = (settings) => ({
return null; ...quietRoutes,
} 'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000', settings}),
'GET /api/library/games': (url) => json(200, mostPlayed(url) ? {content: games, total: 12} : empty),
'GET /api/library/stats': json(200, {trend: null, habits: {moonTrips: 1, longestDay: null, underOneHourGames: 0, underOneHourShare: null}}),
'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: avatar}),
'GET /api/library/activity': json(200, {totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]}),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
'GET /api/library/achievements/summary': json(200, {unlocked: 24, startedGames: 4, perfectGames: 2,
averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []}),
'PUT /api/users/settings/dashboard-tiles': json(204, {}),
});
const ok = (body) => ({ok: true, status: 200, json: async () => body}); const renderDashboard = () => renderWithProviders(<Dashboard/>, {entries: ['/dashboard']});
const unauthorized = () => ({ok: false, status: 401, json: async () => ({})});
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
@@ -32,45 +46,24 @@ afterEach(() => {
describe('Dashboard', () => { describe('Dashboard', () => {
it('opens the login page when the core API answers 401', async () => { it('opens the login page when the core API answers 401', async () => {
globalThis.fetch = vi.fn(async (url) => fetchRoutes({
url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized()); ...Object.fromEntries(Object.keys(dashboardRoutes()).map((key) => [key, json(401, {})])),
render( 'GET /api/users/me': json(200, {email: 'a@b.de', steamId: '76561198000000000'}),
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider><CurrentUserProvider> 'POST /api/auth/refresh': json(401, {}),
<Routes> });
<Route element={<RequireSession/>}><Route path="/dashboard" element={<><Dashboard/><Spy/></>}/></Route> const {router} = renderWithProviders(<Dashboard/>,
<Route path="/login" element={<Spy/>}/> {path: '/dashboard', entries: ['/dashboard'], guarded: true, routes: {'/login': <p>login</p>}});
</Routes>
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
);
await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5)); await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5));
await waitFor(() => expect(current.location.pathname).toBe('/login')); await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
expect(current.location.state).toEqual({sessionExpired: true, from: '/dashboard'}); expect(router.state.location.state).toEqual({sessionExpired: true, from: '/dashboard'});
expect(screen.queryByText('LIBRARY.EMPTY')).toBeNull(); expect(screen.queryByText('LIBRARY.EMPTY')).toBeNull();
}); });
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
const games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i}));
const mockDashboard = (settings) => { const mockDashboard = (settings) => {
globalThis.fetch = vi.fn(async (url, init) => { fetchRoutes(dashboardRoutes(settings));
if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})}; return renderDashboard().container;
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000', settings});
if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) return ok({content: games, total: 12});
if (url.includes('/api/library/stats')) return ok({trend: null, habits: {moonTrips: 1, longestDay: null, underOneHourGames: 0, underOneHourShare: null}});
if (url.includes('/api/library/steam-profile')) return ok({personaName: 'Lobo', avatarUrl: avatar});
if (url.includes('/api/library/activity')) return ok({totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]});
if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true});
if (url.includes('/api/library/achievements/summary')) return ok({unlocked: 24, startedGames: 4, perfectGames: 2,
averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []});
return ok({content: [], total: 0, byStatus: {}});
});
return render(
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider><CurrentUserProvider>
<Dashboard/>
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
).container;
}; };
it('shows the Steam avatar and the most played game of the last two weeks as banner', async () => { it('shows the Steam avatar and the most played game of the last two weeks as banner', async () => {
@@ -154,19 +147,16 @@ describe('Dashboard', () => {
}); });
it('shows the page when a card answers 200 without a JSON body', async () => { it('shows the page when a card answers 200 without a JSON body', async () => {
// No Steam ID, so no activity and achievements are asked. // No Steam ID, so no activity and achievements are asked.
globalThis.fetch = vi.fn(async (url) => { fetchRoutes({
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo'}); ...quietRoutes,
if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true}); 'GET /api/library/stats': json(200, empty),
if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) { 'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo'}),
return {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}; 'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
} 'GET /api/library/games': (url) => (mostPlayed(url)
return ok({content: [], total: 0, byStatus: {}}); ? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}
: json(200, empty)),
}); });
render( renderDashboard();
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider><CurrentUserProvider>
<Dashboard/>
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
);
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy(); expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
}); });
+8 -11
View File
@@ -1,10 +1,9 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import GameDetail from './GameDetail.jsx'; import GameDetail from './GameDetail.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
// Stands in for a saved correction, which loads the game again. // Stands in for a saved correction, which loads the game again.
@@ -21,8 +20,10 @@ const gameLoads = () => fetch.mock.calls.filter(([url]) => url.endsWith('/api/li
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200, fetchRoutes({
json: async () => (url.includes('/api/users/me') ? {role: 'AUDITOR', activeRole: 'AUDITOR'} : game)})); 'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}),
'GET /api/library/games/220': json(200, game),
});
}); });
afterEach(() => { afterEach(() => {
@@ -31,11 +32,7 @@ afterEach(() => {
}); });
it('keeps unsaved notes while the game loads again after a correction', async () => { it('keeps unsaved notes while the game loads again after a correction', async () => {
render( renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider><CurrentUserProvider>
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
);
fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}}); fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}});
fireEvent.click(await screen.findByRole('button', {name: 'correction saved'})); fireEvent.click(await screen.findByRole('button', {name: 'correction saved'}));
+47 -57
View File
@@ -1,10 +1,9 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import GameDetail from './GameDetail.jsx'; import GameDetail from './GameDetail.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
import {resetBrokenImageReports} from '../utils/brokenImages.js'; import {resetBrokenImageReports} from '../utils/brokenImages.js';
import {toast} from 'sonner'; import {toast} from 'sonner';
@@ -16,20 +15,20 @@ const game = {
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [], rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
}; };
const renderDetail = (appid = 220) => render( const renderDetail = (appid = 220) => renderWithProviders(<GameDetail/>,
<MemoryRouter initialEntries={[`/library/${appid}`]}><PreferencesProvider><CurrentUserProvider> {path: '/library/:appid', entries: [`/library/${appid}`], routes: {'/library': <p>library page</p>}});
<Routes>
<Route path="/library/:appid" element={<GameDetail/>}/> const echo = (url, init) => json(200, JSON.parse(init.body));
<Route path="/library" element={<p>library page</p>}/>
</Routes> // The user and the game at /library/<shown.appid>; `routes` adds or replaces answers.
</CurrentUserProvider></PreferencesProvider></MemoryRouter>, const answer = (shown, routes = {}, me = {settings: {}}) => fetchRoutes({
); 'GET /api/users/me': json(200, me),
[`GET /api/library/games/${shown.appid}`]: json(200, shown),
...routes,
});
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT'
? {ok: true, status: 200, json: async () => JSON.parse(init.body)}
: {ok: true, status: 200, json: async () => game});
}); });
afterEach(() => { afterEach(() => {
@@ -40,8 +39,7 @@ afterEach(() => {
describe('GameDetail', () => { describe('GameDetail', () => {
it('asks "finished?" instead of "playing?" when Core has a finished hint', async () => { it('asks "finished?" instead of "playing?" when Core has a finished hint', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, playtime2Weeks: 90, answer({...game, playtime2Weeks: 90, source: 'STEAM', finishedHint: {type: 'ENDING', achievement: 'Survivor'}});
source: 'STEAM', finishedHint: {type: 'ENDING', achievement: 'Survivor'}})}));
renderDetail(); renderDetail();
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_FINISHED'})).toBeTruthy(); expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_FINISHED'})).toBeTruthy();
@@ -49,9 +47,7 @@ describe('GameDetail', () => {
}); });
it('hides a Steam game and shows it again', async () => { it('hides a Steam game and shows it again', async () => {
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT' answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': echo});
? {ok: true, status: 200, json: async () => JSON.parse(init.body)}
: {ok: true, status: 200, json: async () => ({...game, source: 'STEAM', hidden: false})});
renderDetail(); renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'})); fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
@@ -68,9 +64,7 @@ describe('GameDetail', () => {
}); });
it('keeps the game shown when Core refuses', async () => { it('keeps the game shown when Core refuses', async () => {
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT' answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(409, {code: 'MANUAL_GAME'})});
? {ok: false, status: 409, json: async () => ({code: 'MANUAL_GAME'})}
: {ok: true, status: 200, json: async () => ({...game, source: 'STEAM', hidden: false})});
renderDetail(); renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'})); fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
@@ -80,8 +74,7 @@ describe('GameDetail', () => {
}); });
it('offers no hide for a game added by hand', async () => { it('offers no hide for a game added by hand', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, answer({...game, source: 'MANUAL', platform: 'PS5', hidden: false});
json: async () => ({...game, source: 'MANUAL', platform: 'PS5', hidden: false})}));
renderDetail(); renderDetail();
await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM'); await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM');
@@ -91,11 +84,8 @@ describe('GameDetail', () => {
it('reports a broken achievement icon once and shows the empty tile', async () => { it('reports a broken achievement icon once and shows the empty tile', async () => {
const achievement = (apiName) => ({apiName, displayName: apiName, unlocked: false, iconUrl: `https://x/${apiName}.jpg`, const achievement = (apiName) => ({apiName, displayName: apiName, unlocked: false, iconUrl: `https://x/${apiName}.jpg`,
iconGrayUrl: `https://x/${apiName}_gray.jpg`}); iconGrayUrl: `https://x/${apiName}_gray.jpg`});
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST' answer({...game, hasAchievements: true, statsVisible: true, achievementsTotal: 2, achievementsUnlocked: 0,
? {ok: true, status: 204} completionPercent: 0, achievements: [achievement('A'), achievement('B')]}, {'POST /api/library/broken-images': json(204)});
: {ok: true, status: 200, json: async () => ({...game, hasAchievements: true, statsVisible: true,
achievementsTotal: 2, achievementsUnlocked: 0, completionPercent: 0,
achievements: [achievement('A'), achievement('B')]})});
const {container} = renderDetail(); const {container} = renderDetail();
await screen.findByText('A'); await screen.findByText('A');
@@ -109,8 +99,7 @@ describe('GameDetail', () => {
}); });
it('links the speedrun.com page when Core found one', async () => { it('links the speedrun.com page when Core found one', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, answer({...game, speedrunUrl: 'https://www.speedrun.com/hl2'});
json: async () => ({...game, speedrunUrl: 'https://www.speedrun.com/hl2'})}));
renderDetail(); renderDetail();
const link = await screen.findByRole('link', {name: /GAME_DETAIL.OPEN_IN_SPEEDRUN/}); const link = await screen.findByRole('link', {name: /GAME_DETAIL.OPEN_IN_SPEEDRUN/});
@@ -119,7 +108,7 @@ describe('GameDetail', () => {
it.each([null, 'https://www.speedrun.com.evil.example/hl2', 'https://evil.example/hl2', 42])( it.each([null, 'https://www.speedrun.com.evil.example/hl2', 'https://evil.example/hl2', 42])(
'shows no speedrun link for %s', async (speedrunUrl) => { 'shows no speedrun link for %s', async (speedrunUrl) => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, speedrunUrl})})); answer({...game, speedrunUrl});
renderDetail(); renderDetail();
await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM'); await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM');
@@ -127,6 +116,7 @@ describe('GameDetail', () => {
}); });
it('keeps unsaved notes in the backlog card when the hint sets the game to playing', async () => { it('keeps unsaved notes in the backlog card when the hint sets the game to playing', async () => {
answer(game, {'PUT /api/library/games/220/backlog': echo});
renderDetail(); renderDetail();
const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL'); const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL');
fireEvent.change(notes, {target: {value: 'my draft'}}); fireEvent.change(notes, {target: {value: 'my draft'}});
@@ -138,6 +128,7 @@ describe('GameDetail', () => {
}); });
it('uses the game as background and can remove it again', async () => { it('uses the game as background and can remove it again', async () => {
answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail(); renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})); fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'}));
@@ -152,6 +143,7 @@ describe('GameDetail', () => {
}); });
it('uses the game as banner and goes back to the automatic banner', async () => { it('uses the game as banner and goes back to the automatic banner', async () => {
answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail(); renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'})); fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
@@ -166,6 +158,7 @@ describe('GameDetail', () => {
it('offers "use as banner" for the picked game while the banner is off, and turns it on', async () => { it('offers "use as banner" for the picked game while the banner is off, and turns it on', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 220, showBanner: false})); localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 220, showBanner: false}));
answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail(); renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'})); fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
@@ -182,10 +175,10 @@ describe('GameDetail of a game added by hand', () => {
playtime2Weeks: 0, imageUrl: 'https://images.igdb.com/z.jpg', playtime2Weeks: 0, imageUrl: 'https://images.igdb.com/z.jpg',
}; };
const noBody = {ok: true, status: 204, json: async () => { throw new Error('204 has no body'); }};
beforeEach(() => { beforeEach(() => {
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' || init?.method === 'DELETE') answer(manualGame, {'PUT /api/library/games/-1025/manual': noBody, 'DELETE /api/library/games/-1025': noBody});
? {ok: true, status: 204, json: async () => { throw new Error('204 has no body'); }}
: {ok: true, status: 200, json: async () => manualGame});
}); });
it('shows platform and IGDB picture, and offers no Steam actions', async () => { it('shows platform and IGDB picture, and offers no Steam actions', async () => {
@@ -227,7 +220,7 @@ describe('GameDetail of a game added by hand', () => {
}); });
it('keeps Save off for an unchanged game with 75 minutes', async () => { it('keeps Save off for an unchanged game with 75 minutes', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...manualGame, playtimeMinutes: 75})})); answer({...manualGame, playtimeMinutes: 75});
renderDetail(-1025); renderDetail(-1025);
await screen.findByLabelText('MANUAL_GAME.HOURS'); await screen.findByLabelText('MANUAL_GAME.HOURS');
@@ -236,7 +229,7 @@ describe('GameDetail of a game added by hand', () => {
}); });
it('shows no manual card for a Steam game', async () => { it('shows no manual card for a Steam game', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, source: 'STEAM'})})); answer({...game, source: 'STEAM'});
renderDetail(); renderDetail();
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy(); expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
@@ -245,10 +238,10 @@ describe('GameDetail of a game added by hand', () => {
it('keeps a background set in another tab when it sets the profile banner', async () => { it('keeps a background set in another tab when it sets the profile banner', async () => {
let backgroundAppid = null; let backgroundAppid = null;
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200, answer({...game, source: 'STEAM'}, {
json: async () => (url.includes('/api/users/me') 'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', backgroundAppid}}}),
? {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', backgroundAppid}}} 'PUT /api/users/settings/profile-style': json(204),
: {...game, source: 'STEAM'})})); });
renderDetail(); renderDetail();
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'}); const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
@@ -264,10 +257,10 @@ describe('GameDetail of a game added by hand', () => {
it('keeps a banner picked in another tab when it sets this game as banner', async () => { it('keeps a banner picked in another tab when it sets this game as banner', async () => {
let bannerAppid = 220; let bannerAppid = 220;
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200, answer({...game, source: 'STEAM'}, {
json: async () => (url.includes('/api/users/me') 'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: {bannerAppid}}}),
? {role: 'USER', settings: {profileStyle: {bannerAppid}}} 'PUT /api/users/settings/profile-style': json(204),
: {...game, source: 'STEAM'})})); });
renderDetail(); renderDetail();
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'}); const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
@@ -282,12 +275,12 @@ describe('GameDetail of a game added by hand', () => {
it('uses the art as profile banner and removes it on the second click', async () => { it('uses the art as profile banner and removes it on the second click', async () => {
let stored = {accent: 'red', corners: 'round'}; let stored = {accent: 'red', corners: 'round'};
globalThis.fetch = vi.fn(async (url, init) => { answer({...game, source: 'STEAM'}, {
if (init?.method === 'PUT') stored = JSON.parse(init.body); 'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: stored}}),
return {ok: true, status: init?.method === 'PUT' ? 204 : 200, 'PUT /api/users/settings/profile-style': (url, init) => {
json: async () => (url.includes('/api/users/me') stored = JSON.parse(init.body);
? {role: 'USER', settings: {profileStyle: stored}} return json(204);
: {...game, source: 'STEAM'})}; },
}); });
const styleBodies = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style')) const styleBodies = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body)); .map(([, init]) => JSON.parse(init.body));
@@ -308,10 +301,7 @@ describe('GameDetail of a game added by hand', () => {
}); });
describe('corrections', () => { describe('corrections', () => {
const loggedInAs = (me) => { const loggedInAs = (me) => answer(game, {}, me);
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
json: async () => (url.includes('/api/users/me') ? me : game)}));
};
it('offers "correct data" to an active auditor', async () => { it('offers "correct data" to an active auditor', async () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'}); loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
@@ -359,7 +349,7 @@ describe('GameDetail confetti', () => {
}); });
const opened = async (entry) => { const opened = async (entry) => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, ...entry})})); answer({...game, ...entry});
const {container} = renderDetail(); const {container} = renderDetail();
await screen.findByText('Half-Life 2'); await screen.findByText('Half-Life 2');
return container.querySelector('canvas[data-size]'); return container.querySelector('canvas[data-size]');
+22 -20
View File
@@ -1,9 +1,10 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes, useLocation, useNavigate} from 'react-router-dom'; import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
import GameLibrary from './GameLibrary.jsx'; import GameLibrary from './GameLibrary.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})})); vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
@@ -12,9 +13,14 @@ vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), b
const DEBOUNCE_WAIT_MS = 700; const DEBOUNCE_WAIT_MS = 700;
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1}; const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
const answer = (games = page.content, hasSteamId = true) => fetchRoutes({
'GET /api/library/games': json(200, {...page, content: games, totalElements: games.length, totalPages: games.length ? 1 : 0}),
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId}),
});
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
// MemoryRouter, not createMemoryRouter: it pushes like the BrowserRouter in main.jsx, also for an unchanged URL. // A data router like main.jsx: a navigation to the same URL replaces the entry.
function renderLibrary(initialEntry = '/library') { function renderLibrary(initialEntry = '/library') {
const lib = {navigations: 0, location: null, navigate: null}; const lib = {navigations: 0, location: null, navigate: null};
function Spy() { function Spy() {
@@ -26,11 +32,11 @@ function renderLibrary(initialEntry = '/library') {
} }
return null; return null;
} }
render( const router = createMemoryRouter([{
<MemoryRouter initialEntries={[initialEntry]}><PreferencesProvider> path: '/library',
<Routes><Route path="/library" element={<><GameLibrary/><Spy/></>}/></Routes> element: <PreferencesProvider><GameLibrary/><Spy/></PreferencesProvider>,
</PreferencesProvider></MemoryRouter>, }], {initialEntries: [initialEntry]});
); render(<RouterProvider router={router}/>);
return { return {
navigations: () => lib.navigations, navigations: () => lib.navigations,
params: () => new URLSearchParams(lib.location.search), params: () => new URLSearchParams(lib.location.search),
@@ -42,7 +48,7 @@ const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {tar
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page})); answer();
}); });
afterEach(() => { afterEach(() => {
@@ -52,10 +58,10 @@ afterEach(() => {
describe('GameLibrary search', () => { describe('GameLibrary search', () => {
it('shows the achievement progress of a game when it is known', async () => { it('shows the achievement progress of a game when it is known', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...page, content: [ answer([
{...page.content[0], achievementsUnlocked: 3, achievementsTotal: 4}, {...page.content[0], achievementsUnlocked: 3, achievementsTotal: 4},
{appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', achievementsUnlocked: null, achievementsTotal: 5}, {appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', achievementsUnlocked: null, achievementsTotal: 5},
]})})); ]);
renderLibrary(); renderLibrary();
expect(await screen.findByText('3/4')).toBeTruthy(); expect(await screen.findByText('3/4')).toBeTruthy();
@@ -100,7 +106,7 @@ describe('GameLibrary search', () => {
typeInto('half'); typeInto('half');
await waitFor(() => expect(lib.params().get('q')).toBe('half')); await waitFor(() => expect(lib.params().get('q')).toBe('half'));
act(() => lib.back()); await act(async () => lib.back());
await waitFor(() => expect(screen.getByRole('searchbox').value).toBe('')); await waitFor(() => expect(screen.getByRole('searchbox').value).toBe(''));
}); });
@@ -161,7 +167,7 @@ describe('GameLibrary short games', () => {
}); });
it('says so when no game is hidden', async () => { it('says so when no game is hidden', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...page, content: [], totalElements: 0, totalPages: 0})})); answer([]);
renderLibrary('/library?hidden=true'); renderLibrary('/library?hidden=true');
expect(await screen.findByText('LIBRARY.NO_HIDDEN')).toBeTruthy(); expect(await screen.findByText('LIBRARY.NO_HIDDEN')).toBeTruthy();
@@ -169,15 +175,11 @@ describe('GameLibrary short games', () => {
}); });
describe('GameLibrary with games added by hand', () => { describe('GameLibrary with games added by hand', () => {
const answer = (games, hasSteamId) => vi.fn(async (url) => ({
ok: true, status: 200,
json: async () => (String(url).includes('/games-count') ? {count: games.length, hasSteamId} : {...page, content: games}),
}));
const zelda = {appid: -1025, name: 'Zelda', playtimeMinutes: 90, status: 'BACKLOG', source: 'MANUAL', const zelda = {appid: -1025, name: 'Zelda', playtimeMinutes: 90, status: 'BACKLOG', source: 'MANUAL',
platform: 'Nintendo Switch', imageUrl: 'https://images.igdb.com/z.jpg'}; platform: 'Nintendo Switch', imageUrl: 'https://images.igdb.com/z.jpg'};
it('shows the platform and the IGDB picture of a manual game', async () => { it('shows the platform and the IGDB picture of a manual game', async () => {
globalThis.fetch = answer([zelda], true); answer([zelda], true);
renderLibrary(); renderLibrary();
expect(await screen.findByText('Nintendo Switch')).toBeTruthy(); expect(await screen.findByText('Nintendo Switch')).toBeTruthy();
@@ -185,7 +187,7 @@ describe('GameLibrary with games added by hand', () => {
}); });
it('shows Steam as the platform of a Steam game, and links the import', async () => { it('shows Steam as the platform of a Steam game, and links the import', async () => {
globalThis.fetch = answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', source: 'STEAM', answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', source: 'STEAM',
platform: null}], true); platform: null}], true);
renderLibrary(); renderLibrary();
@@ -194,7 +196,7 @@ describe('GameLibrary with games added by hand', () => {
}); });
it('hides the Steam sync for a user without a Steam ID but still offers adding a game', async () => { it('hides the Steam sync for a user without a Steam ID but still offers adding a game', async () => {
globalThis.fetch = answer([zelda], false); answer([zelda], false);
renderLibrary(); renderLibrary();
await screen.findByText('Zelda'); await screen.findByText('Zelda');
@@ -203,7 +205,7 @@ describe('GameLibrary with games added by hand', () => {
}); });
it('opens the add panel', async () => { it('opens the add panel', async () => {
globalThis.fetch = answer([zelda], true); answer([zelda], true);
renderLibrary(); renderLibrary();
fireEvent.click(await screen.findByRole('button', {name: 'MANUAL_GAME.ADD_BUTTON'})); fireEvent.click(await screen.findByRole('button', {name: 'MANUAL_GAME.ADD_BUTTON'}));
+7 -3
View File
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom'; import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import LoginPage from './LoginPage.jsx'; import LoginPage from './LoginPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -22,6 +23,9 @@ function renderLogin(state) {
); );
} }
// The page reads Retry-After from a refusal.
const refused = (status, code) => ({...json(status, {code}), headers: new Headers()});
function submit() { function submit() {
fireEvent.change(screen.getByLabelText('LOGIN.EMAIL'), {target: {value: 'a@b.de'}}); fireEvent.change(screen.getByLabelText('LOGIN.EMAIL'), {target: {value: 'a@b.de'}});
fireEvent.change(screen.getByLabelText('LOGIN.PASSWORD'), {target: {value: 'secret'}}); fireEvent.change(screen.getByLabelText('LOGIN.PASSWORD'), {target: {value: 'secret'}});
@@ -30,7 +34,7 @@ function submit() {
describe('LoginPage', () => { describe('LoginPage', () => {
beforeEach(() => { beforeEach(() => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({message: 'Login successful'})})); fetchRoutes({'POST /api/auth/login': json(200, {message: 'Login successful'})});
}); });
afterEach(() => { afterEach(() => {
@@ -72,7 +76,7 @@ describe('LoginPage', () => {
}); });
it('hides the expired message once a login attempt fails', async () => { it('hides the expired message once a login attempt fails', async () => {
fetch.mockResolvedValueOnce({ok: false, status: 401, headers: new Headers(), json: async () => ({code: 'INVALID_CREDENTIALS'})}); fetchRoutes({'POST /api/auth/login': refused(401, 'INVALID_CREDENTIALS')});
renderLogin({sessionExpired: true, from: '/library'}); renderLogin({sessionExpired: true, from: '/library'});
submit(); submit();
@@ -82,7 +86,7 @@ describe('LoginPage', () => {
}); });
it('says that a blocked account is blocked', async () => { it('says that a blocked account is blocked', async () => {
fetch.mockResolvedValueOnce({ok: false, status: 403, headers: new Headers(), json: async () => ({code: 'ACCOUNT_BLOCKED'})}); fetchRoutes({'POST /api/auth/login': refused(403, 'ACCOUNT_BLOCKED')});
renderLogin(); renderLogin();
submit(); submit();
+2 -1
View File
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react'; import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import PlayNextPage from './PlayNextPage.jsx'; import PlayNextPage from './PlayNextPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
// react-i18next hands out a new `t` on every language change; this mock does the same on demand. // react-i18next hands out a new `t` on every language change; this mock does the same on demand.
@@ -19,7 +20,7 @@ describe('PlayNextPage on a language change', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
i18n.t = (key) => key; i18n.t = (key) => key;
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({games: [game(1)]})})); fetchRoutes({'GET /api/library/picker': json(200, {games: [game(1)]})});
}); });
afterEach(() => { afterEach(() => {
+11 -17
View File
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import PlayNextPage from './PlayNextPage.jsx'; import PlayNextPage from './PlayNextPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -13,15 +14,13 @@ const game = (appid, extra = {}) => ({
timeToBeatMinutes: 600, ...extra, timeToBeatMinutes: 600, ...extra,
}); });
const json = (body, status = 200) => ({ok: status < 400, status, json: async () => body}); // Answers each picker call with the next list.
function mockBackend(pickerAnswers, routes = {}) {
// Answers each picker call with the next list and records every request.
function mockBackend(pickerAnswers, detail = null) {
const answers = [...pickerAnswers]; const answers = [...pickerAnswers];
globalThis.fetch = vi.fn(async (url, options = {}) => { fetchRoutes({
if (url.includes('/api/library/picker')) return json({games: answers.shift() ?? []}); 'GET /api/library/picker': () => json(200, {games: answers.shift() ?? []}),
if (options.method === 'PUT') return json(JSON.parse(options.body)); 'PUT /api/library/games/7/status': (url, init) => json(200, JSON.parse(init.body)),
return json(detail); ...routes,
}); });
} }
@@ -135,10 +134,8 @@ describe('PlayNextPage', () => {
}); });
it('drops the old picks when a new budget fails to load', async () => { it('drops the old picks when a new budget fails to load', async () => {
mockBackend([[game(1)]]); mockBackend([], {'GET /api/library/picker': (url) =>
const pickerFetch = globalThis.fetch; (url.includes('maxMinutes=300') ? json(500, {}) : json(200, {games: [game(1)]}))});
globalThis.fetch = vi.fn(async (url, options) =>
(url.includes('maxMinutes=300') ? json({}, 500) : pickerFetch(url, options)));
renderPage(); renderPage();
await screen.findByText('Game 1'); await screen.findByText('Game 1');
@@ -195,10 +192,7 @@ describe('PlayNextPage', () => {
}); });
it('lets the user try again when saving fails', async () => { it('lets the user try again when saving fails', async () => {
mockBackend([[game(7)]]); mockBackend([[game(7)]], {'PUT /api/library/games/7/status': json(500, {})});
const pickerFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async (url, options) =>
(options?.method === 'PUT' ? json({}, 500) : pickerFetch(url, options)));
renderPage(); renderPage();
await screen.findByText('Game 7'); await screen.findByText('Game 7');
@@ -224,7 +218,7 @@ describe('PlayNextPage', () => {
}); });
it('offers a retry when the first load fails', async () => { it('offers a retry when the first load fails', async () => {
globalThis.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch'); }); mockBackend([], {'GET /api/library/picker': () => { throw new TypeError('Failed to fetch'); }});
renderPage(); renderPage();
await screen.findByText('INFO.ERROR.NETWORK_FAILURE'); await screen.findByText('INFO.ERROR.NETWORK_FAILURE');
+15 -19
View File
@@ -1,10 +1,9 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import PublicProfilePage from './PublicProfilePage.jsx'; import PublicProfilePage from './PublicProfilePage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -15,29 +14,26 @@ const library = {
finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}], finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}],
}; };
function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library, me} = {}) { // `me` only for a test with a session: without one the page asks nothing about the viewer.
globalThis.fetch = vi.fn(async (url, init) => { function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me} = {}) {
if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})}; fetchRoutes({
if (url.includes('/api/users/me')) return {ok: !!me, status: me ? 200 : 401, json: async () => me}; [`GET /api/public/profiles/${encodeURIComponent(name)}`]:
if (url.includes('/api/public/profiles/library')) { json(profileStatus, {displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style}),
return {ok: libraryStatus === 200, status: libraryStatus, json: async () => answer}; 'GET /api/public/profiles/library': json(libraryStatus, answer),
} ...(me && {
return {ok: profileStatus === 200, status: profileStatus, 'GET /api/users/me': json(200, me),
json: async () => ({displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style})}; 'PUT /api/users/settings/profile-tiles': json(204, {}),
}),
}); });
} }
const renderPage = (name = 'Lobo') => render( const renderPage = (name = 'Lobo') => renderWithProviders(<PublicProfilePage/>, {path: '/u/:name', entries: [`/u/${name}`]});
<MemoryRouter initialEntries={[`/u/${name}`]}><PreferencesProvider><CurrentUserProvider>
<Routes><Route path="/u/:name" element={<PublicProfilePage/>}/></Routes>
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
);
describe('PublicProfilePage', () => { describe('PublicProfilePage', () => {
afterEach(cleanup); afterEach(cleanup);
it('asks Auth for the ticket, then Core for the library with it', async () => { it('asks Auth for the ticket, then Core for the library with it', async () => {
mockBackends(); mockBackends({name: 'Lo bo'});
renderPage('Lo bo'); renderPage('Lo bo');
expect(await screen.findByText('Portal 2')).toBeTruthy(); expect(await screen.findByText('Portal 2')).toBeTruthy();
+9 -12
View File
@@ -3,6 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import RegistrationPage from './RegistrationPage.jsx'; import RegistrationPage from './RegistrationPage.jsx';
import {fetchRoutes} from '../test/fetchRoutes.js';
// Own mock: the shared one drops the {{minutes}} value this test checks. // Own mock: the shared one drops the {{minutes}} value this test checks.
vi.mock('react-i18next', () => ({ vi.mock('react-i18next', () => ({
@@ -12,14 +13,10 @@ vi.mock('react-i18next', () => ({
}), }),
})); }));
function errorResponse(status, code, headers = {}, message = undefined) { // Not `json` from fetchRoutes: the page reads Retry-After from the headers.
return { const answerRegistration = (status, code, headers = {}, message = undefined) => fetchRoutes({
ok: false, 'POST /api/registration/new': {ok: false, status, headers: new Headers(headers), json: async () => ({code, message})},
status, });
headers: new Headers(headers),
json: async () => ({code, message}),
};
}
function submitValidForm() { function submitValidForm() {
render(<MemoryRouter><RegistrationPage/></MemoryRouter>); render(<MemoryRouter><RegistrationPage/></MemoryRouter>);
@@ -36,7 +33,7 @@ describe('RegistrationPage', () => {
}); });
it('shows the wait time when the IP is blocked', async () => { it('shows the wait time when the IP is blocked', async () => {
globalThis.fetch = vi.fn(async () => errorResponse(429, 'TOO_MANY_ATTEMPTS', {'Retry-After': '900'})); answerRegistration(429, 'TOO_MANY_ATTEMPTS', {'Retry-After': '900'});
submitValidForm(); submitValidForm();
@@ -44,7 +41,7 @@ describe('RegistrationPage', () => {
}); });
it('asks for no Steam ID and sends none', async () => { it('asks for no Steam ID and sends none', async () => {
globalThis.fetch = vi.fn(async () => errorResponse(400, 'INVALID_INVITATION_CODE')); answerRegistration(400, 'INVALID_INVITATION_CODE');
submitValidForm(); submitValidForm();
@@ -54,7 +51,7 @@ describe('RegistrationPage', () => {
}); });
it('maps a known backend error to its message', async () => { it('maps a known backend error to its message', async () => {
globalThis.fetch = vi.fn(async () => errorResponse(400, 'INVALID_INVITATION_CODE')); answerRegistration(400, 'INVALID_INVITATION_CODE');
submitValidForm(); submitValidForm();
@@ -62,7 +59,7 @@ describe('RegistrationPage', () => {
}); });
it('shows its own text, not the English one of the server, for a refused input', async () => { it('shows its own text, not the English one of the server, for a refused input', async () => {
globalThis.fetch = vi.fn(async () => errorResponse(400, 'VALIDATION_FAILED', {}, 'The request is not valid')); answerRegistration(400, 'VALIDATION_FAILED', {}, 'The request is not valid');
submitValidForm(); submitValidForm();
+13 -13
View File
@@ -1,11 +1,10 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner'; import {toast} from 'sonner';
import SettingsPage from './SettingsPage.jsx'; import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', async () => { vi.mock('react-i18next', async () => {
const mock = await import('../test/i18n-mock.js'); const mock = await import('../test/i18n-mock.js');
@@ -23,17 +22,18 @@ const profile = (overrides = {}) => ({
const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method); const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method);
const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }};
function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) { function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
globalThis.fetch = vi.fn(async (url, init) => { fetchRoutes({
if (init?.method === 'POST' && url.endsWith('/api/users/steam-link')) { 'GET /api/users/me': json(200, me),
return {ok: true, status: 200, json: async () => ({redirectUrl})}; 'POST /api/users/steam-link': json(200, {redirectUrl}),
} 'DELETE /api/users/steam-link': noBody,
if (init?.method && init.method !== 'GET') { 'PUT /api/users/settings': noBody,
return {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}; 'POST /api/auth/refresh': noBody,
} 'GET /api/version': json(404, {}),
return {ok: true, status: 200, json: async () => me};
}); });
render(<MemoryRouter initialEntries={[entry]}><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>); renderWithProviders(<SettingsPage/>, {path: '/settings', entries: [entry]});
} }
describe('SettingsPage Steam account', () => { describe('SettingsPage Steam account', () => {
+9 -8
View File
@@ -1,11 +1,10 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, render, screen} from '@testing-library/react'; import {act, cleanup, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner'; import {toast} from 'sonner';
import SettingsPage from './SettingsPage.jsx'; import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
// Like react-i18next: a language change re-renders every subscriber with a fresh `t`. // Like react-i18next: a language change re-renders every subscriber with a fresh `t`.
vi.mock('react-i18next', async () => { vi.mock('react-i18next', async () => {
@@ -33,9 +32,11 @@ const profile = {
describe('SettingsPage Steam toast', () => { describe('SettingsPage Steam toast', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => (init?.method && init.method !== 'GET' fetchRoutes({
? {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }} 'GET /api/users/me': json(200, profile),
: {ok: true, status: 200, json: async () => profile})); 'POST /api/auth/refresh': {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }},
'GET /api/version': json(404, {}),
});
}); });
afterEach(() => { afterEach(() => {
@@ -45,7 +46,7 @@ describe('SettingsPage Steam toast', () => {
}); });
it('shows the linked toast once, also after a language change', async () => { it('shows the linked toast once, also after a language change', async () => {
render(<MemoryRouter initialEntries={['/settings?steam=linked']}><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>); renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings?steam=linked']});
await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED'); await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED');
expect(toast.success).toHaveBeenCalledTimes(1); expect(toast.success).toHaveBeenCalledTimes(1);
+23 -15
View File
@@ -1,10 +1,9 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; import {act, cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import SettingsPage from './SettingsPage.jsx'; import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', async () => { vi.mock('react-i18next', async () => {
const mock = await import('../test/i18n-mock.js'); const mock = await import('../test/i18n-mock.js');
@@ -22,12 +21,21 @@ const appearanceCard = () => within(screen.getByText('SETTINGS.APPEARANCE').clos
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body)); const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
// Every PUT answers 204 without a body, like Auth does.
const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }};
const authRoutes = (me) => ({
'GET /api/users/me': () => json(200, me()),
'PUT /api/users/settings': noBody,
'PUT /api/users/settings/appearance': noBody,
'PUT /api/users/settings/profile-style': noBody,
'PUT /api/users/settings/favorite-platform': noBody,
'GET /api/version': json(404, {}),
});
function renderPage(me) { function renderPage(me) {
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' fetchRoutes(authRoutes(() => me));
// Both PUTs answer 204 without a body, like Auth does. renderWithProviders(<SettingsPage/>);
? {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}
: {ok: true, status: 200, json: async () => me}));
render(<MemoryRouter><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
} }
/** /**
@@ -36,14 +44,14 @@ function renderPage(me) {
function fakeAuth(me) { function fakeAuth(me) {
const saved = structuredClone(me); const saved = structuredClone(me);
const open = []; const open = [];
globalThis.fetch = vi.fn((url, init) => { fetchRoutes({
if (init?.method !== 'PUT') return Promise.resolve({ok: true, status: 200, json: async () => structuredClone(saved)}); ...authRoutes(() => structuredClone(saved)),
return new Promise((resolve) => open.push(() => { 'PUT /api/users/settings': (url, init) => new Promise((resolve) => open.push(() => {
const body = JSON.parse(init.body); const body = JSON.parse(init.body);
saved.displayName = body.displayName; saved.displayName = body.displayName;
Object.assign(saved.settings, {useDarkTheme: body.useDarkTheme, language: body.language, publicProfile: body.publicProfile}); Object.assign(saved.settings, {useDarkTheme: body.useDarkTheme, language: body.language, publicProfile: body.publicProfile});
resolve({ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}); resolve(noBody);
})); })),
}); });
return {answer: () => open.shift()()}; return {answer: () => open.shift()()};
} }
@@ -67,7 +75,7 @@ describe('SettingsPage public profile', () => {
it('builds a save on the one before it, also while that one is still open', async () => { it('builds a save on the one before it, also while that one is still open', async () => {
const auth = fakeAuth(profile()); const auth = fakeAuth(profile());
render(<MemoryRouter><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>); renderWithProviders(<SettingsPage/>);
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'})); fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
await waitFor(() => expect(puts()).toHaveLength(1)); await waitFor(() => expect(puts()).toHaveLength(1));
+5 -4
View File
@@ -4,10 +4,11 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import StatsPage from './StatsPage.jsx'; import StatsPage from './StatsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const json = (body, status = 200) => ({ok: status < 400, status, json: async () => body}); const answerStats = (answer) => fetchRoutes({'GET /api/library/stats': answer});
const stats = (extra = {}) => ({ const stats = (extra = {}) => ({
overview: {totalGames: 12, totalPlaytimeMinutes: 6000, playedGames: 8, neverStarted: 4, overview: {totalGames: 12, totalPlaytimeMinutes: 6000, playedGames: 8, neverStarted: 4,
@@ -38,7 +39,7 @@ describe('StatsPage', () => {
}); });
it('asks for every group once and shows each of them', async () => { it('asks for every group once and shows each of them', async () => {
globalThis.fetch = vi.fn(async () => json(stats())); answerStats(json(200, stats()));
renderPage(); renderPage();
expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy(); expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy();
@@ -56,7 +57,7 @@ describe('StatsPage', () => {
it('shows the empty texts of a new library', async () => { it('shows the empty texts of a new library', async () => {
const empty = stats(); const empty = stats();
globalThis.fetch = vi.fn(async () => json({ answerStats(json(200, {
...empty, ...empty,
pile: {...empty.pile, paceWeeks: null, oldestUnplayed: null}, pile: {...empty.pile, paceWeeks: null, oldestUnplayed: null},
topGames: {mostPlayed: [], mostLoyal: null}, topGames: {mostPlayed: [], mostLoyal: null},
@@ -77,7 +78,7 @@ describe('StatsPage', () => {
}); });
it('offers a retry when the stats could not be loaded', async () => { it('offers a retry when the stats could not be loaded', async () => {
globalThis.fetch = vi.fn(async () => json({}, 500)); answerStats(json(500, {}));
renderPage(); renderPage();
fireEvent.click(await screen.findByRole('button', {name: /DASHBOARD.RETRY/})); fireEvent.click(await screen.findByRole('button', {name: /DASHBOARD.RETRY/}));
+14 -20
View File
@@ -1,10 +1,9 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import RequireSession from '../components/RequireSession.jsx';
import WishlistPage from './WishlistPage.jsx'; import WishlistPage from './WishlistPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -13,23 +12,15 @@ const wishlist = {games: [
{appid: 3012600, name: 'Coming Soon', addedAt: '2025-06-01T00:00:00Z', price: null, originalPrice: null, discountPct: 0}, {appid: 3012600, name: 'Coming Soon', addedAt: '2025-06-01T00:00:00Z', price: null, originalPrice: null, discountPct: 0},
]}; ]};
function LoginState() { // The providers load the user and the preferences; this page does not use them.
return <div data-testid="login">{JSON.stringify(useLocation().state)}</div>; const withAuth = (routes) => fetchRoutes({'GET /api/users/me': json(200, {settings: {}}), ...routes});
}
const renderPage = () => render( const renderPage = () => renderWithProviders(<WishlistPage/>,
<MemoryRouter initialEntries={['/wishlist']}><PreferencesProvider> {path: '/wishlist', entries: ['/wishlist'], guarded: true, routes: {'/login': <p>login</p>}});
<Routes>
<Route element={<RequireSession/>}><Route path="/wishlist" element={<WishlistPage/>}/></Route>
<Route path="/login" element={<LoginState/>}/>
</Routes>
</PreferencesProvider></MemoryRouter>,
);
describe('WishlistPage', () => { describe('WishlistPage', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => wishlist}));
}); });
afterEach(() => { afterEach(() => {
@@ -38,6 +29,7 @@ describe('WishlistPage', () => {
}); });
it('shows each game with its price, and the discount for games on sale', async () => { it('shows each game with its price, and the discount for games on sale', async () => {
withAuth({'GET /api/library/wishlist': json(200, wishlist)});
renderPage(); renderPage();
expect(await screen.findByText('Prison Architect 2')).toBeTruthy(); expect(await screen.findByText('Prison Architect 2')).toBeTruthy();
@@ -49,6 +41,7 @@ describe('WishlistPage', () => {
}); });
it('can show only the games on sale', async () => { it('can show only the games on sale', async () => {
withAuth({'GET /api/library/wishlist': json(200, wishlist)});
renderPage(); renderPage();
await screen.findByText('Coming Soon'); await screen.findByText('Coming Soon');
@@ -59,16 +52,17 @@ describe('WishlistPage', () => {
}); });
it('explains an empty or private wishlist', async () => { it('explains an empty or private wishlist', async () => {
fetch.mockResolvedValueOnce({ok: true, status: 200, json: async () => ({games: []})}); withAuth({'GET /api/library/wishlist': json(200, {games: []})});
renderPage(); renderPage();
expect(await screen.findByText('WISHLIST.EMPTY')).toBeTruthy(); expect(await screen.findByText('WISHLIST.EMPTY')).toBeTruthy();
}); });
it('opens the login page on a 401 that the refresh cannot fix', async () => { it('opens the login page on a 401 that the refresh cannot fix', async () => {
fetch.mockResolvedValue({ok: false, status: 401, json: async () => ({})}); withAuth({'GET /api/library/wishlist': json(401, {}), 'POST /api/auth/refresh': json(401, {})});
renderPage(); const {router} = renderPage();
await waitFor(() => expect(JSON.parse(screen.getByTestId('login').textContent).sessionExpired).toBe(true)); await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
expect(router.state.location.state.sessionExpired).toBe(true);
}); });
}); });
+11 -8
View File
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react'; import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {switchLanguage} from '../test/i18n-mock.js'; import {switchLanguage} from '../test/i18n-mock.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import Dashboard from './Dashboard.jsx'; import Dashboard from './Dashboard.jsx';
@@ -14,19 +15,20 @@ import WishlistPage from './WishlistPage.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
// The last column: what the page loads besides GET /api/users/me of the provider.
const pages = [ const pages = [
['Dashboard', '/', '/', Dashboard, 'INFO.ERROR.LOAD_PROFILE'], ['Dashboard', '/', '/', Dashboard, 'INFO.ERROR.LOAD_PROFILE', []],
['GameDetail', '/games/:appid', '/games/220', GameDetail, 'GAME_DETAIL.ERROR_LOAD'], ['GameDetail', '/games/:appid', '/games/220', GameDetail, 'GAME_DETAIL.ERROR_LOAD', ['/api/library/games/220']],
['GameLibrary', '/library', '/library', GameLibrary, 'LIBRARY.ERROR_LOAD'], ['GameLibrary', '/library', '/library', GameLibrary, 'LIBRARY.ERROR_LOAD',
['PublicProfilePage', '/u/:name', '/u/Lobo', PublicProfilePage, 'PROFILE.ERROR_LOAD'], ['/api/library/games', '/api/library/games-count', '/api/library/sync-status']],
['SettingsPage', '/settings', '/settings', SettingsPage, 'INFO.ERROR.LOAD_PROFILE'], ['PublicProfilePage', '/u/:name', '/u/Lobo', PublicProfilePage, 'PROFILE.ERROR_LOAD', ['/api/public/profiles/Lobo']],
['WishlistPage', '/wishlist', '/wishlist', WishlistPage, 'WISHLIST.ERROR_LOAD'], ['SettingsPage', '/settings', '/settings', SettingsPage, 'INFO.ERROR.LOAD_PROFILE', []],
['WishlistPage', '/wishlist', '/wishlist', WishlistPage, 'WISHLIST.ERROR_LOAD', ['/api/library/wishlist']],
]; ];
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
switchLanguage('de'); switchLanguage('de');
globalThis.fetch = vi.fn(async () => ({ok: false, status: 500, json: async () => ({})}));
}); });
afterEach(() => { afterEach(() => {
@@ -35,7 +37,8 @@ afterEach(() => {
}); });
describe('a language change', () => { describe('a language change', () => {
it.each(pages)('%s: loads nothing again and shows the error in the new language', async (name, path, url, Page, errorKey) => { it.each(pages)('%s: loads nothing again and shows the error in the new language', async (name, path, url, Page, errorKey, loads) => {
fetchRoutes(Object.fromEntries(['/api/users/me', ...loads].map((load) => [`GET ${load}`, json(500, {})])));
const tree = () => ( const tree = () => (
<MemoryRouter initialEntries={[url]}> <MemoryRouter initialEntries={[url]}>
<PreferencesProvider><CurrentUserProvider><Routes><Route path={path} element={<Page/>}/></Routes></CurrentUserProvider></PreferencesProvider> <PreferencesProvider><CurrentUserProvider><Routes><Route path={path} element={<Page/>}/></Routes></CurrentUserProvider></PreferencesProvider>
+10 -3
View File
@@ -1,20 +1,27 @@
import {vi} from 'vitest'; import {onTestFinished, vi} from 'vitest';
export const json = (status, body) => ({ok: status >= 200 && status < 300, status, json: async () => body}); export const json = (status, body) => ({ok: status >= 200 && status < 300, status, json: async () => body});
/** /**
* A fetch mock that answers by method and path, e.g. `'GET /api/users/me'`. The path starts at `/api/` (the base URL * A fetch mock that answers by method and path, e.g. `'GET /api/users/me'`. The path starts at `/api/` (the base URL
* is not set in tests) and leaves out the query. A value is an answer or `(url, init) => answer`. Any other request * is not set in tests) and leaves out the query. A value is an answer or `(url, init) => answer`. Any other request
* throws, so a test cannot pass on a request it did not expect. * rejects, and the test fails at its end even when the page catches the error.
* Sets `globalThis.fetch` and returns the mock. * Sets `globalThis.fetch` and returns the mock.
*/ */
export function fetchRoutes(routes) { export function fetchRoutes(routes) {
const unknown = [];
const mock = vi.fn(async (url, init) => { const mock = vi.fn(async (url, init) => {
const key = `${init?.method ?? 'GET'} ${new URL(url, 'http://x').pathname.replace(/^.*?(?=\/api\/)/, '')}`; const key = `${init?.method ?? 'GET'} ${new URL(url, 'http://x').pathname.replace(/^.*?(?=\/api\/)/, '')}`;
if (!(key in routes)) throw new Error(`no route ${key}`); if (!(key in routes)) {
unknown.push(key);
throw new Error(`no route ${key}`);
}
const answer = routes[key]; const answer = routes[key];
return typeof answer === 'function' ? answer(url, init) : answer; return typeof answer === 'function' ? answer(url, init) : answer;
}); });
globalThis.fetch = mock; globalThis.fetch = mock;
onTestFinished(() => {
if (unknown.length) throw new Error(`requests without a route: ${[...new Set(unknown)].join(', ')}`);
});
return mock; return mock;
} }
+28
View File
@@ -0,0 +1,28 @@
import {render} from '@testing-library/react';
import {createMemoryRouter, Route, RouterProvider, Routes} from 'react-router-dom';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import RequireSession from '../components/RequireSession.jsx';
/**
* Renders `ui` at `path` like main.jsx does: in a data router with both providers. `guarded` puts it under
* RequireSession, as in App. `routes` adds other pages, e.g. `{'/login': <p>login</p>}`. The result has `router`,
* so a test reads `router.state.location`.
*/
export function renderWithProviders(ui, {path = '*', entries = ['/'], routes = {}, guarded = false} = {}) {
const page = <Route path={path} element={ui}/>;
const router = createMemoryRouter([{
path: '*',
element: (
<PreferencesProvider>
<CurrentUserProvider>
<Routes>
{guarded ? <Route element={<RequireSession/>}>{page}</Route> : page}
{Object.entries(routes).map(([at, element]) => <Route key={at} path={at} element={element}/>)}
</Routes>
</CurrentUserProvider>
</PreferencesProvider>
),
}], {initialEntries: entries});
return {...render(<RouterProvider router={router}/>), router};
}
+8 -6
View File
@@ -1,16 +1,18 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {describe, expect, it, vi} from 'vitest'; import {describe, expect, it} from 'vitest';
import {refreshSession} from './api.js'; import {refreshSession} from './api.js';
import {viewAs} from './admin.js'; import {viewAs} from './admin.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
describe('viewAs', () => { describe('viewAs', () => {
it('waits for a running refresh before it sends the refresh cookie', async () => { it('waits for a running refresh before it sends the refresh cookie', async () => {
let finishRefresh; let finishRefresh;
globalThis.fetch = vi.fn((url) => (url.endsWith('/api/auth/refresh') fetchRoutes({
? new Promise((resolve) => { 'POST /api/auth/refresh': () => new Promise((resolve) => {
finishRefresh = () => resolve({ok: true, status: 204}); finishRefresh = () => resolve(json(204));
}) }),
: Promise.resolve({ok: true, status: 204}))); 'POST /api/auth/view-as': json(204),
});
const refreshing = refreshSession(); const refreshing = refreshSession();
const switching = viewAs('USER'); const switching = viewAs('USER');
+4 -5
View File
@@ -1,10 +1,11 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it} from 'vitest';
import {ACHIEVEMENT_ICONS, COVER, reportBrokenImage, resetBrokenImageReports} from './brokenImages.js'; import {ACHIEVEMENT_ICONS, COVER, reportBrokenImage, resetBrokenImageReports} from './brokenImages.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => ({ok: true, status: 204})); fetchRoutes({'POST /api/library/broken-images': json(204, {})});
}); });
afterEach(() => { afterEach(() => {
@@ -40,9 +41,7 @@ describe('reportBrokenImage', () => {
}); });
it('ignores a failed request', async () => { it('ignores a failed request', async () => {
globalThis.fetch = vi.fn(async () => { fetchRoutes({'POST /api/library/broken-images': () => { throw new TypeError('network'); }});
throw new TypeError('network');
});
expect(() => reportBrokenImage(620, COVER)).not.toThrow(); expect(() => reportBrokenImage(620, COVER)).not.toThrow();
await Promise.resolve(); await Promise.resolve();