feat(settings): link the Steam account through Steam
Build, Test & Push Frontend / quality-check (push) Successful in 16s
Build, Test & Push Frontend / docker (push) Successful in 25s

A Steam card in the settings links, verifies or unlinks the Steam
account through "Sign in through Steam". After the way back the page
shows the outcome and refreshes the session, so Core gets the new
Steam ID at once. Registration and settings no longer ask for a typed
Steam ID.
This commit is contained in:
lobo committed 2026-09-26 11:20:04 +02:00
1 parent 23ab5a91e7
commit ca75e6a530
13 files changed
+393 -86

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {BadgeCheck, CircleAlert, Gamepad2, Link2, Loader2, Unlink} 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 useSessionExpired from '../hooks/useSessionExpired.js';
import {markSession} from '../utils/auth.js';
import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
/**
* The linked Steam account. A Steam ID comes only from "Sign in through Steam"; one typed in before that is shown
* as not verified.
*/
export default function SteamAccountCard({steamId, verified, onUnlinked}) {
const {t} = useTranslation();
const expireSession = useSessionExpired();
const [busy, setBusy] = useState(false);
const link = async () => {
setBusy(true);
try {
const response = await startSteamLink();
if (response.ok) return; // on the way to Steam: the button stays busy
if (response.status === 401) {
expireSession();
} else {
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
}
} catch (err) {
console.error('Steam link error:', err);
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
}
setBusy(false);
};
const unlink = async () => {
setBusy(true);
try {
const response = await unlinkSteam();
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
return;
}
// The new access cookie has no Steam ID; the new stamp makes the header load again.
markSession();
toast.success(t('SETTINGS.STEAM_LINK.UNLINKED'));
onUnlinked();
} catch (err) {
console.error('Steam unlink error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.STEAM')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{steamId ? (
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">{steamId}</span>
{verified ? (
<Badge variant="outline"><BadgeCheck className="size-3"/>{t('SETTINGS.STEAM_LINK.VERIFIED')}</Badge>
) : (
<Badge variant="outline"><CircleAlert className="size-3"/>{t('SETTINGS.STEAM_LINK.NOT_VERIFIED')}</Badge>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">{t('SETTINGS.STEAM_LINK.NONE')}</p>
)}
{steamId && !verified && (
<p className="text-sm text-muted-foreground">{t('SETTINGS.STEAM_LINK.VERIFY_HINT')}</p>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant={steamId && verified ? 'outline' : 'default'} onClick={link} disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <Link2/>}
{t(!steamId ? 'SETTINGS.STEAM_LINK.LINK' : verified ? 'SETTINGS.STEAM_LINK.CHANGE' : 'SETTINGS.STEAM_LINK.VERIFY')}
</Button>
{steamId && (
<Button type="button" variant="outline" onClick={unlink} disabled={busy}>
<Unlink/>{t('SETTINGS.STEAM_LINK.UNLINK')}
</Button>
)}
</div>
</CardContent>
</Card>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import {sessionStamp} from '../utils/auth.js';
/**
* The user's Steam name and avatar from Core, or null while loading, without a Steam ID or on any error. Only
* decoration, so a failed call shows the default icon and never ends the session. Loads again after a new login
* or a settings save (see sessionStamp) on the next render, e.g. the next navigation.
* or a Steam link or unlink (see sessionStamp) on the next render, e.g. the next navigation.
*/
export default function useSteamProfile(enabled) {
const [profile, setProfile] = useState(null);
-13
View File
@@ -17,7 +17,6 @@ import {clientFetch} from '../utils/api.js';
const BACKEND_ERROR_KEY = {
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE',
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_IN_USE',
TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS',
};
@@ -38,7 +37,6 @@ export default function RegistrationPage() {
password: '',
passwordConfirmation: '',
inviteCode: '',
steamId: '',
displayName: '',
},
});
@@ -54,7 +52,6 @@ export default function RegistrationPage() {
email: values.email,
password: values.password,
inviteCode: values.inviteCode,
steamId: values.steamId,
displayName: values.displayName,
}),
});
@@ -140,16 +137,6 @@ export default function RegistrationPage() {
/>
</FormField>
<FormField id="steamId" label={t('REGISTRATION.STEAM_ID')} error={errors.steamId} t={t}>
<Input
id="steamId"
type="text"
inputMode="numeric"
aria-invalid={!!errors.steamId}
{...register('steamId')}
/>
</FormField>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="animate-spin"/>}
{t('REGISTRATION.SUBMIT')}
+10 -1
View File
@@ -27,7 +27,6 @@ function submitValidForm() {
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: 'secret123'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD_CONFIRMATION'), {target: {value: 'secret123'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.INVITE_CODE'), {target: {value: 'code'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.STEAM_ID'), {target: {value: '76561198000000001'}});
fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.SUBMIT'}));
}
@@ -44,6 +43,16 @@ describe('RegistrationPage', () => {
expect(await screen.findByText('REGISTRATION.ERROR_TOO_MANY_ATTEMPTS:15')).toBeTruthy();
});
it('asks for no Steam ID and sends none', async () => {
globalThis.fetch = vi.fn(async () => errorResponse(400, 'INVALID_INVITATION_CODE'));
submitValidForm();
await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE');
expect(screen.queryByLabelText('REGISTRATION.STEAM_ID')).toBeNull();
expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('steamId');
});
it('maps a known backend error to its message', async () => {
globalThis.fetch = vi.fn(async () => errorResponse(400, 'INVALID_INVITATION_CODE'));
+39 -22
View File
@@ -1,5 +1,5 @@
import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useEffect, useRef, useState} from 'react';
import {useNavigate, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
@@ -15,6 +15,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Skeleton} from '@/components/ui/skeleton';
import {Switch} from '@/components/ui/switch';
import FormField from '../components/FormField.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx';
import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
@@ -22,14 +23,21 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {ACCENTS, CORNERS} from '../utils/appearance.js';
import {coverUrl} from '../utils/games.js';
import {cn} from '@/lib/utils';
import {apiFetch} from '../utils/api.js';
import {apiFetch, refreshSession} from '../utils/api.js';
import {hasSession, markSession} from '../utils/auth.js';
const BACKEND_ERROR_KEY = {
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
};
// The `steam` query parameter Auth adds when "Sign in through Steam" sends the user back.
const STEAM_LINK_TOASTS = {
linked: {show: toast.success, key: 'SETTINGS.STEAM_LINK.LINKED'},
'in-use': {show: toast.error, key: 'SETTINGS.STEAM_LINK.IN_USE'},
cancelled: {show: toast.info, key: 'SETTINGS.STEAM_LINK.CANCELLED'},
failed: {show: toast.error, key: 'SETTINGS.STEAM_LINK.FAILED'},
};
const SUPPORTED_LANGUAGES = ['en', 'de'];
const formatDate = (value, locale) => {
@@ -51,6 +59,9 @@ export default function SettingsPage() {
const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [searchParams, setSearchParams] = useSearchParams();
// Read once: removing the parameter below must not load the profile again.
const [steamOutcome] = useState(() => searchParams.get('steam'));
const {
register,
@@ -59,9 +70,22 @@ export default function SettingsPage() {
formState: {errors, isSubmitting},
} = useForm({
resolver: zodResolver(settingsSchema),
defaultValues: {useDarkTheme: true, language: 'en', steamId: '', displayName: ''},
defaultValues: {useDarkTheme: true, language: 'en', displayName: ''},
});
// A language change gives a new `t` and runs the effect again; the toast shows once.
const steamToastShown = useRef(false);
useEffect(() => {
const outcome = STEAM_LINK_TOASTS[steamOutcome];
if (!outcome || steamToastShown.current) return;
steamToastShown.current = true;
outcome.show(t(outcome.key));
setSearchParams((params) => {
params.delete('steam');
return params;
}, {replace: true});
}, [steamOutcome, setSearchParams, t]);
useEffect(() => {
if (!hasSession()) {
navigate('/login');
@@ -70,6 +94,11 @@ export default function SettingsPage() {
const load = async () => {
try {
if (steamOutcome === 'linked') {
// Refresh reads the new Steam ID from the database, so Core gets it in the token at once.
await refreshSession().catch(() => false);
markSession();
}
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
method: 'GET',
headers: {
@@ -92,7 +121,6 @@ export default function SettingsPage() {
reset({
useDarkTheme: !!data.settings?.useDarkTheme,
language: data.settings?.language || 'en',
steamId: data.steamId ?? '',
displayName: data.displayName ?? '',
});
} catch (err) {
@@ -104,7 +132,7 @@ export default function SettingsPage() {
};
load();
}, [navigate, expireSession, reset, t]);
}, [navigate, expireSession, reset, t, steamOutcome]);
const persistSettings = async (payload) => {
if (!hasSession()) {
@@ -133,8 +161,6 @@ export default function SettingsPage() {
return {ok: false};
}
// Auth set a new access cookie for the new Steam ID; the new stamp makes the header load it again.
markSession();
toast.success(t('SETTINGS.SAVED'));
return {ok: true};
} catch (err) {
@@ -148,7 +174,6 @@ export default function SettingsPage() {
const savedSettings = () => ({
useDarkTheme: isDarkMode,
language: i18n.language,
steamId: profile?.steamId ?? null,
displayName: profile?.displayName ?? null,
publicProfile: !!profile?.settings?.publicProfile,
});
@@ -186,13 +211,12 @@ export default function SettingsPage() {
const publicProfile = savedSettings().publicProfile && !!values.displayName;
const result = await persistSettings({
...savedSettings(),
steamId: values.steamId,
displayName: values.displayName,
publicProfile,
});
if (result.ok) {
setProfile((p) => (p ? {
...p, steamId: values.steamId, displayName: values.displayName, settings: {...p.settings, publicProfile},
...p, displayName: values.displayName, settings: {...p.settings, publicProfile},
} : p));
}
};
@@ -358,6 +382,9 @@ export default function SettingsPage() {
</CardContent>
</Card>
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
onUnlinked={() => setProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.PROFILE')}</CardTitle>
@@ -375,16 +402,6 @@ export default function SettingsPage() {
/>
<p className="text-xs text-muted-foreground">{t('SETTINGS.DISPLAY_NAME_HINT')}</p>
</FormField>
<FormField id="steamId" label={t('SETTINGS.STEAM_ID')} error={errors.steamId} t={t}>
<Input
id="steamId"
type="text"
inputMode="numeric"
className="font-mono"
aria-invalid={!!errors.steamId}
{...register('steamId')}
/>
</FormField>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="animate-spin"/> : <Save/>}
{t('SETTINGS.SAVE_PROFILE')}
+114
View File
@@ -0,0 +1,114 @@
// @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 SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
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(), info: vi.fn()}}));
const STEAM_URL = 'https://steamcommunity.com/openid/login?openid.mode=checkid_setup';
const profile = (overrides = {}) => ({
id: 1, email: 'a@b.de', role: 'USER', displayName: 'Lobo', steamId: '76561198005679788', steamIdVerified: false,
settings: {useDarkTheme: true, language: 'de', publicProfile: false}, ...overrides,
});
const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method);
function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
globalThis.fetch = vi.fn(async (url, init) => {
if (init?.method === 'POST' && url.endsWith('/api/users/steam-link')) {
return {ok: true, status: 200, json: async () => ({redirectUrl})};
}
if (init?.method && init.method !== 'GET') {
return {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }};
}
return {ok: true, status: 200, json: async () => me};
});
render(<MemoryRouter initialEntries={[entry]}><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
}
describe('SettingsPage Steam account', () => {
const assign = vi.fn();
const realLocation = window.location;
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
Object.defineProperty(window, 'location', {configurable: true, value: {...realLocation, assign}});
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
Object.defineProperty(window, 'location', {configurable: true, value: realLocation});
});
it('shows a typed-in ID as not verified and sends the user to Steam to verify it', async () => {
renderPage(profile());
expect(await screen.findByText('SETTINGS.STEAM_LINK.NOT_VERIFIED')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: /SETTINGS.STEAM_LINK.VERIFY/}));
await waitFor(() => expect(assign).toHaveBeenCalledWith(STEAM_URL));
expect(calls('POST').map(([url]) => url).some((url) => url.endsWith('/api/users/steam-link'))).toBe(true);
});
it('does not follow a link that is not Steam', async () => {
renderPage(profile({steamId: null}), {redirectUrl: 'https://evil.example/openid/login?x'});
fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.STEAM_LINK.LINK/}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.STEAM_LINK.ERROR'));
expect(assign).not.toHaveBeenCalled();
});
it('shows a verified ID with the option to link another account', async () => {
renderPage(profile({steamIdVerified: true}));
expect(await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED')).toBeTruthy();
expect(screen.getByRole('button', {name: /SETTINGS.STEAM_LINK.CHANGE/})).toBeTruthy();
});
it('back from Steam: refreshes the session so Core gets the new Steam ID, and says so', async () => {
renderPage(profile({steamIdVerified: true}), {entry: '/settings?steam=linked'});
await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED');
expect(calls('POST').some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(true);
expect(toast.success).toHaveBeenCalledWith('SETTINGS.STEAM_LINK.LINKED');
});
it.each([['in-use', 'error', 'SETTINGS.STEAM_LINK.IN_USE'], ['failed', 'error', 'SETTINGS.STEAM_LINK.FAILED'],
['cancelled', 'info', 'SETTINGS.STEAM_LINK.CANCELLED']])('back from Steam with %s', async (outcome, kind, key) => {
renderPage(profile(), {entry: `/settings?steam=${outcome}`});
await screen.findByText('SETTINGS.STEAM_LINK.NOT_VERIFIED');
expect(toast[kind]).toHaveBeenCalledWith(key);
expect(calls('POST').some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(false);
});
it('unlinks the Steam account', async () => {
renderPage(profile({steamIdVerified: true}));
fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.STEAM_LINK.UNLINK/}));
expect(await screen.findByText('SETTINGS.STEAM_LINK.NONE')).toBeTruthy();
expect(calls('DELETE')[0][0]).toMatch(/\/api\/users\/steam-link$/);
});
it('never sends a Steam ID with the settings', async () => {
renderPage(profile());
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
await waitFor(() => expect(calls('PUT')).toHaveLength(1));
expect(JSON.parse(calls('PUT')[0][1].body)).not.toHaveProperty('steamId');
});
});
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
// Like react-i18next: a language change re-renders every subscriber with a fresh `t`.
vi.mock('react-i18next', async () => {
const {useSyncExternalStore} = await import('react');
const listeners = new Set();
const state = {language: 'de', t: (key) => key};
const i18n = {
get language() { return state.language; },
changeLanguage: vi.fn((language) => {
state.language = language;
state.t = (key) => key;
listeners.forEach((listener) => listener());
}),
};
const subscribe = (listener) => { listeners.add(listener); return () => listeners.delete(listener); };
return {useTranslation: () => ({t: useSyncExternalStore(subscribe, () => state.t), i18n}), i18n};
});
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}}));
const profile = {
id: 1, email: 'a@b.de', role: 'USER', displayName: 'Lobo', steamId: '76561198005679788', steamIdVerified: true,
settings: {useDarkTheme: true, language: 'de', publicProfile: false},
};
describe('SettingsPage Steam toast', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => (init?.method && init.method !== 'GET'
? {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}
: {ok: true, status: 200, json: async () => profile}));
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
it('shows the linked toast once, also after a language change', async () => {
render(<MemoryRouter initialEntries={['/settings?steam=linked']}><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED');
expect(toast.success).toHaveBeenCalledTimes(1);
const {i18n} = await import('react-i18next');
await act(async () => { i18n.changeLanguage('en'); });
expect(toast.success).toHaveBeenCalledTimes(1);
});
});
+26
View File
@@ -0,0 +1,26 @@
import {apiFetch} from './api.js';
// Only Steam's own login page; a URL from an API must not send the user anywhere else.
export const STEAM_LOGIN_PREFIX = 'https://steamcommunity.com/openid/login?';
/**
* POST /api/users/steam-link, then leaves the page for Steam's login. The caller handles a response that is not ok.
*
* @returns the response; when it is ok the browser is already on its way to Steam
*/
export async function startSteamLink() {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/steam-link`, {method: 'POST'});
if (!response.ok) return response;
const {redirectUrl} = await response.json();
if (typeof redirectUrl !== 'string' || !redirectUrl.startsWith(STEAM_LOGIN_PREFIX)) {
throw new Error('Auth sent no Steam login URL');
}
window.location.assign(redirectUrl);
return response;
}
/**
* DELETE /api/users/steam-link. Auth sets a new access cookie without the Steam ID.
*/
export const unlinkSteam = () =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/steam-link`, {method: 'DELETE'});
-9
View File
@@ -15,13 +15,6 @@ const inviteCodeField = z
.string()
.min(1, 'VALIDATION.INVITE_CODE_REQUIRED');
// A SteamID64 has 17 digits. Keep it a string: a JS number loses precision above 2^53.
const steamIdField = z
.string()
.trim()
.min(1, 'VALIDATION.STEAM_ID_REQUIRED')
.regex(/^\d{17}$/, 'VALIDATION.STEAM_ID_INVALID');
// Same rule as the Auth service: 3-24 letters, digits, space, _ . - and no space at the ends.
// An empty field means "no display name" and is sent as null.
const displayNameField = z
@@ -39,7 +32,6 @@ export const registrationSchema = z
password: passwordField,
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
inviteCode: inviteCodeField,
steamId: steamIdField,
displayName: displayNameField,
})
.refine((d) => d.password === d.passwordConfirmation, {
@@ -50,6 +42,5 @@ export const registrationSchema = z
export const settingsSchema = z.object({
useDarkTheme: z.boolean(),
language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}),
steamId: steamIdField,
displayName: displayNameField,
});
+8 -24
View File
@@ -1,36 +1,20 @@
import {describe, expect, it} from 'vitest';
import {registrationSchema, settingsSchema} from './validation.js';
const settings = (steamId) => ({useDarkTheme: true, language: 'de', steamId});
const settings = () => ({useDarkTheme: true, language: 'de'});
describe('steamId validation', () => {
it('keeps a SteamID64 as the exact string', () => {
// Number('76561198005679788') is 76561198005679790
expect(settingsSchema.parse(settings('76561198005679788')).steamId).toBe('76561198005679788');
});
it('trims whitespace', () => {
expect(settingsSchema.parse(settings(' 76561198005679788 ')).steamId).toBe('76561198005679788');
});
it.each(['', '12345', '7656119800567978x', '765611980056797880'])('rejects %j', (steamId) => {
expect(settingsSchema.safeParse(settings(steamId)).success).toBe(false);
});
it('keeps the exact string on registration', () => {
const result = registrationSchema.parse({
email: 'a@b.de',
password: 'password1',
passwordConfirmation: 'password1',
inviteCode: 'code',
describe('Steam ID', () => {
it('is no form field any more: it comes only from "Sign in through Steam"', () => {
expect(settingsSchema.parse({...settings(), steamId: '76561198005679788'})).not.toHaveProperty('steamId');
expect(registrationSchema.parse({
email: 'a@b.de', password: 'password1', passwordConfirmation: 'password1', inviteCode: 'code',
steamId: '76561198005679788',
});
expect(result.steamId).toBe('76561198005679788');
})).not.toHaveProperty('steamId');
});
});
describe('displayName validation', () => {
const withName = (displayName) => ({...settings('76561198005679788'), displayName});
const withName = (displayName) => ({...settings(), displayName});
it('turns an empty field into null (no display name)', () => {
expect(settingsSchema.parse(withName(' ')).displayName).toBeNull();