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
99 changes: 94 additions & 5 deletions api-gateway/src/routes/quiz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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) {
Expand All @@ -50,17 +87,69 @@ 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);
}
}
}

res.json({
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();
}
17 changes: 17 additions & 0 deletions api-gateway/src/services/progressRepo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getPool } from '../db/pool';
import { logger } from '../utils/logger';
import { findCourseById } from './courseStore';

export interface ProgressRecord {
courseId: string;
Expand Down Expand Up @@ -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)));
Expand Down
15 changes: 14 additions & 1 deletion api-gateway/tests/integration/courses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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');
});
});
156 changes: 156 additions & 0 deletions api-gateway/tests/integration/quiz.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading