feat(psn): add PlayStation link card and PSN import
Build, Test & Push Frontend / quality-check (push) Successful in 34s
Build, Test & Push Frontend / docker (push) Successful in 22s

Settings card to link and unlink with the NPSSO, shows the expiry
date. Import page gets a PSN source; blocked when the link has ended.
English and German texts.
This commit is contained in:
lobo committed 2026-10-07 13:30:48 +02:00
1 parent 6ae2abee07
commit 599b083591
18 files changed
+573 -12

No files matched your search

+1 -1
View File
@@ -53,7 +53,7 @@ export default function GameDetail() {
if (data && String(data.appid) !== appid) navigate(`/library/${data.appid}`, {replace: true});
}, [data, appid, navigate]);
const manual = data?.source === 'MANUAL';
const manual = !!data && data.source !== 'STEAM';
return (
<PageContainer className="p-4 md:p-8 space-y-6">
+9
View File
@@ -240,6 +240,15 @@ describe('GameDetail of a game added by hand', () => {
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
});
it('treats a game from a PlayStation import like a game added by hand', async () => {
answer({...manualGame, source: 'PSN', platform: 'PS5'},
{'PUT /api/library/games/-1025/manual': noBody, 'DELETE /api/library/games/-1025': noBody});
renderDetail(-1025);
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
});
it('saves platform and hours', async () => {
renderDetail(-1025);
fireEvent.change(await screen.findByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2.5'}});
+4 -3
View File
@@ -14,8 +14,8 @@ import {decodeStoreList} from '../utils/storeList.js';
/**
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
* the matches and saves them. ?id= opens one import; the history below can undo a saved import. #data= is the list
* the bookmarklet read on a store page.
* the matches and saves them. ?id= opens one import; ?source=PSN opens the PlayStation input; the history below can
* undo a saved import. #data= is the list the bookmarklet read on a store page.
*/
export default function ImportPage() {
const {t} = useTranslation();
@@ -84,7 +84,8 @@ export default function ImportPage() {
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
) : (
// A store list comes after the first answer: a new key opens its tab.
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows} onCreated={(created) => {
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows}
initialSource={searchParams.get('source') === 'PSN' ? 'PSN' : null} onCreated={(created) => {
setStoreRows(null);
show(created);
loadImports();
+102
View File
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {toast} from 'sonner';
import ImportPage from './ImportPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.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(), info: vi.fn()}}));
const linked = {enabled: true, linked: true, linkedAt: '2026-10-01T10:00:00Z', expiresAt: new Date(Date.now() + 5 * 86_400_000).toISOString()};
function renderPage(routes, entry = '/import?source=PSN') {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {favoritePlatform: null}}),
'GET /api/library/imports': json(200, {imports: []}),
'GET /api/library/psn-account': json(200, linked),
...routes,
});
render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
<Route path="/settings" element={<p>settings page</p>}/>
</Routes>
</MemoryRouter>,
);
}
const startButton = () => screen.findByRole('button', {name: 'IMPORT.START'});
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('ImportPage PlayStation source', () => {
it('opens on the PlayStation tab for ?source=PSN', async () => {
renderPage({});
expect(await screen.findByText('IMPORT.PSN_HINT')).toBeTruthy();
expect(screen.getByRole('button', {name: 'IMPORT.SOURCE_PSN'}).getAttribute('aria-pressed')).toBe('true');
});
it('opens on the list without the parameter', async () => {
renderPage({}, '/import');
expect(await screen.findByLabelText('IMPORT.TEXT_LABEL')).toBeTruthy();
});
it('points to the settings when no account is linked', async () => {
renderPage({'GET /api/library/psn-account': json(200, {enabled: true, linked: false})});
expect(await screen.findByText(/IMPORT.PSN_NOT_LINKED/)).toBeTruthy();
expect(screen.queryByRole('button', {name: 'IMPORT.START'})).toBeNull();
fireEvent.click(screen.getByRole('link', {name: 'IMPORT.PSN_SETTINGS'}));
expect(await screen.findByText('settings page')).toBeTruthy();
});
it('points to the settings when the link has ended', async () => {
renderPage({'GET /api/library/psn-account': json(200, {...linked, expiresAt: '2020-01-01T00:00:00Z'})});
expect(await screen.findByText(/IMPORT.PSN_RELINK/)).toBeTruthy();
expect(screen.queryByRole('button', {name: 'IMPORT.START'})).toBeNull();
});
it('starts the import at the PlayStation endpoint without sending rows, and shows its progress', async () => {
renderPage({
'POST /api/library/psn-account/imports': json(202, {id: 9}),
'GET /api/library/imports/9': json(200, {id: 9, source: 'PSN', status: 'MATCHING', total: 4, matched: 1}),
});
fireEvent.click(await startButton());
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
const [, init] = fetch.mock.calls.find(([url, call]) => call?.method === 'POST' && url.endsWith('/psn-account/imports'));
expect(init.body).toBeUndefined();
});
it.each([
[204, {}, 'info', 'IMPORT.PSN_NO_TITLES'],
[404, {}, 'error', 'IMPORT.PSN_NOT_LINKED'],
[409, {code: 'PSN_RELINK'}, 'error', 'IMPORT.PSN_RELINK'],
[409, {code: 'IMPORT_RUNNING'}, 'error', 'IMPORT.RUNNING'],
[429, {code: 'PSN_BUSY'}, 'error', 'IMPORT.PSN_BUSY'],
[502, {}, 'error', 'IMPORT.PSN_UNAVAILABLE'],
[503, {}, 'error', 'MANUAL_GAME.UNAVAILABLE'],
])('answers status %i %j with a toast', async (status, body, kind, key) => {
renderPage({'POST /api/library/psn-account/imports': json(status, body)});
fireEvent.click(await startButton());
await waitFor(() => expect(toast[kind]).toHaveBeenCalledWith(key));
expect(screen.getByText('IMPORT.PSN_HINT')).toBeTruthy();
});
});
+3
View File
@@ -12,6 +12,7 @@ import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
import FollowsCard from '../components/FollowsCard.jsx';
import PreferencesCard from '../components/PreferencesCard.jsx';
import ProfileCard from '../components/ProfileCard.jsx';
import PsnAccountCard from '../components/PsnAccountCard.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import useSettingsLoad from '../hooks/useSettingsLoad.js';
@@ -73,6 +74,8 @@ export default function SettingsPage() {
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
<PsnAccountCard/>
<ProfileCard displayName={displayName}/>
<FollowsCard/>
+1
View File
@@ -33,6 +33,7 @@ function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
'POST /api/auth/refresh': noBody,
'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}),
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
});
renderWithProviders(<SettingsPage/>, {path: '/settings', entries: [entry]});
}
@@ -37,6 +37,7 @@ describe('SettingsPage Steam toast', () => {
'POST /api/auth/refresh': {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }},
'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}),
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
});
});
+2
View File
@@ -34,6 +34,7 @@ const authRoutes = (me) => ({
'PUT /api/users/settings/owner-lists': noBody,
'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}),
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
});
function renderPage(me) {
@@ -294,6 +295,7 @@ describe('SettingsPage session', () => {
'POST /api/auth/refresh': json(401, {}),
'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}),
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
});
const {router} = renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings'],
guarded: true, routes: {'/login': <p>login</p>}});