Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ jobs:
- run: pnpm --filter server db:generate
- run: pnpm typecheck

test:
name: Test
runs-on: ubuntu-latest
needs: typecheck
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# shared must be built so ts-jest can resolve @chesskernel/shared types
- run: pnpm --filter shared build
- run: pnpm --filter server db:generate
# runs client (vitest) then server (jest) suites
- run: pnpm test

build:
name: Build
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.13",
"@testing-library/react": "^15.0.7",
"@types/node": "^20.12.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
Expand All @@ -52,6 +53,7 @@
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
Expand Down
128 changes: 128 additions & 0 deletions client/src/components/layout/Navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { Navbar } from './Navbar';
import { useAuthStore } from '@/stores/auth.store';
import i18n from '@/i18n';
import en from '@/i18n/locales/en.json';
import pt from '@/i18n/locales/pt.json';
import es from '@/i18n/locales/es.json';

vi.mock('@/services/api', () => ({
api: { post: vi.fn().mockResolvedValue(undefined) },
}));

const NAV_LOCALES = { en: en.nav, pt: pt.nav, es: es.nav } as const;

function renderNavbar() {
return render(
<MemoryRouter>
<Navbar />
</MemoryRouter>,
);
}

describe('Navbar', () => {
beforeEach(async () => {
cleanup();
localStorage.clear();
useAuthStore.getState().clearAuth();
await i18n.changeLanguage('en');
});

describe.each(['en', 'pt', 'es'] as const)('anonymous variant in %s', (lang) => {
it('renders translated labels with ch-based width reservations', async () => {
await i18n.changeLanguage(lang);
const nav = NAV_LOCALES[lang];

renderNavbar();

expect(screen.getByText(nav.play)).toBeTruthy();
expect(screen.getByText(nav.leaderboard)).toBeTruthy();

// ch-based min-widths reserve space for the longest translation so a
// language switch can never resize the auth buttons.
const loginLink = screen.getByText(nav.login).closest('a');
expect(loginLink?.getAttribute('href')).toBe('/login');
expect(loginLink?.className).toContain('min-w-[13ch]');
expect(loginLink?.className).toContain('whitespace-nowrap');

const signUpLink = screen.getByText(nav.signUp).closest('a');
expect(signUpLink?.getAttribute('href')).toBe('/register');
expect(signUpLink?.className).toContain('min-w-[11ch]');
expect(signUpLink?.className).toContain('whitespace-nowrap');

expect(screen.queryByLabelText(nav.logout)).toBeNull();
});
});

it('shows the segmented language control with all three languages', () => {
renderNavbar();

for (const lang of ['EN', 'PT', 'ES']) {
expect(screen.getAllByText(lang, { selector: 'button' }).length).toBeGreaterThan(0);
}
});

it('switching language calls i18n.changeLanguage and persists to localStorage', async () => {
const changeLanguageSpy = vi.spyOn(i18n, 'changeLanguage');
renderNavbar();

fireEvent.click(screen.getByText('PT', { selector: 'button' }));

expect(changeLanguageSpy).toHaveBeenCalledWith('pt');
await waitFor(() => expect(i18n.language).toBe('pt'));
expect(localStorage.getItem('chesskernel-lang')).toBe('pt');

fireEvent.click(screen.getByText('ES', { selector: 'button' }));
await waitFor(() => expect(i18n.language).toBe('es'));
expect(localStorage.getItem('chesskernel-lang')).toBe('es');
});

it('re-renders nav labels after a language switch', async () => {
renderNavbar();
expect(screen.getByText('Play')).toBeTruthy();

fireEvent.click(screen.getByText('ES', { selector: 'button' }));

await waitFor(() => expect(screen.getByText('Jugar')).toBeTruthy());
expect(screen.queryByText('Play')).toBeNull();
});

it('authenticated variant shows the username and logout instead of auth links', () => {
useAuthStore.getState().setAuth(
{ id: 'u1', username: 'magnus', email: 'm@example.com', avatarUrl: null, isAdmin: false },
'access',
'refresh',
);

renderNavbar();

expect(screen.getByText('magnus')).toBeTruthy();
expect(screen.getByText('M')).toBeTruthy();
expect(screen.getByLabelText(en.nav.logout)).toBeTruthy();
expect(screen.queryByText(en.nav.login)).toBeNull();
expect(screen.queryByText(en.nav.signUp)).toBeNull();
});

it('logout calls the api and clears the auth session', async () => {
const { api } = await import('@/services/api');
useAuthStore.getState().setAuth(
{ id: 'u1', username: 'magnus', email: 'm@example.com', avatarUrl: null, isAdmin: false },
'access',
'refresh',
);

renderNavbar();
fireEvent.click(screen.getByLabelText(en.nav.logout));

await waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false));
expect(api.post).toHaveBeenCalledWith('/auth/logout');
expect(useAuthStore.getState().user).toBeNull();
});

it('shows the theme toggle with an accessible label', () => {
renderNavbar();
expect(screen.getByLabelText(en.nav.theme)).toBeTruthy();
});
});
79 changes: 79 additions & 0 deletions client/src/i18n/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest';
import i18n from './index';
import en from './locales/en.json';
import pt from './locales/pt.json';
import es from './locales/es.json';

const LOCALES = { en, pt, es } as const;
const LANGS = Object.keys(LOCALES) as Array<keyof typeof LOCALES>;

/** Collects dot-notation leaf keys of a nested locale object. */
function leafKeys(obj: Record<string, unknown>, prefix = ''): string[] {
return Object.entries(obj).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object') {
return leafKeys(value as Record<string, unknown>, path);
}
return [path];
});
}

describe('locale resources', () => {
it.each(LANGS)('%s defines non-empty meta.title and meta.description', (lang) => {
const meta = LOCALES[lang].meta;
expect(meta.title.length).toBeGreaterThan(0);
expect(meta.description.length).toBeGreaterThan(0);
expect(meta.title).toContain('ChessKernel');
});

it.each(['pt', 'es'] as const)('%s has exactly the same key set as en', (lang) => {
expect(leafKeys(LOCALES[lang]).sort()).toEqual(leafKeys(en).sort());
});

it('meta titles are actually translated, not copies of the english text', () => {
expect(pt.meta.title).not.toBe(en.meta.title);
expect(es.meta.title).not.toBe(en.meta.title);
});
});

describe('i18n runtime', () => {
beforeEach(async () => {
await i18n.changeLanguage('en');
});

it('falls back to en and resolves nav keys in every language', async () => {
expect(i18n.options.fallbackLng).toEqual(['en']);
for (const lang of LANGS) {
await i18n.changeLanguage(lang);
expect(i18n.t('nav.play')).toBe(LOCALES[lang].nav.play);
expect(i18n.t('nav.login')).toBe(LOCALES[lang].nav.login);
}
});

it('syncs document.title and the meta description on languageChanged', async () => {
const meta = document.createElement('meta');
meta.setAttribute('name', 'description');
document.head.appendChild(meta);

await i18n.changeLanguage('pt');

expect(document.title).toBe(pt.meta.title);
expect(meta.getAttribute('content')).toBe(pt.meta.description);

await i18n.changeLanguage('es');

expect(document.title).toBe(es.meta.title);
expect(meta.getAttribute('content')).toBe(es.meta.description);
});

it('maps pt to the pt-BR html lang tag and keeps en/es as-is', async () => {
await i18n.changeLanguage('pt');
expect(document.documentElement.lang).toBe('pt-BR');

await i18n.changeLanguage('en');
expect(document.documentElement.lang).toBe('en');

await i18n.changeLanguage('es');
expect(document.documentElement.lang).toBe('es');
});
});
33 changes: 33 additions & 0 deletions client/src/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { cn, formatMs } from './utils';

describe('cn', () => {
it('joins class names and drops falsy values', () => {
expect(cn('a', false && 'b', undefined, 'c')).toBe('a c');
});

it('lets the last conflicting tailwind class win', () => {
expect(cn('px-2', 'px-4')).toBe('px-4');
expect(cn('text-muted-foreground', 'text-foreground')).toBe('text-foreground');
});

it('supports conditional object syntax', () => {
expect(cn({ 'bg-muted': true, hidden: false })).toBe('bg-muted');
});
});

describe('formatMs', () => {
it('formats minutes and zero-padded seconds', () => {
expect(formatMs(65_000)).toBe('1:05');
expect(formatMs(600_000)).toBe('10:00');
});

it('floors partial seconds', () => {
expect(formatMs(59_999)).toBe('0:59');
});

it('clamps negative values to 0:00', () => {
expect(formatMs(-5_000)).toBe('0:00');
expect(formatMs(0)).toBe('0:00');
});
});
71 changes: 71 additions & 0 deletions client/src/stores/auth.store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useAuthStore } from './auth.store';

const user = {
id: 'u1',
username: 'magnus',
email: 'magnus@example.com',
avatarUrl: null,
isAdmin: false,
};

describe('auth store', () => {
beforeEach(() => {
localStorage.clear();
useAuthStore.getState().clearAuth();
});

it('starts unauthenticated with no user or tokens', () => {
const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(false);
expect(state.user).toBeNull();
expect(state.accessToken).toBeNull();
expect(state.refreshToken).toBeNull();
});

it('setAuth stores the user with both tokens and flips isAuthenticated', () => {
useAuthStore.getState().setAuth(user, 'access-1', 'refresh-1');

const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(true);
expect(state.user).toEqual(user);
expect(state.accessToken).toBe('access-1');
expect(state.refreshToken).toBe('refresh-1');
});

it('updateAccessToken replaces only the access token', () => {
useAuthStore.getState().setAuth(user, 'access-1', 'refresh-1');
useAuthStore.getState().updateAccessToken('access-2');

const state = useAuthStore.getState();
expect(state.accessToken).toBe('access-2');
expect(state.refreshToken).toBe('refresh-1');
expect(state.user).toEqual(user);
expect(state.isAuthenticated).toBe(true);
});

it('clearAuth wipes the entire session', () => {
useAuthStore.getState().setAuth(user, 'access-1', 'refresh-1');
useAuthStore.getState().clearAuth();

const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(false);
expect(state.user).toBeNull();
expect(state.accessToken).toBeNull();
expect(state.refreshToken).toBeNull();
});

it('persists the session under the chesskernel-auth localStorage key', () => {
useAuthStore.getState().setAuth(user, 'access-1', 'refresh-1');

const raw = localStorage.getItem('chesskernel-auth');
expect(raw).not.toBeNull();
const persisted = JSON.parse(raw as string);
expect(persisted.state).toEqual({
user,
accessToken: 'access-1',
refreshToken: 'refresh-1',
isAuthenticated: true,
});
});
});
38 changes: 38 additions & 0 deletions client/src/stores/theme.store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useThemeStore } from './theme.store';

describe('theme store', () => {
beforeEach(() => {
localStorage.clear();
useThemeStore.getState().setTheme('dark');
});

it('defaults to dark theme', () => {
expect(useThemeStore.getState().theme).toBe('dark');
});

it('setTheme applies the dark class on the document root', () => {
useThemeStore.getState().setTheme('light');
expect(useThemeStore.getState().theme).toBe('light');
expect(document.documentElement.classList.contains('dark')).toBe(false);

useThemeStore.getState().setTheme('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});

it('toggle flips between dark and light', () => {
useThemeStore.getState().toggle();
expect(useThemeStore.getState().theme).toBe('light');

useThemeStore.getState().toggle();
expect(useThemeStore.getState().theme).toBe('dark');
});

it('persists the selection under the chesskernel-theme localStorage key', () => {
useThemeStore.getState().setTheme('light');

const raw = localStorage.getItem('chesskernel-theme');
expect(raw).not.toBeNull();
expect(JSON.parse(raw as string).state.theme).toBe('light');
});
});
Loading
Loading