Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ yarn-error.log*

# Coverage directory
/coverage
/coverage-quiz

# Dotenv files
*.local
Expand Down
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# testing
/coverage
/coverage-quiz

# next.js
/.next/
Expand Down
118 changes: 118 additions & 0 deletions frontend/components/quiz/tests/quiz-container-flow.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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(
<QuizContainer
quizId="quiz-1"
quizSlug="quiz-1"
questions={questions}
encryptedAnswers="encrypted"
userId={null}
timeLimitSeconds={60}
seed={123}
categorySlug="react"
/>
);

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();
});
});
97 changes: 97 additions & 0 deletions frontend/lib/tests/factories/quiz/quiz.ts
Original file line number Diff line number Diff line change
@@ -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>): 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>
): 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<string, string> {
const map: Record<string, string> = {};
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;
}
101 changes: 101 additions & 0 deletions frontend/lib/tests/quiz/guest-quiz.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading