Public Access
fix: abort old loads, stale sync state, warn before unsaved edits are lost
This commit is contained in:
1 parent
cbafa3d540
commit
9ddc50f819
28 files changed
+753
-121
No files matched your search
@@ -25,6 +25,7 @@ import PageBackground from './components/PageBackground.jsx';
|
||||
import {usePreferences} from './context/PreferencesContext.jsx';
|
||||
import {clearSession} from './utils/auth.js';
|
||||
import useSession from './hooks/useSession.js';
|
||||
import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
|
||||
import {logout} from './utils/api.js';
|
||||
|
||||
function App() {
|
||||
@@ -84,6 +85,7 @@ function App() {
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
<UnsavedChangesGuard/>
|
||||
<Toaster richColors position="bottom-right"/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {cn} from '@/lib/utils';
|
||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||
import {NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
|
||||
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
|
||||
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
|
||||
|
||||
const toForm = (entry) => ({
|
||||
status: entry.status ?? 'BACKLOG',
|
||||
@@ -33,6 +34,7 @@ export default function BacklogCard({game, onSaved}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const dirty = JSON.stringify(form) !== JSON.stringify(saved);
|
||||
useUnsavedChanges(dirty);
|
||||
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
|
||||
|
||||
const save = async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
|
||||
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
import useUnsavedChanges, {discardChanges} from '../hooks/useUnsavedChanges.js';
|
||||
|
||||
/**
|
||||
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
|
||||
@@ -26,6 +27,7 @@ export default function ManualGameCard({game, onSaved}) {
|
||||
|
||||
const playtimeMinutes = hoursToMinutes(hours);
|
||||
const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes;
|
||||
useUnsavedChanges(dirty);
|
||||
|
||||
const run = async (request, onOk) => {
|
||||
setBusy(true);
|
||||
@@ -52,6 +54,8 @@ export default function ManualGameCard({game, onSaved}) {
|
||||
|
||||
const remove = () => run(() => removeGame(game.appid), () => {
|
||||
toast.success(t('MANUAL_GAME.REMOVED'));
|
||||
// The game is gone, so are its unsaved edits on this page.
|
||||
discardChanges();
|
||||
navigate('/library', {replace: true});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
import {toast} from 'sonner';
|
||||
import ManualGameCard from './ManualGameCard.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()}}));
|
||||
|
||||
const game = {appid: -1025, platform: 'PS5', playtimeMinutes: 90};
|
||||
|
||||
const answer = (routes) => fetchRoutes({'GET /api/users/me': json(200, {settings: {}}), ...routes});
|
||||
|
||||
const renderCard = (onSaved = vi.fn()) => renderWithProviders(<ManualGameCard game={game} onSaved={onSaved}/>,
|
||||
{path: '/library/:appid', entries: ['/library/-1025'], routes: {'/library': <p>library page</p>}});
|
||||
|
||||
const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ManualGameCard', () => {
|
||||
it('saves the trimmed platform and the minutes, then reports them', async () => {
|
||||
answer({'PUT /api/library/games/-1025/manual': json(200, {})});
|
||||
const onSaved = vi.fn();
|
||||
renderCard(onSaved);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: ' Switch '}});
|
||||
fireEvent.change(screen.getByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2'}});
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({platform: 'Switch', playtimeMinutes: 120}));
|
||||
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
|
||||
expect(url).toContain('/api/library/games/-1025/manual');
|
||||
expect(JSON.parse(init.body)).toEqual({platform: 'Switch', playtimeMinutes: 120});
|
||||
});
|
||||
|
||||
it('keeps the save button disabled while nothing changed', () => {
|
||||
answer({});
|
||||
renderCard();
|
||||
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'PS5 '}});
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('removes the game only after a confirm and opens the library', async () => {
|
||||
answer({'DELETE /api/library/games/-1025': json(204, null)});
|
||||
const {router} = renderCard();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE'}));
|
||||
expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false);
|
||||
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'}));
|
||||
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/library'));
|
||||
});
|
||||
|
||||
it('shows an error when the save fails', async () => {
|
||||
answer({'PUT /api/library/games/-1025/manual': json(500, {})});
|
||||
const onSaved = vi.fn();
|
||||
renderCard(onSaved);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'Switch'}});
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows no toast on a 401', async () => {
|
||||
answer({'PUT /api/library/games/-1025/manual': json(401, {}), 'POST /api/auth/refresh': json(401, {})});
|
||||
const onSaved = vi.fn();
|
||||
renderCard(onSaved);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'Switch'}});
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(true));
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {CircleAlert, CircleCheck, RefreshCw} from 'lucide-react';
|
||||
import {CircleAlert, CircleCheck, Clock, RefreshCw} from 'lucide-react';
|
||||
|
||||
const formatRelative = (iso, locale) => {
|
||||
if (!iso) return null;
|
||||
@@ -12,7 +12,7 @@ const formatRelative = (iso, locale) => {
|
||||
return rtf.format(-Math.round(diffSec / 86400), 'day');
|
||||
};
|
||||
|
||||
export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) {
|
||||
export default function SyncStatusBadge({status, lastSyncedAt, isRunning, stale}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
|
||||
if (isRunning) {
|
||||
@@ -24,6 +24,15 @@ export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) {
|
||||
);
|
||||
}
|
||||
|
||||
if (stale) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="size-4"/>
|
||||
<span>{t('LIBRARY.SYNC_STALE')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'FAILED') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {useCallback, useEffect, useRef} from 'react';
|
||||
|
||||
/**
|
||||
* Returns `nextSignal()` for a load that can run again before the last one answered: it aborts the last request and
|
||||
* gives the signal for the next one. Unmounting aborts too. After the await, a load stops when its signal is aborted.
|
||||
*/
|
||||
export default function useAbortPrevious() {
|
||||
const controller = useRef(null);
|
||||
|
||||
useEffect(() => () => controller.current?.abort(), []);
|
||||
|
||||
return useCallback(() => {
|
||||
controller.current?.abort();
|
||||
controller.current = new AbortController();
|
||||
return controller.current.signal;
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
import {cleanup, renderHook, waitFor} from '@testing-library/react';
|
||||
import useCurrentUser from './useCurrentUser.js';
|
||||
import {CurrentUserProvider, useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
const renderUser = () => renderHook(() => useCurrentUser(true), {wrapper: CurrentUserProvider});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('useCurrentUser', () => {
|
||||
it('maps the user from /me and normalizes the profile style', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
fetchRoutes({'GET /api/users/me': json(200, {role: 'ADMIN', activeRole: 'USER', displayName: 'Lobo',
|
||||
settings: {profileStyle: {accent: 'gold', corners: 'round', bannerAppid: 220, backgroundAppid: -5}}})});
|
||||
const {result} = renderUser();
|
||||
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
expect(result.current).toEqual({role: 'ADMIN', activeRole: 'USER', displayName: 'Lobo',
|
||||
profileStyle: {accent: 'violet', corners: 'round', bannerAppid: 220, backgroundAppid: null}});
|
||||
});
|
||||
|
||||
it('uses the role as active role when there is no "view as"', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
fetchRoutes({'GET /api/users/me': json(200, {role: 'USER', displayName: 'Lobo', settings: {}})});
|
||||
const {result} = renderUser();
|
||||
|
||||
await waitFor(() => expect(result.current?.activeRole).toBe('USER'));
|
||||
expect(result.current.profileStyle).toEqual({accent: 'violet', corners: 'default', bannerAppid: null,
|
||||
backgroundAppid: null});
|
||||
});
|
||||
|
||||
it('returns null when not enabled', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
fetchRoutes({'GET /api/users/me': json(200, {role: 'USER', settings: {}})});
|
||||
const {result} = renderHook(() => ({mapped: useCurrentUser(false), user: useCurrentUserContext().user}),
|
||||
{wrapper: CurrentUserProvider});
|
||||
|
||||
await waitFor(() => expect(result.current.user?.role).toBe('USER'));
|
||||
expect(result.current.mapped).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without a session and asks for no user', () => {
|
||||
const fetch = fetchRoutes({});
|
||||
const {result} = renderUser();
|
||||
|
||||
expect(result.current).toBeNull();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,9 @@ import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
|
||||
const RELOAD_DELAY_MS = 4000;
|
||||
|
||||
/**
|
||||
* Starts a library sync (POST /api/library/sync), reports the result as a toast and calls
|
||||
* onStarted after a short delay, so the caller can reload once Steam data has arrived.
|
||||
* Starts a library sync (POST /api/library/sync), reports the result as a toast and calls onStarted, e.g. the
|
||||
* `watch` of useLibrarySync, which reloads the data when the run is over.
|
||||
*/
|
||||
export default function useForceSync(onStarted) {
|
||||
const {t} = useTranslation();
|
||||
@@ -30,7 +28,7 @@ export default function useForceSync(onStarted) {
|
||||
return;
|
||||
}
|
||||
toast.success(t('LIBRARY.SYNC_STARTED'));
|
||||
setTimeout(onStarted, RELOAD_DELAY_MS);
|
||||
onStarted();
|
||||
} catch (err) {
|
||||
console.error('sync trigger error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, renderHook} from '@testing-library/react';
|
||||
import {toast} from 'sonner';
|
||||
import useForceSync from './useForceSync.js';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), warning: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
const SYNC = 'POST /api/library/sync';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const trigger = async (onStarted) => {
|
||||
const {result} = renderHook(() => useForceSync(onStarted));
|
||||
await act(() => result.current.trigger());
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('useForceSync', () => {
|
||||
it('calls onStarted at once after the start, and not again later', async () => {
|
||||
fetchRoutes({[SYNC]: json(202, {})});
|
||||
const onStarted = vi.fn();
|
||||
|
||||
await trigger(onStarted);
|
||||
expect(onStarted).toHaveBeenCalledTimes(1);
|
||||
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
||||
|
||||
expect(onStarted).toHaveBeenCalledTimes(1);
|
||||
expect(toast.success).toHaveBeenCalledWith('LIBRARY.SYNC_STARTED');
|
||||
});
|
||||
|
||||
it('says how long to wait on a 429', async () => {
|
||||
fetchRoutes({[SYNC]: json(429, {retryAfterSeconds: 42})});
|
||||
const onStarted = vi.fn();
|
||||
|
||||
await trigger(onStarted);
|
||||
|
||||
expect(toast.warning).toHaveBeenCalledWith('LIBRARY.SYNC_COOLDOWN');
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says so when the start fails', async () => {
|
||||
fetchRoutes({[SYNC]: json(500, {})});
|
||||
await trigger(vi.fn());
|
||||
expect(toast.error).toHaveBeenCalledWith('LIBRARY.SYNC_FAILED');
|
||||
});
|
||||
|
||||
it('says so when the network is down', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
fetchRoutes({[SYNC]: () => Promise.reject(new TypeError('Failed to fetch'))});
|
||||
await trigger(vi.fn());
|
||||
expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE');
|
||||
});
|
||||
});
|
||||
+40
-22
@@ -1,4 +1,4 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
@@ -7,28 +7,42 @@ const MAX_POLL_DURATION_MS = 120_000;
|
||||
const INITIAL_SYNC_GRACE_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Polls /api/library/sync-status while a sync is running (or expected),
|
||||
* and fires onComplete once when the run transitions to OK / FAILED.
|
||||
* Polls /api/library/sync-status while a sync is running (or expected), and fires onComplete once when the run ends.
|
||||
* A run that still runs after 2 minutes is shown as `stale` and polling stops. `watch()` polls again after the
|
||||
* user started a sync: that run counts as ended when `lastSyncedAt` changes, also when no poll saw it running.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {boolean} opts.enabled - skip work entirely when false (e.g. user has no steamId)
|
||||
* @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the
|
||||
* gap between async sync dispatch and first DB write)
|
||||
* @param {() => void} opts.onComplete - called once per transition to OK/FAILED
|
||||
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, error: string|null}}
|
||||
* @param {() => void} opts.onComplete - called once per run that ends
|
||||
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, stale: boolean, error: string|null,
|
||||
* watch: () => void}}
|
||||
*/
|
||||
export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) {
|
||||
const [state, setState] = useState({status: null, lastSyncedAt: null, error: null});
|
||||
const previousRunningRef = useRef(null);
|
||||
const [state, setState] = useState({status: null, lastSyncedAt: null, error: null, stale: false});
|
||||
// count starts polling again; since is the last known end, so a new one shows that the started run is over
|
||||
const [watched, setWatched] = useState({count: 0, since: undefined});
|
||||
const lastSyncedAtRef = useRef(undefined);
|
||||
// The count of the last watch whose run ended; a later restart of the polling must not report that run again.
|
||||
const endedWatch = useRef(0);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
onCompleteRef.current = onComplete;
|
||||
|
||||
const watch = useCallback(() => {
|
||||
setWatched((current) => ({count: current.count + 1, since: lastSyncedAtRef.current}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
let timer = null;
|
||||
let wasRunning = false;
|
||||
const startedAt = Date.now();
|
||||
const watching = watched.count > endedWatch.current;
|
||||
// Before the first answer the last end is unknown; then the first answer is the one to compare with.
|
||||
let since = watched.since;
|
||||
|
||||
const fetchOnce = async () => {
|
||||
if (!hasSession()) return;
|
||||
@@ -51,22 +65,24 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
||||
|
||||
const status = json.lastSyncStatus;
|
||||
const lastSyncedAt = json.lastSyncedAt;
|
||||
setState({status, lastSyncedAt, error: null});
|
||||
|
||||
lastSyncedAtRef.current = lastSyncedAt;
|
||||
const isRunning = status === 'RUNNING';
|
||||
const wasRunning = previousRunningRef.current;
|
||||
|
||||
if (wasRunning === true && !isRunning) {
|
||||
onCompleteRef.current?.();
|
||||
}
|
||||
previousRunningRef.current = isRunning;
|
||||
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const waitingForInitial =
|
||||
expectInitialSync && status == null && elapsed < INITIAL_SYNC_GRACE_MS;
|
||||
const stillWaiting = isRunning || waitingForInitial;
|
||||
const stale = isRunning && elapsed >= MAX_POLL_DURATION_MS;
|
||||
setState({status, lastSyncedAt, error: null, stale});
|
||||
|
||||
if (stillWaiting && elapsed < MAX_POLL_DURATION_MS) {
|
||||
if (since === undefined) since = lastSyncedAt;
|
||||
const ended = !isRunning && (wasRunning || (watching && lastSyncedAt !== since));
|
||||
if (ended) {
|
||||
endedWatch.current = watched.count;
|
||||
onCompleteRef.current?.();
|
||||
return;
|
||||
}
|
||||
wasRunning = isRunning;
|
||||
|
||||
const waitingForInitial = expectInitialSync && status == null;
|
||||
const waitingForStart = (watching || waitingForInitial) && elapsed < INITIAL_SYNC_GRACE_MS;
|
||||
if ((isRunning && !stale) || waitingForStart) {
|
||||
timer = setTimeout(fetchOnce, POLL_INTERVAL_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -82,12 +98,14 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, expectInitialSync]);
|
||||
}, [enabled, expectInitialSync, watched]);
|
||||
|
||||
return {
|
||||
status: state.status,
|
||||
lastSyncedAt: state.lastSyncedAt,
|
||||
isRunning: state.status === 'RUNNING',
|
||||
isRunning: state.status === 'RUNNING' && !state.stale,
|
||||
stale: state.stale,
|
||||
error: state.error,
|
||||
watch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, renderHook} from '@testing-library/react';
|
||||
import useLibrarySync from './useLibrarySync.js';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
const status = (lastSyncStatus, lastSyncedAt) => json(200, {lastSyncStatus, lastSyncedAt});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('useLibrarySync', () => {
|
||||
it('shows a sync that runs longer than the poll limit as stale, not as running forever', async () => {
|
||||
const fetch = fetchRoutes({'GET /api/library/sync-status': status('RUNNING', null)});
|
||||
const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete: () => {}}));
|
||||
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||
expect(result.current.isRunning).toBe(true);
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(125_000));
|
||||
|
||||
expect(result.current.isRunning).toBe(false);
|
||||
expect(result.current.stale).toBe(true);
|
||||
const calls = fetch.mock.calls.length;
|
||||
await act(() => vi.advanceTimersByTimeAsync(30_000));
|
||||
expect(fetch.mock.calls.length).toBe(calls);
|
||||
});
|
||||
|
||||
it('after watch() reports the end of a sync once, also when it never saw it running', async () => {
|
||||
let answer = status('OK', '2026-09-30T08:00:00Z');
|
||||
fetchRoutes({'GET /api/library/sync-status': () => answer});
|
||||
const onComplete = vi.fn();
|
||||
const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete}));
|
||||
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||
|
||||
act(() => result.current.watch());
|
||||
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||
// The sync was so fast that the next poll already sees it done.
|
||||
answer = status('OK', '2026-09-30T08:05:00Z');
|
||||
await act(() => vi.advanceTimersByTimeAsync(3_000));
|
||||
await act(() => vi.advanceTimersByTimeAsync(30_000));
|
||||
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the end of a running sync once', async () => {
|
||||
let answer = status('RUNNING', '2026-09-30T08:00:00Z');
|
||||
fetchRoutes({'GET /api/library/sync-status': () => answer});
|
||||
const onComplete = vi.fn();
|
||||
const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete}));
|
||||
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||
|
||||
answer = status('FAILED', '2026-09-30T08:01:00Z');
|
||||
await act(() => vi.advanceTimersByTimeAsync(3_000));
|
||||
await act(() => vi.advanceTimersByTimeAsync(30_000));
|
||||
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.status).toBe('FAILED');
|
||||
});
|
||||
|
||||
it('fires onComplete only once when expectInitialSync flips after the watched run ended', async () => {
|
||||
let answer = status('OK', '2026-09-30T08:00:00Z');
|
||||
fetchRoutes({'GET /api/library/sync-status': () => answer});
|
||||
const onComplete = vi.fn();
|
||||
const {result, rerender} = renderHook(
|
||||
({expectInitialSync}) => useLibrarySync({enabled: true, expectInitialSync, onComplete}),
|
||||
{initialProps: {expectInitialSync: false}});
|
||||
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||
|
||||
act(() => result.current.watch());
|
||||
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||
answer = status('OK', '2026-09-30T08:05:00Z');
|
||||
await act(() => vi.advanceTimersByTimeAsync(3_000));
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
|
||||
// onComplete loads the list: isLoading goes true and back to false, and with 0 games expectInitialSync follows.
|
||||
rerender({expectInitialSync: true});
|
||||
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||
rerender({expectInitialSync: false});
|
||||
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {useEffect} from 'react';
|
||||
import {useBlocker} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
// One entry per component with unsaved edits. A router allows only one blocker, and GameDetail shows two such cards.
|
||||
const owners = new Set();
|
||||
let discardNext = false;
|
||||
|
||||
/**
|
||||
* The next navigation drops the unsaved edits without asking, e.g. after the game itself was removed. Only that one:
|
||||
* a flag in the location state would also skip the question on a later "back" to that entry.
|
||||
*/
|
||||
export const discardChanges = () => {
|
||||
discardNext = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks the calling component as having unsaved edits while `dirty`. UnsavedChangesGuard then asks before a
|
||||
* navigation drops them.
|
||||
*/
|
||||
export default function useUnsavedChanges(dirty) {
|
||||
useEffect(() => {
|
||||
if (!dirty) return undefined;
|
||||
const owner = {};
|
||||
owners.add(owner);
|
||||
return () => owners.delete(owner);
|
||||
}, [dirty]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered once, inside a data router. Asks with the browser's own confirm; a reload or a closed tab gets the
|
||||
* browser's own warning. Without a session nothing is held back, so the login page always opens.
|
||||
*/
|
||||
export function UnsavedChangesGuard() {
|
||||
const {t} = useTranslation();
|
||||
const blocker = useBlocker(() => {
|
||||
const discard = discardNext;
|
||||
discardNext = false;
|
||||
return owners.size > 0 && hasSession() && !discard;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state !== 'blocked') return;
|
||||
if (window.confirm(t('UNSAVED_CHANGES.CONFIRM'))) blocker.proceed();
|
||||
else blocker.reset();
|
||||
}, [blocker, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const warn = (event) => {
|
||||
if (owners.size > 0) event.preventDefault();
|
||||
};
|
||||
window.addEventListener('beforeunload', warn);
|
||||
return () => window.removeEventListener('beforeunload', warn);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||
import {createMemoryRouter, Link, Outlet, RouterProvider, useNavigate} from 'react-router-dom';
|
||||
import useUnsavedChanges, {discardChanges, UnsavedChangesGuard} from './useUnsavedChanges.js';
|
||||
import {expireSession, markSession} from '../utils/auth.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
function Editor({dirty = true}) {
|
||||
useUnsavedChanges(dirty);
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<>
|
||||
<p>editor</p>
|
||||
<Link to="/other">leave</Link>
|
||||
<button onClick={() => {
|
||||
discardChanges();
|
||||
navigate('/other');
|
||||
}}>remove</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderEditor(dirty) {
|
||||
const router = createMemoryRouter([{
|
||||
element: <><UnsavedChangesGuard/><Outlet/></>,
|
||||
children: [
|
||||
{path: '/edit', element: <Editor dirty={dirty}/>},
|
||||
{path: '/other', element: <p>other page</p>},
|
||||
{path: '/login', element: <p>login</p>},
|
||||
],
|
||||
}], {initialEntries: ['/edit']});
|
||||
render(<RouterProvider router={router}/>);
|
||||
return router;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
markSession();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('useUnsavedChanges', () => {
|
||||
it('asks before a link leaves unsaved edits, and stays when the user says no', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
renderEditor(true);
|
||||
|
||||
fireEvent.click(screen.getByText('leave'));
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith('UNSAVED_CHANGES.CONFIRM');
|
||||
expect(await screen.findByText('editor')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('leaves when the user says yes', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
renderEditor(true);
|
||||
|
||||
fireEvent.click(screen.getByText('leave'));
|
||||
|
||||
expect(await screen.findByText('other page')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not ask without unsaved edits, or when the page drops them on purpose', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm');
|
||||
renderEditor(false);
|
||||
fireEvent.click(screen.getByText('leave'));
|
||||
expect(await screen.findByText('other page')).toBeTruthy();
|
||||
cleanup();
|
||||
|
||||
renderEditor(true);
|
||||
fireEvent.click(screen.getByText('remove'));
|
||||
expect(await screen.findByText('other page')).toBeTruthy();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never holds back the login page when the session ended', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm');
|
||||
const router = renderEditor(true);
|
||||
|
||||
act(() => expireSession());
|
||||
await act(() => router.navigate('/login'));
|
||||
|
||||
expect(screen.getByText('login')).toBeTruthy();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets the browser warn before a reload or close only while there are unsaved edits', () => {
|
||||
renderEditor(true);
|
||||
const warned = new Event('beforeunload', {cancelable: true});
|
||||
window.dispatchEvent(warned);
|
||||
expect(warned.defaultPrevented).toBe(true);
|
||||
cleanup();
|
||||
|
||||
renderEditor(false);
|
||||
const free = new Event('beforeunload', {cancelable: true});
|
||||
window.dispatchEvent(free);
|
||||
expect(free.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('asks before going back to an entry that once dropped changes on purpose', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const router = renderEditor(true);
|
||||
|
||||
fireEvent.click(screen.getByText('remove'));
|
||||
await screen.findByText('other page');
|
||||
await act(() => router.navigate('/edit'));
|
||||
await screen.findByText('editor');
|
||||
await act(() => router.navigate(-1));
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith('UNSAVED_CHANGES.CONFIRM');
|
||||
expect(router.state.location.pathname).toBe('/edit');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
@@ -19,6 +19,7 @@ import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const REFUSAL_KEY = {
|
||||
@@ -44,13 +45,13 @@ export default function AdminUsersPage() {
|
||||
const [typedEmail, setTypedEmail] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Only the newest load may show its answer: a slow answer to an older search must not replace the list.
|
||||
const loadCount = useRef(0);
|
||||
const nextSignal = useAbortPrevious();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const current = ++loadCount.current;
|
||||
const signal = nextSignal();
|
||||
try {
|
||||
const response = await fetchUsers(search, page, PAGE_SIZE);
|
||||
if (current !== loadCount.current) return;
|
||||
const response = await fetchUsers(search, page, PAGE_SIZE, {signal});
|
||||
if (signal.aborted) return;
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
setStatus('forbidden');
|
||||
@@ -61,14 +62,15 @@ export default function AdminUsersPage() {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (current !== loadCount.current) return;
|
||||
if (signal.aborted) return;
|
||||
setResult(data);
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
console.error('Admin users error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [search, page]);
|
||||
}, [nextSignal, search, page]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
||||
@@ -137,7 +137,7 @@ const Dashboard = () => {
|
||||
expectInitialSync: hasSteamId && steamData.count === 0,
|
||||
onComplete: reloadLibrary,
|
||||
});
|
||||
const forceSync = useForceSync(reloadLibrary);
|
||||
const forceSync = useForceSync(sync.watch);
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton/>;
|
||||
@@ -227,6 +227,7 @@ const Dashboard = () => {
|
||||
status={sync.status}
|
||||
lastSyncedAt={sync.lastSyncedAt}
|
||||
isRunning={sync.isRunning}
|
||||
stale={sync.stale}
|
||||
/>
|
||||
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
||||
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
|
||||
|
||||
+13
-33
@@ -26,7 +26,8 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import ProfileArtMenu from '../components/ProfileArtMenu.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {request} from '../utils/api.js';
|
||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
||||
import {canCorrect} from '../utils/corrections.js';
|
||||
import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
|
||||
@@ -94,43 +95,22 @@ export default function GameDetail() {
|
||||
setData(null);
|
||||
}
|
||||
|
||||
const nextSignal = useAbortPrevious();
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const signal = nextSignal();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
(async () => {
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`;
|
||||
const response = await apiFetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 404) {
|
||||
setError('GAME_DETAIL.NOT_FOUND');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setError('GAME_DETAIL.ERROR_LOAD');
|
||||
return;
|
||||
}
|
||||
const json = await response.json();
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error('GameDetail load error:', err);
|
||||
setError('INFO.ERROR.NETWORK_FAILURE');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
const {ok, status, data: json} = await request(
|
||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`, {signal});
|
||||
if (signal.aborted) return;
|
||||
setIsLoading(false);
|
||||
if (status === 401) return;
|
||||
if (status === 404) setError('GAME_DETAIL.NOT_FOUND');
|
||||
else if (!ok || !json) setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'GAME_DETAIL.ERROR_LOAD');
|
||||
else setData(json);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [appid, reloads]);
|
||||
}, [nextSignal, appid, reloads]);
|
||||
|
||||
const sortedAchievements = useMemo(() => {
|
||||
if (!data?.achievements) return [];
|
||||
|
||||
@@ -369,3 +369,21 @@ describe('GameDetail confetti', () => {
|
||||
expect(await opened({status: 'PLAYING'})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameDetail session', () => {
|
||||
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
|
||||
toast.error.mockClear();
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/library/games/220': json(401, {}),
|
||||
'POST /api/auth/refresh': json(401, {}),
|
||||
});
|
||||
const {router} = renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220'],
|
||||
guarded: true, routes: {'/login': <p>login</p>}});
|
||||
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library/220'});
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
|
||||
});
|
||||
});
|
||||
+16
-25
@@ -20,7 +20,8 @@ import GameCover from '../components/GameCover.jsx';
|
||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {apiFetch, request} from '../utils/api.js';
|
||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
import {platformLabel} from '../utils/platforms.js';
|
||||
|
||||
const PAGE_SIZES = [20, 40, 80];
|
||||
@@ -65,33 +66,22 @@ export default function GameLibrary() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const nextSignal = useAbortPrevious();
|
||||
const load = useCallback(async () => {
|
||||
const signal = nextSignal();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly})}`;
|
||||
const response = await apiFetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) return;
|
||||
|
||||
if (!response.ok) {
|
||||
setError('LIBRARY.ERROR_LOAD');
|
||||
return;
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
console.error('Library load error:', err);
|
||||
setError('INFO.ERROR.NETWORK_FAILURE');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly})}`;
|
||||
const {ok, status, data: json} = await request(url, {signal});
|
||||
if (signal.aborted) return;
|
||||
setIsLoading(false);
|
||||
if (status === 401) return;
|
||||
if (!ok || !json) {
|
||||
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'LIBRARY.ERROR_LOAD');
|
||||
return;
|
||||
}
|
||||
}, [page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
||||
setData(json);
|
||||
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -156,7 +146,7 @@ export default function GameLibrary() {
|
||||
updateParams({page: target});
|
||||
};
|
||||
|
||||
const {trigger: triggerSync, busy: syncBusy} = useForceSync(load);
|
||||
const {trigger: triggerSync, busy: syncBusy} = useForceSync(sync.watch);
|
||||
|
||||
const syncing = syncBusy || sync.isRunning;
|
||||
const ready = !isLoading && !error;
|
||||
@@ -174,6 +164,7 @@ export default function GameLibrary() {
|
||||
status={sync.status}
|
||||
lastSyncedAt={sync.lastSyncedAt}
|
||||
isRunning={sync.isRunning}
|
||||
stale={sync.stale}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={triggerSync} disabled={syncing}>
|
||||
<RefreshCw className={syncing ? 'animate-spin' : ''}/>
|
||||
|
||||
@@ -6,8 +6,11 @@ import GameLibrary from './GameLibrary.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 {toast} from 'sonner';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
||||
|
||||
@@ -70,6 +73,36 @@ describe('GameLibrary search', () => {
|
||||
expect(screen.queryByText(/\/5$/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the newest search when an older answer comes last', async () => {
|
||||
const later = [];
|
||||
const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1});
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
|
||||
'GET /api/library/games': (url, init) => {
|
||||
const q = new URL(url, 'http://x').searchParams.get('q');
|
||||
if (!q) return games('Half-Life 2');
|
||||
// The first search answers only after the second one.
|
||||
return new Promise((resolve, reject) => {
|
||||
later.push({q, answer: () => resolve(games(q === 'ha' ? 'Old answer' : 'New answer'))});
|
||||
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
||||
});
|
||||
},
|
||||
});
|
||||
renderLibrary();
|
||||
await screen.findByText('Half-Life 2');
|
||||
|
||||
typeInto('ha');
|
||||
await waitFor(() => expect(later).toHaveLength(1));
|
||||
typeInto('half');
|
||||
await waitFor(() => expect(later).toHaveLength(2));
|
||||
await act(async () => later[1].answer());
|
||||
await act(async () => later[0].answer());
|
||||
|
||||
expect(screen.getByText('New answer')).toBeTruthy();
|
||||
expect(screen.queryByText('Old answer')).toBeNull();
|
||||
});
|
||||
|
||||
it('writes the trimmed search to the URL after the delay and resets the page', async () => {
|
||||
const lib = renderLibrary('/library?page=3');
|
||||
|
||||
@@ -216,3 +249,21 @@ describe('GameLibrary with games added by hand', () => {
|
||||
expect(screen.getByText('LIBRARY.RESYNC')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameLibrary session', () => {
|
||||
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/library/games': json(401, {}),
|
||||
'GET /api/library/games-count': json(401, {}),
|
||||
'POST /api/auth/refresh': json(401, {}),
|
||||
});
|
||||
const {router} = renderWithProviders(<GameLibrary/>, {path: '/library', entries: ['/library?q=zelda'],
|
||||
guarded: true, routes: {'/login': <p>login</p>}});
|
||||
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library?q=zelda'});
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('LIBRARY.ERROR_LOAD')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import {GAME_STATUSES, MAX_IMPORT_ROWS, MAX_PLATFORM_LENGTH} from '../utils/limi
|
||||
import {hoursToMinutes, toHours} from '../utils/manualGames.js';
|
||||
import {preselectPlatform} from '../utils/platforms.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
|
||||
|
||||
const POLL_MS = 2000;
|
||||
// Statuses a worker still has: the page polls while it shows one.
|
||||
@@ -341,7 +342,9 @@ export const initialChoice = (row, favorite) => {
|
||||
|
||||
function Review({data, favorite, onSaved, onDone}) {
|
||||
const {t} = useTranslation();
|
||||
const [choices, setChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
|
||||
const [initialChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
|
||||
const [choices, setChoices] = useState(initialChoices);
|
||||
useUnsavedChanges(choices !== initialChoices);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Stable, so a change in one line re-renders only that line (up to 1000 lines).
|
||||
|
||||
@@ -7,6 +7,7 @@ import ImportPage, {initialChoice, stepOf} from './ImportPage.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()}}));
|
||||
@@ -273,3 +274,19 @@ describe('review findings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('ImportPage session', () => {
|
||||
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/library/imports': json(401, {}),
|
||||
'POST /api/auth/refresh': json(401, {}),
|
||||
});
|
||||
const {router} = renderWithProviders(<ImportPage/>, {path: '/import', entries: ['/import'], guarded: true,
|
||||
routes: {'/login': <p>login</p>}});
|
||||
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/import'});
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+17
-23
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
@@ -12,7 +12,8 @@ import GameCover from '../components/GameCover.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {saveGameStatus} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {request} from '../utils/api.js';
|
||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
|
||||
// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data.
|
||||
const BUDGETS = [
|
||||
@@ -45,33 +46,26 @@ export default function PlayNextPage() {
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// A slow answer for an older budget or mood must not replace the newer one.
|
||||
const latestRequest = useRef(0);
|
||||
const nextSignal = useAbortPrevious();
|
||||
|
||||
const load = useCallback(async (maxMinutes, moodKey, exclude) => {
|
||||
const request = ++latestRequest.current;
|
||||
const signal = nextSignal();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`);
|
||||
if (request !== latestRequest.current) return;
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
setError('PLAY_NEXT.ERROR_LOAD');
|
||||
return;
|
||||
}
|
||||
const picked = (await response.json()).games ?? [];
|
||||
if (request !== latestRequest.current) return;
|
||||
setGames(picked);
|
||||
setSeen([...exclude, ...picked.map((g) => g.appid)].slice(-MAX_EXCLUDES));
|
||||
} catch (err) {
|
||||
console.error('Picker load error:', err);
|
||||
if (request === latestRequest.current) setError('INFO.ERROR.NETWORK_FAILURE');
|
||||
} finally {
|
||||
if (request === latestRequest.current) setLoading(false);
|
||||
const {ok, status, data} = await request(
|
||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`, {signal});
|
||||
if (signal.aborted) return;
|
||||
setLoading(false);
|
||||
if (status === 401) return;
|
||||
if (!ok || !data) {
|
||||
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'PLAY_NEXT.ERROR_LOAD');
|
||||
return;
|
||||
}
|
||||
const picked = data.games ?? [];
|
||||
setGames(picked);
|
||||
setSeen([...exclude, ...picked.map((g) => g.appid)].slice(-MAX_EXCLUDES));
|
||||
// No `t` here: a language change gives a new `t`, and a reload would replace the random picks.
|
||||
}, []);
|
||||
}, [nextSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
load(budget.maxMinutes, mood, []);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, screen, waitFor, within} from '@testing-library
|
||||
import SettingsPage from './SettingsPage.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const mock = await import('../test/i18n-mock.js');
|
||||
@@ -239,3 +240,28 @@ describe('SettingsPage favorite platform', () => {
|
||||
await waitFor(() => expect(puts()).toEqual([{platform: 'Switch 2'}, {platform: null}]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsPage session', () => {
|
||||
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
|
||||
toast.error.mockClear();
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(401, {}),
|
||||
'POST /api/auth/refresh': json(401, {}),
|
||||
'GET /api/version': json(404, {}),
|
||||
});
|
||||
const {router} = renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings'],
|
||||
guarded: true, routes: {'/login': <p>login</p>}});
|
||||
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/settings'});
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('INFO.ERROR.LOAD_PROFILE')).toBeNull();
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -7,8 +7,8 @@ export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN'];
|
||||
/**
|
||||
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
|
||||
*/
|
||||
export const fetchUsers = (query, page, size) =>
|
||||
apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, page, size})}`);
|
||||
export const fetchUsers = (query, page, size, init) =>
|
||||
apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, page, size})}`, init);
|
||||
|
||||
/**
|
||||
* 204, or 409 with code SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED.
|
||||
|
||||
@@ -89,5 +89,6 @@ export async function request(url, init = {}) {
|
||||
} catch {
|
||||
// no JSON body, e.g. a 204
|
||||
}
|
||||
if (init.signal?.aborted) return {ok: false, status: 0, data: null};
|
||||
return {ok: response.ok, status: response.status, data};
|
||||
}
|
||||
@@ -209,6 +209,16 @@ describe('request', () => {
|
||||
expect(await request('/api/x', {method: 'PUT'})).toEqual({ok: false, status: 409, data: {code: 'CONFLICT'}});
|
||||
});
|
||||
|
||||
it('gives status 0 when the request is aborted while its body is read', async () => {
|
||||
const controller = new AbortController();
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => {
|
||||
controller.abort();
|
||||
throw new DOMException('aborted', 'AbortError');
|
||||
}}));
|
||||
|
||||
expect(await request('/api/x', {signal: controller.signal})).toEqual({ok: false, status: 0, data: null});
|
||||
});
|
||||
|
||||
it('gives status 0 on a network error and logs it, but not an abort', async () => {
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
globalThis.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch'); });
|
||||
|
||||
Reference in new issue
Block a user