Public Access
feat(library): add game links, auditor queue, audit log and filter
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:
1 parent
80259cb0e9
commit
f71e8f7aff
21 files changed
+992
-11
No files matched your search
@@ -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/>}/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
};
|
||||
Reference in new issue
Block a user