Public Access
feat(psn): add PlayStation link card and PSN import
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:
1 parent
6ae2abee07
commit
599b083591
18 files changed
+573
-12
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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'}});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/>
|
||||
|
||||
@@ -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}),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>}});
|
||||
|
||||
Reference in new issue
Block a user