From 2a2278943573e504e5cbd99c3f502dc6c739372f Mon Sep 17 00:00:00 2001 From: lobo Date: Tue, 29 Sep 2026 16:15:50 +0200 Subject: [PATCH] test: data router, one render helper, fetchRoutes in every test --- .vitest/json/output.json | 1 + src/App.test.jsx | 35 +++---- src/components/AddGamePanel.test.jsx | 33 +++--- src/components/AppHeader.test.jsx | 32 ++---- src/components/BacklogCard.test.jsx | 3 +- src/components/CompletedHint.test.jsx | 6 +- src/components/DashboardHint.test.jsx | 11 +- src/components/DeleteAccountCard.test.jsx | 15 +-- src/components/FinishedHint.test.jsx | 14 ++- src/components/GameCorrectionDialog.test.jsx | 11 +- src/components/GameMergeDialog.test.jsx | 10 +- src/components/PlayingHint.test.jsx | 3 +- src/context/PreferencesContext.test.jsx | 10 +- src/hooks/useSaveAppearance.test.jsx | 52 +++++----- src/hooks/useSteamProfile.test.jsx | 10 +- src/main.jsx | 24 +++-- src/pages/AdminHintsPage.test.jsx | 21 ++-- src/pages/AdminUsersPage.test.jsx | 18 +++- src/pages/Dashboard.test.jsx | 102 ++++++++---------- src/pages/GameDetail.reload.test.jsx | 19 ++-- src/pages/GameDetail.test.jsx | 104 +++++++++---------- src/pages/GameLibrary.test.jsx | 42 ++++---- src/pages/LoginPage.test.jsx | 10 +- src/pages/PlayNextPage.language.test.jsx | 3 +- src/pages/PlayNextPage.test.jsx | 28 ++--- src/pages/PublicProfilePage.test.jsx | 34 +++--- src/pages/RegistrationPage.test.jsx | 21 ++-- src/pages/SettingsPage.steam.test.jsx | 26 ++--- src/pages/SettingsPage.steamToast.test.jsx | 17 +-- src/pages/SettingsPage.test.jsx | 38 ++++--- src/pages/StatsPage.test.jsx | 9 +- src/pages/WishlistPage.test.jsx | 34 +++--- src/pages/languageSwitch.test.jsx | 19 ++-- src/test/fetchRoutes.js | 13 ++- src/test/renderWithProviders.jsx | 28 +++++ src/utils/admin.test.js | 14 +-- src/utils/brokenImages.test.js | 9 +- 37 files changed, 454 insertions(+), 425 deletions(-) create mode 100644 .vitest/json/output.json create mode 100644 src/test/renderWithProviders.jsx diff --git a/.vitest/json/output.json b/.vitest/json/output.json new file mode 100644 index 0000000..19e6a14 --- /dev/null +++ b/.vitest/json/output.json @@ -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,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["BacklogCard"],"fullName":"BacklogCard clears the rating on a click on the current star and sends blank notes as null","status":"passed","title":"clears the rating on a click on the current star and sends blank notes as null","duration":115.30850699999996,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797297,"endTime":1790690797640.3086,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/BacklogCard.test.jsx"},{"assertionResults":[{"ancestorTitles":["CompletedHint"],"fullName":"CompletedHint is hidden for a game already at 100 %","status":"passed","title":"is hidden for a game already at 100 %","duration":17.097065999999813,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["CompletedHint"],"fullName":"CompletedHint is hidden for a game with achievements left","status":"passed","title":"is hidden for a game with achievements left","duration":2.3525070000000596,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["CompletedHint"],"fullName":"CompletedHint is hidden for a game without achievements","status":"passed","title":"is hidden for a game without achievements","duration":2.2013750000000982,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["CompletedHint"],"fullName":"CompletedHint is hidden for a game whose achievements Steam hides","status":"passed","title":"is hidden for a game whose achievements Steam hides","duration":1.488418000000138,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["CompletedHint"],"fullName":"CompletedHint sets only the status to COMPLETED","status":"passed","title":"sets only the status to COMPLETED","duration":147.56993699999998,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797291,"endTime":1790690797461.5698,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/CompletedHint.test.jsx"},{"assertionResults":[{"ancestorTitles":["DashboardHint"],"fullName":"DashboardHint shows one hint at a time and the next after closing","status":"passed","title":"shows one hint at a time and the next after closing","duration":56.239990000000034,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["DashboardHint"],"fullName":"DashboardHint does not show a hint closed in this tab again when the dashboard mounts again","status":"passed","title":"does not show a hint closed in this tab again when the dashboard mounts again","duration":20.723477000000003,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["DashboardHint"],"fullName":"DashboardHint stores a dismiss hint only when \"Do not show again\" is ticked","status":"passed","title":"stores a dismiss hint only when \"Do not show again\" is ticked","duration":16.330081999999948,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["DashboardHint"],"fullName":"DashboardHint closes a dismiss hint by itself after 30 s without storing it","status":"passed","title":"closes a dismiss hint by itself after 30 s without storing it","duration":6.390043999999989,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["DashboardHint"],"fullName":"DashboardHint keeps an accept hint until OK and stores it","status":"passed","title":"keeps an accept hint until OK and stores it","duration":6.000232000000096,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["DashboardHint"],"fullName":"DashboardHint shows an error when the answer is not stored","status":"passed","title":"shows an error when the answer is not stored","duration":5.317176000000018,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["DashboardHint"],"fullName":"DashboardHint does not let a double click on OK accept the next hint unseen","status":"passed","title":"does not let a double click on OK accept the next hint unseen","duration":7.993367000000035,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["DashboardHint"],"fullName":"DashboardHint shows nothing when the hints do not load","status":"passed","title":"shows nothing when the hints do not load","duration":3.309500000000071,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["hintText"],"fullName":"hintText takes the German text for a German UI only when there is one","status":"passed","title":"takes the German text for a German UI only when there is one","duration":0.7604670000000624,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797283,"endTime":1790690797406.7605,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/DashboardHint.test.jsx"},{"assertionResults":[{"ancestorTitles":["DeleteAccountCard"],"fullName":"DeleteAccountCard asks for the own email and a password before it deletes","status":"passed","title":"asks for the own email and a password before it deletes","duration":243.64399099999991,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["DeleteAccountCard"],"fullName":"DeleteAccountCard sends the password, ends the session and goes to the landing page","status":"passed","title":"sends the password, ends the session and goes to the landing page","duration":67.53424999999993,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["DeleteAccountCard"],"fullName":"DeleteAccountCard keeps the dialog open on a wrong password","status":"passed","title":"keeps the dialog open on a wrong password","duration":45.703891999999996,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["DeleteAccountCard"],"fullName":"DeleteAccountCard shows the lock message after too many wrong passwords","status":"passed","title":"shows the lock message after too many wrong passwords","duration":33.090895000000046,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["DeleteAccountCard"],"fullName":"DeleteAccountCard opens the login page when the session is gone","status":"passed","title":"opens the login page when the session is gone","duration":35.711696000000074,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["DeleteAccountCard"],"fullName":"DeleteAccountCard shows the network error when Auth does not answer","status":"passed","title":"shows the network error when Auth does not answer","duration":32.00573499999996,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797353,"endTime":1790690797811.0056,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/DeleteAccountCard.test.jsx"},{"assertionResults":[{"ancestorTitles":["FinishedHint"],"fullName":"FinishedHint shows nothing without a hint","status":"passed","title":"shows nothing without a hint","duration":26.573225000000093,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["FinishedHint"],"fullName":"FinishedHint shows nothing once the status is FINISHED","status":"passed","title":"shows nothing once the status is FINISHED","duration":2.6808999999998377,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["FinishedHint"],"fullName":"FinishedHint shows nothing once the status is COMPLETED","status":"passed","title":"shows nothing once the status is COMPLETED","duration":2.419108999999935,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["FinishedHint"],"fullName":"FinishedHint shows nothing once the status is DROPPED","status":"passed","title":"shows nothing once the status is DROPPED","duration":2.1146219999998266,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["FinishedHint"],"fullName":"FinishedHint names the ending achievement","status":"passed","title":"names the ending achievement","duration":17.293413999999984,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["FinishedHint"],"fullName":"FinishedHint explains the playtime hint","status":"passed","title":"explains the playtime hint","duration":3.1451730000001135,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["FinishedHint"],"fullName":"FinishedHint marks the game as finished","status":"passed","title":"marks the game as finished","duration":107.76157000000012,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["FinishedHint"],"fullName":"FinishedHint sends \"Not yet\" with the type of the hint","status":"passed","title":"sends \"Not yet\" with the type of the hint","duration":14.494319000000132,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["FinishedHint"],"fullName":"FinishedHint ends the session on a 401","status":"passed","title":"ends the session on a 401","duration":18.187516999999843,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797307,"endTime":1790690797501.1875,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/FinishedHint.test.jsx"},{"assertionResults":[{"ancestorTitles":["GameCorrectionDialog"],"fullName":"GameCorrectionDialog saves one field and loads the game again","status":"passed","title":"saves one field and loads the game again","duration":275.75592800000004,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["GameCorrectionDialog"],"fullName":"GameCorrectionDialog sends an empty link as \"no speedrun\" and the chosen moods as a list","status":"passed","title":"sends an empty link as \"no speedrun\" and the chosen moods as a list","duration":177.92127900000014,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["GameCorrectionDialog"],"fullName":"GameCorrectionDialog marks a corrected field and goes back to the source","status":"passed","title":"marks a corrected field and goes back to the source","duration":52.74349300000017,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["GameCorrectionDialog"],"fullName":"GameCorrectionDialog shows why Core refused a value","status":"passed","title":"shows why Core refused a value","duration":63.476901,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["GameCorrectionDialog"],"fullName":"GameCorrectionDialog keeps an unsaved edit of another field after the reload","status":"passed","title":"keeps an unsaved edit of another field after the reload","duration":61.243652999999995,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["GameCorrectionDialog"],"fullName":"GameCorrectionDialog offers no eleventh genre","status":"passed","title":"offers no eleventh genre","duration":197.29650100000003,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797314,"endTime":1790690798143.2964,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/GameCorrectionDialog.test.jsx"},{"assertionResults":[{"ancestorTitles":["GameMergeDialog"],"fullName":"GameMergeDialog merges the picked game after the confirm step","status":"passed","title":"merges the picked game after the confirm step","duration":605.215451,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["GameMergeDialog"],"fullName":"GameMergeDialog goes back from the confirm step without merging","status":"passed","title":"goes back from the confirm step without merging","duration":431.21895900000004,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["GameMergeDialog"],"fullName":"GameMergeDialog says why Core refused the merge","status":"passed","title":"says why Core refused the merge","duration":437.1126390000004,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797301,"endTime":1790690798774.1125,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/GameMergeDialog.test.jsx"},{"assertionResults":[{"ancestorTitles":["PlayingHint"],"fullName":"PlayingHint is hidden for a game that is not in the backlog","status":"passed","title":"is hidden for a game that is not in the backlog","duration":27.032241000000113,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["PlayingHint"],"fullName":"PlayingHint is hidden for a backlog game without recent playtime","status":"passed","title":"is hidden for a backlog game without recent playtime","duration":3.4476850000000923,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["PlayingHint"],"fullName":"PlayingHint sets the game to PLAYING and keeps the other fields","status":"passed","title":"sets the game to PLAYING and keeps the other fields","duration":154.891431,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797315,"endTime":1790690797500.8914,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/PlayingHint.test.jsx"}]} \ No newline at end of file diff --git a/src/App.test.jsx b/src/App.test.jsx index f349197..8c263cd 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -1,9 +1,9 @@ // @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; -import {MemoryRouter, useLocation} from 'react-router-dom'; +import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react'; 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('./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 current = {location: null}; - -function Spy() { - current.location = useLocation(); - return null; -} - beforeEach(() => { localStorage.setItem('tbp_session', '1'); - globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page})); }); afterEach(() => { @@ -33,19 +25,24 @@ afterEach(() => { describe('App', () => { it('ends the session on a 401 that the refresh cannot fix', async () => { - render( - - - , - ); + let expired = false; + const library = () => (expired ? json(401, {}) : json(200, page)); + fetchRoutes({ + '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(, {entries: ['/library']}); 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'}}); await wait(DEBOUNCE_WAIT_MS); - await waitFor(() => expect(current.location.pathname).toBe('/login')); - expect(current.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'}); + await waitFor(() => expect(router.state.location.pathname).toBe('/login')); + expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'}); expect(localStorage.getItem('tbp_session')).toBeNull(); expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/refresh'), expect.objectContaining({method: 'POST'})); }); diff --git a/src/components/AddGamePanel.test.jsx b/src/components/AddGamePanel.test.jsx index 82b7366..27319e2 100644 --- a/src/components/AddGamePanel.test.jsx +++ b/src/components/AddGamePanel.test.jsx @@ -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( - - - {}}/>}/> - detail page

}/> - import page

}/> -
-
, - ); + renderWithProviders( {}}/>, {path: '/library', entries: ['/library'], + routes: {'/library/:appid':

detail page

, '/import':

import page

}}); } 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(); diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index 805d406..ad84ad5 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -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( - - {}}/> - , - ); + fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}), ...routes}); + renderWithProviders( {}}/>); }; 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( - - {}}/> - , - ); + fetchRoutes({}); + renderWithProviders( {}}/>); screen.getByText('ToBePlayed').style.display = 'none'; expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy(); diff --git a/src/components/BacklogCard.test.jsx b/src/components/BacklogCard.test.jsx index 41bb156..05c2f4b 100644 --- a/src/components/BacklogCard.test.jsx +++ b/src/components/BacklogCard.test.jsx @@ -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(() => { diff --git a/src/components/CompletedHint.test.jsx b/src/components/CompletedHint.test.jsx index f9040b8..1188558 100644 --- a/src/components/CompletedHint.test.jsx +++ b/src/components/CompletedHint.test.jsx @@ -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( { 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(() => { diff --git a/src/components/DashboardHint.test.jsx b/src/components/DashboardHint.test.jsx index 04e684b..4cf20d9 100644 --- a/src/components/DashboardHint.test.jsx +++ b/src/components/DashboardHint.test.jsx @@ -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(); 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(); 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(); await act(async () => undefined); diff --git a/src/components/DeleteAccountCard.test.jsx b/src/components/DeleteAccountCard.test.jsx index 50690a8..09672e1 100644 --- a/src/components/DeleteAccountCard.test.jsx +++ b/src/components/DeleteAccountCard.test.jsx @@ -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( @@ -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'); diff --git a/src/components/FinishedHint.test.jsx b/src/components/FinishedHint.test.jsx index 3135e1f..e8d4c68 100644 --- a/src/components/FinishedHint.test.jsx +++ b/src/components/FinishedHint.test.jsx @@ -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'})); diff --git a/src/components/GameCorrectionDialog.test.jsx b/src/components/GameCorrectionDialog.test.jsx index ec045b8..361e062 100644 --- a/src/components/GameCorrectionDialog.test.jsx +++ b/src/components/GameCorrectionDialog.test.jsx @@ -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(); @@ -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'}}); diff --git a/src/components/GameMergeDialog.test.jsx b/src/components/GameMergeDialog.test.jsx index 1a58d3a..436692b 100644 --- a/src/components/GameMergeDialog.test.jsx +++ b/src/components/GameMergeDialog.test.jsx @@ -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(); return onMerged; diff --git a/src/components/PlayingHint.test.jsx b/src/components/PlayingHint.test.jsx index 1227f2e..62a35fb 100644 --- a/src/components/PlayingHint.test.jsx +++ b/src/components/PlayingHint.test.jsx @@ -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( { 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(() => { diff --git a/src/context/PreferencesContext.test.jsx b/src/context/PreferencesContext.test.jsx index aaacca3..c519f9c 100644 --- a/src/context/PreferencesContext.test.jsx +++ b/src/context/PreferencesContext.test.jsx @@ -2,6 +2,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, cleanup, render} from '@testing-library/react'; import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; const i18n = vi.hoisted(() => ({language: 'de'})); vi.mock('react-i18next', async () => { @@ -31,11 +32,10 @@ afterEach(() => { localStorage.clear(); }); -const answerMe = (settings) => { - globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' - ? {ok: true, status: 204} - : {ok: true, status: 200, json: async () => ({displayName: 'Lobo', settings})})); -}; +const answerMe = (settings) => fetchRoutes({ + 'GET /api/users/me': json(200, {displayName: 'Lobo', settings}), + 'PUT /api/users/settings': json(204), +}); describe('PreferencesProvider language', () => { it('takes the language saved in Auth', async () => { diff --git a/src/hooks/useSaveAppearance.test.jsx b/src/hooks/useSaveAppearance.test.jsx index 803d6e5..744145b 100644 --- a/src/hooks/useSaveAppearance.test.jsx +++ b/src/hooks/useSaveAppearance.test.jsx @@ -7,6 +7,7 @@ import {PreferencesProvider, usePreferences} from '../context/PreferencesContext import useSaveAppearance from './useSaveAppearance.js'; import RequireSession from '../components/RequireSession.jsx'; 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('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); @@ -18,6 +19,9 @@ const wrapper = ({children}) => ( ); +const PUT = 'PUT /api/users/settings/appearance'; +const ME = 'GET /api/users/me'; + const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper}); beforeEach(() => { @@ -32,7 +36,7 @@ afterEach(() => { describe('useSaveAppearance', () => { 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(); let saved; @@ -50,7 +54,7 @@ describe('useSaveAppearance', () => { }); 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(); await act(async () => { @@ -63,11 +67,9 @@ describe('useSaveAppearance', () => { it('keeps a later change when an earlier save fails', async () => { let failFirst; - globalThis.fetch = vi.fn() - .mockImplementationOnce(() => new Promise((resolve) => { - failFirst = () => resolve({ok: false, status: 500}); - })) - .mockImplementation(async () => ({ok: true, status: 204})); + fetchRoutes({[PUT]: () => (failFirst ? json(204) : new Promise((resolve) => { + failFirst = () => resolve(json(500, {})); + }))}); const {result} = renderSave(); let first; @@ -89,7 +91,7 @@ describe('useSaveAppearance', () => { }); 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(); 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 () => { 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(); 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 () => { const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false}; let answerMe; - globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT' - ? Promise.resolve({ok: true, status: 204}) - : new Promise((resolve) => { - answerMe = () => resolve({ok: true, status: 200, json: async () => ({settings: {appearance: fromServer}})}); - }))); + fetchRoutes({ + [PUT]: json(204), + [ME]: () => new Promise((resolve) => { + answerMe = () => resolve(json(200, {settings: {appearance: fromServer}})); + }), + }); const {result} = renderSave(); let sync; @@ -146,9 +149,9 @@ describe('useSaveAppearance', () => { it('does not bring back the look of the user before after a reset', async () => { let answerMe; - globalThis.fetch = vi.fn(() => new Promise((resolve) => { - answerMe = () => resolve({ok: true, status: 200, json: async () => ({settings: {appearance: {accent: 'red', corners: 'sharp'}}})}); - })); + fetchRoutes({[ME]: () => new Promise((resolve) => { + answerMe = () => resolve(json(200, {settings: {appearance: {accent: 'red', corners: 'sharp'}}})); + })}); const {result} = renderSave(); 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 () => { const answers = []; - globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT' - ? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204}))) - : Promise.resolve({ok: true, status: 200, json: async () => ({})}))); + fetchRoutes({[PUT]: () => new Promise((resolve) => answers.push(() => resolve(json(204))))}); const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body)); 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 () => { let answerMe; - globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT' - ? Promise.resolve({ok: true, status: 204}) - : new Promise((resolve) => { - answerMe = () => resolve({ok: true, status: 200, json: async () => ({})}); - }))); + fetchRoutes({ + [PUT]: json(204), + [ME]: () => new Promise((resolve) => { + answerMe = () => resolve(json(200, {})); + }), + }); const {result} = renderSave(); act(() => { diff --git a/src/hooks/useSteamProfile.test.jsx b/src/hooks/useSteamProfile.test.jsx index cd7d761..2b73353 100644 --- a/src/hooks/useSteamProfile.test.jsx +++ b/src/hooks/useSteamProfile.test.jsx @@ -1,9 +1,8 @@ // @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 useSteamProfile from './useSteamProfile.js'; - -const ok = (body) => ({ok: true, status: 200, json: async () => body}); +import {fetchRoutes, json} from '../test/fetchRoutes.js'; beforeEach(() => { localStorage.setItem('tbp_session', 'first-login'); @@ -17,7 +16,8 @@ afterEach(() => { describe('useSteamProfile', () => { it('loads again after the session stamp changed, e.g. with a new Steam ID', async () => { 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)); 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 () => { localStorage.removeItem('tbp_session'); - globalThis.fetch = vi.fn(async () => ({ok: false, status: 401, json: async () => ({})})); + fetchRoutes({}); renderHook(() => useSteamProfile(true)); await new Promise((resolve) => setTimeout(resolve, 50)); diff --git a/src/main.jsx b/src/main.jsx index b48b415..8d572c9 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,20 +1,26 @@ import {StrictMode} from 'react'; 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 {PreferencesProvider} from './context/PreferencesContext.jsx'; import {CurrentUserProvider} from './context/CurrentUserContext.jsx'; import './i18n'; import './style.css'; +// A data router for useBlocker; App keeps its own below the one splat route. +const router = createBrowserRouter([{ + path: '*', + element: ( + + + + + + ), +}]); + createRoot(document.getElementById('root')).render( - - - - - - - + -); \ No newline at end of file +); diff --git a/src/pages/AdminHintsPage.test.jsx b/src/pages/AdminHintsPage.test.jsx index ea72cf0..9939625 100644 --- a/src/pages/AdminHintsPage.test.jsx +++ b/src/pages/AdminHintsPage.test.jsx @@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom'; import {toast} from 'sonner'; import AdminHintsPage from './AdminHintsPage.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('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}; 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 renderPage = () => render(); @@ -23,11 +29,7 @@ const renderPage = () => render( { beforeEach(() => { localStorage.setItem('tbp_session', '1'); - globalThis.fetch = vi.fn(async (url, init) => { - if (init?.method === 'POST') return answer(201, {id: 3}); - if (init?.method === 'PUT') return answer(204); - return answer(200, {results: [ACTIVE, ENDED]}); - }); + withRoutes({}); }); afterEach(() => { @@ -68,8 +70,7 @@ describe('AdminHintsPage', () => { }); it('explains an invalid time range', async () => { - fetch.mockImplementation(async (url, init) => - (init?.method === 'POST' ? answer(400, {code: 'INVALID_HINT'}) : answer(200, {results: []}))); + withRoutes({'GET /api/admin/hints': json(200, {results: []}), 'POST /api/admin/hints': json(400, {code: 'INVALID_HINT'})}); renderPage(); 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 () => { // 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'}; - fetch.mockImplementation(async () => answer(200, {results: [endedOnServer]})); + withRoutes({'GET /api/admin/hints': json(200, {results: [endedOnServer]})}); renderPage(); await screen.findByText('Server update tonight'); @@ -103,7 +104,7 @@ describe('AdminHintsPage', () => { }); it('shows "no access" to a non-admin', async () => { - fetch.mockImplementation(async () => answer(403)); + withRoutes({'GET /api/admin/hints': json(403, {})}); renderPage(); expect(await screen.findByText('ADMIN_HINTS.NO_ACCESS')).toBeTruthy(); diff --git a/src/pages/AdminUsersPage.test.jsx b/src/pages/AdminUsersPage.test.jsx index 9ef1cbf..8cef8b5 100644 --- a/src/pages/AdminUsersPage.test.jsx +++ b/src/pages/AdminUsersPage.test.jsx @@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom'; import {toast} from 'sonner'; import AdminUsersPage from './AdminUsersPage.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('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 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(); 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', () => { beforeEach(() => { localStorage.setItem('tbp_session', '1'); - globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(204) : answer(200, page([ADMIN, PLAYER])))); + withRoutes({}); }); afterEach(() => { @@ -78,8 +84,10 @@ describe('AdminUsersPage', () => { }); it('shows the reason when Auth refuses', async () => { - fetch.mockImplementation(async (url, init) => (init?.method - ? answer(409, {code: 'TARGET_IS_ADMIN'}) : answer(200, page([PLAYER])))); + withRoutes({ + 'GET /api/admin/users': json(200, page([PLAYER])), + 'PUT /api/admin/users/7/blocked': json(409, {code: 'TARGET_IS_ADMIN'}), + }); renderPage(); 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 () => { - fetch.mockResolvedValue(answer(403, {})); + withRoutes({'GET /api/admin/users': json(403, {})}); renderPage(); expect(await screen.findByText('ADMIN.NO_ACCESS')).toBeTruthy(); diff --git a/src/pages/Dashboard.test.jsx b/src/pages/Dashboard.test.jsx index bc3f612..7cef741 100644 --- a/src/pages/Dashboard.test.jsx +++ b/src/pages/Dashboard.test.jsx @@ -1,25 +1,39 @@ // @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, useLocation} from 'react-router-dom'; -import RequireSession from '../components/RequireSession.jsx'; +import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react'; import Dashboard from './Dashboard.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('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: 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() { - current.location = useLocation(); - return null; -} +// Every request of the page with a Steam ID; the test of the 401 answers all but the user with 401. +const dashboardRoutes = (settings) => ({ + ...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 unauthorized = () => ({ok: false, status: 401, json: async () => ({})}); +const renderDashboard = () => renderWithProviders(, {entries: ['/dashboard']}); beforeEach(() => { localStorage.setItem('tbp_session', '1'); @@ -32,45 +46,24 @@ afterEach(() => { describe('Dashboard', () => { it('opens the login page when the core API answers 401', async () => { - globalThis.fetch = vi.fn(async (url) => - url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized()); - render( - - - }>}/> - }/> - - , - ); + fetchRoutes({ + ...Object.fromEntries(Object.keys(dashboardRoutes()).map((key) => [key, json(401, {})])), + 'GET /api/users/me': json(200, {email: 'a@b.de', steamId: '76561198000000000'}), + 'POST /api/auth/refresh': json(401, {}), + }); + const {router} = renderWithProviders(, + {path: '/dashboard', entries: ['/dashboard'], guarded: true, routes: {'/login':

login

}}); await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5)); - await waitFor(() => expect(current.location.pathname).toBe('/login')); - expect(current.location.state).toEqual({sessionExpired: true, from: '/dashboard'}); + await waitFor(() => expect(router.state.location.pathname).toBe('/login')); + expect(router.state.location.state).toEqual({sessionExpired: true, from: '/dashboard'}); 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) => { - globalThis.fetch = vi.fn(async (url, init) => { - if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})}; - 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( - - - , - ).container; + fetchRoutes(dashboardRoutes(settings)); + return renderDashboard().container; }; 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 () => { // No Steam ID, so no activity and achievements are asked. - globalThis.fetch = vi.fn(async (url) => { - if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo'}); - if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true}); - if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) { - return {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}; - } - return ok({content: [], total: 0, byStatus: {}}); + fetchRoutes({ + ...quietRoutes, + 'GET /api/library/stats': json(200, empty), + 'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo'}), + 'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}), + 'GET /api/library/games': (url) => (mostPlayed(url) + ? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }} + : json(200, empty)), }); - render( - - - , - ); + renderDashboard(); expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy(); }); diff --git a/src/pages/GameDetail.reload.test.jsx b/src/pages/GameDetail.reload.test.jsx index 412ce23..b7b532c 100644 --- a/src/pages/GameDetail.reload.test.jsx +++ b/src/pages/GameDetail.reload.test.jsx @@ -1,10 +1,9 @@ // @vitest-environment jsdom import {afterEach, beforeEach, 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 GameDetail from './GameDetail.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')); // 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(() => { localStorage.setItem('tbp_session', '1'); - globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200, - json: async () => (url.includes('/api/users/me') ? {role: 'AUDITOR', activeRole: 'AUDITOR'} : game)})); + fetchRoutes({ + 'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}), + 'GET /api/library/games/220': json(200, game), + }); }); afterEach(() => { @@ -31,11 +32,7 @@ afterEach(() => { }); it('keeps unsaved notes while the game loads again after a correction', async () => { - render( - - }/> - , - ); + renderWithProviders(, {path: '/library/:appid', entries: ['/library/220']}); fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}}); fireEvent.click(await screen.findByRole('button', {name: 'correction saved'})); diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index d2c96bf..a290f30 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -1,10 +1,9 @@ // @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; -import {MemoryRouter, Route, Routes} from 'react-router-dom'; +import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react'; import GameDetail from './GameDetail.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'; import {resetBrokenImageReports} from '../utils/brokenImages.js'; import {toast} from 'sonner'; @@ -16,20 +15,20 @@ const game = { rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [], }; -const renderDetail = (appid = 220) => render( - - - }/> - library page

}/> -
-
, -); +const renderDetail = (appid = 220) => renderWithProviders(, + {path: '/library/:appid', entries: [`/library/${appid}`], routes: {'/library':

library page

}}); + +const echo = (url, init) => json(200, JSON.parse(init.body)); + +// The user and the game at /library/; `routes` adds or replaces answers. +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(() => { 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(() => { @@ -40,8 +39,7 @@ afterEach(() => { describe('GameDetail', () => { 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, - source: 'STEAM', finishedHint: {type: 'ENDING', achievement: 'Survivor'}})})); + answer({...game, playtime2Weeks: 90, source: 'STEAM', finishedHint: {type: 'ENDING', achievement: 'Survivor'}}); renderDetail(); 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 () => { - 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, source: 'STEAM', hidden: false})}); + answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': echo}); renderDetail(); 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 () => { - globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT' - ? {ok: false, status: 409, json: async () => ({code: 'MANUAL_GAME'})} - : {ok: true, status: 200, json: async () => ({...game, source: 'STEAM', hidden: false})}); + answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(409, {code: 'MANUAL_GAME'})}); renderDetail(); 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 () => { - globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, - json: async () => ({...game, source: 'MANUAL', platform: 'PS5', hidden: false})})); + answer({...game, source: 'MANUAL', platform: 'PS5', hidden: false}); renderDetail(); 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 () => { const achievement = (apiName) => ({apiName, displayName: apiName, unlocked: false, iconUrl: `https://x/${apiName}.jpg`, iconGrayUrl: `https://x/${apiName}_gray.jpg`}); - globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST' - ? {ok: true, status: 204} - : {ok: true, status: 200, json: async () => ({...game, hasAchievements: true, statsVisible: true, - achievementsTotal: 2, achievementsUnlocked: 0, completionPercent: 0, - achievements: [achievement('A'), achievement('B')]})}); + answer({...game, hasAchievements: true, statsVisible: true, achievementsTotal: 2, achievementsUnlocked: 0, + completionPercent: 0, achievements: [achievement('A'), achievement('B')]}, {'POST /api/library/broken-images': json(204)}); const {container} = renderDetail(); await screen.findByText('A'); @@ -109,8 +99,7 @@ describe('GameDetail', () => { }); it('links the speedrun.com page when Core found one', async () => { - globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, - json: async () => ({...game, speedrunUrl: 'https://www.speedrun.com/hl2'})})); + answer({...game, speedrunUrl: 'https://www.speedrun.com/hl2'}); renderDetail(); 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])( '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(); 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 () => { + answer(game, {'PUT /api/library/games/220/backlog': echo}); renderDetail(); const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL'); 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 () => { + answer(game, {'PUT /api/users/settings/appearance': echo}); renderDetail(); 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 () => { + answer(game, {'PUT /api/users/settings/appearance': echo}); renderDetail(); 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 () => { localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 220, showBanner: false})); + answer(game, {'PUT /api/users/settings/appearance': echo}); renderDetail(); 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', }; + const noBody = {ok: true, status: 204, json: async () => { throw new Error('204 has no body'); }}; + beforeEach(() => { - globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' || init?.method === 'DELETE') - ? {ok: true, status: 204, json: async () => { throw new Error('204 has no body'); }} - : {ok: true, status: 200, json: async () => manualGame}); + answer(manualGame, {'PUT /api/library/games/-1025/manual': noBody, 'DELETE /api/library/games/-1025': noBody}); }); 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 () => { - globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...manualGame, playtimeMinutes: 75})})); + answer({...manualGame, playtimeMinutes: 75}); renderDetail(-1025); 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 () => { - globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, source: 'STEAM'})})); + answer({...game, source: 'STEAM'}); renderDetail(); 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 () => { let backgroundAppid = null; - globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200, - json: async () => (url.includes('/api/users/me') - ? {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', backgroundAppid}}} - : {...game, source: 'STEAM'})})); + answer({...game, source: 'STEAM'}, { + 'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', backgroundAppid}}}), + 'PUT /api/users/settings/profile-style': json(204), + }); renderDetail(); 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 () => { let bannerAppid = 220; - globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200, - json: async () => (url.includes('/api/users/me') - ? {role: 'USER', settings: {profileStyle: {bannerAppid}}} - : {...game, source: 'STEAM'})})); + answer({...game, source: 'STEAM'}, { + 'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: {bannerAppid}}}), + 'PUT /api/users/settings/profile-style': json(204), + }); renderDetail(); 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 () => { let stored = {accent: 'red', corners: 'round'}; - globalThis.fetch = vi.fn(async (url, init) => { - if (init?.method === 'PUT') stored = JSON.parse(init.body); - return {ok: true, status: init?.method === 'PUT' ? 204 : 200, - json: async () => (url.includes('/api/users/me') - ? {role: 'USER', settings: {profileStyle: stored}} - : {...game, source: 'STEAM'})}; + answer({...game, source: 'STEAM'}, { + 'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: stored}}), + 'PUT /api/users/settings/profile-style': (url, init) => { + stored = JSON.parse(init.body); + return json(204); + }, }); const styleBodies = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style')) .map(([, init]) => JSON.parse(init.body)); @@ -308,10 +301,7 @@ describe('GameDetail of a game added by hand', () => { }); describe('corrections', () => { - const loggedInAs = (me) => { - globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200, - json: async () => (url.includes('/api/users/me') ? me : game)})); - }; + const loggedInAs = (me) => answer(game, {}, me); it('offers "correct data" to an active auditor', async () => { loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'}); @@ -359,7 +349,7 @@ describe('GameDetail confetti', () => { }); const opened = async (entry) => { - globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, ...entry})})); + answer({...game, ...entry}); const {container} = renderDetail(); await screen.findByText('Half-Life 2'); return container.querySelector('canvas[data-size]'); diff --git a/src/pages/GameLibrary.test.jsx b/src/pages/GameLibrary.test.jsx index 98f2ac1..4d6bac2 100644 --- a/src/pages/GameLibrary.test.jsx +++ b/src/pages/GameLibrary.test.jsx @@ -1,9 +1,10 @@ // @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; 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 {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); 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 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))); -// 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') { const lib = {navigations: 0, location: null, navigate: null}; function Spy() { @@ -26,11 +32,11 @@ function renderLibrary(initialEntry = '/library') { } return null; } - render( - - }/> - , - ); + const router = createMemoryRouter([{ + path: '/library', + element: , + }], {initialEntries: [initialEntry]}); + render(); return { navigations: () => lib.navigations, params: () => new URLSearchParams(lib.location.search), @@ -42,7 +48,7 @@ const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {tar beforeEach(() => { localStorage.setItem('tbp_session', '1'); - globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page})); + answer(); }); afterEach(() => { @@ -52,10 +58,10 @@ afterEach(() => { describe('GameLibrary search', () => { 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}, {appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', achievementsUnlocked: null, achievementsTotal: 5}, - ]})})); + ]); renderLibrary(); expect(await screen.findByText('3/4')).toBeTruthy(); @@ -100,7 +106,7 @@ describe('GameLibrary search', () => { typeInto('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('')); }); @@ -161,7 +167,7 @@ describe('GameLibrary short games', () => { }); 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'); expect(await screen.findByText('LIBRARY.NO_HIDDEN')).toBeTruthy(); @@ -169,15 +175,11 @@ describe('GameLibrary short games', () => { }); 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', platform: 'Nintendo Switch', imageUrl: 'https://images.igdb.com/z.jpg'}; it('shows the platform and the IGDB picture of a manual game', async () => { - globalThis.fetch = answer([zelda], true); + answer([zelda], true); renderLibrary(); 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 () => { - 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); 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 () => { - globalThis.fetch = answer([zelda], false); + answer([zelda], false); renderLibrary(); await screen.findByText('Zelda'); @@ -203,7 +205,7 @@ describe('GameLibrary with games added by hand', () => { }); it('opens the add panel', async () => { - globalThis.fetch = answer([zelda], true); + answer([zelda], true); renderLibrary(); fireEvent.click(await screen.findByRole('button', {name: 'MANUAL_GAME.ADD_BUTTON'})); diff --git a/src/pages/LoginPage.test.jsx b/src/pages/LoginPage.test.jsx index 74cb5bd..915cd82 100644 --- a/src/pages/LoginPage.test.jsx +++ b/src/pages/LoginPage.test.jsx @@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom'; import LoginPage from './LoginPage.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.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() { fireEvent.change(screen.getByLabelText('LOGIN.EMAIL'), {target: {value: 'a@b.de'}}); fireEvent.change(screen.getByLabelText('LOGIN.PASSWORD'), {target: {value: 'secret'}}); @@ -30,7 +34,7 @@ function submit() { describe('LoginPage', () => { 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(() => { @@ -72,7 +76,7 @@ describe('LoginPage', () => { }); 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'}); submit(); @@ -82,7 +86,7 @@ describe('LoginPage', () => { }); 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(); submit(); diff --git a/src/pages/PlayNextPage.language.test.jsx b/src/pages/PlayNextPage.language.test.jsx index 461c369..50ac28f 100644 --- a/src/pages/PlayNextPage.language.test.jsx +++ b/src/pages/PlayNextPage.language.test.jsx @@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import PlayNextPage from './PlayNextPage.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; // 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(() => { localStorage.setItem('tbp_session', '1'); 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(() => { diff --git a/src/pages/PlayNextPage.test.jsx b/src/pages/PlayNextPage.test.jsx index e64111e..154a4b4 100644 --- a/src/pages/PlayNextPage.test.jsx +++ b/src/pages/PlayNextPage.test.jsx @@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac import {MemoryRouter} from 'react-router-dom'; import PlayNextPage from './PlayNextPage.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('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); @@ -13,15 +14,13 @@ const game = (appid, extra = {}) => ({ timeToBeatMinutes: 600, ...extra, }); -const json = (body, status = 200) => ({ok: status < 400, status, json: async () => body}); - -// Answers each picker call with the next list and records every request. -function mockBackend(pickerAnswers, detail = null) { +// Answers each picker call with the next list. +function mockBackend(pickerAnswers, routes = {}) { const answers = [...pickerAnswers]; - globalThis.fetch = vi.fn(async (url, options = {}) => { - if (url.includes('/api/library/picker')) return json({games: answers.shift() ?? []}); - if (options.method === 'PUT') return json(JSON.parse(options.body)); - return json(detail); + fetchRoutes({ + 'GET /api/library/picker': () => json(200, {games: answers.shift() ?? []}), + 'PUT /api/library/games/7/status': (url, init) => json(200, JSON.parse(init.body)), + ...routes, }); } @@ -135,10 +134,8 @@ describe('PlayNextPage', () => { }); it('drops the old picks when a new budget fails to load', async () => { - mockBackend([[game(1)]]); - const pickerFetch = globalThis.fetch; - globalThis.fetch = vi.fn(async (url, options) => - (url.includes('maxMinutes=300') ? json({}, 500) : pickerFetch(url, options))); + mockBackend([], {'GET /api/library/picker': (url) => + (url.includes('maxMinutes=300') ? json(500, {}) : json(200, {games: [game(1)]}))}); renderPage(); await screen.findByText('Game 1'); @@ -195,10 +192,7 @@ describe('PlayNextPage', () => { }); it('lets the user try again when saving fails', async () => { - mockBackend([[game(7)]]); - const pickerFetch = globalThis.fetch; - globalThis.fetch = vi.fn(async (url, options) => - (options?.method === 'PUT' ? json({}, 500) : pickerFetch(url, options))); + mockBackend([[game(7)]], {'PUT /api/library/games/7/status': json(500, {})}); renderPage(); await screen.findByText('Game 7'); @@ -224,7 +218,7 @@ describe('PlayNextPage', () => { }); 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(); await screen.findByText('INFO.ERROR.NETWORK_FAILURE'); diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index 660c679..8881130 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -1,10 +1,9 @@ // @vitest-environment jsdom import {afterEach, 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 PublicProfilePage from './PublicProfilePage.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')); @@ -15,29 +14,26 @@ const library = { finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}], }; -function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library, me} = {}) { - globalThis.fetch = vi.fn(async (url, init) => { - if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})}; - if (url.includes('/api/users/me')) return {ok: !!me, status: me ? 200 : 401, json: async () => me}; - if (url.includes('/api/public/profiles/library')) { - return {ok: libraryStatus === 200, status: libraryStatus, json: async () => answer}; - } - return {ok: profileStatus === 200, status: profileStatus, - json: async () => ({displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style})}; +// `me` only for a test with a session: without one the page asks nothing about the viewer. +function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me} = {}) { + fetchRoutes({ + [`GET /api/public/profiles/${encodeURIComponent(name)}`]: + json(profileStatus, {displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style}), + 'GET /api/public/profiles/library': json(libraryStatus, answer), + ...(me && { + 'GET /api/users/me': json(200, me), + 'PUT /api/users/settings/profile-tiles': json(204, {}), + }), }); } -const renderPage = (name = 'Lobo') => render( - - }/> - , -); +const renderPage = (name = 'Lobo') => renderWithProviders(, {path: '/u/:name', entries: [`/u/${name}`]}); describe('PublicProfilePage', () => { afterEach(cleanup); it('asks Auth for the ticket, then Core for the library with it', async () => { - mockBackends(); + mockBackends({name: 'Lo bo'}); renderPage('Lo bo'); expect(await screen.findByText('Portal 2')).toBeTruthy(); diff --git a/src/pages/RegistrationPage.test.jsx b/src/pages/RegistrationPage.test.jsx index eca6d32..fd73e88 100644 --- a/src/pages/RegistrationPage.test.jsx +++ b/src/pages/RegistrationPage.test.jsx @@ -3,6 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import RegistrationPage from './RegistrationPage.jsx'; +import {fetchRoutes} from '../test/fetchRoutes.js'; // Own mock: the shared one drops the {{minutes}} value this test checks. vi.mock('react-i18next', () => ({ @@ -12,14 +13,10 @@ vi.mock('react-i18next', () => ({ }), })); -function errorResponse(status, code, headers = {}, message = undefined) { - return { - ok: false, - status, - headers: new Headers(headers), - json: async () => ({code, message}), - }; -} +// Not `json` from fetchRoutes: the page reads Retry-After from the headers. +const answerRegistration = (status, code, headers = {}, message = undefined) => fetchRoutes({ + 'POST /api/registration/new': {ok: false, status, headers: new Headers(headers), json: async () => ({code, message})}, +}); function submitValidForm() { render(); @@ -36,7 +33,7 @@ describe('RegistrationPage', () => { }); 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(); @@ -44,7 +41,7 @@ describe('RegistrationPage', () => { }); 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(); @@ -54,7 +51,7 @@ describe('RegistrationPage', () => { }); 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(); @@ -62,7 +59,7 @@ describe('RegistrationPage', () => { }); 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(); diff --git a/src/pages/SettingsPage.steam.test.jsx b/src/pages/SettingsPage.steam.test.jsx index bdbe572..3844d92 100644 --- a/src/pages/SettingsPage.steam.test.jsx +++ b/src/pages/SettingsPage.steam.test.jsx @@ -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} from 'react-router-dom'; +import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react'; import {toast} from 'sonner'; import SettingsPage from './SettingsPage.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', async () => { 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 noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}; + function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) { - globalThis.fetch = vi.fn(async (url, init) => { - if (init?.method === 'POST' && url.endsWith('/api/users/steam-link')) { - return {ok: true, status: 200, json: async () => ({redirectUrl})}; - } - if (init?.method && init.method !== 'GET') { - return {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}; - } - return {ok: true, status: 200, json: async () => me}; + fetchRoutes({ + 'GET /api/users/me': json(200, me), + 'POST /api/users/steam-link': json(200, {redirectUrl}), + 'DELETE /api/users/steam-link': noBody, + 'PUT /api/users/settings': noBody, + 'POST /api/auth/refresh': noBody, + 'GET /api/version': json(404, {}), }); - render(); + renderWithProviders(, {path: '/settings', entries: [entry]}); } describe('SettingsPage Steam account', () => { diff --git a/src/pages/SettingsPage.steamToast.test.jsx b/src/pages/SettingsPage.steamToast.test.jsx index 77e2801..c38bf90 100644 --- a/src/pages/SettingsPage.steamToast.test.jsx +++ b/src/pages/SettingsPage.steamToast.test.jsx @@ -1,11 +1,10 @@ // @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {act, cleanup, render, screen} from '@testing-library/react'; -import {MemoryRouter} from 'react-router-dom'; +import {act, cleanup, screen} from '@testing-library/react'; import {toast} from 'sonner'; import SettingsPage from './SettingsPage.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'; // Like react-i18next: a language change re-renders every subscriber with a fresh `t`. vi.mock('react-i18next', async () => { @@ -33,9 +32,11 @@ const profile = { describe('SettingsPage Steam toast', () => { beforeEach(() => { localStorage.setItem('tbp_session', '1'); - globalThis.fetch = vi.fn(async (url, init) => (init?.method && init.method !== 'GET' - ? {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }} - : {ok: true, status: 200, json: async () => profile})); + fetchRoutes({ + 'GET /api/users/me': json(200, profile), + 'POST /api/auth/refresh': {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}, + 'GET /api/version': json(404, {}), + }); }); afterEach(() => { @@ -45,7 +46,7 @@ describe('SettingsPage Steam toast', () => { }); it('shows the linked toast once, also after a language change', async () => { - render(); + renderWithProviders(, {path: '/settings', entries: ['/settings?steam=linked']}); await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED'); expect(toast.success).toHaveBeenCalledTimes(1); diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index 1f8ae9b..74a65d9 100644 --- a/src/pages/SettingsPage.test.jsx +++ b/src/pages/SettingsPage.test.jsx @@ -1,10 +1,9 @@ // @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; -import {MemoryRouter} from 'react-router-dom'; +import {act, cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react'; import SettingsPage from './SettingsPage.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', async () => { 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)); +// 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) { - globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' - // Both PUTs answer 204 without a body, like Auth does. - ? {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }} - : {ok: true, status: 200, json: async () => me})); - render(); + fetchRoutes(authRoutes(() => me)); + renderWithProviders(); } /** @@ -36,14 +44,14 @@ function renderPage(me) { function fakeAuth(me) { const saved = structuredClone(me); const open = []; - globalThis.fetch = vi.fn((url, init) => { - if (init?.method !== 'PUT') return Promise.resolve({ok: true, status: 200, json: async () => structuredClone(saved)}); - return new Promise((resolve) => open.push(() => { + fetchRoutes({ + ...authRoutes(() => structuredClone(saved)), + 'PUT /api/users/settings': (url, init) => new Promise((resolve) => open.push(() => { const body = JSON.parse(init.body); saved.displayName = body.displayName; 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()()}; } @@ -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 () => { const auth = fakeAuth(profile()); - render(); + renderWithProviders(); fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'})); await waitFor(() => expect(puts()).toHaveLength(1)); diff --git a/src/pages/StatsPage.test.jsx b/src/pages/StatsPage.test.jsx index 5477925..aaff1b6 100644 --- a/src/pages/StatsPage.test.jsx +++ b/src/pages/StatsPage.test.jsx @@ -4,10 +4,11 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac import {MemoryRouter} from 'react-router-dom'; import StatsPage from './StatsPage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.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 = {}) => ({ 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 () => { - globalThis.fetch = vi.fn(async () => json(stats())); + answerStats(json(200, stats())); renderPage(); expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy(); @@ -56,7 +57,7 @@ describe('StatsPage', () => { it('shows the empty texts of a new library', async () => { const empty = stats(); - globalThis.fetch = vi.fn(async () => json({ + answerStats(json(200, { ...empty, pile: {...empty.pile, paceWeeks: null, oldestUnplayed: null}, topGames: {mostPlayed: [], mostLoyal: null}, @@ -77,7 +78,7 @@ describe('StatsPage', () => { }); it('offers a retry when the stats could not be loaded', async () => { - globalThis.fetch = vi.fn(async () => json({}, 500)); + answerStats(json(500, {})); renderPage(); fireEvent.click(await screen.findByRole('button', {name: /DASHBOARD.RETRY/})); diff --git a/src/pages/WishlistPage.test.jsx b/src/pages/WishlistPage.test.jsx index 7943484..ed2a7a4 100644 --- a/src/pages/WishlistPage.test.jsx +++ b/src/pages/WishlistPage.test.jsx @@ -1,10 +1,9 @@ // @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, useLocation} from 'react-router-dom'; -import RequireSession from '../components/RequireSession.jsx'; +import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react'; 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')); @@ -13,23 +12,15 @@ const wishlist = {games: [ {appid: 3012600, name: 'Coming Soon', addedAt: '2025-06-01T00:00:00Z', price: null, originalPrice: null, discountPct: 0}, ]}; -function LoginState() { - return
{JSON.stringify(useLocation().state)}
; -} +// The providers load the user and the preferences; this page does not use them. +const withAuth = (routes) => fetchRoutes({'GET /api/users/me': json(200, {settings: {}}), ...routes}); -const renderPage = () => render( - - - }>}/> - }/> - - , -); +const renderPage = () => renderWithProviders(, + {path: '/wishlist', entries: ['/wishlist'], guarded: true, routes: {'/login':

login

}}); describe('WishlistPage', () => { beforeEach(() => { localStorage.setItem('tbp_session', '1'); - globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => wishlist})); }); afterEach(() => { @@ -38,6 +29,7 @@ describe('WishlistPage', () => { }); it('shows each game with its price, and the discount for games on sale', async () => { + withAuth({'GET /api/library/wishlist': json(200, wishlist)}); renderPage(); expect(await screen.findByText('Prison Architect 2')).toBeTruthy(); @@ -49,6 +41,7 @@ describe('WishlistPage', () => { }); it('can show only the games on sale', async () => { + withAuth({'GET /api/library/wishlist': json(200, wishlist)}); renderPage(); await screen.findByText('Coming Soon'); @@ -59,16 +52,17 @@ describe('WishlistPage', () => { }); 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(); expect(await screen.findByText('WISHLIST.EMPTY')).toBeTruthy(); }); it('opens the login page on a 401 that the refresh cannot fix', async () => { - fetch.mockResolvedValue({ok: false, status: 401, json: async () => ({})}); - renderPage(); + withAuth({'GET /api/library/wishlist': json(401, {}), 'POST /api/auth/refresh': json(401, {})}); + 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); }); }); diff --git a/src/pages/languageSwitch.test.jsx b/src/pages/languageSwitch.test.jsx index bcda55d..fd51627 100644 --- a/src/pages/languageSwitch.test.jsx +++ b/src/pages/languageSwitch.test.jsx @@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, render, screen} from '@testing-library/react'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {switchLanguage} from '../test/i18n-mock.js'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import Dashboard from './Dashboard.jsx'; @@ -14,19 +15,20 @@ import WishlistPage from './WishlistPage.jsx'; 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 = [ - ['Dashboard', '/', '/', Dashboard, 'INFO.ERROR.LOAD_PROFILE'], - ['GameDetail', '/games/:appid', '/games/220', GameDetail, 'GAME_DETAIL.ERROR_LOAD'], - ['GameLibrary', '/library', '/library', GameLibrary, 'LIBRARY.ERROR_LOAD'], - ['PublicProfilePage', '/u/:name', '/u/Lobo', PublicProfilePage, 'PROFILE.ERROR_LOAD'], - ['SettingsPage', '/settings', '/settings', SettingsPage, 'INFO.ERROR.LOAD_PROFILE'], - ['WishlistPage', '/wishlist', '/wishlist', WishlistPage, 'WISHLIST.ERROR_LOAD'], + ['Dashboard', '/', '/', Dashboard, 'INFO.ERROR.LOAD_PROFILE', []], + ['GameDetail', '/games/:appid', '/games/220', GameDetail, 'GAME_DETAIL.ERROR_LOAD', ['/api/library/games/220']], + ['GameLibrary', '/library', '/library', GameLibrary, 'LIBRARY.ERROR_LOAD', + ['/api/library/games', '/api/library/games-count', '/api/library/sync-status']], + ['PublicProfilePage', '/u/:name', '/u/Lobo', PublicProfilePage, 'PROFILE.ERROR_LOAD', ['/api/public/profiles/Lobo']], + ['SettingsPage', '/settings', '/settings', SettingsPage, 'INFO.ERROR.LOAD_PROFILE', []], + ['WishlistPage', '/wishlist', '/wishlist', WishlistPage, 'WISHLIST.ERROR_LOAD', ['/api/library/wishlist']], ]; beforeEach(() => { localStorage.setItem('tbp_session', '1'); switchLanguage('de'); - globalThis.fetch = vi.fn(async () => ({ok: false, status: 500, json: async () => ({})})); }); afterEach(() => { @@ -35,7 +37,8 @@ afterEach(() => { }); 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 = () => ( }/> diff --git a/src/test/fetchRoutes.js b/src/test/fetchRoutes.js index dc622a6..0bb5bf6 100644 --- a/src/test/fetchRoutes.js +++ b/src/test/fetchRoutes.js @@ -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}); /** * 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 - * 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. */ export function fetchRoutes(routes) { + const unknown = []; const mock = vi.fn(async (url, init) => { 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]; return typeof answer === 'function' ? answer(url, init) : answer; }); globalThis.fetch = mock; + onTestFinished(() => { + if (unknown.length) throw new Error(`requests without a route: ${[...new Set(unknown)].join(', ')}`); + }); return mock; } diff --git a/src/test/renderWithProviders.jsx b/src/test/renderWithProviders.jsx new file mode 100644 index 0000000..4c54cb1 --- /dev/null +++ b/src/test/renderWithProviders.jsx @@ -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':

login

}`. The result has `router`, + * so a test reads `router.state.location`. + */ +export function renderWithProviders(ui, {path = '*', entries = ['/'], routes = {}, guarded = false} = {}) { + const page = ; + const router = createMemoryRouter([{ + path: '*', + element: ( + + + + {guarded ? }>{page} : page} + {Object.entries(routes).map(([at, element]) => )} + + + + ), + }], {initialEntries: entries}); + return {...render(), router}; +} diff --git a/src/utils/admin.test.js b/src/utils/admin.test.js index db38343..60a5004 100644 --- a/src/utils/admin.test.js +++ b/src/utils/admin.test.js @@ -1,16 +1,18 @@ // @vitest-environment jsdom -import {describe, expect, it, vi} from 'vitest'; +import {describe, expect, it} from 'vitest'; import {refreshSession} from './api.js'; import {viewAs} from './admin.js'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; describe('viewAs', () => { it('waits for a running refresh before it sends the refresh cookie', async () => { let finishRefresh; - globalThis.fetch = vi.fn((url) => (url.endsWith('/api/auth/refresh') - ? new Promise((resolve) => { - finishRefresh = () => resolve({ok: true, status: 204}); - }) - : Promise.resolve({ok: true, status: 204}))); + fetchRoutes({ + 'POST /api/auth/refresh': () => new Promise((resolve) => { + finishRefresh = () => resolve(json(204)); + }), + 'POST /api/auth/view-as': json(204), + }); const refreshing = refreshSession(); const switching = viewAs('USER'); diff --git a/src/utils/brokenImages.test.js b/src/utils/brokenImages.test.js index b839304..e5afb7c 100644 --- a/src/utils/brokenImages.test.js +++ b/src/utils/brokenImages.test.js @@ -1,10 +1,11 @@ // @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 {fetchRoutes, json} from '../test/fetchRoutes.js'; beforeEach(() => { localStorage.setItem('tbp_session', '1'); - globalThis.fetch = vi.fn(async () => ({ok: true, status: 204})); + fetchRoutes({'POST /api/library/broken-images': json(204, {})}); }); afterEach(() => { @@ -40,9 +41,7 @@ describe('reportBrokenImage', () => { }); it('ignores a failed request', async () => { - globalThis.fetch = vi.fn(async () => { - throw new TypeError('network'); - }); + fetchRoutes({'POST /api/library/broken-images': () => { throw new TypeError('network'); }}); expect(() => reportBrokenImage(620, COVER)).not.toThrow(); await Promise.resolve();