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

+7
View File
@@ -40,6 +40,10 @@ To Be Played is split into three repositories:
finished games per year of all users.
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements.
- **Editions**: an auditor links the editions of one game (Steam and PS5 copy, remaster, port) for every user. A user
who owns two or more of a linked set sees "Also in your library" on the details page, an "x2" badge in the library
and the filter "Hide duplicates"; the numbers count the set once. "Same game as..." on the details page asks the
auditors for a link.
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several
hits to add them at once through the import review.
@@ -123,6 +127,9 @@ To Be Played is split into three repositories:
speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own.
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
every user.
- **Link queue** (`/audit/links`, AUDITOR or higher): the pairs IGDB suggests and users asked for, with "requested by N
players"; Link or Dismiss; paged, 20 pairs a page. **Audit log** (`/audit/log`): the newest merges, links, unlinks and dismissals with who
did it, filter by action, paged.
- **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the
account at once and removes it after 30 days; the page ends the session and goes to the landing page.
- **Build version** in the settings: commit and build time of Frontend, Auth and Core, each hash links to its
+54 -2
View File
@@ -229,7 +229,9 @@
"FILTER_SHORT": "Kurz (bis 10 h)",
"FILTER_HIDDEN": "Ausgeblendet",
"NO_HIDDEN": "Keine ausgeblendeten Spiele. Blende ein Spiel auf seiner Detailseite aus, z. B. einen Playtest oder ein Tool.",
"ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen"
"ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen",
"FILTER_HIDE_DUPLICATES": "Duplikate ausblenden",
"EDITIONS": "{{count}} Ausgaben in deiner Bibliothek"
},
"GAME_DETAIL": {
"BACK": "Zurück",
@@ -319,7 +321,9 @@
"ADMIN_NEWS": "Neuigkeiten verwalten",
"ADMIN_BROADCASTS": "Rundmail",
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet",
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten"
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten",
"LINK_QUEUE": "Verknüpfungen",
"AUDIT_LOG": "Protokoll"
},
"BACKLOG": {
"TITLE": "Mein Backlog",
@@ -622,6 +626,54 @@
"OTHER": "Die Spiele konnten nicht zusammengeführt werden."
}
},
"LINKS": {
"ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:",
"SAME_AS": "Dasselbe Spiel wie...",
"LINK_WITH": "Verknüpfen mit...",
"DIALOG_HINT": "Wähle das Spiel deiner Bibliothek, das dasselbe Spiel wie \"{{name}}\" ist.",
"SEARCH_PLACEHOLDER": "Deine Bibliothek durchsuchen ...",
"NO_HITS": "Kein Spiel in deiner Bibliothek gefunden.",
"REQUEST_CONFIRM": "Einen Auditor bitten, \"{{name}}\" und \"{{other}}\" für alle als ein Spiel zu verknüpfen?",
"LINK_CONFIRM": "\"{{name}}\" und \"{{other}}\" für alle Nutzer als ein Spiel verknüpfen?",
"REQUEST": "Anfrage senden",
"LINK": "Verknüpfen",
"UNLINK": "Verknüpfung lösen",
"DISMISS": "Nicht dasselbe",
"REQUESTED": "Anfrage gesendet",
"LINKED": "Spiele verknüpft",
"UNLINKED": "Verknüpfung gelöst",
"DISMISSED": "Paar verworfen",
"QUEUE_TITLE": "Verknüpfungen prüfen",
"QUEUE_EMPTY": "Nichts zu prüfen.",
"PAGE_OF": "Seite {{current}} von {{total}}",
"FROM_IGDB": "IGDB",
"REQUESTED_BY": "von {{count}} Spielern angefragt",
"NO_ACCESS": "Nur Auditoren können diese Seite öffnen.",
"ERROR": "Die Aktion ist fehlgeschlagen.",
"ERROR_CODES": {
"SAME_GAME": "Ein Spiel kann nicht mit sich selbst verknüpft werden.",
"ALREADY_LINKED": "Diese Spiele sind schon verknüpft.",
"PAIR_DISMISSED": "Ein Auditor hat bereits entschieden, dass das verschiedene Spiele sind.",
"TOO_MANY_REQUESTS": "Du hast 20 offene Anfragen. Warte, bis ein Auditor sie entschieden hat.",
"GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek."
}
},
"AUDIT": {
"TITLE": "Protokoll",
"EMPTY": "Keine Einträge.",
"NO_ACCESS": "Nur Auditoren können diese Seite öffnen.",
"LOAD_ERROR": "Das Protokoll konnte nicht geladen werden.",
"USER_ID": "Nutzer {{id}}",
"PAGE_OF": "Seite {{current}} von {{total}}",
"ACTION": {
"LABEL": "Aktion",
"ALL": "Alle Aktionen",
"GAME_MERGE": "Zusammengeführt",
"GAME_LINK": "Verknüpft",
"GAME_UNLINK": "Verknüpfung gelöst",
"GAME_LINK_DISMISS": "Verworfen"
}
},
"HINT": {
"CLOSE": "Schließen",
"DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen",
+54 -2
View File
@@ -229,7 +229,9 @@
"FILTER_SHORT": "Short (up to 10 h)",
"FILTER_HIDDEN": "Hidden",
"NO_HIDDEN": "No hidden games. Hide a game on its detail page, e.g. a playtest or a tool.",
"ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements"
"ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements",
"FILTER_HIDE_DUPLICATES": "Hide duplicates",
"EDITIONS": "{{count}} editions in your library"
},
"GAME_DETAIL": {
"BACK": "Back",
@@ -319,7 +321,9 @@
"ADMIN_NEWS": "News",
"ADMIN_BROADCASTS": "Mail",
"PENDING_REQUESTS_one": "{{count}} registration waits",
"PENDING_REQUESTS_other": "{{count}} registrations wait"
"PENDING_REQUESTS_other": "{{count}} registrations wait",
"LINK_QUEUE": "Link queue",
"AUDIT_LOG": "Audit log"
},
"BACKLOG": {
"TITLE": "My backlog",
@@ -622,6 +626,54 @@
"OTHER": "The games could not be merged."
}
},
"LINKS": {
"ALSO_IN_LIBRARY": "Also in your library:",
"SAME_AS": "Same game as...",
"LINK_WITH": "Link with...",
"DIALOG_HINT": "Pick the game of your library that is the same game as \"{{name}}\".",
"SEARCH_PLACEHOLDER": "Search your library ...",
"NO_HITS": "No game found in your library.",
"REQUEST_CONFIRM": "Ask an auditor to link \"{{name}}\" and \"{{other}}\" as one game for everyone?",
"LINK_CONFIRM": "Link \"{{name}}\" and \"{{other}}\" as one game for every user?",
"REQUEST": "Send request",
"LINK": "Link",
"UNLINK": "Unlink",
"DISMISS": "Not the same",
"REQUESTED": "Request sent",
"LINKED": "Games linked",
"UNLINKED": "Link removed",
"DISMISSED": "Pair dismissed",
"QUEUE_TITLE": "Link queue",
"QUEUE_EMPTY": "Nothing to check.",
"PAGE_OF": "Page {{current}} of {{total}}",
"FROM_IGDB": "IGDB",
"REQUESTED_BY": "requested by {{count}} players",
"NO_ACCESS": "Only auditors can open this page.",
"ERROR": "The action failed.",
"ERROR_CODES": {
"SAME_GAME": "A game cannot be linked with itself.",
"ALREADY_LINKED": "These games are already linked.",
"PAIR_DISMISSED": "An auditor already decided that these are different games.",
"TOO_MANY_REQUESTS": "You have 20 open requests. Wait until an auditor decided them.",
"GAME_NOT_FOUND": "This game is not in your library."
}
},
"AUDIT": {
"TITLE": "Audit log",
"EMPTY": "No entries.",
"NO_ACCESS": "Only auditors can open this page.",
"LOAD_ERROR": "The audit log could not be loaded.",
"USER_ID": "user {{id}}",
"PAGE_OF": "Page {{current}} of {{total}}",
"ACTION": {
"LABEL": "Action",
"ALL": "All actions",
"GAME_MERGE": "Merge",
"GAME_LINK": "Link",
"GAME_UNLINK": "Unlink",
"GAME_LINK_DISMISS": "Dismissed"
}
},
"HINT": {
"CLOSE": "Close",
"DO_NOT_SHOW_AGAIN": "Do not show again",
+4
View File
@@ -16,6 +16,8 @@ import WhatsNewPage from './pages/WhatsNewPage.jsx';
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 LinkQueuePage from './pages/LinkQueuePage.jsx';
import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx';
import WishlistPage from './pages/WishlistPage.jsx';
@@ -85,6 +87,8 @@ function App() {
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/whats-new" element={<WhatsNewPage/>}/>
<Route path="/welcome" element={<WelcomePage/>}/>
<Route path="/audit/links" element={<LinkQueuePage/>}/>
<Route path="/audit/log" element={<AuditLogPage/>}/>
<Route path="/admin/users" element={<AdminUsersPage/>}/>
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
<Route path="/admin/news" element={<AdminNewsPage/>}/>
+12 -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, LogOut, Mail, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react';
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Settings, Shield, Sun} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
@@ -24,6 +24,7 @@ import SteamAvatar from './SteamAvatar.jsx';
import useCurrentUser from '../hooks/useCurrentUser.js';
import useSteamProfile from '../hooks/useSteamProfile.js';
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
import {canCorrect} from '../utils/corrections.js';
import {expireSession, markSession} from '../utils/auth.js';
const NAV_ITEMS = [
@@ -155,6 +156,16 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<DropdownMenuItem asChild>
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
</DropdownMenuItem>
{canCorrect(currentUser) && (
<>
<DropdownMenuItem asChild>
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/audit/log"><ScrollText/>{t('NAV.AUDIT_LOG')}</Link>
</DropdownMenuItem>
</>
)}
{seesAdminPage && (
<>
<DropdownMenuItem asChild>
+11
View File
@@ -88,6 +88,16 @@ describe('AppHeader', () => {
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
});
it('offersTheLinkQueueAndAuditLogToAnAuditorOnly', 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(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
});
it('offers nothing of that to a user', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
@@ -97,6 +107,7 @@ describe('AppHeader', () => {
expect(await screen.findByText('NAV.SETTINGS')).toBeTruthy();
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
expect(screen.queryByText('NAV.LINK_QUEUE')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
+72
View File
@@ -0,0 +1,72 @@
import {useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Layers, Link2Off} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameStatusBadge from './GameStatusBadge.jsx';
import {unlinkGame} from '../utils/links.js';
import {platformLabel} from '../utils/platforms.js';
/**
* The other editions of this game in the own library, and the buttons to ask for a link (everyone) or to unlink
* (auditor). Shown on every game: a request must be possible from any game.
*/
export default function EditionsCard({game, auditor, onRequest, onChanged}) {
const {t} = useTranslation();
const [busy, setBusy] = useState(false);
const unlink = async () => {
setBusy(true);
try {
const response = await unlinkGame(game.appid);
if (response.status === 401) return;
if (response.ok) {
toast.success(t('LINKS.UNLINKED'));
onChanged();
} else {
toast.error(t('LINKS.ERROR'));
}
} catch (err) {
console.error('Unlink error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<Card className="gap-3 p-4">
{game.editions?.length > 0 && (
<div className="space-y-2 text-sm">
<p className="font-medium">{t('LINKS.ALSO_IN_LIBRARY')}</p>
<ul className="space-y-1">
{game.editions.map((edition) => (
<li key={edition.appid} className="flex flex-wrap items-center gap-2">
<Link to={`/library/${edition.appid}`} className="hover:text-primary hover:underline">
{edition.name}
</Link>
{platformLabel(edition) && (
<span className="text-muted-foreground">({platformLabel(edition)})</span>
)}
<GameStatusBadge status={edition.status} compact/>
</li>
))}
</ul>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={onRequest}>
<Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
</Button>
{auditor && game.linked && (
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={unlink}>
<Link2Off/>{t('LINKS.UNLINK')}
</Button>
)}
</div>
</Card>
);
}
+129
View File
@@ -0,0 +1,129 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {ArrowLeft, Link2, Loader2, Search, Send} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import GameCover from './GameCover.jsx';
import {linkGames, requestLink, searchOwnGames} from '../utils/links.js';
import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
import {platformLabel} from '../utils/platforms.js';
const SEARCH_DEBOUNCE_MS = 300;
const KNOWN_ERRORS = new Set(['SAME_GAME', 'ALREADY_LINKED', 'PAIR_DISMISSED', 'TOO_MANY_REQUESTS', 'GAME_NOT_FOUND']);
/**
* Picks a game of the own library that is the same game as this one. A user sends a request to the auditors; an
* auditor links at once.
*/
export default function LinkGameDialog({game, open, onOpenChange, auditor, onDone}) {
const {t} = useTranslation();
const [query, setQuery] = useState('');
const [hits, setHits] = useState([]);
const [picked, setPicked] = useState(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (open) {
setQuery('');
setHits([]);
setPicked(null);
}
}, [open]);
useEffect(() => {
if (!open || !query.trim()) {
setHits([]);
return undefined;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
const {ok, data} = await searchOwnGames(query.trim(), controller.signal);
if (ok && data) setHits(data.content);
}, SEARCH_DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [open, query]);
const send = async () => {
setBusy(true);
try {
const response = await (auditor ? linkGames : requestLink)(game.appid, picked.appid);
if (response.status === 401) return;
if (response.ok) {
toast.success(t(auditor ? 'LINKS.LINKED' : 'LINKS.REQUESTED'));
onOpenChange(false);
if (auditor) onDone();
return;
}
const data = await response.json().catch(() => ({}));
toast.error(t(KNOWN_ERRORS.has(data.code) ? `LINKS.ERROR_CODES.${data.code}` : 'LINKS.ERROR'));
} catch (err) {
console.error('Link error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const offered = hits.filter((hit) => hit.appid !== game.appid);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('LINKS.SAME_AS')}</DialogTitle>
<DialogDescription>{t('LINKS.DIALOG_HINT', {name: game.name})}</DialogDescription>
</DialogHeader>
{picked ? (
<div className="space-y-4">
<p className="text-sm">
{t(auditor ? 'LINKS.LINK_CONFIRM' : 'LINKS.REQUEST_CONFIRM', {name: game.name, other: picked.name})}
</p>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={busy} onClick={() => setPicked(null)}>
<ArrowLeft/>{t('MERGE.BACK')}
</Button>
<Button type="button" disabled={busy} onClick={send}>
{busy ? <Loader2 className="animate-spin"/> : auditor ? <Link2/> : <Send/>}
{t(auditor ? 'LINKS.LINK' : 'LINKS.REQUEST')}
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<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" autoFocus value={query} maxLength={MAX_LIBRARY_SEARCH_LENGTH} className="pl-8"
placeholder={t('LINKS.SEARCH_PLACEHOLDER')} aria-label={t('LINKS.SEARCH_PLACEHOLDER')}
onChange={(e) => setQuery(e.target.value)}/>
</div>
{query.trim() && offered.length === 0 && (
<p className="text-sm text-muted-foreground">{t('LINKS.NO_HITS')}</p>
)}
{offered.length > 0 && (
<ul className="divide-y rounded-md border">
{offered.map((hit) => (
<li key={hit.appid}>
<button type="button" onClick={() => setPicked(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{hit.name}</span>
{platformLabel(hit) && (
<span className="text-xs text-muted-foreground">{platformLabel(hit)}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}
+79
View File
@@ -0,0 +1,79 @@
// @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 {toast} from 'sonner';
import LinkGameDialog from './LinkGameDialog.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 hades = {appid: 1145360, name: 'Hades'};
const owned = [
{appid: 1145360, name: 'Hades', source: 'STEAM'},
{appid: -113112, name: 'Hades (Switch)', source: 'MANUAL', platform: 'Switch'},
];
function renderDialog({auditor = false, post}) {
fetchRoutes({
'GET /api/library/games': json(200, {content: owned}),
'POST /api/library/games/1145360/link-requests': post,
'POST /api/library/games/1145360/link': post,
});
const onDone = vi.fn();
render(<MemoryRouter><LinkGameDialog game={hades} open onOpenChange={vi.fn()} auditor={auditor} onDone={onDone}/></MemoryRouter>);
return onDone;
}
const pickSwitch = async () => {
fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'hades'}});
// The own game is not offered.
fireEvent.click(await screen.findByRole('button', {name: /Hades \(Switch\)/}));
expect(screen.queryByRole('button', {name: /^Hades$/})).toBeNull();
};
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('LinkGameDialog', () => {
it('sendsARequestAfterTheConfirmStep', async () => {
renderDialog({post: json(204)});
await pickSwitch();
expect(screen.getByText('LINKS.REQUEST_CONFIRM')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'LINKS.REQUEST'}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('LINKS.REQUESTED'));
const [url, init] = fetch.mock.calls.at(-1);
expect(url).toContain('/api/library/games/1145360/link-requests');
expect(JSON.parse(init.body)).toEqual({otherAppid: -113112});
});
it('linksAtOnceForAnAuditorAndCallsOnDone', async () => {
const onDone = renderDialog({auditor: true, post: json(204)});
await pickSwitch();
fireEvent.click(screen.getByRole('button', {name: 'LINKS.LINK'}));
await waitFor(() => expect(onDone).toHaveBeenCalled());
expect(fetch.mock.calls.at(-1)[0]).toMatch(/\/games\/1145360\/link$/);
expect(toast.success).toHaveBeenCalledWith('LINKS.LINKED');
});
it('showsTheTooManyRequestsText', async () => {
renderDialog({post: json(429, {code: 'TOO_MANY_REQUESTS'})});
await pickSwitch();
fireEvent.click(screen.getByRole('button', {name: 'LINKS.REQUEST'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR_CODES.TOO_MANY_REQUESTS'));
});
});
+10
View File
@@ -31,3 +31,13 @@ describe('admin broadcast texts', () => {
}
});
});
describe('translation keys', () => {
it('de_And_En_HaveTheSameKeys', () => {
const dir = path.resolve(import.meta.dirname, '../public/i18n');
const keys = (value, prefix = '') => Object.entries(value).flatMap(([key, child]) =>
(typeof child === 'object' ? keys(child, `${prefix}${key}.`) : [`${prefix}${key}`]));
const read = (file) => keys(JSON.parse(readFileSync(path.join(dir, file), 'utf8'))).sort();
expect(read('de.json')).toEqual(read('en.json'));
});
});
+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();
});
});
+2 -1
View File
@@ -56,9 +56,10 @@ export const dismissFinishedHint = (appid, dismissed) =>
body: JSON.stringify({dismissed}),
});
export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden}) => {
export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden, hideDuplicates}) => {
const params = new URLSearchParams({page: String(page), size: String(size), sort});
if (hidden) params.set('hidden', 'true');
if (hideDuplicates) params.set('hideDuplicates', 'true');
if (status) params.set('status', status);
if (q?.trim()) params.set('q', q.trim());
if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
+8
View File
@@ -48,3 +48,11 @@ describe('backlogShare', () => {
expect(backlogShare(null)).toBe(0);
});
});
describe('libraryQuery hideDuplicates', () => {
it('libraryQuery_SetsHideDuplicatesOnlyWhenAsked', () => {
const base = {page: 0, size: 20, sort: 'appid,asc', status: null, q: ''};
expect(libraryQuery({...base, hideDuplicates: true})).toBe('page=0&size=20&sort=appid%2Casc&hideDuplicates=true');
expect(libraryQuery({...base, hideDuplicates: false})).not.toContain('hideDuplicates');
});
});
+49
View File
@@ -0,0 +1,49 @@
import {apiFetch, request} from './api.js';
import {libraryQuery} from './backlog.js';
const JSON_HEADERS = {'Content-Type': 'application/json'};
const base = () => `${import.meta.env.VITE_CORE_API_URL}/api/library`;
/**
* GET: 200 `{content: [{a, b, igdb, requests, createdAt}], totalPages}` (a and b are `{appid, name, imageUrl}`), 403
* below AUDITOR.
*/
export const fetchLinkQueue = ({page, size}) => apiFetch(`${base()}/link-queue?page=${page}&size=${size}`);
/**
* POST: 204, 400 SAME_GAME, 403 below AUDITOR, 404 GAME_NOT_FOUND, 409 ALREADY_LINKED.
*/
export const linkGames = (appid, otherAppid) =>
apiFetch(`${base()}/games/${appid}/link`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({otherAppid})});
/**
* DELETE: 204, 404 NOT_LINKED.
*/
export const unlinkGame = (appid) => apiFetch(`${base()}/games/${appid}/link`, {method: 'DELETE'});
/**
* DELETE: 204, 404 PAIR_NOT_OPEN.
*/
export const dismissPair = (appid, otherAppid) => apiFetch(`${base()}/link-queue/${appid}/${otherAppid}`, {method: 'DELETE'});
/**
* POST: 204, 400 SAME_GAME, 404 GAME_NOT_FOUND, 409 ALREADY_LINKED or PAIR_DISMISSED, 429 TOO_MANY_REQUESTS.
*/
export const requestLink = (appid, otherAppid) =>
apiFetch(`${base()}/games/${appid}/link-requests`,
{method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({otherAppid})});
/**
* The user's own games that match the query, as a library page `{content: [...]}`.
*/
export const searchOwnGames = (q, signal) =>
request(`${base()}/games?${libraryQuery({page: 0, size: 20, sort: 'playtimeMinutes,desc', q})}`, {signal});
/**
* GET: 200 `{content: [AuditEntry], totalPages}`, 403 below AUDITOR. An empty action means all.
*/
export const fetchAuditLog = ({action, page, size}) => {
const params = new URLSearchParams({page: String(page), size: String(size)});
if (action) params.set('action', action);
return apiFetch(`${base()}/audit-log?${params}`);
};