diff --git a/frontend/src/__tests__/profile-bio.test.tsx b/frontend/src/__tests__/profile-bio.test.tsx new file mode 100644 index 00000000..4717f0e3 --- /dev/null +++ b/frontend/src/__tests__/profile-bio.test.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ProfileBio } from '@/components/profile/ProfileBio'; +import { MAX_BIO_LENGTH, profileBioSchema } from '@/lib/validations/profile'; +import type { User } from '@/types/user'; + +const baseUser: User = { + id: 'user-1', + username: 'TestPlayer', + email: 'test@example.com', + isVerified: true, + elo: 1500, + createdAt: '2024-01-01T00:00:00Z', + bio: '', + socialLinks: {}, +}; + +function renderBio(user: Partial = {}) { + const onSave = jest.fn(); + render(); + + // Enter edit mode + fireEvent.click(screen.getByRole('button', { name: /edit profile/i })); + + return { onSave }; +} + +describe('ProfileBio — character counter', () => { + it('shows 0 / MAX_BIO_LENGTH initially when bio is empty', () => { + renderBio(); + expect(screen.getByText(`0 / ${MAX_BIO_LENGTH}`)).toBeInTheDocument(); + }); + + it('updates counter immediately as the user types', () => { + renderBio(); + const textarea = screen.getByRole('textbox', { name: /bio/i }); + + fireEvent.change(textarea, { target: { value: 'Hello!' } }); + expect(screen.getByText(`6 / ${MAX_BIO_LENGTH}`)).toBeInTheDocument(); + + fireEvent.change(textarea, { target: { value: 'Hello, world!' } }); + expect(screen.getByText(`13 / ${MAX_BIO_LENGTH}`)).toBeInTheDocument(); + }); + + it('reflects the existing bio length on first render', () => { + const existingBio = 'A'.repeat(50); + renderBio({ bio: existingBio }); + expect(screen.getByText(`50 / ${MAX_BIO_LENGTH}`)).toBeInTheDocument(); + }); +}); + +describe('ProfileBio — warning state at 90% capacity', () => { + const warningThreshold = Math.floor(MAX_BIO_LENGTH * 0.9); + + it('counter has no warning style below 90%', () => { + renderBio(); + const textarea = screen.getByRole('textbox', { name: /bio/i }); + + fireEvent.change(textarea, { target: { value: 'A'.repeat(warningThreshold - 1) } }); + + const counter = screen.getByText(`${warningThreshold - 1} / ${MAX_BIO_LENGTH}`); + expect(counter).not.toHaveClass('text-destructive'); + }); + + it('counter switches to warning style at exactly 90% of MAX_BIO_LENGTH', () => { + renderBio(); + const textarea = screen.getByRole('textbox', { name: /bio/i }); + + fireEvent.change(textarea, { target: { value: 'A'.repeat(warningThreshold) } }); + + const counter = screen.getByText(`${warningThreshold} / ${MAX_BIO_LENGTH}`); + expect(counter).toHaveClass('text-destructive'); + }); + + it('counter keeps warning style when bio exceeds the limit', () => { + renderBio(); + const textarea = screen.getByRole('textbox', { name: /bio/i }); + + fireEvent.change(textarea, { target: { value: 'A'.repeat(MAX_BIO_LENGTH + 1) } }); + + const counter = screen.getByText(`${MAX_BIO_LENGTH + 1} / ${MAX_BIO_LENGTH}`); + expect(counter).toHaveClass('text-destructive'); + }); +}); + +describe('ProfileBio — submission blocking', () => { + it('Save button is enabled within the limit', () => { + renderBio(); + const textarea = screen.getByRole('textbox', { name: /bio/i }); + + fireEvent.change(textarea, { target: { value: 'A'.repeat(MAX_BIO_LENGTH) } }); + + expect(screen.getByRole('button', { name: /save changes/i })).not.toBeDisabled(); + }); + + it('Save button is disabled when bio exceeds MAX_BIO_LENGTH', () => { + renderBio(); + const textarea = screen.getByRole('textbox', { name: /bio/i }); + + fireEvent.change(textarea, { target: { value: 'A'.repeat(MAX_BIO_LENGTH + 1) } }); + + expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled(); + }); + + it('shows inline error message when bio exceeds MAX_BIO_LENGTH', () => { + renderBio(); + const textarea = screen.getByRole('textbox', { name: /bio/i }); + + fireEvent.change(textarea, { target: { value: 'A'.repeat(MAX_BIO_LENGTH + 1) } }); + + expect( + screen.getByText(`Bio must be ${MAX_BIO_LENGTH} characters or less`) + ).toBeInTheDocument(); + }); + + it('clears the error once bio is back within the limit', () => { + renderBio(); + const textarea = screen.getByRole('textbox', { name: /bio/i }); + + fireEvent.change(textarea, { target: { value: 'A'.repeat(MAX_BIO_LENGTH + 1) } }); + expect( + screen.getByText(`Bio must be ${MAX_BIO_LENGTH} characters or less`) + ).toBeInTheDocument(); + + fireEvent.change(textarea, { target: { value: 'A'.repeat(MAX_BIO_LENGTH) } }); + expect( + screen.queryByText(`Bio must be ${MAX_BIO_LENGTH} characters or less`) + ).not.toBeInTheDocument(); + }); + + it('does not call onSave when bio exceeds the limit', () => { + const { onSave } = renderBio(); + const textarea = screen.getByRole('textbox', { name: /bio/i }); + + fireEvent.change(textarea, { target: { value: 'A'.repeat(MAX_BIO_LENGTH + 1) } }); + fireEvent.click(screen.getByRole('button', { name: /save changes/i })); + + expect(onSave).not.toHaveBeenCalled(); + }); +}); + +describe('ProfileBio — successful submission', () => { + it('calls onSave with the correct bio when within the limit', () => { + const { onSave } = renderBio(); + const textarea = screen.getByRole('textbox', { name: /bio/i }); + const validBio = 'Hello, I am a gamer!'; + + fireEvent.change(textarea, { target: { value: validBio } }); + fireEvent.click(screen.getByRole('button', { name: /save changes/i })); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ bio: validBio }) + ); + }); + + it('exits edit mode after a successful save', () => { + renderBio(); + const textarea = screen.getByRole('textbox', { name: /bio/i }); + + fireEvent.change(textarea, { target: { value: 'Short bio' } }); + fireEvent.click(screen.getByRole('button', { name: /save changes/i })); + + expect(screen.queryByRole('textbox', { name: /bio/i })).not.toBeInTheDocument(); + }); +}); + +describe('ProfileBio — constant / schema consistency', () => { + it('profileBioSchema rejects bio longer than MAX_BIO_LENGTH', () => { + const result = profileBioSchema.safeParse({ bio: 'A'.repeat(MAX_BIO_LENGTH + 1) }); + expect(result.success).toBe(false); + }); + + it('profileBioSchema accepts bio exactly at MAX_BIO_LENGTH', () => { + const result = profileBioSchema.safeParse({ bio: 'A'.repeat(MAX_BIO_LENGTH) }); + expect(result.success).toBe(true); + }); + + it('profileBioSchema accepts an undefined bio', () => { + const result = profileBioSchema.safeParse({ bio: undefined }); + expect(result.success).toBe(true); + }); + + it('MAX_BIO_LENGTH matches the schema max constraint', () => { + const tooLong = profileBioSchema.safeParse({ bio: 'A'.repeat(MAX_BIO_LENGTH + 1) }); + const exactLimit = profileBioSchema.safeParse({ bio: 'A'.repeat(MAX_BIO_LENGTH) }); + expect(tooLong.success).toBe(false); + expect(exactLimit.success).toBe(true); + }); +}); diff --git a/frontend/src/__tests__/profile-edit.test.tsx b/frontend/src/__tests__/profile-edit.test.tsx index 8d511ec9..85c71c30 100644 --- a/frontend/src/__tests__/profile-edit.test.tsx +++ b/frontend/src/__tests__/profile-edit.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; -import ProfileEditPage from '@/app/profile/edit/page'; +import ProfileEditPage from '@/app/[locale]/profile/edit/page'; +import { MAX_BIO_LENGTH } from '@/lib/validations/profile'; jest.mock('@/hooks/useAuth', () => ({ useAuth: () => ({ user: { id: 'u1', username: 'TestUser', email: 'test@test.com' } }), @@ -8,6 +9,7 @@ jest.mock('@/hooks/useAuth', () => ({ jest.mock('next/navigation', () => ({ useRouter: () => ({ push: jest.fn(), back: jest.fn() }), + useSearchParams: () => new URLSearchParams(), })); // CustomizationOptions uses lucide-react icons; mock to keep tests simple @@ -16,15 +18,17 @@ jest.mock('@/components/profile/CustomizationOptions', () => ({ })); describe('ProfileEditPage', () => { - it('disables submit button and shows error when bio exceeds 500 characters', () => { + it('disables submit button and shows error when bio exceeds MAX_BIO_LENGTH', () => { render(); const textarea = screen.getByRole('textbox', { name: /bio/i }); - const longBio = 'a'.repeat(501); + const longBio = 'a'.repeat(MAX_BIO_LENGTH + 1); fireEvent.change(textarea, { target: { value: longBio } }); - expect(screen.getByText('Bio must be 500 characters or less')).toBeInTheDocument(); + expect( + screen.getByText(`Bio must be ${MAX_BIO_LENGTH} characters or less`) + ).toBeInTheDocument(); expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); }); diff --git a/frontend/src/__tests__/theme-persistence.test.tsx b/frontend/src/__tests__/theme-persistence.test.tsx new file mode 100644 index 00000000..2342d489 --- /dev/null +++ b/frontend/src/__tests__/theme-persistence.test.tsx @@ -0,0 +1,362 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +const mockSetTheme = jest.fn(); + +jest.mock('next-themes', () => { + const R = require('react'); + return { + ThemeProvider: jest.fn(({ children }: { children: React.ReactNode }) => + R.createElement(R.Fragment, null, children) + ), + useTheme: jest.fn(), + }; +}); + +import { useTheme, ThemeProvider as MockedNextProvider } from 'next-themes'; +import { ThemeProvider } from '@/components/providers/ThemeProvider'; +import { ThemeToggle as UiThemeToggle } from '@/components/ui/ThemeToggle'; +import { ThemeToggle as NavThemeToggle } from '@/components/ThemeToggle'; +import { ThemeSelector } from '@/components/settings/ThemeSelector'; +import type { ThemeSettings } from '@/types/settings'; + +const defaultSettings: ThemeSettings = { + mode: 'light', + accentColor: 'blue', + compactMode: false, + animationsEnabled: true, +}; + +function setupTheme(theme: string, resolvedTheme: string) { + (useTheme as jest.Mock).mockReturnValue({ theme, resolvedTheme, setTheme: mockSetTheme }); +} + +function renderSelector(overrides: Partial = {}) { + const onUpdate = jest.fn(); + const onSave = jest.fn().mockResolvedValue(true); + render( + + ); + return { onUpdate, onSave }; +} + +beforeEach(() => { + mockSetTheme.mockClear(); + (MockedNextProvider as jest.Mock).mockClear(); + localStorage.clear(); +}); + +// ─── ThemeProvider configuration ────────────────────────────────────────────── + +describe('ThemeProvider configuration', () => { + it('forwards storageKey="theme" to NextThemesProvider', () => { + render( + +
+ + ); + expect(MockedNextProvider).toHaveBeenCalledWith( + expect.objectContaining({ storageKey: 'theme' }), + expect.anything() + ); + }); + + it('forwards defaultTheme="system" to NextThemesProvider', () => { + render( + +
+ + ); + expect(MockedNextProvider).toHaveBeenCalledWith( + expect.objectContaining({ defaultTheme: 'system' }), + expect.anything() + ); + }); + + it('forwards enableSystem to NextThemesProvider', () => { + render( + +
+ + ); + expect(MockedNextProvider).toHaveBeenCalledWith( + expect.objectContaining({ enableSystem: true }), + expect.anything() + ); + }); + + it('passes all props through without modifying them', () => { + render( + +
+ + ); + expect(MockedNextProvider).toHaveBeenCalledWith( + expect.objectContaining({ + attribute: 'class', + defaultTheme: 'system', + enableSystem: true, + storageKey: 'theme', + disableTransitionOnChange: true, + }), + expect.anything() + ); + }); +}); + +// ─── localStorage persistence ───────────────────────────────────────────────── + +describe('localStorage persistence', () => { + it('stores the selected theme under the "theme" key', () => { + setupTheme('light', 'light'); + renderSelector(); + fireEvent.click(screen.getByRole('button', { name: /dark/i })); + expect(mockSetTheme).toHaveBeenCalledWith('dark'); + }); + + it('does not store under any other key', () => { + setupTheme('light', 'light'); + renderSelector(); + fireEvent.click(screen.getByRole('button', { name: /dark/i })); + // Only one call, with the value 'dark', implying the key contract is 'theme' + expect(mockSetTheme).toHaveBeenCalledTimes(1); + expect(mockSetTheme).toHaveBeenCalledWith('dark'); + }); + + it('restores the stored theme after remount', () => { + localStorage.setItem('theme', 'dark'); + // next-themes reads localStorage on init; our mock reflects that stored state + setupTheme('dark', 'dark'); + + renderSelector({ mode: 'light' }); // settings.mode is stale; useTheme is authoritative + + const darkBtn = screen.getByRole('button', { name: /dark/i }); + expect(darkBtn).toHaveClass('border-primary'); + }); + + it('restores light theme after remount', () => { + localStorage.setItem('theme', 'light'); + setupTheme('light', 'light'); + + renderSelector({ mode: 'dark' }); // stale settings — useTheme overrides + + const lightBtn = screen.getByRole('button', { name: /light/i }); + expect(lightBtn).toHaveClass('border-primary'); + const darkBtn = screen.getByRole('button', { name: /dark/i }); + expect(darkBtn).not.toHaveClass('border-primary'); + }); + + it('falls back to system mode when no preference is in localStorage', () => { + expect(localStorage.getItem('theme')).toBeNull(); + setupTheme('system', 'light'); + + renderSelector({ mode: 'system' }); + + const systemBtn = screen.getByRole('button', { name: /system/i }); + expect(systemBtn).toHaveClass('border-primary'); + }); +}); + +// ─── ThemeToggle (components/ui/ThemeToggle) ────────────────────────────────── + +describe('ThemeToggle — ui/ThemeToggle', () => { + it('switches from light to dark', () => { + setupTheme('light', 'light'); + render(); + fireEvent.click(screen.getByRole('button', { name: /toggle theme/i })); + expect(mockSetTheme).toHaveBeenCalledWith('dark'); + }); + + it('switches from dark to light', () => { + setupTheme('dark', 'dark'); + render(); + fireEvent.click(screen.getByRole('button', { name: /toggle theme/i })); + expect(mockSetTheme).toHaveBeenCalledWith('light'); + }); + + it('uses resolvedTheme to decide direction when mode is "system" (dark OS)', () => { + setupTheme('system', 'dark'); + render(); + fireEvent.click(screen.getByRole('button', { name: /toggle theme/i })); + expect(mockSetTheme).toHaveBeenCalledWith('light'); + }); + + it('uses resolvedTheme to decide direction when mode is "system" (light OS)', () => { + setupTheme('system', 'light'); + render(); + fireEvent.click(screen.getByRole('button', { name: /toggle theme/i })); + expect(mockSetTheme).toHaveBeenCalledWith('dark'); + }); + + it('SSR output is a placeholder with no click handler (prevents theme flash)', () => { + // renderToStaticMarkup simulates the server render: useEffect never runs, + // so mounted=false and the placeholder branch is returned. This is exactly + // what the browser displays before client JS hydrates — no flash of the + // wrong theme because there is no interactive button yet. + setupTheme('dark', 'dark'); + const html = renderToStaticMarkup(); + + expect(html).toContain('aria-label="Toggle theme"'); + expect(html).not.toContain('onclick'); + }); +}); + +// ─── ThemeToggle (components/ThemeToggle) ──────────────────────────────────── + +describe('ThemeToggle — components/ThemeToggle', () => { + it('switches from light to dark', () => { + setupTheme('light', 'light'); + render(); + fireEvent.click(screen.getByRole('button', { name: /toggle theme/i })); + expect(mockSetTheme).toHaveBeenCalledWith('dark'); + }); + + it('switches from dark to light', () => { + setupTheme('dark', 'dark'); + render(); + fireEvent.click(screen.getByRole('button', { name: /toggle theme/i })); + expect(mockSetTheme).toHaveBeenCalledWith('light'); + }); + + it('uses resolvedTheme when mode is "system" (dark OS)', () => { + setupTheme('system', 'dark'); + render(); + fireEvent.click(screen.getByRole('button', { name: /toggle theme/i })); + expect(mockSetTheme).toHaveBeenCalledWith('light'); + }); + + it('SSR output is a placeholder with no click handler (prevents theme flash)', () => { + setupTheme('light', 'light'); + const html = renderToStaticMarkup(); + + expect(html).toContain('aria-label="Toggle theme"'); + expect(html).not.toContain('onclick'); + }); +}); + +// ─── ThemeSelector ──────────────────────────────────────────────────────────── + +describe('ThemeSelector', () => { + it('shows the active mode from useTheme, not from the stale settings prop', () => { + setupTheme('dark', 'dark'); + renderSelector({ mode: 'light' }); // settings says light, useTheme says dark + + expect(screen.getByRole('button', { name: /dark/i })).toHaveClass('border-primary'); + expect(screen.getByRole('button', { name: /light/i })).not.toHaveClass('border-primary'); + }); + + it('calls setTheme with the selected mode', () => { + setupTheme('light', 'light'); + renderSelector(); + fireEvent.click(screen.getByRole('button', { name: /dark/i })); + expect(mockSetTheme).toHaveBeenCalledWith('dark'); + }); + + it('calls onUpdate with the selected mode', () => { + setupTheme('light', 'light'); + const { onUpdate } = renderSelector(); + fireEvent.click(screen.getByRole('button', { name: /dark/i })); + expect(onUpdate).toHaveBeenCalledWith({ mode: 'dark' }); + }); + + it('can select system mode, calling both setTheme and onUpdate', () => { + setupTheme('light', 'light'); + const { onUpdate } = renderSelector(); + fireEvent.click(screen.getByRole('button', { name: /system/i })); + expect(mockSetTheme).toHaveBeenCalledWith('system'); + expect(onUpdate).toHaveBeenCalledWith({ mode: 'system' }); + }); + + it('can select light mode', () => { + setupTheme('dark', 'dark'); + const { onUpdate } = renderSelector({ mode: 'dark' }); + fireEvent.click(screen.getByRole('button', { name: /light/i })); + expect(mockSetTheme).toHaveBeenCalledWith('light'); + expect(onUpdate).toHaveBeenCalledWith({ mode: 'light' }); + }); + + it('marks the active mode as highlighted and others as not highlighted', () => { + setupTheme('system', 'light'); + renderSelector({ mode: 'system' }); + + expect(screen.getByRole('button', { name: /system/i })).toHaveClass('border-primary'); + expect(screen.getByRole('button', { name: /light/i })).not.toHaveClass('border-primary'); + expect(screen.getByRole('button', { name: /dark/i })).not.toHaveClass('border-primary'); + }); +}); + +// ─── Cross-component synchronization ───────────────────────────────────────── + +describe('cross-component theme synchronization', () => { + it('both ThemeToggle components read the same theme state', () => { + setupTheme('dark', 'dark'); + + const { unmount: unmount1 } = render(); + const { unmount: unmount2 } = render(); + + // Both buttons are rendered; both should call setTheme with 'light' when clicked + const buttons = screen.getAllByRole('button', { name: /toggle theme/i }); + expect(buttons).toHaveLength(2); + fireEvent.click(buttons[0]); + expect(mockSetTheme).toHaveBeenCalledWith('light'); + + unmount1(); + unmount2(); + }); + + it('ThemeSelector and ThemeToggle reflect the same persisted theme', () => { + localStorage.setItem('theme', 'dark'); + setupTheme('dark', 'dark'); + + render( + <> + + + + ); + + // ThemeToggle: clicking toggles to light (from dark) + const toggleBtn = screen.getByRole('button', { name: /toggle theme/i }); + fireEvent.click(toggleBtn); + expect(mockSetTheme).toHaveBeenCalledWith('light'); + + // ThemeSelector: dark mode button is highlighted (matches persisted theme) + expect(screen.getByRole('button', { name: /dark/i })).toHaveClass('border-primary'); + }); + + it('a theme selection in ThemeSelector is what ThemeToggle would then read', () => { + setupTheme('light', 'light'); + const { onUpdate } = renderSelector(); + + // User selects dark in ThemeSelector + fireEvent.click(screen.getByRole('button', { name: /dark/i })); + expect(mockSetTheme).toHaveBeenCalledWith('dark'); + expect(onUpdate).toHaveBeenCalledWith({ mode: 'dark' }); + + // After setTheme('dark'), next-themes updates the store; ThemeToggle would then + // read resolvedTheme='dark' and toggle to 'light'. + setupTheme('dark', 'dark'); + const { unmount } = render(); + const toggleBtn = screen.getByRole('button', { name: /toggle theme/i }); + fireEvent.click(toggleBtn); + expect(mockSetTheme).toHaveBeenLastCalledWith('light'); + unmount(); + }); +}); diff --git a/frontend/src/app/[locale]/layout.tsx b/frontend/src/app/[locale]/layout.tsx index 8dc9ef2a..f7d6d40d 100644 --- a/frontend/src/app/[locale]/layout.tsx +++ b/frontend/src/app/[locale]/layout.tsx @@ -74,6 +74,7 @@ export default function RootLayout({ defaultTheme="system" enableSystem disableTransitionOnChange + storageKey="theme" > diff --git a/frontend/src/app/[locale]/profile/edit/page.tsx b/frontend/src/app/[locale]/profile/edit/page.tsx index 223ae660..e8e3013d 100644 --- a/frontend/src/app/[locale]/profile/edit/page.tsx +++ b/frontend/src/app/[locale]/profile/edit/page.tsx @@ -13,8 +13,8 @@ import { X, Camera, Upload, CheckCircle, AlertTriangle, Twitter, Github, Twitch import { cn } from '@/lib/utils'; import type { ProfileCustomization } from '@/types/profile'; import type { UserProfileUpdate } from '@/types/user'; +import { MAX_BIO_LENGTH } from '@/lib/validations/profile'; -const MAX_BIO_LENGTH = 500; const USERNAME_MIN_LENGTH = 3; const USERNAME_MAX_LENGTH = 20; @@ -141,7 +141,7 @@ function ProfileEditContent() { const value = e.target.value; setBio(value); if (value.length > MAX_BIO_LENGTH) { - setBioError('Bio must be 500 characters or less'); + setBioError(`Bio must be ${MAX_BIO_LENGTH} characters or less`); } else { setBioError(null); } @@ -353,7 +353,10 @@ function ProfileEditContent() { Bio +