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'); + }); + }); +});