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/**',
+ ],
},
},
});