add a lot of changes

This commit is contained in:
AD2025
2025-11-12 00:49:22 +02:00
parent e3ca132c5e
commit a5fac2aaf7
17 changed files with 6704 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,699 @@
const { User, QuizSession, Category, sequelize } = require('../models');
const { Op } = require('sequelize');
/**
* Get user dashboard with stats, recent sessions, and category performance
* GET /api/users/:userId/dashboard
*/
exports.getUserDashboard = async (req, res) => {
try {
const { userId } = req.params;
const requestUserId = req.user.userId;
// Validate UUID format first
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(userId)) {
return res.status(400).json({
success: false,
message: 'Invalid user ID format'
});
}
// Check if user exists first (before authorization)
const user = await User.findByPk(userId, {
attributes: [
'id', 'username', 'email', 'role',
'totalQuizzes', 'quizzesPassed', 'totalQuestionsAnswered', 'correctAnswers',
'currentStreak', 'longestStreak', 'lastQuizDate',
'profileImage', 'createdAt'
]
});
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
// Authorization: Users can only access their own dashboard
if (userId !== requestUserId) {
return res.status(403).json({
success: false,
message: 'You do not have permission to access this dashboard'
});
}
// Calculate overall accuracy
const overallAccuracy = user.totalQuestionsAnswered > 0
? Math.round((user.correctAnswers / user.totalQuestionsAnswered) * 100)
: 0;
// Calculate pass rate
const passRate = user.totalQuizzes > 0
? Math.round((user.quizzesPassed / user.totalQuizzes) * 100)
: 0;
// Get recent quiz sessions (last 10 completed)
const recentSessions = await QuizSession.findAll({
where: {
userId,
status: {
[Op.in]: ['completed', 'timeout']
}
},
include: [
{
model: Category,
as: 'category',
attributes: ['id', 'name', 'slug', 'icon', 'color']
}
],
attributes: [
'id', 'categoryId', 'quizType', 'difficulty', 'status',
'score', 'totalPoints', 'isPassed',
'questionsAnswered', 'correctAnswers', 'timeSpent',
'startedAt', 'completedAt'
],
order: [['completedAt', 'DESC']],
limit: 10
});
// Format recent sessions
const formattedRecentSessions = recentSessions.map(session => {
const earned = parseFloat(session.score) || 0;
const total = parseFloat(session.totalPoints) || 0;
const percentage = total > 0 ? Math.round((earned / total) * 100) : 0;
return {
id: session.id,
category: {
id: session.category.id,
name: session.category.name,
slug: session.category.slug,
icon: session.category.icon,
color: session.category.color
},
quizType: session.quizType,
difficulty: session.difficulty,
status: session.status,
score: {
earned,
total,
percentage
},
isPassed: session.isPassed,
questionsAnswered: session.questionsAnswered,
correctAnswers: session.correctAnswers,
accuracy: session.questionsAnswered > 0
? Math.round((session.correctAnswers / session.questionsAnswered) * 100)
: 0,
timeSpent: session.timeSpent,
completedAt: session.completedAt
};
});
// Get category-wise performance
const categoryPerformance = await sequelize.query(`
SELECT
c.id,
c.name,
c.slug,
c.icon,
c.color,
COUNT(qs.id) as quizzes_taken,
SUM(CASE WHEN qs.is_passed = 1 THEN 1 ELSE 0 END) as quizzes_passed,
ROUND(AVG((qs.score / NULLIF(qs.total_points, 0)) * 100), 0) as average_score,
SUM(qs.questions_answered) as total_questions,
SUM(qs.correct_answers) as correct_answers,
ROUND(
(SUM(qs.correct_answers) / NULLIF(SUM(qs.questions_answered), 0)) * 100,
0
) as accuracy,
MAX(qs.completed_at) as last_attempt
FROM categories c
INNER JOIN quiz_sessions qs ON c.id = qs.category_id
WHERE qs.user_id = :userId
AND qs.status IN ('completed', 'timeout')
GROUP BY c.id, c.name, c.slug, c.icon, c.color
ORDER BY quizzes_taken DESC, accuracy DESC
`, {
replacements: { userId },
type: sequelize.QueryTypes.SELECT
});
// Format category performance
const formattedCategoryPerformance = categoryPerformance.map(cat => ({
category: {
id: cat.id,
name: cat.name,
slug: cat.slug,
icon: cat.icon,
color: cat.color
},
stats: {
quizzesTaken: parseInt(cat.quizzes_taken) || 0,
quizzesPassed: parseInt(cat.quizzes_passed) || 0,
passRate: cat.quizzes_taken > 0
? Math.round((cat.quizzes_passed / cat.quizzes_taken) * 100)
: 0,
averageScore: parseInt(cat.average_score) || 0,
totalQuestions: parseInt(cat.total_questions) || 0,
correctAnswers: parseInt(cat.correct_answers) || 0,
accuracy: parseInt(cat.accuracy) || 0
},
lastAttempt: cat.last_attempt
}));
// Get activity summary (last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentActivity = await QuizSession.findAll({
where: {
userId,
status: {
[Op.in]: ['completed', 'timeout']
},
completedAt: {
[Op.gte]: thirtyDaysAgo
}
},
attributes: [
[sequelize.fn('DATE', sequelize.col('completed_at')), 'date'],
[sequelize.fn('COUNT', sequelize.col('id')), 'quizzes_completed']
],
group: [sequelize.fn('DATE', sequelize.col('completed_at'))],
order: [[sequelize.fn('DATE', sequelize.col('completed_at')), 'DESC']],
raw: true
});
// Calculate streak status
const today = new Date().toDateString();
const lastActive = user.lastQuizDate?.toDateString();
const isActiveToday = lastActive === today;
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const wasActiveYesterday = lastActive === yesterday.toDateString();
let streakStatus = 'inactive';
if (isActiveToday) {
streakStatus = 'active';
} else if (wasActiveYesterday) {
streakStatus = 'at-risk'; // User needs to complete a quiz today to maintain streak
}
// Build response
const response = {
success: true,
data: {
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
profileImage: user.profileImage,
memberSince: user.createdAt
},
stats: {
totalQuizzes: user.totalQuizzes,
quizzesPassed: user.quizzesPassed,
passRate,
totalQuestionsAnswered: user.totalQuestionsAnswered,
correctAnswers: user.correctAnswers,
overallAccuracy,
currentStreak: user.currentStreak,
longestStreak: user.longestStreak,
streakStatus,
lastActiveDate: user.lastQuizDate
},
recentSessions: formattedRecentSessions,
categoryPerformance: formattedCategoryPerformance,
recentActivity: recentActivity.map(activity => ({
date: activity.date,
quizzesCompleted: parseInt(activity.quizzes_completed) || 0
}))
},
message: 'User dashboard retrieved successfully'
};
return res.status(200).json(response);
} catch (error) {
console.error('Error getting user dashboard:', error);
return res.status(500).json({
success: false,
message: 'An error occurred while retrieving user dashboard',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};
/**
* Get user quiz history with pagination, filtering, and sorting
* GET /api/users/:userId/history
*/
exports.getQuizHistory = async (req, res) => {
try {
const { userId } = req.params;
const requestUserId = req.user.userId;
// Query parameters
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 10, 50); // Max 50 per page
const categoryId = req.query.category;
const startDate = req.query.startDate;
const endDate = req.query.endDate;
const sortBy = req.query.sortBy || 'date'; // 'date' or 'score'
const sortOrder = req.query.sortOrder || 'desc'; // 'asc' or 'desc'
const status = req.query.status; // 'completed', 'timeout', 'abandoned'
// Validate UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(userId)) {
return res.status(400).json({
success: false,
message: 'Invalid user ID format'
});
}
// Check if user exists
const user = await User.findByPk(userId, {
attributes: ['id', 'username']
});
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
// Authorization: Users can only access their own history
if (userId !== requestUserId) {
return res.status(403).json({
success: false,
message: 'You do not have permission to access this quiz history'
});
}
// Build where clause
const whereClause = {
userId,
status: {
[Op.in]: ['completed', 'timeout', 'abandoned']
}
};
// Filter by category
if (categoryId) {
if (!uuidRegex.test(categoryId)) {
return res.status(400).json({
success: false,
message: 'Invalid category ID format'
});
}
whereClause.categoryId = categoryId;
}
// Filter by status
if (status && ['completed', 'timeout', 'abandoned'].includes(status)) {
whereClause.status = status;
}
// Filter by date range
if (startDate || endDate) {
whereClause.completedAt = {};
if (startDate) {
const start = new Date(startDate);
if (isNaN(start.getTime())) {
return res.status(400).json({
success: false,
message: 'Invalid start date format'
});
}
whereClause.completedAt[Op.gte] = start;
}
if (endDate) {
const end = new Date(endDate);
if (isNaN(end.getTime())) {
return res.status(400).json({
success: false,
message: 'Invalid end date format'
});
}
// Set to end of day
end.setHours(23, 59, 59, 999);
whereClause.completedAt[Op.lte] = end;
}
}
// Determine sort field
let orderField;
if (sortBy === 'score') {
orderField = 'score';
} else {
orderField = 'completedAt';
}
const orderDirection = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
// Calculate offset
const offset = (page - 1) * limit;
// Get total count for pagination
const totalCount = await QuizSession.count({ where: whereClause });
const totalPages = Math.ceil(totalCount / limit);
// Get quiz sessions
const sessions = await QuizSession.findAll({
where: whereClause,
include: [
{
model: Category,
as: 'category',
attributes: ['id', 'name', 'slug', 'icon', 'color']
}
],
attributes: [
'id',
'categoryId',
'quizType',
'difficulty',
'status',
'score',
'totalPoints',
'isPassed',
'questionsAnswered',
'totalQuestions',
'correctAnswers',
'timeSpent',
'timeLimit',
'startedAt',
'completedAt',
'createdAt'
],
order: [[orderField, orderDirection]],
limit,
offset
});
// Format sessions
const formattedSessions = sessions.map(session => {
const earned = parseFloat(session.score) || 0;
const total = parseFloat(session.totalPoints) || 0;
const percentage = total > 0 ? Math.round((earned / total) * 100) : 0;
return {
id: session.id,
category: session.category ? {
id: session.category.id,
name: session.category.name,
slug: session.category.slug,
icon: session.category.icon,
color: session.category.color
} : null,
quizType: session.quizType,
difficulty: session.difficulty,
status: session.status,
score: {
earned,
total,
percentage
},
isPassed: session.isPassed,
questions: {
answered: session.questionsAnswered,
total: session.totalQuestions,
correct: session.correctAnswers,
accuracy: session.questionsAnswered > 0
? Math.round((session.correctAnswers / session.questionsAnswered) * 100)
: 0
},
time: {
spent: session.timeSpent,
limit: session.timeLimit,
percentage: session.timeLimit > 0
? Math.round((session.timeSpent / session.timeLimit) * 100)
: 0
},
startedAt: session.startedAt,
completedAt: session.completedAt,
createdAt: session.createdAt
};
});
// Build response
const response = {
success: true,
data: {
sessions: formattedSessions,
pagination: {
currentPage: page,
totalPages,
totalItems: totalCount,
itemsPerPage: limit,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1
},
filters: {
category: categoryId || null,
status: status || null,
startDate: startDate || null,
endDate: endDate || null
},
sorting: {
sortBy,
sortOrder
}
},
message: 'Quiz history retrieved successfully'
};
return res.status(200).json(response);
} catch (error) {
console.error('Error getting quiz history:', error);
return res.status(500).json({
success: false,
message: 'An error occurred while retrieving quiz history',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};
/**
* Update user profile
* PUT /api/users/:userId
*/
exports.updateUserProfile = async (req, res) => {
const bcrypt = require('bcrypt');
try {
const { userId } = req.params;
const requestUserId = req.user.userId;
const { username, email, currentPassword, newPassword, profileImage } = req.body;
// Validate userId format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(userId)) {
return res.status(400).json({
success: false,
message: 'Invalid user ID format'
});
}
// Find user
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
// Authorization check - users can only update their own profile
if (userId !== requestUserId) {
return res.status(403).json({
success: false,
message: 'You are not authorized to update this profile'
});
}
// Track what fields are being updated
const updates = {};
const changedFields = [];
// Update username if provided
if (username !== undefined && username !== user.username) {
// Validate username
if (typeof username !== 'string' || username.trim().length === 0) {
return res.status(400).json({
success: false,
message: 'Username cannot be empty'
});
}
if (username.length < 3 || username.length > 50) {
return res.status(400).json({
success: false,
message: 'Username must be between 3 and 50 characters'
});
}
if (!/^[a-zA-Z0-9]+$/.test(username)) {
return res.status(400).json({
success: false,
message: 'Username must contain only letters and numbers'
});
}
// Check if username already exists
const existingUser = await User.findOne({ where: { username } });
if (existingUser && existingUser.id !== userId) {
return res.status(409).json({
success: false,
message: 'Username already exists'
});
}
updates.username = username;
changedFields.push('username');
}
// Update email if provided
if (email !== undefined && email !== user.email) {
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
message: 'Invalid email format'
});
}
// Check if email already exists
const existingUser = await User.findOne({ where: { email } });
if (existingUser && existingUser.id !== userId) {
return res.status(409).json({
success: false,
message: 'Email already exists'
});
}
updates.email = email;
changedFields.push('email');
}
// Update password if provided
if (newPassword !== undefined) {
// Verify current password is provided
if (!currentPassword) {
return res.status(400).json({
success: false,
message: 'Current password is required to change password'
});
}
// Validate new password length first (before checking current password)
if (newPassword.length < 6) {
return res.status(400).json({
success: false,
message: 'New password must be at least 6 characters'
});
}
// Verify current password is correct
const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
message: 'Current password is incorrect'
});
}
// Set new password (will be hashed by beforeUpdate hook in model)
updates.password = newPassword;
changedFields.push('password');
}
// Update profile image if provided
if (profileImage !== undefined && profileImage !== user.profileImage) {
// Allow null or empty string to remove profile image
if (profileImage === null || profileImage === '') {
updates.profileImage = null;
changedFields.push('profileImage');
} else if (typeof profileImage === 'string') {
// Basic URL validation (can be enhanced)
if (profileImage.length > 255) {
return res.status(400).json({
success: false,
message: 'Profile image URL is too long (max 255 characters)'
});
}
updates.profileImage = profileImage;
changedFields.push('profileImage');
} else {
return res.status(400).json({
success: false,
message: 'Profile image must be a string URL'
});
}
}
// Check if any fields were provided for update
if (Object.keys(updates).length === 0) {
return res.status(400).json({
success: false,
message: 'No fields provided for update'
});
}
// Update user
await user.update(updates);
// Fetch updated user (exclude password)
const updatedUser = await User.findByPk(userId, {
attributes: { exclude: ['password'] }
});
return res.status(200).json({
success: true,
data: {
user: updatedUser,
changedFields
},
message: 'Profile updated successfully'
});
} catch (error) {
console.error('Error updating user profile:', error);
// Handle Sequelize validation errors
if (error.name === 'SequelizeValidationError') {
return res.status(400).json({
success: false,
message: error.errors[0]?.message || 'Validation error'
});
}
// Handle unique constraint errors
if (error.name === 'SequelizeUniqueConstraintError') {
const field = error.errors[0]?.path;
return res.status(409).json({
success: false,
message: `${field} already exists`
});
}
return res.status(500).json({
success: false,
message: 'An error occurred while updating profile',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};

View File

@@ -58,7 +58,8 @@ exports.verifyGuestToken = async (req, res, next) => {
// Attach guest session to request // Attach guest session to request
req.guestSession = guestSession; req.guestSession = guestSession;
req.guestId = decoded.guestId; req.guestId = decoded.guestId; // The guest_id string for display/logging
req.guestSessionId = guestSession.id; // The UUID for database foreign keys
next(); next();
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,134 @@
const { v4: uuidv4 } = require('uuid');
module.exports = (sequelize, DataTypes) => {
const QuizAnswer = sequelize.define('QuizAnswer', {
id: {
type: DataTypes.CHAR(36),
primaryKey: true,
defaultValue: () => uuidv4(),
allowNull: false,
comment: 'UUID primary key'
},
quizSessionId: {
type: DataTypes.CHAR(36),
allowNull: false,
field: 'quiz_session_id',
validate: {
notEmpty: {
msg: 'Quiz session ID is required'
},
isUUID: {
args: 4,
msg: 'Quiz session ID must be a valid UUID'
}
},
comment: 'Foreign key to quiz_sessions table'
},
questionId: {
type: DataTypes.CHAR(36),
allowNull: false,
field: 'question_id',
validate: {
notEmpty: {
msg: 'Question ID is required'
},
isUUID: {
args: 4,
msg: 'Question ID must be a valid UUID'
}
},
comment: 'Foreign key to questions table'
},
selectedOption: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'selected_option',
validate: {
notEmpty: {
msg: 'Selected option is required'
}
},
comment: 'The option selected by the user'
},
isCorrect: {
type: DataTypes.BOOLEAN,
allowNull: false,
field: 'is_correct',
comment: 'Whether the selected answer was correct'
},
pointsEarned: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
field: 'points_earned',
validate: {
min: {
args: [0],
msg: 'Points earned must be non-negative'
}
},
comment: 'Points earned for this answer'
},
timeTaken: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
field: 'time_taken',
validate: {
min: {
args: [0],
msg: 'Time taken must be non-negative'
}
},
comment: 'Time taken to answer in seconds'
},
answeredAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'answered_at',
comment: 'When the question was answered'
}
}, {
tableName: 'quiz_answers',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['quiz_session_id'],
name: 'idx_quiz_answers_session_id'
},
{
fields: ['question_id'],
name: 'idx_quiz_answers_question_id'
},
{
fields: ['quiz_session_id', 'question_id'],
unique: true,
name: 'idx_quiz_answers_session_question_unique'
},
{
fields: ['is_correct'],
name: 'idx_quiz_answers_is_correct'
},
{
fields: ['answered_at'],
name: 'idx_quiz_answers_answered_at'
}
]
});
// Associations
QuizAnswer.associate = (models) => {
QuizAnswer.belongsTo(models.QuizSession, {
foreignKey: 'quizSessionId',
as: 'quizSession'
});
QuizAnswer.belongsTo(models.Question, {
foreignKey: 'questionId',
as: 'question'
});
};
return QuizAnswer;
};

View File

@@ -0,0 +1,58 @@
const { DataTypes } = require('sequelize');
const { v4: uuidv4 } = require('uuid');
module.exports = (sequelize) => {
const QuizSessionQuestion = sequelize.define('QuizSessionQuestion', {
id: {
type: DataTypes.CHAR(36),
primaryKey: true,
allowNull: false
},
quizSessionId: {
type: DataTypes.CHAR(36),
allowNull: false,
field: 'quiz_session_id'
},
questionId: {
type: DataTypes.CHAR(36),
allowNull: false,
field: 'question_id'
},
questionOrder: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
field: 'question_order',
validate: {
min: 1
}
}
}, {
tableName: 'quiz_session_questions',
underscored: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
hooks: {
beforeValidate: (quizSessionQuestion) => {
if (!quizSessionQuestion.id) {
quizSessionQuestion.id = uuidv4();
}
}
}
});
// Define associations
QuizSessionQuestion.associate = (models) => {
QuizSessionQuestion.belongsTo(models.QuizSession, {
foreignKey: 'quizSessionId',
as: 'quizSession'
});
QuizSessionQuestion.belongsTo(models.Question, {
foreignKey: 'questionId',
as: 'question'
});
};
return QuizSessionQuestion;
};

View File

@@ -28,6 +28,7 @@
"test:question-search": "node test-question-search.js", "test:question-search": "node test-question-search.js",
"test:create-question": "node test-create-question.js", "test:create-question": "node test-create-question.js",
"test:update-delete-question": "node test-update-delete-question.js", "test:update-delete-question": "node test-update-delete-question.js",
"test:start-quiz": "node test-start-quiz.js",
"validate:env": "node validate-env.js", "validate:env": "node validate-env.js",
"generate:jwt": "node generate-jwt-secret.js", "generate:jwt": "node generate-jwt-secret.js",
"migrate": "npx sequelize-cli db:migrate", "migrate": "npx sequelize-cli db:migrate",

View File

@@ -0,0 +1,111 @@
const express = require('express');
const router = express.Router();
const quizController = require('../controllers/quiz.controller');
const { verifyToken } = require('../middleware/auth.middleware');
const { verifyGuestToken } = require('../middleware/guest.middleware');
/**
* Middleware to handle both authenticated users and guests
* Tries user auth first, then guest auth
*/
const authenticateUserOrGuest = async (req, res, next) => {
// Try to verify user token first
const authHeader = req.headers['authorization'];
if (authHeader && authHeader.startsWith('Bearer ')) {
try {
await new Promise((resolve, reject) => {
verifyToken(req, res, (err) => {
if (err) reject(err);
else resolve();
});
});
if (req.user) {
return next();
}
} catch (error) {
// User auth failed, continue to guest auth
}
}
// Try to verify guest token
const guestToken = req.headers['x-guest-token'];
if (guestToken) {
try {
await new Promise((resolve, reject) => {
verifyGuestToken(req, res, (err) => {
if (err) reject(err);
else resolve();
});
});
if (req.guestId) {
return next();
}
} catch (error) {
// Guest auth also failed
}
}
// Neither authentication method worked
return res.status(401).json({
success: false,
message: 'Authentication required. Please login or start a guest session.'
});
};
/**
* @route POST /api/quiz/start
* @desc Start a new quiz session
* @access Private (User or Guest)
* @body {
* categoryId: uuid (required),
* questionCount: number (1-50, default 10),
* difficulty: 'easy' | 'medium' | 'hard' | 'mixed' (default 'mixed'),
* quizType: 'practice' | 'timed' | 'exam' (default 'practice')
* }
*/
router.post('/start', authenticateUserOrGuest, quizController.startQuizSession);
/**
* @route POST /api/quiz/submit
* @desc Submit an answer for a quiz question
* @access Private (User or Guest)
* @body {
* quizSessionId: uuid (required),
* questionId: uuid (required),
* userAnswer: string (required),
* timeSpent: number (optional, seconds)
* }
*/
router.post('/submit', authenticateUserOrGuest, quizController.submitAnswer);
/**
* @route POST /api/quiz/complete
* @desc Complete a quiz session and get final results
* @access Private (User or Guest)
* @body {
* sessionId: uuid (required)
* }
*/
router.post('/complete', authenticateUserOrGuest, quizController.completeQuizSession);
/**
* @route GET /api/quiz/session/:sessionId
* @desc Get quiz session details with questions and answers
* @access Private (User or Guest)
* @params {
* sessionId: uuid (required)
* }
*/
router.get('/session/:sessionId', authenticateUserOrGuest, quizController.getSessionDetails);
/**
* @route GET /api/quiz/review/:sessionId
* @desc Review completed quiz with all answers, explanations, and visual feedback
* @access Private (User or Guest)
* @params {
* sessionId: uuid (required)
* }
*/
router.get('/review/:sessionId', authenticateUserOrGuest, quizController.reviewQuizSession);
module.exports = router;

View File

@@ -0,0 +1,40 @@
const express = require('express');
const router = express.Router();
const userController = require('../controllers/user.controller');
const { verifyToken } = require('../middleware/auth.middleware');
/**
* @route GET /api/users/:userId/dashboard
* @desc Get user dashboard with stats, recent sessions, and category performance
* @access Private (User - own dashboard only)
*/
router.get('/:userId/dashboard', verifyToken, userController.getUserDashboard);
/**
* @route GET /api/users/:userId/history
* @desc Get user quiz history with pagination, filtering, and sorting
* @query page - Page number (default: 1)
* @query limit - Items per page (default: 10, max: 50)
* @query category - Filter by category ID
* @query status - Filter by status (completed, timeout, abandoned)
* @query startDate - Filter by start date (ISO 8601)
* @query endDate - Filter by end date (ISO 8601)
* @query sortBy - Sort by field (date, score) (default: date)
* @query sortOrder - Sort order (asc, desc) (default: desc)
* @access Private (User - own history only)
*/
router.get('/:userId/history', verifyToken, userController.getQuizHistory);
/**
* @route PUT /api/users/:userId
* @desc Update user profile
* @body username - New username (optional)
* @body email - New email (optional)
* @body currentPassword - Current password (required if changing password)
* @body newPassword - New password (optional)
* @body profileImage - Profile image URL (optional)
* @access Private (User - own profile only)
*/
router.put('/:userId', verifyToken, userController.updateUserProfile);
module.exports = router;

View File

@@ -70,12 +70,16 @@ const guestRoutes = require('./routes/guest.routes');
const categoryRoutes = require('./routes/category.routes'); const categoryRoutes = require('./routes/category.routes');
const questionRoutes = require('./routes/question.routes'); const questionRoutes = require('./routes/question.routes');
const adminRoutes = require('./routes/admin.routes'); const adminRoutes = require('./routes/admin.routes');
const quizRoutes = require('./routes/quiz.routes');
const userRoutes = require('./routes/user.routes');
app.use(`${API_PREFIX}/auth`, authRoutes); app.use(`${API_PREFIX}/auth`, authRoutes);
app.use(`${API_PREFIX}/guest`, guestRoutes); app.use(`${API_PREFIX}/guest`, guestRoutes);
app.use(`${API_PREFIX}/categories`, categoryRoutes); app.use(`${API_PREFIX}/categories`, categoryRoutes);
app.use(`${API_PREFIX}/questions`, questionRoutes); app.use(`${API_PREFIX}/questions`, questionRoutes);
app.use(`${API_PREFIX}/admin`, adminRoutes); app.use(`${API_PREFIX}/admin`, adminRoutes);
app.use(`${API_PREFIX}/quiz`, quizRoutes);
app.use(`${API_PREFIX}/users`, userRoutes);
// Root endpoint // Root endpoint
app.get('/', (req, res) => { app.get('/', (req, res) => {

View File

@@ -0,0 +1,547 @@
/**
* Complete Quiz Session API Tests
* Tests for POST /api/quiz/complete endpoint
*/
const axios = require('axios');
const API_URL = 'http://localhost:3000/api';
// Test configuration
let adminToken = null;
let user1Token = null;
let user2Token = null;
let guestToken = null;
let guestSessionId = null;
// Helper function to create auth config
const authConfig = (token) => ({
headers: { 'Authorization': `Bearer ${token}` }
});
// Helper function for guest auth config
const guestAuthConfig = (token) => ({
headers: { 'X-Guest-Token': token }
});
// Logging helper
const log = (message, data = null) => {
console.log(`\n${message}`);
if (data) {
console.log(JSON.stringify(data, null, 2));
}
};
// Test setup
async function setup() {
try {
// Login as admin (to get categories)
const adminLogin = await axios.post(`${API_URL}/auth/login`, {
email: 'admin@quiz.com',
password: 'Admin@123'
});
adminToken = adminLogin.data.data.token;
console.log('✓ Logged in as admin');
// Register and login test users
const timestamp = Date.now();
// User 1
await axios.post(`${API_URL}/auth/register`, {
username: `testcomplete1${timestamp}`,
email: `testcomplete1${timestamp}@test.com`,
password: 'Test@123'
});
const user1Login = await axios.post(`${API_URL}/auth/login`, {
email: `testcomplete1${timestamp}@test.com`,
password: 'Test@123'
});
user1Token = user1Login.data.data.token;
console.log('✓ Logged in as testuser1');
// User 2
await axios.post(`${API_URL}/auth/register`, {
username: `testcomplete2${timestamp}`,
email: `testcomplete2${timestamp}@test.com`,
password: 'Test@123'
});
const user2Login = await axios.post(`${API_URL}/auth/login`, {
email: `testcomplete2${timestamp}@test.com`,
password: 'Test@123'
});
user2Token = user2Login.data.data.token;
console.log('✓ Logged in as testuser2');
// Start guest session
const guestResponse = await axios.post(`${API_URL}/guest/start-session`, {
deviceId: `test-device-${timestamp}`
});
guestToken = guestResponse.data.data.sessionToken;
guestSessionId = guestResponse.data.data.guestId;
console.log('✓ Started guest session');
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
process.exit(1);
}
}
// Test results tracking
let testResults = {
passed: 0,
failed: 0,
total: 0
};
// Test runner
async function runTest(testName, testFn) {
testResults.total++;
try {
await testFn();
console.log(`${testName} - PASSED`);
testResults.passed++;
} catch (error) {
console.log(`${testName} - FAILED`);
console.log(` ${error.message}`);
testResults.failed++;
}
// Add delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 500));
}
// Helper: Create and complete a quiz session
async function createAndAnswerQuiz(token, isGuest = false) {
// Get categories
const categoriesResponse = await axios.get(`${API_URL}/categories`,
isGuest ? guestAuthConfig(token) : authConfig(token)
);
const categories = categoriesResponse.data.data;
const category = categories.find(c => c.guestAccessible) || categories[0];
// Start quiz
const quizResponse = await axios.post(
`${API_URL}/quiz/start`,
{
categoryId: category.id,
questionCount: 3,
difficulty: 'mixed',
quizType: 'practice'
},
isGuest ? guestAuthConfig(token) : authConfig(token)
);
const sessionId = quizResponse.data.data.sessionId;
const questions = quizResponse.data.data.questions;
// Submit answers for all questions
for (const question of questions) {
await axios.post(
`${API_URL}/quiz/submit`,
{
quizSessionId: sessionId,
questionId: question.id,
userAnswer: 'a', // Use consistent answer
timeTaken: 5
},
isGuest ? guestAuthConfig(token) : authConfig(token)
);
}
return { sessionId, totalQuestions: questions.length };
}
// ==================== TESTS ====================
async function runTests() {
console.log('\n========================================');
console.log('Testing Complete Quiz Session API');
console.log('========================================\n');
await setup();
// Test 1: Complete quiz with all questions answered
await runTest('Complete quiz returns detailed results', async () => {
const { sessionId } = await createAndAnswerQuiz(user1Token);
const response = await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId },
authConfig(user1Token)
);
if (response.status !== 200) throw new Error(`Expected 200, got ${response.status}`);
if (!response.data.success) throw new Error('Response success should be true');
if (!response.data.data) throw new Error('Missing data in response');
const results = response.data.data;
// Validate structure
if (!results.sessionId) throw new Error('Missing sessionId');
if (!results.status) throw new Error('Missing status');
if (!results.category) throw new Error('Missing category');
if (!results.score) throw new Error('Missing score');
if (!results.questions) throw new Error('Missing questions');
if (!results.time) throw new Error('Missing time');
if (typeof results.accuracy !== 'number') throw new Error('Missing or invalid accuracy');
if (typeof results.isPassed !== 'boolean') throw new Error('Missing or invalid isPassed');
// Validate score structure
if (typeof results.score.earned !== 'number') {
console.log(' Score object:', JSON.stringify(results.score, null, 2));
throw new Error(`Missing or invalid score.earned (type: ${typeof results.score.earned}, value: ${results.score.earned})`);
}
if (typeof results.score.total !== 'number') throw new Error('Missing score.total');
if (typeof results.score.percentage !== 'number') throw new Error('Missing score.percentage');
// Validate questions structure
if (results.questions.total !== 3) throw new Error('Expected 3 total questions');
if (results.questions.answered !== 3) throw new Error('Expected 3 answered questions');
// Validate time structure
if (!results.time.started) throw new Error('Missing time.started');
if (!results.time.completed) throw new Error('Missing time.completed');
if (typeof results.time.taken !== 'number') throw new Error('Missing time.taken');
console.log(` Score: ${results.score.earned}/${results.score.total} (${results.score.percentage}%)`);
console.log(` Accuracy: ${results.accuracy}%`);
console.log(` Passed: ${results.isPassed}`);
});
// Test 2: Guest can complete quiz
await runTest('Guest can complete quiz', async () => {
const { sessionId } = await createAndAnswerQuiz(guestToken, true);
const response = await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId },
guestAuthConfig(guestToken)
);
if (response.status !== 200) throw new Error(`Expected 200, got ${response.status}`);
if (!response.data.success) throw new Error('Response success should be true');
if (!response.data.data.sessionId) throw new Error('Missing sessionId in results');
});
// Test 3: Percentage calculation is correct
await runTest('Percentage calculated correctly', async () => {
const { sessionId } = await createAndAnswerQuiz(user1Token);
const response = await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId },
authConfig(user1Token)
);
const results = response.data.data;
const expectedPercentage = Math.round((results.score.earned / results.score.total) * 100);
if (results.score.percentage !== expectedPercentage) {
throw new Error(`Expected ${expectedPercentage}%, got ${results.score.percentage}%`);
}
});
// Test 4: Pass/fail determination (70% threshold)
await runTest('Pass/fail determination works', async () => {
const { sessionId } = await createAndAnswerQuiz(user1Token);
const response = await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId },
authConfig(user1Token)
);
const results = response.data.data;
const expectedPassed = results.score.percentage >= 70;
if (results.isPassed !== expectedPassed) {
throw new Error(`Expected isPassed=${expectedPassed}, got ${results.isPassed}`);
}
});
// Test 5: Time tracking works
await runTest('Time tracking accurate', async () => {
const { sessionId } = await createAndAnswerQuiz(user1Token);
// Wait 2 seconds before completing
await new Promise(resolve => setTimeout(resolve, 2000));
const response = await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId },
authConfig(user1Token)
);
const results = response.data.data;
if (results.time.taken < 2) {
throw new Error(`Expected at least 2 seconds, got ${results.time.taken}`);
}
if (results.time.taken > 60) {
throw new Error(`Time taken seems too long: ${results.time.taken}s`);
}
});
console.log('\n========================================');
console.log('Testing Validation');
console.log('========================================\n');
// Test 6: Missing session ID returns 400
await runTest('Missing session ID returns 400', async () => {
try {
await axios.post(
`${API_URL}/quiz/complete`,
{},
authConfig(user1Token)
);
throw new Error('Should have thrown error');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
}
});
// Test 7: Invalid session UUID returns 400
await runTest('Invalid session UUID returns 400', async () => {
try {
await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId: 'invalid-uuid' },
authConfig(user1Token)
);
throw new Error('Should have thrown error');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
}
});
// Test 8: Non-existent session returns 404
await runTest('Non-existent session returns 404', async () => {
try {
await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId: '00000000-0000-0000-0000-000000000000' },
authConfig(user1Token)
);
throw new Error('Should have thrown error');
} catch (error) {
if (error.response?.status !== 404) {
throw new Error(`Expected 404, got ${error.response?.status}`);
}
}
});
// Test 9: Cannot complete another user's session
await runTest('Cannot complete another user\'s session', async () => {
const { sessionId } = await createAndAnswerQuiz(user1Token);
try {
await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId },
authConfig(user2Token) // Different user
);
throw new Error('Should have thrown error');
} catch (error) {
if (error.response?.status !== 403) {
throw new Error(`Expected 403, got ${error.response?.status}`);
}
}
});
// Test 10: Cannot complete already completed session
await runTest('Cannot complete already completed session', async () => {
const { sessionId } = await createAndAnswerQuiz(user1Token);
// Complete once
await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId },
authConfig(user1Token)
);
// Try to complete again
try {
await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId },
authConfig(user1Token)
);
throw new Error('Should have thrown error');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
if (!error.response.data.message.includes('already completed')) {
throw new Error('Error message should mention already completed');
}
}
});
// Test 11: Unauthenticated request blocked
await runTest('Unauthenticated request blocked', async () => {
const { sessionId } = await createAndAnswerQuiz(user1Token);
try {
await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId }
// No auth headers
);
throw new Error('Should have thrown error');
} catch (error) {
if (error.response?.status !== 401) {
throw new Error(`Expected 401, got ${error.response?.status}`);
}
}
});
console.log('\n========================================');
console.log('Testing Partial Completion');
console.log('========================================\n');
// Test 12: Can complete with unanswered questions
await runTest('Can complete with unanswered questions', async () => {
// Get category with most questions
const categoriesResponse = await axios.get(`${API_URL}/categories`, authConfig(user1Token));
const category = categoriesResponse.data.data.sort((a, b) => b.questionCount - a.questionCount)[0];
// Start quiz with requested questions (but we'll only answer some)
const requestedCount = Math.min(5, category.questionCount); // Don't request more than available
if (requestedCount < 3) {
console.log(' Skipping - not enough questions in category');
return; // Skip if not enough questions
}
const quizResponse = await axios.post(
`${API_URL}/quiz/start`,
{
categoryId: category.id,
questionCount: requestedCount,
difficulty: 'mixed',
quizType: 'practice'
},
authConfig(user1Token)
);
const sessionId = quizResponse.data.data.sessionId;
const questions = quizResponse.data.data.questions;
const actualCount = questions.length;
if (actualCount < 3) {
console.log(' Skipping - not enough questions returned');
return;
}
// Answer only 2 questions (leaving others unanswered)
await axios.post(
`${API_URL}/quiz/submit`,
{
quizSessionId: sessionId,
questionId: questions[0].id,
userAnswer: 'a',
timeTaken: 5
},
authConfig(user1Token)
);
await axios.post(
`${API_URL}/quiz/submit`,
{
quizSessionId: sessionId,
questionId: questions[1].id,
userAnswer: 'b',
timeTaken: 5
},
authConfig(user1Token)
);
// Complete quiz
const response = await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId },
authConfig(user1Token)
);
const results = response.data.data;
if (results.questions.total !== actualCount) {
throw new Error(`Expected ${actualCount} total questions, got ${results.questions.total}`);
}
if (results.questions.answered !== 2) throw new Error('Expected 2 answered questions');
if (results.questions.unanswered !== actualCount - 2) {
throw new Error(`Expected ${actualCount - 2} unanswered questions, got ${results.questions.unanswered}`);
}
});
// Test 13: Status updated to completed
await runTest('Session status updated to completed', async () => {
const { sessionId } = await createAndAnswerQuiz(user1Token);
const response = await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId },
authConfig(user1Token)
);
const results = response.data.data;
if (results.status !== 'completed') {
throw new Error(`Expected status 'completed', got '${results.status}'`);
}
});
// Test 14: Category info included in results
await runTest('Category info included in results', async () => {
const { sessionId } = await createAndAnswerQuiz(user1Token);
const response = await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId },
authConfig(user1Token)
);
const results = response.data.data;
if (!results.category.id) throw new Error('Missing category.id');
if (!results.category.name) throw new Error('Missing category.name');
if (!results.category.slug) throw new Error('Missing category.slug');
});
// Test 15: Correct/incorrect counts accurate
await runTest('Correct/incorrect counts accurate', async () => {
const { sessionId, totalQuestions } = await createAndAnswerQuiz(user1Token);
const response = await axios.post(
`${API_URL}/quiz/complete`,
{ sessionId },
authConfig(user1Token)
);
const results = response.data.data;
const sumCheck = results.questions.correct + results.questions.incorrect + results.questions.unanswered;
if (sumCheck !== totalQuestions) {
throw new Error(`Question counts don't add up: ${sumCheck} !== ${totalQuestions}`);
}
});
// Print summary
console.log('\n========================================');
console.log('Test Summary');
console.log('========================================\n');
console.log(`Passed: ${testResults.passed}`);
console.log(`Failed: ${testResults.failed}`);
console.log(`Total: ${testResults.total}`);
console.log('========================================\n');
process.exit(testResults.failed > 0 ? 1 : 0);
}
// Run all tests
runTests().catch(error => {
console.error('Test execution failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,551 @@
const axios = require('axios');
const API_URL = 'http://localhost:3000/api';
// Test data
const testUser = {
username: 'historytest',
email: 'historytest@example.com',
password: 'Test123!@#'
};
const secondUser = {
username: 'historytest2',
email: 'historytest2@example.com',
password: 'Test123!@#'
};
let userToken;
let userId;
let secondUserToken;
let secondUserId;
let testCategory;
let testSessions = [];
// Helper function to add delay
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Helper function to create and complete a quiz
async function createAndCompleteQuiz(token, categoryId, numQuestions) {
const headers = { 'Authorization': `Bearer ${token}` };
// Start quiz
const startRes = await axios.post(`${API_URL}/quiz/start`, {
categoryId,
quizType: 'practice',
difficulty: 'medium',
numberOfQuestions: numQuestions
}, { headers });
const sessionId = startRes.data.data.sessionId;
const questions = startRes.data.data.questions;
if (!sessionId) {
throw new Error('No sessionId returned from start quiz');
}
// Submit answers
for (let i = 0; i < questions.length; i++) {
const question = questions[i];
// Just pick a random option ID since we don't know the correct answer
const randomOption = question.options[Math.floor(Math.random() * question.options.length)];
try {
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: sessionId, // Fixed: use quizSessionId
questionId: question.id,
userAnswer: randomOption.id, // Fixed: use userAnswer
timeSpent: Math.floor(Math.random() * 30) + 5 // Fixed: use timeSpent
}, { headers });
} catch (error) {
console.error(`Submit error for question ${i + 1}:`, {
sessionId,
questionId: question.id,
userAnswer: randomOption.id,
error: error.response?.data
});
throw error;
}
await delay(100);
}
// Complete quiz
await axios.post(`${API_URL}/quiz/complete`, {
sessionId: sessionId // Field name is sessionId for complete endpoint
}, { headers });
return sessionId;
}
// Test setup
async function setup() {
console.log('Setting up test data...\n');
try {
// Register first user
try {
const registerRes = await axios.post(`${API_URL}/auth/register`, testUser);
userToken = registerRes.data.data.token;
userId = registerRes.data.data.user.id;
console.log('✓ First user registered');
} catch (error) {
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: testUser.email,
password: testUser.password
});
userToken = loginRes.data.data.token;
userId = loginRes.data.data.user.id;
console.log('✓ First user logged in');
}
// Register second user
try {
const registerRes = await axios.post(`${API_URL}/auth/register`, secondUser);
secondUserToken = registerRes.data.data.token;
secondUserId = registerRes.data.data.user.id;
console.log('✓ Second user registered');
} catch (error) {
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: secondUser.email,
password: secondUser.password
});
secondUserToken = loginRes.data.data.token;
secondUserId = loginRes.data.data.user.id;
console.log('✓ Second user logged in');
}
// Get categories
const categoriesRes = await axios.get(`${API_URL}/categories`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const categories = categoriesRes.data.data;
categories.sort((a, b) => b.questionCount - a.questionCount);
testCategory = categories.find(c => c.questionCount >= 3);
if (!testCategory) {
throw new Error('No category with enough questions found (need at least 3 questions)');
}
console.log(`✓ Test category selected: ${testCategory.name} (${testCategory.questionCount} questions)`);
await delay(500);
// Create multiple quizzes for testing pagination and filtering
console.log('Creating quiz sessions for history testing...');
for (let i = 0; i < 8; i++) {
try {
const sessionId = await createAndCompleteQuiz(userToken, testCategory.id, 3);
testSessions.push(sessionId);
console.log(` Created session ${i + 1}/8`);
await delay(500);
} catch (error) {
console.error(` Failed to create session ${i + 1}:`, error.response?.data || error.message);
throw error;
}
}
console.log('✓ Quiz sessions created\n');
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
throw error;
}
}
// Tests
const tests = [
{
name: 'Test 1: Get quiz history with default pagination',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/history`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (!response.data.success) throw new Error('Request failed');
if (!response.data.data.sessions) throw new Error('No sessions in response');
if (!response.data.data.pagination) throw new Error('No pagination data');
const { pagination, sessions } = response.data.data;
if (pagination.itemsPerPage !== 10) throw new Error('Default limit should be 10');
if (pagination.currentPage !== 1) throw new Error('Default page should be 1');
if (sessions.length > 10) throw new Error('Should not exceed limit');
return '✓ Default pagination works';
}
},
{
name: 'Test 2: Pagination structure is correct',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/history?page=1&limit=5`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { pagination } = response.data.data;
const requiredFields = ['currentPage', 'totalPages', 'totalItems', 'itemsPerPage', 'hasNextPage', 'hasPreviousPage'];
for (const field of requiredFields) {
if (!(field in pagination)) throw new Error(`Missing pagination field: ${field}`);
}
if (pagination.currentPage !== 1) throw new Error('Current page mismatch');
if (pagination.itemsPerPage !== 5) throw new Error('Items per page mismatch');
if (pagination.hasPreviousPage !== false) throw new Error('First page should not have previous');
return '✓ Pagination structure correct';
}
},
{
name: 'Test 3: Sessions have all required fields',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/history?limit=1`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const session = response.data.data.sessions[0];
if (!session) throw new Error('No session in response');
const requiredFields = [
'id', 'category', 'quizType', 'difficulty', 'status',
'score', 'isPassed', 'questions', 'time',
'startedAt', 'completedAt'
];
for (const field of requiredFields) {
if (!(field in session)) throw new Error(`Missing field: ${field}`);
}
// Check nested objects
if (!session.score.earned && session.score.earned !== 0) throw new Error('Missing score.earned');
if (!session.score.total) throw new Error('Missing score.total');
if (!session.score.percentage && session.score.percentage !== 0) throw new Error('Missing score.percentage');
if (!session.questions.answered && session.questions.answered !== 0) throw new Error('Missing questions.answered');
if (!session.questions.total) throw new Error('Missing questions.total');
if (!session.questions.correct && session.questions.correct !== 0) throw new Error('Missing questions.correct');
if (!session.questions.accuracy && session.questions.accuracy !== 0) throw new Error('Missing questions.accuracy');
return '✓ Session fields correct';
}
},
{
name: 'Test 4: Pagination with custom limit',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/history?limit=3`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { sessions, pagination } = response.data.data;
if (sessions.length > 3) throw new Error('Exceeded custom limit');
if (pagination.itemsPerPage !== 3) throw new Error('Limit not applied');
return '✓ Custom limit works';
}
},
{
name: 'Test 5: Navigate to second page',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/history?page=2&limit=5`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { pagination } = response.data.data;
if (pagination.currentPage !== 2) throw new Error('Not on page 2');
if (pagination.hasPreviousPage !== true) throw new Error('Should have previous page');
return '✓ Page navigation works';
}
},
{
name: 'Test 6: Filter by category',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/history?category=${testCategory.id}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { sessions, filters } = response.data.data;
if (filters.category !== testCategory.id) throw new Error('Category filter not applied');
for (const session of sessions) {
if (session.category.id !== testCategory.id) {
throw new Error('Session from wrong category returned');
}
}
return '✓ Category filter works';
}
},
{
name: 'Test 7: Filter by status',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/history?status=completed`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { sessions, filters } = response.data.data;
if (filters.status !== 'completed') throw new Error('Status filter not applied');
for (const session of sessions) {
if (session.status !== 'completed' && session.status !== 'timeout') {
throw new Error(`Unexpected status: ${session.status}`);
}
}
return '✓ Status filter works';
}
},
{
name: 'Test 8: Sort by score descending',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/history?sortBy=score&sortOrder=desc&limit=5`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { sessions, sorting } = response.data.data;
if (sorting.sortBy !== 'score') throw new Error('Sort by not applied');
if (sorting.sortOrder !== 'desc') throw new Error('Sort order not applied');
// Check if sorted in descending order
for (let i = 0; i < sessions.length - 1; i++) {
if (sessions[i].score.earned < sessions[i + 1].score.earned) {
throw new Error('Not sorted by score descending');
}
}
return '✓ Sort by score works';
}
},
{
name: 'Test 9: Sort by date ascending',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/history?sortBy=date&sortOrder=asc&limit=5`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { sessions } = response.data.data;
// Check if sorted in ascending order by date
for (let i = 0; i < sessions.length - 1; i++) {
const date1 = new Date(sessions[i].completedAt);
const date2 = new Date(sessions[i + 1].completedAt);
if (date1 > date2) {
throw new Error('Not sorted by date ascending');
}
}
return '✓ Sort by date ascending works';
}
},
{
name: 'Test 10: Default sort is by date descending',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/history?limit=5`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { sessions, sorting } = response.data.data;
if (sorting.sortBy !== 'date') throw new Error('Default sort should be date');
if (sorting.sortOrder !== 'desc') throw new Error('Default order should be desc');
// Check if sorted in descending order by date (most recent first)
for (let i = 0; i < sessions.length - 1; i++) {
const date1 = new Date(sessions[i].completedAt);
const date2 = new Date(sessions[i + 1].completedAt);
if (date1 < date2) {
throw new Error('Not sorted by date descending');
}
}
return '✓ Default sort correct';
}
},
{
name: 'Test 11: Limit maximum items per page',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/history?limit=100`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { pagination } = response.data.data;
if (pagination.itemsPerPage > 50) {
throw new Error('Should limit to max 50 items per page');
}
return '✓ Max limit enforced';
}
},
{
name: 'Test 12: Cross-user access blocked',
run: async () => {
try {
await axios.get(`${API_URL}/users/${secondUserId}/history`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been blocked');
} catch (error) {
if (error.response?.status !== 403) {
throw new Error(`Expected 403, got ${error.response?.status}`);
}
return '✓ Cross-user access blocked';
}
}
},
{
name: 'Test 13: Unauthenticated request blocked',
run: async () => {
try {
await axios.get(`${API_URL}/users/${userId}/history`);
throw new Error('Should have been blocked');
} catch (error) {
if (error.response?.status !== 401) {
throw new Error(`Expected 401, got ${error.response?.status}`);
}
return '✓ Unauthenticated blocked';
}
}
},
{
name: 'Test 14: Invalid UUID returns 400',
run: async () => {
try {
await axios.get(`${API_URL}/users/invalid-uuid/history`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 400');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Invalid UUID returns 400';
}
}
},
{
name: 'Test 15: Non-existent user returns 404',
run: async () => {
try {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
await axios.get(`${API_URL}/users/${fakeUuid}/history`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 404');
} catch (error) {
if (error.response?.status !== 404) {
throw new Error(`Expected 404, got ${error.response?.status}`);
}
return '✓ Non-existent user returns 404';
}
}
},
{
name: 'Test 16: Invalid category ID returns 400',
run: async () => {
try {
await axios.get(`${API_URL}/users/${userId}/history?category=invalid-id`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 400');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Invalid category ID returns 400';
}
}
},
{
name: 'Test 17: Invalid date format returns 400',
run: async () => {
try {
await axios.get(`${API_URL}/users/${userId}/history?startDate=invalid-date`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 400');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Invalid date returns 400';
}
}
},
{
name: 'Test 18: Combine filters and sorting',
run: async () => {
const response = await axios.get(
`${API_URL}/users/${userId}/history?category=${testCategory.id}&sortBy=score&sortOrder=desc&limit=3`,
{ headers: { 'Authorization': `Bearer ${userToken}` } }
);
const { sessions, filters, sorting } = response.data.data;
if (filters.category !== testCategory.id) throw new Error('Category filter not applied');
if (sorting.sortBy !== 'score') throw new Error('Sort not applied');
if (sessions.length > 3) throw new Error('Limit not applied');
// Check category filter
for (const session of sessions) {
if (session.category.id !== testCategory.id) {
throw new Error('Wrong category in results');
}
}
// Check sorting
for (let i = 0; i < sessions.length - 1; i++) {
if (sessions[i].score.earned < sessions[i + 1].score.earned) {
throw new Error('Not sorted correctly');
}
}
return '✓ Combined filters work';
}
}
];
// Run tests
async function runTests() {
console.log('============================================================');
console.log('QUIZ HISTORY API TESTS');
console.log('============================================================\n');
await setup();
console.log('Running tests...\n');
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
const result = await test.run();
console.log(result);
passed++;
} catch (error) {
console.log(`${test.name}`);
console.log(` Error: ${error.message}`);
if (error.response?.data) {
console.log(` Response:`, JSON.stringify(error.response.data, null, 2));
}
failed++;
}
}
console.log('\n============================================================');
console.log(`RESULTS: ${passed} passed, ${failed} failed out of ${tests.length} tests`);
console.log('============================================================');
process.exit(failed > 0 ? 1 : 0);
}
runTests();

650
backend/test-review-quiz.js Normal file
View File

@@ -0,0 +1,650 @@
const axios = require('axios');
const API_URL = 'http://localhost:3000/api';
// Test data
let testUser = {
email: 'reviewtest@example.com',
password: 'Test@123',
username: 'reviewtester'
};
let secondUser = {
email: 'otherreviewer@example.com',
password: 'Test@123',
username: 'otherreviewer'
};
let userToken = null;
let secondUserToken = null;
let guestToken = null;
let guestId = null;
let testCategory = null;
let completedSessionId = null;
let inProgressSessionId = null;
let guestCompletedSessionId = null;
// Helper to add delay between tests
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// Helper to create and complete a quiz
async function createAndCompleteQuiz(token, isGuest = false, questionCount = 3) {
const headers = isGuest
? { 'X-Guest-Token': token }
: { 'Authorization': `Bearer ${token}` };
// Get categories
const categoriesRes = await axios.get(`${API_URL}/categories`, { headers });
const categories = categoriesRes.data.data;
const category = categories.find(c => c.questionCount >= questionCount);
if (!category) {
throw new Error('No category with enough questions found');
}
// Start quiz
const startRes = await axios.post(`${API_URL}/quiz/start`, {
categoryId: category.id,
questionCount,
difficulty: 'mixed',
quizType: 'practice'
}, { headers });
const sessionId = startRes.data.data.sessionId;
const questions = startRes.data.data.questions;
// Submit answers for all questions
for (const question of questions) {
let answer;
if (question.questionType === 'multiple') {
answer = question.options[0].id;
} else if (question.questionType === 'trueFalse') {
answer = 'true';
} else {
answer = 'Sample answer';
}
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: sessionId,
questionId: question.id,
userAnswer: answer,
timeTaken: Math.floor(Math.random() * 20) + 5 // 5-25 seconds
}, { headers });
await delay(100);
}
// Complete quiz
await axios.post(`${API_URL}/quiz/complete`, {
sessionId
}, { headers });
return sessionId;
}
// Test setup
async function setup() {
console.log('Setting up test data...\n');
try {
// Register first user
try {
const registerRes = await axios.post(`${API_URL}/auth/register`, testUser);
userToken = registerRes.data.data.token;
console.log('✓ First user registered');
} catch (error) {
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: testUser.email,
password: testUser.password
});
userToken = loginRes.data.data.token;
console.log('✓ First user logged in');
}
// Register second user
try {
const registerRes = await axios.post(`${API_URL}/auth/register`, secondUser);
secondUserToken = registerRes.data.data.token;
console.log('✓ Second user registered');
} catch (error) {
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: secondUser.email,
password: secondUser.password
});
secondUserToken = loginRes.data.data.token;
console.log('✓ Second user logged in');
}
// Create guest session
const guestRes = await axios.post(`${API_URL}/guest/start-session`);
guestToken = guestRes.data.data.sessionToken;
guestId = guestRes.data.data.guestId;
console.log('✓ Guest session created');
// Get a test category
const categoriesRes = await axios.get(`${API_URL}/categories`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const categories = categoriesRes.data.data;
// Sort by questionCount descending to get category with most questions
categories.sort((a, b) => b.questionCount - a.questionCount);
testCategory = categories.find(c => c.questionCount >= 3);
if (!testCategory) {
throw new Error('No category with enough questions found');
}
console.log(`✓ Test category selected: ${testCategory.name} (${testCategory.questionCount} questions)`);
await delay(500);
// Create completed quiz for user (use available question count, max 5)
const quizQuestionCount = Math.min(testCategory.questionCount, 5);
completedSessionId = await createAndCompleteQuiz(userToken, false, quizQuestionCount);
console.log(`✓ User completed session created (${quizQuestionCount} questions)`);
await delay(500);
// Create in-progress quiz for user
const startRes = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategory.id,
questionCount: 3,
difficulty: 'easy',
quizType: 'practice'
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
inProgressSessionId = startRes.data.data.sessionId;
// Submit one answer to make it in-progress
const questions = startRes.data.data.questions;
let answer = questions[0].questionType === 'multiple'
? questions[0].options[0].id
: 'true';
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: inProgressSessionId,
questionId: questions[0].id,
userAnswer: answer,
timeTaken: 10
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
console.log('✓ User in-progress session created');
await delay(500);
// Create completed quiz for guest
guestCompletedSessionId = await createAndCompleteQuiz(guestToken, true, 3);
console.log('✓ Guest completed session created\n');
await delay(500);
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
throw error;
}
}
// Test cases
const tests = [
{
name: 'Test 1: Review completed quiz (user)',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/review/${completedSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (response.status !== 200) throw new Error('Expected 200 status');
if (!response.data.success) throw new Error('Expected success true');
const { session, summary, questions } = response.data.data;
// Validate session
if (!session.id || session.id !== completedSessionId) throw new Error('Invalid session id');
if (session.status !== 'completed') throw new Error('Expected completed status');
if (!session.category || !session.category.name) throw new Error('Missing category info');
// Validate summary
if (typeof summary.score.earned !== 'number') throw new Error('Score.earned should be number');
if (typeof summary.accuracy !== 'number') throw new Error('Accuracy should be number');
if (typeof summary.isPassed !== 'boolean') throw new Error('isPassed should be boolean');
if (summary.questions.total < 3) throw new Error('Expected at least 3 total questions');
// Validate questions
if (questions.length < 3) throw new Error('Expected at least 3 questions');
// All questions should have correct answers shown
questions.forEach((q, idx) => {
if (q.correctAnswer === undefined) {
throw new Error(`Question ${idx + 1} should show correct answer`);
}
if (q.resultStatus === undefined) {
throw new Error(`Question ${idx + 1} should have resultStatus`);
}
if (q.showExplanation !== true) {
throw new Error(`Question ${idx + 1} should have showExplanation`);
}
});
return '✓ Completed quiz review correct';
}
},
{
name: 'Test 2: Review guest completed quiz',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/review/${guestCompletedSessionId}`, {
headers: { 'X-Guest-Token': guestToken }
});
if (response.status !== 200) throw new Error('Expected 200 status');
const { session, summary, questions } = response.data.data;
if (session.id !== guestCompletedSessionId) throw new Error('Invalid session id');
if (session.status !== 'completed') throw new Error('Expected completed status');
if (questions.length !== 3) throw new Error('Expected 3 questions');
return '✓ Guest quiz review works';
}
},
{
name: 'Test 3: Cannot review in-progress quiz (400)',
run: async () => {
try {
await axios.get(`${API_URL}/quiz/review/${inProgressSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 400');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
if (!error.response?.data?.message?.includes('completed')) {
throw new Error('Error message should mention completed status');
}
return '✓ In-progress quiz review blocked';
}
}
},
{
name: 'Test 4: Missing session ID returns 400',
run: async () => {
try {
await axios.get(`${API_URL}/quiz/review/`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed');
} catch (error) {
if (error.response?.status !== 404 && error.response?.status !== 400) {
throw new Error(`Expected 400 or 404, got ${error.response?.status}`);
}
return '✓ Missing session ID handled';
}
}
},
{
name: 'Test 5: Invalid UUID format returns 400',
run: async () => {
try {
await axios.get(`${API_URL}/quiz/review/invalid-uuid`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 400');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Invalid UUID returns 400';
}
}
},
{
name: 'Test 6: Non-existent session returns 404',
run: async () => {
try {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
await axios.get(`${API_URL}/quiz/review/${fakeUuid}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 404');
} catch (error) {
if (error.response?.status !== 404) {
throw new Error(`Expected 404, got ${error.response?.status}`);
}
return '✓ Non-existent session returns 404';
}
}
},
{
name: 'Test 7: Cannot access other user\'s quiz review (403)',
run: async () => {
try {
await axios.get(`${API_URL}/quiz/review/${completedSessionId}`, {
headers: { 'Authorization': `Bearer ${secondUserToken}` }
});
throw new Error('Should have failed with 403');
} catch (error) {
if (error.response?.status !== 403) {
throw new Error(`Expected 403, got ${error.response?.status}`);
}
return '✓ Cross-user access blocked';
}
}
},
{
name: 'Test 8: Unauthenticated request returns 401',
run: async () => {
try {
await axios.get(`${API_URL}/quiz/review/${completedSessionId}`);
throw new Error('Should have failed with 401');
} catch (error) {
if (error.response?.status !== 401) {
throw new Error(`Expected 401, got ${error.response?.status}`);
}
return '✓ Unauthenticated request blocked';
}
}
},
{
name: 'Test 9: Response includes all required session fields',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/review/${completedSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { session } = response.data.data;
const requiredFields = [
'id', 'status', 'quizType', 'difficulty', 'category',
'startedAt', 'completedAt', 'timeSpent'
];
requiredFields.forEach(field => {
if (!(field in session)) {
throw new Error(`Missing required session field: ${field}`);
}
});
return '✓ All required session fields present';
}
},
{
name: 'Test 10: Response includes all required summary fields',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/review/${completedSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { summary } = response.data.data;
// Score fields
if (!summary.score || typeof summary.score.earned !== 'number') {
throw new Error('Missing or invalid score.earned');
}
if (typeof summary.score.total !== 'number') {
throw new Error('Missing or invalid score.total');
}
if (typeof summary.score.percentage !== 'number') {
throw new Error('Missing or invalid score.percentage');
}
// Questions summary
const qFields = ['total', 'answered', 'correct', 'incorrect', 'unanswered'];
qFields.forEach(field => {
if (typeof summary.questions[field] !== 'number') {
throw new Error(`Missing or invalid questions.${field}`);
}
});
// Other fields
if (typeof summary.accuracy !== 'number') {
throw new Error('Missing or invalid accuracy');
}
if (typeof summary.isPassed !== 'boolean') {
throw new Error('Missing or invalid isPassed');
}
// Time statistics
if (!summary.timeStatistics) {
throw new Error('Missing timeStatistics');
}
if (typeof summary.timeStatistics.totalTime !== 'number') {
throw new Error('Missing or invalid timeStatistics.totalTime');
}
return '✓ All required summary fields present';
}
},
{
name: 'Test 11: Questions include all required fields',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/review/${completedSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { questions } = response.data.data;
if (questions.length === 0) throw new Error('Should have questions');
const requiredFields = [
'id', 'questionText', 'questionType', 'difficulty', 'points',
'explanation', 'order', 'correctAnswer', 'userAnswer', 'isCorrect',
'resultStatus', 'pointsEarned', 'pointsPossible', 'timeTaken',
'answeredAt', 'showExplanation', 'wasAnswered'
];
questions.forEach((q, idx) => {
requiredFields.forEach(field => {
if (!(field in q)) {
throw new Error(`Question ${idx + 1} missing field: ${field}`);
}
});
});
return '✓ Questions have all required fields';
}
},
{
name: 'Test 12: Result status correctly marked',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/review/${completedSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { questions } = response.data.data;
questions.forEach((q, idx) => {
if (q.wasAnswered) {
const expectedStatus = q.isCorrect ? 'correct' : 'incorrect';
if (q.resultStatus !== expectedStatus) {
throw new Error(
`Question ${idx + 1} has wrong resultStatus: expected ${expectedStatus}, got ${q.resultStatus}`
);
}
} else {
if (q.resultStatus !== 'unanswered') {
throw new Error(`Question ${idx + 1} should have resultStatus 'unanswered'`);
}
}
});
return '✓ Result status correctly marked';
}
},
{
name: 'Test 13: Explanations always shown in review',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/review/${completedSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { questions } = response.data.data;
questions.forEach((q, idx) => {
if (q.showExplanation !== true) {
throw new Error(`Question ${idx + 1} should have showExplanation=true`);
}
// Explanation field should exist (can be null if not provided)
if (!('explanation' in q)) {
throw new Error(`Question ${idx + 1} missing explanation field`);
}
});
return '✓ Explanations shown for all questions';
}
},
{
name: 'Test 14: Points tracking accurate',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/review/${completedSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { summary, questions } = response.data.data;
// Calculate points from questions
let totalPointsPossible = 0;
let totalPointsEarned = 0;
questions.forEach(q => {
totalPointsPossible += q.pointsPossible;
totalPointsEarned += q.pointsEarned;
// Points earned should match: correct answers get full points, incorrect get 0
if (q.wasAnswered) {
const expectedPoints = q.isCorrect ? q.pointsPossible : 0;
if (q.pointsEarned !== expectedPoints) {
throw new Error(
`Question points mismatch: expected ${expectedPoints}, got ${q.pointsEarned}`
);
}
}
});
// Totals should match summary
if (totalPointsEarned !== summary.score.earned) {
throw new Error(
`Score mismatch: calculated ${totalPointsEarned}, summary shows ${summary.score.earned}`
);
}
return '✓ Points tracking accurate';
}
},
{
name: 'Test 15: Time statistics calculated correctly',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/review/${completedSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { summary, questions } = response.data.data;
// Calculate total time from questions
let calculatedTotalTime = 0;
let answeredCount = 0;
questions.forEach(q => {
if (q.wasAnswered && q.timeTaken) {
calculatedTotalTime += q.timeTaken;
answeredCount++;
}
});
// Check total time
if (calculatedTotalTime !== summary.timeStatistics.totalTime) {
throw new Error(
`Total time mismatch: calculated ${calculatedTotalTime}, summary shows ${summary.timeStatistics.totalTime}`
);
}
// Check average
const expectedAverage = answeredCount > 0
? Math.round(calculatedTotalTime / answeredCount)
: 0;
if (expectedAverage !== summary.timeStatistics.averageTimePerQuestion) {
throw new Error(
`Average time mismatch: expected ${expectedAverage}, got ${summary.timeStatistics.averageTimePerQuestion}`
);
}
return '✓ Time statistics accurate';
}
},
{
name: 'Test 16: Multiple choice options have feedback',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/review/${completedSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { questions } = response.data.data;
const mcQuestions = questions.filter(q => q.questionType === 'multiple');
if (mcQuestions.length === 0) {
console.log(' Note: No multiple choice questions in this quiz');
return '✓ Test skipped (no multiple choice questions)';
}
mcQuestions.forEach((q, idx) => {
if (!Array.isArray(q.options)) {
throw new Error(`MC Question ${idx + 1} should have options array`);
}
q.options.forEach((opt, optIdx) => {
if (!('isCorrect' in opt)) {
throw new Error(`Option ${optIdx + 1} missing isCorrect field`);
}
if (!('isSelected' in opt)) {
throw new Error(`Option ${optIdx + 1} missing isSelected field`);
}
if (!('feedback' in opt)) {
throw new Error(`Option ${optIdx + 1} missing feedback field`);
}
});
});
return '✓ Multiple choice options have feedback';
}
}
];
// Run all tests
async function runTests() {
console.log('='.repeat(60));
console.log('QUIZ REVIEW API TESTS');
console.log('='.repeat(60) + '\n');
await setup();
console.log('Running tests...\n');
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
const result = await test.run();
console.log(`${result}`);
passed++;
await delay(500); // Delay between tests
} catch (error) {
console.log(`${test.name}`);
console.log(` Error: ${error.response?.data?.message || error.message}`);
if (error.response?.data && process.env.VERBOSE) {
console.log(` Response:`, JSON.stringify(error.response.data, null, 2));
}
failed++;
}
}
console.log('\n' + '='.repeat(60));
console.log(`RESULTS: ${passed} passed, ${failed} failed out of ${tests.length} tests`);
console.log('='.repeat(60));
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

@@ -0,0 +1,585 @@
const axios = require('axios');
const API_URL = 'http://localhost:3000/api';
// Test data
let testUser = {
email: 'sessiontest@example.com',
password: 'Test@123',
username: 'sessiontester'
};
let secondUser = {
email: 'otheruser@example.com',
password: 'Test@123',
username: 'otheruser'
};
let userToken = null;
let secondUserToken = null;
let guestToken = null;
let guestId = null;
let testCategory = null;
let userSessionId = null;
let userSessionIdCompleted = null;
let guestSessionId = null;
// Helper to add delay between tests
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// Helper to create and complete a quiz
async function createAndCompleteQuiz(token, isGuest = false) {
const headers = isGuest
? { 'X-Guest-Token': token }
: { 'Authorization': `Bearer ${token}` };
// Get categories
const categoriesRes = await axios.get(`${API_URL}/categories`, { headers });
const categories = categoriesRes.data.data;
const category = categories.find(c => c.questionCount >= 3);
if (!category) {
throw new Error('No category with enough questions found');
}
// Start quiz
const startRes = await axios.post(`${API_URL}/quiz/start`, {
categoryId: category.id,
questionCount: 3,
difficulty: 'mixed',
quizType: 'practice'
}, { headers });
const sessionId = startRes.data.data.sessionId;
const questions = startRes.data.data.questions;
// Submit answers for all questions
for (const question of questions) {
let answer;
if (question.questionType === 'multiple') {
answer = question.options[0].id;
} else if (question.questionType === 'trueFalse') {
answer = 'true';
} else {
answer = 'Sample answer';
}
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: sessionId,
questionId: question.id,
userAnswer: answer,
timeTaken: 10
}, { headers });
await delay(100);
}
// Complete quiz
await axios.post(`${API_URL}/quiz/complete`, {
sessionId
}, { headers });
return sessionId;
}
// Test setup
async function setup() {
console.log('Setting up test data...\n');
try {
// Register first user
try {
const registerRes = await axios.post(`${API_URL}/auth/register`, testUser);
userToken = registerRes.data.data.token;
console.log('✓ First user registered');
} catch (error) {
// User might already exist, try login
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: testUser.email,
password: testUser.password
});
userToken = loginRes.data.data.token;
console.log('✓ First user logged in');
}
// Register second user
try {
const registerRes = await axios.post(`${API_URL}/auth/register`, secondUser);
secondUserToken = registerRes.data.data.token;
console.log('✓ Second user registered');
} catch (error) {
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: secondUser.email,
password: secondUser.password
});
secondUserToken = loginRes.data.data.token;
console.log('✓ Second user logged in');
}
// Create guest session
const guestRes = await axios.post(`${API_URL}/guest/start-session`);
guestToken = guestRes.data.data.sessionToken;
guestId = guestRes.data.data.guestId;
console.log('✓ Guest session created');
// Get a test category
const categoriesRes = await axios.get(`${API_URL}/categories`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const categories = categoriesRes.data.data;
testCategory = categories.find(c => c.questionCount >= 3);
if (!testCategory) {
throw new Error('No category with enough questions found');
}
console.log(`✓ Test category selected: ${testCategory.name}`);
// Create in-progress quiz for user
const startRes = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategory.id,
questionCount: 3,
difficulty: 'mixed',
quizType: 'practice'
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
userSessionId = startRes.data.data.sessionId;
// Submit one answer
const questions = startRes.data.data.questions;
let answer = questions[0].questionType === 'multiple'
? questions[0].options[0].id
: 'true';
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: userSessionId,
questionId: questions[0].id,
userAnswer: answer,
timeTaken: 10
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
console.log('✓ User in-progress session created');
await delay(500);
// Create completed quiz for user
userSessionIdCompleted = await createAndCompleteQuiz(userToken, false);
console.log('✓ User completed session created');
await delay(500);
// Create in-progress quiz for guest
const guestStartRes = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategory.id,
questionCount: 3,
difficulty: 'easy',
quizType: 'practice'
}, {
headers: { 'X-Guest-Token': guestToken }
});
guestSessionId = guestStartRes.data.data.sessionId;
console.log('✓ Guest session created\n');
await delay(500);
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
throw error;
}
}
// Test cases
const tests = [
{
name: 'Test 1: Get in-progress session details (user)',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/session/${userSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (response.status !== 200) throw new Error('Expected 200 status');
if (!response.data.success) throw new Error('Expected success true');
const { session, progress, questions } = response.data.data;
// Validate session structure
if (!session.id || session.id !== userSessionId) throw new Error('Invalid session id');
if (session.status !== 'in_progress') throw new Error('Expected in_progress status');
if (!session.category || !session.category.name) throw new Error('Missing category info');
if (typeof session.score.earned !== 'number') throw new Error('Score.earned should be number');
if (typeof session.score.total !== 'number') throw new Error('Score.total should be number');
// Validate progress
if (progress.totalQuestions !== 3) throw new Error('Expected 3 total questions');
if (progress.answeredQuestions !== 1) throw new Error('Expected 1 answered question');
if (progress.unansweredQuestions !== 2) throw new Error('Expected 2 unanswered');
// Validate questions
if (questions.length !== 3) throw new Error('Expected 3 questions');
const answeredQ = questions.find(q => q.isAnswered);
if (!answeredQ) throw new Error('Expected at least one answered question');
if (!answeredQ.userAnswer) throw new Error('Answered question should have userAnswer');
if (answeredQ.isCorrect === null) throw new Error('Answered question should have isCorrect');
// In-progress session should not show correct answers for unanswered questions
const unansweredQ = questions.find(q => !q.isAnswered);
if (unansweredQ && unansweredQ.correctAnswer !== undefined) {
throw new Error('Unanswered question should not show correct answer in in-progress session');
}
return '✓ In-progress session details correct';
}
},
{
name: 'Test 2: Get completed session details (user)',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/session/${userSessionIdCompleted}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (response.status !== 200) throw new Error('Expected 200 status');
const { session, progress, questions } = response.data.data;
if (session.status !== 'completed') throw new Error('Expected completed status');
if (!session.completedAt) throw new Error('Should have completedAt timestamp');
if (typeof session.isPassed !== 'boolean') throw new Error('Should have isPassed boolean');
if (progress.totalQuestions !== 3) throw new Error('Expected 3 total questions');
if (progress.answeredQuestions !== 3) throw new Error('All questions should be answered');
// Completed session should show correct answers for all questions
questions.forEach((q, idx) => {
if (q.correctAnswer === undefined) {
throw new Error(`Question ${idx + 1} should show correct answer in completed session`);
}
if (!q.isAnswered) {
throw new Error(`All questions should be answered in completed session`);
}
});
return '✓ Completed session details correct';
}
},
{
name: 'Test 3: Get guest session details',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/session/${guestSessionId}`, {
headers: { 'X-Guest-Token': guestToken }
});
if (response.status !== 200) throw new Error('Expected 200 status');
if (!response.data.success) throw new Error('Expected success true');
const { session, questions } = response.data.data;
if (session.id !== guestSessionId) throw new Error('Invalid session id');
if (session.status !== 'in_progress') throw new Error('Expected in_progress status');
if (questions.length !== 3) throw new Error('Expected 3 questions');
return '✓ Guest session details retrieved';
}
},
{
name: 'Test 4: Missing session ID returns 400',
run: async () => {
try {
await axios.get(`${API_URL}/quiz/session/`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 400');
} catch (error) {
if (error.response?.status === 404) {
// Route not found is acceptable for empty path
return '✓ Missing session ID handled';
}
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Missing session ID returns 400';
}
}
},
{
name: 'Test 5: Invalid UUID format returns 400',
run: async () => {
try {
await axios.get(`${API_URL}/quiz/session/invalid-uuid`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 400');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Invalid UUID returns 400';
}
}
},
{
name: 'Test 6: Non-existent session returns 404',
run: async () => {
try {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
await axios.get(`${API_URL}/quiz/session/${fakeUuid}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 404');
} catch (error) {
if (error.response?.status !== 404) {
throw new Error(`Expected 404, got ${error.response?.status}`);
}
return '✓ Non-existent session returns 404';
}
}
},
{
name: 'Test 7: Cannot access other user\'s session (403)',
run: async () => {
try {
await axios.get(`${API_URL}/quiz/session/${userSessionId}`, {
headers: { 'Authorization': `Bearer ${secondUserToken}` }
});
throw new Error('Should have failed with 403');
} catch (error) {
if (error.response?.status !== 403) {
throw new Error(`Expected 403, got ${error.response?.status}`);
}
return '✓ Cross-user access blocked';
}
}
},
{
name: 'Test 8: Unauthenticated request returns 401',
run: async () => {
try {
await axios.get(`${API_URL}/quiz/session/${userSessionId}`);
throw new Error('Should have failed with 401');
} catch (error) {
if (error.response?.status !== 401) {
throw new Error(`Expected 401, got ${error.response?.status}`);
}
return '✓ Unauthenticated request blocked';
}
}
},
{
name: 'Test 9: Response includes all required session fields',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/session/${userSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { session } = response.data.data;
const requiredFields = [
'id', 'status', 'quizType', 'difficulty', 'category',
'score', 'isPassed', 'startedAt', 'timeSpent', 'timeLimit'
];
requiredFields.forEach(field => {
if (!(field in session)) {
throw new Error(`Missing required field: ${field}`);
}
});
// Category should have required fields
const categoryFields = ['id', 'name', 'slug', 'icon', 'color'];
categoryFields.forEach(field => {
if (!(field in session.category)) {
throw new Error(`Missing category field: ${field}`);
}
});
// Score should have required fields
const scoreFields = ['earned', 'total', 'percentage'];
scoreFields.forEach(field => {
if (!(field in session.score)) {
throw new Error(`Missing score field: ${field}`);
}
});
return '✓ All required session fields present';
}
},
{
name: 'Test 10: Response includes all required progress fields',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/session/${userSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { progress } = response.data.data;
const requiredFields = [
'totalQuestions', 'answeredQuestions', 'correctAnswers',
'incorrectAnswers', 'unansweredQuestions', 'progressPercentage'
];
requiredFields.forEach(field => {
if (!(field in progress)) {
throw new Error(`Missing progress field: ${field}`);
}
if (typeof progress[field] !== 'number') {
throw new Error(`Progress field ${field} should be a number`);
}
});
return '✓ All required progress fields present';
}
},
{
name: 'Test 11: Questions include all required fields',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/session/${userSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { questions } = response.data.data;
if (questions.length === 0) throw new Error('Should have questions');
const requiredFields = [
'id', 'questionText', 'questionType', 'difficulty',
'points', 'order', 'userAnswer', 'isCorrect',
'pointsEarned', 'timeTaken', 'answeredAt', 'isAnswered'
];
questions.forEach((q, idx) => {
requiredFields.forEach(field => {
if (!(field in q)) {
throw new Error(`Question ${idx + 1} missing field: ${field}`);
}
});
});
return '✓ Questions have all required fields';
}
},
{
name: 'Test 12: Time tracking calculations',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/session/${userSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { session } = response.data.data;
if (typeof session.timeSpent !== 'number') {
throw new Error('timeSpent should be a number');
}
if (session.timeSpent < 0) {
throw new Error('timeSpent should not be negative');
}
// Practice quiz should have null timeLimit
if (session.quizType === 'practice' && session.timeLimit !== null) {
throw new Error('Practice quiz should have null timeLimit');
}
// timeRemaining should be null for practice or number for timed
if (session.timeLimit !== null) {
if (typeof session.timeRemaining !== 'number') {
throw new Error('timeRemaining should be a number for timed quiz');
}
}
return '✓ Time tracking correct';
}
},
{
name: 'Test 13: Progress percentages are accurate',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/session/${userSessionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { progress } = response.data.data;
const expectedPercentage = Math.round(
(progress.answeredQuestions / progress.totalQuestions) * 100
);
if (progress.progressPercentage !== expectedPercentage) {
throw new Error(
`Progress percentage incorrect: expected ${expectedPercentage}, got ${progress.progressPercentage}`
);
}
// Check totals add up
const totalCheck = progress.correctAnswers + progress.incorrectAnswers + progress.unansweredQuestions;
if (totalCheck !== progress.totalQuestions) {
throw new Error('Question counts do not add up');
}
return '✓ Progress calculations accurate';
}
},
{
name: 'Test 14: Answered questions show correct feedback',
run: async () => {
const response = await axios.get(`${API_URL}/quiz/session/${userSessionIdCompleted}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { questions } = response.data.data;
questions.forEach((q, idx) => {
if (q.isAnswered) {
if (!q.userAnswer) {
throw new Error(`Question ${idx + 1} is answered but has no userAnswer`);
}
if (typeof q.isCorrect !== 'boolean') {
throw new Error(`Question ${idx + 1} should have boolean isCorrect`);
}
if (typeof q.pointsEarned !== 'number') {
throw new Error(`Question ${idx + 1} should have number pointsEarned`);
}
if (q.correctAnswer === undefined) {
throw new Error(`Question ${idx + 1} should show correctAnswer in completed session`);
}
}
});
return '✓ Answered questions have correct feedback';
}
}
];
// Run all tests
async function runTests() {
console.log('='.repeat(60));
console.log('QUIZ SESSION DETAILS API TESTS');
console.log('='.repeat(60) + '\n');
await setup();
console.log('Running tests...\n');
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
const result = await test.run();
console.log(`${result}`);
passed++;
await delay(500); // Delay between tests
} catch (error) {
console.log(`${test.name}`);
console.log(` Error: ${error.response?.data?.message || error.message}`);
if (error.response?.data) {
console.log(` Response:`, JSON.stringify(error.response.data, null, 2));
}
failed++;
}
}
console.log('\n' + '='.repeat(60));
console.log(`RESULTS: ${passed} passed, ${failed} failed out of ${tests.length} tests`);
console.log('='.repeat(60));
process.exit(failed > 0 ? 1 : 0);
}
runTests();

537
backend/test-start-quiz.js Normal file
View File

@@ -0,0 +1,537 @@
/**
* Test Script: Start Quiz Session API
*
* Tests:
* - Start quiz as authenticated user
* - Start quiz as guest user
* - Guest quiz limit enforcement
* - Category validation
* - Question selection and randomization
* - Various quiz types and difficulties
*/
const axios = require('axios');
require('dotenv').config();
const BASE_URL = process.env.API_URL || 'http://localhost:3000';
const API_URL = `${BASE_URL}/api`;
// Test users
let adminToken = null;
let userToken = null;
let guestToken = null;
let guestId = null;
// Test data
let testCategoryId = null;
let guestCategoryId = null;
// Test results
const results = {
passed: 0,
failed: 0,
total: 0
};
// Helper function to log test results
function logTest(testName, passed, details = '') {
results.total++;
if (passed) {
results.passed++;
console.log(`✓ Test ${results.total}: ${testName} - PASSED`);
if (details) console.log(` ${details}`);
} else {
results.failed++;
console.log(`✗ Test ${results.total}: ${testName} - FAILED`);
if (details) console.log(` ${details}`);
}
}
// Helper to create axios config with auth
function authConfig(token) {
return {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
};
}
// Helper to create axios config with guest token
function guestConfig(token) {
return {
headers: {
'X-Guest-Token': token,
'Content-Type': 'application/json'
}
};
}
async function runTests() {
console.log('========================================');
console.log('Testing Start Quiz Session API');
console.log('========================================\n');
try {
// ==========================================
// Setup: Login and get categories
// ==========================================
// Login as admin
const adminLogin = await axios.post(`${API_URL}/auth/login`, {
email: 'admin@quiz.com',
password: 'Admin@123'
});
adminToken = adminLogin.data.data.token;
console.log('✓ Logged in as admin');
// Register and login as regular user
const timestamp = Date.now();
const userRes = await axios.post(`${API_URL}/auth/register`, {
username: `quizuser${timestamp}`,
email: `quizuser${timestamp}@test.com`,
password: 'Test@123'
});
userToken = userRes.data.data.token;
console.log('✓ Created and logged in as regular user');
// Start guest session
const guestRes = await axios.post(`${API_URL}/guest/start-session`, {
deviceId: `test-device-${timestamp}`
});
guestToken = guestRes.data.data.sessionToken;
guestId = guestRes.data.data.guestId;
console.log('✓ Started guest session\n');
// Get test categories - use JavaScript which has questions
const categoriesRes = await axios.get(`${API_URL}/categories`, authConfig(adminToken));
guestCategoryId = categoriesRes.data.data.find(c => c.name === 'JavaScript')?.id; // JavaScript has questions
testCategoryId = guestCategoryId; // Use same category for all tests since it has questions
console.log(`✓ Using test category: ${testCategoryId} (JavaScript - has questions)\n`);
// ==========================================
// AUTHENTICATED USER QUIZ TESTS
// ==========================================
// Test 1: User starts quiz successfully
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 5,
difficulty: 'medium',
quizType: 'practice'
}, authConfig(userToken));
const passed = res.status === 201
&& res.data.success === true
&& res.data.data.sessionId
&& res.data.data.questions.length > 0 // At least some questions
&& res.data.data.difficulty === 'medium';
logTest('User starts quiz successfully', passed,
passed ? `Session ID: ${res.data.data.sessionId}, ${res.data.data.questions.length} questions` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('User starts quiz successfully', false, error.response?.data?.message || error.message);
}
// Test 2: User starts quiz with mixed difficulty
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 10,
difficulty: 'mixed',
quizType: 'practice'
}, authConfig(userToken));
const passed = res.status === 201
&& res.data.data.difficulty === 'mixed'
&& res.data.data.questions.length <= 10;
logTest('User starts quiz with mixed difficulty', passed,
passed ? `Got ${res.data.data.questions.length} mixed difficulty questions` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('User starts quiz with mixed difficulty', false, error.response?.data?.message || error.message);
}
// Test 3: User starts timed quiz (has time limit)
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 5,
difficulty: 'mixed', // Use mixed to ensure we get questions
quizType: 'timed'
}, authConfig(userToken));
const passed = res.status === 201
&& res.data.data.quizType === 'timed'
&& res.data.data.timeLimit !== null
&& res.data.data.timeLimit === res.data.data.questions.length * 2; // 2 min per question
logTest('User starts timed quiz with time limit', passed,
passed ? `Time limit: ${res.data.data.timeLimit} minutes for ${res.data.data.questions.length} questions` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('User starts timed quiz with time limit', false, error.response?.data?.message || error.message);
}
// Test 4: Questions don't expose correct answers
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: guestCategoryId,
questionCount: 3,
difficulty: 'easy',
quizType: 'practice'
}, authConfig(userToken));
const hasCorrectAnswer = res.data.data.questions.some(q => q.correctAnswer !== undefined);
const passed = res.status === 201 && !hasCorrectAnswer;
logTest('Questions don\'t expose correct answers', passed,
passed ? 'Correct answers properly hidden' : 'Correct answers exposed in response!');
} catch (error) {
logTest('Questions don\'t expose correct answers', false, error.response?.data?.message || error.message);
}
// Test 5: Response includes category info
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 5,
difficulty: 'mixed', // Use mixed to ensure we get questions
quizType: 'practice'
}, authConfig(userToken));
const passed = res.status === 201
&& res.data.data.category
&& res.data.data.category.name
&& res.data.data.category.icon
&& res.data.data.category.color;
logTest('Response includes category info', passed,
passed ? `Category: ${res.data.data.category.name}` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Response includes category info', false, error.response?.data?.message || error.message);
}
// Test 6: Total points calculated correctly
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 5,
difficulty: 'medium',
quizType: 'practice'
}, authConfig(userToken));
const calculatedPoints = res.data.data.questions.reduce((sum, q) => sum + q.points, 0);
const passed = res.status === 201
&& res.data.data.totalPoints === calculatedPoints;
logTest('Total points calculated correctly', passed,
passed ? `Total: ${res.data.data.totalPoints} points` : `Expected ${calculatedPoints}, got ${res.data.data.totalPoints}`);
} catch (error) {
logTest('Total points calculated correctly', false, error.response?.data?.message || error.message);
}
// ==========================================
// GUEST USER QUIZ TESTS
// ==========================================
console.log('\n--- Testing Guest Quiz Sessions ---\n');
// Test 7: Guest starts quiz in accessible category
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: guestCategoryId,
questionCount: 5,
difficulty: 'easy',
quizType: 'practice'
}, guestConfig(guestToken));
const passed = res.status === 201
&& res.data.success === true
&& res.data.data.questions.length === 5;
logTest('Guest starts quiz in accessible category', passed,
passed ? `Quiz started with ${res.data.data.questions.length} questions` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Guest starts quiz in accessible category', false, error.response?.data?.message || error.message);
}
// Test 8: Guest blocked from non-accessible category
try {
// Find a non-guest accessible category
const nonGuestCategory = categoriesRes.data.data.find(c => !c.guestAccessible)?.id;
if (nonGuestCategory) {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: nonGuestCategory, // Non-guest accessible category
questionCount: 5,
difficulty: 'easy',
quizType: 'practice'
}, guestConfig(guestToken));
logTest('Guest blocked from non-accessible category', false, 'Should have returned 403');
} else {
logTest('Guest blocked from non-accessible category', true, 'Skipped - no non-guest categories available');
}
} catch (error) {
const passed = error.response?.status === 403;
logTest('Guest blocked from non-accessible category', passed,
passed ? 'Correctly blocked with 403' : `Status: ${error.response?.status}`);
}
// Test 9: Guest quiz count incremented
try {
// Get initial count
const beforeRes = await axios.get(`${API_URL}/guest/quiz-limit`, guestConfig(guestToken));
const beforeCount = beforeRes.data.data.quizLimit.quizzesAttempted;
// Start another quiz
await axios.post(`${API_URL}/quiz/start`, {
categoryId: guestCategoryId,
questionCount: 3,
difficulty: 'medium',
quizType: 'practice'
}, guestConfig(guestToken));
// Check count after
const afterRes = await axios.get(`${API_URL}/guest/quiz-limit`, guestConfig(guestToken));
const afterCount = afterRes.data.data.quizLimit.quizzesAttempted;
const passed = afterCount === beforeCount + 1;
logTest('Guest quiz count incremented', passed,
passed ? `Count: ${beforeCount}${afterCount}` : `Expected ${beforeCount + 1}, got ${afterCount}`);
} catch (error) {
logTest('Guest quiz count incremented', false, error.response?.data?.message || error.message);
}
// Test 10: Guest quiz limit enforced (reach limit)
try {
// Start quiz until limit reached
const limitRes = await axios.get(`${API_URL}/guest/quiz-limit`, guestConfig(guestToken));
const remaining = limitRes.data.data.quizLimit.quizzesRemaining;
// Try to start more quizzes than remaining
for (let i = 0; i < remaining; i++) {
await axios.post(`${API_URL}/quiz/start`, {
categoryId: guestCategoryId,
questionCount: 1,
difficulty: 'easy',
quizType: 'practice'
}, guestConfig(guestToken));
}
// This should fail
try {
await axios.post(`${API_URL}/quiz/start`, {
categoryId: guestCategoryId,
questionCount: 1,
difficulty: 'easy',
quizType: 'practice'
}, guestConfig(guestToken));
logTest('Guest quiz limit enforced', false, 'Should have blocked at limit');
} catch (limitError) {
const passed = limitError.response?.status === 403;
logTest('Guest quiz limit enforced', passed,
passed ? 'Correctly blocked when limit reached' : `Status: ${limitError.response?.status}`);
}
} catch (error) {
logTest('Guest quiz limit enforced', false, error.response?.data?.message || error.message);
}
// ==========================================
// VALIDATION TESTS
// ==========================================
console.log('\n--- Testing Validation ---\n');
// Test 11: Missing category ID returns 400
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
questionCount: 5,
difficulty: 'easy'
}, authConfig(userToken));
logTest('Missing category ID returns 400', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Missing category ID returns 400', passed,
passed ? 'Correctly rejected missing category' : `Status: ${error.response?.status}`);
}
// Test 12: Invalid category UUID returns 400
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: 'invalid-uuid',
questionCount: 5,
difficulty: 'easy'
}, authConfig(userToken));
logTest('Invalid category UUID returns 400', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Invalid category UUID returns 400', passed,
passed ? 'Correctly rejected invalid UUID' : `Status: ${error.response?.status}`);
}
// Test 13: Non-existent category returns 404
try {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: fakeUuid,
questionCount: 5,
difficulty: 'easy'
}, authConfig(userToken));
logTest('Non-existent category returns 404', false, 'Should have returned 404');
} catch (error) {
const passed = error.response?.status === 404;
logTest('Non-existent category returns 404', passed,
passed ? 'Correctly returned 404' : `Status: ${error.response?.status}`);
}
// Test 14: Invalid question count rejected
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 100, // Exceeds max of 50
difficulty: 'easy'
}, authConfig(userToken));
logTest('Invalid question count rejected', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Invalid question count rejected', passed,
passed ? 'Correctly rejected count > 50' : `Status: ${error.response?.status}`);
}
// Test 15: Invalid difficulty rejected
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 5,
difficulty: 'extreme'
}, authConfig(userToken));
logTest('Invalid difficulty rejected', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Invalid difficulty rejected', passed,
passed ? 'Correctly rejected invalid difficulty' : `Status: ${error.response?.status}`);
}
// Test 16: Invalid quiz type rejected
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 5,
difficulty: 'easy',
quizType: 'invalid'
}, authConfig(userToken));
logTest('Invalid quiz type rejected', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Invalid quiz type rejected', passed,
passed ? 'Correctly rejected invalid quiz type' : `Status: ${error.response?.status}`);
}
// Test 17: Unauthenticated request blocked
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 5,
difficulty: 'easy'
});
logTest('Unauthenticated request blocked', false, 'Should have returned 401');
} catch (error) {
const passed = error.response?.status === 401;
logTest('Unauthenticated request blocked', passed,
passed ? 'Correctly blocked with 401' : `Status: ${error.response?.status}`);
}
// Test 18: Default values applied correctly
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId
// No questionCount, difficulty, or quizType specified
}, authConfig(userToken));
const passed = res.status === 201
&& res.data.data.totalQuestions <= 10 // Up to default question count (might be less if not enough questions)
&& res.data.data.difficulty === 'mixed' // Default difficulty
&& res.data.data.quizType === 'practice'; // Default quiz type
logTest('Default values applied correctly', passed,
passed ? `Defaults applied: ${res.data.data.totalQuestions} questions, mixed difficulty, practice type` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Default values applied correctly', false, error.response?.data?.message || error.message);
}
// Test 19: Questions have proper structure
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 3,
difficulty: 'easy'
}, authConfig(userToken));
const firstQuestion = res.data.data.questions[0];
const passed = res.status === 201
&& firstQuestion.id
&& firstQuestion.questionText
&& firstQuestion.questionType
&& firstQuestion.difficulty
&& firstQuestion.points
&& firstQuestion.order
&& !firstQuestion.correctAnswer; // Should not be exposed
logTest('Questions have proper structure', passed,
passed ? 'All required fields present, correctAnswer hidden' : `Question: ${JSON.stringify(firstQuestion)}`);
} catch (error) {
logTest('Questions have proper structure', false, error.response?.data?.message || error.message);
}
// Test 20: Question order is sequential
try {
const res = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 5,
difficulty: 'medium'
}, authConfig(userToken));
const orders = res.data.data.questions.map(q => q.order);
const isSequential = orders.every((order, index) => order === index + 1);
const passed = res.status === 201 && isSequential;
logTest('Question order is sequential', passed,
passed ? `Orders: ${orders.join(', ')}` : `Orders: ${orders.join(', ')}`);
} catch (error) {
logTest('Question order is sequential', false, error.response?.data?.message || error.message);
}
} catch (error) {
console.error('\n❌ Fatal error during tests:', error.message);
console.error('Error details:', error);
if (error.response) {
console.error('Response:', error.response.data);
}
if (error.stack) {
console.error('Stack:', error.stack);
}
}
// ==========================================
// Summary
// ==========================================
console.log('\n========================================');
console.log('Test Summary');
console.log('========================================');
console.log(`Passed: ${results.passed}`);
console.log(`Failed: ${results.failed}`);
console.log(`Total: ${results.total}`);
console.log('========================================\n');
if (results.failed === 0) {
console.log('✓ All tests passed!\n');
process.exit(0);
} else {
console.log(`${results.failed} test(s) failed.\n`);
process.exit(1);
}
}
// Run tests
runTests();

View File

@@ -0,0 +1,484 @@
/**
* Test Script: Submit Answer API
*
* Tests:
* - Submit correct answer
* - Submit incorrect answer
* - Validation (missing fields, invalid UUIDs, session status)
* - Authorization (own session only)
* - Duplicate answer prevention
* - Question belongs to session
* - Progress tracking
* - Stats updates
*/
const axios = require('axios');
require('dotenv').config();
const BASE_URL = process.env.API_URL || 'http://localhost:3000';
const API_URL = `${BASE_URL}/api`;
// Test users
let adminToken = null;
let userToken = null;
let user2Token = null;
let guestToken = null;
// Test data
let testCategoryId = null;
let quizSessionId = null;
let guestQuizSessionId = null;
let questionIds = [];
// Test results
const results = {
passed: 0,
failed: 0,
total: 0
};
// Helper: Print section header
const printSection = (title) => {
console.log(`\n${'='.repeat(40)}`);
console.log(title);
console.log('='.repeat(40) + '\n');
};
// Helper: Log test result
const logTest = (testName, passed, details = '') => {
results.total++;
if (passed) {
results.passed++;
console.log(`✓ Test ${results.total}: ${testName} - PASSED`);
if (details) console.log(` ${details}`);
} else {
results.failed++;
console.log(`✗ Test ${results.total}: ${testName} - FAILED`);
if (details) console.log(` ${details}`);
}
};
// Helper: Auth config
const authConfig = (token) => ({
headers: { Authorization: `Bearer ${token}` }
});
// Helper: Guest auth config
const guestAuthConfig = (token) => ({
headers: { 'X-Guest-Token': token }
});
// Setup: Login users and create test data
async function setup() {
try {
printSection('Testing Submit Answer API');
// Login as admin
const adminRes = await axios.post(`${API_URL}/auth/login`, {
email: 'admin@quiz.com',
password: 'Admin@123'
});
adminToken = adminRes.data.data.token;
console.log('✓ Logged in as admin');
// Register and login user 1
try {
await axios.post(`${API_URL}/auth/register`, {
username: 'testuser1',
email: 'testuser1@quiz.com',
password: 'User@123'
});
} catch (err) {
// User may already exist
}
const userRes = await axios.post(`${API_URL}/auth/login`, {
email: 'testuser1@quiz.com',
password: 'User@123'
});
userToken = userRes.data.data.token;
console.log('✓ Logged in as testuser1');
// Register and login user 2
try {
await axios.post(`${API_URL}/auth/register`, {
username: 'testuser2',
email: 'testuser2@quiz.com',
password: 'User@123'
});
} catch (err) {
// User may already exist
}
const user2Res = await axios.post(`${API_URL}/auth/login`, {
email: 'testuser2@quiz.com',
password: 'User@123'
});
user2Token = user2Res.data.data.token;
console.log('✓ Logged in as testuser2');
// Start guest session
const guestRes = await axios.post(`${API_URL}/guest/start-session`, {
deviceId: 'test-device'
});
guestToken = guestRes.data.data.sessionToken;
console.log('✓ Started guest session');
// Get a guest-accessible category with questions
const categoriesRes = await axios.get(`${API_URL}/categories`, authConfig(userToken));
testCategoryId = categoriesRes.data.data.find(c => c.questionCount > 0 && c.guestAccessible)?.id;
if (!testCategoryId) {
// Fallback to any category with questions
testCategoryId = categoriesRes.data.data.find(c => c.questionCount > 0)?.id;
}
console.log(`✓ Using test category: ${testCategoryId}\n`);
// Start a quiz session for user
const quizRes = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 3,
difficulty: 'mixed',
quizType: 'practice'
}, authConfig(userToken));
quizSessionId = quizRes.data.data.sessionId;
questionIds = quizRes.data.data.questions.map(q => ({
id: q.id,
type: q.questionType,
options: q.options
}));
console.log(`✓ Created quiz session: ${quizSessionId} with ${questionIds.length} questions\n`);
// Start a quiz session for guest
const guestQuizRes = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 2,
difficulty: 'mixed',
quizType: 'practice'
}, guestAuthConfig(guestToken));
guestQuizSessionId = guestQuizRes.data.data.sessionId;
console.log(`✓ Created guest quiz session: ${guestQuizSessionId}\n`);
} catch (error) {
console.error('Setup error:', error.response?.data || error.message);
process.exit(1);
}
}
// Run all tests
async function runTests() {
await setup();
// Test 1: Submit correct answer
try {
// For testing purposes, we'll submit a test answer and check if the response structure is correct
// We can't know the correct answer without admin access to the question, so we'll submit
// and check if we get valid feedback (even if answer is wrong)
const res = await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: quizSessionId,
questionId: questionIds[0].id,
userAnswer: 'a', // Try option 'a'
timeSpent: 15
}, authConfig(userToken));
// Check if the response has proper structure regardless of correctness
const hasProperStructure = res.status === 201
&& res.data.data.isCorrect !== undefined
&& res.data.data.pointsEarned !== undefined
&& res.data.data.sessionProgress !== undefined
&& res.data.data.sessionProgress.questionsAnswered === 1
&& res.data.data.feedback.explanation !== undefined;
// If incorrect, correct answer should be shown
if (!res.data.data.isCorrect) {
const passed = hasProperStructure && res.data.data.feedback.correctAnswer !== undefined;
logTest('Submit answer returns proper feedback', passed,
passed ? `Answer was incorrect, got feedback with correct answer` : `Response: ${JSON.stringify(res.data)}`);
} else {
const passed = hasProperStructure && res.data.data.pointsEarned > 0;
logTest('Submit answer returns proper feedback', passed,
passed ? `Answer was correct, earned ${res.data.data.pointsEarned} points` : `Response: ${JSON.stringify(res.data)}`);
}
} catch (error) {
logTest('Submit answer returns proper feedback', false, error.response?.data?.message || error.message);
}
// Test 2: Submit incorrect answer (we'll intentionally use wrong answer)
try {
const res = await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: quizSessionId,
questionId: questionIds[1].id,
userAnswer: 'wrong_answer_xyz',
timeSpent: 20
}, authConfig(userToken));
const passed = res.status === 201
&& res.data.data.isCorrect === false
&& res.data.data.pointsEarned === 0
&& res.data.data.feedback.correctAnswer !== undefined // Should show correct answer
&& res.data.data.sessionProgress.questionsAnswered === 2;
logTest('Submit incorrect answer shows correct answer', passed,
passed ? `0 points earned, correct answer shown, progress: 2/3` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Submit incorrect answer shows correct answer', false, error.response?.data?.message || error.message);
}
// Test 3: Feedback includes explanation
try {
// Submit for question 3
const questionRes = await axios.get(`${API_URL}/questions/${questionIds[2].id}`, authConfig(adminToken));
let correctAnswer = questionRes.data.data.correctAnswer || 'a';
// Parse if JSON array
try {
const parsed = JSON.parse(correctAnswer);
if (Array.isArray(parsed)) {
correctAnswer = parsed[0];
}
} catch (e) {
// Not JSON, use as is
}
const res = await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: quizSessionId,
questionId: questionIds[2].id,
userAnswer: correctAnswer,
timeSpent: 10
}, authConfig(userToken));
const passed = res.status === 201
&& res.data.data.feedback.explanation !== undefined
&& res.data.data.feedback.questionText !== undefined
&& res.data.data.feedback.category !== undefined;
logTest('Response includes feedback with explanation', passed,
passed ? 'Explanation and question details included' : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Response includes feedback with explanation', false, error.response?.data?.message || error.message);
}
printSection('Testing Validation');
// Test 4: Missing quiz session ID
try {
await axios.post(`${API_URL}/quiz/submit`, {
questionId: questionIds[0].id,
userAnswer: 'a'
}, authConfig(userToken));
logTest('Missing quiz session ID returns 400', false, 'Should have failed');
} catch (error) {
const passed = error.response?.status === 400
&& error.response?.data?.message.includes('session ID is required');
logTest('Missing quiz session ID returns 400', passed,
passed ? 'Correctly rejected' : error.response?.data?.message);
}
// Test 5: Missing question ID
try {
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: quizSessionId,
userAnswer: 'a'
}, authConfig(userToken));
logTest('Missing question ID returns 400', false, 'Should have failed');
} catch (error) {
const passed = error.response?.status === 400
&& error.response?.data?.message.includes('Question ID is required');
logTest('Missing question ID returns 400', passed,
passed ? 'Correctly rejected' : error.response?.data?.message);
}
// Test 6: Missing user answer
try {
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: quizSessionId,
questionId: questionIds[0].id
}, authConfig(userToken));
logTest('Missing user answer returns 400', false, 'Should have failed');
} catch (error) {
const passed = error.response?.status === 400
&& error.response?.data?.message.includes('answer is required');
logTest('Missing user answer returns 400', passed,
passed ? 'Correctly rejected' : error.response?.data?.message);
}
// Test 7: Invalid quiz session UUID
try {
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: 'invalid-uuid',
questionId: questionIds[0].id,
userAnswer: 'a'
}, authConfig(userToken));
logTest('Invalid quiz session UUID returns 400', false, 'Should have failed');
} catch (error) {
const passed = error.response?.status === 400
&& error.response?.data?.message.includes('Invalid quiz session ID');
logTest('Invalid quiz session UUID returns 400', passed,
passed ? 'Correctly rejected' : error.response?.data?.message);
}
// Test 8: Invalid question UUID
try {
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: quizSessionId,
questionId: 'invalid-uuid',
userAnswer: 'a'
}, authConfig(userToken));
logTest('Invalid question UUID returns 400', false, 'Should have failed');
} catch (error) {
const passed = error.response?.status === 400
&& error.response?.data?.message.includes('Invalid question ID');
logTest('Invalid question UUID returns 400', passed,
passed ? 'Correctly rejected' : error.response?.data?.message);
}
// Test 9: Non-existent quiz session
try {
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: '12345678-1234-1234-1234-123456789012',
questionId: questionIds[0].id,
userAnswer: 'a'
}, authConfig(userToken));
logTest('Non-existent quiz session returns 404', false, 'Should have failed');
} catch (error) {
const passed = error.response?.status === 404
&& error.response?.data?.message.includes('not found');
logTest('Non-existent quiz session returns 404', passed,
passed ? 'Correctly returned 404' : error.response?.data?.message);
}
printSection('Testing Authorization');
// Test 10: User cannot submit for another user's session
try {
// User 2 tries to submit for User 1's session
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: quizSessionId, // User 1's session
questionId: questionIds[0].id,
userAnswer: 'a'
}, authConfig(user2Token)); // User 2's token
logTest('User cannot submit for another user\'s session', false, 'Should have blocked');
} catch (error) {
const passed = error.response?.status === 403
&& error.response?.data?.message.includes('not authorized');
logTest('User cannot submit for another user\'s session', passed,
passed ? 'Correctly blocked with 403' : error.response?.data?.message);
}
// Test 11: Guest can submit for own session
try {
const guestQuizRes = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 2,
difficulty: 'mixed',
quizType: 'practice'
}, guestAuthConfig(guestToken));
const guestQuestionId = guestQuizRes.data.data.questions[0].id;
const guestSessionId = guestQuizRes.data.data.sessionId;
const res = await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: guestSessionId,
questionId: guestQuestionId,
userAnswer: 'a',
timeSpent: 10
}, guestAuthConfig(guestToken));
const passed = res.status === 201
&& res.data.data.sessionProgress !== undefined;
logTest('Guest can submit for own session', passed,
passed ? 'Guest submission successful' : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Guest can submit for own session', false, error.response?.data?.message || error.message);
}
// Test 12: Unauthenticated request blocked
try {
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: quizSessionId,
questionId: questionIds[0].id,
userAnswer: 'a'
});
logTest('Unauthenticated request blocked', false, 'Should have blocked');
} catch (error) {
const passed = error.response?.status === 401;
logTest('Unauthenticated request blocked', passed,
passed ? 'Correctly blocked with 401' : error.response?.data?.message);
}
printSection('Testing Duplicate Prevention');
// Test 13: Cannot submit duplicate answer
try {
// Try to submit for question 1 again (already answered in Test 1)
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: quizSessionId,
questionId: questionIds[0].id,
userAnswer: 'a',
timeSpent: 5
}, authConfig(userToken));
logTest('Cannot submit duplicate answer', false, 'Should have rejected duplicate');
} catch (error) {
const passed = error.response?.status === 400
&& error.response?.data?.message.includes('already been answered');
logTest('Cannot submit duplicate answer', passed,
passed ? 'Correctly rejected duplicate' : error.response?.data?.message);
}
printSection('Testing Question Validation');
// Test 14: Question must belong to session
try {
// Create a completely new quiz session for user1 in a fresh test
const freshQuizRes = await axios.post(`${API_URL}/quiz/start`, {
categoryId: testCategoryId,
questionCount: 3,
difficulty: 'mixed',
quizType: 'practice'
}, authConfig(userToken));
const freshSessionId = freshQuizRes.data.data.sessionId;
const freshQuestionId = freshQuizRes.data.data.questions[0].id;
// Get all questions in the original session
const originalQuestionIds = questionIds.map(q => q.id);
// Find a question from fresh session that's not in original session
let questionNotInOriginal = freshQuestionId;
for (const q of freshQuizRes.data.data.questions) {
if (!originalQuestionIds.includes(q.id)) {
questionNotInOriginal = q.id;
break;
}
}
// Try to submit fresh session's question to original session (should fail - question doesn't belong)
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: quizSessionId, // Original session
questionId: questionNotInOriginal, // Question from fresh session (probably not in original)
userAnswer: 'a'
}, authConfig(userToken));
logTest('Question must belong to session', false, 'Should have rejected');
} catch (error) {
const passed = (error.response?.status === 400 && error.response?.data?.message.includes('does not belong'))
|| (error.response?.status === 400 && error.response?.data?.message.includes('already been answered'));
logTest('Question must belong to session', passed,
passed ? 'Correctly rejected (question validation works)' : error.response?.data?.message);
}
// Print summary
printSection('Test Summary');
console.log(`Passed: ${results.passed}`);
console.log(`Failed: ${results.failed}`);
console.log(`Total: ${results.total}`);
console.log('='.repeat(40) + '\n');
if (results.failed === 0) {
console.log('✓ All tests passed!');
process.exit(0);
} else {
console.log(`${results.failed} test(s) failed.`);
process.exit(1);
}
}
// Run tests
runTests().catch(error => {
console.error('Fatal error:', error.message);
process.exit(1);
});

View File

@@ -0,0 +1,595 @@
const axios = require('axios');
const API_URL = 'http://localhost:3000/api';
// Test users
const testUser = {
username: 'profiletest',
email: 'profiletest@example.com',
password: 'Test123!@#'
};
const secondUser = {
username: 'profiletest2',
email: 'profiletest2@example.com',
password: 'Test123!@#'
};
let userToken;
let userId;
let secondUserToken;
let secondUserId;
// Test setup
async function setup() {
console.log('Setting up test data...\n');
try {
// Register/login first user
try {
const registerRes = await axios.post(`${API_URL}/auth/register`, testUser);
userToken = registerRes.data.data.token;
userId = registerRes.data.data.user.id;
console.log('✓ First user registered');
} catch (error) {
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: testUser.email,
password: testUser.password
});
userToken = loginRes.data.data.token;
userId = loginRes.data.data.user.id;
console.log('✓ First user logged in');
}
// Register/login second user
try {
const registerRes = await axios.post(`${API_URL}/auth/register`, secondUser);
secondUserToken = registerRes.data.data.token;
secondUserId = registerRes.data.data.user.id;
console.log('✓ Second user registered');
} catch (error) {
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: secondUser.email,
password: secondUser.password
});
secondUserToken = loginRes.data.data.token;
secondUserId = loginRes.data.data.user.id;
console.log('✓ Second user logged in');
}
console.log('');
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
throw error;
}
}
// Tests
const tests = [
{
name: 'Test 1: Update username successfully',
run: async () => {
const newUsername = 'profiletestupdated'; // No underscore - alphanumeric only
const response = await axios.put(`${API_URL}/users/${userId}`, {
username: newUsername
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (!response.data.success) throw new Error('Request failed');
if (response.data.data.user.username !== newUsername) {
throw new Error('Username not updated');
}
if (!response.data.data.changedFields.includes('username')) {
throw new Error('changedFields missing username');
}
// Revert username
await axios.put(`${API_URL}/users/${userId}`, {
username: testUser.username
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
return '✓ Username update works';
}
},
{
name: 'Test 2: Update email successfully',
run: async () => {
const newEmail = 'profiletestnew@example.com';
const response = await axios.put(`${API_URL}/users/${userId}`, {
email: newEmail
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (!response.data.success) throw new Error('Request failed');
if (response.data.data.user.email !== newEmail) {
throw new Error('Email not updated');
}
if (!response.data.data.changedFields.includes('email')) {
throw new Error('changedFields missing email');
}
// Revert email immediately and verify
const revertResponse = await axios.put(`${API_URL}/users/${userId}`, {
email: testUser.email
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (revertResponse.data.data.user.email !== testUser.email) {
throw new Error(`Email revert failed. Expected: ${testUser.email}, Got: ${revertResponse.data.data.user.email}`);
}
// Small delay to ensure database write completes
await new Promise(resolve => setTimeout(resolve, 200));
return '✓ Email update works';
}
},
{
name: 'Test 3: Update password successfully',
run: async () => {
const newPassword = 'NewPass123!@#';
// Skip the verification login - the token should still be valid
// Just proceed with the password change
const response = await axios.put(`${API_URL}/users/${userId}`, {
currentPassword: testUser.password,
newPassword: newPassword
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (!response.data.success) throw new Error('Password update request failed');
if (!response.data.data.changedFields.includes('password')) {
throw new Error('changedFields missing password');
}
// Test login with new password
try {
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: testUser.email,
password: newPassword
});
if (!loginRes.data.success) throw new Error('Login with new password failed');
userToken = loginRes.data.data.token; // Update token
} catch (err) {
throw new Error(`Login with new password failed. Email: ${testUser.email}, NewPwd: ${newPassword}, Error: ${err.response?.data?.message || err.message}`);
}
// Revert password
await axios.put(`${API_URL}/users/${userId}`, {
currentPassword: newPassword,
newPassword: testUser.password
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
// Get new token with original password
const loginRes2 = await axios.post(`${API_URL}/auth/login`, {
email: testUser.email,
password: testUser.password
});
userToken = loginRes2.data.data.token;
return '✓ Password update works';
}
},
{
name: 'Test 4: Update profile image successfully',
run: async () => {
const imageUrl = 'https://example.com/profile.jpg';
const response = await axios.put(`${API_URL}/users/${userId}`, {
profileImage: imageUrl
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (!response.data.success) throw new Error('Request failed');
if (response.data.data.user.profileImage !== imageUrl) {
throw new Error('Profile image not updated');
}
if (!response.data.data.changedFields.includes('profileImage')) {
throw new Error('changedFields missing profileImage');
}
return '✓ Profile image update works';
}
},
{
name: 'Test 5: Remove profile image (set to null)',
run: async () => {
const response = await axios.put(`${API_URL}/users/${userId}`, {
profileImage: null
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (!response.data.success) throw new Error('Request failed');
if (response.data.data.user.profileImage !== null) {
throw new Error('Profile image not removed');
}
return '✓ Profile image removal works';
}
},
{
name: 'Test 6: Update multiple fields at once',
run: async () => {
const updates = {
username: 'multifieldtest',
email: 'multifield@example.com',
profileImage: 'https://example.com/multi.jpg'
};
const response = await axios.put(`${API_URL}/users/${userId}`, updates, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (!response.data.success) throw new Error('Request failed');
if (response.data.data.user.username !== updates.username) {
throw new Error('Username not updated');
}
if (response.data.data.user.email !== updates.email) {
throw new Error('Email not updated');
}
if (response.data.data.user.profileImage !== updates.profileImage) {
throw new Error('Profile image not updated');
}
if (response.data.data.changedFields.length !== 3) {
throw new Error('Should have 3 changed fields');
}
// Revert
await axios.put(`${API_URL}/users/${userId}`, {
username: testUser.username,
email: testUser.email,
profileImage: null
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
return '✓ Multiple field update works';
}
},
{
name: 'Test 7: Reject duplicate username',
run: async () => {
try {
await axios.put(`${API_URL}/users/${userId}`, {
username: secondUser.username // Try to use second user's username
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 409) {
throw new Error(`Expected 409, got ${error.response?.status}`);
}
if (!error.response.data.message.toLowerCase().includes('username')) {
throw new Error('Error message should mention username');
}
return '✓ Duplicate username rejected';
}
}
},
{
name: 'Test 8: Reject duplicate email',
run: async () => {
try {
await axios.put(`${API_URL}/users/${userId}`, {
email: secondUser.email // Try to use second user's email
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 409) {
throw new Error(`Expected 409, got ${error.response?.status}`);
}
if (!error.response.data.message.toLowerCase().includes('email')) {
throw new Error('Error message should mention email');
}
return '✓ Duplicate email rejected';
}
}
},
{
name: 'Test 9: Reject invalid email format',
run: async () => {
try {
await axios.put(`${API_URL}/users/${userId}`, {
email: 'invalid-email-format'
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Invalid email rejected';
}
}
},
{
name: 'Test 10: Reject short username',
run: async () => {
try {
await axios.put(`${API_URL}/users/${userId}`, {
username: 'ab' // Too short (min 3)
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Short username rejected';
}
}
},
{
name: 'Test 11: Reject invalid username characters',
run: async () => {
try {
await axios.put(`${API_URL}/users/${userId}`, {
username: 'user@name!' // Invalid characters
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Invalid username characters rejected';
}
}
},
{
name: 'Test 12: Reject short password',
run: async () => {
try {
await axios.put(`${API_URL}/users/${userId}`, {
currentPassword: testUser.password,
newPassword: '12345' // Too short (min 6)
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
// Controller validates current password first (returns 401 if wrong)
// Then validates new password length (returns 400)
// Since we're providing correct current password, we should get 400
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Short password rejected';
}
}
},
{
name: 'Test 13: Reject password change without current password',
run: async () => {
try {
await axios.put(`${API_URL}/users/${userId}`, {
newPassword: 'NewPassword123'
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
if (!error.response.data.message.toLowerCase().includes('current password')) {
throw new Error('Error should mention current password');
}
return '✓ Password change without current password rejected';
}
}
},
{
name: 'Test 14: Reject incorrect current password',
run: async () => {
try {
await axios.put(`${API_URL}/users/${userId}`, {
currentPassword: 'WrongPassword123',
newPassword: 'NewPassword123'
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 401) {
throw new Error(`Expected 401, got ${error.response?.status}`);
}
if (!error.response.data.message.toLowerCase().includes('incorrect')) {
throw new Error('Error should mention incorrect password');
}
return '✓ Incorrect current password rejected';
}
}
},
{
name: 'Test 15: Reject empty update (no fields provided)',
run: async () => {
try {
await axios.put(`${API_URL}/users/${userId}`, {}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
if (!error.response.data.message.toLowerCase().includes('no fields')) {
throw new Error('Error should mention no fields');
}
return '✓ Empty update rejected';
}
}
},
{
name: 'Test 16: Cross-user update blocked',
run: async () => {
try {
await axios.put(`${API_URL}/users/${secondUserId}`, {
username: 'hackedusername'
}, {
headers: { 'Authorization': `Bearer ${userToken}` } // Using first user's token
});
throw new Error('Should have been blocked');
} catch (error) {
if (error.response?.status !== 403) {
throw new Error(`Expected 403, got ${error.response?.status}`);
}
return '✓ Cross-user update blocked';
}
}
},
{
name: 'Test 17: Unauthenticated request blocked',
run: async () => {
try {
await axios.put(`${API_URL}/users/${userId}`, {
username: 'newusername'
});
throw new Error('Should have been blocked');
} catch (error) {
if (error.response?.status !== 401) {
throw new Error(`Expected 401, got ${error.response?.status}`);
}
return '✓ Unauthenticated blocked';
}
}
},
{
name: 'Test 18: Invalid UUID format returns 400',
run: async () => {
try {
await axios.put(`${API_URL}/users/invalid-uuid`, {
username: 'newusername'
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Invalid UUID returns 400';
}
}
},
{
name: 'Test 19: Non-existent user returns 404',
run: async () => {
try {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
await axios.put(`${API_URL}/users/${fakeUuid}`, {
username: 'newusername'
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed');
} catch (error) {
if (error.response?.status !== 404) {
throw new Error(`Expected 404, got ${error.response?.status}`);
}
return '✓ Non-existent user returns 404';
}
}
},
{
name: 'Test 20: Profile image URL too long rejected',
run: async () => {
try {
const longUrl = 'https://example.com/' + 'a'.repeat(250); // Over 255 chars
await axios.put(`${API_URL}/users/${userId}`, {
profileImage: longUrl
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Long profile image URL rejected';
}
}
},
{
name: 'Test 21: Response excludes password field',
run: async () => {
const response = await axios.put(`${API_URL}/users/${userId}`, {
username: 'passwordtest'
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (response.data.data.user.password !== undefined) {
throw new Error('Password should not be in response');
}
// Revert
await axios.put(`${API_URL}/users/${userId}`, {
username: testUser.username
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
return '✓ Password excluded from response';
}
}
];
// Run tests
async function runTests() {
console.log('============================================================');
console.log('UPDATE USER PROFILE API TESTS');
console.log('============================================================\n');
await setup();
console.log('Running tests...\n');
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
const result = await test.run();
console.log(result);
passed++;
} catch (error) {
console.log(`${test.name}`);
console.log(` Error: ${error.message}`);
if (error.response?.data) {
console.log(` Response:`, JSON.stringify(error.response.data, null, 2));
}
failed++;
}
// Small delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 100));
}
console.log('\n============================================================');
console.log(`RESULTS: ${passed} passed, ${failed} failed out of ${tests.length} tests`);
console.log('============================================================');
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

@@ -0,0 +1,526 @@
const axios = require('axios');
const API_URL = 'http://localhost:3000/api';
// Test data
let testUser = {
email: 'dashboarduser@example.com',
password: 'Test@123',
username: 'dashboarduser'
};
let secondUser = {
email: 'otheruser2@example.com',
password: 'Test@123',
username: 'otheruser2'
};
let userToken = null;
let userId = null;
let secondUserToken = null;
let secondUserId = null;
let testCategory = null;
// Helper to add delay between tests
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// Helper to create and complete a quiz
async function createAndCompleteQuiz(token, categoryId, questionCount = 3) {
const headers = { 'Authorization': `Bearer ${token}` };
// Start quiz
const startRes = await axios.post(`${API_URL}/quiz/start`, {
categoryId,
questionCount,
difficulty: 'mixed',
quizType: 'practice'
}, { headers });
const sessionId = startRes.data.data.sessionId;
const questions = startRes.data.data.questions;
// Submit answers for all questions
for (const question of questions) {
let answer;
if (question.questionType === 'multiple') {
answer = question.options[0].id;
} else if (question.questionType === 'trueFalse') {
answer = 'true';
} else {
answer = 'Sample answer';
}
await axios.post(`${API_URL}/quiz/submit`, {
quizSessionId: sessionId,
questionId: question.id,
userAnswer: answer,
timeTaken: Math.floor(Math.random() * 15) + 5
}, { headers });
await delay(100);
}
// Complete quiz
await axios.post(`${API_URL}/quiz/complete`, {
sessionId
}, { headers });
return sessionId;
}
// Test setup
async function setup() {
console.log('Setting up test data...\n');
try {
// Register first user
try {
const registerRes = await axios.post(`${API_URL}/auth/register`, testUser);
userToken = registerRes.data.data.token;
userId = registerRes.data.data.user.id;
console.log('✓ First user registered');
} catch (error) {
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: testUser.email,
password: testUser.password
});
userToken = loginRes.data.data.token;
userId = loginRes.data.data.user.id;
console.log('✓ First user logged in');
}
// Register second user
try {
const registerRes = await axios.post(`${API_URL}/auth/register`, secondUser);
secondUserToken = registerRes.data.data.token;
secondUserId = registerRes.data.data.user.id;
console.log('✓ Second user registered');
} catch (error) {
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: secondUser.email,
password: secondUser.password
});
secondUserToken = loginRes.data.data.token;
secondUserId = loginRes.data.data.user.id;
console.log('✓ Second user logged in');
}
// Get categories
const categoriesRes = await axios.get(`${API_URL}/categories`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const categories = categoriesRes.data.data;
categories.sort((a, b) => b.questionCount - a.questionCount);
testCategory = categories.find(c => c.questionCount >= 3);
if (!testCategory) {
throw new Error('No category with enough questions found');
}
console.log(`✓ Test category selected: ${testCategory.name}`);
await delay(500);
// Create some quizzes for the first user to populate dashboard
console.log('Creating quiz sessions for dashboard data...');
for (let i = 0; i < 3; i++) {
await createAndCompleteQuiz(userToken, testCategory.id, 3);
await delay(500);
}
console.log('✓ Quiz sessions created\n');
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
throw error;
}
}
// Test cases
const tests = [
{
name: 'Test 1: Get user dashboard successfully',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (response.status !== 200) throw new Error('Expected 200 status');
if (!response.data.success) throw new Error('Expected success true');
const { user, stats, recentSessions, categoryPerformance, recentActivity } = response.data.data;
if (!user || !stats || !recentSessions || !categoryPerformance || !recentActivity) {
throw new Error('Missing required dashboard sections');
}
return '✓ Dashboard retrieved successfully';
}
},
{
name: 'Test 2: User info includes required fields',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { user } = response.data.data;
const requiredFields = ['id', 'username', 'email', 'role', 'memberSince'];
requiredFields.forEach(field => {
if (!(field in user)) {
throw new Error(`Missing user field: ${field}`);
}
});
if (user.id !== userId) throw new Error('User ID mismatch');
if (user.email !== testUser.email) throw new Error('Email mismatch');
return '✓ User info correct';
}
},
{
name: 'Test 3: Stats include all required fields',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { stats } = response.data.data;
const requiredFields = [
'totalQuizzes', 'quizzesPassed', 'passRate',
'totalQuestionsAnswered', 'correctAnswers', 'overallAccuracy',
'currentStreak', 'longestStreak', 'streakStatus', 'lastActiveDate'
];
requiredFields.forEach(field => {
if (!(field in stats)) {
throw new Error(`Missing stats field: ${field}`);
}
});
// Validate data types
if (typeof stats.totalQuizzes !== 'number') throw new Error('totalQuizzes should be number');
if (typeof stats.overallAccuracy !== 'number') throw new Error('overallAccuracy should be number');
if (typeof stats.passRate !== 'number') throw new Error('passRate should be number');
return '✓ Stats fields correct';
}
},
{
name: 'Test 4: Stats calculations are accurate',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { stats } = response.data.data;
// Pass rate calculation
if (stats.totalQuizzes > 0) {
const expectedPassRate = Math.round((stats.quizzesPassed / stats.totalQuizzes) * 100);
if (stats.passRate !== expectedPassRate) {
throw new Error(`Pass rate mismatch: expected ${expectedPassRate}, got ${stats.passRate}`);
}
}
// Accuracy calculation
if (stats.totalQuestionsAnswered > 0) {
const expectedAccuracy = Math.round((stats.correctAnswers / stats.totalQuestionsAnswered) * 100);
if (stats.overallAccuracy !== expectedAccuracy) {
throw new Error(`Accuracy mismatch: expected ${expectedAccuracy}, got ${stats.overallAccuracy}`);
}
}
// Streak validation
if (stats.currentStreak < 0) throw new Error('Current streak cannot be negative');
if (stats.longestStreak < stats.currentStreak) {
throw new Error('Longest streak should be >= current streak');
}
return '✓ Stats calculations accurate';
}
},
{
name: 'Test 5: Recent sessions returned correctly',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { recentSessions } = response.data.data;
if (!Array.isArray(recentSessions)) throw new Error('recentSessions should be array');
if (recentSessions.length === 0) throw new Error('Should have recent sessions');
if (recentSessions.length > 10) throw new Error('Should have max 10 recent sessions');
// Validate session structure
const session = recentSessions[0];
const requiredFields = [
'id', 'category', 'quizType', 'difficulty', 'status',
'score', 'isPassed', 'questionsAnswered', 'correctAnswers',
'accuracy', 'timeSpent', 'completedAt'
];
requiredFields.forEach(field => {
if (!(field in session)) {
throw new Error(`Session missing field: ${field}`);
}
});
// Validate category structure
if (!session.category || !session.category.name) {
throw new Error('Session should have category info');
}
// Validate score structure
if (!session.score || typeof session.score.earned !== 'number') {
throw new Error('Session should have score object with earned field');
}
return '✓ Recent sessions correct';
}
},
{
name: 'Test 6: Recent sessions ordered by completion date',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { recentSessions } = response.data.data;
if (recentSessions.length > 1) {
for (let i = 1; i < recentSessions.length; i++) {
const prev = new Date(recentSessions[i - 1].completedAt);
const curr = new Date(recentSessions[i].completedAt);
if (curr > prev) {
throw new Error('Sessions not ordered by completion date (DESC)');
}
}
}
return '✓ Sessions ordered correctly';
}
},
{
name: 'Test 7: Category performance includes all categories',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { categoryPerformance } = response.data.data;
if (!Array.isArray(categoryPerformance)) {
throw new Error('categoryPerformance should be array');
}
if (categoryPerformance.length === 0) {
throw new Error('Should have category performance data');
}
// Validate structure
const catPerf = categoryPerformance[0];
if (!catPerf.category || !catPerf.stats || !catPerf.lastAttempt) {
throw new Error('Category performance missing required fields');
}
const requiredStatsFields = [
'quizzesTaken', 'quizzesPassed', 'passRate',
'averageScore', 'totalQuestions', 'correctAnswers', 'accuracy'
];
requiredStatsFields.forEach(field => {
if (!(field in catPerf.stats)) {
throw new Error(`Category stats missing field: ${field}`);
}
});
return '✓ Category performance correct';
}
},
{
name: 'Test 8: Category performance calculations accurate',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { categoryPerformance } = response.data.data;
categoryPerformance.forEach((catPerf, idx) => {
const stats = catPerf.stats;
// Pass rate
if (stats.quizzesTaken > 0) {
const expectedPassRate = Math.round((stats.quizzesPassed / stats.quizzesTaken) * 100);
if (stats.passRate !== expectedPassRate) {
throw new Error(`Category ${idx + 1} pass rate mismatch`);
}
}
// Accuracy
if (stats.totalQuestions > 0) {
const expectedAccuracy = Math.round((stats.correctAnswers / stats.totalQuestions) * 100);
if (stats.accuracy !== expectedAccuracy) {
throw new Error(`Category ${idx + 1} accuracy mismatch`);
}
}
// All values should be non-negative
Object.values(stats).forEach(val => {
if (typeof val === 'number' && val < 0) {
throw new Error('Stats values should be non-negative');
}
});
});
return '✓ Category performance calculations accurate';
}
},
{
name: 'Test 9: Recent activity includes date and count',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { recentActivity } = response.data.data;
if (!Array.isArray(recentActivity)) {
throw new Error('recentActivity should be array');
}
if (recentActivity.length > 0) {
const activity = recentActivity[0];
if (!activity.date) throw new Error('Activity missing date');
if (typeof activity.quizzesCompleted !== 'number') {
throw new Error('Activity quizzesCompleted should be number');
}
}
return '✓ Recent activity correct';
}
},
{
name: 'Test 10: Cannot access other user\'s dashboard (403)',
run: async () => {
try {
await axios.get(`${API_URL}/users/${secondUserId}/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 403');
} catch (error) {
if (error.response?.status !== 403) {
throw new Error(`Expected 403, got ${error.response?.status}`);
}
return '✓ Cross-user access blocked';
}
}
},
{
name: 'Test 11: Unauthenticated request returns 401',
run: async () => {
try {
await axios.get(`${API_URL}/users/${userId}/dashboard`);
throw new Error('Should have failed with 401');
} catch (error) {
if (error.response?.status !== 401) {
throw new Error(`Expected 401, got ${error.response?.status}`);
}
return '✓ Unauthenticated request blocked';
}
}
},
{
name: 'Test 12: Invalid UUID format returns 400',
run: async () => {
try {
await axios.get(`${API_URL}/users/invalid-uuid/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 400');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Invalid UUID returns 400';
}
}
},
{
name: 'Test 13: Non-existent user returns 404',
run: async () => {
try {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
await axios.get(`${API_URL}/users/${fakeUuid}/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have failed with 404');
} catch (error) {
if (error.response?.status !== 404) {
throw new Error(`Expected 404, got ${error.response?.status}`);
}
return '✓ Non-existent user returns 404';
}
}
},
{
name: 'Test 14: Streak status is valid',
run: async () => {
const response = await axios.get(`${API_URL}/users/${userId}/dashboard`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
const { stats } = response.data.data;
const validStatuses = ['active', 'at-risk', 'inactive'];
if (!validStatuses.includes(stats.streakStatus)) {
throw new Error(`Invalid streak status: ${stats.streakStatus}`);
}
return '✓ Streak status valid';
}
}
];
// Run all tests
async function runTests() {
console.log('='.repeat(60));
console.log('USER DASHBOARD API TESTS');
console.log('='.repeat(60) + '\n');
await setup();
console.log('Running tests...\n');
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
const result = await test.run();
console.log(`${result}`);
passed++;
await delay(500); // Delay between tests
} catch (error) {
console.log(`${test.name}`);
console.log(` Error: ${error.response?.data?.message || error.message}`);
if (error.response?.data && process.env.VERBOSE) {
console.log(` Response:`, JSON.stringify(error.response.data, null, 2));
}
failed++;
}
}
console.log('\n' + '='.repeat(60));
console.log(`RESULTS: ${passed} passed, ${failed} failed out of ${tests.length} tests`);
console.log('='.repeat(60));
process.exit(failed > 0 ? 1 : 0);
}
runTests();