diff --git a/.gitignore b/.gitignore index 8705e153..65e2c957 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ yarn-error.log* # Coverage directory /coverage +/coverage-quiz # Dotenv files *.local diff --git a/frontend/.gitignore b/frontend/.gitignore index 1f9b14cf..f5dd8f9e 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,7 @@ # testing /coverage +/coverage-quiz # next.js /.next/ diff --git a/frontend/components/quiz/tests/quiz-container-flow.test.tsx b/frontend/components/quiz/tests/quiz-container-flow.test.tsx new file mode 100644 index 00000000..88b87008 --- /dev/null +++ b/frontend/components/quiz/tests/quiz-container-flow.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +const { routerReplace, routerBack, toastMock } = vi.hoisted(() => ({ + routerReplace: vi.fn(), + routerBack: vi.fn(), + toastMock: Object.assign(vi.fn(), { error: vi.fn() }), +})); + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: routerReplace, back: routerBack }), + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock('sonner', () => ({ toast: toastMock })); + + +vi.mock('@/hooks/useAntiCheat', () => ({ + useAntiCheat: () => ({ + violations: [], + violationsCount: 0, + resetViolations: vi.fn(), + }), +})); + +vi.mock('@/hooks/useQuizGuards', () => ({ + useQuizGuards: () => ({ markQuitting: vi.fn() }), +})); + +vi.mock('@/hooks/useQuizSession', () => ({ + useQuizSession: () => {}, +})); + +vi.mock('@/components/quiz/CountdownTimer', () => ({ + CountdownTimer: () => null, +})); + +vi.mock('@/actions/quiz', () => ({ + submitQuizAttempt: vi.fn(async () => ({ success: true, pointsAwarded: 10 })), +})); + +import { QuizContainer } from '@/components/quiz/QuizContainer'; + +describe('QuizContainer flow', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ isCorrect: true }), + }); + vi.stubGlobal('fetch', fetchMock); + localStorage.clear(); + sessionStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('runs a guest flow from rules to result', async () => { + const questions = [ + { + id: 'q1', + displayOrder: 1, + difficulty: null, + questionText: 'Question 1', + explanation: null, + answers: [ + { id: 'a1', displayOrder: 1, answerText: 'Answer 1' }, + { id: 'a2', displayOrder: 2, answerText: 'Answer 2' }, + ], + }, + ]; + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'startButton' })); + + expect(await screen.findByText('Question 1')).toBeTruthy(); + + fireEvent.click(screen.getByText('Answer 1')); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + const [url, options] = fetchMock.mock.calls[0]; + const payload = JSON.parse(options.body); + + expect(url).toBe('/api/quiz/verify-answer'); + expect(payload.questionId).toBe('q1'); + expect(payload.answerId).toBe('a1'); + expect(payload.encryptedAnswers).toBe('encrypted'); + + fireEvent.click(await screen.findByRole('button', { name: 'nextButton' })); + + expect(await screen.findByRole('button', { name: 'loginButton' })).toBeTruthy(); + }); +}); diff --git a/frontend/lib/tests/factories/quiz/quiz.ts b/frontend/lib/tests/factories/quiz/quiz.ts new file mode 100644 index 00000000..d83d7e8b --- /dev/null +++ b/frontend/lib/tests/factories/quiz/quiz.ts @@ -0,0 +1,97 @@ +/** + * Test factories for quiz module + * Creates consistent test data for unit and integration tests + */ + +export interface MockQuestion { + id: string; + answers: Array<{ + id: string; + isCorrect: boolean; + answerText?: string; + }>; +} + +export interface MockQuizSession { + status: 'rules' | 'in_progress' | 'completed'; + currentIndex: number; + answers: Array<{ + questionId: string; + selectedAnswerId: string; + isCorrect: boolean; + answeredAt: number; + }>; + questionStatus: 'answering' | 'revealed'; + selectedAnswerId: string | null; + startedAt: number | null; + savedAt: number; +} + +let questionCounter = 0; + +/** + * Creates a mock question with one correct answer + */ +export function createMockQuestion(overrides?: Partial): MockQuestion { + questionCounter++; + const qId = `q-${questionCounter}`; + + return { + id: qId, + answers: [ + { id: `${qId}-a1`, isCorrect: true, answerText: 'Correct answer' }, + { id: `${qId}-a2`, isCorrect: false, answerText: 'Wrong answer 1' }, + { id: `${qId}-a3`, isCorrect: false, answerText: 'Wrong answer 2' }, + { id: `${qId}-a4`, isCorrect: false, answerText: 'Wrong answer 3' }, + ], + ...overrides, + }; +} + +/** + * Creates multiple mock questions + */ +export function createMockQuestions(count: number): MockQuestion[] { + return Array.from({ length: count }, () => createMockQuestion()); +} + +/** + * Creates a mock quiz session for localStorage tests + */ +export function createMockQuizSession( + overrides?: Partial +): MockQuizSession { + return { + status: 'in_progress', + currentIndex: 0, + answers: [], + questionStatus: 'answering', + selectedAnswerId: null, + startedAt: Date.now(), + savedAt: Date.now(), + ...overrides, + }; +} + +/** + * Creates a correct answers map from questions + */ +export function createCorrectAnswersMap( + questions: MockQuestion[] +): Record { + const map: Record = {}; + for (const q of questions) { + const correct = q.answers.find(a => a.isCorrect); + if (correct) { + map[q.id] = correct.id; + } + } + return map; +} + +/** + * Reset counters between test files + */ +export function resetFactoryCounters(): void { + questionCounter = 0; +} diff --git a/frontend/lib/tests/quiz/guest-quiz.test.ts b/frontend/lib/tests/quiz/guest-quiz.test.ts new file mode 100644 index 00000000..ae225595 --- /dev/null +++ b/frontend/lib/tests/quiz/guest-quiz.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + savePendingQuizResult, + getPendingQuizResult, + clearPendingQuizResult, +} from '@/lib/quiz/guest-quiz'; + +describe('guest-quiz storage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('saves pending quiz result to localStorage', () => { + const result = { + quizId: 'quiz-1', + quizSlug: 'react', + answers: [{ questionId: 'q1', selectedAnswerId: 'a1', isCorrect: true }], + score: 1, + totalQuestions: 1, + percentage: 100, + violations: [], + timeSpentSeconds: 10, + savedAt: Date.now(), + }; + + savePendingQuizResult(result); + + const stored = localStorage.getItem('devlovers_pending_quiz'); + expect(stored).not.toBeNull(); + }); + + it('returns null and clears storage for expired result', () => { + const now = Date.now(); + vi.spyOn(Date, 'now').mockReturnValue(now + 25 * 60 * 60 * 1000); + + localStorage.setItem( + 'devlovers_pending_quiz', + JSON.stringify({ + quizId: 'quiz-1', + quizSlug: 'react', + answers: [], + score: 0, + totalQuestions: 1, + percentage: 0, + violations: [], + timeSpentSeconds: 0, + savedAt: now, + }) + ); + + const result = getPendingQuizResult(); + + expect(result).toBeNull(); + expect(localStorage.getItem('devlovers_pending_quiz')).toBeNull(); + }); + + it('returns null and clears storage for invalid JSON', () => { + localStorage.setItem('devlovers_pending_quiz', '{bad json'); + + const result = getPendingQuizResult(); + + expect(result).toBeNull(); + expect(localStorage.getItem('devlovers_pending_quiz')).toBeNull(); + }); + + it('returns stored result when not expired', () => { + const now = Date.now(); + vi.spyOn(Date, 'now').mockReturnValue(now); + + const stored = { + quizId: 'quiz-1', + quizSlug: 'react', + answers: [], + score: 0, + totalQuestions: 1, + percentage: 0, + violations: [], + timeSpentSeconds: 0, + savedAt: now, + }; + + localStorage.setItem('devlovers_pending_quiz', JSON.stringify(stored)); + + const result = getPendingQuizResult(); + + expect(result?.quizId).toBe('quiz-1'); + }); + + it('clears pending quiz result', () => { + localStorage.setItem('devlovers_pending_quiz', JSON.stringify({ quizId: 'q1' })); + + clearPendingQuizResult(); + + expect(localStorage.getItem('devlovers_pending_quiz')).toBeNull(); + }); +}); diff --git a/frontend/lib/tests/quiz/guest-result-route.test.ts b/frontend/lib/tests/quiz/guest-result-route.test.ts new file mode 100644 index 00000000..45296717 --- /dev/null +++ b/frontend/lib/tests/quiz/guest-result-route.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { POST } from '@/app/api/quiz/guest-result/route'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn(), +})); + +vi.mock('@/db/queries/points', () => ({ + calculateQuizPoints: vi.fn(), + awardQuizPoints: vi.fn(), +})); + +vi.mock('@/db', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + }, +})); + +import { getCurrentUser } from '@/lib/auth'; +import { calculateQuizPoints, awardQuizPoints } from '@/db/queries/points'; +import { db } from '@/db'; + +const getCurrentUserMock = getCurrentUser as ReturnType; +const calculateQuizPointsMock = calculateQuizPoints as ReturnType; +const awardQuizPointsMock = awardQuizPoints as ReturnType; +const selectMock = db.select as ReturnType; +const insertMock = db.insert as ReturnType; + +const makeSelectChain = (result: unknown) => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(result), + }), +}); + +describe('POST /api/quiz/guest-result', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 401 when user is not authenticated', async () => { + getCurrentUserMock.mockResolvedValue(null); + + const request = new Request('http://localhost/api/quiz/guest-result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + quizId: 'quiz-1', + answers: [{ questionId: 'q1', selectedAnswerId: 'a1' }], + violations: [], + timeSpentSeconds: 10, + }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe('Unauthorized'); + }); + + it('returns 404 when quiz has no questions', async () => { + getCurrentUserMock.mockResolvedValue({ id: 'user-1' }); + selectMock.mockImplementationOnce(() => makeSelectChain([])); + + const request = new Request('http://localhost/api/quiz/guest-result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + quizId: 'quiz-1', + answers: [{ questionId: 'q1', selectedAnswerId: 'a1' }], + violations: [], + timeSpentSeconds: 10, + }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Quiz not found'); + }); + + it('returns 400 when answers count mismatches questions', async () => { + getCurrentUserMock.mockResolvedValue({ id: 'user-1' }); + selectMock.mockImplementationOnce(() => + makeSelectChain([{ id: 'q1' }, { id: 'q2' }]) + ); + + const request = new Request('http://localhost/api/quiz/guest-result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + quizId: 'quiz-1', + answers: [{ questionId: 'q1', selectedAnswerId: 'a1' }], + violations: [], + timeSpentSeconds: 10, + }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Invalid input: answers count mismatch'); + }); + + it('returns 400 when answer selection is invalid', async () => { + getCurrentUserMock.mockResolvedValue({ id: 'user-1' }); + selectMock + .mockImplementationOnce(() => makeSelectChain([{ id: 'q1' }])) + .mockImplementationOnce(() => makeSelectChain([])); + + const request = new Request('http://localhost/api/quiz/guest-result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + quizId: 'quiz-1', + answers: [{ questionId: 'q1', selectedAnswerId: 'a1' }], + violations: [], + timeSpentSeconds: 10, + }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Invalid answer selection'); + }); + + it('returns success and persists attempt', async () => { + getCurrentUserMock.mockResolvedValue({ id: 'user-1' }); + calculateQuizPointsMock.mockReturnValue(10); + awardQuizPointsMock.mockResolvedValue(7); + + selectMock + .mockImplementationOnce(() => makeSelectChain([{ id: 'q1' }, { id: 'q2' }])) + .mockImplementationOnce(() => + makeSelectChain([ + { id: 'a1', quizQuestionId: 'q1', isCorrect: true }, + { id: 'a2', quizQuestionId: 'q2', isCorrect: false }, + ]) + ); + + insertMock + .mockImplementationOnce(() => ({ + values: vi.fn().mockImplementation(() => ({ + returning: vi.fn().mockResolvedValue([{ id: 'attempt-1' }]), + })), + })) + .mockImplementationOnce(() => ({ + values: vi.fn().mockResolvedValue(undefined), + })); + + const request = new Request('http://localhost/api/quiz/guest-result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + quizId: 'quiz-1', + answers: [ + { questionId: 'q1', selectedAnswerId: 'a1' }, + { questionId: 'q2', selectedAnswerId: 'a2' }, + ], + violations: [{ type: 'copy', timestamp: Date.now() }], + timeSpentSeconds: 30, + }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.score).toBe(1); + expect(data.totalQuestions).toBe(2); + expect(data.pointsAwarded).toBe(7); + }); +}); diff --git a/frontend/lib/tests/quiz/quiz-anticheat.test.ts b/frontend/lib/tests/quiz/quiz-anticheat.test.ts new file mode 100644 index 00000000..ea5967df --- /dev/null +++ b/frontend/lib/tests/quiz/quiz-anticheat.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +// Mock next-intl before importing the hook +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => `translated:${key}`, +})); + +// Mock sonner toast +vi.mock('sonner', () => ({ + toast: { + warning: vi.fn(), + }, +})); + +import { useAntiCheat } from '@/hooks/useAntiCheat'; +import { toast } from 'sonner'; + +describe('useAntiCheat', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('when isActive = true (default)', () => { + it('starts with zero violations', () => { + const { result } = renderHook(() => useAntiCheat(true)); + + expect(result.current.violations).toHaveLength(0); + expect(result.current.violationsCount).toBe(0); + }); + + it('detects copy event and adds violation', () => { + const { result } = renderHook(() => useAntiCheat(true)); + + act(() => { + const event = new Event('copy', { bubbles: true, cancelable: true }); + document.dispatchEvent(event); + }); + + expect(result.current.violationsCount).toBe(1); + expect(result.current.violations[0].type).toBe('copy'); + expect(toast.warning).toHaveBeenCalledWith('translated:copy', { duration: 3000 }); + }); + + it('detects paste event and adds violation', () => { + const { result } = renderHook(() => useAntiCheat(true)); + + act(() => { + const event = new Event('paste', { bubbles: true, cancelable: true }); + document.dispatchEvent(event); + }); + + expect(result.current.violationsCount).toBe(1); + expect(result.current.violations[0].type).toBe('paste'); + }); + + it('detects context-menu event and adds violation', () => { + const { result } = renderHook(() => useAntiCheat(true)); + + act(() => { + const event = new Event('contextmenu', { bubbles: true, cancelable: true }); + document.dispatchEvent(event); + }); + + expect(result.current.violationsCount).toBe(1); + expect(result.current.violations[0].type).toBe('context-menu'); + }); + + it('detects tab switch (visibilitychange) and adds violation', () => { + const { result } = renderHook(() => useAntiCheat(true)); + + // Mock document.hidden = true (tab became hidden) + Object.defineProperty(document, 'hidden', { + value: true, + writable: true, + configurable: true, + }); + + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + expect(result.current.violationsCount).toBe(1); + expect(result.current.violations[0].type).toBe('tab-switch'); + expect(result.current.isTabActive).toBe(false); + + // Restore + Object.defineProperty(document, 'hidden', { + value: false, + writable: true, + configurable: true, + }); + }); + + it('accumulates multiple violations', () => { + const { result } = renderHook(() => useAntiCheat(true)); + + act(() => { + document.dispatchEvent(new Event('copy')); + document.dispatchEvent(new Event('paste')); + document.dispatchEvent(new Event('contextmenu')); + }); + + expect(result.current.violationsCount).toBe(3); + }); + + it('sets showWarning to true when violation occurs', () => { + const { result } = renderHook(() => useAntiCheat(true)); + + expect(result.current.showWarning).toBe(false); + + act(() => { + document.dispatchEvent(new Event('copy')); + }); + + expect(result.current.showWarning).toBe(true); + }); + + + it('resetViolations clears all violations', () => { + const { result } = renderHook(() => useAntiCheat(true)); + + act(() => { + document.dispatchEvent(new Event('copy')); + document.dispatchEvent(new Event('paste')); + }); + + expect(result.current.violationsCount).toBe(2); + + act(() => { + result.current.resetViolations(); + }); + + expect(result.current.violationsCount).toBe(0); + expect(result.current.violations).toHaveLength(0); + }); + }); + + describe('when isActive = false', () => { + it('does not track violations', () => { + const { result } = renderHook(() => useAntiCheat(false)); + + act(() => { + document.dispatchEvent(new Event('copy')); + document.dispatchEvent(new Event('paste')); + document.dispatchEvent(new Event('contextmenu')); + }); + + expect(result.current.violationsCount).toBe(0); + expect(toast.warning).not.toHaveBeenCalled(); + }); + }); + + describe('cleanup on unmount', () => { + it('removes event listeners on unmount', () => { + const { unmount } = renderHook(() => useAntiCheat(true)); + + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('copy', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('contextmenu', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + + removeEventListenerSpy.mockRestore(); + }); + }); +}); diff --git a/frontend/lib/tests/quiz/quiz-crypto.test.ts b/frontend/lib/tests/quiz/quiz-crypto.test.ts new file mode 100644 index 00000000..84370f6b --- /dev/null +++ b/frontend/lib/tests/quiz/quiz-crypto.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + encryptAnswers, + decryptAnswers, + createEncryptedAnswersBlob, +} from '@/lib/quiz/quiz-crypto'; +import { setupQuizTestEnv, cleanupQuizTestEnv } from './setup'; +import { + createMockQuestions, + createCorrectAnswersMap, + resetFactoryCounters, +} from '../factories/quiz/quiz'; + +describe('quiz-crypto', () => { + // Setup: set encryption key before each test + beforeEach(() => { + setupQuizTestEnv(); + resetFactoryCounters(); + }); + + // Cleanup: remove encryption key after each test + afterEach(() => { + cleanupQuizTestEnv(); + }); + + describe('encryptAnswers', () => { + it('returns a base64 string', () => { + const answers = { 'q-1': 'a-1' }; + + const result = encryptAnswers(answers); + + // Base64 pattern: letters, numbers, +, /, ends with optional = + expect(result).toMatch(/^[A-Za-z0-9+/]+=*$/); + }); + + it('returns different output for same input (random IV)', () => { + const answers = { 'q-1': 'a-1' }; + + const result1 = encryptAnswers(answers); + const result2 = encryptAnswers(answers); + + // Each encryption uses random IV, so outputs differ + expect(result1).not.toBe(result2); + }); + + it('handles empty object', () => { + const result = encryptAnswers({}); + + expect(result).toBeDefined(); + expect(typeof result).toBe('string'); + }); + + it('handles multiple questions', () => { + const answers = { + 'q-1': 'a-1', + 'q-2': 'a-2', + 'q-3': 'a-3', + }; + + const result = encryptAnswers(answers); + + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('decryptAnswers', () => { + it('decrypts back to original data', () => { + const original = { 'q-1': 'a-1', 'q-2': 'a-2' }; + + const encrypted = encryptAnswers(original); + const decrypted = decryptAnswers(encrypted); + + expect(decrypted).toEqual(original); + }); + + it('returns null for tampered data', () => { + const original = { 'q-1': 'a-1' }; + const encrypted = encryptAnswers(original); + + // Tamper: change last 5 characters + const tampered = encrypted.slice(0, -5) + 'XXXXX'; + + const result = decryptAnswers(tampered); + + expect(result).toBeNull(); + }); + + it('returns null for invalid base64', () => { + const result = decryptAnswers('not-valid-base64!!!'); + + expect(result).toBeNull(); + }); + + it('returns null for empty string', () => { + const result = decryptAnswers(''); + + expect(result).toBeNull(); + }); + + it('returns null for truncated data', () => { + const original = { 'q-1': 'a-1' }; + const encrypted = encryptAnswers(original); + + // Truncate: remove half of the data + const truncated = encrypted.slice(0, encrypted.length / 2); + + const result = decryptAnswers(truncated); + + expect(result).toBeNull(); + }); + }); + + describe('createEncryptedAnswersBlob', () => { + it('creates encrypted blob from questions', () => { + const questions = createMockQuestions(3); + + const blob = createEncryptedAnswersBlob(questions); + + expect(blob).toBeDefined(); + expect(typeof blob).toBe('string'); + expect(blob.length).toBeGreaterThan(0); + }); + + it('encrypted blob decrypts to correct answers map', () => { + const questions = createMockQuestions(3); + const expectedMap = createCorrectAnswersMap(questions); + + const blob = createEncryptedAnswersBlob(questions); + const decrypted = decryptAnswers(blob); + + expect(decrypted).toEqual(expectedMap); + }); + + it('handles questions with no correct answer', () => { + const questions = [ + { + id: 'q-no-correct', + answers: [ + { id: 'a-1', isCorrect: false }, + { id: 'a-2', isCorrect: false }, + ], + }, + ]; + + const blob = createEncryptedAnswersBlob(questions); + const decrypted = decryptAnswers(blob); + + // Question with no correct answer is not included in map + expect(decrypted).toEqual({}); + }); + + it('handles empty questions array', () => { + const blob = createEncryptedAnswersBlob([]); + const decrypted = decryptAnswers(blob); + + expect(decrypted).toEqual({}); + }); + }); +}); diff --git a/frontend/lib/tests/quiz/quiz-session.test.ts b/frontend/lib/tests/quiz/quiz-session.test.ts new file mode 100644 index 00000000..8b204578 --- /dev/null +++ b/frontend/lib/tests/quiz/quiz-session.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + saveQuizSession, + loadQuizSession, + clearQuizSession, +} from '@/lib/quiz/quiz-session'; +import { installMockLocalStorage } from './setup'; +import { createMockQuizSession, resetFactoryCounters } from '../factories/quiz/quiz'; + +describe('quiz-session', () => { + beforeEach(() => { + installMockLocalStorage(); + resetFactoryCounters(); + }); + + describe('saveQuizSession', () => { + it('saves session to localStorage', () => { + const session = createMockQuizSession(); + + saveQuizSession('quiz-123', session); + + const stored = localStorage.getItem('quiz_session_quiz-123'); + expect(stored).not.toBeNull(); + }); + + it('saves session with updated savedAt timestamp', () => { + const session = createMockQuizSession({ savedAt: 0 }); + + saveQuizSession('quiz-123', session); + + const stored = JSON.parse(localStorage.getItem('quiz_session_quiz-123')!); + expect(stored.savedAt).toBeGreaterThan(0); + }); + + it('overwrites existing session', () => { + const session1 = createMockQuizSession({ currentIndex: 1 }); + const session2 = createMockQuizSession({ currentIndex: 5 }); + + saveQuizSession('quiz-123', session1); + saveQuizSession('quiz-123', session2); + + const stored = JSON.parse(localStorage.getItem('quiz_session_quiz-123')!); + expect(stored.currentIndex).toBe(5); + }); + }); + + describe('loadQuizSession', () => { + it('loads saved session', () => { + const session = createMockQuizSession({ + currentIndex: 3, + status: 'in_progress', + }); + saveQuizSession('quiz-123', session); + + const loaded = loadQuizSession('quiz-123'); + + expect(loaded).not.toBeNull(); + expect(loaded!.currentIndex).toBe(3); + expect(loaded!.status).toBe('in_progress'); + }); + + it('returns null for non-existent session', () => { + const loaded = loadQuizSession('non-existent'); + + expect(loaded).toBeNull(); + }); + + it('returns null for expired session (>30 min)', () => { + const thirtyOneMinutesAgo = Date.now() - 31 * 60 * 1000; + const session = createMockQuizSession({ + savedAt: thirtyOneMinutesAgo, + status: 'in_progress', + }); + + // Directly set localStorage to bypass saveQuizSession's timestamp update + localStorage.setItem( + 'quiz_session_quiz-123', + JSON.stringify(session) + ); + + const loaded = loadQuizSession('quiz-123'); + + expect(loaded).toBeNull(); + }); + + it('returns session if within 30 min', () => { + const twentyMinutesAgo = Date.now() - 20 * 60 * 1000; + const session = createMockQuizSession({ + savedAt: twentyMinutesAgo, + status: 'in_progress', + }); + + localStorage.setItem( + 'quiz_session_quiz-123', + JSON.stringify(session) + ); + + const loaded = loadQuizSession('quiz-123'); + + expect(loaded).not.toBeNull(); + }); + + it('returns null for completed session', () => { + const session = createMockQuizSession({ status: 'completed' }); + localStorage.setItem( + 'quiz_session_quiz-123', + JSON.stringify({ ...session, savedAt: Date.now() }) + ); + + const loaded = loadQuizSession('quiz-123'); + + expect(loaded).toBeNull(); + }); + + it('returns null for rules session', () => { + const session = createMockQuizSession({ status: 'rules' }); + localStorage.setItem( + 'quiz_session_quiz-123', + JSON.stringify({ ...session, savedAt: Date.now() }) + ); + + const loaded = loadQuizSession('quiz-123'); + + expect(loaded).toBeNull(); + }); + + it('clears expired session from storage', () => { + const thirtyOneMinutesAgo = Date.now() - 31 * 60 * 1000; + const session = createMockQuizSession({ savedAt: thirtyOneMinutesAgo }); + localStorage.setItem( + 'quiz_session_quiz-123', + JSON.stringify(session) + ); + + loadQuizSession('quiz-123'); + + expect(localStorage.getItem('quiz_session_quiz-123')).toBeNull(); + }); + }); + + describe('clearQuizSession', () => { + it('removes session from localStorage', () => { + const session = createMockQuizSession(); + saveQuizSession('quiz-123', session); + + clearQuizSession('quiz-123'); + + expect(localStorage.getItem('quiz_session_quiz-123')).toBeNull(); + }); + + it('does nothing if session does not exist', () => { + // Should not throw + expect(() => clearQuizSession('non-existent')).not.toThrow(); + }); + }); +}); diff --git a/frontend/lib/tests/quiz/quiz-setup.test.ts b/frontend/lib/tests/quiz/quiz-setup.test.ts new file mode 100644 index 00000000..3f4b1004 --- /dev/null +++ b/frontend/lib/tests/quiz/quiz-setup.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + createMockQuestion, + createMockQuestions, + createMockQuizSession, + createCorrectAnswersMap, + resetFactoryCounters, +} from '../factories/quiz/quiz'; +import { + setupQuizTestEnv, + cleanupQuizTestEnv, + installMockLocalStorage, + TEST_ENCRYPTION_KEY, +} from './setup'; + +describe('Quiz Test Infrastructure', () => { + describe('factories', () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it('createMockQuestion generates valid question', () => { + const question = createMockQuestion(); + + expect(question.id).toBe('q-1'); + expect(question.answers).toHaveLength(4); + expect(question.answers.filter(a => a.isCorrect)).toHaveLength(1); + }); + + it('createMockQuestions generates multiple questions', () => { + const questions = createMockQuestions(5); + + expect(questions).toHaveLength(5); + expect(questions[0].id).toBe('q-1'); + expect(questions[4].id).toBe('q-5'); + }); + + it('createCorrectAnswersMap extracts correct answers', () => { + const questions = createMockQuestions(3); + const map = createCorrectAnswersMap(questions); + + expect(Object.keys(map)).toHaveLength(3); + expect(map['q-1']).toBe('q-1-a1'); + expect(map['q-2']).toBe('q-2-a1'); + }); + + it('createMockQuizSession creates valid session', () => { + const session = createMockQuizSession(); + + expect(session.status).toBe('in_progress'); + expect(session.currentIndex).toBe(0); + expect(session.answers).toEqual([]); + }); + + it('createMockQuizSession accepts overrides', () => { + const session = createMockQuizSession({ + status: 'completed', + currentIndex: 5, + }); + + expect(session.status).toBe('completed'); + expect(session.currentIndex).toBe(5); + }); + }); + + describe('environment setup', () => { + afterEach(() => { + cleanupQuizTestEnv(); + }); + + it('setupQuizTestEnv sets encryption key', () => { + expect(process.env.QUIZ_ENCRYPTION_KEY).toBeUndefined(); + + setupQuizTestEnv(); + + expect(process.env.QUIZ_ENCRYPTION_KEY).toBe(TEST_ENCRYPTION_KEY); + }); + + it('cleanupQuizTestEnv removes encryption key', () => { + setupQuizTestEnv(); + cleanupQuizTestEnv(); + + expect(process.env.QUIZ_ENCRYPTION_KEY).toBeUndefined(); + }); + }); + + describe('localStorage mock', () => { + it('installMockLocalStorage provides working storage', () => { + const { store } = installMockLocalStorage(); + + localStorage.setItem('test-key', 'test-value'); + + expect(localStorage.getItem('test-key')).toBe('test-value'); + expect(store['test-key']).toBe('test-value'); + }); + + it('localStorage.removeItem works', () => { + installMockLocalStorage(); + + localStorage.setItem('key', 'value'); + localStorage.removeItem('key'); + + expect(localStorage.getItem('key')).toBeNull(); + }); + }); +}); diff --git a/frontend/lib/tests/quiz/quiz-slug-route.test.ts b/frontend/lib/tests/quiz/quiz-slug-route.test.ts new file mode 100644 index 00000000..23e814a7 --- /dev/null +++ b/frontend/lib/tests/quiz/quiz-slug-route.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/db/queries/quiz', () => ({ + getQuizBySlug: vi.fn(), + getQuizQuestionsRandomized: vi.fn(), +})); + +import { GET } from '@/app/api/quiz/[slug]/route'; +import { getQuizBySlug, getQuizQuestionsRandomized } from '@/db/queries/quiz'; + +const getQuizBySlugMock = getQuizBySlug as ReturnType; +const getQuizQuestionsRandomizedMock = getQuizQuestionsRandomized as ReturnType; + +describe('GET /api/quiz/[slug]', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 404 when quiz not found', async () => { + getQuizBySlugMock.mockResolvedValue(null); + + const request = new NextRequest('http://localhost/api/quiz/react?locale=en'); + const response = await GET(request, { params: Promise.resolve({ slug: 'react' }) }); + + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Quiz not found'); + }); + + it('returns quiz with formatted questions', async () => { + getQuizBySlugMock.mockResolvedValue({ + id: 'quiz-1', + slug: 'react', + title: 'React Quiz', + description: 'Basics', + questionsCount: 1, + timeLimitSeconds: 60, + }); + + getQuizQuestionsRandomizedMock.mockResolvedValue([ + { + id: 'q1', + displayOrder: 1, + difficulty: null, + questionText: 'Question 1', + explanation: null, + answers: [ + { id: 'a1', displayOrder: 1, isCorrect: true, answerText: 'Answer 1' }, + ], + }, + ]); + + const request = new NextRequest('http://localhost/api/quiz/react?locale=en'); + const response = await GET(request, { params: Promise.resolve({ slug: 'react' }) }); + + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.quiz).toEqual({ + id: 'quiz-1', + slug: 'react', + title: 'React Quiz', + description: 'Basics', + questionsCount: 1, + timeLimitSeconds: 60, + }); + expect(data.questions[0]).toMatchObject({ + id: 'q1', + number: 1, + text: 'Question 1', + difficulty: null, + explanation: null, + }); + expect(data.questions[0].answers[0]).toEqual({ + id: 'a1', + text: 'Answer 1', + isCorrect: true, + }); + }); + + it('returns 500 on unexpected error', async () => { + getQuizBySlugMock.mockRejectedValue(new Error('db error')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const request = new NextRequest('http://localhost/api/quiz/react?locale=en'); + const response = await GET(request, { params: Promise.resolve({ slug: 'react' }) }); + + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('Internal server error'); + consoleSpy.mockRestore(); + }); +}); diff --git a/frontend/lib/tests/quiz/setup.ts b/frontend/lib/tests/quiz/setup.ts new file mode 100644 index 00000000..237e1b38 --- /dev/null +++ b/frontend/lib/tests/quiz/setup.ts @@ -0,0 +1,79 @@ +import { vi, beforeEach, afterEach } from 'vitest'; + +/** + * 32-byte hex key for AES-256 (64 hex characters) + * Only used in tests — not a real secret + */ +export const TEST_ENCRYPTION_KEY = + 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2'; + +/** + * Sets up test environment with encryption key + * Call in beforeEach() or at top of test file + */ +export function setupQuizTestEnv(): void { + process.env.QUIZ_ENCRYPTION_KEY = TEST_ENCRYPTION_KEY; +} + +/** + * Cleans up test environment + * Call in afterEach() to prevent test pollution + */ +export function cleanupQuizTestEnv(): void { + delete process.env.QUIZ_ENCRYPTION_KEY; +} + +/** + * Mock localStorage for session tests + * Returns object with mock functions for assertions + */ +export function createMockLocalStorage() { + const store: Record = {}; + + const mockStorage = { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + Object.keys(store).forEach(key => delete store[key]); + }), + get length() { + return Object.keys(store).length; + }, + key: vi.fn((index: number) => Object.keys(store)[index] ?? null), + }; + + return { + storage: mockStorage, + store, // direct access to data for assertions + }; +} + +/** + * Installs mock localStorage on global object + * Use in beforeEach() for session tests + */ +export function installMockLocalStorage() { + const mock = createMockLocalStorage(); + + Object.defineProperty(globalThis, 'localStorage', { + value: mock.storage, + writable: true, + configurable: true, + }); + + // Mock window for browser environment check + if (typeof globalThis.window === 'undefined') { + Object.defineProperty(globalThis, 'window', { + value: { localStorage: mock.storage }, + writable: true, + configurable: true, + }); + } + + return mock; +} diff --git a/frontend/lib/tests/quiz/use-quiz-guards.test.ts b/frontend/lib/tests/quiz/use-quiz-guards.test.ts new file mode 100644 index 00000000..62fe0763 --- /dev/null +++ b/frontend/lib/tests/quiz/use-quiz-guards.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useQuizGuards } from '@/hooks/useQuizGuards'; +import { getQuizReloadKey } from '@/lib/quiz/quiz-storage-keys'; + +vi.mock('@/lib/quiz/quiz-session', () => ({ + clearQuizSession: vi.fn(), +})); + +import { clearQuizSession } from '@/lib/quiz/quiz-session'; + +const createParams = (overrides: Partial<{ + quizId: string; + status: 'rules' | 'in_progress' | 'completed'; + onExit: () => void; + resetViolations: () => void; +}> = {}) => ({ + quizId: 'quiz-1', + status: 'in_progress' as const, + onExit: vi.fn(), + resetViolations: vi.fn(), + ...overrides, +}); + +describe('useQuizGuards', () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + window.history.replaceState({}, ''); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('pushes history guard when quiz is in progress', () => { + renderHook(() => useQuizGuards(createParams())); + + expect(window.history.state?.quizGuard).toBe(true); + }); + + it('does not push guard when status is not in_progress', () => { + renderHook(() => + useQuizGuards(createParams({ status: 'rules' })) + ); + + expect(window.history.state?.quizGuard).toBeUndefined(); + }); + + it('sets reload flag on beforeunload when in progress', () => { + const params = createParams(); + const reloadKey = getQuizReloadKey(params.quizId); + + renderHook(() => useQuizGuards(params)); + + act(() => { + window.dispatchEvent(new Event('beforeunload')); + }); + + expect(sessionStorage.getItem(reloadKey)).toBe('1'); + }); + + it('clears session and resets violations on external link click', () => { + const params = createParams(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + renderHook(() => useQuizGuards(params)); + + window.history.pushState({}, '', '/en/quizzes'); + const link = document.createElement('a'); + link.setAttribute('href', 'javascript:void(0)'); + document.body.appendChild(link); + + const event = new MouseEvent('click', { bubbles: true }); + link.dispatchEvent(event); + + expect(confirmSpy).toHaveBeenCalled(); + expect(clearQuizSession).toHaveBeenCalledWith(params.quizId); + expect(params.resetViolations).toHaveBeenCalled(); + + link.remove(); + }); + + it('prevents navigation when user cancels external link', () => { + const params = createParams(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + renderHook(() => useQuizGuards(params)); + + const link = document.createElement('a'); + link.setAttribute('href', 'javascript:void(0)'); + document.body.appendChild(link); + + const preventDefault = vi.fn(); + const stopPropagation = vi.fn(); + + const event = new MouseEvent('click', { bubbles: true }) as MouseEvent & { + preventDefault: () => void; + stopPropagation: () => void; + }; + event.preventDefault = preventDefault; + event.stopPropagation = stopPropagation; + + link.dispatchEvent(event); + + expect(confirmSpy).toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + expect(clearQuizSession).not.toHaveBeenCalled(); + + link.remove(); + }); + + it('handles back navigation with confirm', () => { + const params = createParams(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + renderHook(() => useQuizGuards(params)); + + act(() => { + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + expect(confirmSpy).toHaveBeenCalled(); + expect(clearQuizSession).toHaveBeenCalledWith(params.quizId); + expect(params.resetViolations).toHaveBeenCalled(); + expect(params.onExit).toHaveBeenCalled(); + }); + + it('cancels back navigation when user declines', () => { + const params = createParams(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + const pushStateSpy = vi.spyOn(window.history, 'pushState'); + + renderHook(() => useQuizGuards(params)); + + act(() => { + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + expect(confirmSpy).toHaveBeenCalled(); + expect(pushStateSpy).toHaveBeenCalled(); + expect(clearQuizSession).not.toHaveBeenCalled(); + expect(params.onExit).not.toHaveBeenCalled(); + }); + + it('markQuitting bypasses unload confirmation', () => { + const params = createParams(); + const reloadKey = getQuizReloadKey(params.quizId); + + const { result } = renderHook(() => useQuizGuards(params)); + + act(() => { + result.current.markQuitting(); + window.dispatchEvent(new Event('beforeunload')); + }); + + expect(sessionStorage.getItem(reloadKey)).toBeNull(); + }); +}); diff --git a/frontend/lib/tests/quiz/use-quiz-session.test.ts b/frontend/lib/tests/quiz/use-quiz-session.test.ts new file mode 100644 index 00000000..9d91a904 --- /dev/null +++ b/frontend/lib/tests/quiz/use-quiz-session.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useQuizSession } from '@/hooks/useQuizSession'; +import { QUIZ_ALLOW_RESTORE_KEY, getQuizReloadKey } from '@/lib/quiz/quiz-storage-keys'; + +vi.mock('@/lib/quiz/quiz-session', () => ({ + saveQuizSession: vi.fn(), + loadQuizSession: vi.fn(), + clearQuizSession: vi.fn(), +})); + +import { saveQuizSession, loadQuizSession, clearQuizSession } from '@/lib/quiz/quiz-session'; + +type QuizState = { + status: 'rules' | 'in_progress' | 'completed'; + currentIndex: number; + answers: Array<{ + questionId: string; + selectedAnswerId: string; + isCorrect: boolean; + answeredAt: Date; + }>; + questionStatus: 'answering' | 'revealed'; + selectedAnswerId: string | null; + startedAt: Date | null; +}; + +const createState = (overrides: Partial = {}): QuizState => ({ + status: 'rules', + currentIndex: 0, + answers: [], + questionStatus: 'answering', + selectedAnswerId: null, + startedAt: null, + ...overrides, +}); + +describe('useQuizSession', () => { + const quizId = 'quiz-1'; + + const loadMock = loadQuizSession as ReturnType; + const saveMock = saveQuizSession as ReturnType; + const clearMock = clearQuizSession as ReturnType; + + beforeEach(() => { + sessionStorage.clear(); + vi.clearAllMocks(); + }); + + + it('restores session on reload', async () => { + const onRestore = vi.fn(); + const reloadKey = getQuizReloadKey(quizId); + + sessionStorage.setItem(reloadKey, '1'); + loadMock.mockReturnValue({ + status: 'in_progress', + currentIndex: 1, + answers: [], + questionStatus: 'answering', + selectedAnswerId: null, + startedAt: null, + savedAt: Date.now(), + }); + + renderHook(() => useQuizSession({ quizId, state: createState(), onRestore })); + + await waitFor(() => expect(onRestore).toHaveBeenCalledTimes(1)); + expect(clearMock).not.toHaveBeenCalled(); + expect(sessionStorage.getItem(reloadKey)).toBeNull(); + }); + + it('restores session when allow-restore flag is set', async () => { + const onRestore = vi.fn(); + + sessionStorage.setItem(QUIZ_ALLOW_RESTORE_KEY, '1'); + loadMock.mockReturnValue({ + status: 'in_progress', + currentIndex: 0, + answers: [], + questionStatus: 'answering', + selectedAnswerId: null, + startedAt: null, + savedAt: Date.now(), + }); + + renderHook(() => useQuizSession({ quizId, state: createState(), onRestore })); + + await waitFor(() => expect(onRestore).toHaveBeenCalledTimes(1)); + expect(clearMock).not.toHaveBeenCalled(); + expect(sessionStorage.getItem(QUIZ_ALLOW_RESTORE_KEY)).toBeNull(); + }); + + it('clears saved session when restore is not allowed', async () => { + const onRestore = vi.fn(); + + loadMock.mockReturnValue({ + status: 'in_progress', + currentIndex: 0, + answers: [], + questionStatus: 'answering', + selectedAnswerId: null, + startedAt: null, + savedAt: Date.now(), + }); + + renderHook(() => useQuizSession({ quizId, state: createState(), onRestore })); + + await waitFor(() => expect(clearMock).toHaveBeenCalledWith(quizId)); + expect(onRestore).not.toHaveBeenCalled(); + }); + + it('does nothing when no saved session exists', async () => { + const onRestore = vi.fn(); + + loadMock.mockReturnValue(null); + + renderHook(() => useQuizSession({ quizId, state: createState(), onRestore })); + + await waitFor(() => { + expect(onRestore).not.toHaveBeenCalled(); + expect(clearMock).not.toHaveBeenCalled(); + }); + }); + + it('saves session when status is in_progress', async () => { + const onRestore = vi.fn(); + const startedAt = new Date('2026-01-25T12:00:00Z'); + const answeredAt = new Date('2026-01-25T12:00:10Z'); + + const state = createState({ + status: 'in_progress', + currentIndex: 0, + questionStatus: 'revealed', + selectedAnswerId: 'a1', + startedAt, + answers: [ + { + questionId: 'q1', + selectedAnswerId: 'a1', + isCorrect: true, + answeredAt, + }, + ], + }); + + renderHook(() => useQuizSession({ quizId, state, onRestore })); + + await waitFor(() => expect(saveMock).toHaveBeenCalledTimes(1)); + + const [calledQuizId, payload] = saveMock.mock.calls[0]; + + expect(calledQuizId).toBe(quizId); + expect(payload).toEqual( + expect.objectContaining({ + status: 'in_progress', + currentIndex: 0, + questionStatus: 'revealed', + selectedAnswerId: 'a1', + startedAt: startedAt.getTime(), + savedAt: expect.any(Number), + }) + ); + expect(payload.answers).toEqual([ + { + questionId: 'q1', + selectedAnswerId: 'a1', + isCorrect: true, + answeredAt: answeredAt.getTime(), + }, + ]); + }); + + it('does not save session when status is not in_progress', async () => { + const onRestore = vi.fn(); + + renderHook(() => useQuizSession({ quizId, state: createState(), onRestore })); + + await waitFor(() => expect(saveMock).not.toHaveBeenCalled()); + }); +}); diff --git a/frontend/lib/tests/quiz/verify-answer.test.ts b/frontend/lib/tests/quiz/verify-answer.test.ts new file mode 100644 index 00000000..8bccbd60 --- /dev/null +++ b/frontend/lib/tests/quiz/verify-answer.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { POST } from '@/app/api/quiz/verify-answer/route'; +import { encryptAnswers } from '@/lib/quiz/quiz-crypto'; +import { setupQuizTestEnv, cleanupQuizTestEnv } from './setup'; +import { + createMockQuestions, + createCorrectAnswersMap, + resetFactoryCounters, +} from '../factories/quiz/quiz'; + +/** + * Creates a mock NextRequest for POST /api/quiz/verify-answer + */ +function createVerifyRequest(body: unknown): NextRequest { + return new NextRequest('http://localhost/api/quiz/verify-answer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /api/quiz/verify-answer', () => { + beforeEach(() => { + setupQuizTestEnv(); + resetFactoryCounters(); + }); + + afterEach(() => { + cleanupQuizTestEnv(); + }); + + describe('successful verification', () => { + it('returns isCorrect: true for correct answer', async () => { + const questions = createMockQuestions(3); + const correctAnswersMap = createCorrectAnswersMap(questions); + const encryptedAnswers = encryptAnswers(correctAnswersMap); + + const questionId = questions[0].id; + const correctAnswerId = correctAnswersMap[questionId]; + + const request = createVerifyRequest({ + questionId, + answerId: correctAnswerId, + encryptedAnswers, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.isCorrect).toBe(true); + }); + + it('returns isCorrect: false for wrong answer', async () => { + const questions = createMockQuestions(3); + const correctAnswersMap = createCorrectAnswersMap(questions); + const encryptedAnswers = encryptAnswers(correctAnswersMap); + + const questionId = questions[0].id; + const wrongAnswerId = questions[0].answers[1].id; // second answer is wrong + + const request = createVerifyRequest({ + questionId, + answerId: wrongAnswerId, + encryptedAnswers, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.isCorrect).toBe(false); + }); + }); + + describe('validation errors (400)', () => { + it('returns 400 for missing questionId', async () => { + const questions = createMockQuestions(1); + const correctAnswersMap = createCorrectAnswersMap(questions); + const encryptedAnswers = encryptAnswers(correctAnswersMap); + + const request = createVerifyRequest({ + answerId: 'some-answer', + encryptedAnswers, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Missing required fields'); + }); + + it('returns 400 for missing answerId', async () => { + const questions = createMockQuestions(1); + const correctAnswersMap = createCorrectAnswersMap(questions); + const encryptedAnswers = encryptAnswers(correctAnswersMap); + + const request = createVerifyRequest({ + questionId: questions[0].id, + encryptedAnswers, + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + }); + + it('returns 400 for missing encryptedAnswers', async () => { + const request = createVerifyRequest({ + questionId: 'q-1', + answerId: 'a-1', + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + }); + + it('returns 400 for tampered encryptedAnswers', async () => { + const questions = createMockQuestions(1); + const correctAnswersMap = createCorrectAnswersMap(questions); + const encryptedAnswers = encryptAnswers(correctAnswersMap); + + // Tamper with the encrypted data + const tamperedAnswers = encryptedAnswers.slice(0, -10) + 'XXXXXXXXXX'; + + const request = createVerifyRequest({ + questionId: questions[0].id, + answerId: questions[0].answers[0].id, + encryptedAnswers: tamperedAnswers, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Invalid encrypted data'); + }); + + it('returns 400 for invalid base64 encryptedAnswers', async () => { + const request = createVerifyRequest({ + questionId: 'q-1', + answerId: 'a-1', + encryptedAnswers: 'not-valid-base64!!!', + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + }); + }); + + describe('not found (404)', () => { + it('returns 404 for unknown questionId', async () => { + const questions = createMockQuestions(1); + const correctAnswersMap = createCorrectAnswersMap(questions); + const encryptedAnswers = encryptAnswers(correctAnswersMap); + + const request = createVerifyRequest({ + questionId: 'unknown-question-id', + answerId: 'some-answer', + encryptedAnswers, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Question not found'); + }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ba86bae7..b35b2343 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -53,6 +53,7 @@ "@types/nodemailer": "^7.0.4", "@types/react": "^19.2.8", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.0.18", "drizzle-kit": "^0.18.1", "eslint": "^9", "eslint-config-next": "16.0.1", @@ -1150,6 +1151,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -5586,6 +5597,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -5976,6 +6018,25 @@ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -8718,6 +8779,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -9260,6 +9328,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -9878,6 +9985,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -10193,16 +10341,6 @@ } } }, - "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==", - "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/package.json b/frontend/package.json index 6a58522d..26ff0c9b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -64,6 +64,7 @@ "@types/nodemailer": "^7.0.4", "@types/react": "^19.2.8", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.0.18", "drizzle-kit": "^0.18.1", "eslint": "^9", "eslint-config-next": "16.0.1", diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index b880cfdc..161ceee9 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -13,7 +13,12 @@ export default defineConfig({ }, test: { environment: 'node', - include: ['lib/tests/**/*.test.ts', 'components/tests/**/*.test.tsx'], + include: ['lib/tests/**/*.test.ts', 'components/tests/**/*.test.tsx', 'components/quiz/tests/**/*.test.tsx'], globals: true, + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: ['lib/quiz/**', 'hooks/**', 'app/api/quiz/**'], + }, }, });