diff --git a/frontend/.env.example b/frontend/.env.example index c1ebeb15..9feb174f 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -97,3 +97,5 @@ TRUST_FORWARDED_HEADERS=0 # emergency switch RATE_LIMIT_DISABLED=0 + +GROQ_API_KEY= \ No newline at end of file diff --git a/frontend/components/tests/q&a/accordion-list.test.tsx b/frontend/components/tests/q&a/accordion-list.test.tsx new file mode 100644 index 00000000..f4e95abf --- /dev/null +++ b/frontend/components/tests/q&a/accordion-list.test.tsx @@ -0,0 +1,366 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +const getCachedTermsMock = vi.fn(); + +vi.mock('@/lib/ai/explainCache', () => ({ + getCachedTerms: () => getCachedTermsMock(), +})); + +vi.mock('@/components/ui/accordion', () => ({ + Accordion: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AccordionItem: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AccordionTrigger: ({ children }: { children: React.ReactNode }) => ( + + ), + AccordionContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('@/components/q&a/CodeBlock', () => ({ + __esModule: true, + default: ({ + code, + content, + }: { + code?: string; + content?: string; + }) =>
{content ?? code}
, +})); + +vi.mock('@/components/q&a/SelectableText', () => ({ + __esModule: true, + default: ({ + children, + onTextSelect, + onSelectionClear, + }: { + children: React.ReactNode; + onTextSelect: (text: string, position: { x: number; y: number }) => void; + onSelectionClear: () => void; + }) => ( +
+ + + {children} +
+ ), +})); + +vi.mock('@/components/q&a/FloatingExplainButton', () => ({ + __esModule: true, + default: ({ + onClick, + onDismiss, + }: { + onClick: () => void; + onDismiss: () => void; + }) => ( +
+ + +
+ ), +})); + +vi.mock('@/components/q&a/AIWordHelper', () => ({ + __esModule: true, + default: ({ + term, + isOpen, + }: { + term: string; + isOpen: boolean; + }) => ( +
+ {isOpen ? `open:${term}` : 'closed'} +
+ ), +})); + +vi.mock('@/components/q&a/HighlightCachedTerms', () => ({ + __esModule: true, + default: ({ + text, + cachedTerms, + onTermClick, + }: { + text: string; + cachedTerms: Set; + onTermClick: (term: string) => void; + }) => { + const normalized = text.toLowerCase().trim(); + if (!cachedTerms.has(normalized)) { + return {text}; + } + + return ( + + ); + }, +})); + +import AccordionList from '@/components/q&a/AccordionList'; +import type { QuestionEntry } from '@/components/q&a/types'; + +describe('AccordionList', () => { + beforeEach(() => { + getCachedTermsMock.mockReturnValue([]); + }); + + it('renders questions and answer blocks', () => { + const items: QuestionEntry[] = [ + { + id: 'q1', + question: 'What is CSS?', + category: 'css', + answerBlocks: [ + { + type: 'paragraph', + children: [{ text: 'CSS styles pages.' }], + }, + ], + }, + ]; + + render(); + + expect(screen.getByText('What is CSS?')).toBeTruthy(); + expect(screen.getByText('CSS styles pages.')).toBeTruthy(); + }); + + it('opens AI helper from selection', () => { + const items: QuestionEntry[] = [ + { + id: 'q1', + question: 'What is CSS?', + category: 'css', + answerBlocks: [ + { + type: 'paragraph', + children: [{ text: 'CSS styles pages.' }], + }, + ], + }, + ]; + + render(); + + fireEvent.click(screen.getByText('select-text')); + fireEvent.click(screen.getByText('explain')); + + expect(screen.getByTestId('ai-helper').textContent).toBe('open:CSS'); + }); + + it('opens AI helper when cached term clicked', () => { + getCachedTermsMock.mockReturnValue(['HTML']); + + const items: QuestionEntry[] = [ + { + id: 'q1', + question: 'What is HTML?', + category: 'html', + answerBlocks: [ + { + type: 'paragraph', + children: [{ text: 'HTML' }], + }, + ], + }, + ]; + + render(); + + fireEvent.click(screen.getByText('HTML')); + + expect(screen.getByTestId('ai-helper').textContent).toBe('open:HTML'); + }); + + it('clears selection when requested', () => { + const items: QuestionEntry[] = [ + { + id: 'q1', + question: 'What is CSS?', + category: 'css', + answerBlocks: [ + { + type: 'paragraph', + children: [{ text: 'CSS styles pages.' }], + }, + ], + }, + ]; + + render(); + + fireEvent.click(screen.getByText('select-text')); + expect(screen.getByText('explain')).toBeTruthy(); + + fireEvent.click(screen.getByText('clear-selection')); + expect(screen.queryByText('explain')).toBeNull(); + }); + + it('keeps selection when modal is open', () => { + const items: QuestionEntry[] = [ + { + id: 'q1', + question: 'What is CSS?', + category: 'css', + answerBlocks: [ + { + type: 'paragraph', + children: [{ text: 'CSS styles pages.' }], + }, + ], + }, + ]; + + render(); + + fireEvent.click(screen.getByText('select-text')); + fireEvent.click(screen.getByText('explain')); + expect(screen.getByTestId('ai-helper').textContent).toBe('open:CSS'); + + fireEvent.click(screen.getByText('clear-selection')); + expect(screen.getByTestId('ai-helper').textContent).toBe('open:CSS'); + }); + + it('renders mixed answer blocks', () => { + const items: QuestionEntry[] = [ + { + id: 'q1', + question: 'What is HTML?', + category: 'html', + answerBlocks: [ + { + type: 'paragraph', + children: [ + { text: 'Plain' }, + { text: 'Bold', bold: true }, + { text: 'Italic', italic: true }, + { text: 'Both', boldItalic: true }, + { text: 'Inline', code: true }, + ], + }, + { + type: 'heading', + level: 3, + children: [{ text: 'Heading' }], + }, + { + type: 'bulletList', + children: [ + { + type: 'listItem', + children: [ + { text: 'Item 1' }, + { type: 'code', language: 'js', content: 'const a = 1;' }, + { + type: 'bulletList', + children: [{ text: 'Nested' }], + }, + ], + }, + { text: 'Loose item' }, + ], + }, + { + type: 'numberedList', + children: [ + { + type: 'listItem', + children: [{ text: 'Numbered' }], + }, + ], + }, + { + type: 'table', + header: [[{ text: 'Col' }]], + rows: [[[{ text: 'Cell' }]]], + }, + { + type: 'code', + language: 'html', + content: '
', + }, + ], + }, + ]; + + render(); + + expect(screen.getByText('Bold').tagName).toBe('STRONG'); + expect(screen.getByText('Italic').tagName).toBe('EM'); + expect(screen.getByText('Both').tagName).toBe('STRONG'); + expect(screen.getByText('Inline').tagName).toBe('CODE'); + expect(screen.getByText('Heading').tagName).toBe('SPAN'); + expect(screen.getByText('Nested')).toBeTruthy(); + expect(screen.getByText('Col')).toBeTruthy(); + expect(screen.getByText('Cell')).toBeTruthy(); + expect(screen.getAllByTestId('code-block').length).toBeGreaterThan(0); + }); + + it('dismisses explain button', () => { + const items: QuestionEntry[] = [ + { + id: 'q1', + question: 'What is CSS?', + category: 'css', + answerBlocks: [ + { + type: 'paragraph', + children: [{ text: 'CSS styles pages.' }], + }, + ], + }, + ]; + + render(); + + fireEvent.click(screen.getByText('select-text')); + expect(screen.getByText('dismiss')).toBeTruthy(); + + fireEvent.click(screen.getByText('dismiss')); + expect(screen.queryByText('dismiss')).toBeNull(); + }); + + it('renders code blocks in answers', () => { + const items: QuestionEntry[] = [ + { + id: 'q1', + question: 'What is HTML?', + category: 'html', + answerBlocks: [ + { + type: 'code', + language: 'html', + content: '
', + }, + ], + }, + ]; + + render(); + + expect(screen.getByTestId('code-block').textContent).toBe('
'); + }); +}); diff --git a/frontend/components/tests/q&a/ai-word-helper.test.tsx b/frontend/components/tests/q&a/ai-word-helper.test.tsx new file mode 100644 index 00000000..130610f7 --- /dev/null +++ b/frontend/components/tests/q&a/ai-word-helper.test.tsx @@ -0,0 +1,281 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import type React from 'react'; + +const getCachedExplanationMock = vi.fn(); +const setCachedExplanationMock = vi.fn(); + +vi.mock('@/lib/ai/explainCache', () => ({ + getCachedExplanation: (term: string) => getCachedExplanationMock(term), + setCachedExplanation: (term: string, value: unknown) => + setCachedExplanationMock(term, value), +})); + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock('next/navigation', () => ({ + useParams: () => ({ locale: 'en' }), +})); + +vi.mock('@/i18n/routing', () => ({ + Link: ({ children, ...props }: { children: React.ReactNode }) => ( + {children} + ), +})); + +import AIWordHelper from '@/components/q&a/AIWordHelper'; + +function mockFetchSequence(responses: Array<{ ok: boolean; status: number; json: () => Promise }>) { + const fetchMock = vi.fn(); + responses.forEach(response => { + fetchMock.mockResolvedValueOnce(response); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('AIWordHelper', () => { + beforeEach(() => { + getCachedExplanationMock.mockReset(); + setCachedExplanationMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('shows guest CTA when user is not authenticated', async () => { + mockFetchSequence([ + { + ok: true, + status: 200, + json: async () => ({ user: null }), + }, + ]); + + render( + + ); + + expect(await screen.findByText('guest.title')).toBeTruthy(); + }); + + it('renders cached explanation without calling AI endpoint', async () => { + getCachedExplanationMock.mockReturnValue({ + en: 'Cached', + uk: 'Cached-ua', + pl: 'Cached-pl', + }); + + const fetchMock = mockFetchSequence([ + { + ok: true, + status: 200, + json: async () => ({ user: { id: 'u1' } }), + }, + ]); + + render( + + ); + + expect(await screen.findByText('Cached')).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('renders explanation from API', async () => { + mockFetchSequence([ + { + ok: true, + status: 200, + json: async () => ({ user: { id: 'u1' } }), + }, + { + ok: true, + status: 200, + json: async () => ({ + en: 'Hello', + uk: 'Привіт', + pl: 'Czesc', + }), + }, + ]); + + render( + + ); + + expect(await screen.findByText('Hello')).toBeTruthy(); + expect(setCachedExplanationMock).toHaveBeenCalled(); + }); + + it('shows rate limit state for 429 responses', async () => { + mockFetchSequence([ + { + ok: true, + status: 200, + json: async () => ({ user: { id: 'u1' } }), + }, + { + ok: false, + status: 429, + json: async () => ({ code: 'RATE_LIMITED', resetIn: 60000 }), + }, + ]); + + render( + + ); + + expect(await screen.findByText('Give it a moment')).toBeTruthy(); + }); + + it('shows service error state for 503 responses', async () => { + mockFetchSequence([ + { + ok: true, + status: 200, + json: async () => ({ user: { id: 'u1' } }), + }, + { + ok: false, + status: 503, + json: async () => ({ code: 'SERVICE_UNAVAILABLE' }), + }, + ]); + + render( + + ); + + expect(await screen.findByText('AI is taking a nap')).toBeTruthy(); + }); + + it('retries after rate limit and eventually renders content', async () => { + mockFetchSequence([ + { + ok: true, + status: 200, + json: async () => ({ user: { id: 'u1' } }), + }, + { + ok: false, + status: 429, + json: async () => ({ code: 'RATE_LIMITED', resetIn: 60000 }), + }, + { + ok: true, + status: 200, + json: async () => ({ + en: 'Recovered', + uk: 'Відновлено', + pl: 'Odzyskano', + }), + }, + ]); + + render( + + ); + + expect(await screen.findByText('Give it a moment')).toBeTruthy(); + + const retryButton = screen.getByRole('button', { name: "I'll try later" }); + await act(async () => { + retryButton.click(); + }); + + expect(await screen.findByText('Recovered')).toBeTruthy(); + }); + + it('renders loading state while fetching', async () => { + let resolveJson: (value: unknown) => void; + + const fetchMock = mockFetchSequence([ + { + ok: true, + status: 200, + json: async () => ({ user: { id: 'u1' } }), + }, + { + ok: true, + status: 200, + json: () => + new Promise(resolve => { + resolveJson = resolve; + }), + }, + ]); + + render( + + ); + + expect(await screen.findByText('loading')).toBeTruthy(); + expect(fetchMock).toHaveBeenCalled(); + + resolveJson?.({ + en: 'Delayed', + uk: 'Затримка', + pl: 'Opóźnienie', + }); + + expect(await screen.findByText('Delayed')).toBeTruthy(); + }); + + it('renders fallback content when payload is partial', async () => { + mockFetchSequence([ + { + ok: true, + status: 200, + json: async () => ({ user: { id: 'u1' } }), + }, + { + ok: true, + status: 200, + json: async () => ({ en: 'Missing locales' }), + }, + ]); + + render( + + ); + + expect(await screen.findByText('Missing locales')).toBeTruthy(); + }); +}); diff --git a/frontend/components/tests/q&a/code-block.test.tsx b/frontend/components/tests/q&a/code-block.test.tsx new file mode 100644 index 00000000..a1627410 --- /dev/null +++ b/frontend/components/tests/q&a/code-block.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; + +vi.mock('next-themes', () => ({ + useTheme: () => ({ resolvedTheme: 'light' }), +})); + +vi.mock('prism-react-renderer', () => ({ + Highlight: ({ + code, + children, + }: { + code: string; + children: (args: { + className: string; + style: Record; + tokens: Array>; + getLineProps: (args: { line: { content: string }[] }) => Record; + getTokenProps: (args: { token: { content: string } }) => Record; + }) => unknown; + }) => + children({ + className: 'code', + style: {}, + tokens: [[{ content: code, types: [] }]], + getLineProps: () => ({}), + getTokenProps: ({ token }: { token: { content: string } }) => ({ + children: token.content, + }), + }), + themes: { github: {}, nightOwl: {} }, +})); + +import CodeBlock from '@/components/q&a/CodeBlock'; + +describe('CodeBlock', () => { + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders language label and copies to clipboard', async () => { + render(); + + expect(screen.getByText('js')).toBeTruthy(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy code')); + await Promise.resolve(); + }); + + const writeText = navigator.clipboard.writeText as unknown as ReturnType; + expect(writeText).toHaveBeenCalledWith('const a = 1;'); + + expect(screen.getByText('Copied')).toBeTruthy(); + + act(() => { + vi.runAllTimers(); + }); + + expect(screen.getByText('Copy')).toBeTruthy(); + }); +}); diff --git a/frontend/components/tests/q&a/floating-explain-button.test.tsx b/frontend/components/tests/q&a/floating-explain-button.test.tsx new file mode 100644 index 00000000..6fad0620 --- /dev/null +++ b/frontend/components/tests/q&a/floating-explain-button.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +import FloatingExplainButton from '@/components/q&a/FloatingExplainButton'; + +describe('FloatingExplainButton', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('calls onClick when button clicked', () => { + const onClick = vi.fn(); + const onDismiss = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByLabelText('buttonText')); + + expect(onClick).toHaveBeenCalled(); + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('dismisses on outside click and scroll', () => { + const onClick = vi.fn(); + const onDismiss = vi.fn(); + + render( + + ); + + vi.runAllTimers(); + + fireEvent.mouseDown(document.body); + expect(onDismiss).toHaveBeenCalledTimes(1); + + fireEvent.scroll(window); + expect(onDismiss).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/components/tests/q&a/highlight-cached-terms.test.tsx b/frontend/components/tests/q&a/highlight-cached-terms.test.tsx new file mode 100644 index 00000000..fa4a142d --- /dev/null +++ b/frontend/components/tests/q&a/highlight-cached-terms.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import HighlightCachedTerms from '@/components/q&a/HighlightCachedTerms'; + +describe('HighlightCachedTerms', () => { + it('highlights cached term and calls onTermClick', () => { + const onTermClick = vi.fn(); + const cachedTerms = new Set(['html']); + + render( + + ); + + fireEvent.click(screen.getByText('HTML')); + + expect(onTermClick).toHaveBeenCalledWith('html'); + }); + + it('renders plain text when no cached terms', () => { + render( + + ); + + expect(screen.getByText('No highlights')).toBeTruthy(); + }); +}); diff --git a/frontend/components/tests/q&a/pagination.test.tsx b/frontend/components/tests/q&a/pagination.test.tsx new file mode 100644 index 00000000..9b4d4f0c --- /dev/null +++ b/frontend/components/tests/q&a/pagination.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: { page?: number }) => + values?.page ? `${key}-${values.page}` : key, +})); + +import { Pagination } from '@/components/q&a/Pagination'; + +describe('Pagination', () => { + beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); + }); + + it('calls onPageChange when clicking next page', () => { + const onPageChange = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByLabelText('nextPage')); + expect(onPageChange).toHaveBeenCalledWith(2); + }); + + it('disables previous button on first page', () => { + const onPageChange = vi.fn(); + + render( + + ); + + const prevButton = screen.getByLabelText('previousPage'); + expect(prevButton instanceof HTMLButtonElement).toBe(true); + expect((prevButton as HTMLButtonElement).disabled).toBe(true); + }); + + it('renders ellipsis for large page counts', () => { + const onPageChange = vi.fn(); + + render( + + ); + + const ellipsis = screen.getAllByText('...'); + expect(ellipsis.length).toBeGreaterThan(0); + }); + + it('uses mobile layout for small screens', () => { + const onPageChange = vi.fn(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); + + render( + + ); + + const ellipsis = screen.getAllByText('...'); + expect(ellipsis.length).toBeGreaterThan(0); + }); + + it('disables next button on last page', () => { + const onPageChange = vi.fn(); + + render( + + ); + + const nextButton = screen.getByLabelText('nextPage'); + expect(nextButton instanceof HTMLButtonElement).toBe(true); + expect((nextButton as HTMLButtonElement).disabled).toBe(true); + }); + + it('renders all pages when total is small', () => { + const onPageChange = vi.fn(); + + render( + + ); + + expect(screen.getAllByLabelText(/page-/).length).toBe(4); + }); +}); diff --git a/frontend/components/tests/q&a/qa-section.test.tsx b/frontend/components/tests/q&a/qa-section.test.tsx new file mode 100644 index 00000000..1ae52c2f --- /dev/null +++ b/frontend/components/tests/q&a/qa-section.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type React from 'react'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +const qaState = { + active: 'git', + currentPage: 1, + handleCategoryChange: vi.fn(), + handlePageChange: vi.fn(), + isLoading: false, + items: [] as unknown[], + localeKey: 'en', + totalPages: 0, +}; + +vi.mock('@/components/q&a/useQaTabs', () => ({ + useQaTabs: () => qaState, +})); + +vi.mock('@/components/q&a/AccordionList', () => ({ + __esModule: true, + default: ({ items }: { items: unknown[] }) => ( +
{items.length}
+ ), +})); + +vi.mock('@/components/ui/tabs', () => ({ + Tabs: ({ children }: { children: React.ReactNode }) =>
{children}
, + TabsList: ({ children }: { children: React.ReactNode }) =>
{children}
, + TabsContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('@/components/q&a/Pagination', () => ({ + Pagination: () =>
, +})); + +vi.mock('@/components/shared/CategoryTabButton', () => ({ + CategoryTabButton: ({ label }: { label: string }) => , +})); + +import QaSection from '@/components/q&a/QaSection'; +import { categoryData } from '@/data/category'; + +describe('QaSection', () => { + it('renders empty state when no questions', () => { + qaState.totalPages = 0; + render(); + + expect(screen.getAllByText('noQuestions').length).toBeGreaterThan(0); + }); + + it('renders category tabs and pagination', () => { + qaState.totalPages = 3; + render(); + + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBe(categoryData.length); + expect(screen.getByTestId('pagination')).toBeTruthy(); + }); +}); diff --git a/frontend/components/tests/q&a/selectable-text.test.tsx b/frontend/components/tests/q&a/selectable-text.test.tsx new file mode 100644 index 00000000..6b08933f --- /dev/null +++ b/frontend/components/tests/q&a/selectable-text.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; + +import SelectableText from '@/components/q&a/SelectableText'; + +describe('SelectableText', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('calls onSelectionClear for collapsed selection', () => { + const onTextSelect = vi.fn(); + const onSelectionClear = vi.fn(); + const { container: root } = render( + +
Text
+
+ ); + const wrapper = root.firstChild as HTMLElement; + + vi.stubGlobal( + 'getSelection', + vi.fn(() => ({ + isCollapsed: true, + toString: () => '', + })) as unknown as typeof window.getSelection + ); + + fireEvent.mouseUp(wrapper); + vi.runAllTimers(); + + expect(onSelectionClear).toHaveBeenCalled(); + expect(onTextSelect).not.toHaveBeenCalled(); + }); + + it('calls onTextSelect when selection is valid and inside container', () => { + const onTextSelect = vi.fn(); + const onSelectionClear = vi.fn(); + + const { container: root } = render( + +
HTML
+
+ ); + const wrapper = root.firstChild as HTMLElement; + + vi.stubGlobal( + 'getSelection', + vi.fn(() => ({ + isCollapsed: false, + toString: () => 'HTML', + getRangeAt: () => ({ + commonAncestorContainer: wrapper, + getBoundingClientRect: () => + ({ + left: 100, + top: 200, + width: 50, + height: 10, + }) as DOMRect, + }), + })) as unknown as typeof window.getSelection + ); + + fireEvent.mouseUp(wrapper); + vi.runAllTimers(); + + expect(onTextSelect).toHaveBeenCalledWith('HTML', { x: 125, y: 200 }); + expect(onSelectionClear).not.toHaveBeenCalled(); + }); + + it('ignores selection outside container', () => { + const onTextSelect = vi.fn(); + const onSelectionClear = vi.fn(); + + const { container: root } = render( + +
CSS
+
+ ); + const wrapper = root.firstChild as HTMLElement; + const outside = document.createElement('div'); + + vi.stubGlobal( + 'getSelection', + vi.fn(() => ({ + isCollapsed: false, + toString: () => 'CSS', + getRangeAt: () => ({ + commonAncestorContainer: outside, + getBoundingClientRect: () => + ({ + left: 100, + top: 200, + width: 50, + height: 10, + }) as DOMRect, + }), + })) as unknown as typeof window.getSelection + ); + + fireEvent.mouseUp(wrapper); + vi.runAllTimers(); + + expect(onTextSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/components/tests/q&a/types.test.ts b/frontend/components/tests/q&a/types.test.ts new file mode 100644 index 00000000..4d84d368 --- /dev/null +++ b/frontend/components/tests/q&a/types.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest'; + +import { qaConstants } from '@/components/q&a/types'; + +describe('qaConstants', () => { + it('exposes supported locales', () => { + expect(qaConstants.supportedLocales).toEqual(['uk', 'en', 'pl']); + }); +}); diff --git a/frontend/components/tests/q&a/use-qa-tabs.test.tsx b/frontend/components/tests/q&a/use-qa-tabs.test.tsx new file mode 100644 index 00000000..285c76c6 --- /dev/null +++ b/frontend/components/tests/q&a/use-qa-tabs.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; + +const routerReplace = vi.fn(); +let searchParamsValue = new URLSearchParams(); + +vi.mock('next/navigation', () => ({ + useSearchParams: () => searchParamsValue, +})); + +vi.mock('next-intl', () => ({ + useLocale: () => 'en', +})); + +vi.mock('@/i18n/routing', () => ({ + useRouter: () => ({ replace: routerReplace }), +})); + +import { useQaTabs } from '@/components/q&a/useQaTabs'; + +describe('useQaTabs', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + items: [ + { + id: 'q1', + categoryId: 'cat-1', + sortOrder: 1, + difficulty: null, + question: 'Question 1', + answerBlocks: [], + locale: 'en', + }, + ], + total: 1, + page: 1, + totalPages: 1, + locale: 'en', + }), + }); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('scrollTo', vi.fn()); + routerReplace.mockClear(); + searchParamsValue = new URLSearchParams(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('loads questions for default category', async () => { + const { result } = renderHook(() => useQaTabs()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/questions/git?page=1&limit=10&locale=en', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + expect(result.current.items).toHaveLength(1); + }); + + it('updates page and URL on page change', async () => { + const { result } = renderHook(() => useQaTabs()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.handlePageChange(2); + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + expect(routerReplace).toHaveBeenCalledWith('/q&a?page=2', { + scroll: false, + }); + expect(window.scrollTo).toHaveBeenCalled(); + }); + + it('updates category and URL on category change', async () => { + const { result } = renderHook(() => useQaTabs()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.handleCategoryChange('css'); + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + expect(routerReplace).toHaveBeenCalledWith('/q&a?category=css', { + scroll: false, + }); + }); + + it('falls back to default category on invalid URL category', async () => { + searchParamsValue = new URLSearchParams('category=invalid&page=2'); + + const { result } = renderHook(() => useQaTabs()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/questions/git?page=2&limit=10&locale=en', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + + it('handles fetch error by clearing items', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({}), + }); + + const { result } = renderHook(() => useQaTabs()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.items).toEqual([]); + expect(result.current.totalPages).toBe(0); + consoleSpy.mockRestore(); + }); +}); diff --git a/frontend/lib/tests/q&a/questions-route.test.ts b/frontend/lib/tests/q&a/questions-route.test.ts new file mode 100644 index 00000000..fae43044 --- /dev/null +++ b/frontend/lib/tests/q&a/questions-route.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('@/db', () => ({ + db: { + select: vi.fn(), + }, +})); + +import { db } from '@/db'; +import { GET } from '@/app/api/questions/[category]/route'; + +type Builder = { + from: ReturnType; + innerJoin: ReturnType; + where: ReturnType; + limit: ReturnType; + orderBy: ReturnType; + offset: ReturnType; +}; + +function makeBuilder(finalMethod: keyof Builder, result: unknown): Builder { + const builder: Builder = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + offset: vi.fn().mockReturnThis(), + }; + + builder[finalMethod] = vi.fn().mockResolvedValue(result); + return builder; +} + +describe('GET /api/questions/[category]', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('returns empty payload for unknown category', async () => { + const selectMock = db.select as ReturnType; + selectMock.mockReturnValueOnce(makeBuilder('limit', [])); + + const req = new Request( + 'http://localhost/api/questions/unknown?page=1&limit=10&locale=en' + ); + const res = await GET(req, { + params: Promise.resolve({ category: 'unknown' }), + }); + + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.items).toEqual([]); + expect(data.total).toBe(0); + expect(data.totalPages).toBe(0); + expect(data.locale).toBe('en'); + }); + + it('returns paginated questions for category', async () => { + const selectMock = db.select as ReturnType; + selectMock + .mockReturnValueOnce(makeBuilder('limit', [{ id: 'cat-1' }])) + .mockReturnValueOnce(makeBuilder('where', [{ count: 2 }])) + .mockReturnValueOnce( + makeBuilder('offset', [ + { + id: 'q1', + categoryId: 'cat-1', + sortOrder: 1, + difficulty: null, + question: 'Question 1', + answerBlocks: [], + locale: 'en', + }, + ]) + ); + + const req = new Request( + 'http://localhost/api/questions/git?page=1&limit=10&locale=en' + ); + const res = await GET(req, { + params: Promise.resolve({ category: 'git' }), + }); + + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.items).toHaveLength(1); + expect(data.items[0].question).toBe('Question 1'); + expect(data.total).toBe(2); + expect(data.totalPages).toBe(1); + expect(data.page).toBe(1); + }); + + it('returns 500 on db error', async () => { + const selectMock = db.select as ReturnType; + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + selectMock.mockImplementation(() => { + throw new Error('db error'); + }); + + const req = new Request('http://localhost/api/questions/git'); + const res = await GET(req, { + params: Promise.resolve({ category: 'git' }), + }); + + const data = await res.json(); + + expect(res.status).toBe(500); + expect(data.items).toEqual([]); + expect(data.total).toBe(0); + expect(data.totalPages).toBe(0); + consoleSpy.mockRestore(); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b35b2343..68580e15 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10341,6 +10341,17 @@ } } }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 161ceee9..dddda48c 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -18,7 +18,13 @@ export default defineConfig({ coverage: { provider: 'v8', reporter: ['text', 'html'], - include: ['lib/quiz/**', 'hooks/**', 'app/api/quiz/**'], + include: [ + 'lib/quiz/**', + 'hooks/**', + 'app/api/quiz/**', + 'components/q&a/**', + 'app/api/questions/**', + ], }, }, });