add a lot of changes
This commit is contained in:
1180
backend/controllers/quiz.controller.js
Normal file
1180
backend/controllers/quiz.controller.js
Normal file
File diff suppressed because it is too large
Load Diff
699
backend/controllers/user.controller.js
Normal file
699
backend/controllers/user.controller.js
Normal 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
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -58,7 +58,8 @@ exports.verifyGuestToken = async (req, res, next) => {
|
||||
|
||||
// Attach guest session to request
|
||||
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();
|
||||
} catch (error) {
|
||||
|
||||
134
backend/models/QuizAnswer.js
Normal file
134
backend/models/QuizAnswer.js
Normal 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;
|
||||
};
|
||||
58
backend/models/QuizSessionQuestion.js
Normal file
58
backend/models/QuizSessionQuestion.js
Normal 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;
|
||||
};
|
||||
@@ -28,6 +28,7 @@
|
||||
"test:question-search": "node test-question-search.js",
|
||||
"test:create-question": "node test-create-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",
|
||||
"generate:jwt": "node generate-jwt-secret.js",
|
||||
"migrate": "npx sequelize-cli db:migrate",
|
||||
|
||||
111
backend/routes/quiz.routes.js
Normal file
111
backend/routes/quiz.routes.js
Normal 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;
|
||||
40
backend/routes/user.routes.js
Normal file
40
backend/routes/user.routes.js
Normal 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;
|
||||
@@ -70,12 +70,16 @@ const guestRoutes = require('./routes/guest.routes');
|
||||
const categoryRoutes = require('./routes/category.routes');
|
||||
const questionRoutes = require('./routes/question.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}/guest`, guestRoutes);
|
||||
app.use(`${API_PREFIX}/categories`, categoryRoutes);
|
||||
app.use(`${API_PREFIX}/questions`, questionRoutes);
|
||||
app.use(`${API_PREFIX}/admin`, adminRoutes);
|
||||
app.use(`${API_PREFIX}/quiz`, quizRoutes);
|
||||
app.use(`${API_PREFIX}/users`, userRoutes);
|
||||
|
||||
// Root endpoint
|
||||
app.get('/', (req, res) => {
|
||||
|
||||
547
backend/test-complete-quiz.js
Normal file
547
backend/test-complete-quiz.js
Normal 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);
|
||||
});
|
||||
551
backend/test-quiz-history.js
Normal file
551
backend/test-quiz-history.js
Normal 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
650
backend/test-review-quiz.js
Normal 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();
|
||||
585
backend/test-session-details.js
Normal file
585
backend/test-session-details.js
Normal 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
537
backend/test-start-quiz.js
Normal 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();
|
||||
484
backend/test-submit-answer.js
Normal file
484
backend/test-submit-answer.js
Normal 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);
|
||||
});
|
||||
595
backend/test-update-profile.js
Normal file
595
backend/test-update-profile.js
Normal 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();
|
||||
526
backend/test-user-dashboard.js
Normal file
526
backend/test-user-dashboard.js
Normal 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();
|
||||
Reference in New Issue
Block a user