feat(library): add game links, auditor queue, audit log and filter
Build, Test & Push Frontend / quality-check (push) Successful in 25s
Build, Test & Push Frontend / docker (push) Successful in 23s

LinkGameDialog (user request or auditor link), EditionsCard on the
details page, LinkQueuePage and AuditLogPage for auditors with paging,
hideDuplicates filter and editions badge in the library, audit menu
entries, de/en texts.
This commit is contained in:
lobo committed 2026-10-02 09:10:56 +02:00
1 parent 80259cb0e9
commit f71e8f7aff
21 files changed
+992 -11

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
import {useCallback, useEffect, useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChevronLeft, ChevronRight, Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import PageContainer from '../components/PageContainer.jsx';
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 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>;
}
/**
* AUDITOR or higher: the newest rows of Core's audit log (merge, link, unlink, dismiss). Core checks the role.
*/
export default function AuditLogPage() {
const {t, i18n} = useTranslation();
const [action, setAction] = useState(ALL);
const [page, setPage] = useState(0);
const [result, setResult] = useState({content: [], totalPages: 0});
const [status, setStatus] = useState('loading');
const load = useCallback(async () => {
try {
const response = await fetchAuditLog({action: action === ALL ? '' : action, page, size: PAGE_SIZE});
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) {
console.error('Audit log error:', err);
setStatus('error');
}
}, [action, page]);
useEffect(() => {
load();
}, [load]);
const onActionChange = (value) => {
setAction(value);
setPage(0);
};
if (status === 'forbidden') {
return (
<PageContainer className="px-4 py-6 md:px-8">
<p className="text-sm text-muted-foreground">{t('AUDIT.NO_ACCESS')}</p>
</PageContainer>
);
}
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader className="flex flex-wrap items-center justify-between gap-3">
<CardTitle>{t('AUDIT.TITLE')}</CardTitle>
<Select value={action} onValueChange={onActionChange}>
<SelectTrigger className="w-52" aria-label={t('AUDIT.ACTION.LABEL')}>
<SelectValue/>
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('AUDIT.ACTION.ALL')}</SelectItem>
{ACTIONS.map((key) => <SelectItem key={key} value={key}>{t(`AUDIT.ACTION.${key}`)}</SelectItem>)}
</SelectContent>
</Select>
</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('AUDIT.LOAD_ERROR')}</p>}
{status === 'ready' && result.content.length === 0 && (
<p className="text-sm text-muted-foreground">{t('AUDIT.EMPTY')}</p>
)}
{status === 'ready' && result.content.length > 0 && (
<ul className="divide-y">
{result.content.map((entry) => (
<li key={entry.id} className="space-y-1 py-3 text-sm">
<p className="flex flex-wrap items-center gap-x-2">
<span className="text-xs text-muted-foreground">{formatDateTime(entry.createdAt, i18n.language)}</span>
<span>{entry.actorName ?? t('AUDIT.USER_ID', {id: entry.actorUserId})}</span>
<span className="font-medium">{t(`AUDIT.ACTION.${entry.action}`)}</span>
<GameRef appid={entry.appid} name={entry.gameName} fallback={entry.detail}/>
<GameRef appid={entry.targetAppid} name={entry.targetGameName} fallback={entry.targetAppid}/>
</p>
{entry.detail && <p className="break-words text-xs text-muted-foreground">{entry.detail}</p>}
</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>
</PageContainer>
);
}
+61
View File
@@ -0,0 +1,61 @@
// @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 AuditLogPage from './AuditLogPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const entry = (over) => ({id: 1, actorUserId: 5, actorName: 'lobo', action: 'GAME_LINK', appid: 10, gameName: 'Dark Souls',
targetAppid: -7, targetGameName: 'Dark Souls Remastered', detail: '"Dark Souls" with "Dark Souls Remastered"',
createdAt: '2026-10-01T10:00:00Z', ...over});
const answer = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages});
const renderPage = (route) => {
fetchRoutes({'GET /api/library/audit-log': route});
render(<MemoryRouter><PreferencesProvider><AuditLogPage/></PreferencesProvider></MemoryRouter>);
};
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('AuditLogPage', () => {
it('showsActorActionAndGameLink', async () => {
renderPage(answer([entry({})]));
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');
});
it('fallsBackToTheUserIdAndTheDetailForAMergedKey', async () => {
renderPage(answer([entry({actorName: null, actorUserId: 9, action: 'GAME_MERGE', gameName: null, detail: 'Old key',
targetAppid: null, targetGameName: null})]));
expect(await screen.findByText('AUDIT.USER_ID')).toBeTruthy();
expect(screen.getAllByText('Old key').length).toBeGreaterThan(0);
});
it('pagesThroughTheLog', async () => {
renderPage(() => answer([entry({})], 2));
await screen.findByText('lobo');
fireEvent.click(screen.getByRole('button', {name: /ADMIN.NEXT/}));
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toContain('page=1'));
expect(screen.getByText('AUDIT.PAGE_OF')).toBeTruthy();
});
it('showsNoAccessOn403', async () => {
renderPage(json(403, {}));
expect(await screen.findByText('AUDIT.NO_ACCESS')).toBeTruthy();
});
});
+6
View File
@@ -14,7 +14,9 @@ import ManualGameCard from '../components/ManualGameCard.jsx';
import CompletedHint from '../components/CompletedHint.jsx';
import FinishedHint from '../components/FinishedHint.jsx';
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
import EditionsCard from '../components/EditionsCard.jsx';
import GameMergeDialog from '../components/GameMergeDialog.jsx';
import LinkGameDialog from '../components/LinkGameDialog.jsx';
import PlayingHint from '../components/PlayingHint.jsx';
import useCurrentUser from '../hooks/useCurrentUser.js';
import useGame from '../hooks/useGame.js';
@@ -30,6 +32,7 @@ export default function GameDetail() {
const {data, setData, isLoading, error, reload} = useGame(appid);
const [correcting, setCorrecting] = useState(false);
const [merging, setMerging] = useState(false);
const [linking, setLinking] = useState(false);
const currentUser = useCurrentUser(true);
const auditor = canCorrect(currentUser);
const [savedProfileStyle, setSavedProfileStyle] = useState(null);
@@ -90,6 +93,8 @@ export default function GameDetail() {
<BacklogCard key={data.appid} game={data}
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
<EditionsCard game={data} auditor={auditor} onRequest={() => setLinking(true)} onChanged={reload}/>
{manual && (
<ManualGameCard key={`manual-${data.appid}`} game={data}
onSaved={(changes) => setData((d) => ({...d, ...changes}))}/>
@@ -118,6 +123,7 @@ export default function GameDetail() {
<GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting}
onChanged={reload}/>
)}
{data && <LinkGameDialog game={data} open={linking} onOpenChange={setLinking} auditor={auditor} onDone={reload}/>}
{auditor && data && (
<GameMergeDialog game={data} open={merging} onOpenChange={setMerging} onMerged={(merged) => {
// A new key loads through the address; the same key needs a reload.
+32
View File
@@ -322,6 +322,26 @@ describe('GameDetail of a game added by hand', () => {
expect(await screen.findByText('MERGE.TITLE')).toBeTruthy();
});
it('showsTheOtherEditionsAndOffersUnlinkOnlyToAnAuditorOnALinkedGame', async () => {
const linked = {...game, linked: true, editions: [{appid: -7, name: 'Half-Life 2 PS5', platform: 'PS5', source: 'MANUAL', status: 'FINISHED'}]};
answer(linked, {'DELETE /api/library/games/220/link': json(204)}, {role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail();
expect(await screen.findByRole('link', {name: 'Half-Life 2 PS5'})).toBeTruthy();
fireEvent.click(await screen.findByRole('button', {name: 'LINKS.UNLINK'}));
await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(true));
expect(toast.success).toHaveBeenCalledWith('LINKS.UNLINKED');
});
it('offersNoUnlinkWithoutALink', async () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail();
await screen.findByRole('button', {name: 'LINKS.LINK_WITH'});
expect(screen.queryByRole('button', {name: 'LINKS.UNLINK'})).toBeNull();
});
it('offers nothing to a user or to an admin viewing as user', async () => {
for (const me of [{role: 'USER', activeRole: 'USER'}, {role: 'PLATFORM_ADMIN', activeRole: 'USER'}]) {
loggedInAs(me);
@@ -333,12 +353,24 @@ describe('GameDetail of a game added by hand', () => {
await act(async () => {});
expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull();
expect(screen.queryByRole('button', {name: 'MERGE.OPEN'})).toBeNull();
expect(screen.queryByRole('button', {name: 'LINKS.UNLINK'})).toBeNull();
cleanup();
}
});
});
});
describe('GameDetail edition requests', () => {
it('opensTheSameGameDialogForAUser', async () => {
answer(game, {}, {role: 'USER', activeRole: 'USER'});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'LINKS.SAME_AS'}));
expect(await screen.findByText('LINKS.DIALOG_HINT')).toBeTruthy();
});
});
describe('GameDetail confetti', () => {
// jsdom has no canvas drawing.
beforeEach(() => {
+21 -5
View File
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js';
import {libraryQuery, parseStatus} from '../utils/backlog.js';
import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Layers, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
@@ -55,10 +55,12 @@ export default function GameLibrary() {
const q = searchParams.get('q') ?? '';
const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES);
const hiddenOnly = searchParams.get('hidden') === 'true';
const hideDuplicates = searchParams.get('hideDuplicates') === 'true';
// The hidden list stands alone, also for a hand-made URL: its status and short filters are ignored.
const listStatus = hiddenOnly ? null : status;
const listShortOnly = !hiddenOnly && shortOnly;
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly);
const listHideDuplicates = !hiddenOnly && hideDuplicates;
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || listHideDuplicates);
const [search, setSearch] = useState(q);
const [adding, setAdding] = useState(false);
// Starts true until /games-count answers, so a Steam user does not see the sync controls vanish and come back.
@@ -73,7 +75,7 @@ export default function GameLibrary() {
const signal = nextSignal();
setIsLoading(true);
setError(null);
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 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, hideDuplicates: listHideDuplicates})}`;
const {ok, status, data: json} = await request(url, {signal});
if (signal.aborted) return;
setIsLoading(false);
@@ -83,7 +85,7 @@ export default function GameLibrary() {
return;
}
setData(json);
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, listHideDuplicates]);
useEffect(() => {
load();
@@ -132,7 +134,7 @@ export default function GameLibrary() {
};
const onHiddenChange = () => {
updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, page: 0});
updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, hideDuplicates: null, page: 0});
};
const onSizeChange = (value) => {
@@ -232,6 +234,15 @@ export default function GameLibrary() {
>
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
</Button>
<Button
size="sm"
variant={listHideDuplicates ? 'default' : 'outline'}
aria-pressed={listHideDuplicates}
className="h-7 rounded-full px-3 text-xs"
onClick={() => updateParams({hideDuplicates: listHideDuplicates ? null : true, hidden: null, page: 0})}
>
<Layers className="size-3"/>{t('LIBRARY.FILTER_HIDE_DUPLICATES')}
</Button>
<Button
size="sm"
variant={hiddenOnly ? 'default' : 'outline'}
@@ -299,6 +310,11 @@ export default function GameLibrary() {
<Gamepad2 className="size-3"/>{platformLabel(g)}
</Badge>
)}
{g.editions >= 2 && (
<Badge variant="outline" className="tabular-nums" title={t('LIBRARY.EDITIONS', {count: g.editions})}>
<Layers className="size-3"/>x{g.editions}
</Badge>
)}
<GameStatusBadge status={g.status} compact/>
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
+19
View File
@@ -182,6 +182,25 @@ describe('GameLibrary short games', () => {
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_ALL'}).getAttribute('aria-pressed')).toBe('false');
});
it('showsTheEditionsBadgeOnlyFromTwo', async () => {
answer([{...page.content[0], editions: 2}, {appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', editions: null}]);
renderLibrary();
expect(await screen.findByText('x2')).toBeTruthy();
expect(screen.getAllByText(/^x\d/)).toHaveLength(1);
});
it('hideDuplicatesFilter_AddsHideDuplicatesToTheUrlAndTheRequest', async () => {
const lib = renderLibrary();
await waitFor(() => expect(fetch).toHaveBeenCalled());
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'}));
await waitFor(() => expect(lib.params().get('hideDuplicates')).toBe('true'));
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]hideDuplicates=true(&|$)/));
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'}).getAttribute('aria-pressed')).toBe('true');
});
it('leaves the hidden games with a status click', async () => {
const lib = renderLibrary('/library?hidden=true');
await waitFor(() => expect(fetch).toHaveBeenCalled());
+144
View File
@@ -0,0 +1,144 @@
import {useCallback, useEffect, useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChevronLeft, ChevronRight, Link2, Loader2, X} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import GameCover from '../components/GameCover.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {dismissPair, fetchLinkQueue, linkGames} from '../utils/links.js';
const PAGE_SIZE = 20;
function PairGame({game}) {
return (
<Link to={`/library/${game.appid}`} className="flex min-w-0 flex-1 items-center gap-2 hover:text-primary">
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 truncate text-sm font-medium">{game.name ?? `App ${game.appid}`}</span>
</Link>
);
}
/**
* AUDITOR or higher: the pairs IGDB suggests and users asked for. Core checks the role.
*/
export default function LinkQueuePage() {
const {t} = useTranslation();
const [page, setPage] = useState(0);
const [result, setResult] = useState({content: [], totalPages: 0});
const [status, setStatus] = useState('loading');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const response = await fetchLinkQueue({page, size: PAGE_SIZE});
if (response.status === 401) return;
if (response.status === 403) {
setStatus('forbidden');
return;
}
if (!response.ok) {
setStatus('error');
return;
}
const data = await response.json();
// The last pair of the last page was decided: show the page before.
if (data.content.length === 0 && page > 0) {
setPage(page - 1);
return;
}
setResult(data);
setStatus('ready');
} catch (err) {
console.error('Link queue error:', err);
setStatus('error');
}
}, [page]);
useEffect(() => {
load();
}, [load]);
const run = async (request, doneKey) => {
setBusy(true);
try {
const response = await request();
if (response.status === 401) return;
if (response.ok) {
toast.success(t(doneKey));
} else {
const data = await response.json().catch(() => ({}));
toast.error(t(data.code === 'ALREADY_LINKED' ? 'LINKS.ERROR_CODES.ALREADY_LINKED' : 'LINKS.ERROR'));
}
await load();
} catch (err) {
console.error('Link queue action error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
if (status === 'forbidden') {
return (
<PageContainer className="px-4 py-6 md:px-8">
<p className="text-sm text-muted-foreground">{t('LINKS.NO_ACCESS')}</p>
</PageContainer>
);
}
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader>
<CardTitle>{t('LINKS.QUEUE_TITLE')}</CardTitle>
</CardHeader>
<CardContent>
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('LINKS.ERROR')}</p>}
{status === 'ready' && result.content.length === 0 && (
<p className="text-sm text-muted-foreground">{t('LINKS.QUEUE_EMPTY')}</p>
)}
{status === 'ready' && result.content.length > 0 && (
<ul className="divide-y">
{result.content.map(({a, b, igdb, requests}) => (
<li key={`${a.appid}-${b.appid}`} className="flex flex-wrap items-center gap-3 py-3">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<PairGame game={a}/>
<PairGame game={b}/>
</div>
{igdb && <Badge variant="secondary">{t('LINKS.FROM_IGDB')}</Badge>}
{requests > 0 && <Badge variant="outline">{t('LINKS.REQUESTED_BY', {count: requests})}</Badge>}
<Button type="button" size="sm" disabled={busy}
onClick={() => run(() => linkGames(a.appid, b.appid), 'LINKS.LINKED')}>
<Link2/>{t('LINKS.LINK')}
</Button>
<Button type="button" variant="outline" size="sm" disabled={busy}
onClick={() => run(() => dismissPair(a.appid, b.appid), 'LINKS.DISMISSED')}>
<X/>{t('LINKS.DISMISS')}
</Button>
</li>
))}
</ul>
)}
{status === 'ready' && result.totalPages > 1 && (
<div className="flex items-center justify-between gap-2 pt-3 text-sm">
<Button type="button" variant="outline" size="sm" disabled={page === 0}
onClick={() => setPage(page - 1)}>
<ChevronLeft/>{t('ADMIN.PREVIOUS')}
</Button>
<span>{t('LINKS.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>
</PageContainer>
);
}
+95
View File
@@ -0,0 +1,95 @@
// @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 LinkQueuePage from './LinkQueuePage.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 pair = {a: {appid: 10, name: 'Dark Souls', imageUrl: null}, b: {appid: -7, name: 'Dark Souls Remastered', imageUrl: null},
igdb: true, requests: 2, createdAt: '2026-10-01T10:00:00Z'};
const queue = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages});
const renderPage = (routes) => {
fetchRoutes(routes);
render(<MemoryRouter><PreferencesProvider><LinkQueuePage/></PreferencesProvider></MemoryRouter>);
};
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('LinkQueuePage', () => {
it('listsThePairWithItsBadges', async () => {
renderPage({'GET /api/library/link-queue': queue([pair])});
expect(await screen.findByText('Dark Souls')).toBeTruthy();
expect(screen.getByText('Dark Souls Remastered')).toBeTruthy();
expect(screen.getByText('LINKS.FROM_IGDB')).toBeTruthy();
expect(screen.getByText('LINKS.REQUESTED_BY:2')).toBeTruthy();
});
it('linksThePairAndLoadsTheQueueAgain', async () => {
let pending = [pair];
renderPage({
'GET /api/library/link-queue': () => queue(pending),
'POST /api/library/games/10/link': () => {
pending = [];
return json(204);
},
});
fireEvent.click(await screen.findByRole('button', {name: 'LINKS.LINK'}));
expect(await screen.findByText('LINKS.QUEUE_EMPTY')).toBeTruthy();
const post = fetch.mock.calls.find(([, init]) => init?.method === 'POST');
expect(JSON.parse(post[1].body)).toEqual({otherAppid: -7});
});
it('dismissesThePair', async () => {
renderPage({
'GET /api/library/link-queue': queue([pair]),
'DELETE /api/library/link-queue/10/-7': json(204),
});
fireEvent.click(await screen.findByRole('button', {name: 'LINKS.DISMISS'}));
await waitFor(() => expect(fetch.mock.calls.some(([url, init]) => init?.method === 'DELETE' && url.endsWith('/link-queue/10/-7'))).toBe(true));
});
it('pagesThroughTheQueue', async () => {
renderPage({'GET /api/library/link-queue': () => queue([pair], 2)});
await screen.findByText('Dark Souls');
fireEvent.click(screen.getByRole('button', {name: /ADMIN.NEXT/}));
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toContain('page=1'));
expect(screen.getByText('LINKS.PAGE_OF')).toBeTruthy();
});
it('goesBackAPageWhenThePageIsEmpty', async () => {
renderPage({'GET /api/library/link-queue': (url) => (url.includes('page=1') ? queue([], 2) : queue([pair], 2))});
await screen.findByText('Dark Souls');
fireEvent.click(screen.getByRole('button', {name: /ADMIN.NEXT/}));
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toContain('page=0'));
expect(fetch.mock.calls.some(([url]) => url.includes('page=1'))).toBe(true);
expect(await screen.findByText('Dark Souls')).toBeTruthy();
});
it('showsNoAccessOn403', async () => {
renderPage({'GET /api/library/link-queue': json(403, {})});
expect(await screen.findByText('LINKS.NO_ACCESS')).toBeTruthy();
});
});