feat(audit): game catalog search and time to beat correction

New page /audit/games: search every game and open the correction dialog
without owning it. Time to beat as hours + minutes. Audit log game links
open /audit/games?appid=. Menu entry next to the link queue.
This commit is contained in:
lobo committed 2026-10-02 11:21:15 +02:00
1 parent 6627a8db66
commit 0c300c455d
14 files changed
+439 -19

No files matched your search

+2
View File
@@ -18,6 +18,7 @@ import WelcomePage from './pages/WelcomePage.jsx';
import WelcomeGate from './components/WelcomeGate.jsx';
import AdminUsersPage from './pages/AdminUsersPage.jsx';
import AuditLogPage from './pages/AuditLogPage.jsx';
import GameCatalogPage from './pages/GameCatalogPage.jsx';
import LinkQueuePage from './pages/LinkQueuePage.jsx';
import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx';
@@ -89,6 +90,7 @@ function App() {
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/whats-new" element={<WhatsNewPage/>}/>
<Route path="/welcome" element={<WelcomePage/>}/>
<Route path="/audit/games" element={<GameCatalogPage/>}/>
<Route path="/audit/links" element={<LinkQueuePage/>}/>
<Route path="/audit/log" element={<AuditLogPage/>}/>
<Route path="/admin/users" element={<AdminUsersPage/>}/>
+4 -1
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Settings, Shield, Sun} from 'lucide-react';
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
@@ -158,6 +158,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
</DropdownMenuItem>
{canCorrect(currentUser) && (
<>
<DropdownMenuItem asChild>
<Link to="/audit/games"><Search/>{t('NAV.GAME_CATALOG')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
</DropdownMenuItem>
+4 -2
View File
@@ -88,12 +88,13 @@ describe('AppHeader', () => {
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
});
it('offersTheLinkQueueAndAuditLogToAnAuditorOnly', async () => {
it('offersTheCatalogLinkQueueAndAuditLogToAnAuditorOnly', async () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
openMenu();
expect(await screen.findByText('NAV.LINK_QUEUE')).toBeTruthy();
expect(await screen.findByText('NAV.GAME_CATALOG')).toBeTruthy();
expect(screen.getByText('NAV.LINK_QUEUE')).toBeTruthy();
expect(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
});
@@ -108,6 +109,7 @@ describe('AppHeader', () => {
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
expect(screen.queryByText('NAV.LINK_QUEUE')).toBeNull();
expect(screen.queryByText('NAV.GAME_CATALOG')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
+52 -3
View File
@@ -10,12 +10,19 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
import {genreKey} from '../utils/stats.js';
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js';
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH, MAX_TIME_TO_BEAT_MINUTES} from '../utils/limits.js';
const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'};
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
const toHoursAndMinutes = (minutes) => (minutes ? {h: String(Math.floor(minutes / 60)), m: String(minutes % 60)} : {h: '', m: ''});
const toMinutes = ({h, m}) => (h.trim() === '' && m.trim() === '' ? null
: Math.round(Number(h || 0) * 60 + Number(m || 0)));
const hasNegativePart = ({normally, completely}) => [normally.h, normally.m, completely.h, completely.m].some((part) => Number(part) < 0);
const initialValues = (game) => ({
name: game.name ?? '',
image: game.imageUrl ?? '',
@@ -23,10 +30,19 @@ const initialValues = (game) => ({
moods: game.moods ?? [],
endless: game.endless ?? null,
genres: game.genres ?? [],
time_to_beat: {
normally: toHoursAndMinutes(game.timeToBeat?.normallyMinutes),
completely: toHoursAndMinutes(game.timeToBeat?.completelyMinutes),
},
});
// An empty text field means "none": the Steam header, or no speedrun.
const toValue = (value) => (typeof value === 'string' ? value.trim() || null : value);
const toValue = (field, value) => {
if (field === 'time_to_beat') {
return {normallyMinutes: toMinutes(value.normally), completelyMinutes: toMinutes(value.completely)};
}
return typeof value === 'string' ? value.trim() || null : value;
};
const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]);
@@ -81,6 +97,14 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
}
};
const save = (field) => {
if (field === 'time_to_beat' && hasNegativePart(values.time_to_beat)) {
toast.error(t('CORRECTION.NEGATIVE'));
return;
}
send(field, () => saveCorrection(game.appid, field, toValue(field, values[field])));
};
const row = (field, input) => (
<div key={field} className="space-y-2 border-b pb-4 last:border-b-0">
<div className="flex items-center gap-2">
@@ -90,7 +114,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
{input}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" disabled={busy !== null}
onClick={() => send(field, () => saveCorrection(game.appid, field, toValue(values[field])))}>
onClick={() => save(field)}>
{busy === field ? <Loader2 className="animate-spin"/> : <Save/>}{t('CORRECTION.SAVE')}
</Button>
{corrected.has(field) && (
@@ -116,6 +140,25 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
</div>
);
const timeInputs = (kind, label) => {
const setPart = (part, text) => set('time_to_beat', {
...values.time_to_beat, [kind]: {...values.time_to_beat[kind], [part]: text},
});
return (
<div className="flex flex-wrap items-center gap-2">
<span className="w-20 text-sm">{label}</span>
<Input type="number" min="0" max={MAX_TIME_TO_BEAT_MINUTES / 60} step="1" className="w-24"
aria-label={`${label} ${t('CORRECTION.HOURS')}`} value={values.time_to_beat[kind].h}
onChange={(e) => setPart('h', e.target.value)}/>
<span className="text-sm text-muted-foreground">{t('CORRECTION.HOURS')}</span>
<Input type="number" min="0" max="59" step="1" className="w-20"
aria-label={`${label} ${t('CORRECTION.MINUTES')}`} value={values.time_to_beat[kind].m}
onChange={(e) => setPart('m', e.target.value)}/>
<span className="text-sm text-muted-foreground">{t('CORRECTION.MINUTES')}</span>
</div>
);
};
// A genre IGDB added later still shows, so it can be removed.
const genreOptions = [...IGDB_GENRES, ...values.genres.filter((g) => !IGDB_GENRES.includes(g))];
@@ -149,6 +192,12 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
))}
{row('genres', choices('genres', genreOptions,
(genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_CORRECTION_GENRES))}
{row('time_to_beat', (
<div id="correction-time_to_beat" className="space-y-2">
{timeInputs('normally', t('GAME_DETAIL.TTB_NORMALLY'))}
{timeInputs('completely', t('GAME_DETAIL.TTB_COMPLETELY'))}
</div>
))}
</div>
</DialogContent>
</Dialog>
+46 -2
View File
@@ -13,12 +13,13 @@ const game = {
appid: 620, name: 'Portal 2', imageUrl: null, speedrunUrl: 'https://www.speedrun.com/portal_2',
moods: ['STORY'], endless: null, genres: ['Puzzle'],
corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}],
timeToBeat: {hastilyMinutes: 300, normallyMinutes: 750, completelyMinutes: null},
};
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,
[`PUT ${GAME_URL}/moods`]: answer, [`PUT ${GAME_URL}/time_to_beat`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
});
const renderDialog = (onChanged = vi.fn()) => {
@@ -26,7 +27,7 @@ const renderDialog = (onChanged = vi.fn()) => {
return onChanged;
};
// The rows in order: name, image, speedrun, moods, endless, genres.
// The rows in order: name, image, speedrun, moods, endless, genres, time_to_beat.
const saveButton = (row) => screen.getAllByRole('button', {name: 'CORRECTION.SAVE'})[row];
const sent = () => fetch.mock.calls.map(([url, init]) => ({url, method: init?.method, body: init?.body && JSON.parse(init.body)}));
@@ -69,6 +70,49 @@ describe('GameCorrectionDialog', () => {
expect(sent().map((call) => call.body)).toEqual([{value: null}, {value: ['COOP']}]);
});
it('shows the time to beat as hours and minutes and sends minutes, empty as null', async () => {
renderDialog();
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS').value).toBe('12');
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES').value).toBe('30');
expect(screen.getByLabelText('GAME_DETAIL.TTB_COMPLETELY CORRECTION.HOURS').value).toBe('');
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
fireEvent.click(saveButton(6));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(sent()[0].body).toEqual({value: {normallyMinutes: 720, completelyMinutes: null}});
});
it('sendsFractionalHoursAsExactMinutes', async () => {
renderDialog();
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1.5'}});
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
fireEvent.click(saveButton(6));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(sent()[0].body.value.normallyMinutes).toBe(90);
});
it('refusesANegativeTimePartBeforeSaving', () => {
renderDialog();
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1'}});
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: '-5'}});
fireEvent.click(saveButton(6));
expect(toast.error).toHaveBeenCalledWith('CORRECTION.NEGATIVE');
expect(fetch).not.toHaveBeenCalled();
});
it('offers back to source for a corrected time to beat', () => {
render(<MemoryRouter><GameCorrectionDialog open onOpenChange={vi.fn()} onChanged={vi.fn()}
game={{...game, corrections: [{field: 'TIME_TO_BEAT', correctedAt: '2026-09-28T10:00:00Z'}]}}/></MemoryRouter>);
expect(screen.getAllByText('CORRECTION.CORRECTED')).toHaveLength(1);
expect(screen.getByRole('button', {name: 'CORRECTION.BACK_TO_SOURCE'})).toBeTruthy();
});
it('marks a corrected field and goes back to the source', async () => {
const onChanged = renderDialog();
+3 -2
View File
@@ -11,12 +11,13 @@ import {fetchAuditLog} from '../utils/links.js';
import {formatDateTime} from '../utils/format.js';
const PAGE_SIZE = 20;
const ACTIONS = ['GAME_MERGE', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS'];
const ACTIONS = ['GAME_MERGE', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS', 'GAME_CORRECT',
'GAME_CORRECTION_RESET'];
const ALL = 'ALL';
function GameRef({appid, name, fallback}) {
if (appid == null) return null;
return <Link to={`/library/${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>;
return <Link to={`/audit/games?appid=${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>;
}
/**
+1 -1
View File
@@ -33,7 +33,7 @@ describe('AuditLogPage', () => {
expect(await screen.findByText('lobo')).toBeTruthy();
expect(screen.getByText('AUDIT.ACTION.GAME_LINK')).toBeTruthy();
expect(screen.getByRole('link', {name: 'Dark Souls'}).getAttribute('href')).toBe('/library/10');
expect(screen.getByRole('link', {name: 'Dark Souls'}).getAttribute('href')).toBe('/audit/games?appid=10');
});
it('fallsBackToTheUserIdAndTheDetailForAMergedKey', async () => {
+174
View File
@@ -0,0 +1,174 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChevronLeft, ChevronRight, Loader2, Search} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
import GameCover from '../components/GameCover.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {fetchCatalogGame, searchCatalog} from '../utils/corrections.js';
import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
const PAGE_SIZE = 20;
const SEARCH_DEBOUNCE_MS = 300;
/**
* AUDITOR or higher: finds any game some user has and opens the correction dialog for it. ?appid= opens a game
* directly (the audit log links here). Core checks the role.
*/
export default function GameCatalogPage() {
const {t} = useTranslation();
const [params, setParams] = useSearchParams();
const openAppid = params.get('appid');
const [query, setQuery] = useState('');
const [term, setTerm] = useState('');
const [page, setPage] = useState(0);
const [result, setResult] = useState({content: [], totalPages: 0});
const [status, setStatus] = useState('loading');
const [detail, setDetail] = useState(null);
const [reloads, setReloads] = useState(0);
// The answer for a game that is no longer the open one (Esc before it came) is dropped.
const openRef = useRef(openAppid);
useEffect(() => {
openRef.current = openAppid;
}, [openAppid]);
useEffect(() => {
const timer = setTimeout(() => {
if (query.trim() !== term) {
setTerm(query.trim());
setPage(0);
}
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [query, term]);
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const response = await searchCatalog({q: term, page, size: PAGE_SIZE}, controller.signal);
if (response.status === 401) return;
if (response.status === 403) {
setStatus('forbidden');
return;
}
if (!response.ok) {
setStatus('error');
return;
}
setResult(await response.json());
setStatus('ready');
} catch (err) {
if (err.name === 'AbortError') return;
console.error('Catalog error:', err);
setStatus('error');
}
})();
return () => controller.abort();
}, [term, page, reloads]);
const close = useCallback(() => {
setDetail(null);
setParams({}, {replace: true});
}, [setParams]);
const loadDetail = useCallback(async (appid) => {
try {
const response = await fetchCatalogGame(appid);
if (response.status === 401 || String(appid) !== openRef.current) return;
if (response.ok) {
setDetail(await response.json());
return;
}
// 404: the game was merged meanwhile.
toast.error(t(response.status === 404 ? 'CATALOG.GONE' : 'CATALOG.LOAD_ERROR'));
close();
setReloads((n) => n + 1);
} catch (err) {
console.error('Catalog game error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
close();
}
}, [close, t]);
useEffect(() => {
if (openAppid) loadDetail(openAppid);
else setDetail(null);
}, [openAppid, loadDetail]);
const changed = () => {
loadDetail(detail.appid);
setReloads((n) => n + 1);
};
if (status === 'forbidden') {
return (
<PageContainer className="px-4 py-6 md:px-8">
<p className="text-sm text-muted-foreground">{t('CATALOG.NO_ACCESS')}</p>
</PageContainer>
);
}
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader className="space-y-3">
<CardTitle>{t('CATALOG.TITLE')}</CardTitle>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input type="search" value={query} maxLength={MAX_LIBRARY_SEARCH_LENGTH} className="pl-8"
placeholder={t('CATALOG.SEARCH')} aria-label={t('CATALOG.SEARCH')}
onChange={(e) => setQuery(e.target.value)}/>
</div>
</CardHeader>
<CardContent className="space-y-3">
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('CATALOG.LOAD_ERROR')}</p>}
{status === 'ready' && result.content.length === 0 && (
<p className="text-sm text-muted-foreground">{t('CATALOG.EMPTY')}</p>
)}
{status === 'ready' && result.content.length > 0 && (
<ul className="divide-y">
{result.content.map((game) => (
<li key={game.appid} className="flex items-center gap-3 py-2">
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-20 shrink-0"/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{game.name}</p>
<p className="text-xs text-muted-foreground">
{game.platforms.join(', ')} · {t('CATALOG.KEY', {appid: game.appid})}
</p>
</div>
<Button type="button" size="sm" variant="outline"
onClick={() => setParams({appid: String(game.appid)})}>
{t('CATALOG.CORRECT')}
</Button>
</li>
))}
</ul>
)}
{status === 'ready' && result.totalPages > 1 && (
<div className="flex items-center justify-between gap-2 text-sm">
<Button type="button" variant="outline" size="sm" disabled={page === 0}
onClick={() => setPage(page - 1)}>
<ChevronLeft/>{t('ADMIN.PREVIOUS')}
</Button>
<span>{t('AUDIT.PAGE_OF', {current: page + 1, total: result.totalPages})}</span>
<Button type="button" variant="outline" size="sm" disabled={page + 1 >= result.totalPages}
onClick={() => setPage(page + 1)}>
{t('ADMIN.NEXT')}<ChevronRight/>
</Button>
</div>
)}
</CardContent>
</Card>
{detail && (
<GameCorrectionDialog game={detail} open onOpenChange={(open) => !open && close()} onChanged={changed}/>
)}
</PageContainer>
);
}
+91
View File
@@ -0,0 +1,91 @@
// @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 GameCatalogPage from './GameCatalogPage.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()}}));
const hit = {appid: 10, name: 'Hades', imageUrl: null, platforms: ['PS5', 'Steam']};
const detail = {appid: 10, name: 'Hades', imageUrl: null, speedrunUrl: null, moods: [], endless: null, genres: [],
timeToBeat: null, corrections: []};
const catalog = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages});
const renderPage = (routes, url = '/audit/games') => {
fetchRoutes(routes);
render(<MemoryRouter initialEntries={[url]}><PreferencesProvider><GameCatalogPage/></PreferencesProvider></MemoryRouter>);
};
const urls = () => fetch.mock.calls.map(([url]) => url);
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('GameCatalogPage', () => {
it('listsHitsWithPlatformsAndKey', async () => {
renderPage({'GET /api/library/catalog': catalog([hit])});
expect(await screen.findByText('Hades')).toBeTruthy();
expect(screen.getByText(/PS5, Steam/)).toBeTruthy();
expect(screen.getByText(/CATALOG.KEY/)).toBeTruthy();
});
it('searchesWithTheTypedName', async () => {
renderPage({'GET /api/library/catalog': catalog([hit])});
await screen.findByText('Hades');
fireEvent.change(screen.getByLabelText('CATALOG.SEARCH'), {target: {value: ' had '}});
await waitFor(() => expect(urls().some((u) => u.includes('q=had'))).toBe(true));
});
it('opensTheCorrectionDialogForTheChosenGame', async () => {
renderPage({'GET /api/library/catalog': catalog([hit]), 'GET /api/library/catalog/10': json(200, detail)});
fireEvent.click(await screen.findByRole('button', {name: 'CATALOG.CORRECT'}));
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
expect(urls().some((u) => u.endsWith('/api/library/catalog/10'))).toBe(true);
});
it('opensTheGameOfTheAppidInTheUrl', async () => {
renderPage({'GET /api/library/catalog': catalog([]), 'GET /api/library/catalog/10': json(200, detail)},
'/audit/games?appid=10');
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
});
it('saysSoWhenTheGameIsGone', async () => {
const {toast} = await import('sonner');
renderPage({'GET /api/library/catalog': catalog([]), 'GET /api/library/catalog/10': json(404, {})},
'/audit/games?appid=10');
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CATALOG.GONE'));
expect(screen.queryByText('CORRECTION.TITLE')).toBeNull();
});
it('showsNoAccessOn403', async () => {
renderPage({'GET /api/library/catalog': json(403, {})});
expect(await screen.findByText('CATALOG.NO_ACCESS')).toBeTruthy();
});
it('pagesThroughTheCatalog', async () => {
renderPage({'GET /api/library/catalog': () => catalog([hit], 2)});
await screen.findByText('Hades');
fireEvent.click(screen.getByRole('button', {name: /ADMIN.NEXT/}));
await waitFor(() => expect(urls().at(-1)).toContain('page=1'));
});
});
+15
View File
@@ -41,3 +41,18 @@ export const resetCorrection = (appid, field) => apiFetch(url(appid, field), {me
export const mergeGame = (appid, otherAppid) =>
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/merge`,
{method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({otherAppid})});
/**
* GET: 200 `{content: [{appid, name, imageUrl, platforms}], totalPages}` of every game some user has, 403 below
* AUDITOR. A blank q means all.
*/
export const searchCatalog = ({q, page, size}, signal) => {
const params = new URLSearchParams({page: String(page), size: String(size)});
if (q?.trim()) params.set('q', q.trim());
return apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/catalog?${params}`, {signal});
};
/**
* GET: 200 what GameCorrectionDialog reads (like the game details), 403 below AUDITOR, 404 GAME_NOT_FOUND.
*/
export const fetchCatalogGame = (appid) => apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/catalog/${appid}`);
+2
View File
@@ -23,6 +23,8 @@ export const MAX_GAME_SEARCH_LENGTH = 100;
export const MAX_LIBRARY_SEARCH_LENGTH = 100;
// Core GameCorrectionService.MAX_TEXT
export const MAX_CORRECTION_LENGTH = 512;
// Core GameCorrectionService.MAX_TIME_TO_BEAT_MINUTES
export const MAX_TIME_TO_BEAT_MINUTES = 60_000;
// Core GameCorrectionService.MAX_GENRES
export const MAX_CORRECTION_GENRES = 10;