feat(profile): public profile page and the switch in settings

/u/<name> shows a public profile without login. Settings get the switch and
the link; every settings save now sends the other saved values, and removing
the display name also makes the profile private.
This commit is contained in:
lobo committed 2026-09-24 14:18:32 +02:00
1 parent aa5124ea1e
commit fd343da4be
7 files changed
+418 -18

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import PublicProfilePage from './PublicProfilePage.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const library = {
totalGames: 233, totalPlaytimeMinutes: 60000,
byStatus: {BACKLOG: 142, PLAYING: 6, FINISHED: 51, COMPLETED: 9, DROPPED: 25},
playing: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 300, status: 'PLAYING', rating: null}],
finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}],
};
function mockBackends({profileStatus = 200, libraryStatus = 200} = {}) {
globalThis.fetch = vi.fn(async (url) => {
if (url.includes('/api/public/profiles/library')) {
return {ok: libraryStatus === 200, status: libraryStatus, json: async () => library};
}
return {ok: profileStatus === 200, status: profileStatus,
json: async () => ({displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket'})};
});
}
const renderPage = (name = 'Lobo') => render(
<MemoryRouter initialEntries={[`/u/${name}`]}>
<Routes><Route path="/u/:name" element={<PublicProfilePage/>}/></Routes>
</MemoryRouter>,
);
describe('PublicProfilePage', () => {
afterEach(cleanup);
it('asks Auth for the ticket, then Core for the library with it', async () => {
mockBackends();
renderPage('Lo bo');
expect(await screen.findByText('Portal 2')).toBeTruthy();
expect(fetch.mock.calls[0][0]).toMatch(/\/api\/public\/profiles\/Lo%20bo$/);
expect(fetch.mock.calls[1][1].headers['X-Profile-Ticket']).toBe('the-ticket');
expect(screen.getByText('Lobo')).toBeTruthy();
expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.getByText('233')).toBeTruthy();
});
it('says the profile is unknown or private on a 404', async () => {
mockBackends({profileStatus: 404});
renderPage();
expect(await screen.findByText('PROFILE.NOT_FOUND')).toBeTruthy();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('shows an error when Core fails', async () => {
mockBackends({libraryStatus: 500});
renderPage();
await waitFor(() => expect(screen.getByText('PROFILE.ERROR_LOAD')).toBeTruthy());
});
});