Files
ToBePlayed-Frontend/src/pages/SettingsPage.test.jsx
T
lobo 599b083591
Build, Test & Push Frontend / quality-check (push) Successful in 34s
Build, Test & Push Frontend / docker (push) Successful in 22s
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.
2026-10-07 13:30:48 +02:00

334 lines
15 KiB
React

// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
import SettingsPage from './SettingsPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
import {toast} from 'sonner';
vi.mock('react-i18next', async () => {
const mock = await import('../test/i18n-mock.js');
const i18n = {language: 'de', changeLanguage: vi.fn()};
return {...mock, useTranslation: () => ({t: mock.useTranslation().t, i18n})};
});
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const profile = (overrides = {}) => ({
id: 1, email: 'a@b.de', role: 'USER', displayName: 'Lobo', steamId: '76561198005679788',
settings: {useDarkTheme: true, language: 'de', publicProfile: true}, ...overrides,
});
const appearanceCard = () => within(screen.getByText('SETTINGS.APPEARANCE').closest('[data-slot="card"]'));
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
// Every PUT answers 204 without a body, like Auth does.
const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }};
const authRoutes = (me) => ({
'GET /api/users/me': () => json(200, me()),
'PUT /api/users/settings': noBody,
'PUT /api/users/settings/appearance': noBody,
'PUT /api/users/settings/profile-style': noBody,
'PUT /api/users/settings/favorite-platform': noBody,
'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) {
fetchRoutes(authRoutes(() => me));
renderWithProviders(<SettingsPage/>);
}
/**
* Auth with PUT /api/users/settings answered one by one through answer(); GET /api/users/me shows what was saved.
*/
function fakeAuth(me) {
const saved = structuredClone(me);
const open = [];
fetchRoutes({
...authRoutes(() => structuredClone(saved)),
'PUT /api/users/settings': (url, init) => new Promise((resolve) => open.push(() => {
const body = JSON.parse(init.body);
saved.displayName = body.displayName;
Object.assign(saved.settings, {useDarkTheme: body.useDarkTheme, language: body.language, publicProfile: body.publicProfile});
resolve(noBody);
})),
});
return {answer: () => open.shift()()};
}
describe('SettingsPage public profile', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('disablesTheOwnerListSwitchWhileTheProfileIsPrivate', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false, showInOwnerLists: true}}));
const toggle = await screen.findByRole('switch', {name: 'SETTINGS.OWNER_LISTS'});
expect(toggle.disabled).toBe(true);
expect(toggle.getAttribute('aria-checked')).toBe('false');
});
it('savesTheOwnerListSwitchThroughItsOwnEndpoint', async () => {
renderPage(profile());
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.OWNER_LISTS'}));
await waitFor(() => expect(screen.getByRole('switch', {name: 'SETTINGS.OWNER_LISTS'}).getAttribute('aria-checked')).toBe('true'));
const call = fetch.mock.calls.find(([url]) => url.endsWith('/settings/owner-lists'));
expect(call[1].method).toBe('PUT');
expect(JSON.parse(call[1].body)).toEqual({enabled: true});
expect(puts().filter((body) => 'useDarkTheme' in body)).toHaveLength(0);
});
it('keeps the public profile when another setting is saved', async () => {
renderPage(profile());
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
await waitFor(() => expect(puts()).toHaveLength(1));
expect(puts()[0].publicProfile).toBe(true);
});
it('builds a save on the one before it, also while that one is still open', async () => {
const auth = fakeAuth(profile());
renderWithProviders(<SettingsPage/>);
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
await waitFor(() => expect(puts()).toHaveLength(1));
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
await act(async () => auth.answer());
await waitFor(() => expect(puts()).toHaveLength(2));
expect(puts()[1]).toMatchObject({publicProfile: false, useDarkTheme: false, displayName: 'Lobo'});
await act(async () => auth.answer());
});
it('links to the welcome page', async () => {
renderPage(profile());
expect((await screen.findByRole('link', {name: 'SETTINGS.OPEN_WELCOME'})).getAttribute('href')).toBe('/welcome');
});
it('turns the public profile off and shows the link while it is on', async () => {
renderPage(profile());
expect(await screen.findByText(/\/u\/Lobo$/)).toBeTruthy();
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
await waitFor(() => expect(puts()).toHaveLength(1));
expect(puts()[0]).toMatchObject({publicProfile: false, displayName: 'Lobo'});
await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull());
});
const styled = (profileStyle) => profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStyle}});
const styleGroup = (label) => within(screen.getAllByRole('group', {name: label})[1]);
const stylePuts = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body));
it('saves the profile accent apart from the app accent', async () => {
renderPage(styled({accent: 'blue', corners: 'sharp', bannerAppid: 620, backgroundAppid: null}));
await screen.findByText('SETTINGS.PROFILE_STYLE');
fireEvent.click(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}));
await waitFor(() => expect(stylePuts()).toEqual([{accent: 'red', corners: 'sharp', bannerAppid: 620,
backgroundAppid: null}]));
expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'})
.getAttribute('aria-pressed')).toBe('true');
});
it('points to the profile page for the tiles', async () => {
renderPage(profile());
expect(await screen.findByText('SETTINGS.PROFILE_TILES_HINT')).toBeTruthy();
});
it('removes the profile banner', async () => {
renderPage(styled({bannerAppid: 620}));
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PROFILE_ART_REMOVE'}));
await waitFor(() => expect(stylePuts()).toHaveLength(1));
expect(stylePuts()[0].bannerAppid).toBeNull();
});
it('offers the profile look only while the profile is public', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
await screen.findByText('SETTINGS.PROFILE_STYLE');
expect(screen.queryByText('SETTINGS.PROFILE_TILES_HINT')).toBeNull();
expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}).disabled).toBe(true);
});
it('needs a display name first', async () => {
renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
const toggle = await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'});
expect(toggle.disabled).toBe(true);
expect(screen.getByText('SETTINGS.PUBLIC_PROFILE_NEEDS_NAME')).toBeTruthy();
});
it('turns the public profile off when the display name is removed', async () => {
renderPage(profile());
const name = await screen.findByLabelText('SETTINGS.DISPLAY_NAME');
fireEvent.change(name, {target: {value: ''}});
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.SAVE_PROFILE'}));
await waitFor(() => expect(puts()).toHaveLength(1));
expect(puts()[0]).toMatchObject({displayName: null, publicProfile: false});
await waitFor(() => expect(screen.getByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}).getAttribute('aria-checked')).toBe('false'));
});
});
describe('SettingsPage appearance', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
const appearancePuts = () => fetch.mock.calls
.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/api/users/settings/appearance'))
.map(([, init]) => JSON.parse(init.body));
it('saves a picked accent and corner style and marks them as picked', async () => {
renderPage(profile());
await screen.findByText('SETTINGS.APPEARANCE');
fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}));
fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(2));
expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true, reduceMotion: false});
expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true');
expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false');
});
it('saves the reduce animations switch', async () => {
renderPage(profile());
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.REDUCE_MOTION'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(1));
expect(appearancePuts()[0].reduceMotion).toBe(true);
});
it('removes the background', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620}));
renderPage(profile());
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.BACKGROUND_REMOVE'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(1));
expect(appearancePuts()[0].backgroundAppid).toBeNull();
expect(screen.getByText('SETTINGS.BACKGROUND_HINT')).toBeTruthy();
});
it('turns the banner off and goes back to the automatic banner', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620}));
renderPage(profile());
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.BANNER_AUTO'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(1));
expect(appearancePuts()[0]).toMatchObject({bannerAppid: null, showBanner: true});
expect(screen.getByText('SETTINGS.BANNER_HINT')).toBeTruthy();
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.BANNER'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(2));
expect(appearancePuts()[1].showBanner).toBe(false);
});
});
describe('SettingsPage favorite platform', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
// Radix Select needs these in jsdom.
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.scrollIntoView ??= () => {};
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('shows the saved favorite and saves a new one, or none', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, favoritePlatform: 'PS5'}}));
const trigger = await screen.findByRole('combobox', {name: 'SETTINGS.FAVORITE_PLATFORM'});
expect(trigger.textContent).toContain('PlayStation 5');
fireEvent.pointerDown(trigger, {button: 0, ctrlKey: false, pointerType: 'mouse'});
fireEvent.click(await screen.findByRole('option', {name: 'Nintendo Switch 2'}));
await waitFor(() => expect(puts()).toEqual([{platform: 'Switch 2'}]));
expect(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[0]).toContain('/api/users/settings/favorite-platform');
fireEvent.pointerDown(trigger, {button: 0, ctrlKey: false, pointerType: 'mouse'});
fireEvent.click(await screen.findByRole('option', {name: 'SETTINGS.FAVORITE_PLATFORM_NONE'}));
await waitFor(() => expect(puts()).toEqual([{platform: 'Switch 2'}, {platform: null}]));
});
});
describe('SettingsPage session', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
toast.error.mockClear();
fetchRoutes({
'GET /api/users/me': json(401, {}),
'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>}});
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/settings'});
expect(toast.error).not.toHaveBeenCalled();
expect(screen.queryByText('INFO.ERROR.LOAD_PROFILE')).toBeNull();
});
});
describe('SettingsPage load order', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('shows the display name from its own load when the two /me answers arrive one after the other', async () => {
const open = [];
fetchRoutes({
...authRoutes(() => profile()),
'GET /api/users/me': () => new Promise((resolve) => open.push(() => resolve(json(200, profile())))),
});
renderWithProviders(<SettingsPage/>);
await waitFor(() => expect(open).toHaveLength(2));
await act(async () => open.shift()());
await act(async () => open.shift()());
expect(await screen.findByText('a@b.de')).toBeTruthy();
expect(screen.getByLabelText('SETTINGS.DISPLAY_NAME').value).toBe('Lobo');
});
});