diff --git a/api-gateway/src/routes/quiz.ts b/api-gateway/src/routes/quiz.ts index f3aa328..be37c84 100644 --- a/api-gateway/src/routes/quiz.ts +++ b/api-gateway/src/routes/quiz.ts @@ -5,14 +5,20 @@ import { asyncHandler } from '../utils/asyncHandler'; import { requireAuth } from '../middleware/auth'; import { getPool } from '../db/pool'; import { logger } from '../utils/logger'; +import { findCourseById } from '../services/courseStore'; +import { BadRequestError, ForbiddenError, NotFoundError } from '../utils/errors'; export const quizRouter = Router(); +const memCertificates = new Map< + string, + Array<{ id: string; courseId: string; issuedAt: string; serial: string }> +>(); + const SubmitBody = z.object({ courseId: z.string().min(1).max(80), sectionId: z.string().min(1).max(80).optional(), - answers: z.record(z.string(), z.unknown()).optional(), - score: z.number().int().min(0).max(100), + answers: z.array(z.number().int().min(0)).min(1), }); quizRouter.post( @@ -21,23 +27,54 @@ quizRouter.post( validate(SubmitBody), asyncHandler(async (req, res) => { const userId = req.user!.userId; - const { courseId, sectionId, answers, score } = req.body; + const { courseId, sectionId, answers } = req.body as { + courseId: string; + sectionId?: string; + answers: number[]; + }; + + const course = findCourseById(courseId); + if (!course) throw new NotFoundError(`Course '${courseId}' not found`); + + const quizLesson = course.sections + ?.filter((s) => !sectionId || s.id === sectionId) + .flatMap((s) => s.lessons ?? []) + .find((l) => l.type === 'quiz' && l.questions?.length); + + if (!quizLesson?.questions?.length) { + throw new NotFoundError('No quiz found for the specified course/section'); + } + + const questions = quizLesson.questions; + if (answers.length !== questions.length) { + throw new BadRequestError( + `Expected ${questions.length} answers but received ${answers.length}`, + ); + } + + // Server-side scoring + const results = questions.map((q, i) => ({ + correct: answers[i] === q.correctIndex, + explanation: q.explanation, + })); + const correctCount = results.filter((r) => r.correct).length; + const score = Math.round((correctCount / questions.length) * 100); const passed = score >= 70; - let certificateId: string | null = null; const pool = getPool(); if (pool) { try { await pool.query( `INSERT INTO quiz_attempts (user_id, course_id, section_id, score, passed, answers) VALUES ($1,$2,$3,$4,$5,$6)`, - [userId, courseId, sectionId ?? null, score, passed, answers ?? null], + [userId, courseId, sectionId ?? null, score, passed, JSON.stringify(answers)], ); } catch (err) { logger.warn('quiz_attempts insert failed', { error: (err as Error).message }); } } + let certificateId: string | null = null; if (passed) { certificateId = `CERT-${userId.slice(0, 4).toUpperCase()}-${courseId.slice(-3).toUpperCase()}-${Date.now()}`; if (pool) { @@ -50,6 +87,17 @@ quizRouter.post( } catch (err) { logger.warn('certificate insert failed', { error: (err as Error).message }); } + } else { + const existing = memCertificates.get(userId) ?? []; + if (!existing.some((c) => c.courseId === courseId)) { + existing.push({ + id: certificateId, + courseId, + issuedAt: new Date().toISOString(), + serial: certificateId, + }); + memCertificates.set(userId, existing); + } } } @@ -57,10 +105,51 @@ quizRouter.post( success: true, score, passed, + correctCount, + totalQuestions: questions.length, certificateId, + results, message: passed ? 'Congratulations! You passed!' : 'Keep studying and try again — you need 70% to pass.', }); }), ); + +quizRouter.get( + '/certificates/:userId', + requireAuth, + asyncHandler(async (req, res) => { + const { userId } = req.params; + if (req.user!.userId !== userId && req.user!.role !== 'admin') { + throw new ForbiddenError(); + } + + const pool = getPool(); + if (pool) { + try { + const { rows } = await pool.query( + `SELECT id, course_id, issued_at, serial + FROM certificates WHERE user_id=$1 ORDER BY issued_at DESC`, + [userId], + ); + return res.json({ + certificates: rows.map((r) => ({ + id: r.id, + courseId: r.course_id, + issuedAt: new Date(r.issued_at).toISOString(), + serial: r.serial, + })), + }); + } catch (err) { + logger.warn('certificates fetch DB error', { error: (err as Error).message }); + } + } + + res.json({ certificates: memCertificates.get(userId) ?? [] }); + }), +); + +export function _resetQuizMemoryStore(): void { + memCertificates.clear(); +} diff --git a/api-gateway/src/services/progressRepo.ts b/api-gateway/src/services/progressRepo.ts index 5498b7a..22fec86 100644 --- a/api-gateway/src/services/progressRepo.ts +++ b/api-gateway/src/services/progressRepo.ts @@ -1,5 +1,6 @@ import { getPool } from '../db/pool'; import { logger } from '../utils/logger'; +import { findCourseById } from './courseStore'; export interface ProgressRecord { courseId: string; @@ -155,6 +156,22 @@ export async function recordProgress(args: { if (args.lessonId && args.completed && !current.completedLessons.includes(args.lessonId)) { current.completedLessons.push(args.lessonId); + // Auto-compute percentage from course structure when caller doesn't provide one + if (typeof args.percentage === 'undefined') { + const course = findCourseById(args.courseId); + if (course) { + const totalLessons = (course.sections ?? []).reduce( + (acc, s) => acc + (s.lessons ?? []).length, + 0, + ); + if (totalLessons > 0) { + current.percentage = Math.min( + 100, + Math.round((current.completedLessons.length / totalLessons) * 100), + ); + } + } + } } if (typeof args.percentage === 'number') { current.percentage = Math.max(0, Math.min(100, Math.round(args.percentage))); diff --git a/api-gateway/tests/integration/courses.test.ts b/api-gateway/tests/integration/courses.test.ts index b145172..e40ce8a 100644 --- a/api-gateway/tests/integration/courses.test.ts +++ b/api-gateway/tests/integration/courses.test.ts @@ -50,7 +50,7 @@ describe('courses and progress', () => { expect(list.body.enrollments[0].courseId).toBe('course-001'); }); - it('forbids reading another user’s progress', async () => { + it('forbids reading another users progress', async () => { const { token } = await authedUser(); const res = await request(app) .get('/api/v1/progress/some-other-user') @@ -82,4 +82,17 @@ describe('courses and progress', () => { expect(get.status).toBe(200); expect(get.body.progress[0].completedLessons).toContain('l1-1'); }); + + it('auto-calculates percentage from completed lessons when no explicit percentage given', async () => { + const { token } = await authedUser(); + const post = await request(app) + .post('/api/v1/progress') + .set('Authorization', `Bearer ${token}`) + .send({ courseId: 'course-001', lessonId: 'l1-1', completed: true }); + expect(post.status).toBe(200); + // course-001 has more than 1 lesson, so completing 1 gives a percentage > 0 + expect(post.body.progress.percentage).toBeGreaterThan(0); + expect(post.body.progress.percentage).toBeLessThan(100); + expect(post.body.progress.completedLessons).toContain('l1-1'); + }); }); diff --git a/api-gateway/tests/integration/quiz.test.ts b/api-gateway/tests/integration/quiz.test.ts new file mode 100644 index 0000000..0bcc657 --- /dev/null +++ b/api-gateway/tests/integration/quiz.test.ts @@ -0,0 +1,156 @@ +import request from 'supertest'; +import { createApp } from '../../src/app'; +import { _resetMemoryStore as resetUsers } from '../../src/services/userRepo'; +import { _resetMemoryStore as resetProgress } from '../../src/services/progressRepo'; +import { _resetQuizMemoryStore } from '../../src/routes/quiz'; +import { resetCourseCache } from '../../src/services/courseStore'; + +// course-001 / section s1 / quiz q1 has 4 questions with correctIndices [1, 2, 2, 2] +const CORRECT_ANSWERS = [1, 2, 2, 2]; +const ALL_WRONG_ANSWERS = [0, 0, 0, 0]; + +describe('quiz routes', () => { + const app = createApp(); + + beforeAll(() => { + // Force course cache to reload from the full JSON + resetCourseCache(); + }); + + beforeEach(() => { + resetUsers(); + resetProgress(); + _resetQuizMemoryStore(); + }); + + async function authedUser(email = 'quiz@krai.test') { + const reg = await request(app).post('/api/v1/auth/register').send({ + email, + password: 'Sup3rSecret!', + firstName: 'QuizUser', + }); + return { token: reg.body.accessToken as string, userId: reg.body.user.id as string }; + } + + it('rejects unauthenticated quiz submissions', async () => { + const res = await request(app) + .post('/api/v1/quiz/submit') + .send({ courseId: 'course-001', sectionId: 's1', answers: CORRECT_ANSWERS }); + expect(res.status).toBe(401); + }); + + it('returns 404 for an unknown course', async () => { + const { token } = await authedUser(); + const res = await request(app) + .post('/api/v1/quiz/submit') + .set('Authorization', `Bearer ${token}`) + .send({ courseId: 'no-such-course', answers: [0] }); + expect(res.status).toBe(404); + }); + + it('returns 404 for a section with no quiz', async () => { + const { token } = await authedUser(); + const res = await request(app) + .post('/api/v1/quiz/submit') + .set('Authorization', `Bearer ${token}`) + .send({ courseId: 'course-001', sectionId: 'no-such-section', answers: [0] }); + expect(res.status).toBe(404); + }); + + it('rejects mismatched answer count', async () => { + const { token } = await authedUser(); + const res = await request(app) + .post('/api/v1/quiz/submit') + .set('Authorization', `Bearer ${token}`) + .send({ courseId: 'course-001', sectionId: 's1', answers: [1, 2] }); + expect(res.status).toBe(400); + }); + + it('scores all-correct answers: 100%, passes, issues a certificate', async () => { + const { token, userId } = await authedUser(); + const res = await request(app) + .post('/api/v1/quiz/submit') + .set('Authorization', `Bearer ${token}`) + .send({ courseId: 'course-001', sectionId: 's1', answers: CORRECT_ANSWERS }); + + expect(res.status).toBe(200); + expect(res.body.score).toBe(100); + expect(res.body.passed).toBe(true); + expect(res.body.correctCount).toBe(4); + expect(res.body.totalQuestions).toBe(4); + expect(res.body.certificateId).toBeTruthy(); + expect(res.body.results).toHaveLength(4); + expect(res.body.results.every((r: { correct: boolean }) => r.correct)).toBe(true); + + const certs = await request(app) + .get(`/api/v1/quiz/certificates/${userId}`) + .set('Authorization', `Bearer ${token}`); + expect(certs.status).toBe(200); + expect(certs.body.certificates).toHaveLength(1); + expect(certs.body.certificates[0].courseId).toBe('course-001'); + }); + + it('scores all-wrong answers: 0%, fails, no certificate', async () => { + const { token, userId } = await authedUser(); + const res = await request(app) + .post('/api/v1/quiz/submit') + .set('Authorization', `Bearer ${token}`) + .send({ courseId: 'course-001', sectionId: 's1', answers: ALL_WRONG_ANSWERS }); + + expect(res.status).toBe(200); + expect(res.body.score).toBe(0); + expect(res.body.passed).toBe(false); + expect(res.body.correctCount).toBe(0); + expect(res.body.certificateId).toBeNull(); + + const certs = await request(app) + .get(`/api/v1/quiz/certificates/${userId}`) + .set('Authorization', `Bearer ${token}`); + expect(certs.body.certificates).toHaveLength(0); + }); + + it('returns per-question correctness and explanations', async () => { + const { token } = await authedUser(); + // First answer correct (1), rest wrong (0) + const res = await request(app) + .post('/api/v1/quiz/submit') + .set('Authorization', `Bearer ${token}`) + .send({ courseId: 'course-001', sectionId: 's1', answers: [1, 0, 0, 0] }); + + expect(res.status).toBe(200); + expect(res.body.score).toBe(25); + expect(res.body.results[0].correct).toBe(true); + expect(res.body.results[1].correct).toBe(false); + expect(res.body.results[0].explanation).toBeTruthy(); + }); + + it('duplicate passes only issue one certificate', async () => { + const { token, userId } = await authedUser(); + await request(app) + .post('/api/v1/quiz/submit') + .set('Authorization', `Bearer ${token}`) + .send({ courseId: 'course-001', sectionId: 's1', answers: CORRECT_ANSWERS }); + await request(app) + .post('/api/v1/quiz/submit') + .set('Authorization', `Bearer ${token}`) + .send({ courseId: 'course-001', sectionId: 's1', answers: CORRECT_ANSWERS }); + + const certs = await request(app) + .get(`/api/v1/quiz/certificates/${userId}`) + .set('Authorization', `Bearer ${token}`); + expect(certs.body.certificates).toHaveLength(1); + }); + + it('forbids viewing another users certificates', async () => { + const { token } = await authedUser('quizA@krai.test'); + const res = await request(app) + .get('/api/v1/quiz/certificates/some-other-user-id') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(403); + }); + + it('requires auth to view certificates', async () => { + const res = await request(app).get('/api/v1/quiz/certificates/any-user-id'); + expect(res.status).toBe(401); + }); +});