diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2280ea3..d7203b1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/client/package.json b/client/package.json
index 195cfd6..694e6f8 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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",
@@ -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",
diff --git a/client/src/components/layout/Navbar.test.tsx b/client/src/components/layout/Navbar.test.tsx
new file mode 100644
index 0000000..3b1ea5b
--- /dev/null
+++ b/client/src/components/layout/Navbar.test.tsx
@@ -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(
+
+
+ ,
+ );
+}
+
+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();
+ });
+});
diff --git a/client/src/i18n/i18n.test.ts b/client/src/i18n/i18n.test.ts
new file mode 100644
index 0000000..3c07d7e
--- /dev/null
+++ b/client/src/i18n/i18n.test.ts
@@ -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;
+
+/** Collects dot-notation leaf keys of a nested locale object. */
+function leafKeys(obj: Record, 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, 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');
+ });
+});
diff --git a/client/src/lib/utils.test.ts b/client/src/lib/utils.test.ts
new file mode 100644
index 0000000..669a735
--- /dev/null
+++ b/client/src/lib/utils.test.ts
@@ -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');
+ });
+});
diff --git a/client/src/stores/auth.store.test.ts b/client/src/stores/auth.store.test.ts
new file mode 100644
index 0000000..8a67a8b
--- /dev/null
+++ b/client/src/stores/auth.store.test.ts
@@ -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,
+ });
+ });
+});
diff --git a/client/src/stores/theme.store.test.ts b/client/src/stores/theme.store.test.ts
new file mode 100644
index 0000000..c6625c8
--- /dev/null
+++ b/client/src/stores/theme.store.test.ts
@@ -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');
+ });
+});
diff --git a/client/vitest.config.ts b/client/vitest.config.ts
new file mode 100644
index 0000000..7ab9f92
--- /dev/null
+++ b/client/vitest.config.ts
@@ -0,0 +1,14 @@
+import { mergeConfig, defineConfig } from 'vitest/config';
+import viteConfig from './vite.config';
+
+export default mergeConfig(
+ viteConfig,
+ defineConfig({
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ include: ['src/**/*.test.{ts,tsx}'],
+ restoreMocks: true,
+ },
+ }),
+);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7959bb8..6e48f2b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -99,6 +99,9 @@ importers:
'@tailwindcss/typography':
specifier: ^0.5.13
version: 0.5.20(tailwindcss@3.4.19)
+ '@testing-library/react':
+ specifier: ^15.0.7
+ version: 15.0.7(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1)
'@types/node':
specifier: ^20.12.12
version: 20.19.43
@@ -129,6 +132,9 @@ importers:
eslint-plugin-react-refresh:
specifier: ^0.4.7
version: 0.4.26(eslint@8.57.1)
+ jsdom:
+ specifier: ^24.1.0
+ version: 24.1.3
postcss:
specifier: ^8.4.38
version: 8.5.16
@@ -143,7 +149,7 @@ importers:
version: 5.4.21(@types/node@20.19.43)
vitest:
specifier: ^1.6.0
- version: 1.6.1(@types/node@20.19.43)
+ version: 1.6.1(@types/node@20.19.43)(jsdom@24.1.3)
server:
dependencies:
@@ -352,6 +358,16 @@ packages:
- chokidar
dev: true
+ /@asamuzakjp/css-color@3.2.0:
+ resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
+ dependencies:
+ '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-tokenizer': 3.0.4
+ lru-cache: 10.4.3
+ dev: true
+
/@babel/code-frame@7.29.7:
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -712,6 +728,49 @@ packages:
'@jridgewell/trace-mapping': 0.3.9
dev: true
+ /@csstools/color-helpers@5.1.0:
+ resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
+ engines: {node: '>=18'}
+ dev: true
+
+ /@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4):
+ resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^3.0.5
+ '@csstools/css-tokenizer': ^3.0.4
+ dependencies:
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-tokenizer': 3.0.4
+ dev: true
+
+ /@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4):
+ resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^3.0.5
+ '@csstools/css-tokenizer': ^3.0.4
+ dependencies:
+ '@csstools/color-helpers': 5.1.0
+ '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-tokenizer': 3.0.4
+ dev: true
+
+ /@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4):
+ resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^3.0.4
+ dependencies:
+ '@csstools/css-tokenizer': 3.0.4
+ dev: true
+
+ /@csstools/css-tokenizer@3.0.4:
+ resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
+ engines: {node: '>=18'}
+ dev: true
+
/@esbuild/aix-ppc64@0.21.5:
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -2907,6 +2966,39 @@ packages:
react: 18.3.1
dev: false
+ /@testing-library/dom@10.4.1:
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/runtime': 7.29.7
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+ dev: true
+
+ /@testing-library/react@15.0.7(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-cg0RvEdD1TIhhkm1IeYMQxrzy0MtUNfa3minv4MjbgcYzJAZ7yD0i0lwoPOTPr+INtiXFezt2o8xMSnyHhEn2Q==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/react': ^18.0.0
+ react: ^18.0.0
+ react-dom: ^18.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@testing-library/dom': 10.4.1
+ '@types/react': 18.3.31
+ '@types/react-dom': 18.3.7(@types/react@18.3.31)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ dev: true
+
/@tokenizer/inflate@0.2.7:
resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==}
engines: {node: '>=18'}
@@ -2936,6 +3028,10 @@ packages:
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
dev: true
+ /@types/aria-query@5.0.4:
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+ dev: true
+
/@types/babel__core@7.20.5:
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
dependencies:
@@ -3542,6 +3638,11 @@ packages:
- supports-color
dev: false
+ /agent-base@7.1.4:
+ resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
+ engines: {node: '>= 14'}
+ dev: true
+
/ajv-formats@2.1.1(ajv@8.12.0):
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -3697,6 +3798,12 @@ packages:
tslib: 2.8.1
dev: false
+ /aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+ dependencies:
+ dequal: 2.0.3
+ dev: true
+
/array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
@@ -3713,6 +3820,10 @@ packages:
resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==}
dev: true
+ /asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+ dev: true
+
/autoprefixer@10.5.2(postcss@8.5.16):
resolution: {integrity: sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==}
engines: {node: ^10 || ^12 || >=14}
@@ -4178,6 +4289,13 @@ packages:
hasBin: true
dev: false
+ /combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+ dependencies:
+ delayed-stream: 1.0.0
+ dev: true
+
/commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
dev: true
@@ -4342,9 +4460,25 @@ packages:
hasBin: true
dev: true
+ /cssstyle@4.6.0:
+ resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
+ engines: {node: '>=18'}
+ dependencies:
+ '@asamuzakjp/css-color': 3.2.0
+ rrweb-cssom: 0.8.0
+ dev: true
+
/csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ /data-urls@5.0.0:
+ resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
+ engines: {node: '>=18'}
+ dependencies:
+ whatwg-mimetype: 4.0.0
+ whatwg-url: 14.2.0
+ dev: true
+
/date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
@@ -4384,6 +4518,10 @@ packages:
dependencies:
ms: 2.1.3
+ /decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+ dev: true
+
/dedent@1.7.2:
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
peerDependencies:
@@ -4424,6 +4562,11 @@ packages:
gopd: 1.2.0
dev: true
+ /delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+ dev: true
+
/delegates@1.0.0:
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
dev: false
@@ -4437,6 +4580,11 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
+ /dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+ dev: true
+
/destroy@1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -4495,6 +4643,10 @@ packages:
esutils: 2.0.3
dev: true
+ /dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+ dev: true
+
/dotenv-expand@10.0.0:
resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==}
engines: {node: '>=12'}
@@ -4591,6 +4743,11 @@ packages:
tapable: 2.3.3
dev: true
+ /entities@6.0.1:
+ resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+ engines: {node: '>=0.12'}
+ dev: true
+
/error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
dependencies:
@@ -4619,6 +4776,16 @@ packages:
dependencies:
es-errors: 1.3.0
+ /es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.4
+ dev: true
+
/esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -5115,6 +5282,17 @@ packages:
webpack: 5.97.1
dev: true
+ /form-data@4.0.6:
+ resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
+ engines: {node: '>= 6'}
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.4
+ mime-types: 2.1.35
+ dev: true
+
/forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@@ -5358,6 +5536,13 @@ packages:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
+ /has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ has-symbols: 1.1.0
+ dev: true
+
/has-unicode@2.0.1:
resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
dev: false
@@ -5374,6 +5559,13 @@ packages:
react-is: 16.13.1
dev: false
+ /html-encoding-sniffer@4.0.0:
+ resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
+ engines: {node: '>=18'}
+ dependencies:
+ whatwg-encoding: 3.1.1
+ dev: true
+
/html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
dev: true
@@ -5394,6 +5586,16 @@ packages:
statuses: 2.0.2
toidentifier: 1.0.1
+ /http-proxy-agent@7.0.2:
+ resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
+ engines: {node: '>= 14'}
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
/https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
@@ -5404,6 +5606,16 @@ packages:
- supports-color
dev: false
+ /https-proxy-agent@7.0.6:
+ resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+ engines: {node: '>= 14'}
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
/human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -5431,6 +5643,13 @@ packages:
dependencies:
safer-buffer: 2.1.2
+ /iconv-lite@0.6.3:
+ resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+ engines: {node: '>=0.10.0'}
+ dependencies:
+ safer-buffer: 2.1.2
+ dev: true
+
/ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -5586,6 +5805,10 @@ packages:
engines: {node: '>=8'}
dev: true
+ /is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+ dev: true
+
/is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
@@ -6135,6 +6358,42 @@ packages:
argparse: 2.0.1
dev: true
+ /jsdom@24.1.3:
+ resolution: {integrity: sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ canvas: ^2.11.2
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+ dependencies:
+ cssstyle: 4.6.0
+ data-urls: 5.0.0
+ decimal.js: 10.6.0
+ form-data: 4.0.6
+ html-encoding-sniffer: 4.0.0
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ is-potential-custom-element-name: 1.0.1
+ nwsapi: 2.2.24
+ parse5: 7.3.0
+ rrweb-cssom: 0.7.1
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 4.1.4
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 7.0.0
+ whatwg-encoding: 3.1.1
+ whatwg-mimetype: 4.0.0
+ whatwg-url: 14.2.0
+ ws: 8.21.0
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ dev: true
+
/jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -6370,7 +6629,6 @@ packages:
hasBin: true
dependencies:
js-tokens: 4.0.0
- dev: false
/loupe@2.3.7:
resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==}
@@ -6401,6 +6659,11 @@ packages:
engines: {node: '>=12'}
dev: false
+ /lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+ dev: true
+
/magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
dependencies:
@@ -6756,6 +7019,10 @@ packages:
set-blocking: 2.0.0
dev: false
+ /nwsapi@2.2.24:
+ resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==}
+ dev: true
+
/object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -6886,6 +7153,12 @@ packages:
lines-and-columns: 1.2.4
dev: true
+ /parse5@7.3.0:
+ resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+ dependencies:
+ entities: 6.0.1
+ dev: true
+
/parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -7120,6 +7393,15 @@ packages:
hasBin: true
dev: true
+ /pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+ dev: true
+
/pretty-format@29.7.0:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -7162,6 +7444,12 @@ packages:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+ /psl@1.15.0:
+ resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
+ dependencies:
+ punycode: 2.3.1
+ dev: true
+
/punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -7177,6 +7465,10 @@ packages:
dependencies:
side-channel: 1.1.1
+ /querystringify@2.2.0:
+ resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
+ dev: true
+
/queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true
@@ -7257,7 +7549,6 @@ packages:
loose-envify: 1.4.0
react: 18.3.1
scheduler: 0.23.2
- dev: false
/react-i18next@17.0.8(i18next@26.3.4)(react-dom@18.3.1)(react@18.3.1)(typescript@5.9.3):
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
@@ -7288,6 +7579,10 @@ packages:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false
+ /react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+ dev: true
+
/react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
dev: true
@@ -7376,7 +7671,6 @@ packages:
engines: {node: '>=0.10.0'}
dependencies:
loose-envify: 1.4.0
- dev: false
/read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
@@ -7440,6 +7734,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
+ /requires-port@1.0.0:
+ resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
+ dev: true
+
/resolve-cwd@3.0.0:
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
engines: {node: '>=8'}
@@ -7528,6 +7826,14 @@ packages:
fsevents: 2.3.3
dev: true
+ /rrweb-cssom@0.7.1:
+ resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==}
+ dev: true
+
+ /rrweb-cssom@0.8.0:
+ resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
+ dev: true
+
/run-async@2.4.1:
resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
engines: {node: '>=0.12.0'}
@@ -7561,11 +7867,17 @@ packages:
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ /saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+ dependencies:
+ xmlchars: 2.2.0
+ dev: true
+
/scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
dependencies:
loose-envify: 1.4.0
- dev: false
/schema-utils@3.3.0:
resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==}
@@ -7975,6 +8287,10 @@ packages:
engines: {node: '>=0.10'}
dev: true
+ /symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+ dev: true
+
/tailwind-merge@2.6.1:
resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
dev: false
@@ -8182,9 +8498,26 @@ packages:
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
+ /tough-cookie@4.1.4:
+ resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
+ engines: {node: '>=6'}
+ dependencies:
+ psl: 1.15.0
+ punycode: 2.3.1
+ universalify: 0.2.0
+ url-parse: 1.5.10
+ dev: true
+
/tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+ /tr46@5.1.1:
+ resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
+ engines: {node: '>=18'}
+ dependencies:
+ punycode: 2.3.1
+ dev: true
+
/tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -8442,6 +8775,11 @@ packages:
/undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ /universalify@0.2.0:
+ resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
+ engines: {node: '>= 4.0.0'}
+ dev: true
+
/universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
@@ -8468,6 +8806,13 @@ packages:
punycode: 2.3.1
dev: true
+ /url-parse@1.5.10:
+ resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
+ dependencies:
+ querystringify: 2.2.0
+ requires-port: 1.0.0
+ dev: true
+
/use-callback-ref@1.3.3(@types/react@18.3.31)(react@18.3.1):
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
@@ -8607,7 +8952,7 @@ packages:
fsevents: 2.3.3
dev: true
- /vitest@1.6.1(@types/node@20.19.43):
+ /vitest@1.6.1(@types/node@20.19.43)(jsdom@24.1.3):
resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
@@ -8642,6 +8987,7 @@ packages:
chai: 4.5.0
debug: 4.4.3
execa: 8.0.1
+ jsdom: 24.1.3
local-pkg: 0.5.1
magic-string: 0.30.21
pathe: 1.1.2
@@ -8669,6 +9015,13 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
+ /w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+ dependencies:
+ xml-name-validator: 5.0.0
+ dev: true
+
/walker@1.0.8:
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
dependencies:
@@ -8691,6 +9044,11 @@ packages:
/webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+ /webidl-conversions@7.0.0:
+ resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+ engines: {node: '>=12'}
+ dev: true
+
/webpack-node-externals@3.0.0:
resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==}
engines: {node: '>=6'}
@@ -8796,6 +9154,27 @@ packages:
- uglify-js
dev: true
+ /whatwg-encoding@3.1.1:
+ resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
+ engines: {node: '>=18'}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
+ dependencies:
+ iconv-lite: 0.6.3
+ dev: true
+
+ /whatwg-mimetype@4.0.0:
+ resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
+ engines: {node: '>=18'}
+ dev: true
+
+ /whatwg-url@14.2.0:
+ resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
+ engines: {node: '>=18'}
+ dependencies:
+ tr46: 5.1.1
+ webidl-conversions: 7.0.0
+ dev: true
+
/whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
dependencies:
@@ -8884,6 +9263,15 @@ packages:
utf-8-validate:
optional: true
+ /xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+ dev: true
+
+ /xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+ dev: true
+
/xmlhttprequest-ssl@2.1.2:
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
engines: {node: '>=0.4.0'}
diff --git a/server/src/modules/auth/auth.service.spec.ts b/server/src/modules/auth/auth.service.spec.ts
new file mode 100644
index 0000000..61ef1c4
--- /dev/null
+++ b/server/src/modules/auth/auth.service.spec.ts
@@ -0,0 +1,263 @@
+import { Test } from '@nestjs/testing';
+import { UnauthorizedException, ConflictException } from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
+import { ConfigService } from '@nestjs/config';
+import * as bcrypt from 'bcrypt';
+import * as crypto from 'crypto';
+import { AuthService } from './auth.service';
+import { PrismaService } from '../../database/prisma.service';
+import { UsersService } from '../users/users.service';
+
+const sha256 = (value: string) => crypto.createHash('sha256').update(value).digest('hex');
+
+describe('AuthService', () => {
+ let service: AuthService;
+ let passwordHash: string;
+
+ const prisma = {
+ user: {
+ findUnique: jest.fn(),
+ create: jest.fn(),
+ update: jest.fn(),
+ },
+ refreshToken: {
+ create: jest.fn(),
+ findFirst: jest.fn(),
+ update: jest.fn(),
+ updateMany: jest.fn(),
+ },
+ };
+
+ const jwtService = { sign: jest.fn().mockReturnValue('signed-access-token') };
+ const configService = { get: jest.fn().mockReturnValue('7d') };
+
+ const dbUser = {
+ id: 'u1',
+ username: 'magnus',
+ email: 'magnus@example.com',
+ avatarUrl: null,
+ isAdmin: false,
+ isBanned: false,
+ passwordHash: '',
+ };
+
+ beforeAll(async () => {
+ passwordHash = await bcrypt.hash('correct-horse', 4);
+ dbUser.passwordHash = passwordHash;
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ jwtService.sign.mockReturnValue('signed-access-token');
+ configService.get.mockReturnValue('7d');
+
+ const moduleRef = await Test.createTestingModule({
+ providers: [
+ AuthService,
+ { provide: PrismaService, useValue: prisma },
+ { provide: JwtService, useValue: jwtService },
+ { provide: ConfigService, useValue: configService },
+ { provide: UsersService, useValue: {} },
+ ],
+ }).compile();
+
+ service = moduleRef.get(AuthService);
+ });
+
+ describe('validateUser', () => {
+ it('returns null when no user matches the email', async () => {
+ prisma.user.findUnique.mockResolvedValue(null);
+ expect(await service.validateUser('nobody@example.com', 'pw')).toBeNull();
+ });
+
+ it('throws UnauthorizedException for banned accounts before checking the password', async () => {
+ prisma.user.findUnique.mockResolvedValue({ ...dbUser, isBanned: true });
+ await expect(service.validateUser(dbUser.email, 'correct-horse')).rejects.toThrow(
+ UnauthorizedException,
+ );
+ });
+
+ it('returns null for a wrong password', async () => {
+ prisma.user.findUnique.mockResolvedValue(dbUser);
+ expect(await service.validateUser(dbUser.email, 'wrong-password')).toBeNull();
+ });
+
+ it('returns the public auth user shape for valid credentials', async () => {
+ prisma.user.findUnique.mockResolvedValue(dbUser);
+
+ const result = await service.validateUser(dbUser.email, 'correct-horse');
+
+ expect(result).toEqual({
+ id: 'u1',
+ username: 'magnus',
+ email: 'magnus@example.com',
+ avatarUrl: null,
+ isAdmin: false,
+ });
+ });
+ });
+
+ describe('register', () => {
+ const dto = { username: 'magnus', email: 'magnus@example.com', password: 'correct-horse' };
+
+ it('rejects duplicate emails with ConflictException', async () => {
+ prisma.user.findUnique.mockResolvedValueOnce(dbUser);
+ await expect(service.register(dto)).rejects.toThrow(ConflictException);
+ expect(prisma.user.create).not.toHaveBeenCalled();
+ });
+
+ it('rejects duplicate usernames with ConflictException', async () => {
+ prisma.user.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(dbUser);
+ await expect(service.register(dto)).rejects.toThrow(ConflictException);
+ expect(prisma.user.create).not.toHaveBeenCalled();
+ });
+
+ it('creates the user with a bcrypt hash and one rating row per time control', async () => {
+ prisma.user.findUnique.mockResolvedValue(null);
+ prisma.user.create.mockResolvedValue(dbUser);
+ prisma.refreshToken.create.mockResolvedValue({});
+
+ await service.register(dto);
+
+ const createArg = prisma.user.create.mock.calls[0][0];
+ expect(createArg.data.username).toBe('magnus');
+ expect(createArg.data.email).toBe('magnus@example.com');
+ expect(createArg.data.passwordHash).not.toBe('correct-horse');
+ expect(await bcrypt.compare('correct-horse', createArg.data.passwordHash)).toBe(true);
+ expect(createArg.data.ratings.create).toEqual([
+ { timeControl: 'bullet' },
+ { timeControl: 'blitz' },
+ { timeControl: 'rapid' },
+ { timeControl: 'classical' },
+ ]);
+ });
+
+ it('returns a full token pair with the public user shape', async () => {
+ prisma.user.findUnique.mockResolvedValue(null);
+ prisma.user.create.mockResolvedValue(dbUser);
+ prisma.refreshToken.create.mockResolvedValue({});
+
+ const result = await service.register(dto);
+
+ expect(result.accessToken).toBe('signed-access-token');
+ expect(typeof result.refreshToken).toBe('string');
+ expect(result.refreshToken.length).toBe(96);
+ expect(result.expiresIn).toBe(900);
+ expect(result.user).toEqual({
+ id: 'u1',
+ username: 'magnus',
+ email: 'magnus@example.com',
+ avatarUrl: null,
+ isAdmin: false,
+ });
+ });
+ });
+
+ describe('login', () => {
+ it('bumps lastSeenAt and issues tokens signed with sub and username', async () => {
+ prisma.user.update.mockResolvedValue({});
+ prisma.refreshToken.create.mockResolvedValue({});
+
+ const result = await service.login({
+ id: 'u1',
+ username: 'magnus',
+ email: 'magnus@example.com',
+ avatarUrl: null,
+ isAdmin: false,
+ });
+
+ expect(prisma.user.update).toHaveBeenCalledWith({
+ where: { id: 'u1' },
+ data: { lastSeenAt: expect.any(Date) },
+ });
+ expect(jwtService.sign).toHaveBeenCalledWith({ sub: 'u1', username: 'magnus' });
+ expect(result.accessToken).toBe('signed-access-token');
+ });
+
+ it('stores only a sha256 hash of the refresh token, expiring in 7 days', async () => {
+ prisma.user.update.mockResolvedValue({});
+ prisma.refreshToken.create.mockResolvedValue({});
+
+ const before = Date.now();
+ const result = await service.login({
+ id: 'u1',
+ username: 'magnus',
+ email: 'magnus@example.com',
+ avatarUrl: null,
+ isAdmin: false,
+ });
+
+ const stored = prisma.refreshToken.create.mock.calls[0][0].data;
+ expect(stored.userId).toBe('u1');
+ expect(stored.tokenHash).toBe(sha256(result.refreshToken));
+ expect(stored.tokenHash).not.toBe(result.refreshToken);
+
+ const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
+ expect(stored.expiresAt.getTime() - before).toBeGreaterThan(sevenDaysMs - 60_000);
+ expect(stored.expiresAt.getTime() - before).toBeLessThan(sevenDaysMs + 60_000);
+ });
+ });
+
+ describe('refresh', () => {
+ const storedToken = {
+ id: 'rt1',
+ revoked: false,
+ expiresAt: new Date(Date.now() + 86_400_000),
+ user: dbUser,
+ };
+
+ it('rejects tokens with no matching non-revoked hash', async () => {
+ prisma.refreshToken.findFirst.mockResolvedValue(null);
+
+ await expect(service.refresh('bogus')).rejects.toThrow('Invalid refresh token');
+ expect(prisma.refreshToken.findFirst).toHaveBeenCalledWith({
+ where: { tokenHash: sha256('bogus'), revoked: false },
+ include: { user: true },
+ });
+ });
+
+ it('revokes and rejects expired tokens', async () => {
+ prisma.refreshToken.findFirst.mockResolvedValue({
+ ...storedToken,
+ expiresAt: new Date(Date.now() - 1000),
+ });
+ prisma.refreshToken.update.mockResolvedValue({});
+
+ await expect(service.refresh('old-token')).rejects.toThrow('Refresh token expired');
+ expect(prisma.refreshToken.update).toHaveBeenCalledWith({
+ where: { id: 'rt1' },
+ data: { revoked: true },
+ });
+ });
+
+ it('rotates the token: revokes the old one and issues a brand new pair', async () => {
+ prisma.refreshToken.findFirst.mockResolvedValue(storedToken);
+ prisma.refreshToken.update.mockResolvedValue({});
+ prisma.refreshToken.create.mockResolvedValue({});
+
+ const result = await service.refresh('valid-token');
+
+ expect(prisma.refreshToken.update).toHaveBeenCalledWith({
+ where: { id: 'rt1' },
+ data: { revoked: true },
+ });
+ const newHash = prisma.refreshToken.create.mock.calls[0][0].data.tokenHash;
+ expect(newHash).toBe(sha256(result.refreshToken));
+ expect(newHash).not.toBe(sha256('valid-token'));
+ expect(result.user.id).toBe('u1');
+ });
+ });
+
+ describe('logout', () => {
+ it('revokes every active refresh token for the user', async () => {
+ prisma.refreshToken.updateMany.mockResolvedValue({});
+
+ await service.logout('u1');
+
+ expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({
+ where: { userId: 'u1', revoked: false },
+ data: { revoked: true },
+ });
+ });
+ });
+});
diff --git a/server/src/modules/games/clock.service.spec.ts b/server/src/modules/games/clock.service.spec.ts
new file mode 100644
index 0000000..f607e02
--- /dev/null
+++ b/server/src/modules/games/clock.service.spec.ts
@@ -0,0 +1,70 @@
+import { ClockService } from './clock.service';
+import { GameStateService } from './game-state.service';
+
+describe('ClockService', () => {
+ let service: ClockService;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ service = new ClockService({} as GameStateService);
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('fires the timeout callback 500ms after the remaining time (grace period)', () => {
+ const onTimeout = jest.fn();
+ service.scheduleTimeout('g1', 'white', 1_000, onTimeout);
+
+ jest.advanceTimersByTime(1_499);
+ expect(onTimeout).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(1);
+ expect(onTimeout).toHaveBeenCalledWith('g1', 'white');
+ expect(service.hasActiveTimeout('g1')).toBe(false);
+ });
+
+ it('cancelTimeout prevents the callback from firing', () => {
+ const onTimeout = jest.fn();
+ service.scheduleTimeout('g1', 'black', 1_000, onTimeout);
+
+ service.cancelTimeout('g1');
+ jest.advanceTimersByTime(10_000);
+
+ expect(onTimeout).not.toHaveBeenCalled();
+ expect(service.hasActiveTimeout('g1')).toBe(false);
+ });
+
+ it('rescheduling the same game replaces the previous timeout', () => {
+ const first = jest.fn();
+ const second = jest.fn();
+ service.scheduleTimeout('g1', 'white', 1_000, first);
+ service.scheduleTimeout('g1', 'black', 5_000, second);
+
+ jest.advanceTimersByTime(2_000);
+ expect(first).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(3_500);
+ expect(second).toHaveBeenCalledWith('g1', 'black');
+ });
+
+ it('tracks timeouts per game independently', () => {
+ const onTimeout = jest.fn();
+ service.scheduleTimeout('g1', 'white', 1_000, onTimeout);
+ service.scheduleTimeout('g2', 'white', 1_000, onTimeout);
+
+ service.cancelTimeout('g1');
+
+ expect(service.hasActiveTimeout('g1')).toBe(false);
+ expect(service.hasActiveTimeout('g2')).toBe(true);
+
+ jest.advanceTimersByTime(1_500);
+ expect(onTimeout).toHaveBeenCalledTimes(1);
+ expect(onTimeout).toHaveBeenCalledWith('g2', 'white');
+ });
+
+ it('cancelTimeout on an unknown game is a no-op', () => {
+ expect(() => service.cancelTimeout('unknown')).not.toThrow();
+ });
+});
diff --git a/server/src/modules/games/game-state.service.spec.ts b/server/src/modules/games/game-state.service.spec.ts
new file mode 100644
index 0000000..7ff31a2
--- /dev/null
+++ b/server/src/modules/games/game-state.service.spec.ts
@@ -0,0 +1,171 @@
+import { GameStateService } from './game-state.service';
+import { RedisService } from '../../database/redis.service';
+
+const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
+
+/** Minimal in-memory stand-in for the RedisService hash commands used here. */
+function createFakeRedis() {
+ const hashes = new Map>();
+ return {
+ hashes,
+ hmset: jest.fn(async (key: string, data: Record) => {
+ const existing = hashes.get(key) ?? {};
+ const next: Record = { ...existing };
+ for (const [field, value] of Object.entries(data)) next[field] = String(value);
+ hashes.set(key, next);
+ }),
+ hset: jest.fn(async (key: string, field: string, value: string) => {
+ const existing = hashes.get(key) ?? {};
+ hashes.set(key, { ...existing, [field]: value });
+ }),
+ hgetall: jest.fn(async (key: string) => hashes.get(key) ?? {}),
+ expire: jest.fn(async () => undefined),
+ del: jest.fn(async (...keys: string[]) => {
+ for (const key of keys) hashes.delete(key);
+ }),
+ };
+}
+
+describe('GameStateService', () => {
+ let redis: ReturnType;
+ let service: GameStateService;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2026-01-01T12:00:00Z'));
+ redis = createFakeRedis();
+ service = new GameStateService(redis as unknown as RedisService);
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('initGame stores starting position, equal clocks, white to move, no draw offer', async () => {
+ await service.initGame('g1', 300_000);
+
+ const state = await service.getState('g1');
+ expect(state).toEqual({
+ fen: START_FEN,
+ whiteTimeMs: 300_000,
+ blackTimeMs: 300_000,
+ activeColor: 'white',
+ lastMoveAt: Date.now(),
+ drawOffer: null,
+ });
+ expect(redis.expire).toHaveBeenCalledWith('game:g1:state', 7200);
+ });
+
+ it('getState returns null when no state exists', async () => {
+ expect(await service.getState('unknown')).toBeNull();
+ });
+
+ describe('applyMove', () => {
+ it('returns null for a game with no cached state', async () => {
+ expect(await service.applyMove('nope', 'e2', 'e4', undefined, 0)).toBeNull();
+ });
+
+ it('throws on an illegal move (chess.js v1 throws instead of returning null) and leaves state untouched', async () => {
+ await service.initGame('g1', 300_000);
+
+ await expect(service.applyMove('g1', 'e2', 'e5', undefined, 0)).rejects.toThrow(
+ 'Invalid move',
+ );
+
+ const state = await service.getState('g1');
+ expect(state?.fen).toBe(START_FEN);
+ expect(state?.activeColor).toBe('white');
+ });
+
+ it('applies a legal move: new fen, san, uci, turn passes to black', async () => {
+ await service.initGame('g1', 300_000);
+ jest.advanceTimersByTime(5_000);
+
+ const result = await service.applyMove('g1', 'e2', 'e4', undefined, 0);
+
+ expect(result).not.toBeNull();
+ expect(result?.san).toBe('e4');
+ expect(result?.uci).toBe('e2e4');
+ expect(result?.newFen).toContain(' b KQkq ');
+ expect(result?.clock.activeColor).toBe('black');
+
+ const state = await service.getState('g1');
+ expect(state?.fen).toBe(result?.newFen);
+ expect(state?.activeColor).toBe('black');
+ });
+
+ it('charges elapsed time to the mover and adds the increment, opponent clock untouched', async () => {
+ await service.initGame('g1', 300_000);
+ jest.advanceTimersByTime(5_000);
+
+ const result = await service.applyMove('g1', 'e2', 'e4', undefined, 3_000);
+
+ expect(result?.clock.white).toBe(300_000 - 5_000 + 3_000);
+ expect(result?.clock.black).toBe(300_000);
+ });
+
+ it('clamps the mover clock at zero instead of going negative', async () => {
+ await service.initGame('g1', 10_000);
+ jest.advanceTimersByTime(60_000);
+
+ const result = await service.applyMove('g1', 'e2', 'e4', undefined, 0);
+
+ expect(result?.clock.white).toBe(0);
+ });
+
+ it('includes the promotion piece in the uci string', async () => {
+ const promotionFen = '8/P7/8/8/8/8/7k/K7 w - - 0 1';
+ await service.initGame('g1', 60_000, promotionFen);
+
+ const result = await service.applyMove('g1', 'a7', 'a8', 'q', 0);
+
+ expect(result?.uci).toBe('a7a8q');
+ expect(result?.san).toContain('=Q');
+ });
+
+ it('clears a pending draw offer when a move is played', async () => {
+ await service.initGame('g1', 300_000);
+ await service.setDrawOffer('g1', 'black');
+
+ await service.applyMove('g1', 'e2', 'e4', undefined, 0);
+
+ const state = await service.getState('g1');
+ expect(state?.drawOffer).toBeNull();
+ });
+ });
+
+ describe('draw offers', () => {
+ it('setDrawOffer and clearDrawOffer round-trip through getState', async () => {
+ await service.initGame('g1', 300_000);
+
+ await service.setDrawOffer('g1', 'white');
+ expect((await service.getState('g1'))?.drawOffer).toBe('white');
+
+ await service.clearDrawOffer('g1');
+ expect((await service.getState('g1'))?.drawOffer).toBeNull();
+ });
+ });
+
+ describe('getRemainingTime', () => {
+ it('returns null when no state exists', async () => {
+ expect(await service.getRemainingTime('nope')).toBeNull();
+ });
+
+ it('counts down only the active color', async () => {
+ await service.initGame('g1', 300_000);
+ jest.advanceTimersByTime(20_000);
+
+ const clock = await service.getRemainingTime('g1');
+
+ expect(clock?.white).toBe(280_000);
+ expect(clock?.black).toBe(300_000);
+ expect(clock?.activeColor).toBe('white');
+ });
+ });
+
+ it('deleteGameState removes the cached state', async () => {
+ await service.initGame('g1', 300_000);
+ await service.deleteGameState('g1');
+ expect(await service.getState('g1')).toBeNull();
+ });
+});
diff --git a/server/src/modules/games/games.service.spec.ts b/server/src/modules/games/games.service.spec.ts
new file mode 100644
index 0000000..1ecc442
--- /dev/null
+++ b/server/src/modules/games/games.service.spec.ts
@@ -0,0 +1,308 @@
+import { Test } from '@nestjs/testing';
+import { BadRequestException, NotFoundException } from '@nestjs/common';
+import { GamesService } from './games.service';
+import { PrismaService } from '../../database/prisma.service';
+import { GameStateService } from './game-state.service';
+import { RatingsService } from '../ratings/ratings.service';
+
+describe('GamesService', () => {
+ let service: GamesService;
+
+ const prisma = {
+ game: {
+ create: jest.fn(),
+ findUnique: jest.fn(),
+ findMany: jest.fn(),
+ count: jest.fn(),
+ update: jest.fn(),
+ },
+ gameMove: {
+ create: jest.fn(),
+ findMany: jest.fn(),
+ },
+ };
+
+ const gameStateService = {
+ initGame: jest.fn(),
+ deleteGameState: jest.fn(),
+ };
+
+ const ratingsService = {
+ getRatingForUser: jest.fn(),
+ updateRatingsAfterGame: jest.fn(),
+ };
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+
+ const moduleRef = await Test.createTestingModule({
+ providers: [
+ GamesService,
+ { provide: PrismaService, useValue: prisma },
+ { provide: GameStateService, useValue: gameStateService },
+ { provide: RatingsService, useValue: ratingsService },
+ ],
+ }).compile();
+
+ service = moduleRef.get(GamesService);
+ });
+
+ describe('createGame', () => {
+ it('rejects unknown time control keys', async () => {
+ await expect(service.createGame('w', 'b', 'nonsense_9_9')).rejects.toThrow(
+ BadRequestException,
+ );
+ expect(prisma.game.create).not.toHaveBeenCalled();
+ });
+
+ it('creates a human game with time control config and both ratings snapshot', async () => {
+ ratingsService.getRatingForUser
+ .mockResolvedValueOnce({ rating: 1500 })
+ .mockResolvedValueOnce({ rating: 1450 });
+ prisma.game.create.mockResolvedValue({ id: 'g1' });
+
+ const game = await service.createGame('white-id', 'black-id', 'blitz_5_3');
+
+ expect(prisma.game.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ whiteId: 'white-id',
+ blackId: 'black-id',
+ timeControl: 'blitz',
+ initialTimeMs: 300_000,
+ incrementMs: 3_000,
+ status: 'active',
+ isBotGame: false,
+ botDifficulty: null,
+ whiteRatingBefore: 1500,
+ blackRatingBefore: 1450,
+ }),
+ });
+ expect(gameStateService.initGame).toHaveBeenCalledWith('g1', 300_000);
+ expect(game).toEqual({ id: 'g1' });
+ });
+
+ it('creates a bot game with null blackId and no black rating lookup', async () => {
+ ratingsService.getRatingForUser.mockResolvedValueOnce({ rating: 1300 });
+ prisma.game.create.mockResolvedValue({ id: 'g2' });
+
+ await service.createGame('user-id', 'user-id', 'rapid_10_0', true, 'hard');
+
+ expect(ratingsService.getRatingForUser).toHaveBeenCalledTimes(1);
+ expect(prisma.game.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ whiteId: 'user-id',
+ blackId: null,
+ isBotGame: true,
+ botDifficulty: 'hard',
+ whiteRatingBefore: 1300,
+ blackRatingBefore: null,
+ }),
+ });
+ });
+
+ it('stores null rating snapshots for unrated players', async () => {
+ ratingsService.getRatingForUser.mockResolvedValue(null);
+ prisma.game.create.mockResolvedValue({ id: 'g3' });
+
+ await service.createGame('w', 'b', 'bullet_1_0');
+
+ expect(prisma.game.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ whiteRatingBefore: null,
+ blackRatingBefore: null,
+ }),
+ });
+ });
+ });
+
+ describe('getGameCore', () => {
+ it('throws NotFoundException for a missing game', async () => {
+ prisma.game.findUnique.mockResolvedValue(null);
+ await expect(service.getGameCore('missing')).rejects.toThrow(NotFoundException);
+ });
+
+ it('selects only the trimmed scalar shape plus move count, never the move list', async () => {
+ prisma.game.findUnique.mockResolvedValue({ id: 'g1' });
+
+ await service.getGameCore('g1');
+
+ const arg = prisma.game.findUnique.mock.calls[0][0];
+ expect(arg.where).toEqual({ id: 'g1' });
+ expect(Object.keys(arg.select).sort()).toEqual(
+ [
+ '_count',
+ 'black',
+ 'blackId',
+ 'blackRatingBefore',
+ 'botDifficulty',
+ 'id',
+ 'incrementMs',
+ 'initialTimeMs',
+ 'isBotGame',
+ 'pgn',
+ 'result',
+ 'status',
+ 'termination',
+ 'timeControl',
+ 'white',
+ 'whiteId',
+ 'whiteRatingBefore',
+ ].sort(),
+ );
+ expect(arg.select._count).toEqual({ select: { moves: true } });
+ expect(arg.select.moves).toBeUndefined();
+ expect(arg.include).toBeUndefined();
+ });
+ });
+
+ describe('getUserGames', () => {
+ it('paginates ended games for the user on either color', async () => {
+ prisma.game.findMany.mockResolvedValue([{ id: 'g1' }]);
+ prisma.game.count.mockResolvedValue(41);
+
+ const result = await service.getUserGames('u1', 2, 20);
+
+ expect(prisma.game.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { OR: [{ whiteId: 'u1' }, { blackId: 'u1' }], status: 'ended' },
+ skip: 20,
+ take: 20,
+ orderBy: { createdAt: 'desc' },
+ }),
+ );
+ expect(result).toEqual({
+ games: [{ id: 'g1' }],
+ total: 41,
+ page: 2,
+ limit: 20,
+ totalPages: 3,
+ });
+ });
+ });
+
+ describe('endGame', () => {
+ const activeRatedGame = {
+ id: 'g1',
+ status: 'active',
+ isBotGame: false,
+ whiteId: 'w1',
+ blackId: 'b1',
+ timeControl: 'blitz',
+ };
+
+ it('returns null deltas and does nothing when the game does not exist', async () => {
+ prisma.game.findUnique.mockResolvedValue(null);
+
+ const result = await service.endGame('gx', 'white', 'checkmate');
+
+ expect(result).toEqual({ whiteDelta: null, blackDelta: null });
+ expect(prisma.game.update).not.toHaveBeenCalled();
+ expect(gameStateService.deleteGameState).not.toHaveBeenCalled();
+ });
+
+ it('returns null deltas when the game is already ended (idempotent)', async () => {
+ prisma.game.findUnique.mockResolvedValue({ ...activeRatedGame, status: 'ended' });
+
+ const result = await service.endGame('g1', 'white', 'resignation');
+
+ expect(result).toEqual({ whiteDelta: null, blackDelta: null });
+ expect(prisma.game.update).not.toHaveBeenCalled();
+ });
+
+ it('transitions active to ended, rebuilds pgn from moves, and updates ratings', async () => {
+ prisma.game.findUnique.mockResolvedValue(activeRatedGame);
+ prisma.gameMove.findMany.mockResolvedValue([
+ { san: 'f3' },
+ { san: 'e5' },
+ { san: 'g4' },
+ { san: 'Qh4#' },
+ ]);
+ ratingsService.updateRatingsAfterGame.mockResolvedValue({ whiteDelta: -12, blackDelta: 11 });
+
+ const result = await service.endGame('g1', 'black', 'checkmate');
+
+ expect(prisma.game.update).toHaveBeenCalledWith({
+ where: { id: 'g1' },
+ data: expect.objectContaining({
+ status: 'ended',
+ result: 'black',
+ termination: 'checkmate',
+ pgn: expect.stringContaining('Qh4#'),
+ endedAt: expect.any(Date),
+ }),
+ });
+ expect(ratingsService.updateRatingsAfterGame).toHaveBeenCalledWith(
+ 'g1',
+ 'w1',
+ 'b1',
+ 'black',
+ 'blitz',
+ );
+ expect(gameStateService.deleteGameState).toHaveBeenCalledWith('g1');
+ expect(result).toEqual({ whiteDelta: -12, blackDelta: 11 });
+ });
+
+ it('skips rating updates for bot games', async () => {
+ prisma.game.findUnique.mockResolvedValue({
+ ...activeRatedGame,
+ isBotGame: true,
+ blackId: null,
+ });
+ prisma.gameMove.findMany.mockResolvedValue([]);
+
+ const result = await service.endGame('g1', 'white', 'resignation');
+
+ expect(ratingsService.updateRatingsAfterGame).not.toHaveBeenCalled();
+ expect(result).toEqual({ whiteDelta: null, blackDelta: null });
+ expect(gameStateService.deleteGameState).toHaveBeenCalledWith('g1');
+ });
+
+ it('skips rating updates for abandoned results', async () => {
+ prisma.game.findUnique.mockResolvedValue(activeRatedGame);
+ prisma.gameMove.findMany.mockResolvedValue([]);
+
+ const result = await service.endGame('g1', 'abandoned', 'abandoned');
+
+ expect(ratingsService.updateRatingsAfterGame).not.toHaveBeenCalled();
+ expect(result).toEqual({ whiteDelta: null, blackDelta: null });
+ });
+ });
+
+ describe('recordMove', () => {
+ it('persists the move exactly as provided', async () => {
+ prisma.gameMove.create.mockResolvedValue({ id: 'm1' });
+
+ await service.recordMove('g1', 3, 'white', 'Nf3', 'g1f3', 'fen-after', 295_000);
+
+ expect(prisma.gameMove.create).toHaveBeenCalledWith({
+ data: {
+ gameId: 'g1',
+ moveNumber: 3,
+ color: 'white',
+ san: 'Nf3',
+ uci: 'g1f3',
+ fenAfter: 'fen-after',
+ timeLeftMs: 295_000,
+ },
+ });
+ });
+ });
+
+ describe('abandonGame', () => {
+ it('marks the game abandoned and clears cached state', async () => {
+ prisma.game.update.mockResolvedValue({});
+
+ await service.abandonGame('g1');
+
+ expect(prisma.game.update).toHaveBeenCalledWith({
+ where: { id: 'g1' },
+ data: expect.objectContaining({
+ status: 'abandoned',
+ result: 'abandoned',
+ termination: 'abandoned',
+ }),
+ });
+ expect(gameStateService.deleteGameState).toHaveBeenCalledWith('g1');
+ });
+ });
+});
diff --git a/server/src/modules/leaderboards/leaderboards.service.spec.ts b/server/src/modules/leaderboards/leaderboards.service.spec.ts
new file mode 100644
index 0000000..6613c19
--- /dev/null
+++ b/server/src/modules/leaderboards/leaderboards.service.spec.ts
@@ -0,0 +1,152 @@
+import { Test } from '@nestjs/testing';
+import { LeaderboardsService } from './leaderboards.service';
+import { PrismaService } from '../../database/prisma.service';
+import { RedisService } from '../../database/redis.service';
+
+describe('LeaderboardsService', () => {
+ let service: LeaderboardsService;
+
+ const prisma = {
+ userRating: {
+ findMany: jest.fn(),
+ findUnique: jest.fn(),
+ count: jest.fn(),
+ },
+ };
+
+ const redis = {
+ get: jest.fn(),
+ set: jest.fn(),
+ };
+
+ const entry = (username: string, rating: number) => ({
+ user: { id: `id-${username}`, username, avatarUrl: null, country: null },
+ rating,
+ ratingDeviation: 60,
+ gamesPlayed: 20,
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+
+ const moduleRef = await Test.createTestingModule({
+ providers: [
+ LeaderboardsService,
+ { provide: PrismaService, useValue: prisma },
+ { provide: RedisService, useValue: redis },
+ ],
+ }).compile();
+
+ service = moduleRef.get(LeaderboardsService);
+ });
+
+ describe('getLeaderboard', () => {
+ it('returns the cached payload without touching the database on cache hit', async () => {
+ const cached = { entries: [], total: 0, page: 1, limit: 50, totalPages: 0 };
+ redis.get.mockResolvedValue(JSON.stringify(cached));
+
+ const result = await service.getLeaderboard('blitz', 1, 50);
+
+ expect(result).toEqual(cached);
+ expect(redis.get).toHaveBeenCalledWith('leaderboard:blitz:1:50');
+ expect(prisma.userRating.findMany).not.toHaveBeenCalled();
+ expect(redis.set).not.toHaveBeenCalled();
+ });
+
+ it('queries only players with at least 5 games, ranked by rating desc, on cache miss', async () => {
+ redis.get.mockResolvedValue(null);
+ prisma.userRating.findMany.mockResolvedValue([entry('alice', 1900), entry('bob', 1850)]);
+ prisma.userRating.count.mockResolvedValue(2);
+
+ const result = await service.getLeaderboard('blitz', 1, 50);
+
+ expect(prisma.userRating.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { timeControl: 'blitz', gamesPlayed: { gte: 5 } },
+ orderBy: { rating: 'desc' },
+ skip: 0,
+ take: 50,
+ }),
+ );
+ expect(result.entries.map((e: { rank: number }) => e.rank)).toEqual([1, 2]);
+ expect(result.entries[0].user.username).toBe('alice');
+ expect(result.total).toBe(2);
+ expect(result.totalPages).toBe(1);
+ });
+
+ it('offsets ranks by the page skip', async () => {
+ redis.get.mockResolvedValue(null);
+ prisma.userRating.findMany.mockResolvedValue([entry('carol', 1700)]);
+ prisma.userRating.count.mockResolvedValue(101);
+
+ const result = await service.getLeaderboard('rapid', 3, 50);
+
+ expect(result.entries[0].rank).toBe(101);
+ expect(result.totalPages).toBe(3);
+ });
+
+ it('caches the computed result for 300 seconds', async () => {
+ redis.get.mockResolvedValue(null);
+ prisma.userRating.findMany.mockResolvedValue([]);
+ prisma.userRating.count.mockResolvedValue(0);
+
+ const result = await service.getLeaderboard('bullet', 1, 50);
+
+ expect(redis.set).toHaveBeenCalledWith(
+ 'leaderboard:bullet:1:50',
+ JSON.stringify(result),
+ 300,
+ );
+ });
+
+ it('serves from the database when the cache read fails', async () => {
+ redis.get.mockRejectedValue(new Error('redis down'));
+ prisma.userRating.findMany.mockResolvedValue([entry('dave', 1600)]);
+ prisma.userRating.count.mockResolvedValue(1);
+
+ const result = await service.getLeaderboard('blitz', 1, 50);
+
+ expect(result.entries).toHaveLength(1);
+ });
+
+ it('still returns the result when the cache write fails', async () => {
+ redis.get.mockResolvedValue(null);
+ redis.set.mockRejectedValue(new Error('redis down'));
+ prisma.userRating.findMany.mockResolvedValue([]);
+ prisma.userRating.count.mockResolvedValue(0);
+
+ const result = await service.getLeaderboard('blitz', 1, 50);
+
+ expect(result.total).toBe(0);
+ });
+ });
+
+ describe('getUserRank', () => {
+ it('returns null when the user has no rating record', async () => {
+ prisma.userRating.findUnique.mockResolvedValue(null);
+ expect(await service.getUserRank('u1', 'blitz')).toBeNull();
+ });
+
+ it('returns null for provisional players with fewer than 5 games', async () => {
+ prisma.userRating.findUnique.mockResolvedValue({ rating: 1500, gamesPlayed: 4 });
+ expect(await service.getUserRank('u1', 'blitz')).toBeNull();
+ expect(prisma.userRating.count).not.toHaveBeenCalled();
+ });
+
+ it('ranks the user as one plus the number of qualified higher-rated players', async () => {
+ prisma.userRating.findUnique.mockResolvedValue({ rating: 1500, gamesPlayed: 10 });
+ prisma.userRating.count.mockResolvedValue(7);
+
+ const rank = await service.getUserRank('u1', 'blitz');
+
+ expect(rank).toBe(8);
+ expect(prisma.userRating.count).toHaveBeenCalledWith({
+ where: {
+ timeControl: 'blitz',
+ gamesPlayed: { gte: 5 },
+ rating: { gt: 1500 },
+ },
+ });
+ });
+ });
+});
diff --git a/server/src/modules/matchmaking/matchmaking.service.spec.ts b/server/src/modules/matchmaking/matchmaking.service.spec.ts
new file mode 100644
index 0000000..7a40b67
--- /dev/null
+++ b/server/src/modules/matchmaking/matchmaking.service.spec.ts
@@ -0,0 +1,226 @@
+import { Test } from '@nestjs/testing';
+import { MatchmakingService } from './matchmaking.service';
+import { RedisService } from '../../database/redis.service';
+import { PrismaService } from '../../database/prisma.service';
+import { GamesService } from '../games/games.service';
+import { RatingsService } from '../ratings/ratings.service';
+
+/** In-memory stand-in for the RedisService key/value and sorted-set commands. */
+function createFakeRedis() {
+ const kv = new Map();
+ const zsets = new Map>();
+ return {
+ kv,
+ zsets,
+ get: jest.fn(async (key: string) => kv.get(key) ?? null),
+ set: jest.fn(async (key: string, value: string) => {
+ kv.set(key, value);
+ }),
+ del: jest.fn(async (...keys: string[]) => {
+ for (const key of keys) kv.delete(key);
+ }),
+ zadd: jest.fn(async (key: string, score: number, member: string) => {
+ const set = zsets.get(key) ?? new Map();
+ set.set(member, score);
+ zsets.set(key, set);
+ }),
+ zrem: jest.fn(async (key: string, member: string) => {
+ zsets.get(key)?.delete(member);
+ }),
+ zscore: jest.fn(async (key: string, member: string) => {
+ const score = zsets.get(key)?.get(member);
+ return score === undefined ? null : String(score);
+ }),
+ zrangebyscore: jest.fn(async (key: string, min: number | string, max: number | string) => {
+ const set = zsets.get(key);
+ if (!set) return [];
+ const lo = min === '-inf' ? -Infinity : Number(min);
+ const hi = max === '+inf' ? Infinity : Number(max);
+ return [...set.entries()]
+ .filter(([, score]) => score >= lo && score <= hi)
+ .sort((a, b) => a[1] - b[1])
+ .map(([member]) => member);
+ }),
+ };
+}
+
+describe('MatchmakingService', () => {
+ let service: MatchmakingService;
+ let redis: ReturnType;
+
+ const gamesService = { createGame: jest.fn() };
+ const ratingsService = { getRatingForUser: jest.fn() };
+
+ beforeEach(async () => {
+ jest.restoreAllMocks();
+ jest.clearAllMocks();
+ redis = createFakeRedis();
+ gamesService.createGame.mockResolvedValue({ id: 'game-1' });
+
+ const moduleRef = await Test.createTestingModule({
+ providers: [
+ MatchmakingService,
+ { provide: RedisService, useValue: redis },
+ { provide: PrismaService, useValue: {} },
+ { provide: GamesService, useValue: gamesService },
+ { provide: RatingsService, useValue: ratingsService },
+ ],
+ }).compile();
+
+ service = moduleRef.get(MatchmakingService);
+ });
+
+ describe('joinQueue', () => {
+ it('throws for an unknown time control key', async () => {
+ await expect(service.joinQueue('u1', 'warp_0_0')).rejects.toThrow(
+ 'Unknown time control: warp_0_0',
+ );
+ });
+
+ it('queues a lone player under their rating and returns no match', async () => {
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 1500 });
+
+ const result = await service.joinQueue('u1', 'blitz_5_0');
+
+ expect(result).toBeNull();
+ expect(await redis.zscore('matchmaking:blitz_5_0', 'u1')).toBe('1500');
+ expect(await service.isInQueue('u1')).toBe('blitz_5_0');
+ });
+
+ it('defaults unrated players to 1200', async () => {
+ ratingsService.getRatingForUser.mockResolvedValue(null);
+
+ await service.joinQueue('u1', 'blitz_5_0');
+
+ expect(await redis.zscore('matchmaking:blitz_5_0', 'u1')).toBe('1200');
+ });
+
+ it('matches two players within the base 100 rating window and empties the queue', async () => {
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 1500 });
+ await service.joinQueue('u1', 'blitz_5_0');
+
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 1560 });
+ jest.spyOn(Math, 'random').mockReturnValue(0.1);
+ const result = await service.joinQueue('u2', 'blitz_5_0');
+
+ expect(result).toEqual({ gameId: 'game-1', whiteId: 'u2', blackId: 'u1' });
+ expect(gamesService.createGame).toHaveBeenCalledWith('u2', 'u1', 'blitz_5_0');
+ expect(await service.isInQueue('u1')).toBeNull();
+ expect(await service.isInQueue('u2')).toBeNull();
+ });
+
+ it('assigns the joining player black when the coin flip favors the opponent', async () => {
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 1500 });
+ await service.joinQueue('u1', 'blitz_5_0');
+
+ jest.spyOn(Math, 'random').mockReturnValue(0.9);
+ const result = await service.joinQueue('u2', 'blitz_5_0');
+
+ expect(result).toEqual({ gameId: 'game-1', whiteId: 'u1', blackId: 'u2' });
+ });
+
+ it('does not match players more than 100 points apart with no wait time', async () => {
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 1500 });
+ await service.joinQueue('u1', 'blitz_5_0');
+
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 1650 });
+ const result = await service.joinQueue('u2', 'blitz_5_0');
+
+ expect(result).toBeNull();
+ expect(gamesService.createGame).not.toHaveBeenCalled();
+ expect(await service.isInQueue('u1')).toBe('blitz_5_0');
+ expect(await service.isInQueue('u2')).toBe('blitz_5_0');
+ });
+
+ it('expands the window by 50 points per 10s of waiting, capped at 400', async () => {
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 1500 });
+ await service.joinQueue('opponent', 'blitz_5_0');
+
+ // First Date.now stamps the meta, later calls simulate 120s in the queue,
+ // which yields the capped 400-point window (100 + 12 * 50 > 400).
+ const t0 = Date.now();
+ const nowSpy = jest.spyOn(Date, 'now');
+ nowSpy.mockReturnValueOnce(t0);
+ nowSpy.mockReturnValue(t0 + 120_000);
+
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 1850 });
+ jest.spyOn(Math, 'random').mockReturnValue(0.1);
+ const result = await service.joinQueue('u2', 'blitz_5_0');
+
+ expect(result).toEqual({ gameId: 'game-1', whiteId: 'u2', blackId: 'opponent' });
+ });
+
+ it('does not match players queued for different time controls', async () => {
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 1500 });
+ await service.joinQueue('u1', 'blitz_5_0');
+
+ const result = await service.joinQueue('u2', 'rapid_10_0');
+
+ expect(result).toBeNull();
+ expect(gamesService.createGame).not.toHaveBeenCalled();
+ });
+
+ it('re-joining moves the player to the new queue instead of duplicating entries', async () => {
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 1500 });
+ await service.joinQueue('u1', 'blitz_5_0');
+ await service.joinQueue('u1', 'rapid_10_0');
+
+ expect(await redis.zscore('matchmaking:blitz_5_0', 'u1')).toBeNull();
+ expect(await service.isInQueue('u1')).toBe('rapid_10_0');
+ });
+ });
+
+ describe('leaveQueue / isInQueue', () => {
+ it('leaveQueue removes the player and their meta from every time control queue', async () => {
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 1500 });
+ await service.joinQueue('u1', 'blitz_5_0');
+
+ await service.leaveQueue('u1');
+
+ expect(await service.isInQueue('u1')).toBeNull();
+ expect(await redis.get('matchmaking:meta:blitz_5_0:u1')).toBeNull();
+ });
+
+ it('isInQueue returns null for a player who never joined', async () => {
+ expect(await service.isInQueue('ghost')).toBeNull();
+ });
+ });
+
+ describe('processExpiredQueueEntries', () => {
+ it('removes entries older than 5 minutes and entries with missing meta', async () => {
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 1500 });
+ await service.joinQueue('stale', 'blitz_5_0');
+ redis.kv.set(
+ 'matchmaking:meta:blitz_5_0:stale',
+ JSON.stringify({ joinedAt: Date.now() - 301_000, timeControlKey: 'blitz_5_0' }),
+ );
+
+ ratingsService.getRatingForUser.mockResolvedValue({ rating: 2000 });
+ await service.joinQueue('fresh', 'blitz_5_0');
+
+ await redis.zadd('matchmaking:blitz_5_0', 1400, 'no-meta');
+
+ await service.processExpiredQueueEntries();
+
+ expect(await redis.zscore('matchmaking:blitz_5_0', 'stale')).toBeNull();
+ expect(await redis.zscore('matchmaking:blitz_5_0', 'no-meta')).toBeNull();
+ expect(await redis.zscore('matchmaking:blitz_5_0', 'fresh')).toBe('2000');
+ });
+ });
+
+ describe('createBotGame', () => {
+ it('creates a bot game passing the user as both seats with difficulty flag', async () => {
+ await service.createBotGame('u1', 'rapid_10_0', 'hard', 'white');
+
+ expect(gamesService.createGame).toHaveBeenCalledWith('u1', 'u1', 'rapid_10_0', true, 'hard');
+ });
+
+ it('resolves a random color preference without failing', async () => {
+ jest.spyOn(Math, 'random').mockReturnValue(0.7);
+
+ await service.createBotGame('u1', 'blitz_3_0', 'easy', 'random');
+
+ expect(gamesService.createGame).toHaveBeenCalledWith('u1', 'u1', 'blitz_3_0', true, 'easy');
+ });
+ });
+});
diff --git a/server/src/websocket/game.gateway.spec.ts b/server/src/websocket/game.gateway.spec.ts
new file mode 100644
index 0000000..a3a5f8b
--- /dev/null
+++ b/server/src/websocket/game.gateway.spec.ts
@@ -0,0 +1,395 @@
+import { JwtService } from '@nestjs/jwt';
+import { Server, Socket } from 'socket.io';
+import { GameGateway } from './game.gateway';
+import { GamesService } from '../modules/games/games.service';
+import { GameStateService } from '../modules/games/game-state.service';
+import { ClockService } from '../modules/games/clock.service';
+import { MatchmakingService } from '../modules/matchmaking/matchmaking.service';
+import { BotsService } from '../modules/bots/bots.service';
+import { PresenceService } from '../websocket/presence.service';
+
+const FEN_AFTER_E4 = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPPPPPP/RNBQKBNR b KQkq e3 0 1';
+const FEN_FOOLS_MATE = 'rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3';
+
+describe('GameGateway', () => {
+ let gateway: GameGateway;
+
+ const gamesService = {
+ getGameCore: jest.fn(),
+ recordMove: jest.fn(),
+ endGame: jest.fn(),
+ };
+ const gameStateService = {
+ getState: jest.fn(),
+ applyMove: jest.fn(),
+ setDrawOffer: jest.fn(),
+ clearDrawOffer: jest.fn(),
+ };
+ const clockService = {
+ scheduleTimeout: jest.fn(),
+ cancelTimeout: jest.fn(),
+ };
+ const matchmakingService = { joinQueue: jest.fn(), leaveQueue: jest.fn() };
+ const botsService = { makeBotMove: jest.fn() };
+ const presenceService = {
+ registerSocket: jest.fn(),
+ removeSocket: jest.fn(),
+ heartbeat: jest.fn(),
+ };
+
+ const roomEmit = jest.fn();
+ const socketsJoin = jest.fn();
+ const server = {
+ to: jest.fn().mockReturnValue({ emit: roomEmit, socketsJoin }),
+ };
+
+ const activeGame = {
+ id: 'g1',
+ status: 'active',
+ result: null,
+ termination: null,
+ pgn: null,
+ whiteId: 'w1',
+ blackId: 'b1',
+ timeControl: 'blitz',
+ initialTimeMs: 300_000,
+ incrementMs: 0,
+ isBotGame: false,
+ botDifficulty: null,
+ whiteRatingBefore: 1500,
+ blackRatingBefore: 1480,
+ white: { id: 'w1', username: 'whitey', avatarUrl: null },
+ black: { id: 'b1', username: 'blacky', avatarUrl: null },
+ _count: { moves: 0 },
+ };
+
+ function createSocket(userId: string): Socket {
+ return {
+ data: { userId, username: `user-${userId}` },
+ emit: jest.fn(),
+ join: jest.fn(),
+ leave: jest.fn(),
+ } as unknown as Socket;
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ server.to.mockReturnValue({ emit: roomEmit, socketsJoin });
+ gamesService.endGame.mockResolvedValue({ whiteDelta: 8, blackDelta: -8 });
+
+ gateway = new GameGateway(
+ {} as JwtService,
+ gamesService as unknown as GamesService,
+ gameStateService as unknown as GameStateService,
+ clockService as unknown as ClockService,
+ matchmakingService as unknown as MatchmakingService,
+ botsService as unknown as BotsService,
+ presenceService as unknown as PresenceService,
+ );
+ gateway.server = server as unknown as Server;
+ });
+
+ describe('handleMove', () => {
+ it('rejects moves when the game is not active', async () => {
+ const socket = createSocket('w1');
+ gamesService.getGameCore.mockResolvedValue({ ...activeGame, status: 'ended' });
+
+ await gateway.handleMove(socket, { gameId: 'g1', from: 'e2', to: 'e4' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', {
+ code: 'GAME_NOT_ACTIVE',
+ message: 'Game is not active',
+ });
+ expect(gamesService.recordMove).not.toHaveBeenCalled();
+ });
+
+ it('rejects moves from users who are not players in the game', async () => {
+ const socket = createSocket('intruder');
+ gamesService.getGameCore.mockResolvedValue(activeGame);
+
+ await gateway.handleMove(socket, { gameId: 'g1', from: 'e2', to: 'e4' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', {
+ code: 'NOT_PLAYER',
+ message: 'You are not a player in this game',
+ });
+ expect(gameStateService.applyMove).not.toHaveBeenCalled();
+ });
+
+ it('rejects moves out of turn', async () => {
+ const socket = createSocket('b1');
+ gamesService.getGameCore.mockResolvedValue(activeGame);
+ gameStateService.getState.mockResolvedValue({ activeColor: 'white' });
+
+ await gateway.handleMove(socket, { gameId: 'g1', from: 'e7', to: 'e5' });
+
+ expect(socket.emit).toHaveBeenCalledWith('game:move:rejected', {
+ reason: 'Not your turn',
+ from: 'e7',
+ to: 'e5',
+ });
+ expect(gameStateService.applyMove).not.toHaveBeenCalled();
+ });
+
+ it('rejects illegal moves reported by the state service', async () => {
+ const socket = createSocket('w1');
+ gamesService.getGameCore.mockResolvedValue(activeGame);
+ gameStateService.getState.mockResolvedValue({ activeColor: 'white' });
+ gameStateService.applyMove.mockResolvedValue(null);
+
+ await gateway.handleMove(socket, { gameId: 'g1', from: 'e2', to: 'e5' });
+
+ expect(socket.emit).toHaveBeenCalledWith('game:move:rejected', {
+ reason: 'Illegal move',
+ from: 'e2',
+ to: 'e5',
+ });
+ expect(gamesService.recordMove).not.toHaveBeenCalled();
+ });
+
+ it('records a legal move, broadcasts it, and schedules the opponent clock', async () => {
+ const socket = createSocket('w1');
+ gamesService.getGameCore.mockResolvedValue(activeGame);
+ gameStateService.getState.mockResolvedValue({ activeColor: 'white' });
+ gameStateService.applyMove.mockResolvedValue({
+ san: 'e4',
+ uci: 'e2e4',
+ newFen: FEN_AFTER_E4,
+ clock: { white: 299_000, black: 300_000, activeColor: 'black', lastUpdatedAt: 1 },
+ });
+
+ await gateway.handleMove(socket, { gameId: 'g1', from: 'e2', to: 'e4' });
+
+ expect(gamesService.recordMove).toHaveBeenCalledWith(
+ 'g1',
+ 1,
+ 'white',
+ 'e4',
+ 'e2e4',
+ FEN_AFTER_E4,
+ 299_000,
+ );
+ expect(server.to).toHaveBeenCalledWith('game:g1');
+ expect(roomEmit).toHaveBeenCalledWith(
+ 'game:move:broadcast',
+ expect.objectContaining({
+ fen: FEN_AFTER_E4,
+ isCheck: false,
+ isCheckmate: false,
+ isStalemate: false,
+ isDraw: false,
+ move: expect.objectContaining({ moveNumber: 1, color: 'white', san: 'e4' }),
+ }),
+ );
+ expect(clockService.scheduleTimeout).toHaveBeenCalledWith(
+ 'g1',
+ 'black',
+ 300_000,
+ expect.any(Function),
+ );
+ expect(gamesService.endGame).not.toHaveBeenCalled();
+ });
+
+ it('ends the game with the mover as winner when the move is checkmate', async () => {
+ const socket = createSocket('b1');
+ gamesService.getGameCore.mockResolvedValue(activeGame);
+ gameStateService.getState.mockResolvedValue({ activeColor: 'black' });
+ gameStateService.applyMove.mockResolvedValue({
+ san: 'Qh4#',
+ uci: 'd8h4',
+ newFen: FEN_FOOLS_MATE,
+ clock: { white: 290_000, black: 295_000, activeColor: 'white', lastUpdatedAt: 1 },
+ });
+
+ await gateway.handleMove(socket, { gameId: 'g1', from: 'd8', to: 'h4' });
+
+ expect(gamesService.endGame).toHaveBeenCalledWith('g1', 'black', 'checkmate');
+ expect(clockService.cancelTimeout).toHaveBeenCalledWith('g1');
+ expect(roomEmit).toHaveBeenCalledWith(
+ 'game:over',
+ expect.objectContaining({
+ result: 'black',
+ termination: 'checkmate',
+ winner: 'black',
+ whiteRatingDelta: 8,
+ blackRatingDelta: -8,
+ }),
+ );
+ expect(clockService.scheduleTimeout).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('handleResign', () => {
+ it('awards the win to the opponent of the resigning player', async () => {
+ const socket = createSocket('w1');
+ gamesService.getGameCore.mockResolvedValue(activeGame);
+
+ await gateway.handleResign(socket, { gameId: 'g1' });
+
+ expect(gamesService.endGame).toHaveBeenCalledWith('g1', 'black', 'resignation');
+ expect(roomEmit).toHaveBeenCalledWith(
+ 'game:over',
+ expect.objectContaining({ result: 'black', termination: 'resignation' }),
+ );
+ });
+
+ it('ignores resignations from non-players', async () => {
+ const socket = createSocket('spectator');
+ gamesService.getGameCore.mockResolvedValue(activeGame);
+
+ await gateway.handleResign(socket, { gameId: 'g1' });
+
+ expect(gamesService.endGame).not.toHaveBeenCalled();
+ });
+
+ it('ignores resignations for games that already ended', async () => {
+ const socket = createSocket('w1');
+ gamesService.getGameCore.mockResolvedValue({ ...activeGame, status: 'ended' });
+
+ await gateway.handleResign(socket, { gameId: 'g1' });
+
+ expect(gamesService.endGame).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('draw handling', () => {
+ it('stores the draw offer and notifies the game room', async () => {
+ const socket = createSocket('b1');
+ gamesService.getGameCore.mockResolvedValue(activeGame);
+
+ await gateway.handleDrawOffer(socket, { gameId: 'g1' });
+
+ expect(gameStateService.setDrawOffer).toHaveBeenCalledWith('g1', 'black');
+ expect(roomEmit).toHaveBeenCalledWith('game:draw:offered', { byColor: 'black' });
+ });
+
+ it('accepting a pending offer ends the game as a draw by agreement', async () => {
+ const socket = createSocket('w1');
+ gameStateService.getState.mockResolvedValue({ drawOffer: 'black' });
+ gamesService.getGameCore.mockResolvedValue(activeGame);
+ gamesService.endGame.mockResolvedValue({ whiteDelta: 0, blackDelta: 0 });
+
+ await gateway.handleDrawAccept(socket, { gameId: 'g1' });
+
+ expect(gamesService.endGame).toHaveBeenCalledWith('g1', 'draw', 'draw_agreement');
+ expect(roomEmit).toHaveBeenCalledWith(
+ 'game:over',
+ expect.objectContaining({ result: 'draw', winner: null, termination: 'draw_agreement' }),
+ );
+ });
+
+ it('accepting with no pending offer does nothing', async () => {
+ const socket = createSocket('w1');
+ gameStateService.getState.mockResolvedValue({ drawOffer: null });
+
+ await gateway.handleDrawAccept(socket, { gameId: 'g1' });
+
+ expect(gamesService.endGame).not.toHaveBeenCalled();
+ });
+
+ it('declining clears the offer and notifies the room', async () => {
+ const socket = createSocket('w1');
+
+ await gateway.handleDrawDecline(socket, { gameId: 'g1' });
+
+ expect(gameStateService.clearDrawOffer).toHaveBeenCalledWith('g1');
+ expect(roomEmit).toHaveBeenCalledWith('game:draw:declined');
+ });
+ });
+
+ describe('handleSpectate', () => {
+ it('replays the stored result when joining an already ended game', async () => {
+ const socket = createSocket('viewer');
+ gamesService.getGameCore.mockResolvedValue({
+ ...activeGame,
+ status: 'ended',
+ result: 'white',
+ termination: 'timeout',
+ pgn: '1. e4 e5',
+ });
+
+ await gateway.handleSpectate(socket, { gameId: 'g1' });
+
+ expect(socket.join).toHaveBeenCalledWith('game:g1');
+ expect(socket.emit).toHaveBeenCalledWith('game:over', {
+ result: 'white',
+ termination: 'timeout',
+ winner: 'white',
+ pgn: '1. e4 e5',
+ whiteRatingDelta: 0,
+ blackRatingDelta: 0,
+ });
+ });
+
+ it('sends the live clock when joining an active game', async () => {
+ const socket = createSocket('viewer');
+ gamesService.getGameCore.mockResolvedValue(activeGame);
+ gameStateService.getState.mockResolvedValue({
+ whiteTimeMs: 250_000,
+ blackTimeMs: 260_000,
+ activeColor: 'white',
+ lastMoveAt: 42,
+ });
+
+ await gateway.handleSpectate(socket, { gameId: 'g1' });
+
+ expect(socket.emit).toHaveBeenCalledWith('game:clock', {
+ white: 250_000,
+ black: 260_000,
+ activeColor: 'white',
+ lastUpdatedAt: 42,
+ });
+ });
+ });
+
+ describe('queue handlers', () => {
+ it('notifies both players with their color and opponent info on a match', async () => {
+ const socket = createSocket('w1');
+ matchmakingService.joinQueue.mockResolvedValue({
+ gameId: 'g1',
+ whiteId: 'w1',
+ blackId: 'b1',
+ });
+ gamesService.getGameCore.mockResolvedValue(activeGame);
+
+ await gateway.handleQueueJoin(socket, { timeControlKey: 'blitz_5_0' });
+
+ expect(roomEmit).toHaveBeenCalledWith('queue:matched', {
+ gameId: 'g1',
+ color: 'white',
+ opponentUsername: 'blacky',
+ opponentRating: 1480,
+ });
+ expect(roomEmit).toHaveBeenCalledWith('queue:matched', {
+ gameId: 'g1',
+ color: 'black',
+ opponentUsername: 'whitey',
+ opponentRating: 1500,
+ });
+ expect(clockService.scheduleTimeout).toHaveBeenCalledWith(
+ 'g1',
+ 'white',
+ 300_000,
+ expect.any(Function),
+ );
+ });
+
+ it('does nothing beyond queueing when no match is found', async () => {
+ const socket = createSocket('w1');
+ matchmakingService.joinQueue.mockResolvedValue(null);
+
+ await gateway.handleQueueJoin(socket, { timeControlKey: 'blitz_5_0' });
+
+ expect(roomEmit).not.toHaveBeenCalled();
+ expect(clockService.scheduleTimeout).not.toHaveBeenCalled();
+ });
+
+ it('queue:leave removes the user from matchmaking', async () => {
+ const socket = createSocket('w1');
+
+ await gateway.handleQueueLeave(socket);
+
+ expect(matchmakingService.leaveQueue).toHaveBeenCalledWith('w1');
+ });
+ });
+});