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

+13 -20
View File
@@ -1,11 +1,10 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import AddGamePanel from './AddGamePanel.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import {json} from '../test/fetchRoutes.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
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};
function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch') {
globalThis.fetch = vi.fn(async (url, init) => {
if (url.includes('/api/users/me')) return json(200, {role: 'USER', settings: {favoritePlatform}});
return init?.method === 'POST' ? post : json(200, {available: true, results: [zelda, portal]});
function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch',
found = json(200, {available: true, results: [zelda, portal]})) {
fetchRoutes({
'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(
<MemoryRouter initialEntries={['/library']}><CurrentUserProvider>
<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>,
);
renderWithProviders(<AddGamePanel onClose={() => {}}/>, {path: '/library', entries: ['/library'],
routes: {'/library/:appid': <p>detail page</p>, '/import': <p>import page</p>}});
}
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 () => {
renderPanel();
fetch.mockImplementation(async (url) => (url.includes('/api/users/me')
? json(200, {settings: {}}) : json(200, {available: false, results: []})));
renderPanel(undefined, undefined, json(200, {available: false, results: []}));
search('zelda');
expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy();
+10 -22
View File
@@ -1,12 +1,11 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {cleanup, fireEvent, screen} from '@testing-library/react';
import {toast} from 'sonner';
import {useTranslation} from 'react-i18next';
import AppHeader from './AppHeader.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.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('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -17,26 +16,18 @@ afterEach(() => {
useTranslation().i18n.language = 'de';
});
const loggedInAs = (me) => {
const loggedInAs = (me, routes = {}) => {
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url) => ({
ok: true, status: 200, json: async () => (url.includes('/api/users/me') ? me : {}),
}));
render(
<MemoryRouter>
<PreferencesProvider><CurrentUserProvider><AppHeader isAuthenticated onLogout={() => {}}/></CurrentUserProvider></PreferencesProvider>
</MemoryRouter>,
);
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}), ...routes});
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>);
};
const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'});
describe('AppHeader', () => {
it('says so when Auth refuses the language switch', async () => {
loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}});
fetch.mockImplementation(async (url, init) => (init?.method === 'PUT'
? {ok: false, status: 500, json: async () => ({})}
: {ok: true, status: 200, json: async () => ({role: 'USER', activeRole: 'USER', settings: {language: 'de'}})}));
loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}},
{'PUT /api/users/settings': json(500, {})});
await screen.findByRole('button', {name: 'NAV.MENU'});
openMenu();
@@ -47,11 +38,8 @@ describe('AppHeader', () => {
// 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', () => {
render(
<MemoryRouter>
<PreferencesProvider><CurrentUserProvider><AppHeader isAuthenticated={false} onLogout={() => {}}/></CurrentUserProvider></PreferencesProvider>
</MemoryRouter>,
);
fetchRoutes({});
renderWithProviders(<AppHeader isAuthenticated={false} onLogout={() => {}}/>);
screen.getByText('ToBePlayed').style.display = 'none';
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 {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import BacklogCard from './BacklogCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -13,7 +14,7 @@ const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
describe('BacklogCard', () => {
beforeEach(() => {
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(() => {
+3 -3
View File
@@ -2,6 +2,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import CompletedHint from './CompletedHint.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -13,9 +14,8 @@ const renderHint = (props) => render(<MemoryRouter><CompletedHint game={game} on
describe('CompletedHint', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => ({
ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null}),
}));
fetchRoutes({'PUT /api/library/games/220/status': (url, init) =>
json(200, {...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null})});
});
afterEach(() => {
+7 -4
View File
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import {toast} from 'sonner';
import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx';
import {hintText} from '../utils/hints.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
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 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 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/>);
await act(async () => undefined);
};
@@ -88,7 +91,7 @@ describe('DashboardHint', () => {
});
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/>);
await act(async () => undefined);
@@ -109,7 +112,7 @@ describe('DashboardHint', () => {
});
it('shows nothing when the hints do not load', async () => {
globalThis.fetch = vi.fn(async () => answer(500));
hintRoutes(json(500));
render(<DashboardHint/>);
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 {toast} from 'sonner';
import DeleteAccountCard from './DeleteAccountCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
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(
<MemoryRouter initialEntries={['/settings']}>
@@ -33,7 +34,7 @@ const confirmButton = () => screen.getByRole('button', {name: 'SETTINGS.DELETE_A
describe('DeleteAccountCard', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => answer(204));
deleteAnswers(json(204, {}));
});
afterEach(() => {
@@ -68,7 +69,7 @@ describe('DeleteAccountCard', () => {
});
it('keeps the dialog open on a wrong password', async () => {
fetch.mockResolvedValue(answer(403));
deleteAnswers(json(403, {}));
renderCard();
openAndFill(EMAIL, 'wrong');
@@ -80,7 +81,7 @@ describe('DeleteAccountCard', () => {
});
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();
openAndFill(EMAIL, 'wrong');
@@ -90,7 +91,7 @@ describe('DeleteAccountCard', () => {
});
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();
openAndFill(EMAIL, 'secret');
@@ -100,7 +101,9 @@ describe('DeleteAccountCard', () => {
});
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();
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 RequireSession from './RequireSession.jsx';
import FinishedHint from './FinishedHint.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
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', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => (String(url).endsWith('/finished-hint')
? {ok: true, status: 204}
: {ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: null, notes: null, priority: null})}));
fetchRoutes({
'PUT /api/library/games/322110/status': (url, init) =>
json(200, {...JSON.parse(init.body), rating: null, notes: null, priority: null}),
'PUT /api/library/games/322110/finished-hint': json(204),
});
});
afterEach(() => {
@@ -83,7 +86,10 @@ describe('FinishedHint', () => {
});
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();
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 {toast} from 'sonner';
import GameCorrectionDialog from './GameCorrectionDialog.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
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'}],
};
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()) => {
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(() => {
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => answer(204));
corrections(json(204, {}));
});
afterEach(() => {
@@ -75,7 +80,7 @@ describe('GameCorrectionDialog', () => {
});
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();
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 {toast} from 'sonner';
import GameMergeDialog from './GameMergeDialog.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
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,
platforms: ['Switch'], inLibrary: false, inCatalog: true});
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
function renderDialog(game, post, results) {
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST'
? post
: json(200, {available: true, results}));
fetchRoutes({
'GET /api/library/game-search': json(200, {available: true, results}),
[`POST /api/library/games/${game.appid}/merge`]: post,
});
const onMerged = vi.fn();
render(<MemoryRouter><GameMergeDialog game={game} open onOpenChange={vi.fn()} onMerged={onMerged}/></MemoryRouter>);
return onMerged;
+2 -1
View File
@@ -2,6 +2,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import PlayingHint from './PlayingHint.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -13,7 +14,7 @@ const renderHint = (props) => render(<MemoryRouter><PlayingHint game={game} onCh
describe('PlayingHint', () => {
beforeEach(() => {
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(() => {