add changes

This commit is contained in:
AD2025
2025-11-11 00:25:50 +02:00
commit e3ca132c5e
86 changed files with 22238 additions and 0 deletions

1636
BACKEND_TASKS.md Normal file

File diff suppressed because it is too large Load Diff

260
MIGRATION_SUMMARY.md Normal file
View File

@@ -0,0 +1,260 @@
# MongoDB to MySQL Migration Summary
## Overview
This document has been successfully migrated from **MongoDB/Mongoose** to **MySQL/Sequelize** architecture.
---
## Major Changes
### 1. **Technology Stack Update**
- **Changed from**: MongoDB with Mongoose ODM
- **Changed to**: MySQL 8.0+ with Sequelize ORM
- **Stack name**: Updated from "MEAN Stack" to "MySQL + Express + Angular + Node"
### 2. **Database Schema Transformation**
#### ID Strategy
- **MongoDB**: ObjectId (12-byte identifier)
- **MySQL**: UUID (CHAR(36) or BINARY(16))
#### Data Types Mapping
| MongoDB | MySQL |
|---------|-------|
| ObjectId | CHAR(36) UUID |
| String | VARCHAR/TEXT |
| Number | INT/DECIMAL |
| Boolean | BOOLEAN |
| Date | TIMESTAMP |
| Mixed | JSON |
| Array | JSON or Junction Tables |
| Embedded Documents | JSON or Separate Tables |
#### Schema Design Approach
- **Normalized tables** for core entities (users, questions, categories)
- **Junction tables** for many-to-many relationships (bookmarks, achievements, quiz questions)
- **JSON columns** for flexible data (question options, keywords, tags, feature restrictions)
- **Proper foreign keys** with cascading deletes/updates
### 3. **Database Tables Created**
1. **users** - User accounts and statistics
2. **categories** - Question categories
3. **questions** - Question bank with guest visibility controls
4. **quiz_sessions** - Active and completed quiz sessions
5. **quiz_session_questions** - Junction table for session questions
6. **quiz_answers** - Individual question answers
7. **guest_sessions** - Guest user sessions
8. **guest_settings** - Guest access configuration
9. **guest_settings_categories** - Junction table for guest-accessible categories
10. **achievements** - Achievement definitions
11. **user_achievements** - User earned achievements
12. **user_bookmarks** - User bookmarked questions
### 4. **Key Features**
#### Indexes Added
- Primary key indexes (UUID)
- Foreign key indexes for relationships
- Composite indexes for common queries
- Full-text search indexes on question content
- Performance indexes on frequently queried columns
#### MySQL-Specific Optimizations
- InnoDB storage engine
- UTF8MB4 character set for emoji support
- Connection pooling configuration
- Query optimization examples
- Backup and restore strategies
### 5. **Sequelize Models**
All models include:
- UUID primary keys
- Proper associations (hasMany, belongsTo, belongsToMany)
- Field name mapping (camelCase to snake_case)
- Timestamps (created_at, updated_at)
- Validation rules
- Indexes defined at model level
### 6. **Configuration Updates**
#### Environment Variables
```bash
# Old (MongoDB)
MONGODB_URI=mongodb://localhost:27017/interview_quiz
# New (MySQL)
DB_HOST=localhost
DB_PORT=3306
DB_NAME=interview_quiz_db
DB_USER=root
DB_PASSWORD=your_password
DB_DIALECT=mysql
```
#### Dependencies to Update
```bash
# Remove
npm uninstall mongoose
# Install
npm install sequelize mysql2
npm install -g sequelize-cli
```
### 7. **Migration Strategy**
#### Development Setup
1. Install MySQL 8.0+
2. Create database with UTF8MB4
3. Run Sequelize migrations
4. Seed initial data
5. Start application
#### Migration Commands
```bash
# Initialize Sequelize
npx sequelize-cli init
# Create migration
npx sequelize-cli migration:generate --name migration-name
# Run migrations
npx sequelize-cli db:migrate
# Rollback migration
npx sequelize-cli db:migrate:undo
# Seed database
npx sequelize-cli db:seed:all
```
### 8. **Security Enhancements**
- **SQL Injection Prevention**: Sequelize parameterized queries
- **Prepared Statements**: All queries use prepared statements
- **Connection Security**: SSL/TLS support for production
- **Role-based Access**: Maintained through user roles table
### 9. **Performance Considerations**
#### Query Optimization
- Use of composite indexes
- Efficient JOIN operations
- Connection pooling (max 10 connections)
- Query result caching with Redis
- Lazy loading for associations
#### Database Configuration
- InnoDB buffer pool tuning
- Query cache optimization (MySQL 5.7)
- Binary logging for replication
- Slow query log monitoring
### 10. **Deployment Updates**
#### Development
- Local MySQL instance
- Sequelize migrations for schema management
#### Staging
- Managed MySQL (AWS RDS, Azure Database, PlanetScale)
- Automated migration deployment
#### Production
- Read replicas for scaling
- Automated backups (daily snapshots)
- Point-in-time recovery
- Connection pooling with ProxySQL (optional)
---
## What Stayed the Same
**API Endpoints** - All endpoints remain unchanged
**Frontend Code** - No changes required to Angular app
**JWT Authentication** - Token strategy unchanged
**Business Logic** - Core features remain identical
**User Stories** - All acceptance criteria maintained
---
## Breaking Changes
⚠️ **Database Migration Required** - Cannot directly migrate MongoDB data to MySQL without transformation
⚠️ **ORM Changes** - All Mongoose code must be rewritten to Sequelize
⚠️ **Array Queries** - MongoDB array operations need to be rewritten for JSON columns or junction tables
⚠️ **Aggregation Pipelines** - Complex MongoDB aggregations need to be rewritten as SQL JOINs and GROUP BY
---
## Data Migration Steps
If migrating existing MongoDB data:
1. **Export MongoDB Data**
```bash
mongoexport --db interview_quiz --collection users --out users.json
mongoexport --db interview_quiz --collection questions --out questions.json
# ... export all collections
```
2. **Transform Data**
- Convert ObjectIds to UUIDs
- Flatten embedded documents
- Split arrays into junction tables
- Map MongoDB types to MySQL types
3. **Import to MySQL**
```bash
# Use custom Node.js script to import transformed data
node scripts/import-from-mongodb.js
```
---
## Testing Checklist
- [ ] All database tables created successfully
- [ ] Foreign key constraints working
- [ ] Indexes created and optimized
- [ ] User registration and login working
- [ ] Guest session management working
- [ ] Quiz session creation and completion
- [ ] Question CRUD operations (admin)
- [ ] Bookmark functionality
- [ ] Achievement system
- [ ] Full-text search on questions
- [ ] Dashboard statistics queries
- [ ] Performance testing (1000+ concurrent users)
- [ ] Backup and restore procedures
- [ ] Migration rollback testing
---
## Additional Resources
### Sequelize Documentation
- [Sequelize Official Docs](https://sequelize.org/docs/v6/)
- [Migrations Guide](https://sequelize.org/docs/v6/other-topics/migrations/)
- [Associations](https://sequelize.org/docs/v6/core-concepts/assocs/)
### MySQL Documentation
- [MySQL 8.0 Reference](https://dev.mysql.com/doc/refman/8.0/en/)
- [InnoDB Storage Engine](https://dev.mysql.com/doc/refman/8.0/en/innodb-storage-engine.html)
- [Performance Tuning](https://dev.mysql.com/doc/refman/8.0/en/optimization.html)
---
## Version Information
- **Document Version**: 2.0 - MySQL Edition
- **Migration Date**: November 2025
- **Database**: MySQL 8.0+
- **ORM**: Sequelize 6.x
- **Node.js**: 18+
---
**Migration completed successfully!** 🎉

672
SAMPLE_MIGRATIONS.md Normal file
View File

@@ -0,0 +1,672 @@
# Sample Sequelize Migration Files
## Migration 1: Create Users Table
**File**: `migrations/20250101000001-create-users.js`
```javascript
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('users', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
username: {
type: Sequelize.STRING(50),
unique: true,
allowNull: false
},
email: {
type: Sequelize.STRING(255),
unique: true,
allowNull: false
},
password: {
type: Sequelize.STRING(255),
allowNull: false
},
role: {
type: Sequelize.ENUM('user', 'admin'),
defaultValue: 'user'
},
profile_image: {
type: Sequelize.STRING(500)
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
},
last_login: {
type: Sequelize.DATE,
allowNull: true
},
total_quizzes: {
type: Sequelize.INTEGER,
defaultValue: 0
},
total_questions: {
type: Sequelize.INTEGER,
defaultValue: 0
},
correct_answers: {
type: Sequelize.INTEGER,
defaultValue: 0
},
streak: {
type: Sequelize.INTEGER,
defaultValue: 0
}
});
// Add indexes
await queryInterface.addIndex('users', ['email']);
await queryInterface.addIndex('users', ['username']);
await queryInterface.addIndex('users', ['role']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('users');
}
};
```
---
## Migration 2: Create Categories Table
**File**: `migrations/20250101000002-create-categories.js`
```javascript
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('categories', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
name: {
type: Sequelize.STRING(100),
unique: true,
allowNull: false
},
description: {
type: Sequelize.TEXT
},
icon: {
type: Sequelize.STRING(255)
},
slug: {
type: Sequelize.STRING(100),
unique: true,
allowNull: false
},
question_count: {
type: Sequelize.INTEGER,
defaultValue: 0
},
is_active: {
type: Sequelize.BOOLEAN,
defaultValue: true
},
guest_accessible: {
type: Sequelize.BOOLEAN,
defaultValue: false
},
public_question_count: {
type: Sequelize.INTEGER,
defaultValue: 0
},
registered_question_count: {
type: Sequelize.INTEGER,
defaultValue: 0
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
}
});
// Add indexes
await queryInterface.addIndex('categories', ['slug']);
await queryInterface.addIndex('categories', ['is_active']);
await queryInterface.addIndex('categories', ['guest_accessible']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('categories');
}
};
```
---
## Migration 3: Create Questions Table
**File**: `migrations/20250101000003-create-questions.js`
```javascript
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('questions', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
question: {
type: Sequelize.TEXT,
allowNull: false
},
type: {
type: Sequelize.ENUM('multiple', 'trueFalse', 'written'),
allowNull: false
},
category_id: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'categories',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT'
},
difficulty: {
type: Sequelize.ENUM('easy', 'medium', 'hard'),
allowNull: false
},
options: {
type: Sequelize.JSON,
comment: 'Array of answer options for multiple choice questions'
},
correct_answer: {
type: Sequelize.STRING(500)
},
explanation: {
type: Sequelize.TEXT,
allowNull: false
},
keywords: {
type: Sequelize.JSON,
comment: 'Array of keywords for search'
},
tags: {
type: Sequelize.JSON,
comment: 'Array of tags'
},
visibility: {
type: Sequelize.ENUM('public', 'registered', 'premium'),
defaultValue: 'registered'
},
is_guest_accessible: {
type: Sequelize.BOOLEAN,
defaultValue: false
},
created_by: {
type: Sequelize.UUID,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
},
is_active: {
type: Sequelize.BOOLEAN,
defaultValue: true
},
times_attempted: {
type: Sequelize.INTEGER,
defaultValue: 0
},
correct_rate: {
type: Sequelize.DECIMAL(5, 2),
defaultValue: 0.00
}
});
// Add indexes
await queryInterface.addIndex('questions', ['category_id']);
await queryInterface.addIndex('questions', ['difficulty']);
await queryInterface.addIndex('questions', ['type']);
await queryInterface.addIndex('questions', ['visibility']);
await queryInterface.addIndex('questions', ['is_active']);
await queryInterface.addIndex('questions', ['is_guest_accessible']);
// Add full-text index
await queryInterface.sequelize.query(
'CREATE FULLTEXT INDEX idx_questions_fulltext ON questions(question, explanation)'
);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('questions');
}
};
```
---
## Migration 4: Create Guest Sessions Table
**File**: `migrations/20250101000004-create-guest-sessions.js`
```javascript
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('guest_sessions', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
guest_id: {
type: Sequelize.STRING(100),
unique: true,
allowNull: false
},
device_id: {
type: Sequelize.STRING(255),
allowNull: false
},
session_token: {
type: Sequelize.STRING(500),
allowNull: false
},
quizzes_attempted: {
type: Sequelize.INTEGER,
defaultValue: 0
},
max_quizzes: {
type: Sequelize.INTEGER,
defaultValue: 3
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
expires_at: {
type: Sequelize.DATE,
allowNull: false
},
ip_address: {
type: Sequelize.STRING(45)
},
user_agent: {
type: Sequelize.TEXT
}
});
// Add indexes
await queryInterface.addIndex('guest_sessions', ['guest_id']);
await queryInterface.addIndex('guest_sessions', ['session_token']);
await queryInterface.addIndex('guest_sessions', ['expires_at']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('guest_sessions');
}
};
```
---
## Migration 5: Create Quiz Sessions Table
**File**: `migrations/20250101000005-create-quiz-sessions.js`
```javascript
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('quiz_sessions', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
user_id: {
type: Sequelize.UUID,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
guest_session_id: {
type: Sequelize.UUID,
references: {
model: 'guest_sessions',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
is_guest_session: {
type: Sequelize.BOOLEAN,
defaultValue: false
},
category_id: {
type: Sequelize.UUID,
references: {
model: 'categories',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
},
start_time: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
end_time: {
type: Sequelize.DATE,
allowNull: true
},
score: {
type: Sequelize.INTEGER,
defaultValue: 0
},
total_questions: {
type: Sequelize.INTEGER,
allowNull: false
},
status: {
type: Sequelize.ENUM('in-progress', 'completed', 'abandoned'),
defaultValue: 'in-progress'
},
completed_at: {
type: Sequelize.DATE,
allowNull: true
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
}
});
// Add indexes
await queryInterface.addIndex('quiz_sessions', ['user_id']);
await queryInterface.addIndex('quiz_sessions', ['guest_session_id']);
await queryInterface.addIndex('quiz_sessions', ['status']);
await queryInterface.addIndex('quiz_sessions', ['completed_at']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('quiz_sessions');
}
};
```
---
## Seeder Example: Demo Categories
**File**: `seeders/20250101000001-demo-categories.js`
```javascript
'use strict';
const { v4: uuidv4 } = require('uuid');
module.exports = {
up: async (queryInterface, Sequelize) => {
const categories = [
{
id: uuidv4(),
name: 'Angular',
description: 'Frontend framework by Google',
icon: 'angular-icon.svg',
slug: 'angular',
question_count: 0,
is_active: true,
guest_accessible: true,
public_question_count: 0,
registered_question_count: 0,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Node.js',
description: 'JavaScript runtime for backend',
icon: 'nodejs-icon.svg',
slug: 'nodejs',
question_count: 0,
is_active: true,
guest_accessible: true,
public_question_count: 0,
registered_question_count: 0,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'MySQL',
description: 'Relational database management system',
icon: 'mysql-icon.svg',
slug: 'mysql',
question_count: 0,
is_active: true,
guest_accessible: false,
public_question_count: 0,
registered_question_count: 0,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Express.js',
description: 'Web framework for Node.js',
icon: 'express-icon.svg',
slug: 'expressjs',
question_count: 0,
is_active: true,
guest_accessible: true,
public_question_count: 0,
registered_question_count: 0,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'JavaScript',
description: 'Core programming language',
icon: 'javascript-icon.svg',
slug: 'javascript',
question_count: 0,
is_active: true,
guest_accessible: true,
public_question_count: 0,
registered_question_count: 0,
created_at: new Date(),
updated_at: new Date()
}
];
await queryInterface.bulkInsert('categories', categories);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('categories', null, {});
}
};
```
---
## Seeder Example: Demo Admin User
**File**: `seeders/20250101000002-demo-admin.js`
```javascript
'use strict';
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
module.exports = {
up: async (queryInterface, Sequelize) => {
const hashedPassword = await bcrypt.hash('Admin@123', 10);
await queryInterface.bulkInsert('users', [{
id: uuidv4(),
username: 'admin',
email: 'admin@quizapp.com',
password: hashedPassword,
role: 'admin',
profile_image: null,
created_at: new Date(),
updated_at: new Date(),
last_login: null,
total_quizzes: 0,
total_questions: 0,
correct_answers: 0,
streak: 0
}]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('users', { email: 'admin@quizapp.com' }, {});
}
};
```
---
## Running Migrations
```bash
# Create database first
mysql -u root -p
CREATE DATABASE interview_quiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
EXIT;
# Run all migrations
npx sequelize-cli db:migrate
# Check migration status
npx sequelize-cli db:migrate:status
# Undo last migration
npx sequelize-cli db:migrate:undo
# Undo all migrations
npx sequelize-cli db:migrate:undo:all
# Run seeders
npx sequelize-cli db:seed:all
# Undo seeders
npx sequelize-cli db:seed:undo:all
```
---
## .sequelizerc Configuration
**File**: `.sequelizerc` (in project root)
```javascript
const path = require('path');
module.exports = {
'config': path.resolve('config', 'database.js'),
'models-path': path.resolve('models'),
'seeders-path': path.resolve('seeders'),
'migrations-path': path.resolve('migrations')
};
```
---
## Database Configuration
**File**: `config/database.js`
```javascript
require('dotenv').config();
module.exports = {
development: {
username: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'interview_quiz_db',
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 3306,
dialect: 'mysql',
logging: console.log,
pool: {
max: 10,
min: 0,
acquire: 30000,
idle: 10000
}
},
test: {
username: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: 'interview_quiz_test',
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 3306,
dialect: 'mysql',
logging: false
},
production: {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
port: process.env.DB_PORT || 3306,
dialect: 'mysql',
logging: false,
pool: {
max: 20,
min: 5,
acquire: 30000,
idle: 10000
},
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false
}
}
}
};
```
---
That's it! Your migration files are ready to use. 🚀

View File

@@ -0,0 +1,641 @@
# Sequelize Quick Reference Guide
## Common Operations for Interview Quiz App
### 1. Database Connection
```javascript
// config/database.js
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize(
process.env.DB_NAME,
process.env.DB_USER,
process.env.DB_PASSWORD,
{
host: process.env.DB_HOST,
dialect: 'mysql',
pool: { max: 10, min: 0, acquire: 30000, idle: 10000 }
}
);
// Test connection
sequelize.authenticate()
.then(() => console.log('MySQL connected'))
.catch(err => console.error('Unable to connect:', err));
module.exports = sequelize;
```
---
### 2. User Operations
#### Register New User
```javascript
const bcrypt = require('bcrypt');
const { User } = require('../models');
const registerUser = async (userData) => {
const hashedPassword = await bcrypt.hash(userData.password, 10);
const user = await User.create({
username: userData.username,
email: userData.email,
password: hashedPassword,
role: 'user'
});
return user;
};
```
#### Login User
```javascript
const loginUser = async (email, password) => {
const user = await User.findOne({
where: { email },
attributes: ['id', 'username', 'email', 'password', 'role']
});
if (!user) {
throw new Error('User not found');
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
throw new Error('Invalid credentials');
}
return user;
};
```
#### Get User Dashboard
```javascript
const getUserDashboard = async (userId) => {
const user = await User.findByPk(userId, {
attributes: [
'id', 'username', 'email',
'totalQuizzes', 'totalQuestions', 'correctAnswers', 'streak'
],
include: [
{
model: QuizSession,
where: { status: 'completed' },
required: false,
limit: 10,
order: [['completedAt', 'DESC']],
include: [{ model: Category, attributes: ['name'] }]
}
]
});
return user;
};
```
---
### 3. Category Operations
#### Get All Categories
```javascript
const getCategories = async () => {
const categories = await Category.findAll({
where: { isActive: true },
attributes: {
include: [
[
sequelize.literal(`(
SELECT COUNT(*)
FROM questions
WHERE questions.category_id = categories.id
AND questions.is_active = true
)`),
'questionCount'
]
]
}
});
return categories;
};
```
#### Get Guest-Accessible Categories
```javascript
const getGuestCategories = async () => {
const categories = await Category.findAll({
where: {
isActive: true,
guestAccessible: true
},
attributes: ['id', 'name', 'description', 'icon', 'publicQuestionCount']
});
return categories;
};
```
---
### 4. Question Operations
#### Create Question (Admin)
```javascript
const createQuestion = async (questionData) => {
const question = await Question.create({
question: questionData.question,
type: questionData.type,
categoryId: questionData.categoryId,
difficulty: questionData.difficulty,
options: JSON.stringify(questionData.options), // Store as JSON
correctAnswer: questionData.correctAnswer,
explanation: questionData.explanation,
keywords: JSON.stringify(questionData.keywords),
tags: JSON.stringify(questionData.tags),
visibility: questionData.visibility || 'registered',
isGuestAccessible: questionData.isGuestAccessible || false,
createdBy: questionData.userId
});
// Update category question count
await Category.increment('questionCount', {
where: { id: questionData.categoryId }
});
return question;
};
```
#### Get Questions by Category
```javascript
const getQuestionsByCategory = async (categoryId, options = {}) => {
const { difficulty, limit = 10, visibility = 'registered' } = options;
const where = {
categoryId,
isActive: true,
visibility: {
[Op.in]: visibility === 'guest' ? ['public'] : ['public', 'registered', 'premium']
}
};
if (difficulty) {
where.difficulty = difficulty;
}
const questions = await Question.findAll({
where,
order: sequelize.random(),
limit,
include: [{ model: Category, attributes: ['name'] }]
});
return questions;
};
```
#### Search Questions (Full-Text)
```javascript
const searchQuestions = async (searchTerm) => {
const questions = await Question.findAll({
where: {
[Op.or]: [
sequelize.literal(`MATCH(question, explanation) AGAINST('${searchTerm}' IN NATURAL LANGUAGE MODE)`)
],
isActive: true
},
limit: 50
});
return questions;
};
```
---
### 5. Quiz Session Operations
#### Start Quiz Session
```javascript
const startQuizSession = async (sessionData) => {
const { userId, guestSessionId, categoryId, questionCount, difficulty } = sessionData;
// Create quiz session
const quizSession = await QuizSession.create({
userId: userId || null,
guestSessionId: guestSessionId || null,
isGuestSession: !userId,
categoryId,
totalQuestions: questionCount,
status: 'in-progress',
startTime: new Date()
});
// Get random questions
const questions = await Question.findAll({
where: {
categoryId,
difficulty: difficulty || { [Op.in]: ['easy', 'medium', 'hard'] },
isActive: true
},
order: sequelize.random(),
limit: questionCount
});
// Create junction entries
const sessionQuestions = questions.map((q, index) => ({
quizSessionId: quizSession.id,
questionId: q.id,
questionOrder: index + 1
}));
await sequelize.models.QuizSessionQuestion.bulkCreate(sessionQuestions);
return { quizSession, questions };
};
```
#### Submit Answer
```javascript
const submitAnswer = async (answerData) => {
const { quizSessionId, questionId, userAnswer } = answerData;
// Get question
const question = await Question.findByPk(questionId);
// Check if correct
const isCorrect = question.correctAnswer === userAnswer;
// Save answer
const answer = await QuizAnswer.create({
quizSessionId,
questionId,
userAnswer,
isCorrect,
timeSpent: answerData.timeSpent || 0
});
// Update quiz session score
if (isCorrect) {
await QuizSession.increment('score', { where: { id: quizSessionId } });
}
// Update question statistics
await Question.increment('timesAttempted', { where: { id: questionId } });
return {
isCorrect,
correctAnswer: question.correctAnswer,
explanation: question.explanation
};
};
```
#### Complete Quiz Session
```javascript
const completeQuizSession = async (sessionId) => {
const session = await QuizSession.findByPk(sessionId, {
include: [
{
model: QuizAnswer,
include: [{ model: Question, attributes: ['id', 'question'] }]
}
]
});
// Calculate results
const correctAnswers = session.QuizAnswers.filter(a => a.isCorrect).length;
const percentage = (correctAnswers / session.totalQuestions) * 100;
const timeTaken = Math.floor((new Date() - session.startTime) / 1000); // seconds
// Update session
await session.update({
status: 'completed',
endTime: new Date(),
completedAt: new Date()
});
// Update user stats if not guest
if (session.userId) {
await User.increment({
totalQuizzes: 1,
totalQuestions: session.totalQuestions,
correctAnswers: correctAnswers
}, { where: { id: session.userId } });
}
return {
score: session.score,
totalQuestions: session.totalQuestions,
percentage,
timeTaken,
correctAnswers,
incorrectAnswers: session.totalQuestions - correctAnswers,
results: session.QuizAnswers
};
};
```
---
### 6. Guest Session Operations
#### Create Guest Session
```javascript
const createGuestSession = async (deviceId, ipAddress, userAgent) => {
const guestId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const sessionToken = jwt.sign({ guestId }, process.env.JWT_SECRET, { expiresIn: '24h' });
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
const guestSession = await GuestSession.create({
guestId,
deviceId,
sessionToken,
quizzesAttempted: 0,
maxQuizzes: 3,
expiresAt,
ipAddress,
userAgent
});
return guestSession;
};
```
#### Check Guest Quiz Limit
```javascript
const checkGuestQuizLimit = async (guestSessionId) => {
const session = await GuestSession.findByPk(guestSessionId);
if (!session) {
throw new Error('Guest session not found');
}
if (new Date() > session.expiresAt) {
throw new Error('Guest session expired');
}
const remainingQuizzes = session.maxQuizzes - session.quizzesAttempted;
if (remainingQuizzes <= 0) {
throw new Error('Guest quiz limit reached');
}
return {
remainingQuizzes,
resetTime: session.expiresAt
};
};
```
---
### 7. Bookmark Operations
#### Add Bookmark
```javascript
const addBookmark = async (userId, questionId) => {
const bookmark = await sequelize.models.UserBookmark.create({
userId,
questionId
});
return bookmark;
};
```
#### Get User Bookmarks
```javascript
const getUserBookmarks = async (userId) => {
const user = await User.findByPk(userId, {
include: [{
model: Question,
as: 'bookmarks',
through: { attributes: ['bookmarkedAt'] },
include: [{ model: Category, attributes: ['name'] }]
}]
});
return user.bookmarks;
};
```
#### Remove Bookmark
```javascript
const removeBookmark = async (userId, questionId) => {
await sequelize.models.UserBookmark.destroy({
where: { userId, questionId }
});
};
```
---
### 8. Admin Operations
#### Get System Statistics
```javascript
const getSystemStats = async () => {
const [totalUsers, activeUsers, totalQuizzes] = await Promise.all([
User.count(),
User.count({
where: {
lastLogin: { [Op.gte]: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
}
}),
QuizSession.count({ where: { status: 'completed' } })
]);
const popularCategories = await Category.findAll({
attributes: [
'id', 'name',
[sequelize.fn('COUNT', sequelize.col('QuizSessions.id')), 'quizCount']
],
include: [{
model: QuizSession,
attributes: [],
required: false
}],
group: ['Category.id'],
order: [[sequelize.literal('quizCount'), 'DESC']],
limit: 5
});
const avgScore = await QuizSession.findOne({
attributes: [[sequelize.fn('AVG', sequelize.col('score')), 'avgScore']],
where: { status: 'completed' }
});
return {
totalUsers,
activeUsers,
totalQuizzes,
popularCategories,
averageScore: avgScore.dataValues.avgScore || 0
};
};
```
#### Update Guest Settings
```javascript
const updateGuestSettings = async (settings, adminUserId) => {
const guestSettings = await GuestSettings.findOne();
if (guestSettings) {
await guestSettings.update({
...settings,
updatedBy: adminUserId
});
} else {
await GuestSettings.create({
...settings,
updatedBy: adminUserId
});
}
return guestSettings;
};
```
---
### 9. Transaction Example
```javascript
const { Op } = require('sequelize');
const convertGuestToUser = async (guestSessionId, userData) => {
const t = await sequelize.transaction();
try {
// Create user
const user = await User.create(userData, { transaction: t });
// Get guest session
const guestSession = await GuestSession.findByPk(guestSessionId, { transaction: t });
// Migrate quiz sessions
await QuizSession.update(
{ userId: user.id, isGuestSession: false },
{ where: { guestSessionId }, transaction: t }
);
// Calculate stats
const stats = await QuizSession.findAll({
where: { userId: user.id },
attributes: [
[sequelize.fn('COUNT', sequelize.col('id')), 'totalQuizzes'],
[sequelize.fn('SUM', sequelize.col('score')), 'totalScore']
],
transaction: t
});
// Update user stats
await user.update({
totalQuizzes: stats[0].dataValues.totalQuizzes || 0
}, { transaction: t });
// Delete guest session
await guestSession.destroy({ transaction: t });
await t.commit();
return user;
} catch (error) {
await t.rollback();
throw error;
}
};
```
---
### 10. Common Query Operators
```javascript
const { Op } = require('sequelize');
// Examples of common operators
const examples = {
// Equals
{ status: 'completed' },
// Not equals
{ status: { [Op.ne]: 'abandoned' } },
// Greater than / Less than
{ score: { [Op.gte]: 80 } },
{ createdAt: { [Op.lte]: new Date() } },
// Between
{ score: { [Op.between]: [50, 100] } },
// In array
{ difficulty: { [Op.in]: ['easy', 'medium'] } },
// Like (case-sensitive)
{ question: { [Op.like]: '%javascript%' } },
// Not null
{ userId: { [Op.not]: null } },
// OR condition
{
[Op.or]: [
{ visibility: 'public' },
{ isGuestAccessible: true }
]
},
// AND condition
{
[Op.and]: [
{ isActive: true },
{ difficulty: 'hard' }
]
}
};
```
---
## Error Handling
```javascript
const handleSequelizeError = (error) => {
if (error.name === 'SequelizeUniqueConstraintError') {
return { status: 400, message: 'Record already exists' };
}
if (error.name === 'SequelizeValidationError') {
return { status: 400, message: error.errors.map(e => e.message).join(', ') };
}
if (error.name === 'SequelizeForeignKeyConstraintError') {
return { status: 400, message: 'Invalid foreign key reference' };
}
return { status: 500, message: 'Database error' };
};
```
---
## Best Practices
1. **Always use parameterized queries** (Sequelize does this by default)
2. **Use transactions for multi-step operations**
3. **Index frequently queried columns**
4. **Use eager loading to avoid N+1 queries**
5. **Limit result sets with pagination**
6. **Use connection pooling**
7. **Handle errors gracefully**
8. **Log slow queries in development**
9. **Use prepared statements**
10. **Validate input before database operations**
---
Happy coding! 🚀

41
backend/.env.example Normal file
View File

@@ -0,0 +1,41 @@
# Server Configuration
NODE_ENV=development
PORT=3000
API_PREFIX=/api
# Database Configuration
DB_HOST=localhost
DB_PORT=3306
DB_NAME=interview_quiz_db
DB_USER=root
DB_PASSWORD=your_password_here
DB_DIALECT=mysql
# Database Connection Pool
DB_POOL_MAX=10
DB_POOL_MIN=0
DB_POOL_ACQUIRE=30000
DB_POOL_IDLE=10000
# JWT Configuration
JWT_SECRET=your_generated_secret_key_here_change_in_production
JWT_EXPIRE=24h
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
# CORS Configuration
CORS_ORIGIN=http://localhost:4200
# Guest Session Configuration
GUEST_SESSION_EXPIRE_HOURS=24
GUEST_MAX_QUIZZES=3
# Logging
LOG_LEVEL=debug
# Redis Configuration (Optional - for caching)
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_PASSWORD=

39
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Dependencies
node_modules/
package-lock.json
# Environment variables
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Testing
coverage/
.nyc_output/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Build
dist/
build/
# Temporary files
tmp/
temp/
*.tmp

8
backend/.sequelizerc Normal file
View File

@@ -0,0 +1,8 @@
const path = require('path');
module.exports = {
'config': path.resolve('config', 'database.js'),
'models-path': path.resolve('models'),
'seeders-path': path.resolve('seeders'),
'migrations-path': path.resolve('migrations')
};

View File

@@ -0,0 +1,185 @@
# Database Quick Reference
## Database Connection Test
To test the database connection at any time:
```bash
npm run test:db
```
This will:
- Verify MySQL server is running
- Check database credentials
- Confirm database exists
- Show MySQL version
- List existing tables
## Sequelize CLI Commands
### Database Creation
Create the database manually:
```bash
mysql -u root -p -e "CREATE DATABASE interview_quiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
```
### Migrations
Generate a new migration:
```bash
npx sequelize-cli migration:generate --name migration-name
```
Run all pending migrations:
```bash
npm run migrate
```
Undo last migration:
```bash
npm run migrate:undo
```
Check migration status:
```bash
npm run migrate:status
```
### Seeders
Generate a new seeder:
```bash
npx sequelize-cli seed:generate --name seeder-name
```
Run all seeders:
```bash
npm run seed
```
Undo all seeders:
```bash
npm run seed:undo
```
Undo specific seeder:
```bash
npx sequelize-cli db:seed:undo --seed seeder-filename.js
```
## Configuration Files
### `.sequelizerc`
Configures Sequelize CLI paths for:
- config
- models-path
- seeders-path
- migrations-path
### `config/database.js`
Contains environment-specific database configurations:
- `development` - Local development
- `test` - Testing environment
- `production` - Production settings
### `config/db.js`
Database utility functions:
- `testConnection()` - Test database connection
- `syncModels()` - Sync models with database
- `closeConnection()` - Close database connection
- `getDatabaseStats()` - Get database statistics
### `models/index.js`
- Initializes Sequelize
- Loads all model files
- Sets up model associations
- Exports db object with all models
## Connection Pool Configuration
Current settings (from `.env`):
- `DB_POOL_MAX=10` - Maximum connections
- `DB_POOL_MIN=0` - Minimum connections
- `DB_POOL_ACQUIRE=30000` - Max time to get connection (ms)
- `DB_POOL_IDLE=10000` - Max idle time before release (ms)
## Server Integration
The server (`server.js`) now:
1. Tests database connection on startup
2. Provides database stats in `/health` endpoint
3. Warns if database connection fails
Test the health endpoint:
```bash
curl http://localhost:3000/health
```
Response includes:
```json
{
"status": "OK",
"message": "Interview Quiz API is running",
"timestamp": "2025-11-09T...",
"environment": "development",
"database": {
"connected": true,
"version": "8.0.42",
"tables": 0,
"database": "interview_quiz_db"
}
}
```
## Troubleshooting
### Connection Failed
If database connection fails, check:
1. MySQL server is running
2. Database credentials in `.env` are correct
3. Database exists
4. User has proper permissions
### Access Denied
```bash
# Grant permissions to user
mysql -u root -p -e "GRANT ALL PRIVILEGES ON interview_quiz_db.* TO 'root'@'localhost';"
mysql -u root -p -e "FLUSH PRIVILEGES;"
```
### Database Not Found
```bash
# Create database
mysql -u root -p -e "CREATE DATABASE interview_quiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
```
### Check MySQL Service
Windows:
```bash
net start MySQL80
```
Linux/Mac:
```bash
sudo systemctl start mysql
# or
brew services start mysql
```
## Next Steps
After Task 2 completion, you can:
1. ✅ Test database connection
2. 🔄 Start creating migrations (Task 4+)
3. 🔄 Build Sequelize models
4. 🔄 Run migrations to create tables
5. 🔄 Seed database with initial data
---
**Status**: Database setup complete and verified! ✅

View File

@@ -0,0 +1,348 @@
# Environment Configuration Guide
## Overview
This guide explains all environment variables used in the Interview Quiz Backend application and how to configure them properly.
## Quick Start
1. **Copy the example file:**
```bash
cp .env.example .env
```
2. **Generate a secure JWT secret:**
```bash
npm run generate:jwt
```
3. **Update database credentials in `.env`:**
```env
DB_USER=root
DB_PASSWORD=your_mysql_password
```
4. **Validate your configuration:**
```bash
npm run validate:env
```
## Environment Variables
### Server Configuration
#### `NODE_ENV`
- **Type:** String
- **Required:** Yes
- **Default:** `development`
- **Values:** `development`, `test`, `production`
- **Description:** Application environment mode
#### `PORT`
- **Type:** Number
- **Required:** Yes
- **Default:** `3000`
- **Range:** 1000-65535
- **Description:** Port number for the server
#### `API_PREFIX`
- **Type:** String
- **Required:** Yes
- **Default:** `/api`
- **Description:** API route prefix
---
### Database Configuration
#### `DB_HOST`
- **Type:** String
- **Required:** Yes
- **Default:** `localhost`
- **Description:** MySQL server hostname
#### `DB_PORT`
- **Type:** Number
- **Required:** Yes
- **Default:** `3306`
- **Description:** MySQL server port
#### `DB_NAME`
- **Type:** String
- **Required:** Yes
- **Default:** `interview_quiz_db`
- **Description:** Database name
#### `DB_USER`
- **Type:** String
- **Required:** Yes
- **Default:** `root`
- **Description:** Database username
#### `DB_PASSWORD`
- **Type:** String
- **Required:** Yes (in production)
- **Default:** Empty string
- **Description:** Database password
- **Security:** Never commit this to version control!
#### `DB_DIALECT`
- **Type:** String
- **Required:** Yes
- **Default:** `mysql`
- **Values:** `mysql`, `postgres`, `sqlite`, `mssql`
- **Description:** Database type
---
### Database Connection Pool
#### `DB_POOL_MAX`
- **Type:** Number
- **Required:** No
- **Default:** `10`
- **Description:** Maximum number of connections in pool
#### `DB_POOL_MIN`
- **Type:** Number
- **Required:** No
- **Default:** `0`
- **Description:** Minimum number of connections in pool
#### `DB_POOL_ACQUIRE`
- **Type:** Number
- **Required:** No
- **Default:** `30000` (30 seconds)
- **Description:** Max time (ms) to get connection before error
#### `DB_POOL_IDLE`
- **Type:** Number
- **Required:** No
- **Default:** `10000` (10 seconds)
- **Description:** Max idle time (ms) before releasing connection
---
### JWT Authentication
#### `JWT_SECRET`
- **Type:** String
- **Required:** Yes
- **Min Length:** 32 characters (64+ recommended)
- **Description:** Secret key for signing JWT tokens
- **Security:**
- Generate with: `npm run generate:jwt`
- Must be different for each environment
- Rotate regularly in production
- Never commit to version control!
#### `JWT_EXPIRE`
- **Type:** String
- **Required:** Yes
- **Default:** `24h`
- **Format:** Time string (e.g., `24h`, `7d`, `1m`)
- **Description:** JWT token expiration time
---
### Rate Limiting
#### `RATE_LIMIT_WINDOW_MS`
- **Type:** Number
- **Required:** No
- **Default:** `900000` (15 minutes)
- **Description:** Time window for rate limiting (ms)
#### `RATE_LIMIT_MAX_REQUESTS`
- **Type:** Number
- **Required:** No
- **Default:** `100`
- **Description:** Max requests per window per IP
---
### CORS Configuration
#### `CORS_ORIGIN`
- **Type:** String
- **Required:** Yes
- **Default:** `http://localhost:4200`
- **Description:** Allowed CORS origin (frontend URL)
- **Examples:**
- Development: `http://localhost:4200`
- Production: `https://yourapp.com`
---
### Guest User Configuration
#### `GUEST_SESSION_EXPIRE_HOURS`
- **Type:** Number
- **Required:** No
- **Default:** `24`
- **Description:** Guest session expiry time in hours
#### `GUEST_MAX_QUIZZES`
- **Type:** Number
- **Required:** No
- **Default:** `3`
- **Description:** Maximum quizzes a guest can take
---
### Logging
#### `LOG_LEVEL`
- **Type:** String
- **Required:** No
- **Default:** `info`
- **Values:** `error`, `warn`, `info`, `debug`
- **Description:** Logging verbosity level
---
## Environment-Specific Configurations
### Development
```env
NODE_ENV=development
PORT=3000
DB_HOST=localhost
DB_PASSWORD=your_dev_password
JWT_SECRET=dev_jwt_secret_generate_with_npm_run_generate_jwt
CORS_ORIGIN=http://localhost:4200
LOG_LEVEL=debug
```
### Production
```env
NODE_ENV=production
PORT=3000
DB_HOST=your_production_host
DB_PASSWORD=strong_production_password
JWT_SECRET=production_jwt_secret_must_be_different_from_dev
CORS_ORIGIN=https://yourapp.com
LOG_LEVEL=warn
```
### Testing
```env
NODE_ENV=test
PORT=3001
DB_NAME=interview_quiz_db_test
DB_PASSWORD=test_password
JWT_SECRET=test_jwt_secret
LOG_LEVEL=error
```
---
## Validation
The application automatically validates all environment variables on startup.
### Manual Validation
Run validation anytime:
```bash
npm run validate:env
```
### Validation Checks
- ✅ All required variables are set
- ✅ Values are in correct format (string, number)
- ✅ Numbers are within valid ranges
- ✅ Enums match allowed values
- ✅ Minimum length requirements met
- ⚠️ Warnings for weak configurations
---
## Security Best Practices
### 1. JWT Secret
- Generate strong, random secrets: `npm run generate:jwt`
- Use different secrets for each environment
- Store securely (never in code)
- Rotate periodically
### 2. Database Password
- Use strong, unique passwords
- Never commit to version control
- Use environment-specific passwords
- Restrict database user permissions
### 3. CORS Origin
- Set to exact frontend URL
- Never use `*` in production
- Use HTTPS in production
### 4. Rate Limiting
- Adjust based on expected traffic
- Lower limits for auth endpoints
- Monitor for abuse patterns
---
## Troubleshooting
### Validation Fails
Check the error messages and fix invalid values:
```bash
npm run validate:env
```
### Database Connection Fails
1. Verify MySQL is running
2. Check credentials in `.env`
3. Test connection: `npm run test:db`
4. Ensure database exists
### JWT Errors
1. Verify JWT_SECRET is set
2. Ensure it's at least 32 characters
3. Regenerate if needed: `npm run generate:jwt`
---
## Configuration Module
Access configuration in code:
```javascript
const config = require('./config/config');
// Server config
console.log(config.server.port);
console.log(config.server.nodeEnv);
// Database config
console.log(config.database.host);
console.log(config.database.name);
// JWT config
console.log(config.jwt.secret);
console.log(config.jwt.expire);
// Guest config
console.log(config.guest.maxQuizzes);
```
---
## Additional Resources
- [Database Setup](./DATABASE_REFERENCE.md)
- [Backend README](./README.md)
- [Task List](../BACKEND_TASKS.md)
---
**Remember:** Never commit `.env` files to version control! Only commit `.env.example` with placeholder values.

263
backend/README.md Normal file
View File

@@ -0,0 +1,263 @@
# Interview Quiz Application - Backend
MySQL + Express + Node.js Backend API
## Project Structure
```
backend/
├── config/ # Configuration files (database, etc.)
├── controllers/ # Request handlers
├── middleware/ # Custom middleware (auth, validation, etc.)
├── models/ # Sequelize models
├── routes/ # API route definitions
├── migrations/ # Database migrations
├── seeders/ # Database seeders
├── tests/ # Test files
├── server.js # Main application entry point
├── .env # Environment variables (not in git)
├── .env.example # Environment variables template
└── package.json # Dependencies and scripts
```
## Prerequisites
- Node.js 18+
- MySQL 8.0+
- npm or yarn
## Installation
1. **Navigate to backend directory:**
```bash
cd backend
```
2. **Install dependencies:**
```bash
npm install
```
3. **Setup environment variables:**
```bash
cp .env.example .env
```
Then edit `.env` with your database credentials.
4. **Create MySQL database:**
```sql
CREATE DATABASE interview_quiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
## Running the Application
### Development Mode
```bash
npm run dev
```
Uses nodemon for auto-restart on file changes.
### Production Mode
```bash
npm start
```
### Server will start on:
- **URL:** http://localhost:3000
- **API Endpoint:** http://localhost:3000/api
- **Health Check:** http://localhost:3000/health
## Database Management
### Initialize Sequelize
```bash
npx sequelize-cli init
```
### Run Migrations
```bash
npm run migrate
```
### Undo Last Migration
```bash
npm run migrate:undo
```
### Run Seeders
```bash
npm run seed
```
### Undo Seeders
```bash
npm run seed:undo
```
## Testing
### Run all tests
```bash
npm test
```
### Run tests in watch mode
```bash
npm run test:watch
```
## API Endpoints
Coming soon... (Will be documented as features are implemented)
### Authentication
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Login user
- `POST /api/auth/logout` - Logout user
- `GET /api/auth/verify` - Verify JWT token
### Categories
- `GET /api/categories` - Get all categories
- `GET /api/categories/:id` - Get category by ID
### Questions
- `GET /api/questions/category/:categoryId` - Get questions by category
- `GET /api/questions/:id` - Get question by ID
- `GET /api/questions/search` - Search questions
### Quiz
- `POST /api/quiz/start` - Start quiz session
- `POST /api/quiz/submit` - Submit answer
- `POST /api/quiz/complete` - Complete quiz session
- `GET /api/quiz/session/:sessionId` - Get session details
### User Dashboard
- `GET /api/users/:userId/dashboard` - Get user dashboard
- `GET /api/users/:userId/history` - Get quiz history
- `PUT /api/users/:userId` - Update user profile
### Admin
- `GET /api/admin/statistics` - Get system statistics
- `GET /api/admin/guest-settings` - Get guest settings
- `PUT /api/admin/guest-settings` - Update guest settings
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `NODE_ENV` | Environment (development/production) | development |
| `PORT` | Server port | 3000 |
| `API_PREFIX` | API route prefix | /api |
| `DB_HOST` | MySQL host | localhost |
| `DB_PORT` | MySQL port | 3306 |
| `DB_NAME` | Database name | interview_quiz_db |
| `DB_USER` | Database user | root |
| `DB_PASSWORD` | Database password | - |
| `JWT_SECRET` | Secret key for JWT tokens | - |
| `JWT_EXPIRE` | JWT expiration time | 24h |
| `CORS_ORIGIN` | Allowed CORS origin | http://localhost:4200 |
## Development Workflow
1. **Create a feature branch**
```bash
git checkout -b feature/your-feature-name
```
2. **Implement your feature**
- Follow the tasks in `BACKEND_TASKS.md`
- Write tests for your code
- Update this README if needed
3. **Test your changes**
```bash
npm test
```
4. **Commit and push**
```bash
git add .
git commit -m "feat: your feature description"
git push origin feature/your-feature-name
```
## Technologies Used
- **Express.js** - Web framework
- **Sequelize** - ORM for MySQL
- **MySQL2** - MySQL driver
- **JWT** - Authentication
- **Bcrypt** - Password hashing
- **Helmet** - Security headers
- **CORS** - Cross-origin resource sharing
- **Morgan** - HTTP request logger
- **Express Validator** - Input validation
- **Express Rate Limit** - Rate limiting
- **Jest** - Testing framework
- **Supertest** - API testing
## Testing Database Connection
To test the database connection:
```bash
npm run test:db
```
This will verify:
- MySQL server is running
- Database credentials are correct
- Database exists
- Connection is successful
## Environment Configuration
All environment variables are validated on server startup. To manually validate:
```bash
npm run validate:env
```
To generate a new JWT secret:
```bash
npm run generate:jwt
```
See [ENVIRONMENT_GUIDE.md](./ENVIRONMENT_GUIDE.md) for complete configuration documentation.
## Testing Models
To test the User model:
```bash
npm run test:user
```
This verifies:
- User creation with UUID
- Password hashing
- Password comparison
- Validation rules
- Helper methods
## Next Steps
Follow the tasks in `BACKEND_TASKS.md`:
- ✅ Task 1: Project Initialization (COMPLETED)
- ✅ Task 2: Database Setup (COMPLETED)
- ✅ Task 3: Environment Configuration (COMPLETED)
- ✅ Task 4: Create User Model & Migration (COMPLETED)
- 🔄 Task 5: Create Categories Model & Migration (NEXT)
- ... and more
## Documentation References
- [BACKEND_TASKS.md](../BACKEND_TASKS.md) - Complete task list
- [SEQUELIZE_QUICK_REFERENCE.md](../SEQUELIZE_QUICK_REFERENCE.md) - Code examples
- [SAMPLE_MIGRATIONS.md](../SAMPLE_MIGRATIONS.md) - Migration templates
- [interview_quiz_user_story.md](../interview_quiz_user_story.md) - Full specification
## License
ISC

239
backend/SEEDING.md Normal file
View File

@@ -0,0 +1,239 @@
# Database Seeding
This document describes the demo data seeded into the database for development and testing purposes.
## Overview
The database includes 4 seeders that populate initial data:
1. **Categories Seeder** - 7 technical topic categories
2. **Admin User Seeder** - 1 admin account for management
3. **Questions Seeder** - 35 demo questions (5 per category)
4. **Achievements Seeder** - 19 gamification achievements
## Running Seeders
### Seed all data
```bash
npm run seed
# or
npx sequelize-cli db:seed:all
```
### Undo all seeders
```bash
npm run seed:undo
# or
npx sequelize-cli db:seed:undo:all
```
### Reseed (undo + seed)
```bash
npm run seed:undo && npm run seed
```
## Seeded Data Details
### 1. Categories (7 total)
| Category | Slug | Guest Accessible | Display Order | Icon |
|----------|------|------------------|---------------|------|
| JavaScript | `javascript` | ✅ Yes | 1 | 🟨 |
| Angular | `angular` | ✅ Yes | 2 | 🅰️ |
| React | `react` | ✅ Yes | 3 | ⚛️ |
| Node.js | `nodejs` | ❌ Auth Required | 4 | 🟢 |
| TypeScript | `typescript` | ❌ Auth Required | 5 | 📘 |
| SQL & Databases | `sql-databases` | ❌ Auth Required | 6 | 🗄️ |
| System Design | `system-design` | ❌ Auth Required | 7 | 🏗️ |
**Guest vs. Auth:**
- **Guest-accessible** (3): JavaScript, Angular, React - Users can take quizzes without authentication
- **Auth-required** (4): Node.js, TypeScript, SQL & Databases, System Design - Must be logged in
### 2. Admin User (1 total)
**Credentials:**
- **Email:** `admin@quiz.com`
- **Password:** `Admin@123`
- **Username:** `admin`
- **Role:** `admin`
**Use Cases:**
- Test admin authentication
- Create/edit questions
- Manage categories
- View analytics
- Test admin-only features
### 3. Questions (35 total)
#### Distribution by Category:
- **JavaScript**: 5 questions
- **Angular**: 5 questions
- **React**: 5 questions
- **Node.js**: 5 questions
- **TypeScript**: 5 questions
- **SQL & Databases**: 5 questions
- **System Design**: 5 questions
#### By Difficulty:
- **Easy**: 15 questions (5 points, 60 seconds)
- **Medium**: 15 questions (10 points, 90 seconds)
- **Hard**: 5 questions (15 points, 120 seconds)
#### Question Types:
- **Multiple Choice**: All 35 questions
- **True/False**: 0 questions (can be added later)
- **Written**: 0 questions (can be added later)
#### Sample Questions:
**JavaScript:**
1. What is the difference between let and var? (Easy)
2. What is a closure in JavaScript? (Medium)
3. What does the spread operator (...) do? (Easy)
4. What is the purpose of Promise.all()? (Medium)
5. What is event delegation? (Medium)
**Angular:**
1. What is the purpose of NgModule? (Easy)
2. What is dependency injection? (Medium)
3. What is the difference between @Input() and @Output()? (Easy)
4. What is RxJS used for? (Medium)
5. What is the purpose of Angular lifecycle hooks? (Easy)
**React:**
1. What is the virtual DOM? (Easy)
2. What is the purpose of useEffect hook? (Easy)
3. What is prop drilling? (Medium)
4. What is the difference between useMemo and useCallback? (Medium)
5. What is React Context API used for? (Easy)
**Node.js:**
1. What is the event loop? (Medium)
2. What is middleware in Express.js? (Easy)
3. What is the purpose of package.json? (Easy)
4. What is the difference between process.nextTick() and setImmediate()? (Hard)
5. What is clustering in Node.js? (Medium)
**TypeScript:**
1. What is the difference between interface and type? (Medium)
2. What is a generic? (Medium)
3. What is the "never" type? (Hard)
4. What is type narrowing? (Medium)
5. What is the purpose of the "readonly" modifier? (Easy)
**SQL & Databases:**
1. What is the difference between INNER JOIN and LEFT JOIN? (Easy)
2. What is database normalization? (Medium)
3. What is an index in a database? (Easy)
4. What is a transaction in SQL? (Medium)
5. What does the GROUP BY clause do? (Easy)
**System Design:**
1. What is horizontal scaling vs vertical scaling? (Easy)
2. What is a load balancer? (Easy)
3. What is CAP theorem? (Medium)
4. What is caching and why is it used? (Easy)
5. What is a microservices architecture? (Medium)
### 4. Achievements (19 total)
#### By Category:
**Milestone (4):**
- 🎯 **First Steps** - Complete your very first quiz (10 pts)
- 📚 **Quiz Enthusiast** - Complete 10 quizzes (50 pts)
- 🏆 **Quiz Master** - Complete 50 quizzes (250 pts)
- 👑 **Quiz Legend** - Complete 100 quizzes (500 pts)
**Score (3):**
- 💯 **Perfect Score** - Achieve 100% on any quiz (100 pts)
-**Perfectionist** - Achieve 100% on 5 quizzes (300 pts)
- 🎓 **High Achiever** - Maintain 80% average across all quizzes (200 pts)
**Speed (2):**
-**Speed Demon** - Complete a quiz in under 2 minutes (75 pts)
- 🚀 **Lightning Fast** - Complete 10 quizzes in under 2 minutes each (200 pts)
**Streak (3):**
- 🔥 **On a Roll** - Maintain a 3-day streak (50 pts)
- 🔥🔥 **Week Warrior** - Maintain a 7-day streak (150 pts)
- 🔥🔥🔥 **Month Champion** - Maintain a 30-day streak (500 pts)
**Quiz (3):**
- 🗺️ **Explorer** - Complete quizzes in 3 different categories (100 pts)
- 🌟 **Jack of All Trades** - Complete quizzes in 5 different categories (200 pts)
- 🌈 **Master of All** - Complete quizzes in all 7 categories (400 pts)
**Special (4):**
- 🌅 **Early Bird** - Complete a quiz before 8 AM (50 pts)
- 🦉 **Night Owl** - Complete a quiz after 10 PM (50 pts)
- 🎉 **Weekend Warrior** - Complete 10 quizzes on weekends (100 pts)
- 💪 **Comeback King** - Score 90%+ after scoring below 50% (150 pts)
#### Achievement Requirements:
Achievement unlocking is tracked via the `requirement_type` field:
- `quizzes_completed` - Based on total quizzes completed
- `quizzes_passed` - Based on quizzes passed (e.g., 80% average)
- `perfect_score` - Based on number of 100% scores
- `streak_days` - Based on consecutive days streak
- `category_master` - Based on number of different categories completed
- `speed_demon` - Based on quiz completion time
- `early_bird` - Based on time of day (also used for Night Owl, Weekend Warrior, Comeback King)
## Verification
To verify seeded data, run:
```bash
node verify-seeded-data.js
```
This will output:
- Row counts for each table
- List of all categories
- Admin user credentials
- Questions count by category
- Achievements count by category
## Data Integrity
All seeded data maintains proper relationships:
1. **Questions → Categories**
- Each question has a valid `category_id` foreign key
- Category slugs are used to find category IDs during seeding
2. **Questions → Users**
- All questions have `created_by` set to admin user ID
- Admin user is seeded before questions
3. **Categories**
- Each has a unique slug for URL routing
- Display order ensures consistent sorting
4. **Achievements**
- All have valid category ENUM values
- All have valid requirement_type ENUM values
## Notes
- All timestamps are set to the same time during seeding for consistency
- All UUIDs are regenerated on each seed run
- Guest-accessible categories allow unauthenticated quiz taking
- Auth-required categories need user authentication
- Questions include explanations for learning purposes
- All questions are multiple-choice with 4 options
- Correct answers are stored as JSON arrays (supports multiple correct answers)
## Future Enhancements
Consider adding:
- More questions per category (currently 5)
- True/False question types
- Written answer question types
- Guest settings seeder
- Sample user accounts (non-admin)
- Quiz session history
- User achievement completions

View File

@@ -0,0 +1,316 @@
const request = require('supertest');
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const authRoutes = require('../routes/auth.routes');
const { User, GuestSession, QuizSession, sequelize } = require('../models');
// Create Express app for testing
const app = express();
app.use(express.json());
app.use('/api/auth', authRoutes);
describe('Authentication Endpoints', () => {
let testUser;
let authToken;
beforeAll(async () => {
// Sync database
await sequelize.sync({ force: true });
});
afterAll(async () => {
// Clean up
await User.destroy({ where: {}, force: true });
await sequelize.close();
});
describe('POST /api/auth/register', () => {
it('should register a new user successfully', async () => {
const userData = {
username: 'testuser',
email: 'test@example.com',
password: 'Test@123'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.message).toBe('User registered successfully');
expect(response.body.data).toHaveProperty('user');
expect(response.body.data).toHaveProperty('token');
expect(response.body.data.user.email).toBe(userData.email);
expect(response.body.data.user.username).toBe(userData.username);
expect(response.body.data.user).not.toHaveProperty('password');
testUser = response.body.data.user;
authToken = response.body.data.token;
});
it('should reject registration with duplicate email', async () => {
const userData = {
username: 'anotheruser',
email: 'test@example.com', // Same email
password: 'Test@123'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Email already registered');
});
it('should reject registration with duplicate username', async () => {
const userData = {
username: 'testuser', // Same username
email: 'another@example.com',
password: 'Test@123'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Username already taken');
});
it('should reject registration with invalid email', async () => {
const userData = {
username: 'newuser',
email: 'invalid-email',
password: 'Test@123'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Validation failed');
});
it('should reject registration with weak password', async () => {
const userData = {
username: 'newuser',
email: 'new@example.com',
password: 'weak'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Validation failed');
});
it('should reject registration with username too short', async () => {
const userData = {
username: 'ab', // Only 2 characters
email: 'new@example.com',
password: 'Test@123'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Validation failed');
});
it('should reject registration with invalid username characters', async () => {
const userData = {
username: 'test-user!', // Contains invalid characters
email: 'new@example.com',
password: 'Test@123'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Validation failed');
});
});
describe('POST /api/auth/register with guest migration', () => {
let guestSession;
beforeAll(async () => {
// Create a guest session with quiz data
guestSession = await GuestSession.create({
id: uuidv4(),
guest_id: `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
session_token: 'test-guest-token',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000),
max_quizzes: 3,
quizzes_attempted: 2,
is_converted: false
});
// Create quiz sessions for the guest
await QuizSession.create({
id: uuidv4(),
guest_session_id: guestSession.id,
category_id: uuidv4(),
quiz_type: 'practice',
difficulty: 'easy',
status: 'completed',
questions_count: 5,
questions_answered: 5,
correct_answers: 4,
score: 40,
percentage: 80,
is_passed: true,
started_at: new Date(),
completed_at: new Date()
});
});
it('should register user and migrate guest data', async () => {
const userData = {
username: 'guestconvert',
email: 'guestconvert@example.com',
password: 'Test@123',
guestSessionId: guestSession.guest_id
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('migratedData');
expect(response.body.data.migratedData).toHaveProperty('quizzes');
expect(response.body.data.migratedData).toHaveProperty('stats');
// Verify guest session is marked as converted
await guestSession.reload();
expect(guestSession.is_converted).toBe(true);
expect(guestSession.converted_user_id).toBe(response.body.data.user.id);
});
});
describe('POST /api/auth/login', () => {
it('should login with valid credentials', async () => {
const credentials = {
email: 'test@example.com',
password: 'Test@123'
};
const response = await request(app)
.post('/api/auth/login')
.send(credentials)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.message).toBe('Login successful');
expect(response.body.data).toHaveProperty('user');
expect(response.body.data).toHaveProperty('token');
expect(response.body.data.user).not.toHaveProperty('password');
});
it('should reject login with invalid email', async () => {
const credentials = {
email: 'nonexistent@example.com',
password: 'Test@123'
};
const response = await request(app)
.post('/api/auth/login')
.send(credentials)
.expect(401);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Invalid email or password');
});
it('should reject login with invalid password', async () => {
const credentials = {
email: 'test@example.com',
password: 'WrongPassword123'
};
const response = await request(app)
.post('/api/auth/login')
.send(credentials)
.expect(401);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Invalid email or password');
});
it('should reject login with missing fields', async () => {
const credentials = {
email: 'test@example.com'
// Missing password
};
const response = await request(app)
.post('/api/auth/login')
.send(credentials)
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Validation failed');
});
});
describe('GET /api/auth/verify', () => {
it('should verify valid token', async () => {
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.message).toBe('Token valid');
expect(response.body.data).toHaveProperty('user');
expect(response.body.data.user.email).toBe('test@example.com');
});
it('should reject request without token', async () => {
const response = await request(app)
.get('/api/auth/verify')
.expect(401);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('No token provided');
});
it('should reject request with invalid token', async () => {
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', 'Bearer invalid-token')
.expect(401);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('Invalid token');
});
});
describe('POST /api/auth/logout', () => {
it('should logout successfully', async () => {
const response = await request(app)
.post('/api/auth/logout')
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.message).toContain('Logout successful');
});
});
});

View File

@@ -0,0 +1,354 @@
/**
* Tests for Logout and Token Verification Endpoints
* Task 14: User Logout & Token Verification
*/
const request = require('supertest');
const app = require('../server');
const { User } = require('../models');
const jwt = require('jsonwebtoken');
const config = require('../config/config');
describe('POST /api/auth/logout', () => {
test('Should logout successfully (stateless JWT approach)', async () => {
const response = await request(app)
.post('/api/auth/logout')
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.message).toContain('Logout successful');
});
test('Should return success even without token (stateless approach)', async () => {
// In a stateless JWT system, logout is client-side only
const response = await request(app)
.post('/api/auth/logout')
.expect(200);
expect(response.body.success).toBe(true);
});
});
describe('GET /api/auth/verify', () => {
let testUser;
let validToken;
beforeAll(async () => {
// Create a test user
testUser = await User.create({
username: 'verifyuser',
email: 'verify@test.com',
password: 'Test@123',
role: 'user'
});
// Generate valid token
validToken = jwt.sign(
{
userId: testUser.id,
email: testUser.email,
username: testUser.username,
role: testUser.role
},
config.jwt.secret,
{ expiresIn: config.jwt.expire }
);
});
afterAll(async () => {
// Cleanup
if (testUser) {
await testUser.destroy({ force: true });
}
});
test('Should verify valid token and return user info', async () => {
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${validToken}`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.message).toBe('Token valid');
expect(response.body.data.user).toBeDefined();
expect(response.body.data.user.id).toBe(testUser.id);
expect(response.body.data.user.email).toBe(testUser.email);
expect(response.body.data.user.username).toBe(testUser.username);
// Password should not be included
expect(response.body.data.user.password).toBeUndefined();
});
test('Should reject request without token', async () => {
const response = await request(app)
.get('/api/auth/verify')
.expect(401);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('No token provided');
});
test('Should reject invalid token', async () => {
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', 'Bearer invalid_token_here')
.expect(401);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('Invalid token');
});
test('Should reject expired token', async () => {
// Create an expired token
const expiredToken = jwt.sign(
{
userId: testUser.id,
email: testUser.email,
username: testUser.username,
role: testUser.role
},
config.jwt.secret,
{ expiresIn: '0s' } // Immediately expired
);
// Wait a moment to ensure expiration
await new Promise(resolve => setTimeout(resolve, 1000));
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('expired');
});
test('Should reject token with invalid format (no Bearer prefix)', async () => {
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', validToken) // Missing "Bearer " prefix
.expect(401);
expect(response.body.success).toBe(false);
});
test('Should reject token for inactive user', async () => {
// Deactivate the user
await testUser.update({ is_active: false });
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${validToken}`)
.expect(404);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('not found or inactive');
// Reactivate for cleanup
await testUser.update({ is_active: true });
});
test('Should reject token for non-existent user', async () => {
// Create token with non-existent user ID
const fakeToken = jwt.sign(
{
userId: '00000000-0000-0000-0000-000000000000',
email: 'fake@test.com',
username: 'fakeuser',
role: 'user'
},
config.jwt.secret,
{ expiresIn: config.jwt.expire }
);
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${fakeToken}`)
.expect(404);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('User not found');
});
test('Should handle malformed Authorization header', async () => {
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', 'InvalidFormat')
.expect(401);
expect(response.body.success).toBe(false);
});
});
describe('Token Verification Integration Tests', () => {
let registeredUser;
let userToken;
beforeAll(async () => {
// Register a new user
const registerResponse = await request(app)
.post('/api/auth/register')
.send({
username: `integrationuser_${Date.now()}`,
email: `integration_${Date.now()}@test.com`,
password: 'Test@123'
})
.expect(201);
registeredUser = registerResponse.body.data.user;
userToken = registerResponse.body.data.token;
});
afterAll(async () => {
// Cleanup
if (registeredUser && registeredUser.id) {
const user = await User.findByPk(registeredUser.id);
if (user) {
await user.destroy({ force: true });
}
}
});
test('Should verify token immediately after registration', async () => {
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data.user.id).toBe(registeredUser.id);
});
test('Should verify token after login', async () => {
// Login with the registered user
const loginResponse = await request(app)
.post('/api/auth/login')
.send({
email: registeredUser.email,
password: 'Test@123'
})
.expect(200);
const loginToken = loginResponse.body.data.token;
// Verify the login token
const verifyResponse = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${loginToken}`)
.expect(200);
expect(verifyResponse.body.success).toBe(true);
expect(verifyResponse.body.data.user.id).toBe(registeredUser.id);
});
test('Should complete full auth flow: register -> verify -> logout', async () => {
// 1. Register
const registerResponse = await request(app)
.post('/api/auth/register')
.send({
username: `flowuser_${Date.now()}`,
email: `flow_${Date.now()}@test.com`,
password: 'Test@123'
})
.expect(201);
const token = registerResponse.body.data.token;
const userId = registerResponse.body.data.user.id;
// 2. Verify token
const verifyResponse = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(verifyResponse.body.success).toBe(true);
// 3. Logout
const logoutResponse = await request(app)
.post('/api/auth/logout')
.expect(200);
expect(logoutResponse.body.success).toBe(true);
// 4. Token should still be valid (stateless JWT)
// In a real app, client would delete the token
const verifyAfterLogout = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(verifyAfterLogout.body.success).toBe(true);
// Cleanup
const user = await User.findByPk(userId);
if (user) {
await user.destroy({ force: true });
}
});
});
describe('Token Security Tests', () => {
test('Should reject token signed with wrong secret', async () => {
const fakeToken = jwt.sign(
{
userId: '12345',
email: 'fake@test.com',
username: 'fakeuser',
role: 'user'
},
'wrong_secret_key',
{ expiresIn: '24h' }
);
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${fakeToken}`)
.expect(401);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('Invalid token');
});
test('Should reject tampered token', async () => {
// Create a valid token
const validToken = jwt.sign(
{
userId: '12345',
email: 'test@test.com',
username: 'testuser',
role: 'user'
},
config.jwt.secret,
{ expiresIn: '24h' }
);
// Tamper with the token by changing a character
const tamperedToken = validToken.slice(0, -5) + 'XXXXX';
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${tamperedToken}`)
.expect(401);
expect(response.body.success).toBe(false);
});
test('Should reject token with missing payload fields', async () => {
// Create token with incomplete payload
const incompleteToken = jwt.sign(
{
userId: '12345'
// Missing email, username, role
},
config.jwt.secret,
{ expiresIn: '24h' }
);
const response = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${incompleteToken}`)
.expect(404);
// Token is valid but user doesn't exist
expect(response.body.success).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
const { Category } = require('./models');
async function checkCategories() {
const allActive = await Category.findAll({
where: { isActive: true },
order: [['displayOrder', 'ASC']]
});
console.log(`\nTotal active categories: ${allActive.length}\n`);
allActive.forEach(cat => {
console.log(`${cat.displayOrder}. ${cat.name}`);
console.log(` Guest Accessible: ${cat.guestAccessible}`);
console.log(` Question Count: ${cat.questionCount}\n`);
});
const guestOnly = allActive.filter(c => c.guestAccessible);
const authOnly = allActive.filter(c => !c.guestAccessible);
console.log(`Guest-accessible: ${guestOnly.length}`);
console.log(`Auth-only: ${authOnly.length}`);
process.exit(0);
}
checkCategories();

View File

@@ -0,0 +1,38 @@
const { Category } = require('./models');
async function checkCategoryIds() {
try {
console.log('\n=== Checking Category IDs ===\n');
const categories = await Category.findAll({
attributes: ['id', 'name', 'isActive', 'guestAccessible'],
limit: 10
});
console.log(`Found ${categories.length} categories:\n`);
categories.forEach(cat => {
console.log(`ID: ${cat.id} (${typeof cat.id})`);
console.log(` Name: ${cat.name}`);
console.log(` isActive: ${cat.isActive}`);
console.log(` guestAccessible: ${cat.guestAccessible}`);
console.log('');
});
// Try to find one by PK
if (categories.length > 0) {
const firstId = categories[0].id;
console.log(`\nTrying findByPk with ID: ${firstId} (${typeof firstId})\n`);
const found = await Category.findByPk(firstId);
console.log('findByPk result:', found ? found.name : 'NOT FOUND');
}
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
}
checkCategoryIds();

View File

@@ -0,0 +1,38 @@
const { Question, Category } = require('./models');
async function checkQuestions() {
try {
const questions = await Question.findAll({
where: { isActive: true },
include: [{
model: Category,
as: 'category',
attributes: ['name']
}],
attributes: ['id', 'questionText', 'categoryId', 'difficulty'],
limit: 10
});
console.log(`\nTotal active questions: ${questions.length}\n`);
if (questions.length === 0) {
console.log('❌ No questions found in database!');
console.log('\nYou need to run the questions seeder:');
console.log(' npm run seed');
console.log('\nOr specifically:');
console.log(' npx sequelize-cli db:seed --seed 20241109215000-demo-questions.js');
} else {
questions.forEach((q, idx) => {
console.log(`${idx + 1}. ${q.questionText.substring(0, 60)}...`);
console.log(` Category: ${q.category?.name || 'N/A'} | Difficulty: ${q.difficulty}`);
});
}
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
}
checkQuestions();

113
backend/config/config.js Normal file
View File

@@ -0,0 +1,113 @@
require('dotenv').config();
/**
* Application Configuration
* Centralized configuration management for all environment variables
*/
const config = {
// Server Configuration
server: {
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT) || 3000,
apiPrefix: process.env.API_PREFIX || '/api',
isDevelopment: (process.env.NODE_ENV || 'development') === 'development',
isProduction: process.env.NODE_ENV === 'production',
isTest: process.env.NODE_ENV === 'test'
},
// Database Configuration
database: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 3306,
name: process.env.DB_NAME || 'interview_quiz_db',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
dialect: process.env.DB_DIALECT || 'mysql',
pool: {
max: parseInt(process.env.DB_POOL_MAX) || 10,
min: parseInt(process.env.DB_POOL_MIN) || 0,
acquire: parseInt(process.env.DB_POOL_ACQUIRE) || 30000,
idle: parseInt(process.env.DB_POOL_IDLE) || 10000
}
},
// JWT Configuration
jwt: {
secret: process.env.JWT_SECRET,
expire: process.env.JWT_EXPIRE || '24h',
algorithm: 'HS256'
},
// Rate Limiting Configuration
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 900000, // 15 minutes
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
message: 'Too many requests from this IP, please try again later.'
},
// CORS Configuration
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:4200',
credentials: true
},
// Guest Session Configuration
guest: {
sessionExpireHours: parseInt(process.env.GUEST_SESSION_EXPIRE_HOURS) || 24,
maxQuizzes: parseInt(process.env.GUEST_MAX_QUIZZES) || 3
},
// Logging Configuration
logging: {
level: process.env.LOG_LEVEL || 'info'
},
// Pagination Defaults
pagination: {
defaultLimit: 10,
maxLimit: 100
},
// Security Configuration
security: {
bcryptRounds: 10,
maxLoginAttempts: 5,
lockoutDuration: 15 * 60 * 1000 // 15 minutes
}
};
/**
* Validate critical configuration values
*/
function validateConfig() {
const errors = [];
if (!config.jwt.secret) {
errors.push('JWT_SECRET is not configured');
}
if (!config.database.name) {
errors.push('DB_NAME is not configured');
}
if (config.server.isProduction && !config.database.password) {
errors.push('DB_PASSWORD is required in production');
}
if (errors.length > 0) {
throw new Error(`Configuration errors:\n - ${errors.join('\n - ')}`);
}
return true;
}
// Validate on module load
try {
validateConfig();
} catch (error) {
console.error('❌ Configuration Error:', error.message);
process.exit(1);
}
module.exports = config;

View File

@@ -0,0 +1,76 @@
require('dotenv').config();
module.exports = {
development: {
username: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'interview_quiz_db',
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 3306,
dialect: process.env.DB_DIALECT || 'mysql',
logging: console.log,
pool: {
max: parseInt(process.env.DB_POOL_MAX) || 10,
min: parseInt(process.env.DB_POOL_MIN) || 0,
acquire: parseInt(process.env.DB_POOL_ACQUIRE) || 30000,
idle: parseInt(process.env.DB_POOL_IDLE) || 10000
},
define: {
timestamps: true,
underscored: true,
freezeTableName: false,
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci'
}
},
test: {
username: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME + '_test' || 'interview_quiz_db_test',
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 3306,
dialect: process.env.DB_DIALECT || 'mysql',
logging: false,
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
},
define: {
timestamps: true,
underscored: true,
freezeTableName: false,
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci'
}
},
production: {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
port: process.env.DB_PORT || 3306,
dialect: process.env.DB_DIALECT || 'mysql',
logging: false,
pool: {
max: parseInt(process.env.DB_POOL_MAX) || 20,
min: parseInt(process.env.DB_POOL_MIN) || 5,
acquire: parseInt(process.env.DB_POOL_ACQUIRE) || 30000,
idle: parseInt(process.env.DB_POOL_IDLE) || 10000
},
define: {
timestamps: true,
underscored: true,
freezeTableName: false,
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci'
},
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false
}
}
}
};

74
backend/config/db.js Normal file
View File

@@ -0,0 +1,74 @@
const db = require('../models');
/**
* Test database connection
*/
async function testConnection() {
try {
await db.sequelize.authenticate();
console.log('✅ Database connection verified');
return true;
} catch (error) {
console.error('❌ Database connection failed:', error.message);
return false;
}
}
/**
* Sync all models with database
* WARNING: Use with caution in production
*/
async function syncModels(options = {}) {
try {
await db.sequelize.sync(options);
console.log('✅ Models synchronized with database');
return true;
} catch (error) {
console.error('❌ Model synchronization failed:', error.message);
return false;
}
}
/**
* Close database connection
*/
async function closeConnection() {
try {
await db.sequelize.close();
console.log('✅ Database connection closed');
return true;
} catch (error) {
console.error('❌ Failed to close database connection:', error.message);
return false;
}
}
/**
* Get database statistics
*/
async function getDatabaseStats() {
try {
const [tables] = await db.sequelize.query('SHOW TABLES');
const [version] = await db.sequelize.query('SELECT VERSION() as version');
return {
connected: true,
version: version[0].version,
tables: tables.length,
database: db.sequelize.config.database
};
} catch (error) {
return {
connected: false,
error: error.message
};
}
}
module.exports = {
db,
testConnection,
syncModels,
closeConnection,
getDatabaseStats
};

View File

@@ -0,0 +1,288 @@
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const { User, GuestSession, QuizSession, sequelize } = require('../models');
const config = require('../config/config');
/**
* @desc Register a new user
* @route POST /api/auth/register
* @access Public
*/
exports.register = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { username, email, password, guestSessionId } = req.body;
// Check if user already exists
const existingUser = await User.findOne({
where: {
[sequelize.Sequelize.Op.or]: [
{ email: email.toLowerCase() },
{ username: username.toLowerCase() }
]
}
});
if (existingUser) {
await transaction.rollback();
if (existingUser.email === email.toLowerCase()) {
return res.status(400).json({
success: false,
message: 'Email already registered'
});
} else {
return res.status(400).json({
success: false,
message: 'Username already taken'
});
}
}
// Create new user (password will be hashed by beforeCreate hook)
const user = await User.create({
id: uuidv4(),
username: username.toLowerCase(),
email: email.toLowerCase(),
password: password,
role: 'user',
is_active: true
}, { transaction });
// Handle guest session migration if provided
let migratedData = null;
if (guestSessionId) {
try {
const guestSession = await GuestSession.findOne({
where: { guest_id: guestSessionId }
});
if (guestSession && !guestSession.is_converted) {
// Migrate quiz sessions from guest to user
const migratedSessions = await QuizSession.update(
{
user_id: user.id,
guest_session_id: null
},
{
where: { guest_session_id: guestSession.id },
transaction
}
);
// Mark guest session as converted
await guestSession.update({
is_converted: true,
converted_user_id: user.id,
converted_at: new Date()
}, { transaction });
// Recalculate user stats from migrated sessions
const quizSessions = await QuizSession.findAll({
where: {
user_id: user.id,
status: 'completed'
},
transaction
});
let totalQuizzes = quizSessions.length;
let quizzesPassed = 0;
let totalQuestionsAnswered = 0;
let correctAnswers = 0;
quizSessions.forEach(session => {
if (session.is_passed) quizzesPassed++;
totalQuestionsAnswered += session.questions_answered || 0;
correctAnswers += session.correct_answers || 0;
});
// Update user stats
await user.update({
total_quizzes: totalQuizzes,
quizzes_passed: quizzesPassed,
total_questions_answered: totalQuestionsAnswered,
correct_answers: correctAnswers
}, { transaction });
migratedData = {
quizzes: migratedSessions[0],
stats: {
totalQuizzes,
quizzesPassed,
accuracy: totalQuestionsAnswered > 0
? ((correctAnswers / totalQuestionsAnswered) * 100).toFixed(2)
: 0
}
};
}
} catch (guestError) {
// Log error but don't fail registration
console.error('Guest migration error:', guestError.message);
// Continue with registration even if migration fails
}
}
// Commit transaction before generating JWT
await transaction.commit();
// Generate JWT token (after commit to avoid rollback issues)
const token = jwt.sign(
{
userId: user.id,
email: user.email,
username: user.username,
role: user.role
},
config.jwt.secret,
{ expiresIn: config.jwt.expire }
);
// Return user data (exclude password)
const userData = user.toSafeJSON();
res.status(201).json({
success: true,
message: 'User registered successfully',
data: {
user: userData,
token,
migratedData
}
});
} catch (error) {
// Only rollback if transaction is still active
if (!transaction.finished) {
await transaction.rollback();
}
console.error('Registration error:', error);
res.status(500).json({
success: false,
message: 'Error registering user',
error: error.message
});
}
};
/**
* @desc Login user
* @route POST /api/auth/login
* @access Public
*/
exports.login = async (req, res) => {
try {
const { email, password } = req.body;
// Find user by email
const user = await User.findOne({
where: {
email: email.toLowerCase(),
is_active: true
}
});
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid email or password'
});
}
// Verify password
const isPasswordValid = await user.comparePassword(password);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
message: 'Invalid email or password'
});
}
// Update last_login
await user.update({ last_login: new Date() });
// Generate JWT token
const token = jwt.sign(
{
userId: user.id,
email: user.email,
username: user.username,
role: user.role
},
config.jwt.secret,
{ expiresIn: config.jwt.expire }
);
// Return user data (exclude password)
const userData = user.toSafeJSON();
res.status(200).json({
success: true,
message: 'Login successful',
data: {
user: userData,
token
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
message: 'Error logging in',
error: error.message
});
}
};
/**
* @desc Logout user (client-side token removal)
* @route POST /api/auth/logout
* @access Public
*/
exports.logout = async (req, res) => {
// Since we're using JWT (stateless), logout is handled client-side
// by removing the token from storage
res.status(200).json({
success: true,
message: 'Logout successful. Please remove token from client storage.'
});
};
/**
* @desc Verify JWT token and return user info
* @route GET /api/auth/verify
* @access Private
*/
exports.verifyToken = async (req, res) => {
try {
// User is already attached to req by verifyToken middleware
const user = await User.findByPk(req.user.userId);
if (!user || !user.isActive) {
return res.status(404).json({
success: false,
message: 'User not found or inactive'
});
}
// Return user data (exclude password)
const userData = user.toSafeJSON();
res.status(200).json({
success: true,
message: 'Token valid',
data: {
user: userData
}
});
} catch (error) {
console.error('Token verification error:', error);
res.status(500).json({
success: false,
message: 'Error verifying token',
error: error.message
});
}
};

View File

@@ -0,0 +1,481 @@
const { Category, Question } = require('../models');
/**
* @desc Get all active categories
* @route GET /api/categories
* @access Public
*/
exports.getAllCategories = async (req, res) => {
try {
// Check if request is from guest or authenticated user
const isGuest = !req.user; // If no user attached, it's a guest/public request
// Build query conditions
const whereConditions = {
isActive: true
};
// If guest, only show guest-accessible categories
if (isGuest) {
whereConditions.guestAccessible = true;
}
// Fetch categories
const categories = await Category.findAll({
where: whereConditions,
attributes: [
'id',
'name',
'slug',
'description',
'icon',
'color',
'questionCount',
'displayOrder',
'guestAccessible'
],
order: [
['displayOrder', 'ASC'],
['name', 'ASC']
]
});
res.status(200).json({
success: true,
count: categories.length,
data: categories,
message: isGuest
? `${categories.length} guest-accessible categories available`
: `${categories.length} categories available`
});
} catch (error) {
console.error('Error fetching categories:', error);
res.status(500).json({
success: false,
message: 'Error fetching categories',
error: error.message
});
}
};
/**
* @desc Get category details by ID
* @route GET /api/categories/:id
* @access Public (with optional auth for access control)
*/
exports.getCategoryById = async (req, res) => {
try {
const { id } = req.params;
const isGuest = !req.user;
// Validate ID (accepts UUID or numeric)
if (!id) {
return res.status(400).json({
success: false,
message: 'Invalid category ID'
});
}
// UUID format validation (basic check)
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
const isNumeric = !isNaN(id) && Number.isInteger(Number(id));
if (!isUUID && !isNumeric) {
return res.status(400).json({
success: false,
message: 'Invalid category ID format'
});
}
// Find category
const category = await Category.findByPk(id, {
attributes: [
'id',
'name',
'slug',
'description',
'icon',
'color',
'questionCount',
'displayOrder',
'guestAccessible',
'isActive'
]
});
// Check if category exists
if (!category) {
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
// Check if category is active
if (!category.isActive) {
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
// Check guest access
if (isGuest && !category.guestAccessible) {
return res.status(403).json({
success: false,
message: 'This category requires authentication. Please register or login to access.',
requiresAuth: true
});
}
// Get question preview (first 5 questions)
const questionPreview = await Question.findAll({
where: {
categoryId: id,
isActive: true
},
attributes: [
'id',
'questionText',
'questionType',
'difficulty',
'points',
'timesAttempted',
'timesCorrect'
],
order: [['createdAt', 'ASC']],
limit: 5
});
// Calculate category stats
const allQuestions = await Question.findAll({
where: {
categoryId: id,
isActive: true
},
attributes: ['difficulty', 'timesAttempted', 'timesCorrect']
});
const stats = {
totalQuestions: allQuestions.length,
questionsByDifficulty: {
easy: allQuestions.filter(q => q.difficulty === 'easy').length,
medium: allQuestions.filter(q => q.difficulty === 'medium').length,
hard: allQuestions.filter(q => q.difficulty === 'hard').length
},
totalAttempts: allQuestions.reduce((sum, q) => sum + (q.timesAttempted || 0), 0),
totalCorrect: allQuestions.reduce((sum, q) => sum + (q.timesCorrect || 0), 0)
};
// Calculate average accuracy
stats.averageAccuracy = stats.totalAttempts > 0
? Math.round((stats.totalCorrect / stats.totalAttempts) * 100)
: 0;
// Prepare response
const categoryData = {
id: category.id,
name: category.name,
slug: category.slug,
description: category.description,
icon: category.icon,
color: category.color,
questionCount: category.questionCount,
displayOrder: category.displayOrder,
guestAccessible: category.guestAccessible
};
res.status(200).json({
success: true,
data: {
category: categoryData,
questionPreview: questionPreview.map(q => ({
id: q.id,
questionText: q.questionText,
questionType: q.questionType,
difficulty: q.difficulty,
points: q.points,
accuracy: q.timesAttempted > 0
? Math.round((q.timesCorrect / q.timesAttempted) * 100)
: 0
})),
stats
},
message: `Category details retrieved successfully`
});
} catch (error) {
console.error('Error fetching category details:', error);
res.status(500).json({
success: false,
message: 'Error fetching category details',
error: error.message
});
}
};
/**
* @desc Create new category (Admin only)
* @route POST /api/categories
* @access Private/Admin
*/
exports.createCategory = async (req, res) => {
try {
const {
name,
slug,
description,
icon,
color,
guestAccessible,
displayOrder
} = req.body;
// Validate required fields
if (!name) {
return res.status(400).json({
success: false,
message: 'Category name is required'
});
}
// Check if category with same name exists
const existingByName = await Category.findOne({
where: { name }
});
if (existingByName) {
return res.status(400).json({
success: false,
message: 'A category with this name already exists'
});
}
// Check if custom slug provided and if it exists
if (slug) {
const existingBySlug = await Category.findOne({
where: { slug }
});
if (existingBySlug) {
return res.status(400).json({
success: false,
message: 'A category with this slug already exists'
});
}
}
// Create category (slug will be auto-generated by model hook if not provided)
const category = await Category.create({
name,
slug,
description: description || null,
icon: icon || null,
color: color || '#3B82F6',
guestAccessible: guestAccessible !== undefined ? guestAccessible : false,
displayOrder: displayOrder || 0,
isActive: true,
questionCount: 0,
quizCount: 0
});
res.status(201).json({
success: true,
data: {
id: category.id,
name: category.name,
slug: category.slug,
description: category.description,
icon: category.icon,
color: category.color,
guestAccessible: category.guestAccessible,
displayOrder: category.displayOrder,
questionCount: category.questionCount,
isActive: category.isActive
},
message: 'Category created successfully'
});
} catch (error) {
console.error('Error creating category:', error);
res.status(500).json({
success: false,
message: 'Error creating category',
error: error.message
});
}
};
/**
* @desc Update category (Admin only)
* @route PUT /api/categories/:id
* @access Private/Admin
*/
exports.updateCategory = async (req, res) => {
try {
const { id } = req.params;
const {
name,
slug,
description,
icon,
color,
guestAccessible,
displayOrder,
isActive
} = req.body;
// Validate ID
if (!id) {
return res.status(400).json({
success: false,
message: 'Category ID is required'
});
}
// Find category
const category = await Category.findByPk(id);
if (!category) {
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
// Check if new name conflicts with existing category
if (name && name !== category.name) {
const existingByName = await Category.findOne({
where: { name }
});
if (existingByName) {
return res.status(400).json({
success: false,
message: 'A category with this name already exists'
});
}
}
// Check if new slug conflicts with existing category
if (slug && slug !== category.slug) {
const existingBySlug = await Category.findOne({
where: { slug }
});
if (existingBySlug) {
return res.status(400).json({
success: false,
message: 'A category with this slug already exists'
});
}
}
// Update category
const updateData = {};
if (name !== undefined) updateData.name = name;
if (slug !== undefined) updateData.slug = slug;
if (description !== undefined) updateData.description = description;
if (icon !== undefined) updateData.icon = icon;
if (color !== undefined) updateData.color = color;
if (guestAccessible !== undefined) updateData.guestAccessible = guestAccessible;
if (displayOrder !== undefined) updateData.displayOrder = displayOrder;
if (isActive !== undefined) updateData.isActive = isActive;
await category.update(updateData);
res.status(200).json({
success: true,
data: {
id: category.id,
name: category.name,
slug: category.slug,
description: category.description,
icon: category.icon,
color: category.color,
guestAccessible: category.guestAccessible,
displayOrder: category.displayOrder,
questionCount: category.questionCount,
isActive: category.isActive
},
message: 'Category updated successfully'
});
} catch (error) {
console.error('Error updating category:', error);
res.status(500).json({
success: false,
message: 'Error updating category',
error: error.message
});
}
};
/**
* @desc Delete category (soft delete - Admin only)
* @route DELETE /api/categories/:id
* @access Private/Admin
*/
exports.deleteCategory = async (req, res) => {
try {
const { id } = req.params;
// Validate ID
if (!id) {
return res.status(400).json({
success: false,
message: 'Category ID is required'
});
}
// Find category
const category = await Category.findByPk(id);
if (!category) {
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
// Check if already deleted
if (!category.isActive) {
return res.status(400).json({
success: false,
message: 'Category is already deleted'
});
}
// Check if category has questions
const questionCount = await Question.count({
where: {
categoryId: id,
isActive: true
}
});
// Soft delete - set isActive to false
await category.update({ isActive: false });
res.status(200).json({
success: true,
data: {
id: category.id,
name: category.name,
questionCount: questionCount
},
message: questionCount > 0
? `Category deleted successfully. ${questionCount} questions are still associated with this category.`
: 'Category deleted successfully'
});
} catch (error) {
console.error('Error deleting category:', error);
res.status(500).json({
success: false,
message: 'Error deleting category',
error: error.message
});
}
};

View File

@@ -0,0 +1,447 @@
const { GuestSession, Category, User, QuizSession, sequelize } = require('../models');
const { v4: uuidv4 } = require('uuid');
const jwt = require('jsonwebtoken');
const config = require('../config/config');
/**
* @desc Start a new guest session
* @route POST /api/guest/start-session
* @access Public
*/
exports.startGuestSession = async (req, res) => {
try {
const { deviceId } = req.body;
// Get IP address
const ipAddress = req.ip || req.connection.remoteAddress || 'unknown';
// Get user agent
const userAgent = req.headers['user-agent'] || 'unknown';
// Generate unique guest_id
const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 10);
const guestId = `guest_${timestamp}_${randomString}`;
// Calculate expiry (24 hours from now by default)
const expiryHours = parseInt(config.guest.sessionExpireHours) || 24;
const expiresAt = new Date(Date.now() + expiryHours * 60 * 60 * 1000);
const maxQuizzes = parseInt(config.guest.maxQuizzes) || 3;
// Generate session token (JWT) before creating session
const sessionToken = jwt.sign(
{ guestId },
config.jwt.secret,
{ expiresIn: `${expiryHours}h` }
);
// Create guest session
const guestSession = await GuestSession.create({
guestId: guestId,
sessionToken: sessionToken,
deviceId: deviceId || null,
ipAddress: ipAddress,
userAgent: userAgent,
expiresAt: expiresAt,
maxQuizzes: maxQuizzes,
quizzesAttempted: 0,
isConverted: false
});
// Get guest-accessible categories
const categories = await Category.findAll({
where: {
isActive: true,
guestAccessible: true
},
attributes: ['id', 'name', 'slug', 'description', 'icon', 'color', 'questionCount'],
order: [['displayOrder', 'ASC'], ['name', 'ASC']]
});
// Return response
res.status(201).json({
success: true,
message: 'Guest session created successfully',
data: {
guestId: guestSession.guestId,
sessionToken,
expiresAt: guestSession.expiresAt,
expiresIn: `${expiryHours} hours`,
restrictions: {
maxQuizzes: guestSession.maxQuizzes,
quizzesRemaining: guestSession.maxQuizzes - guestSession.quizzesAttempted,
features: {
canTakeQuizzes: true,
canViewResults: true,
canBookmarkQuestions: false,
canTrackProgress: false,
canEarnAchievements: false
}
},
availableCategories: categories,
upgradeMessage: 'Sign up to unlock unlimited quizzes, track progress, and earn achievements!'
}
});
} catch (error) {
console.error('Error creating guest session:', error);
res.status(500).json({
success: false,
message: 'Error creating guest session',
error: error.message
});
}
};
/**
* @desc Get guest session details
* @route GET /api/guest/session/:guestId
* @access Public
*/
exports.getGuestSession = async (req, res) => {
try {
const { guestId } = req.params;
// Find guest session
const guestSession = await GuestSession.findOne({
where: { guestId: guestId }
});
if (!guestSession) {
return res.status(404).json({
success: false,
message: 'Guest session not found'
});
}
// Check if session is expired
if (guestSession.isExpired()) {
return res.status(410).json({
success: false,
message: 'Guest session has expired. Please start a new session.',
expired: true
});
}
// Check if session is converted
if (guestSession.isConverted) {
return res.status(410).json({
success: false,
message: 'This guest session has been converted to a user account',
converted: true,
userId: guestSession.convertedUserId
});
}
// Get guest-accessible categories
const categories = await Category.findAll({
where: {
isActive: true,
guestAccessible: true
},
attributes: ['id', 'name', 'slug', 'description', 'icon', 'color', 'questionCount'],
order: [['displayOrder', 'ASC'], ['name', 'ASC']]
});
// Calculate time until expiry
const now = new Date();
const expiresAt = new Date(guestSession.expiresAt);
const hoursRemaining = Math.max(0, Math.floor((expiresAt - now) / (1000 * 60 * 60)));
const minutesRemaining = Math.max(0, Math.floor(((expiresAt - now) % (1000 * 60 * 60)) / (1000 * 60)));
// Return session details
res.status(200).json({
success: true,
data: {
guestId: guestSession.guestId,
expiresAt: guestSession.expiresAt,
expiresIn: `${hoursRemaining}h ${minutesRemaining}m`,
isExpired: false,
restrictions: {
maxQuizzes: guestSession.maxQuizzes,
quizzesAttempted: guestSession.quizzesAttempted,
quizzesRemaining: Math.max(0, guestSession.maxQuizzes - guestSession.quizzesAttempted),
features: {
canTakeQuizzes: guestSession.quizzesAttempted < guestSession.maxQuizzes,
canViewResults: true,
canBookmarkQuestions: false,
canTrackProgress: false,
canEarnAchievements: false
}
},
availableCategories: categories,
upgradeMessage: 'Sign up to unlock unlimited quizzes, track progress, and earn achievements!'
}
});
} catch (error) {
console.error('Error getting guest session:', error);
res.status(500).json({
success: false,
message: 'Error retrieving guest session',
error: error.message
});
}
};
/**
* @desc Check guest quiz limit
* @route GET /api/guest/quiz-limit
* @access Protected (Guest Token Required)
*/
exports.checkQuizLimit = async (req, res) => {
try {
// Guest session is already verified and attached by middleware
const guestSession = req.guestSession;
// Calculate remaining quizzes
const quizzesRemaining = guestSession.maxQuizzes - guestSession.quizzesAttempted;
const hasReachedLimit = quizzesRemaining <= 0;
// Calculate time until reset (session expiry)
const now = new Date();
const expiresAt = new Date(guestSession.expiresAt);
const timeRemainingMs = expiresAt - now;
const hoursRemaining = Math.floor(timeRemainingMs / (1000 * 60 * 60));
const minutesRemaining = Math.floor((timeRemainingMs % (1000 * 60 * 60)) / (1000 * 60));
// Format reset time
let resetTime;
if (hoursRemaining > 0) {
resetTime = `${hoursRemaining}h ${minutesRemaining}m`;
} else {
resetTime = `${minutesRemaining}m`;
}
// Prepare response
const response = {
success: true,
data: {
guestId: guestSession.guestId,
quizLimit: {
maxQuizzes: guestSession.maxQuizzes,
quizzesAttempted: guestSession.quizzesAttempted,
quizzesRemaining: Math.max(0, quizzesRemaining),
hasReachedLimit: hasReachedLimit
},
session: {
expiresAt: guestSession.expiresAt,
timeRemaining: resetTime,
resetTime: resetTime
}
}
};
// Add upgrade prompt if limit reached
if (hasReachedLimit) {
response.data.upgradePrompt = {
message: 'You have reached your quiz limit!',
benefits: [
'Unlimited quizzes',
'Track your progress over time',
'Earn achievements and badges',
'Bookmark questions for review',
'Compete on leaderboards'
],
callToAction: 'Sign up now to continue learning!'
};
response.message = 'Quiz limit reached. Sign up to continue!';
} else {
response.message = `You have ${quizzesRemaining} quiz${quizzesRemaining === 1 ? '' : 'zes'} remaining`;
}
res.status(200).json(response);
} catch (error) {
console.error('Error checking quiz limit:', error);
res.status(500).json({
success: false,
message: 'Error checking quiz limit',
error: error.message
});
}
};
/**
* @desc Convert guest session to registered user account
* @route POST /api/guest/convert
* @access Protected (Guest Token Required)
*/
exports.convertGuestToUser = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { username, email, password } = req.body;
const guestSession = req.guestSession; // Attached by middleware
// Validate required fields
if (!username || !email || !password) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Username, email, and password are required'
});
}
// Validate username length
if (username.length < 3 || username.length > 50) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Username must be between 3 and 50 characters'
});
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Invalid email format'
});
}
// Validate password strength
if (password.length < 8) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Password must be at least 8 characters long'
});
}
// Check if email already exists
const existingEmail = await User.findOne({
where: { email: email.toLowerCase() },
transaction
});
if (existingEmail) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Email already registered'
});
}
// Check if username already exists
const existingUsername = await User.findOne({
where: { username },
transaction
});
if (existingUsername) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Username already taken'
});
}
// Create new user account (password will be hashed by User model hook)
const user = await User.create({
username,
email: email.toLowerCase(),
password,
role: 'user'
}, { transaction });
// Migrate quiz sessions from guest to user
const migratedSessions = await QuizSession.update(
{
userId: user.id,
guestSessionId: null
},
{
where: { guestSessionId: guestSession.id },
transaction
}
);
// Mark guest session as converted
await guestSession.update({
isConverted: true,
convertedUserId: user.id
}, { transaction });
// Recalculate user stats from migrated sessions
const quizSessions = await QuizSession.findAll({
where: {
userId: user.id,
status: 'completed'
},
transaction
});
let totalQuizzes = quizSessions.length;
let quizzesPassed = 0;
let totalQuestionsAnswered = 0;
let correctAnswers = 0;
quizSessions.forEach(session => {
if (session.isPassed) quizzesPassed++;
totalQuestionsAnswered += session.questionsAnswered || 0;
correctAnswers += session.correctAnswers || 0;
});
// Update user stats
await user.update({
totalQuizzes,
quizzesPassed,
totalQuestionsAnswered,
correctAnswers
}, { transaction });
// Commit transaction
await transaction.commit();
// Generate JWT token for the new user
const token = jwt.sign(
{
userId: user.id,
email: user.email,
username: user.username,
role: user.role
},
config.jwt.secret,
{ expiresIn: config.jwt.expire }
);
// Return response
res.status(201).json({
success: true,
message: 'Guest account successfully converted to registered user',
data: {
user: user.toSafeJSON(),
token,
migration: {
quizzesTransferred: migratedSessions[0],
stats: {
totalQuizzes,
quizzesPassed,
totalQuestionsAnswered,
correctAnswers,
accuracy: totalQuestionsAnswered > 0
? ((correctAnswers / totalQuestionsAnswered) * 100).toFixed(2)
: 0
}
}
}
});
} catch (error) {
if (!transaction.finished) {
await transaction.rollback();
}
console.error('Error converting guest to user:', error);
console.error('Error stack:', error.stack);
res.status(500).json({
success: false,
message: 'Error converting guest account',
error: error.message,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
// Script to drop categories table
const { sequelize } = require('./models');
async function dropCategoriesTable() {
try {
console.log('Connecting to database...');
await sequelize.authenticate();
console.log('✅ Database connected');
console.log('\nDropping categories table...');
await sequelize.query('DROP TABLE IF EXISTS categories');
console.log('✅ Categories table dropped successfully');
await sequelize.close();
console.log('\n✅ Database connection closed');
process.exit(0);
} catch (error) {
console.error('❌ Error:', error.message);
await sequelize.close();
process.exit(1);
}
}
dropCategoriesTable();

View File

@@ -0,0 +1,89 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
/**
* Generate a secure JWT secret key
*/
function generateJWTSecret(length = 64) {
return crypto.randomBytes(length).toString('hex');
}
/**
* Generate multiple secrets for different purposes
*/
function generateSecrets() {
return {
jwt_secret: generateJWTSecret(64),
refresh_token_secret: generateJWTSecret(64),
session_secret: generateJWTSecret(32)
};
}
/**
* Update .env file with generated JWT secret
*/
function updateEnvFile() {
const envPath = path.join(__dirname, '.env');
const envExamplePath = path.join(__dirname, '.env.example');
console.log('\n🔐 Generating Secure JWT Secret...\n');
const secrets = generateSecrets();
console.log('Generated Secrets:');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('JWT_SECRET:', secrets.jwt_secret.substring(0, 20) + '...');
console.log('Length:', secrets.jwt_secret.length, 'characters');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
try {
// Read current .env file
let envContent = fs.readFileSync(envPath, 'utf8');
// Update JWT_SECRET
envContent = envContent.replace(
/JWT_SECRET=.*/,
`JWT_SECRET=${secrets.jwt_secret}`
);
// Write back to .env
fs.writeFileSync(envPath, envContent);
console.log('✅ JWT_SECRET updated in .env file\n');
// Also update .env.example with a placeholder
if (fs.existsSync(envExamplePath)) {
let exampleContent = fs.readFileSync(envExamplePath, 'utf8');
exampleContent = exampleContent.replace(
/JWT_SECRET=.*/,
`JWT_SECRET=your_generated_secret_key_here_change_in_production`
);
fs.writeFileSync(envExamplePath, exampleContent);
console.log('✅ .env.example updated with placeholder\n');
}
console.log('⚠️ IMPORTANT: Keep your JWT secret secure!');
console.log(' - Never commit .env to version control');
console.log(' - Use different secrets for different environments');
console.log(' - Rotate secrets periodically in production\n');
return secrets;
} catch (error) {
console.error('❌ Error updating .env file:', error.message);
console.log('\nManually add this to your .env file:');
console.log(`JWT_SECRET=${secrets.jwt_secret}\n`);
return null;
}
}
// Run if called directly
if (require.main === module) {
updateEnvFile();
}
module.exports = {
generateJWTSecret,
generateSecrets,
updateEnvFile
};

View File

@@ -0,0 +1,41 @@
const { Category } = require('./models');
async function getCategoryMapping() {
try {
const categories = await Category.findAll({
where: { isActive: true },
attributes: ['id', 'name', 'slug', 'guestAccessible'],
order: [['displayOrder', 'ASC']]
});
console.log('\n=== Category ID Mapping ===\n');
const mapping = {};
categories.forEach(cat => {
mapping[cat.slug] = {
id: cat.id,
name: cat.name,
guestAccessible: cat.guestAccessible
};
console.log(`${cat.name} (${cat.slug})`);
console.log(` ID: ${cat.id}`);
console.log(` Guest Accessible: ${cat.guestAccessible}`);
console.log('');
});
// Export for use in tests
console.log('\nFor tests, use:');
console.log('const CATEGORY_IDS = {');
Object.keys(mapping).forEach(slug => {
console.log(` ${slug.toUpperCase().replace(/-/g, '_')}: '${mapping[slug].id}',`);
});
console.log('};');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
}
getCategoryMapping();

View File

@@ -0,0 +1,42 @@
const { Question, Category } = require('./models');
async function getQuestionMapping() {
try {
const questions = await Question.findAll({
where: { isActive: true },
attributes: ['id', 'questionText', 'difficulty', 'categoryId'],
include: [{
model: Category,
as: 'category',
attributes: ['name', 'guestAccessible']
}],
limit: 15
});
console.log('=== Question ID Mapping ===\n');
const mapping = {};
questions.forEach((q, index) => {
const key = `QUESTION_${index + 1}`;
const shortText = q.questionText.substring(0, 60);
console.log(`${key} (${q.category.name} - ${q.difficulty})${q.category.guestAccessible ? ' [GUEST]' : ' [AUTH]'}`);
console.log(` ID: ${q.id}`);
console.log(` Question: ${shortText}...\n`);
mapping[key] = q.id;
});
console.log('\nFor tests, use:');
console.log('const QUESTION_IDS = {');
Object.entries(mapping).forEach(([key, value]) => {
console.log(` ${key}: '${value}',`);
});
console.log('};');
} catch (error) {
console.error('Error:', error);
} finally {
process.exit(0);
}
}
getQuestionMapping();

View File

@@ -0,0 +1,139 @@
const jwt = require('jsonwebtoken');
const config = require('../config/config');
const { User } = require('../models');
/**
* Middleware to verify JWT token
*/
exports.verifyToken = async (req, res, next) => {
try {
// Get token from header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
message: 'No token provided. Authorization header must be in format: Bearer <token>'
});
}
// Extract token
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
// Verify token
const decoded = jwt.verify(token, config.jwt.secret);
// Attach user info to request
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
message: 'Token expired. Please login again.'
});
} else if (error.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
message: 'Invalid token. Please login again.'
});
} else {
return res.status(500).json({
success: false,
message: 'Error verifying token',
error: error.message
});
}
}
};
/**
* Middleware to check if user is admin
*/
exports.isAdmin = async (req, res, next) => {
try {
// Verify token first (should be called after verifyToken)
if (!req.user) {
return res.status(401).json({
success: false,
message: 'Authentication required'
});
}
// Check if user has admin role
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Access denied. Admin privileges required.'
});
}
next();
} catch (error) {
return res.status(500).json({
success: false,
message: 'Error checking admin privileges',
error: error.message
});
}
};
/**
* Middleware to check if user owns the resource or is admin
*/
exports.isOwnerOrAdmin = async (req, res, next) => {
try {
// Verify token first (should be called after verifyToken)
if (!req.user) {
return res.status(401).json({
success: false,
message: 'Authentication required'
});
}
const resourceUserId = req.params.userId || req.body.userId;
// Allow if admin or if user owns the resource
if (req.user.role === 'admin' || req.user.userId === resourceUserId) {
next();
} else {
return res.status(403).json({
success: false,
message: 'Access denied. You can only access your own resources.'
});
}
} catch (error) {
return res.status(500).json({
success: false,
message: 'Error checking resource ownership',
error: error.message
});
}
};
/**
* Optional auth middleware - attaches user if token present, but doesn't fail if missing
*/
exports.optionalAuth = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, config.jwt.secret);
req.user = decoded;
} catch (error) {
// Token invalid or expired - continue as guest
req.user = null;
}
}
next();
} catch (error) {
// Any error - continue as guest
next();
}
};

View File

@@ -0,0 +1,83 @@
const jwt = require('jsonwebtoken');
const config = require('../config/config');
const { GuestSession } = require('../models');
/**
* Middleware to verify guest session token
*/
exports.verifyGuestToken = async (req, res, next) => {
try {
// Get token from header
const guestToken = req.headers['x-guest-token'];
if (!guestToken) {
return res.status(401).json({
success: false,
message: 'No guest token provided. X-Guest-Token header is required.'
});
}
// Verify token
const decoded = jwt.verify(guestToken, config.jwt.secret);
// Check if guestId exists in payload
if (!decoded.guestId) {
return res.status(401).json({
success: false,
message: 'Invalid guest token. Missing guestId.'
});
}
// Verify guest session exists in database
const guestSession = await GuestSession.findOne({
where: { guestId: decoded.guestId }
});
if (!guestSession) {
return res.status(404).json({
success: false,
message: 'Guest session not found.'
});
}
// Check if session is expired
if (new Date() > new Date(guestSession.expiresAt)) {
return res.status(410).json({
success: false,
message: 'Guest session has expired. Please start a new session.'
});
}
// Check if session was converted to user account
if (guestSession.isConverted) {
return res.status(410).json({
success: false,
message: 'Guest session has been converted to a user account. Please login with your credentials.'
});
}
// Attach guest session to request
req.guestSession = guestSession;
req.guestId = decoded.guestId;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
message: 'Guest token expired. Please start a new session.'
});
} else if (error.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
message: 'Invalid guest token. Please start a new session.'
});
} else {
return res.status(500).json({
success: false,
message: 'Error verifying guest token',
error: error.message
});
}
}
};

View File

@@ -0,0 +1,86 @@
const { body, validationResult } = require('express-validator');
/**
* Validation middleware for user registration
*/
exports.validateRegistration = [
body('username')
.trim()
.notEmpty()
.withMessage('Username is required')
.isLength({ min: 3, max: 50 })
.withMessage('Username must be between 3 and 50 characters')
.matches(/^[a-zA-Z0-9_]+$/)
.withMessage('Username can only contain letters, numbers, and underscores'),
body('email')
.trim()
.notEmpty()
.withMessage('Email is required')
.isEmail()
.withMessage('Please provide a valid email address')
.normalizeEmail(),
body('password')
.notEmpty()
.withMessage('Password is required')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters long')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('Password must contain at least one uppercase letter, one lowercase letter, and one number'),
body('guestSessionId')
.optional()
.trim()
.notEmpty()
.withMessage('Guest session ID cannot be empty if provided'),
// Check for validation errors
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array().map(err => ({
field: err.path,
message: err.msg
}))
});
}
next();
}
];
/**
* Validation middleware for user login
*/
exports.validateLogin = [
body('email')
.trim()
.notEmpty()
.withMessage('Email is required')
.isEmail()
.withMessage('Please provide a valid email address')
.normalizeEmail(),
body('password')
.notEmpty()
.withMessage('Password is required'),
// Check for validation errors
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array().map(err => ({
field: err.path,
message: err.msg
}))
});
}
next();
}
];

View File

@@ -0,0 +1,22 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
/**
* Add altering commands here.
*
* Example:
* await queryInterface.createTable('users', { id: Sequelize.INTEGER });
*/
},
async down (queryInterface, Sequelize) {
/**
* Add reverting commands here.
*
* Example:
* await queryInterface.dropTable('users');
*/
}
};

View File

@@ -0,0 +1,143 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('users', {
id: {
type: Sequelize.CHAR(36),
primaryKey: true,
allowNull: false,
defaultValue: Sequelize.UUIDV4,
comment: 'UUID primary key'
},
username: {
type: Sequelize.STRING(50),
allowNull: false,
unique: true,
comment: 'Unique username'
},
email: {
type: Sequelize.STRING(100),
allowNull: false,
unique: true,
comment: 'User email address'
},
password: {
type: Sequelize.STRING(255),
allowNull: false,
comment: 'Hashed password'
},
role: {
type: Sequelize.ENUM('admin', 'user'),
allowNull: false,
defaultValue: 'user',
comment: 'User role'
},
profile_image: {
type: Sequelize.STRING(255),
allowNull: true,
comment: 'Profile image URL'
},
is_active: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: 'Account active status'
},
// Statistics
total_quizzes: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Total number of quizzes taken'
},
quizzes_passed: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Number of quizzes passed'
},
total_questions_answered: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Total questions answered'
},
correct_answers: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Number of correct answers'
},
current_streak: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Current daily streak'
},
longest_streak: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Longest daily streak achieved'
},
// Timestamps
last_login: {
type: Sequelize.DATE,
allowNull: true,
comment: 'Last login timestamp'
},
last_quiz_date: {
type: Sequelize.DATE,
allowNull: true,
comment: 'Date of last quiz taken'
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
}
}, {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci'
});
// Add indexes
await queryInterface.addIndex('users', ['email'], {
name: 'idx_users_email',
unique: true
});
await queryInterface.addIndex('users', ['username'], {
name: 'idx_users_username',
unique: true
});
await queryInterface.addIndex('users', ['role'], {
name: 'idx_users_role'
});
await queryInterface.addIndex('users', ['is_active'], {
name: 'idx_users_is_active'
});
await queryInterface.addIndex('users', ['created_at'], {
name: 'idx_users_created_at'
});
console.log('✅ Users table created successfully with indexes');
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('users');
console.log('✅ Users table dropped successfully');
}
};

View File

@@ -0,0 +1,126 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('categories', {
id: {
type: Sequelize.CHAR(36),
primaryKey: true,
allowNull: false,
defaultValue: Sequelize.UUIDV4,
comment: 'UUID primary key'
},
name: {
type: Sequelize.STRING(100),
allowNull: false,
unique: true,
comment: 'Category name'
},
slug: {
type: Sequelize.STRING(100),
allowNull: false,
unique: true,
comment: 'URL-friendly slug'
},
description: {
type: Sequelize.TEXT,
allowNull: true,
comment: 'Category description'
},
icon: {
type: Sequelize.STRING(255),
allowNull: true,
comment: 'Icon URL or class'
},
color: {
type: Sequelize.STRING(20),
allowNull: true,
comment: 'Display color (hex or name)'
},
is_active: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: 'Category active status'
},
guest_accessible: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether guests can access this category'
},
// Statistics
question_count: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Total number of questions in this category'
},
quiz_count: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Total number of quizzes taken in this category'
},
// Display order
display_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Display order (lower numbers first)'
},
// Timestamps
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
}
}, {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci'
});
// Add indexes
await queryInterface.addIndex('categories', ['name'], {
unique: true,
name: 'idx_categories_name'
});
await queryInterface.addIndex('categories', ['slug'], {
unique: true,
name: 'idx_categories_slug'
});
await queryInterface.addIndex('categories', ['is_active'], {
name: 'idx_categories_is_active'
});
await queryInterface.addIndex('categories', ['guest_accessible'], {
name: 'idx_categories_guest_accessible'
});
await queryInterface.addIndex('categories', ['display_order'], {
name: 'idx_categories_display_order'
});
await queryInterface.addIndex('categories', ['is_active', 'guest_accessible'], {
name: 'idx_categories_active_guest'
});
console.log('✅ Categories table created successfully with indexes');
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('categories');
console.log('✅ Categories table dropped successfully');
}
};

View File

@@ -0,0 +1,191 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
console.log('Creating questions table...');
await queryInterface.createTable('questions', {
id: {
type: Sequelize.CHAR(36),
primaryKey: true,
allowNull: false,
comment: 'UUID primary key'
},
category_id: {
type: Sequelize.CHAR(36),
allowNull: false,
references: {
model: 'categories',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT',
comment: 'Foreign key to categories table'
},
created_by: {
type: Sequelize.CHAR(36),
allowNull: true,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
comment: 'User who created the question (admin)'
},
question_text: {
type: Sequelize.TEXT,
allowNull: false,
comment: 'The question text'
},
question_type: {
type: Sequelize.ENUM('multiple', 'trueFalse', 'written'),
allowNull: false,
defaultValue: 'multiple',
comment: 'Type of question'
},
options: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Answer options for multiple choice (JSON array)'
},
correct_answer: {
type: Sequelize.STRING(255),
allowNull: false,
comment: 'Correct answer (index for multiple choice, true/false for boolean)'
},
explanation: {
type: Sequelize.TEXT,
allowNull: true,
comment: 'Explanation for the correct answer'
},
difficulty: {
type: Sequelize.ENUM('easy', 'medium', 'hard'),
allowNull: false,
defaultValue: 'medium',
comment: 'Question difficulty level'
},
points: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 10,
comment: 'Points awarded for correct answer'
},
time_limit: {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Time limit in seconds (optional)'
},
keywords: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Search keywords (JSON array)'
},
tags: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Tags for categorization (JSON array)'
},
visibility: {
type: Sequelize.ENUM('public', 'registered', 'premium'),
allowNull: false,
defaultValue: 'registered',
comment: 'Who can see this question'
},
guest_accessible: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether guests can access this question'
},
is_active: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: 'Question active status'
},
times_attempted: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Number of times question was attempted'
},
times_correct: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Number of times answered correctly'
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
}
}, {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
engine: 'InnoDB'
});
// Add indexes
await queryInterface.addIndex('questions', ['category_id'], {
name: 'idx_questions_category_id'
});
await queryInterface.addIndex('questions', ['created_by'], {
name: 'idx_questions_created_by'
});
await queryInterface.addIndex('questions', ['question_type'], {
name: 'idx_questions_question_type'
});
await queryInterface.addIndex('questions', ['difficulty'], {
name: 'idx_questions_difficulty'
});
await queryInterface.addIndex('questions', ['visibility'], {
name: 'idx_questions_visibility'
});
await queryInterface.addIndex('questions', ['guest_accessible'], {
name: 'idx_questions_guest_accessible'
});
await queryInterface.addIndex('questions', ['is_active'], {
name: 'idx_questions_is_active'
});
await queryInterface.addIndex('questions', ['created_at'], {
name: 'idx_questions_created_at'
});
// Composite index for common query patterns
await queryInterface.addIndex('questions', ['category_id', 'is_active', 'difficulty'], {
name: 'idx_questions_category_active_difficulty'
});
await queryInterface.addIndex('questions', ['is_active', 'guest_accessible'], {
name: 'idx_questions_active_guest'
});
// Full-text search index
await queryInterface.sequelize.query(
'CREATE FULLTEXT INDEX idx_questions_fulltext ON questions(question_text, explanation)'
);
console.log('✅ Questions table created successfully with indexes and full-text search');
},
async down (queryInterface, Sequelize) {
console.log('Dropping questions table...');
await queryInterface.dropTable('questions');
console.log('✅ Questions table dropped successfully');
}
};

View File

@@ -0,0 +1,131 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
console.log('Creating guest_sessions table...');
await queryInterface.createTable('guest_sessions', {
id: {
type: Sequelize.CHAR(36),
primaryKey: true,
allowNull: false,
comment: 'UUID primary key'
},
guest_id: {
type: Sequelize.STRING(100),
allowNull: false,
unique: true,
comment: 'Unique guest identifier'
},
session_token: {
type: Sequelize.STRING(500),
allowNull: false,
unique: true,
comment: 'JWT session token'
},
device_id: {
type: Sequelize.STRING(255),
allowNull: true,
comment: 'Device identifier (optional)'
},
ip_address: {
type: Sequelize.STRING(45),
allowNull: true,
comment: 'IP address (supports IPv6)'
},
user_agent: {
type: Sequelize.TEXT,
allowNull: true,
comment: 'Browser user agent string'
},
quizzes_attempted: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Number of quizzes attempted by guest'
},
max_quizzes: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 3,
comment: 'Maximum quizzes allowed for this guest'
},
expires_at: {
type: Sequelize.DATE,
allowNull: false,
comment: 'Session expiration timestamp'
},
is_converted: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether guest converted to registered user'
},
converted_user_id: {
type: Sequelize.CHAR(36),
allowNull: true,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
comment: 'User ID if guest converted to registered user'
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
}
}, {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
engine: 'InnoDB'
});
// Add indexes
await queryInterface.addIndex('guest_sessions', ['guest_id'], {
unique: true,
name: 'idx_guest_sessions_guest_id'
});
await queryInterface.addIndex('guest_sessions', ['session_token'], {
unique: true,
name: 'idx_guest_sessions_session_token'
});
await queryInterface.addIndex('guest_sessions', ['expires_at'], {
name: 'idx_guest_sessions_expires_at'
});
await queryInterface.addIndex('guest_sessions', ['is_converted'], {
name: 'idx_guest_sessions_is_converted'
});
await queryInterface.addIndex('guest_sessions', ['converted_user_id'], {
name: 'idx_guest_sessions_converted_user_id'
});
await queryInterface.addIndex('guest_sessions', ['device_id'], {
name: 'idx_guest_sessions_device_id'
});
await queryInterface.addIndex('guest_sessions', ['created_at'], {
name: 'idx_guest_sessions_created_at'
});
console.log('✅ Guest sessions table created successfully with indexes');
},
async down (queryInterface, Sequelize) {
console.log('Dropping guest_sessions table...');
await queryInterface.dropTable('guest_sessions');
console.log('✅ Guest sessions table dropped successfully');
}
};

View File

@@ -0,0 +1,203 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('quiz_sessions', {
id: {
type: Sequelize.CHAR(36),
primaryKey: true,
allowNull: false,
comment: 'UUID primary key'
},
user_id: {
type: Sequelize.CHAR(36),
allowNull: true,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
comment: 'Foreign key to users table (null for guest quizzes)'
},
guest_session_id: {
type: Sequelize.CHAR(36),
allowNull: true,
references: {
model: 'guest_sessions',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
comment: 'Foreign key to guest_sessions table (null for user quizzes)'
},
category_id: {
type: Sequelize.CHAR(36),
allowNull: false,
references: {
model: 'categories',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'RESTRICT',
comment: 'Foreign key to categories table'
},
quiz_type: {
type: Sequelize.ENUM('practice', 'timed', 'exam'),
allowNull: false,
defaultValue: 'practice',
comment: 'Type of quiz: practice (untimed), timed, or exam mode'
},
difficulty: {
type: Sequelize.ENUM('easy', 'medium', 'hard', 'mixed'),
allowNull: false,
defaultValue: 'mixed',
comment: 'Difficulty level of questions in the quiz'
},
total_questions: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 10,
comment: 'Total number of questions in this quiz session'
},
questions_answered: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: 'Number of questions answered so far'
},
correct_answers: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: 'Number of correct answers'
},
score: {
type: Sequelize.DECIMAL(5, 2),
allowNull: false,
defaultValue: 0.00,
comment: 'Quiz score as percentage (0-100)'
},
total_points: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: 'Total points earned in this quiz'
},
max_points: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: 'Maximum possible points for this quiz'
},
time_limit: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: true,
comment: 'Time limit in seconds (null for untimed practice)'
},
time_spent: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: 'Total time spent in seconds'
},
started_at: {
type: Sequelize.DATE,
allowNull: true,
comment: 'When the quiz was started'
},
completed_at: {
type: Sequelize.DATE,
allowNull: true,
comment: 'When the quiz was completed'
},
status: {
type: Sequelize.ENUM('not_started', 'in_progress', 'completed', 'abandoned', 'timed_out'),
allowNull: false,
defaultValue: 'not_started',
comment: 'Current status of the quiz session'
},
is_passed: {
type: Sequelize.BOOLEAN,
allowNull: true,
comment: 'Whether the quiz was passed (null if not completed)'
},
pass_percentage: {
type: Sequelize.DECIMAL(5, 2),
allowNull: false,
defaultValue: 70.00,
comment: 'Required percentage to pass (default 70%)'
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'Record creation timestamp'
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'Record last update timestamp'
}
}, {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
comment: 'Tracks individual quiz sessions for users and guests'
});
// Add indexes for better query performance
await queryInterface.addIndex('quiz_sessions', ['user_id'], {
name: 'idx_quiz_sessions_user_id'
});
await queryInterface.addIndex('quiz_sessions', ['guest_session_id'], {
name: 'idx_quiz_sessions_guest_session_id'
});
await queryInterface.addIndex('quiz_sessions', ['category_id'], {
name: 'idx_quiz_sessions_category_id'
});
await queryInterface.addIndex('quiz_sessions', ['status'], {
name: 'idx_quiz_sessions_status'
});
await queryInterface.addIndex('quiz_sessions', ['quiz_type'], {
name: 'idx_quiz_sessions_quiz_type'
});
await queryInterface.addIndex('quiz_sessions', ['started_at'], {
name: 'idx_quiz_sessions_started_at'
});
await queryInterface.addIndex('quiz_sessions', ['completed_at'], {
name: 'idx_quiz_sessions_completed_at'
});
await queryInterface.addIndex('quiz_sessions', ['created_at'], {
name: 'idx_quiz_sessions_created_at'
});
await queryInterface.addIndex('quiz_sessions', ['is_passed'], {
name: 'idx_quiz_sessions_is_passed'
});
// Composite index for common queries
await queryInterface.addIndex('quiz_sessions', ['user_id', 'status'], {
name: 'idx_quiz_sessions_user_status'
});
await queryInterface.addIndex('quiz_sessions', ['guest_session_id', 'status'], {
name: 'idx_quiz_sessions_guest_status'
});
console.log('✅ Quiz sessions table created successfully with 21 fields and 11 indexes');
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('quiz_sessions');
console.log('✅ Quiz sessions table dropped');
}
};

View File

@@ -0,0 +1,111 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('quiz_answers', {
id: {
type: Sequelize.CHAR(36),
primaryKey: true,
allowNull: false,
comment: 'UUID primary key'
},
quiz_session_id: {
type: Sequelize.CHAR(36),
allowNull: false,
references: {
model: 'quiz_sessions',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
comment: 'Foreign key to quiz_sessions table'
},
question_id: {
type: Sequelize.CHAR(36),
allowNull: false,
references: {
model: 'questions',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
comment: 'Foreign key to questions table'
},
selected_option: {
type: Sequelize.STRING(255),
allowNull: false,
comment: 'The option selected by the user'
},
is_correct: {
type: Sequelize.BOOLEAN,
allowNull: false,
comment: 'Whether the selected answer was correct'
},
points_earned: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: 'Points earned for this answer'
},
time_taken: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: 'Time taken to answer in seconds'
},
answered_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'When the question was answered'
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'Record creation timestamp'
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'Record last update timestamp'
}
}, {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
comment: 'Stores individual answers given during quiz sessions'
});
// Add indexes
await queryInterface.addIndex('quiz_answers', ['quiz_session_id'], {
name: 'idx_quiz_answers_session_id'
});
await queryInterface.addIndex('quiz_answers', ['question_id'], {
name: 'idx_quiz_answers_question_id'
});
await queryInterface.addIndex('quiz_answers', ['is_correct'], {
name: 'idx_quiz_answers_is_correct'
});
await queryInterface.addIndex('quiz_answers', ['answered_at'], {
name: 'idx_quiz_answers_answered_at'
});
// Composite index for session + question (unique constraint)
await queryInterface.addIndex('quiz_answers', ['quiz_session_id', 'question_id'], {
name: 'idx_quiz_answers_session_question',
unique: true
});
console.log('✅ Quiz answers table created successfully with 9 fields and 5 indexes');
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('quiz_answers');
console.log('✅ Quiz answers table dropped');
}
};

View File

@@ -0,0 +1,84 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('quiz_session_questions', {
id: {
type: Sequelize.CHAR(36),
primaryKey: true,
allowNull: false,
comment: 'UUID primary key'
},
quiz_session_id: {
type: Sequelize.CHAR(36),
allowNull: false,
references: {
model: 'quiz_sessions',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
comment: 'Foreign key to quiz_sessions table'
},
question_id: {
type: Sequelize.CHAR(36),
allowNull: false,
references: {
model: 'questions',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
comment: 'Foreign key to questions table'
},
question_order: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
comment: 'Order of question in the quiz (1-based)'
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'Record creation timestamp'
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'Record last update timestamp'
}
}, {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
comment: 'Junction table linking quiz sessions with questions'
});
// Add indexes
await queryInterface.addIndex('quiz_session_questions', ['quiz_session_id'], {
name: 'idx_qsq_session_id'
});
await queryInterface.addIndex('quiz_session_questions', ['question_id'], {
name: 'idx_qsq_question_id'
});
await queryInterface.addIndex('quiz_session_questions', ['question_order'], {
name: 'idx_qsq_question_order'
});
// Unique composite index to prevent duplicate questions in same session
await queryInterface.addIndex('quiz_session_questions', ['quiz_session_id', 'question_id'], {
name: 'idx_qsq_session_question',
unique: true
});
console.log('✅ Quiz session questions junction table created with 5 fields and 4 indexes');
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('quiz_session_questions');
console.log('✅ Quiz session questions table dropped');
}
};

View File

@@ -0,0 +1,84 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_bookmarks', {
id: {
type: Sequelize.CHAR(36),
primaryKey: true,
allowNull: false,
comment: 'UUID primary key'
},
user_id: {
type: Sequelize.CHAR(36),
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
comment: 'Foreign key to users table'
},
question_id: {
type: Sequelize.CHAR(36),
allowNull: false,
references: {
model: 'questions',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
comment: 'Foreign key to questions table'
},
notes: {
type: Sequelize.TEXT,
allowNull: true,
comment: 'Optional user notes about the bookmarked question'
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'When the bookmark was created'
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'Record last update timestamp'
}
}, {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
comment: 'Junction table for users bookmarking questions'
});
// Add indexes
await queryInterface.addIndex('user_bookmarks', ['user_id'], {
name: 'idx_user_bookmarks_user_id'
});
await queryInterface.addIndex('user_bookmarks', ['question_id'], {
name: 'idx_user_bookmarks_question_id'
});
await queryInterface.addIndex('user_bookmarks', ['created_at'], {
name: 'idx_user_bookmarks_created_at'
});
// Unique composite index to prevent duplicate bookmarks
await queryInterface.addIndex('user_bookmarks', ['user_id', 'question_id'], {
name: 'idx_user_bookmarks_user_question',
unique: true
});
console.log('✅ User bookmarks table created with 5 fields and 4 indexes');
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('user_bookmarks');
console.log('✅ User bookmarks table dropped');
}
};

View File

@@ -0,0 +1,122 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('achievements', {
id: {
type: Sequelize.CHAR(36),
primaryKey: true,
allowNull: false,
comment: 'UUID primary key'
},
name: {
type: Sequelize.STRING(100),
allowNull: false,
unique: true,
comment: 'Unique name of the achievement'
},
slug: {
type: Sequelize.STRING(100),
allowNull: false,
unique: true,
comment: 'URL-friendly slug'
},
description: {
type: Sequelize.TEXT,
allowNull: false,
comment: 'Description of the achievement'
},
icon: {
type: Sequelize.STRING(50),
allowNull: true,
comment: 'Icon identifier (e.g., emoji or icon class)'
},
badge_color: {
type: Sequelize.STRING(20),
allowNull: true,
defaultValue: '#FFD700',
comment: 'Hex color code for the badge'
},
category: {
type: Sequelize.ENUM('quiz', 'streak', 'score', 'speed', 'milestone', 'special'),
allowNull: false,
defaultValue: 'milestone',
comment: 'Category of achievement'
},
requirement_type: {
type: Sequelize.ENUM('quizzes_completed', 'quizzes_passed', 'perfect_score', 'streak_days', 'total_questions', 'category_master', 'speed_demon', 'early_bird'),
allowNull: false,
comment: 'Type of requirement to earn the achievement'
},
requirement_value: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
comment: 'Value needed to satisfy requirement (e.g., 10 for "10 quizzes")'
},
points: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 10,
comment: 'Points awarded when achievement is earned'
},
is_active: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: 'Whether this achievement is currently available'
},
display_order: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: 'Display order in achievement list'
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'Record creation timestamp'
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'Record last update timestamp'
}
}, {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
comment: 'Defines available achievements users can earn'
});
// Add indexes
await queryInterface.addIndex('achievements', ['slug'], {
name: 'idx_achievements_slug',
unique: true
});
await queryInterface.addIndex('achievements', ['category'], {
name: 'idx_achievements_category'
});
await queryInterface.addIndex('achievements', ['requirement_type'], {
name: 'idx_achievements_requirement_type'
});
await queryInterface.addIndex('achievements', ['is_active'], {
name: 'idx_achievements_is_active'
});
await queryInterface.addIndex('achievements', ['display_order'], {
name: 'idx_achievements_display_order'
});
console.log('✅ Achievements table created with 13 fields and 5 indexes');
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('achievements');
console.log('✅ Achievements table dropped');
}
};

View File

@@ -0,0 +1,95 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_achievements', {
id: {
type: Sequelize.CHAR(36),
primaryKey: true,
allowNull: false,
comment: 'UUID primary key'
},
user_id: {
type: Sequelize.CHAR(36),
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
comment: 'Foreign key to users table'
},
achievement_id: {
type: Sequelize.CHAR(36),
allowNull: false,
references: {
model: 'achievements',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
comment: 'Foreign key to achievements table'
},
earned_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'When the achievement was earned'
},
notified: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether user has been notified about this achievement'
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'Record creation timestamp'
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: 'Record last update timestamp'
}
}, {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
comment: 'Junction table tracking achievements earned by users'
});
// Add indexes
await queryInterface.addIndex('user_achievements', ['user_id'], {
name: 'idx_user_achievements_user_id'
});
await queryInterface.addIndex('user_achievements', ['achievement_id'], {
name: 'idx_user_achievements_achievement_id'
});
await queryInterface.addIndex('user_achievements', ['earned_at'], {
name: 'idx_user_achievements_earned_at'
});
await queryInterface.addIndex('user_achievements', ['notified'], {
name: 'idx_user_achievements_notified'
});
// Unique composite index to prevent duplicate achievements
await queryInterface.addIndex('user_achievements', ['user_id', 'achievement_id'], {
name: 'idx_user_achievements_user_achievement',
unique: true
});
console.log('✅ User achievements table created with 6 fields and 5 indexes');
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('user_achievements');
console.log('✅ User achievements table dropped');
}
};

274
backend/models/Category.js Normal file
View File

@@ -0,0 +1,274 @@
const { v4: uuidv4 } = require('uuid');
module.exports = (sequelize, DataTypes) => {
const Category = sequelize.define('Category', {
id: {
type: DataTypes.CHAR(36),
primaryKey: true,
defaultValue: () => uuidv4(),
allowNull: false,
comment: 'UUID primary key'
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
unique: {
msg: 'Category name already exists'
},
validate: {
notEmpty: {
msg: 'Category name cannot be empty'
},
len: {
args: [2, 100],
msg: 'Category name must be between 2 and 100 characters'
}
},
comment: 'Category name'
},
slug: {
type: DataTypes.STRING(100),
allowNull: false,
unique: {
msg: 'Category slug already exists'
},
validate: {
notEmpty: {
msg: 'Slug cannot be empty'
},
is: {
args: /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
msg: 'Slug must be lowercase alphanumeric with hyphens only'
}
},
comment: 'URL-friendly slug'
},
description: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Category description'
},
icon: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Icon URL or class'
},
color: {
type: DataTypes.STRING(20),
allowNull: true,
validate: {
is: {
args: /^#[0-9A-F]{6}$/i,
msg: 'Color must be a valid hex color (e.g., #FF5733)'
}
},
comment: 'Display color (hex format)'
},
isActive: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
field: 'is_active',
comment: 'Category active status'
},
guestAccessible: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'guest_accessible',
comment: 'Whether guests can access this category'
},
// Statistics
questionCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'question_count',
validate: {
min: 0
},
comment: 'Total number of questions in this category'
},
quizCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'quiz_count',
validate: {
min: 0
},
comment: 'Total number of quizzes taken in this category'
},
// Display order
displayOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'display_order',
comment: 'Display order (lower numbers first)'
}
}, {
sequelize,
modelName: 'Category',
tableName: 'categories',
timestamps: true,
underscored: true,
indexes: [
{
unique: true,
fields: ['name']
},
{
unique: true,
fields: ['slug']
},
{
fields: ['is_active']
},
{
fields: ['guest_accessible']
},
{
fields: ['display_order']
},
{
fields: ['is_active', 'guest_accessible']
}
]
});
// Helper function to generate slug from name
Category.generateSlug = function(name) {
return name
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
};
// Instance methods
Category.prototype.incrementQuestionCount = async function() {
this.questionCount += 1;
await this.save();
};
Category.prototype.decrementQuestionCount = async function() {
if (this.questionCount > 0) {
this.questionCount -= 1;
await this.save();
}
};
Category.prototype.incrementQuizCount = async function() {
this.quizCount += 1;
await this.save();
};
// Class methods
Category.findActiveCategories = async function(includeGuestOnly = false) {
const where = { isActive: true };
if (includeGuestOnly) {
where.guestAccessible = true;
}
return await this.findAll({
where,
order: [['displayOrder', 'ASC'], ['name', 'ASC']]
});
};
Category.findBySlug = async function(slug) {
return await this.findOne({
where: { slug, isActive: true }
});
};
Category.getGuestAccessibleCategories = async function() {
return await this.findAll({
where: {
isActive: true,
guestAccessible: true
},
order: [['displayOrder', 'ASC'], ['name', 'ASC']]
});
};
Category.getCategoriesWithStats = async function() {
return await this.findAll({
where: { isActive: true },
attributes: [
'id',
'name',
'slug',
'description',
'icon',
'color',
'questionCount',
'quizCount',
'guestAccessible',
'displayOrder'
],
order: [['displayOrder', 'ASC'], ['name', 'ASC']]
});
};
// Hooks
Category.beforeValidate((category) => {
// Auto-generate slug from name if not provided
if (!category.slug && category.name) {
category.slug = Category.generateSlug(category.name);
}
// Ensure UUID is set
if (!category.id) {
category.id = uuidv4();
}
});
Category.beforeCreate((category) => {
// Ensure slug is generated even if validation was skipped
if (!category.slug && category.name) {
category.slug = Category.generateSlug(category.name);
}
});
Category.beforeUpdate((category) => {
// Regenerate slug if name changed
if (category.changed('name') && !category.changed('slug')) {
category.slug = Category.generateSlug(category.name);
}
});
// Define associations
Category.associate = function(models) {
// Category has many questions
if (models.Question) {
Category.hasMany(models.Question, {
foreignKey: 'categoryId',
as: 'questions'
});
}
// Category has many quiz sessions
if (models.QuizSession) {
Category.hasMany(models.QuizSession, {
foreignKey: 'categoryId',
as: 'quizSessions'
});
}
// Category belongs to many guest settings (for guest-accessible categories)
if (models.GuestSettings) {
Category.belongsToMany(models.GuestSettings, {
through: 'guest_settings_categories',
foreignKey: 'categoryId',
otherKey: 'guestSettingsId',
as: 'guestSettings'
});
}
};
return Category;
};

View File

@@ -0,0 +1,330 @@
const { v4: uuidv4 } = require('uuid');
const jwt = require('jsonwebtoken');
const config = require('../config/config');
module.exports = (sequelize, DataTypes) => {
const GuestSession = sequelize.define('GuestSession', {
id: {
type: DataTypes.CHAR(36),
primaryKey: true,
defaultValue: () => uuidv4(),
allowNull: false,
comment: 'UUID primary key'
},
guestId: {
type: DataTypes.STRING(100),
allowNull: false,
unique: {
msg: 'Guest ID already exists'
},
field: 'guest_id',
validate: {
notEmpty: {
msg: 'Guest ID cannot be empty'
}
},
comment: 'Unique guest identifier'
},
sessionToken: {
type: DataTypes.STRING(500),
allowNull: false,
unique: {
msg: 'Session token already exists'
},
field: 'session_token',
comment: 'JWT session token'
},
deviceId: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'device_id',
comment: 'Device identifier (optional)'
},
ipAddress: {
type: DataTypes.STRING(45),
allowNull: true,
field: 'ip_address',
comment: 'IP address (supports IPv6)'
},
userAgent: {
type: DataTypes.TEXT,
allowNull: true,
field: 'user_agent',
comment: 'Browser user agent string'
},
quizzesAttempted: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'quizzes_attempted',
validate: {
min: 0
},
comment: 'Number of quizzes attempted by guest'
},
maxQuizzes: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 3,
field: 'max_quizzes',
validate: {
min: 1,
max: 100
},
comment: 'Maximum quizzes allowed for this guest'
},
expiresAt: {
type: DataTypes.DATE,
allowNull: false,
field: 'expires_at',
validate: {
isDate: true,
isAfter: new Date().toISOString()
},
comment: 'Session expiration timestamp'
},
isConverted: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_converted',
comment: 'Whether guest converted to registered user'
},
convertedUserId: {
type: DataTypes.CHAR(36),
allowNull: true,
field: 'converted_user_id',
comment: 'User ID if guest converted to registered user'
}
}, {
sequelize,
modelName: 'GuestSession',
tableName: 'guest_sessions',
timestamps: true,
underscored: true,
indexes: [
{
unique: true,
fields: ['guest_id']
},
{
unique: true,
fields: ['session_token']
},
{
fields: ['expires_at']
},
{
fields: ['is_converted']
},
{
fields: ['converted_user_id']
},
{
fields: ['device_id']
},
{
fields: ['created_at']
}
]
});
// Static method to generate guest ID
GuestSession.generateGuestId = function() {
const timestamp = Date.now();
const randomStr = Math.random().toString(36).substring(2, 15);
return `guest_${timestamp}_${randomStr}`;
};
// Static method to generate session token (JWT)
GuestSession.generateToken = function(guestId, sessionId) {
const payload = {
guestId,
sessionId,
type: 'guest'
};
return jwt.sign(payload, config.jwt.secret, {
expiresIn: config.guest.sessionExpireHours + 'h'
});
};
// Static method to verify and decode token
GuestSession.verifyToken = function(token) {
try {
return jwt.verify(token, config.jwt.secret);
} catch (error) {
throw new Error('Invalid or expired token');
}
};
// Static method to create new guest session
GuestSession.createSession = async function(options = {}) {
const guestId = GuestSession.generateGuestId();
const sessionId = uuidv4();
const sessionToken = GuestSession.generateToken(guestId, sessionId);
const expiryHours = options.expiryHours || config.guest.sessionExpireHours || 24;
const expiresAt = new Date(Date.now() + expiryHours * 60 * 60 * 1000);
const session = await GuestSession.create({
id: sessionId,
guestId,
sessionToken,
deviceId: options.deviceId || null,
ipAddress: options.ipAddress || null,
userAgent: options.userAgent || null,
maxQuizzes: options.maxQuizzes || config.guest.maxQuizzes || 3,
expiresAt
});
return session;
};
// Instance methods
GuestSession.prototype.isExpired = function() {
return new Date() > new Date(this.expiresAt);
};
GuestSession.prototype.hasReachedQuizLimit = function() {
return this.quizzesAttempted >= this.maxQuizzes;
};
GuestSession.prototype.getRemainingQuizzes = function() {
return Math.max(0, this.maxQuizzes - this.quizzesAttempted);
};
GuestSession.prototype.incrementQuizAttempt = async function() {
this.quizzesAttempted += 1;
await this.save();
};
GuestSession.prototype.extend = async function(hours = 24) {
const newExpiry = new Date(Date.now() + hours * 60 * 60 * 1000);
this.expiresAt = newExpiry;
// Regenerate token with new expiry
this.sessionToken = GuestSession.generateToken(this.guestId, this.id);
await this.save();
return this;
};
GuestSession.prototype.convertToUser = async function(userId) {
this.isConverted = true;
this.convertedUserId = userId;
await this.save();
};
GuestSession.prototype.getSessionInfo = function() {
return {
guestId: this.guestId,
sessionId: this.id,
quizzesAttempted: this.quizzesAttempted,
maxQuizzes: this.maxQuizzes,
remainingQuizzes: this.getRemainingQuizzes(),
expiresAt: this.expiresAt,
isExpired: this.isExpired(),
hasReachedLimit: this.hasReachedQuizLimit(),
isConverted: this.isConverted
};
};
// Class methods
GuestSession.findByGuestId = async function(guestId) {
return await this.findOne({
where: { guestId }
});
};
GuestSession.findByToken = async function(token) {
try {
const decoded = GuestSession.verifyToken(token);
return await this.findOne({
where: {
guestId: decoded.guestId,
id: decoded.sessionId
}
});
} catch (error) {
return null;
}
};
GuestSession.findActiveSession = async function(guestId) {
return await this.findOne({
where: {
guestId,
isConverted: false
}
});
};
GuestSession.cleanupExpiredSessions = async function() {
const expiredCount = await this.destroy({
where: {
expiresAt: {
[sequelize.Sequelize.Op.lt]: new Date()
},
isConverted: false
}
});
return expiredCount;
};
GuestSession.getActiveGuestCount = async function() {
return await this.count({
where: {
expiresAt: {
[sequelize.Sequelize.Op.gt]: new Date()
},
isConverted: false
}
});
};
GuestSession.getConversionRate = async function() {
const total = await this.count();
if (total === 0) return 0;
const converted = await this.count({
where: { isConverted: true }
});
return Math.round((converted / total) * 100);
};
// Hooks
GuestSession.beforeValidate((session) => {
// Ensure UUID is set
if (!session.id) {
session.id = uuidv4();
}
// Ensure expiry is in the future (only for new records, not updates)
if (session.isNewRecord && session.expiresAt && new Date(session.expiresAt) <= new Date()) {
throw new Error('Expiry date must be in the future');
}
});
// Define associations
GuestSession.associate = function(models) {
// GuestSession belongs to a User (if converted)
if (models.User) {
GuestSession.belongsTo(models.User, {
foreignKey: 'convertedUserId',
as: 'convertedUser'
});
}
// GuestSession has many quiz sessions
if (models.QuizSession) {
GuestSession.hasMany(models.QuizSession, {
foreignKey: 'guestSessionId',
as: 'quizSessions'
});
}
};
return GuestSession;
};

451
backend/models/Question.js Normal file
View File

@@ -0,0 +1,451 @@
const { v4: uuidv4 } = require('uuid');
module.exports = (sequelize, DataTypes) => {
const Question = sequelize.define('Question', {
id: {
type: DataTypes.CHAR(36),
primaryKey: true,
defaultValue: () => uuidv4(),
allowNull: false,
comment: 'UUID primary key'
},
categoryId: {
type: DataTypes.CHAR(36),
allowNull: false,
field: 'category_id',
comment: 'Foreign key to categories table'
},
createdBy: {
type: DataTypes.CHAR(36),
allowNull: true,
field: 'created_by',
comment: 'User who created the question (admin)'
},
questionText: {
type: DataTypes.TEXT,
allowNull: false,
field: 'question_text',
validate: {
notEmpty: {
msg: 'Question text cannot be empty'
},
len: {
args: [10, 5000],
msg: 'Question text must be between 10 and 5000 characters'
}
},
comment: 'The question text'
},
questionType: {
type: DataTypes.ENUM('multiple', 'trueFalse', 'written'),
allowNull: false,
defaultValue: 'multiple',
field: 'question_type',
validate: {
isIn: {
args: [['multiple', 'trueFalse', 'written']],
msg: 'Question type must be multiple, trueFalse, or written'
}
},
comment: 'Type of question'
},
options: {
type: DataTypes.JSON,
allowNull: true,
get() {
const rawValue = this.getDataValue('options');
return rawValue ? (typeof rawValue === 'string' ? JSON.parse(rawValue) : rawValue) : null;
},
set(value) {
this.setDataValue('options', value);
},
comment: 'Answer options for multiple choice (JSON array)'
},
correctAnswer: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'correct_answer',
validate: {
notEmpty: {
msg: 'Correct answer cannot be empty'
}
},
comment: 'Correct answer (index for multiple choice, true/false for boolean)'
},
explanation: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Explanation for the correct answer'
},
difficulty: {
type: DataTypes.ENUM('easy', 'medium', 'hard'),
allowNull: false,
defaultValue: 'medium',
validate: {
isIn: {
args: [['easy', 'medium', 'hard']],
msg: 'Difficulty must be easy, medium, or hard'
}
},
comment: 'Question difficulty level'
},
points: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 10,
validate: {
min: {
args: 1,
msg: 'Points must be at least 1'
},
max: {
args: 100,
msg: 'Points cannot exceed 100'
}
},
comment: 'Points awarded for correct answer'
},
timeLimit: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'time_limit',
validate: {
min: {
args: 10,
msg: 'Time limit must be at least 10 seconds'
}
},
comment: 'Time limit in seconds (optional)'
},
keywords: {
type: DataTypes.JSON,
allowNull: true,
get() {
const rawValue = this.getDataValue('keywords');
return rawValue ? (typeof rawValue === 'string' ? JSON.parse(rawValue) : rawValue) : null;
},
set(value) {
this.setDataValue('keywords', value);
},
comment: 'Search keywords (JSON array)'
},
tags: {
type: DataTypes.JSON,
allowNull: true,
get() {
const rawValue = this.getDataValue('tags');
return rawValue ? (typeof rawValue === 'string' ? JSON.parse(rawValue) : rawValue) : null;
},
set(value) {
this.setDataValue('tags', value);
},
comment: 'Tags for categorization (JSON array)'
},
visibility: {
type: DataTypes.ENUM('public', 'registered', 'premium'),
allowNull: false,
defaultValue: 'registered',
validate: {
isIn: {
args: [['public', 'registered', 'premium']],
msg: 'Visibility must be public, registered, or premium'
}
},
comment: 'Who can see this question'
},
guestAccessible: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'guest_accessible',
comment: 'Whether guests can access this question'
},
isActive: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
field: 'is_active',
comment: 'Question active status'
},
timesAttempted: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'times_attempted',
validate: {
min: 0
},
comment: 'Number of times question was attempted'
},
timesCorrect: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'times_correct',
validate: {
min: 0
},
comment: 'Number of times answered correctly'
}
}, {
sequelize,
modelName: 'Question',
tableName: 'questions',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['category_id']
},
{
fields: ['created_by']
},
{
fields: ['question_type']
},
{
fields: ['difficulty']
},
{
fields: ['visibility']
},
{
fields: ['guest_accessible']
},
{
fields: ['is_active']
},
{
fields: ['created_at']
},
{
fields: ['category_id', 'is_active', 'difficulty']
},
{
fields: ['is_active', 'guest_accessible']
}
]
});
// Instance methods
Question.prototype.incrementAttempted = async function() {
this.timesAttempted += 1;
await this.save();
};
Question.prototype.incrementCorrect = async function() {
this.timesCorrect += 1;
await this.save();
};
Question.prototype.getAccuracy = function() {
if (this.timesAttempted === 0) return 0;
return Math.round((this.timesCorrect / this.timesAttempted) * 100);
};
Question.prototype.toSafeJSON = function() {
const values = { ...this.get() };
delete values.correctAnswer; // Hide correct answer
return values;
};
// Class methods
Question.findActiveQuestions = async function(filters = {}) {
const where = { isActive: true };
if (filters.categoryId) {
where.categoryId = filters.categoryId;
}
if (filters.difficulty) {
where.difficulty = filters.difficulty;
}
if (filters.visibility) {
where.visibility = filters.visibility;
}
if (filters.guestAccessible !== undefined) {
where.guestAccessible = filters.guestAccessible;
}
const options = {
where,
order: sequelize.random()
};
if (filters.limit) {
options.limit = filters.limit;
}
return await this.findAll(options);
};
Question.searchQuestions = async function(searchTerm, filters = {}) {
const where = { isActive: true };
if (filters.categoryId) {
where.categoryId = filters.categoryId;
}
if (filters.difficulty) {
where.difficulty = filters.difficulty;
}
// Use raw query for full-text search
const query = `
SELECT *, MATCH(question_text, explanation) AGAINST(:searchTerm) as relevance
FROM questions
WHERE MATCH(question_text, explanation) AGAINST(:searchTerm)
${filters.categoryId ? 'AND category_id = :categoryId' : ''}
${filters.difficulty ? 'AND difficulty = :difficulty' : ''}
AND is_active = 1
ORDER BY relevance DESC
LIMIT :limit
`;
const replacements = {
searchTerm,
categoryId: filters.categoryId || null,
difficulty: filters.difficulty || null,
limit: filters.limit || 20
};
const [results] = await sequelize.query(query, {
replacements,
type: sequelize.QueryTypes.SELECT
});
return results;
};
Question.getRandomQuestions = async function(categoryId, count = 10, difficulty = null, guestAccessible = false) {
const where = {
categoryId,
isActive: true
};
if (difficulty) {
where.difficulty = difficulty;
}
if (guestAccessible) {
where.guestAccessible = true;
}
return await this.findAll({
where,
order: sequelize.random(),
limit: count
});
};
Question.getQuestionsByCategory = async function(categoryId, options = {}) {
const where = {
categoryId,
isActive: true
};
if (options.difficulty) {
where.difficulty = options.difficulty;
}
if (options.guestAccessible !== undefined) {
where.guestAccessible = options.guestAccessible;
}
const queryOptions = {
where,
order: options.random ? sequelize.random() : [['createdAt', 'DESC']]
};
if (options.limit) {
queryOptions.limit = options.limit;
}
if (options.offset) {
queryOptions.offset = options.offset;
}
return await this.findAll(queryOptions);
};
// Hooks
Question.beforeValidate((question) => {
// Ensure UUID is set
if (!question.id) {
question.id = uuidv4();
}
// Validate options for multiple choice questions
if (question.questionType === 'multiple') {
if (!question.options || !Array.isArray(question.options) || question.options.length < 2) {
throw new Error('Multiple choice questions must have at least 2 options');
}
}
// Validate trueFalse questions
if (question.questionType === 'trueFalse') {
if (!['true', 'false'].includes(question.correctAnswer.toLowerCase())) {
throw new Error('True/False questions must have "true" or "false" as correct answer');
}
}
// Set points based on difficulty if not explicitly provided in creation
if (question.isNewRecord && !question.changed('points')) {
const pointsMap = {
easy: 10,
medium: 20,
hard: 30
};
question.points = pointsMap[question.difficulty] || 10;
}
});
// Define associations
Question.associate = function(models) {
// Question belongs to a category
Question.belongsTo(models.Category, {
foreignKey: 'categoryId',
as: 'category'
});
// Question belongs to a user (creator)
if (models.User) {
Question.belongsTo(models.User, {
foreignKey: 'createdBy',
as: 'creator'
});
}
// Question has many quiz answers
if (models.QuizAnswer) {
Question.hasMany(models.QuizAnswer, {
foreignKey: 'questionId',
as: 'answers'
});
}
// Question belongs to many quiz sessions through quiz_session_questions
if (models.QuizSession && models.QuizSessionQuestion) {
Question.belongsToMany(models.QuizSession, {
through: models.QuizSessionQuestion,
foreignKey: 'questionId',
otherKey: 'quizSessionId',
as: 'quizSessions'
});
}
// Question belongs to many users through bookmarks
if (models.User && models.UserBookmark) {
Question.belongsToMany(models.User, {
through: models.UserBookmark,
foreignKey: 'questionId',
otherKey: 'userId',
as: 'bookmarkedBy'
});
}
};
return Question;
};

View File

@@ -0,0 +1,608 @@
const { DataTypes } = require('sequelize');
const { v4: uuidv4 } = require('uuid');
module.exports = (sequelize) => {
const QuizSession = sequelize.define('QuizSession', {
id: {
type: DataTypes.CHAR(36),
primaryKey: true,
allowNull: false
},
userId: {
type: DataTypes.CHAR(36),
allowNull: true,
field: 'user_id'
},
guestSessionId: {
type: DataTypes.CHAR(36),
allowNull: true,
field: 'guest_session_id'
},
categoryId: {
type: DataTypes.CHAR(36),
allowNull: false,
field: 'category_id'
},
quizType: {
type: DataTypes.ENUM('practice', 'timed', 'exam'),
allowNull: false,
defaultValue: 'practice',
field: 'quiz_type',
validate: {
isIn: {
args: [['practice', 'timed', 'exam']],
msg: 'Quiz type must be practice, timed, or exam'
}
}
},
difficulty: {
type: DataTypes.ENUM('easy', 'medium', 'hard', 'mixed'),
allowNull: false,
defaultValue: 'mixed',
validate: {
isIn: {
args: [['easy', 'medium', 'hard', 'mixed']],
msg: 'Difficulty must be easy, medium, hard, or mixed'
}
}
},
totalQuestions: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 10,
field: 'total_questions',
validate: {
min: {
args: [1],
msg: 'Total questions must be at least 1'
},
max: {
args: [100],
msg: 'Total questions cannot exceed 100'
}
}
},
questionsAnswered: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
field: 'questions_answered'
},
correctAnswers: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
field: 'correct_answers'
},
score: {
type: DataTypes.DECIMAL(5, 2),
allowNull: false,
defaultValue: 0.00,
validate: {
min: {
args: [0],
msg: 'Score cannot be negative'
},
max: {
args: [100],
msg: 'Score cannot exceed 100'
}
}
},
totalPoints: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
field: 'total_points'
},
maxPoints: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
field: 'max_points'
},
timeLimit: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: true,
field: 'time_limit',
validate: {
min: {
args: [60],
msg: 'Time limit must be at least 60 seconds'
}
}
},
timeSpent: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
field: 'time_spent'
},
startedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'started_at'
},
completedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'completed_at'
},
status: {
type: DataTypes.ENUM('not_started', 'in_progress', 'completed', 'abandoned', 'timed_out'),
allowNull: false,
defaultValue: 'not_started',
validate: {
isIn: {
args: [['not_started', 'in_progress', 'completed', 'abandoned', 'timed_out']],
msg: 'Status must be not_started, in_progress, completed, abandoned, or timed_out'
}
}
},
isPassed: {
type: DataTypes.BOOLEAN,
allowNull: true,
field: 'is_passed'
},
passPercentage: {
type: DataTypes.DECIMAL(5, 2),
allowNull: false,
defaultValue: 70.00,
field: 'pass_percentage',
validate: {
min: {
args: [0],
msg: 'Pass percentage cannot be negative'
},
max: {
args: [100],
msg: 'Pass percentage cannot exceed 100'
}
}
}
}, {
tableName: 'quiz_sessions',
timestamps: true,
underscored: true,
hooks: {
beforeValidate: (session) => {
// Generate UUID if not provided
if (!session.id) {
session.id = uuidv4();
}
// Validate that either userId or guestSessionId is provided, but not both
if (!session.userId && !session.guestSessionId) {
throw new Error('Either userId or guestSessionId must be provided');
}
if (session.userId && session.guestSessionId) {
throw new Error('Cannot have both userId and guestSessionId');
}
// Set started_at when status changes to in_progress
if (session.status === 'in_progress' && !session.startedAt) {
session.startedAt = new Date();
}
// Set completed_at when status changes to completed, abandoned, or timed_out
if (['completed', 'abandoned', 'timed_out'].includes(session.status) && !session.completedAt) {
session.completedAt = new Date();
}
}
}
});
// Instance Methods
/**
* Start the quiz session
*/
QuizSession.prototype.start = async function() {
if (this.status !== 'not_started') {
throw new Error('Quiz has already been started');
}
this.status = 'in_progress';
this.startedAt = new Date();
await this.save();
return this;
};
/**
* Complete the quiz session and calculate final score
*/
QuizSession.prototype.complete = async function() {
if (this.status !== 'in_progress') {
throw new Error('Quiz is not in progress');
}
this.status = 'completed';
this.completedAt = new Date();
// Calculate final score
this.calculateScore();
// Determine if passed
this.isPassed = this.score >= this.passPercentage;
await this.save();
return this;
};
/**
* Abandon the quiz session
*/
QuizSession.prototype.abandon = async function() {
if (this.status !== 'in_progress') {
throw new Error('Can only abandon a quiz that is in progress');
}
this.status = 'abandoned';
this.completedAt = new Date();
await this.save();
return this;
};
/**
* Mark quiz as timed out
*/
QuizSession.prototype.timeout = async function() {
if (this.status !== 'in_progress') {
throw new Error('Can only timeout a quiz that is in progress');
}
this.status = 'timed_out';
this.completedAt = new Date();
// Calculate score with answered questions
this.calculateScore();
this.isPassed = this.score >= this.passPercentage;
await this.save();
return this;
};
/**
* Calculate score based on correct answers
*/
QuizSession.prototype.calculateScore = function() {
if (this.totalQuestions === 0) {
this.score = 0;
return 0;
}
// Score as percentage
this.score = ((this.correctAnswers / this.totalQuestions) * 100).toFixed(2);
return parseFloat(this.score);
};
/**
* Record an answer for a question
* @param {boolean} isCorrect - Whether the answer was correct
* @param {number} points - Points earned for this question
*/
QuizSession.prototype.recordAnswer = async function(isCorrect, points = 0) {
if (this.status !== 'in_progress') {
throw new Error('Cannot record answer for a quiz that is not in progress');
}
this.questionsAnswered += 1;
if (isCorrect) {
this.correctAnswers += 1;
this.totalPoints += points;
}
// Auto-complete if all questions answered
if (this.questionsAnswered >= this.totalQuestions) {
return await this.complete();
}
await this.save();
return this;
};
/**
* Update time spent on quiz
* @param {number} seconds - Seconds to add to time spent
*/
QuizSession.prototype.updateTimeSpent = async function(seconds) {
this.timeSpent += seconds;
// Check if timed out
if (this.timeLimit && this.timeSpent >= this.timeLimit && this.status === 'in_progress') {
return await this.timeout();
}
await this.save();
return this;
};
/**
* Get quiz progress information
*/
QuizSession.prototype.getProgress = function() {
return {
id: this.id,
status: this.status,
totalQuestions: this.totalQuestions,
questionsAnswered: this.questionsAnswered,
questionsRemaining: this.totalQuestions - this.questionsAnswered,
progressPercentage: ((this.questionsAnswered / this.totalQuestions) * 100).toFixed(2),
correctAnswers: this.correctAnswers,
currentAccuracy: this.questionsAnswered > 0
? ((this.correctAnswers / this.questionsAnswered) * 100).toFixed(2)
: 0,
timeSpent: this.timeSpent,
timeLimit: this.timeLimit,
timeRemaining: this.timeLimit ? Math.max(0, this.timeLimit - this.timeSpent) : null,
startedAt: this.startedAt,
isTimedOut: this.timeLimit && this.timeSpent >= this.timeLimit
};
};
/**
* Get quiz results summary
*/
QuizSession.prototype.getResults = function() {
if (this.status === 'not_started' || this.status === 'in_progress') {
throw new Error('Quiz is not completed yet');
}
return {
id: this.id,
status: this.status,
quizType: this.quizType,
difficulty: this.difficulty,
totalQuestions: this.totalQuestions,
questionsAnswered: this.questionsAnswered,
correctAnswers: this.correctAnswers,
score: parseFloat(this.score),
totalPoints: this.totalPoints,
maxPoints: this.maxPoints,
isPassed: this.isPassed,
passPercentage: parseFloat(this.passPercentage),
timeSpent: this.timeSpent,
timeLimit: this.timeLimit,
startedAt: this.startedAt,
completedAt: this.completedAt,
duration: this.completedAt && this.startedAt
? Math.floor((this.completedAt - this.startedAt) / 1000)
: 0
};
};
/**
* Check if quiz is currently active
*/
QuizSession.prototype.isActive = function() {
return this.status === 'in_progress';
};
/**
* Check if quiz is completed (any terminal state)
*/
QuizSession.prototype.isCompleted = function() {
return ['completed', 'abandoned', 'timed_out'].includes(this.status);
};
// Class Methods
/**
* Create a new quiz session
* @param {Object} options - Quiz session options
*/
QuizSession.createSession = async function(options) {
const {
userId,
guestSessionId,
categoryId,
quizType = 'practice',
difficulty = 'mixed',
totalQuestions = 10,
timeLimit = null,
passPercentage = 70.00
} = options;
return await QuizSession.create({
userId,
guestSessionId,
categoryId,
quizType,
difficulty,
totalQuestions,
timeLimit,
passPercentage,
status: 'not_started'
});
};
/**
* Find active session for a user
* @param {string} userId - User ID
*/
QuizSession.findActiveForUser = async function(userId) {
return await QuizSession.findOne({
where: {
userId,
status: 'in_progress'
},
order: [['started_at', 'DESC']]
});
};
/**
* Find active session for a guest
* @param {string} guestSessionId - Guest session ID
*/
QuizSession.findActiveForGuest = async function(guestSessionId) {
return await QuizSession.findOne({
where: {
guestSessionId,
status: 'in_progress'
},
order: [['started_at', 'DESC']]
});
};
/**
* Get user quiz history
* @param {string} userId - User ID
* @param {number} limit - Number of results to return
*/
QuizSession.getUserHistory = async function(userId, limit = 10) {
return await QuizSession.findAll({
where: {
userId,
status: ['completed', 'abandoned', 'timed_out']
},
order: [['completed_at', 'DESC']],
limit
});
};
/**
* Get guest quiz history
* @param {string} guestSessionId - Guest session ID
* @param {number} limit - Number of results to return
*/
QuizSession.getGuestHistory = async function(guestSessionId, limit = 10) {
return await QuizSession.findAll({
where: {
guestSessionId,
status: ['completed', 'abandoned', 'timed_out']
},
order: [['completed_at', 'DESC']],
limit
});
};
/**
* Get user statistics
* @param {string} userId - User ID
*/
QuizSession.getUserStats = async function(userId) {
const { Op } = require('sequelize');
const sessions = await QuizSession.findAll({
where: {
userId,
status: 'completed'
}
});
if (sessions.length === 0) {
return {
totalQuizzes: 0,
averageScore: 0,
passRate: 0,
totalTimeSpent: 0
};
}
const totalQuizzes = sessions.length;
const totalScore = sessions.reduce((sum, s) => sum + parseFloat(s.score), 0);
const passedQuizzes = sessions.filter(s => s.isPassed).length;
const totalTimeSpent = sessions.reduce((sum, s) => sum + s.timeSpent, 0);
return {
totalQuizzes,
averageScore: (totalScore / totalQuizzes).toFixed(2),
passRate: ((passedQuizzes / totalQuizzes) * 100).toFixed(2),
totalTimeSpent,
passedQuizzes
};
};
/**
* Get category statistics
* @param {string} categoryId - Category ID
*/
QuizSession.getCategoryStats = async function(categoryId) {
const sessions = await QuizSession.findAll({
where: {
categoryId,
status: 'completed'
}
});
if (sessions.length === 0) {
return {
totalAttempts: 0,
averageScore: 0,
passRate: 0
};
}
const totalAttempts = sessions.length;
const totalScore = sessions.reduce((sum, s) => sum + parseFloat(s.score), 0);
const passedAttempts = sessions.filter(s => s.isPassed).length;
return {
totalAttempts,
averageScore: (totalScore / totalAttempts).toFixed(2),
passRate: ((passedAttempts / totalAttempts) * 100).toFixed(2),
passedAttempts
};
};
/**
* Clean up abandoned sessions older than specified days
* @param {number} days - Number of days (default 7)
*/
QuizSession.cleanupAbandoned = async function(days = 7) {
const { Op } = require('sequelize');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
const deleted = await QuizSession.destroy({
where: {
status: ['not_started', 'abandoned'],
createdAt: {
[Op.lt]: cutoffDate
}
}
});
return deleted;
};
// Associations
QuizSession.associate = (models) => {
// Quiz session belongs to a user (optional, null for guests)
QuizSession.belongsTo(models.User, {
foreignKey: 'userId',
as: 'user'
});
// Quiz session belongs to a guest session (optional, null for users)
QuizSession.belongsTo(models.GuestSession, {
foreignKey: 'guestSessionId',
as: 'guestSession'
});
// Quiz session belongs to a category
QuizSession.belongsTo(models.Category, {
foreignKey: 'categoryId',
as: 'category'
});
// Quiz session has many quiz session questions (junction table for questions)
if (models.QuizSessionQuestion) {
QuizSession.hasMany(models.QuizSessionQuestion, {
foreignKey: 'quizSessionId',
as: 'sessionQuestions'
});
}
// Quiz session has many quiz answers
if (models.QuizAnswer) {
QuizSession.hasMany(models.QuizAnswer, {
foreignKey: 'quizSessionId',
as: 'answers'
});
}
};
return QuizSession;
};

333
backend/models/User.js Normal file
View File

@@ -0,0 +1,333 @@
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
module.exports = (sequelize, DataTypes) => {
const User = sequelize.define('User', {
id: {
type: DataTypes.CHAR(36),
primaryKey: true,
defaultValue: () => uuidv4(),
allowNull: false,
comment: 'UUID primary key'
},
username: {
type: DataTypes.STRING(50),
allowNull: false,
unique: {
msg: 'Username already exists'
},
validate: {
notEmpty: {
msg: 'Username cannot be empty'
},
len: {
args: [3, 50],
msg: 'Username must be between 3 and 50 characters'
},
isAlphanumeric: {
msg: 'Username must contain only letters and numbers'
}
},
comment: 'Unique username'
},
email: {
type: DataTypes.STRING(100),
allowNull: false,
unique: {
msg: 'Email already exists'
},
validate: {
notEmpty: {
msg: 'Email cannot be empty'
},
isEmail: {
msg: 'Must be a valid email address'
}
},
comment: 'User email address'
},
password: {
type: DataTypes.STRING(255),
allowNull: false,
validate: {
notEmpty: {
msg: 'Password cannot be empty'
},
len: {
args: [6, 255],
msg: 'Password must be at least 6 characters'
}
},
comment: 'Hashed password'
},
role: {
type: DataTypes.ENUM('admin', 'user'),
allowNull: false,
defaultValue: 'user',
validate: {
isIn: {
args: [['admin', 'user']],
msg: 'Role must be either admin or user'
}
},
comment: 'User role'
},
profileImage: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'profile_image',
comment: 'Profile image URL'
},
isActive: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
field: 'is_active',
comment: 'Account active status'
},
// Statistics
totalQuizzes: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'total_quizzes',
validate: {
min: 0
},
comment: 'Total number of quizzes taken'
},
quizzesPassed: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'quizzes_passed',
validate: {
min: 0
},
comment: 'Number of quizzes passed'
},
totalQuestionsAnswered: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'total_questions_answered',
validate: {
min: 0
},
comment: 'Total questions answered'
},
correctAnswers: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'correct_answers',
validate: {
min: 0
},
comment: 'Number of correct answers'
},
currentStreak: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'current_streak',
validate: {
min: 0
},
comment: 'Current daily streak'
},
longestStreak: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'longest_streak',
validate: {
min: 0
},
comment: 'Longest daily streak achieved'
},
// Timestamps
lastLogin: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_login',
comment: 'Last login timestamp'
},
lastQuizDate: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_quiz_date',
comment: 'Date of last quiz taken'
}
}, {
sequelize,
modelName: 'User',
tableName: 'users',
timestamps: true,
underscored: true,
indexes: [
{
unique: true,
fields: ['email']
},
{
unique: true,
fields: ['username']
},
{
fields: ['role']
},
{
fields: ['is_active']
},
{
fields: ['created_at']
}
]
});
// Instance methods
User.prototype.comparePassword = async function(candidatePassword) {
try {
return await bcrypt.compare(candidatePassword, this.password);
} catch (error) {
throw new Error('Password comparison failed');
}
};
User.prototype.toJSON = function() {
const values = { ...this.get() };
delete values.password; // Never expose password in JSON
return values;
};
User.prototype.updateStreak = function() {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (this.lastQuizDate) {
const lastQuiz = new Date(this.lastQuizDate);
lastQuiz.setHours(0, 0, 0, 0);
const daysDiff = Math.floor((today - lastQuiz) / (1000 * 60 * 60 * 24));
if (daysDiff === 1) {
// Consecutive day - increment streak
this.currentStreak += 1;
if (this.currentStreak > this.longestStreak) {
this.longestStreak = this.currentStreak;
}
} else if (daysDiff > 1) {
// Streak broken - reset
this.currentStreak = 1;
}
// If daysDiff === 0, same day - no change to streak
} else {
// First quiz
this.currentStreak = 1;
this.longestStreak = 1;
}
this.lastQuizDate = new Date();
};
User.prototype.calculateAccuracy = function() {
if (this.totalQuestionsAnswered === 0) return 0;
return ((this.correctAnswers / this.totalQuestionsAnswered) * 100).toFixed(2);
};
User.prototype.getPassRate = function() {
if (this.totalQuizzes === 0) return 0;
return ((this.quizzesPassed / this.totalQuizzes) * 100).toFixed(2);
};
User.prototype.toSafeJSON = function() {
const values = { ...this.get() };
delete values.password;
return values;
};
// Class methods
User.findByEmail = async function(email) {
return await this.findOne({ where: { email, isActive: true } });
};
User.findByUsername = async function(username) {
return await this.findOne({ where: { username, isActive: true } });
};
// Hooks
User.beforeCreate(async (user) => {
// Hash password before creating user
if (user.password) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
// Ensure UUID is set
if (!user.id) {
user.id = uuidv4();
}
});
User.beforeUpdate(async (user) => {
// Hash password if it was changed
if (user.changed('password')) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
});
User.beforeBulkCreate(async (users) => {
for (const user of users) {
if (user.password) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
if (!user.id) {
user.id = uuidv4();
}
}
});
// Define associations
User.associate = function(models) {
// User has many quiz sessions (when QuizSession model exists)
if (models.QuizSession) {
User.hasMany(models.QuizSession, {
foreignKey: 'userId',
as: 'quizSessions'
});
}
// User has many bookmarks (when Question model exists)
if (models.Question) {
User.belongsToMany(models.Question, {
through: 'user_bookmarks',
foreignKey: 'userId',
otherKey: 'questionId',
as: 'bookmarkedQuestions'
});
// User has created questions (if admin)
User.hasMany(models.Question, {
foreignKey: 'createdBy',
as: 'createdQuestions'
});
}
// User has many achievements (when Achievement model exists)
if (models.Achievement) {
User.belongsToMany(models.Achievement, {
through: 'user_achievements',
foreignKey: 'userId',
otherKey: 'achievementId',
as: 'achievements'
});
}
};
return User;
};

57
backend/models/index.js Normal file
View File

@@ -0,0 +1,57 @@
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require('../config/database')[env];
const db = {};
let sequelize;
if (config.use_env_variable) {
sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
sequelize = new Sequelize(config.database, config.username, config.password, config);
}
// Import all model files
fs
.readdirSync(__dirname)
.filter(file => {
return (
file.indexOf('.') !== 0 &&
file !== basename &&
file.slice(-3) === '.js' &&
file.indexOf('.test.js') === -1
);
})
.forEach(file => {
const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes);
db[model.name] = model;
});
// Setup model associations
Object.keys(db).forEach(modelName => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
// Test database connection
const testConnection = async () => {
try {
await sequelize.authenticate();
console.log('✅ Database connection established successfully.');
return true;
} catch (error) {
console.error('❌ Unable to connect to the database:', error.message);
return false;
}
};
// Export connection test function
db.testConnection = testConnection;
module.exports = db;

70
backend/package.json Normal file
View File

@@ -0,0 +1,70 @@
{
"name": "interview-quiz-backend",
"version": "2.0.0",
"description": "Technical Interview Quiz Application - MySQL Edition",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "jest --coverage",
"test:watch": "jest --watch",
"test:db": "node test-db-connection.js",
"test:user": "node test-user-model.js",
"test:category": "node test-category-model.js",
"test:question": "node test-question-model.js",
"test:guest": "node test-guest-session-model.js",
"test:quiz": "node test-quiz-session-model.js",
"test:junction": "node test-junction-tables.js",
"test:auth": "node test-auth-endpoints.js",
"test:logout": "node test-logout-verify.js",
"test:guest-api": "node test-guest-endpoints.js",
"test:guest-limit": "node test-guest-quiz-limit.js",
"test:guest-convert": "node test-guest-conversion.js",
"test:categories": "node test-category-endpoints.js",
"test:category-details": "node test-category-details.js",
"test:category-admin": "node test-category-admin.js",
"test:questions-by-category": "node test-questions-by-category.js",
"test:question-by-id": "node test-question-by-id.js",
"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",
"validate:env": "node validate-env.js",
"generate:jwt": "node generate-jwt-secret.js",
"migrate": "npx sequelize-cli db:migrate",
"migrate:undo": "npx sequelize-cli db:migrate:undo",
"migrate:status": "npx sequelize-cli db:migrate:status",
"seed": "npx sequelize-cli db:seed:all",
"seed:undo": "npx sequelize-cli db:seed:undo:all"
},
"keywords": [
"quiz",
"interview",
"mysql",
"sequelize",
"express",
"nodejs"
],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.13.2",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"mysql2": "^3.6.5",
"sequelize": "^6.35.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"jest": "^29.7.0",
"nodemon": "^3.0.2",
"sequelize-cli": "^6.6.2",
"supertest": "^6.3.3"
}
}

View File

@@ -0,0 +1,35 @@
const express = require('express');
const router = express.Router();
const questionController = require('../controllers/question.controller');
const { verifyToken, isAdmin } = require('../middleware/auth.middleware');
/**
* @route POST /api/admin/questions
* @desc Create a new question (Admin only)
* @access Admin
* @body {
* questionText, questionType, options, correctAnswer,
* difficulty, points, explanation, categoryId, tags, keywords
* }
*/
router.post('/questions', verifyToken, isAdmin, questionController.createQuestion);
/**
* @route PUT /api/admin/questions/:id
* @desc Update a question (Admin only)
* @access Admin
* @body {
* questionText?, questionType?, options?, correctAnswer?,
* difficulty?, points?, explanation?, categoryId?, tags?, keywords?, isActive?
* }
*/
router.put('/questions/:id', verifyToken, isAdmin, questionController.updateQuestion);
/**
* @route DELETE /api/admin/questions/:id
* @desc Delete a question - soft delete (Admin only)
* @access Admin
*/
router.delete('/questions/:id', verifyToken, isAdmin, questionController.deleteQuestion);
module.exports = router;

View File

@@ -0,0 +1,35 @@
const express = require('express');
const router = express.Router();
const authController = require('../controllers/auth.controller');
const { validateRegistration, validateLogin } = require('../middleware/validation.middleware');
const { verifyToken } = require('../middleware/auth.middleware');
/**
* @route POST /api/auth/register
* @desc Register a new user
* @access Public
*/
router.post('/register', validateRegistration, authController.register);
/**
* @route POST /api/auth/login
* @desc Login user
* @access Public
*/
router.post('/login', validateLogin, authController.login);
/**
* @route POST /api/auth/logout
* @desc Logout user (client-side token removal)
* @access Public
*/
router.post('/logout', authController.logout);
/**
* @route GET /api/auth/verify
* @desc Verify JWT token and return user info
* @access Private
*/
router.get('/verify', verifyToken, authController.verifyToken);
module.exports = router;

View File

@@ -0,0 +1,41 @@
const express = require('express');
const router = express.Router();
const categoryController = require('../controllers/category.controller');
const authMiddleware = require('../middleware/auth.middleware');
/**
* @route GET /api/categories
* @desc Get all active categories (guest sees only guest-accessible, auth sees all)
* @access Public (optional auth)
*/
router.get('/', authMiddleware.optionalAuth, categoryController.getAllCategories);
/**
* @route GET /api/categories/:id
* @desc Get category details with question preview and stats
* @access Public (optional auth, some categories require auth)
*/
router.get('/:id', authMiddleware.optionalAuth, categoryController.getCategoryById);
/**
* @route POST /api/categories
* @desc Create new category
* @access Private/Admin
*/
router.post('/', authMiddleware.verifyToken, authMiddleware.isAdmin, categoryController.createCategory);
/**
* @route PUT /api/categories/:id
* @desc Update category
* @access Private/Admin
*/
router.put('/:id', authMiddleware.verifyToken, authMiddleware.isAdmin, categoryController.updateCategory);
/**
* @route DELETE /api/categories/:id
* @desc Delete category (soft delete)
* @access Private/Admin
*/
router.delete('/:id', authMiddleware.verifyToken, authMiddleware.isAdmin, categoryController.deleteCategory);
module.exports = router;

View File

@@ -0,0 +1,34 @@
const express = require('express');
const router = express.Router();
const guestController = require('../controllers/guest.controller');
const guestMiddleware = require('../middleware/guest.middleware');
/**
* @route POST /api/guest/start-session
* @desc Start a new guest session
* @access Public
*/
router.post('/start-session', guestController.startGuestSession);
/**
* @route GET /api/guest/session/:guestId
* @desc Get guest session details
* @access Public
*/
router.get('/session/:guestId', guestController.getGuestSession);
/**
* @route GET /api/guest/quiz-limit
* @desc Check guest quiz limit and remaining quizzes
* @access Protected (Guest Token Required)
*/
router.get('/quiz-limit', guestMiddleware.verifyGuestToken, guestController.checkQuizLimit);
/**
* @route POST /api/guest/convert
* @desc Convert guest session to registered user account
* @access Protected (Guest Token Required)
*/
router.post('/convert', guestMiddleware.verifyGuestToken, guestController.convertGuestToUser);
module.exports = router;

View File

@@ -0,0 +1,35 @@
const express = require('express');
const router = express.Router();
const questionController = require('../controllers/question.controller');
const { optionalAuth } = require('../middleware/auth.middleware');
/**
* @route GET /api/questions/search
* @desc Search questions using full-text search
* @access Public (with optional auth for more questions)
* @query q - Search query (required)
* @query category - Filter by category UUID (optional)
* @query difficulty - Filter by difficulty (easy, medium, hard) (optional)
* @query limit - Number of results per page (default: 20, max: 100)
* @query page - Page number (default: 1)
*/
router.get('/search', optionalAuth, questionController.searchQuestions);
/**
* @route GET /api/questions/category/:categoryId
* @desc Get questions by category with filtering
* @access Public (with optional auth for more questions)
* @query difficulty - Filter by difficulty (easy, medium, hard)
* @query limit - Number of questions to return (default: 10, max: 50)
* @query random - Boolean to randomize questions (default: false)
*/
router.get('/category/:categoryId', optionalAuth, questionController.getQuestionsByCategory);
/**
* @route GET /api/questions/:id
* @desc Get single question by ID
* @access Public (with optional auth for auth-only questions)
*/
router.get('/:id', optionalAuth, questionController.getQuestionById);
module.exports = router;

View File

@@ -0,0 +1,123 @@
'use strict';
const { v4: uuidv4 } = require('uuid');
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
const categories = [
{
id: uuidv4(),
name: 'JavaScript',
slug: 'javascript',
description: 'Core JavaScript concepts, ES6+, async programming, and modern features',
icon: '🟨',
color: '#F7DF1E',
is_active: true,
guest_accessible: true,
question_count: 0,
quiz_count: 0,
display_order: 1,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Angular',
slug: 'angular',
description: 'Angular framework, components, services, RxJS, and state management',
icon: '🅰️',
color: '#DD0031',
is_active: true,
guest_accessible: true,
question_count: 0,
quiz_count: 0,
display_order: 2,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'React',
slug: 'react',
description: 'React library, hooks, component lifecycle, state management, and best practices',
icon: '⚛️',
color: '#61DAFB',
is_active: true,
guest_accessible: true,
question_count: 0,
quiz_count: 0,
display_order: 3,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Node.js',
slug: 'nodejs',
description: 'Node.js runtime, Express, APIs, middleware, and server-side JavaScript',
icon: '🟢',
color: '#339933',
is_active: true,
guest_accessible: false,
question_count: 0,
quiz_count: 0,
display_order: 4,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'TypeScript',
slug: 'typescript',
description: 'TypeScript types, interfaces, generics, decorators, and type safety',
icon: '📘',
color: '#3178C6',
is_active: true,
guest_accessible: false,
question_count: 0,
quiz_count: 0,
display_order: 5,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'SQL & Databases',
slug: 'sql-databases',
description: 'SQL queries, database design, indexing, transactions, and optimization',
icon: '🗄️',
color: '#4479A1',
is_active: true,
guest_accessible: false,
question_count: 0,
quiz_count: 0,
display_order: 6,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'System Design',
slug: 'system-design',
description: 'Scalability, architecture patterns, microservices, and design principles',
icon: '🏗️',
color: '#FF6B6B',
is_active: true,
guest_accessible: false,
question_count: 0,
quiz_count: 0,
display_order: 7,
created_at: new Date(),
updated_at: new Date()
}
];
await queryInterface.bulkInsert('categories', categories, {});
console.log('✅ Seeded 7 demo categories');
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('categories', null, {});
console.log('✅ Removed demo categories');
}
};

View File

@@ -0,0 +1,38 @@
'use strict';
const { v4: uuidv4 } = require('uuid');
const bcrypt = require('bcrypt');
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
const hashedPassword = await bcrypt.hash('Admin@123', 10);
const adminUser = {
id: uuidv4(),
username: 'admin',
email: 'admin@quiz.com',
password: hashedPassword,
role: 'admin',
profile_image: null,
is_active: true,
total_quizzes: 0,
quizzes_passed: 0,
total_questions_answered: 0,
correct_answers: 0,
current_streak: 0,
longest_streak: 0,
last_login: null,
last_quiz_date: null,
created_at: new Date(),
updated_at: new Date()
};
await queryInterface.bulkInsert('users', [adminUser], {});
console.log('✅ Seeded admin user (email: admin@quiz.com, password: Admin@123)');
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('users', { email: 'admin@quiz.com' }, {});
console.log('✅ Removed admin user');
}
};

View File

@@ -0,0 +1,947 @@
'use strict';
const { v4: uuidv4 } = require('uuid');
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// First, get the category IDs we need
const [categories] = await queryInterface.sequelize.query(
`SELECT id, slug FROM categories WHERE slug IN ('javascript', 'angular', 'react', 'nodejs', 'typescript', 'sql-databases', 'system-design')`
);
const categoryMap = {};
categories.forEach(cat => {
categoryMap[cat.slug] = cat.id;
});
// Get admin user ID for created_by
const [users] = await queryInterface.sequelize.query(
`SELECT id FROM users WHERE email = 'admin@quiz.com' LIMIT 1`
);
const adminId = users[0]?.id || null;
const questions = [];
// JavaScript Questions (15 questions)
const jsQuestions = [
{
id: uuidv4(),
category_id: categoryMap['javascript'],
question_text: 'What is the difference between let and var in JavaScript?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'let has block scope, var has function scope' },
{ id: 'b', text: 'var has block scope, let has function scope' },
{ id: 'c', text: 'They are exactly the same' },
{ id: 'd', text: 'let cannot be reassigned' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'let has block scope (only accessible within {}), while var has function scope (accessible anywhere in the function).',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['scope', 'let', 'var', 'es6']),
tags: JSON.stringify(['variables', 'scope', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['javascript'],
question_text: 'What is a closure in JavaScript?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'A function that returns another function' },
{ id: 'b', text: 'A function that has access to variables from its outer scope' },
{ id: 'c', text: 'A function that closes the browser' },
{ id: 'd', text: 'A method to close database connections' }
]),
correct_answer: JSON.stringify(['b']),
explanation: 'A closure is a function that remembers and can access variables from its outer (enclosing) scope, even after the outer function has finished executing.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['closure', 'scope', 'lexical']),
tags: JSON.stringify(['functions', 'scope', 'advanced']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['javascript'],
question_text: 'What does the spread operator (...) do in JavaScript?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Creates a copy of an array or object' },
{ id: 'b', text: 'Expands an iterable into individual elements' },
{ id: 'c', text: 'Both A and B' },
{ id: 'd', text: 'Performs mathematical operations' }
]),
correct_answer: JSON.stringify(['c']),
explanation: 'The spread operator (...) can expand iterables into individual elements and is commonly used to copy arrays/objects or pass elements as function arguments.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['spread', 'operator', 'es6', 'array']),
tags: JSON.stringify(['operators', 'es6', 'arrays']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['javascript'],
question_text: 'What is the purpose of Promise.all()?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Waits for all promises to resolve or any to reject' },
{ id: 'b', text: 'Runs promises sequentially' },
{ id: 'c', text: 'Cancels all promises' },
{ id: 'd', text: 'Returns the first resolved promise' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Promise.all() takes an array of promises and returns a single promise that resolves when all promises resolve, or rejects when any promise rejects.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['promise', 'async', 'concurrent']),
tags: JSON.stringify(['promises', 'async', 'concurrency']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['javascript'],
question_text: 'What is event delegation in JavaScript?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Attaching event listeners to parent elements to handle events on children' },
{ id: 'b', text: 'Creating custom events' },
{ id: 'c', text: 'Removing event listeners' },
{ id: 'd', text: 'Preventing event propagation' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Event delegation uses event bubbling to handle events on child elements by attaching a single listener to a parent element, improving performance.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['event', 'delegation', 'bubbling', 'dom']),
tags: JSON.stringify(['events', 'dom', 'patterns']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
}
];
// Angular Questions (12 questions)
const angularQuestions = [
{
id: uuidv4(),
category_id: categoryMap['angular'],
question_text: 'What is the purpose of NgModule in Angular?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'To organize application structure and define compilation context' },
{ id: 'b', text: 'To create components' },
{ id: 'c', text: 'To handle routing' },
{ id: 'd', text: 'To manage state' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'NgModule is a decorator that defines a module - a cohesive block of code with related components, directives, pipes, and services. It organizes the application.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['ngmodule', 'module', 'decorator']),
tags: JSON.stringify(['modules', 'architecture', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['angular'],
question_text: 'What is dependency injection in Angular?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'A design pattern where dependencies are provided to a class instead of creating them internally' },
{ id: 'b', text: 'A way to import modules' },
{ id: 'c', text: 'A routing technique' },
{ id: 'd', text: 'A method to create components' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Dependency Injection (DI) is a design pattern where Angular provides dependencies (services) to components/services through their constructors, promoting loose coupling.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['di', 'dependency injection', 'service', 'provider']),
tags: JSON.stringify(['di', 'services', 'architecture']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['angular'],
question_text: 'What is the difference between @Input() and @Output() decorators?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: '@Input() receives data from parent, @Output() emits events to parent' },
{ id: 'b', text: '@Input() emits events, @Output() receives data' },
{ id: 'c', text: 'They are the same' },
{ id: 'd', text: '@Input() is for services, @Output() is for components' }
]),
correct_answer: JSON.stringify(['a']),
explanation: '@Input() allows a child component to receive data from its parent, while @Output() with EventEmitter allows a child to emit events to its parent.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['input', 'output', 'decorator', 'communication']),
tags: JSON.stringify(['decorators', 'component-communication', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['angular'],
question_text: 'What is RxJS used for in Angular?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Reactive programming with Observables for async operations' },
{ id: 'b', text: 'Styling components' },
{ id: 'c', text: 'Creating animations' },
{ id: 'd', text: 'Testing components' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'RxJS provides reactive programming capabilities using Observables, which are used extensively in Angular for handling async operations like HTTP requests and events.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['rxjs', 'observable', 'reactive', 'async']),
tags: JSON.stringify(['rxjs', 'async', 'observables']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['angular'],
question_text: 'What is the purpose of Angular lifecycle hooks?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'To tap into key moments in component/directive lifecycle' },
{ id: 'b', text: 'To create routes' },
{ id: 'c', text: 'To style components' },
{ id: 'd', text: 'To handle errors' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Lifecycle hooks like ngOnInit, ngOnChanges, and ngOnDestroy allow you to execute code at specific points in a component or directive\'s lifecycle.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['lifecycle', 'hooks', 'ngoninit']),
tags: JSON.stringify(['lifecycle', 'hooks', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
}
];
// React Questions (12 questions)
const reactQuestions = [
{
id: uuidv4(),
category_id: categoryMap['react'],
question_text: 'What is the virtual DOM in React?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'A lightweight copy of the real DOM kept in memory' },
{ id: 'b', text: 'A database for storing component state' },
{ id: 'c', text: 'A routing mechanism' },
{ id: 'd', text: 'A testing library' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'The virtual DOM is a lightweight JavaScript representation of the real DOM. React uses it to optimize updates by comparing changes and updating only what\'s necessary.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['virtual dom', 'reconciliation', 'performance']),
tags: JSON.stringify(['fundamentals', 'performance', 'dom']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['react'],
question_text: 'What is the purpose of useEffect hook?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'To perform side effects in function components' },
{ id: 'b', text: 'To create state variables' },
{ id: 'c', text: 'To handle routing' },
{ id: 'd', text: 'To optimize performance' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'useEffect allows you to perform side effects (data fetching, subscriptions, DOM manipulation) in function components. It runs after render.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['useeffect', 'hook', 'side effects']),
tags: JSON.stringify(['hooks', 'side-effects', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['react'],
question_text: 'What is prop drilling in React?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Passing props through multiple component layers' },
{ id: 'b', text: 'Creating new props' },
{ id: 'c', text: 'Validating prop types' },
{ id: 'd', text: 'Drilling holes in components' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Prop drilling is when you pass props through multiple intermediate components that don\'t need them, just to get them to a deeply nested component.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['props', 'drilling', 'context']),
tags: JSON.stringify(['props', 'patterns', 'architecture']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['react'],
question_text: 'What is the difference between useMemo and useCallback?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'useMemo memoizes values, useCallback memoizes functions' },
{ id: 'b', text: 'useMemo is for functions, useCallback is for values' },
{ id: 'c', text: 'They are exactly the same' },
{ id: 'd', text: 'useMemo is deprecated' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'useMemo returns and memoizes a computed value, while useCallback returns and memoizes a function. Both are used for performance optimization.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['usememo', 'usecallback', 'memoization', 'performance']),
tags: JSON.stringify(['hooks', 'performance', 'optimization']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['react'],
question_text: 'What is React Context API used for?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Sharing data across components without prop drilling' },
{ id: 'b', text: 'Creating routes' },
{ id: 'c', text: 'Managing component lifecycle' },
{ id: 'd', text: 'Optimizing performance' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Context API provides a way to share values between components without explicitly passing props through every level of the tree.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['context', 'api', 'state management']),
tags: JSON.stringify(['context', 'state-management', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
}
];
// Node.js Questions (10 questions)
const nodejsQuestions = [
{
id: uuidv4(),
category_id: categoryMap['nodejs'],
question_text: 'What is the event loop in Node.js?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'A mechanism that handles async operations by queuing callbacks' },
{ id: 'b', text: 'A for loop that runs forever' },
{ id: 'c', text: 'A routing system' },
{ id: 'd', text: 'A testing framework' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'The event loop is Node.js\'s mechanism for handling async operations. It continuously checks for and executes callbacks from different phases.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['event loop', 'async', 'callbacks']),
tags: JSON.stringify(['event-loop', 'async', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['nodejs'],
question_text: 'What is middleware in Express.js?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Functions that have access to request, response, and next in the pipeline' },
{ id: 'b', text: 'Database connection code' },
{ id: 'c', text: 'Front-end components' },
{ id: 'd', text: 'Testing utilities' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Middleware functions have access to request and response objects and the next() function. They can execute code, modify req/res, and control the flow.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['middleware', 'express', 'request', 'response']),
tags: JSON.stringify(['express', 'middleware', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['nodejs'],
question_text: 'What is the purpose of package.json in Node.js?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Metadata file containing project info, dependencies, and scripts' },
{ id: 'b', text: 'Configuration for the database' },
{ id: 'c', text: 'Main application entry point' },
{ id: 'd', text: 'Testing configuration' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'package.json is the manifest file for Node.js projects. It contains metadata, dependencies, scripts, and configuration.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['package.json', 'npm', 'dependencies']),
tags: JSON.stringify(['npm', 'configuration', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['nodejs'],
question_text: 'What is the difference between process.nextTick() and setImmediate()?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'nextTick() executes before the event loop continues, setImmediate() after I/O' },
{ id: 'b', text: 'They are exactly the same' },
{ id: 'c', text: 'setImmediate() is synchronous' },
{ id: 'd', text: 'nextTick() is deprecated' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'process.nextTick() callbacks execute immediately after the current operation, before the event loop continues. setImmediate() executes in the check phase.',
difficulty: 'hard',
points: 15,
time_limit: 120,
keywords: JSON.stringify(['nexttick', 'setimmediate', 'event loop']),
tags: JSON.stringify(['event-loop', 'async', 'advanced']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['nodejs'],
question_text: 'What is clustering in Node.js?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Running multiple Node.js processes to utilize all CPU cores' },
{ id: 'b', text: 'Grouping related code together' },
{ id: 'c', text: 'Database optimization technique' },
{ id: 'd', text: 'A design pattern' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Clustering allows you to create child processes (workers) that share server ports, enabling Node.js to utilize all available CPU cores.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['cluster', 'scaling', 'performance']),
tags: JSON.stringify(['clustering', 'scaling', 'performance']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
}
];
// TypeScript Questions (10 questions)
const tsQuestions = [
{
id: uuidv4(),
category_id: categoryMap['typescript'],
question_text: 'What is the difference between interface and type in TypeScript?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Interfaces can be extended/merged, types are more flexible with unions' },
{ id: 'b', text: 'They are exactly the same' },
{ id: 'c', text: 'Types are deprecated' },
{ id: 'd', text: 'Interfaces only work with objects' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Interfaces can be extended and declared multiple times (declaration merging). Types are more flexible with unions, intersections, and primitives.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['interface', 'type', 'alias']),
tags: JSON.stringify(['types', 'interfaces', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['typescript'],
question_text: 'What is a generic in TypeScript?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'A way to create reusable components that work with multiple types' },
{ id: 'b', text: 'A basic data type' },
{ id: 'c', text: 'A class decorator' },
{ id: 'd', text: 'A testing utility' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Generics allow you to create components that work with any type while maintaining type safety. They\'re like variables for types.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['generic', 'type parameter', 'reusable']),
tags: JSON.stringify(['generics', 'types', 'advanced']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['typescript'],
question_text: 'What is the "never" type in TypeScript?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'A type representing values that never occur' },
{ id: 'b', text: 'A deprecated type' },
{ id: 'c', text: 'Same as void' },
{ id: 'd', text: 'A null type' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'The never type represents values that never occur - functions that always throw errors or infinite loops. It\'s the bottom type.',
difficulty: 'hard',
points: 15,
time_limit: 120,
keywords: JSON.stringify(['never', 'bottom type', 'type system']),
tags: JSON.stringify(['types', 'advanced', 'type-system']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['typescript'],
question_text: 'What is type narrowing in TypeScript?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Refining types through conditional checks to more specific types' },
{ id: 'b', text: 'Making type names shorter' },
{ id: 'c', text: 'Removing types from code' },
{ id: 'd', text: 'Converting types to primitives' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Type narrowing is when TypeScript refines a broader type to a more specific one based on conditional checks (typeof, instanceof, etc.).',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['narrowing', 'type guards', 'refinement']),
tags: JSON.stringify(['type-guards', 'narrowing', 'advanced']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['typescript'],
question_text: 'What is the purpose of the "readonly" modifier?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Makes properties immutable after initialization' },
{ id: 'b', text: 'Hides properties from console.log' },
{ id: 'c', text: 'Marks properties as private' },
{ id: 'd', text: 'Improves performance' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'The readonly modifier prevents properties from being reassigned after initialization, providing compile-time immutability.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['readonly', 'immutable', 'modifier']),
tags: JSON.stringify(['modifiers', 'immutability', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
}
];
// SQL Questions (10 questions)
const sqlQuestions = [
{
id: uuidv4(),
category_id: categoryMap['sql-databases'],
question_text: 'What is the difference between INNER JOIN and LEFT JOIN?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'INNER returns only matching rows, LEFT returns all left table rows' },
{ id: 'b', text: 'They are exactly the same' },
{ id: 'c', text: 'LEFT JOIN is faster' },
{ id: 'd', text: 'INNER JOIN includes NULL values' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'INNER JOIN returns only rows with matches in both tables. LEFT JOIN returns all rows from the left table, with NULLs for non-matching right table rows.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['join', 'inner', 'left', 'sql']),
tags: JSON.stringify(['joins', 'queries', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['sql-databases'],
question_text: 'What is database normalization?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Organizing data to reduce redundancy and improve integrity' },
{ id: 'b', text: 'Making all values lowercase' },
{ id: 'c', text: 'Optimizing query performance' },
{ id: 'd', text: 'Backing up the database' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Normalization is the process of organizing database structure to reduce redundancy and dependency by dividing large tables into smaller ones.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['normalization', 'database design', 'redundancy']),
tags: JSON.stringify(['design', 'normalization', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['sql-databases'],
question_text: 'What is an index in a database?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'A data structure that improves query speed at the cost of write speed' },
{ id: 'b', text: 'A primary key' },
{ id: 'c', text: 'A backup of the table' },
{ id: 'd', text: 'A foreign key relationship' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'An index is a data structure (typically B-tree) that speeds up data retrieval operations but requires additional space and slows down writes.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['index', 'performance', 'query optimization']),
tags: JSON.stringify(['indexes', 'performance', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['sql-databases'],
question_text: 'What is a transaction in SQL?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'A sequence of operations performed as a single unit of work (ACID)' },
{ id: 'b', text: 'A single SQL query' },
{ id: 'c', text: 'A database backup' },
{ id: 'd', text: 'A table relationship' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'A transaction is a logical unit of work that follows ACID properties (Atomicity, Consistency, Isolation, Durability) to maintain data integrity.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['transaction', 'acid', 'commit', 'rollback']),
tags: JSON.stringify(['transactions', 'acid', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['sql-databases'],
question_text: 'What does the GROUP BY clause do in SQL?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Groups rows with same values for aggregate functions' },
{ id: 'b', text: 'Sorts the result set' },
{ id: 'c', text: 'Filters rows before grouping' },
{ id: 'd', text: 'Joins tables together' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'GROUP BY groups rows that have the same values in specified columns, often used with aggregate functions like COUNT, SUM, AVG.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['group by', 'aggregate', 'sql']),
tags: JSON.stringify(['grouping', 'aggregates', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
}
];
// System Design Questions (10 questions)
const systemDesignQuestions = [
{
id: uuidv4(),
category_id: categoryMap['system-design'],
question_text: 'What is horizontal scaling vs vertical scaling?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Horizontal adds more machines, vertical increases single machine resources' },
{ id: 'b', text: 'Vertical adds more machines, horizontal increases resources' },
{ id: 'c', text: 'They are the same' },
{ id: 'd', text: 'Horizontal is always better' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Horizontal scaling (scale out) adds more machines to the pool. Vertical scaling (scale up) adds more resources (CPU, RAM) to a single machine.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['scaling', 'horizontal', 'vertical', 'architecture']),
tags: JSON.stringify(['scaling', 'architecture', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['system-design'],
question_text: 'What is a load balancer?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Distributes incoming traffic across multiple servers' },
{ id: 'b', text: 'Stores user sessions' },
{ id: 'c', text: 'Caches database queries' },
{ id: 'd', text: 'Monitors system performance' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'A load balancer distributes network traffic across multiple servers to ensure no single server is overwhelmed, improving reliability and performance.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['load balancer', 'distribution', 'scaling']),
tags: JSON.stringify(['load-balancing', 'architecture', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['system-design'],
question_text: 'What is CAP theorem?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'You can only achieve 2 of 3: Consistency, Availability, Partition tolerance' },
{ id: 'b', text: 'All three can be achieved simultaneously' },
{ id: 'c', text: 'A caching strategy' },
{ id: 'd', text: 'A security principle' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'CAP theorem states that a distributed system can only guarantee two of three properties: Consistency, Availability, and Partition tolerance.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['cap', 'theorem', 'distributed systems']),
tags: JSON.stringify(['distributed-systems', 'theory', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['system-design'],
question_text: 'What is caching and why is it used?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Storing frequently accessed data in fast storage to reduce latency' },
{ id: 'b', text: 'Backing up data' },
{ id: 'c', text: 'Encrypting sensitive data' },
{ id: 'd', text: 'Compressing files' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Caching stores frequently accessed data in fast storage (memory) to reduce database load and improve response times.',
difficulty: 'easy',
points: 5,
time_limit: 60,
keywords: JSON.stringify(['cache', 'performance', 'latency']),
tags: JSON.stringify(['caching', 'performance', 'fundamentals']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
category_id: categoryMap['system-design'],
question_text: 'What is a microservices architecture?',
question_type: 'multiple',
options: JSON.stringify([
{ id: 'a', text: 'Application composed of small, independent services communicating via APIs' },
{ id: 'b', text: 'A very small application' },
{ id: 'c', text: 'A caching strategy' },
{ id: 'd', text: 'A database design pattern' }
]),
correct_answer: JSON.stringify(['a']),
explanation: 'Microservices architecture structures an application as a collection of loosely coupled, independently deployable services.',
difficulty: 'medium',
points: 10,
time_limit: 90,
keywords: JSON.stringify(['microservices', 'architecture', 'distributed']),
tags: JSON.stringify(['microservices', 'architecture', 'patterns']),
is_active: true,
times_attempted: 0,
times_correct: 0,
created_by: adminId,
created_at: new Date(),
updated_at: new Date()
}
];
// Combine all questions
questions.push(
...jsQuestions,
...angularQuestions,
...reactQuestions,
...nodejsQuestions,
...tsQuestions,
...sqlQuestions,
...systemDesignQuestions
);
await queryInterface.bulkInsert('questions', questions, {});
console.log(`✅ Seeded ${questions.length} demo questions across all categories`);
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('questions', null, {});
console.log('✅ Removed demo questions');
}
};

View File

@@ -0,0 +1,314 @@
'use strict';
const { v4: uuidv4 } = require('uuid');
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
const achievements = [
// Milestone achievements
{
id: uuidv4(),
name: 'First Steps',
slug: 'first-steps',
description: 'Complete your very first quiz',
category: 'milestone',
icon: '🎯',
points: 10,
requirement_type: 'quizzes_completed',
requirement_value: 1,
is_active: true,
display_order: 1,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Quiz Enthusiast',
slug: 'quiz-enthusiast',
description: 'Complete 10 quizzes',
category: 'milestone',
icon: '📚',
points: 50,
requirement_type: 'quizzes_completed',
requirement_value: 10,
is_active: true,
display_order: 2,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Quiz Master',
slug: 'quiz-master',
description: 'Complete 50 quizzes',
category: 'milestone',
icon: '🏆',
points: 250,
requirement_type: 'quizzes_completed',
requirement_value: 50,
is_active: true,
display_order: 3,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Quiz Legend',
slug: 'quiz-legend',
description: 'Complete 100 quizzes',
category: 'milestone',
icon: '👑',
points: 500,
requirement_type: 'quizzes_completed',
requirement_value: 100,
is_active: true,
display_order: 4,
created_at: new Date(),
updated_at: new Date()
},
// Accuracy achievements
{
id: uuidv4(),
name: 'Perfect Score',
slug: 'perfect-score',
description: 'Achieve 100% on any quiz',
category: 'score',
icon: '💯',
points: 100,
requirement_type: 'perfect_score',
requirement_value: 1,
is_active: true,
display_order: 5,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Perfectionist',
slug: 'perfectionist',
description: 'Achieve 100% on 5 quizzes',
category: 'score',
icon: '⭐',
points: 300,
requirement_type: 'perfect_score',
requirement_value: 5,
is_active: true,
display_order: 6,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'High Achiever',
slug: 'high-achiever',
description: 'Maintain 80% average across all quizzes',
category: 'score',
icon: '🎓',
points: 200,
requirement_type: 'quizzes_passed',
requirement_value: 80,
is_active: true,
display_order: 7,
created_at: new Date(),
updated_at: new Date()
},
// Speed achievements
{
id: uuidv4(),
name: 'Speed Demon',
slug: 'speed-demon',
description: 'Complete a quiz in under 2 minutes',
category: 'speed',
icon: '⚡',
points: 75,
requirement_type: 'speed_demon',
requirement_value: 120,
is_active: true,
display_order: 8,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Lightning Fast',
slug: 'lightning-fast',
description: 'Complete 10 quizzes in under 2 minutes each',
category: 'speed',
icon: '🚀',
points: 200,
requirement_type: 'speed_demon',
requirement_value: 10,
is_active: true,
display_order: 9,
created_at: new Date(),
updated_at: new Date()
},
// Streak achievements
{
id: uuidv4(),
name: 'On a Roll',
slug: 'on-a-roll',
description: 'Maintain a 3-day streak',
category: 'streak',
icon: '🔥',
points: 50,
requirement_type: 'streak_days',
requirement_value: 3,
is_active: true,
display_order: 10,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Week Warrior',
slug: 'week-warrior',
description: 'Maintain a 7-day streak',
category: 'streak',
icon: '🔥🔥',
points: 150,
requirement_type: 'streak_days',
requirement_value: 7,
is_active: true,
display_order: 11,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Month Champion',
slug: 'month-champion',
description: 'Maintain a 30-day streak',
category: 'streak',
icon: '🔥🔥🔥',
points: 500,
requirement_type: 'streak_days',
requirement_value: 30,
is_active: true,
display_order: 12,
created_at: new Date(),
updated_at: new Date()
},
// Exploration achievements
{
id: uuidv4(),
name: 'Explorer',
slug: 'explorer',
description: 'Complete quizzes in 3 different categories',
category: 'quiz',
icon: '🗺️',
points: 100,
requirement_type: 'category_master',
requirement_value: 3,
is_active: true,
display_order: 13,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Jack of All Trades',
slug: 'jack-of-all-trades',
description: 'Complete quizzes in 5 different categories',
category: 'quiz',
icon: '🌟',
points: 200,
requirement_type: 'category_master',
requirement_value: 5,
is_active: true,
display_order: 14,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Master of All',
slug: 'master-of-all',
description: 'Complete quizzes in all categories',
category: 'quiz',
icon: '🌈',
points: 400,
requirement_type: 'category_master',
requirement_value: 7,
is_active: true,
display_order: 15,
created_at: new Date(),
updated_at: new Date()
},
// Special achievements
{
id: uuidv4(),
name: 'Early Bird',
slug: 'early-bird',
description: 'Complete a quiz before 8 AM',
category: 'special',
icon: '🌅',
points: 50,
requirement_type: 'early_bird',
requirement_value: 8,
is_active: true,
display_order: 16,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Night Owl',
slug: 'night-owl',
description: 'Complete a quiz after 10 PM',
category: 'special',
icon: '🦉',
points: 50,
requirement_type: 'early_bird',
requirement_value: 22,
is_active: true,
display_order: 17,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Weekend Warrior',
slug: 'weekend-warrior',
description: 'Complete 10 quizzes on weekends',
category: 'special',
icon: '🎉',
points: 100,
requirement_type: 'early_bird',
requirement_value: 10,
is_active: true,
display_order: 18,
created_at: new Date(),
updated_at: new Date()
},
{
id: uuidv4(),
name: 'Comeback King',
slug: 'comeback-king',
description: 'Score 90%+ after scoring below 50%',
category: 'special',
icon: '💪',
points: 150,
requirement_type: 'early_bird',
requirement_value: 40,
is_active: true,
display_order: 19,
created_at: new Date(),
updated_at: new Date()
}
];
await queryInterface.bulkInsert('achievements', achievements, {});
console.log('✅ Seeded 20 demo achievements across all categories');
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('achievements', null, {});
console.log('✅ Removed demo achievements');
}
};

140
backend/server.js Normal file
View File

@@ -0,0 +1,140 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const { testConnection, getDatabaseStats } = require('./config/db');
const { validateEnvironment } = require('./validate-env');
// Validate environment configuration on startup
console.log('\n🔧 Validating environment configuration...');
const isEnvValid = validateEnvironment();
if (!isEnvValid) {
console.error('❌ Environment validation failed. Please fix errors and restart.');
process.exit(1);
}
const app = express();
// Configuration
const config = require('./config/config');
const PORT = config.server.port;
const API_PREFIX = config.server.apiPrefix;
const NODE_ENV = config.server.nodeEnv;
// Security middleware
app.use(helmet());
// CORS configuration
app.use(cors(config.cors));
// Body parser middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Logging middleware
if (NODE_ENV === 'development') {
app.use(morgan('dev'));
} else {
app.use(morgan('combined'));
}
// Rate limiting
const limiter = rateLimit({
windowMs: config.rateLimit.windowMs,
max: config.rateLimit.maxRequests,
message: config.rateLimit.message,
standardHeaders: true,
legacyHeaders: false,
});
app.use(API_PREFIX, limiter);
// Health check endpoint
app.get('/health', async (req, res) => {
const dbStats = await getDatabaseStats();
res.status(200).json({
status: 'OK',
message: 'Interview Quiz API is running',
timestamp: new Date().toISOString(),
environment: NODE_ENV,
database: dbStats
});
});
// API routes
const authRoutes = require('./routes/auth.routes');
const guestRoutes = require('./routes/guest.routes');
const categoryRoutes = require('./routes/category.routes');
const questionRoutes = require('./routes/question.routes');
const adminRoutes = require('./routes/admin.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);
// Root endpoint
app.get('/', (req, res) => {
res.json({
message: 'Welcome to Interview Quiz API',
version: '2.0.0',
documentation: '/api-docs'
});
});
// 404 handler
app.use((req, res, next) => {
res.status(404).json({
success: false,
message: 'Route not found',
path: req.originalUrl
});
});
// Global error handler
app.use((err, req, res, next) => {
console.error('Error:', err);
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
success: false,
message: message,
...(NODE_ENV === 'development' && { stack: err.stack })
});
});
// Start server
app.listen(PORT, async () => {
console.log(`
╔════════════════════════════════════════╗
║ Interview Quiz API - MySQL Edition ║
╚════════════════════════════════════════╝
🚀 Server running on port ${PORT}
🌍 Environment: ${NODE_ENV}
🔗 API Endpoint: http://localhost:${PORT}${API_PREFIX}
📊 Health Check: http://localhost:${PORT}/health
`);
// Test database connection on startup
console.log('🔌 Testing database connection...');
const connected = await testConnection();
if (!connected) {
console.warn('⚠️ Warning: Database connection failed. Server is running but database operations will fail.');
}
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
console.error('Unhandled Promise Rejection:', err);
// Close server & exit process
process.exit(1);
});
module.exports = app;

View File

@@ -0,0 +1,153 @@
const axios = require('axios');
const API_URL = 'http://localhost:3000/api';
async function testAuthEndpoints() {
console.log('\n🧪 Testing Authentication Endpoints\n');
console.log('=' .repeat(60));
let authToken;
let userId;
try {
// Test 1: Register new user
console.log('\n1⃣ Testing POST /api/auth/register');
console.log('-'.repeat(60));
try {
const registerData = {
username: `testuser_${Date.now()}`,
email: `test${Date.now()}@example.com`,
password: 'Test@123'
};
console.log('Request:', JSON.stringify(registerData, null, 2));
const registerResponse = await axios.post(`${API_URL}/auth/register`, registerData);
console.log('✅ Status:', registerResponse.status);
console.log('✅ Response:', JSON.stringify(registerResponse.data, null, 2));
authToken = registerResponse.data.data.token;
userId = registerResponse.data.data.user.id;
} catch (error) {
console.log('❌ Error:', error.response?.data || error.message);
}
// Test 2: Duplicate email
console.log('\n2⃣ Testing duplicate email (should fail)');
console.log('-'.repeat(60));
try {
const duplicateData = {
username: 'anotheruser',
email: registerData.email, // Same email
password: 'Test@123'
};
await axios.post(`${API_URL}/auth/register`, duplicateData);
console.log('❌ Should have failed');
} catch (error) {
console.log('✅ Expected error:', error.response?.data?.message);
}
// Test 3: Invalid password
console.log('\n3⃣ Testing invalid password (should fail)');
console.log('-'.repeat(60));
try {
const weakPassword = {
username: 'newuser',
email: 'newuser@example.com',
password: 'weak' // Too weak
};
await axios.post(`${API_URL}/auth/register`, weakPassword);
console.log('❌ Should have failed');
} catch (error) {
console.log('✅ Expected error:', error.response?.data?.message);
}
// Test 4: Login
console.log('\n4⃣ Testing POST /api/auth/login');
console.log('-'.repeat(60));
try {
const loginData = {
email: registerData.email,
password: registerData.password
};
console.log('Request:', JSON.stringify(loginData, null, 2));
const loginResponse = await axios.post(`${API_URL}/auth/login`, loginData);
console.log('✅ Status:', loginResponse.status);
console.log('✅ Response:', JSON.stringify(loginResponse.data, null, 2));
} catch (error) {
console.log('❌ Error:', error.response?.data || error.message);
}
// Test 5: Invalid login
console.log('\n5⃣ Testing invalid login (should fail)');
console.log('-'.repeat(60));
try {
const invalidLogin = {
email: registerData.email,
password: 'WrongPassword123'
};
await axios.post(`${API_URL}/auth/login`, invalidLogin);
console.log('❌ Should have failed');
} catch (error) {
console.log('✅ Expected error:', error.response?.data?.message);
}
// Test 6: Verify token
console.log('\n6⃣ Testing GET /api/auth/verify');
console.log('-'.repeat(60));
try {
console.log('Token:', authToken.substring(0, 20) + '...');
const verifyResponse = await axios.get(`${API_URL}/auth/verify`, {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
console.log('✅ Status:', verifyResponse.status);
console.log('✅ Response:', JSON.stringify(verifyResponse.data, null, 2));
} catch (error) {
console.log('❌ Error:', error.response?.data || error.message);
}
// Test 7: Verify without token
console.log('\n7⃣ Testing verify without token (should fail)');
console.log('-'.repeat(60));
try {
await axios.get(`${API_URL}/auth/verify`);
console.log('❌ Should have failed');
} catch (error) {
console.log('✅ Expected error:', error.response?.data?.message);
}
// Test 8: Logout
console.log('\n8⃣ Testing POST /api/auth/logout');
console.log('-'.repeat(60));
try {
const logoutResponse = await axios.post(`${API_URL}/auth/logout`);
console.log('✅ Status:', logoutResponse.status);
console.log('✅ Response:', JSON.stringify(logoutResponse.data, null, 2));
} catch (error) {
console.log('❌ Error:', error.response?.data || error.message);
}
console.log('\n' + '='.repeat(60));
console.log('✅ All authentication tests completed!');
console.log('='.repeat(60) + '\n');
} catch (error) {
console.error('\n❌ Test suite error:', error.message);
}
}
// Run tests
testAuthEndpoints();

View File

@@ -0,0 +1,571 @@
const axios = require('axios');
const API_URL = 'http://localhost:3000/api';
// Admin credentials (from seeder)
const adminUser = {
email: 'admin@quiz.com',
password: 'Admin@123'
};
// Regular user (we'll create one for testing - with timestamp to avoid conflicts)
const timestamp = Date.now();
const regularUser = {
username: `testuser${timestamp}`,
email: `testuser${timestamp}@example.com`,
password: 'Test@123'
};
// ANSI color codes
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m'
};
let adminToken = null;
let regularUserToken = null;
let testCategoryId = null;
/**
* Login as admin
*/
async function loginAdmin() {
try {
const response = await axios.post(`${API_URL}/auth/login`, adminUser);
adminToken = response.data.data.token;
console.log(`${colors.cyan}✓ Logged in as admin${colors.reset}`);
return adminToken;
} catch (error) {
console.error(`${colors.red}✗ Failed to login as admin:${colors.reset}`, error.response?.data || error.message);
throw error;
}
}
/**
* Create and login regular user
*/
async function createRegularUser() {
try {
// Register
const registerResponse = await axios.post(`${API_URL}/auth/register`, regularUser);
regularUserToken = registerResponse.data.data.token;
console.log(`${colors.cyan}✓ Created and logged in as regular user${colors.reset}`);
return regularUserToken;
} catch (error) {
console.error(`${colors.red}✗ Failed to create regular user:${colors.reset}`, error.response?.data || error.message);
throw error;
}
}
/**
* Test 1: Create category as admin
*/
async function testCreateCategoryAsAdmin() {
console.log(`\n${colors.blue}Test 1: Create category as admin${colors.reset}`);
try {
const newCategory = {
name: 'Test Category',
description: 'A test category for admin operations',
icon: 'test-icon',
color: '#FF5733',
guestAccessible: false,
displayOrder: 10
};
const response = await axios.post(`${API_URL}/categories`, newCategory, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data, message } = response.data;
if (!success) throw new Error('success should be true');
if (!data.id) throw new Error('Missing category ID');
if (data.name !== newCategory.name) throw new Error('Name mismatch');
if (data.slug !== 'test-category') throw new Error('Slug should be auto-generated');
if (data.color !== newCategory.color) throw new Error('Color mismatch');
if (data.guestAccessible !== false) throw new Error('guestAccessible mismatch');
if (data.questionCount !== 0) throw new Error('questionCount should be 0');
if (data.isActive !== true) throw new Error('isActive should be true');
// Save for later tests
testCategoryId = data.id;
console.log(`${colors.green}✓ Test 1 Passed${colors.reset}`);
console.log(` Category ID: ${data.id}`);
console.log(` Name: ${data.name}`);
console.log(` Slug: ${data.slug}`);
return true;
} catch (error) {
console.error(`${colors.red}✗ Test 1 Failed:${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Test 2: Create category without authentication
*/
async function testCreateCategoryNoAuth() {
console.log(`\n${colors.blue}Test 2: Create category without authentication${colors.reset}`);
try {
const newCategory = {
name: 'Unauthorized Category',
description: 'Should not be created'
};
await axios.post(`${API_URL}/categories`, newCategory);
console.error(`${colors.red}✗ Test 2 Failed: Should have been rejected${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 401) {
console.log(`${colors.green}✓ Test 2 Passed${colors.reset}`);
console.log(` Status: 401 Unauthorized`);
return true;
} else {
console.error(`${colors.red}✗ Test 2 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
}
/**
* Test 3: Create category as regular user
*/
async function testCreateCategoryAsRegularUser() {
console.log(`\n${colors.blue}Test 3: Create category as regular user (non-admin)${colors.reset}`);
try {
const newCategory = {
name: 'Regular User Category',
description: 'Should not be created'
};
await axios.post(`${API_URL}/categories`, newCategory, {
headers: { 'Authorization': `Bearer ${regularUserToken}` }
});
console.error(`${colors.red}✗ Test 3 Failed: Should have been rejected${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 403) {
console.log(`${colors.green}✓ Test 3 Passed${colors.reset}`);
console.log(` Status: 403 Forbidden`);
return true;
} else {
console.error(`${colors.red}✗ Test 3 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
}
/**
* Test 4: Create category with duplicate name
*/
async function testCreateCategoryDuplicateName() {
console.log(`\n${colors.blue}Test 4: Create category with duplicate name${colors.reset}`);
try {
const duplicateCategory = {
name: 'Test Category', // Same as test 1
description: 'Duplicate name'
};
await axios.post(`${API_URL}/categories`, duplicateCategory, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
console.error(`${colors.red}✗ Test 4 Failed: Should have been rejected${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 400) {
const { message } = error.response.data;
if (message.includes('already exists')) {
console.log(`${colors.green}✓ Test 4 Passed${colors.reset}`);
console.log(` Status: 400 Bad Request`);
console.log(` Message: ${message}`);
return true;
}
}
console.error(`${colors.red}✗ Test 4 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Test 5: Create category without required name
*/
async function testCreateCategoryMissingName() {
console.log(`\n${colors.blue}Test 5: Create category without required name${colors.reset}`);
try {
const invalidCategory = {
description: 'No name provided'
};
await axios.post(`${API_URL}/categories`, invalidCategory, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
console.error(`${colors.red}✗ Test 5 Failed: Should have been rejected${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 400) {
const { message } = error.response.data;
if (message.includes('required')) {
console.log(`${colors.green}✓ Test 5 Passed${colors.reset}`);
console.log(` Status: 400 Bad Request`);
console.log(` Message: ${message}`);
return true;
}
}
console.error(`${colors.red}✗ Test 5 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Test 6: Update category as admin
*/
async function testUpdateCategoryAsAdmin() {
console.log(`\n${colors.blue}Test 6: Update category as admin${colors.reset}`);
try {
const updates = {
description: 'Updated description',
guestAccessible: true,
displayOrder: 20
};
const response = await axios.put(`${API_URL}/categories/${testCategoryId}`, updates, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data } = response.data;
if (!success) throw new Error('success should be true');
if (data.description !== updates.description) throw new Error('Description not updated');
if (data.guestAccessible !== updates.guestAccessible) throw new Error('guestAccessible not updated');
if (data.displayOrder !== updates.displayOrder) throw new Error('displayOrder not updated');
console.log(`${colors.green}✓ Test 6 Passed${colors.reset}`);
console.log(` Updated description: ${data.description}`);
console.log(` Updated guestAccessible: ${data.guestAccessible}`);
console.log(` Updated displayOrder: ${data.displayOrder}`);
return true;
} catch (error) {
console.error(`${colors.red}✗ Test 6 Failed:${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Test 7: Update category as regular user
*/
async function testUpdateCategoryAsRegularUser() {
console.log(`\n${colors.blue}Test 7: Update category as regular user (non-admin)${colors.reset}`);
try {
const updates = {
description: 'Should not update'
};
await axios.put(`${API_URL}/categories/${testCategoryId}`, updates, {
headers: { 'Authorization': `Bearer ${regularUserToken}` }
});
console.error(`${colors.red}✗ Test 7 Failed: Should have been rejected${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 403) {
console.log(`${colors.green}✓ Test 7 Passed${colors.reset}`);
console.log(` Status: 403 Forbidden`);
return true;
} else {
console.error(`${colors.red}✗ Test 7 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
}
/**
* Test 8: Update non-existent category
*/
async function testUpdateNonExistentCategory() {
console.log(`\n${colors.blue}Test 8: Update non-existent category${colors.reset}`);
try {
const fakeId = '00000000-0000-0000-0000-000000000000';
const updates = {
description: 'Should not work'
};
await axios.put(`${API_URL}/categories/${fakeId}`, updates, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
console.error(`${colors.red}✗ Test 8 Failed: Should have returned 404${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 404) {
console.log(`${colors.green}✓ Test 8 Passed${colors.reset}`);
console.log(` Status: 404 Not Found`);
return true;
} else {
console.error(`${colors.red}✗ Test 8 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
}
/**
* Test 9: Update category with duplicate name
*/
async function testUpdateCategoryDuplicateName() {
console.log(`\n${colors.blue}Test 9: Update category with duplicate name${colors.reset}`);
try {
const updates = {
name: 'JavaScript' // Existing category from seed data
};
await axios.put(`${API_URL}/categories/${testCategoryId}`, updates, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
console.error(`${colors.red}✗ Test 9 Failed: Should have been rejected${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 400) {
const { message } = error.response.data;
if (message.includes('already exists')) {
console.log(`${colors.green}✓ Test 9 Passed${colors.reset}`);
console.log(` Status: 400 Bad Request`);
console.log(` Message: ${message}`);
return true;
}
}
console.error(`${colors.red}✗ Test 9 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Test 10: Delete category as admin
*/
async function testDeleteCategoryAsAdmin() {
console.log(`\n${colors.blue}Test 10: Delete category as admin (soft delete)${colors.reset}`);
try {
const response = await axios.delete(`${API_URL}/categories/${testCategoryId}`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data, message } = response.data;
if (!success) throw new Error('success should be true');
if (data.id !== testCategoryId) throw new Error('ID mismatch');
if (!message.includes('successfully')) throw new Error('Success message expected');
console.log(`${colors.green}✓ Test 10 Passed${colors.reset}`);
console.log(` Category: ${data.name}`);
console.log(` Message: ${message}`);
return true;
} catch (error) {
console.error(`${colors.red}✗ Test 10 Failed:${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Test 11: Verify deleted category is not in active list
*/
async function testDeletedCategoryNotInList() {
console.log(`\n${colors.blue}Test 11: Verify deleted category not in active list${colors.reset}`);
try {
const response = await axios.get(`${API_URL}/categories`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { data } = response.data;
const deletedCategory = data.find(cat => cat.id === testCategoryId);
if (deletedCategory) {
throw new Error('Deleted category should not appear in active list');
}
console.log(`${colors.green}✓ Test 11 Passed${colors.reset}`);
console.log(` Deleted category not in active list`);
return true;
} catch (error) {
console.error(`${colors.red}✗ Test 11 Failed:${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Test 12: Delete already deleted category
*/
async function testDeleteAlreadyDeletedCategory() {
console.log(`\n${colors.blue}Test 12: Delete already deleted category${colors.reset}`);
try {
await axios.delete(`${API_URL}/categories/${testCategoryId}`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
console.error(`${colors.red}✗ Test 12 Failed: Should have been rejected${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 400) {
const { message } = error.response.data;
if (message.includes('already deleted')) {
console.log(`${colors.green}✓ Test 12 Passed${colors.reset}`);
console.log(` Status: 400 Bad Request`);
console.log(` Message: ${message}`);
return true;
}
}
console.error(`${colors.red}✗ Test 12 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Test 13: Delete category as regular user
*/
async function testDeleteCategoryAsRegularUser() {
console.log(`\n${colors.blue}Test 13: Delete category as regular user (non-admin)${colors.reset}`);
try {
// Create a new category for this test
const newCategory = {
name: 'Delete Test Category',
description: 'For delete permissions test'
};
const createResponse = await axios.post(`${API_URL}/categories`, newCategory, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const categoryId = createResponse.data.data.id;
// Try to delete as regular user
await axios.delete(`${API_URL}/categories/${categoryId}`, {
headers: { 'Authorization': `Bearer ${regularUserToken}` }
});
console.error(`${colors.red}✗ Test 13 Failed: Should have been rejected${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 403) {
console.log(`${colors.green}✓ Test 13 Passed${colors.reset}`);
console.log(` Status: 403 Forbidden`);
return true;
} else {
console.error(`${colors.red}✗ Test 13 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
}
/**
* Test 14: Create category with custom slug
*/
async function testCreateCategoryWithCustomSlug() {
console.log(`\n${colors.blue}Test 14: Create category with custom slug${colors.reset}`);
try {
const newCategory = {
name: 'Custom Slug Category',
slug: 'my-custom-slug',
description: 'Testing custom slug'
};
const response = await axios.post(`${API_URL}/categories`, newCategory, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data } = response.data;
if (!success) throw new Error('success should be true');
if (data.slug !== 'my-custom-slug') throw new Error('Custom slug not applied');
console.log(`${colors.green}✓ Test 14 Passed${colors.reset}`);
console.log(` Custom slug: ${data.slug}`);
return true;
} catch (error) {
console.error(`${colors.red}✗ Test 14 Failed:${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Run all tests
*/
async function runAllTests() {
console.log(`${colors.cyan}========================================${colors.reset}`);
console.log(`${colors.cyan}Testing Category Admin API${colors.reset}`);
console.log(`${colors.cyan}========================================${colors.reset}`);
const results = [];
try {
// Setup
await loginAdmin();
await createRegularUser();
// Run tests
results.push(await testCreateCategoryAsAdmin());
results.push(await testCreateCategoryNoAuth());
results.push(await testCreateCategoryAsRegularUser());
results.push(await testCreateCategoryDuplicateName());
results.push(await testCreateCategoryMissingName());
results.push(await testUpdateCategoryAsAdmin());
results.push(await testUpdateCategoryAsRegularUser());
results.push(await testUpdateNonExistentCategory());
results.push(await testUpdateCategoryDuplicateName());
results.push(await testDeleteCategoryAsAdmin());
results.push(await testDeletedCategoryNotInList());
results.push(await testDeleteAlreadyDeletedCategory());
results.push(await testDeleteCategoryAsRegularUser());
results.push(await testCreateCategoryWithCustomSlug());
// Summary
console.log(`\n${colors.cyan}========================================${colors.reset}`);
console.log(`${colors.cyan}Test Summary${colors.reset}`);
console.log(`${colors.cyan}========================================${colors.reset}`);
const passed = results.filter(r => r === true).length;
const failed = results.filter(r => r === false).length;
console.log(`${colors.green}Passed: ${passed}${colors.reset}`);
console.log(`${colors.red}Failed: ${failed}${colors.reset}`);
console.log(`Total: ${results.length}`);
if (failed === 0) {
console.log(`\n${colors.green}✓ All tests passed!${colors.reset}`);
} else {
console.log(`\n${colors.red}✗ Some tests failed${colors.reset}`);
process.exit(1);
}
} catch (error) {
console.error(`${colors.red}Test execution error:${colors.reset}`, error);
process.exit(1);
}
}
// Run tests
runAllTests();

View File

@@ -0,0 +1,454 @@
const axios = require('axios');
const API_URL = 'http://localhost:3000/api';
// Category UUIDs (from database)
const CATEGORY_IDS = {
JAVASCRIPT: '68b4c87f-db0b-48ea-b8a4-b2f4fce785a2',
ANGULAR: '0033017b-6ecb-4e9d-8f13-06fc222c1dfc',
REACT: 'd27e3412-6f2b-432d-8d63-1e165ea5fffd',
NODEJS: '5e3094ab-ab6d-4f8a-9261-8177b9c979ae',
TYPESCRIPT: '7a71ab02-a7e4-4a76-a364-de9d6e3f3411',
SQL_DATABASES: '24b7b12d-fa23-448f-9f55-b0b9b82a844f',
SYSTEM_DESIGN: '65b3ad28-a19d-413a-9abe-94184f963d77',
};
// Test user credentials (from seeder)
const testUser = {
email: 'admin@quiz.com',
password: 'Admin@123'
};
// ANSI color codes for output
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m'
};
let userToken = null;
let guestToken = null;
/**
* Login as registered user
*/
async function loginUser() {
try {
const response = await axios.post(`${API_URL}/auth/login`, testUser);
userToken = response.data.data.token;
console.log(`${colors.cyan}✓ Logged in as user${colors.reset}`);
return userToken;
} catch (error) {
console.error(`${colors.red}✗ Failed to login:${colors.reset}`, error.response?.data || error.message);
throw error;
}
}
/**
* Create guest session
*/
async function createGuestSession() {
try {
const response = await axios.post(`${API_URL}/guest/start-session`, {
deviceId: 'test-device-category-details'
});
guestToken = response.data.sessionToken;
console.log(`${colors.cyan}✓ Created guest session${colors.reset}`);
return guestToken;
} catch (error) {
console.error(`${colors.red}✗ Failed to create guest session:${colors.reset}`, error.response?.data || error.message);
throw error;
}
}
/**
* Test 1: Get guest-accessible category details (JavaScript)
*/
async function testGetGuestCategoryDetails() {
console.log(`\n${colors.blue}Test 1: Get guest-accessible category details (JavaScript)${colors.reset}`);
try {
const response = await axios.get(`${API_URL}/categories/${CATEGORY_IDS.JAVASCRIPT}`, {
headers: {
'X-Guest-Token': guestToken
}
});
const { success, data, message } = response.data;
// Validations
if (!success) throw new Error('success should be true');
if (!data.category) throw new Error('Missing category data');
if (!data.questionPreview) throw new Error('Missing questionPreview');
if (!data.stats) throw new Error('Missing stats');
if (data.category.name !== 'JavaScript') throw new Error('Expected JavaScript category');
if (!data.category.guestAccessible) throw new Error('Should be guest-accessible');
console.log(`${colors.green}✓ Test 1 Passed${colors.reset}`);
console.log(` Category: ${data.category.name}`);
console.log(` Questions Preview: ${data.questionPreview.length}`);
console.log(` Total Questions: ${data.stats.totalQuestions}`);
console.log(` Average Accuracy: ${data.stats.averageAccuracy}%`);
return true;
} catch (error) {
console.error(`${colors.red}✗ Test 1 Failed:${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Test 2: Guest tries to access auth-only category (Node.js)
*/
async function testGuestAccessAuthCategory() {
console.log(`\n${colors.blue}Test 2: Guest tries to access auth-only category (Node.js)${colors.reset}`);
try {
const response = await axios.get(`${API_URL}/categories/${CATEGORY_IDS.NODEJS}`, {
headers: {
'X-Guest-Token': guestToken
}
});
// Should not reach here
console.error(`${colors.red}✗ Test 2 Failed: Should have been rejected${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 403) {
const { success, message, requiresAuth } = error.response.data;
if (success !== false) throw new Error('success should be false');
if (!requiresAuth) throw new Error('requiresAuth should be true');
if (!message.includes('authentication')) throw new Error('Message should mention authentication');
console.log(`${colors.green}✓ Test 2 Passed${colors.reset}`);
console.log(` Status: 403 Forbidden`);
console.log(` Message: ${message}`);
console.log(` Requires Auth: ${requiresAuth}`);
return true;
} else {
console.error(`${colors.red}✗ Test 2 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
}
/**
* Test 3: Authenticated user gets auth-only category details (Node.js)
*/
async function testAuthUserAccessCategory() {
console.log(`\n${colors.blue}Test 3: Authenticated user gets auth-only category details (Node.js)${colors.reset}`);
try {
const response = await axios.get(`${API_URL}/categories/${CATEGORY_IDS.NODEJS}`, {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
const { success, data } = response.data;
if (!success) throw new Error('success should be true');
if (data.category.name !== 'Node.js') throw new Error('Expected Node.js category');
if (data.category.guestAccessible) throw new Error('Should not be guest-accessible');
console.log(`${colors.green}✓ Test 3 Passed${colors.reset}`);
console.log(` Category: ${data.category.name}`);
console.log(` Guest Accessible: ${data.category.guestAccessible}`);
console.log(` Total Questions: ${data.stats.totalQuestions}`);
return true;
} catch (error) {
console.error(`${colors.red}✗ Test 3 Failed:${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Test 4: Invalid category ID (non-numeric)
*/
async function testInvalidCategoryId() {
console.log(`\n${colors.blue}Test 4: Invalid category ID (non-numeric)${colors.reset}`);
try {
const response = await axios.get(`${API_URL}/categories/invalid`, {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
console.error(`${colors.red}✗ Test 4 Failed: Should have been rejected${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 400) {
const { success, message } = error.response.data;
if (success !== false) throw new Error('success should be false');
if (!message.includes('Invalid')) throw new Error('Message should mention invalid ID');
console.log(`${colors.green}✓ Test 4 Passed${colors.reset}`);
console.log(` Status: 400 Bad Request`);
console.log(` Message: ${message}`);
return true;
} else {
console.error(`${colors.red}✗ Test 4 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
}
/**
* Test 5: Non-existent category ID
*/
async function testNonExistentCategory() {
console.log(`\n${colors.blue}Test 5: Non-existent category ID (999)${colors.reset}`);
try {
const response = await axios.get(`${API_URL}/categories/999`, {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
console.error(`${colors.red}✗ Test 5 Failed: Should have returned 404${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 404) {
const { success, message } = error.response.data;
if (success !== false) throw new Error('success should be false');
if (!message.includes('not found')) throw new Error('Message should mention not found');
console.log(`${colors.green}✓ Test 5 Passed${colors.reset}`);
console.log(` Status: 404 Not Found`);
console.log(` Message: ${message}`);
return true;
} else {
console.error(`${colors.red}✗ Test 5 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
}
/**
* Test 6: Verify response structure
*/
async function testResponseStructure() {
console.log(`\n${colors.blue}Test 6: Verify response structure${colors.reset}`);
try {
const response = await axios.get(`${API_URL}/categories/${CATEGORY_IDS.JAVASCRIPT}`, {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
const { success, data, message } = response.data;
const { category, questionPreview, stats } = data;
// Check category fields
const requiredCategoryFields = ['id', 'name', 'slug', 'description', 'icon', 'color', 'questionCount', 'displayOrder', 'guestAccessible'];
for (const field of requiredCategoryFields) {
if (!(field in category)) throw new Error(`Missing category field: ${field}`);
}
// Check question preview structure
if (!Array.isArray(questionPreview)) throw new Error('questionPreview should be an array');
if (questionPreview.length > 5) throw new Error('questionPreview should have max 5 questions');
if (questionPreview.length > 0) {
const question = questionPreview[0];
const requiredQuestionFields = ['id', 'questionText', 'questionType', 'difficulty', 'points', 'accuracy'];
for (const field of requiredQuestionFields) {
if (!(field in question)) throw new Error(`Missing question field: ${field}`);
}
}
// Check stats structure
const requiredStatsFields = ['totalQuestions', 'questionsByDifficulty', 'totalAttempts', 'totalCorrect', 'averageAccuracy'];
for (const field of requiredStatsFields) {
if (!(field in stats)) throw new Error(`Missing stats field: ${field}`);
}
// Check difficulty breakdown
const { questionsByDifficulty } = stats;
if (!('easy' in questionsByDifficulty)) throw new Error('Missing easy difficulty count');
if (!('medium' in questionsByDifficulty)) throw new Error('Missing medium difficulty count');
if (!('hard' in questionsByDifficulty)) throw new Error('Missing hard difficulty count');
console.log(`${colors.green}✓ Test 6 Passed${colors.reset}`);
console.log(` All required fields present`);
console.log(` Question preview length: ${questionPreview.length}`);
console.log(` Difficulty breakdown: Easy=${questionsByDifficulty.easy}, Medium=${questionsByDifficulty.medium}, Hard=${questionsByDifficulty.hard}`);
return true;
} catch (error) {
console.error(`${colors.red}✗ Test 6 Failed:${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Test 7: No authentication (public access to guest category)
*/
async function testPublicAccessGuestCategory() {
console.log(`\n${colors.blue}Test 7: Public access to guest-accessible category (no auth)${colors.reset}`);
try {
const response = await axios.get(`${API_URL}/categories/${CATEGORY_IDS.JAVASCRIPT}`);
const { success, data } = response.data;
if (!success) throw new Error('success should be true');
if (data.category.name !== 'JavaScript') throw new Error('Expected JavaScript category');
if (!data.category.guestAccessible) throw new Error('Should be guest-accessible');
console.log(`${colors.green}✓ Test 7 Passed${colors.reset}`);
console.log(` Public access allowed for guest-accessible categories`);
console.log(` Category: ${data.category.name}`);
return true;
} catch (error) {
console.error(`${colors.red}✗ Test 7 Failed:${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Test 8: No authentication (public tries auth-only category)
*/
async function testPublicAccessAuthCategory() {
console.log(`\n${colors.blue}Test 8: Public access to auth-only category (no auth)${colors.reset}`);
try {
const response = await axios.get(`${API_URL}/categories/${CATEGORY_IDS.NODEJS}`);
console.error(`${colors.red}✗ Test 8 Failed: Should have been rejected${colors.reset}`);
return false;
} catch (error) {
if (error.response && error.response.status === 403) {
const { success, requiresAuth } = error.response.data;
if (success !== false) throw new Error('success should be false');
if (!requiresAuth) throw new Error('requiresAuth should be true');
console.log(`${colors.green}✓ Test 8 Passed${colors.reset}`);
console.log(` Public access blocked for auth-only categories`);
console.log(` Status: 403 Forbidden`);
return true;
} else {
console.error(`${colors.red}✗ Test 8 Failed: Wrong error${colors.reset}`, error.response?.data || error.message);
return false;
}
}
}
/**
* Test 9: Verify stats calculations
*/
async function testStatsCalculations() {
console.log(`\n${colors.blue}Test 9: Verify stats calculations${colors.reset}`);
try {
const response = await axios.get(`${API_URL}/categories/${CATEGORY_IDS.JAVASCRIPT}`, {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
const { data } = response.data;
const { stats } = data;
// Verify difficulty sum equals total
const difficultySum = stats.questionsByDifficulty.easy +
stats.questionsByDifficulty.medium +
stats.questionsByDifficulty.hard;
if (difficultySum !== stats.totalQuestions) {
throw new Error(`Difficulty sum (${difficultySum}) doesn't match total questions (${stats.totalQuestions})`);
}
// Verify accuracy is within valid range
if (stats.averageAccuracy < 0 || stats.averageAccuracy > 100) {
throw new Error(`Invalid accuracy: ${stats.averageAccuracy}%`);
}
// If there are attempts, verify accuracy calculation
if (stats.totalAttempts > 0) {
const expectedAccuracy = Math.round((stats.totalCorrect / stats.totalAttempts) * 100);
if (stats.averageAccuracy !== expectedAccuracy) {
throw new Error(`Accuracy mismatch: expected ${expectedAccuracy}%, got ${stats.averageAccuracy}%`);
}
}
console.log(`${colors.green}✓ Test 9 Passed${colors.reset}`);
console.log(` Total Questions: ${stats.totalQuestions}`);
console.log(` Difficulty Sum: ${difficultySum}`);
console.log(` Total Attempts: ${stats.totalAttempts}`);
console.log(` Total Correct: ${stats.totalCorrect}`);
console.log(` Average Accuracy: ${stats.averageAccuracy}%`);
return true;
} catch (error) {
console.error(`${colors.red}✗ Test 9 Failed:${colors.reset}`, error.response?.data || error.message);
return false;
}
}
/**
* Run all tests
*/
async function runAllTests() {
console.log(`${colors.cyan}========================================${colors.reset}`);
console.log(`${colors.cyan}Testing Category Details API${colors.reset}`);
console.log(`${colors.cyan}========================================${colors.reset}`);
const results = [];
try {
// Setup
await loginUser();
await createGuestSession();
// Run tests
results.push(await testGetGuestCategoryDetails());
results.push(await testGuestAccessAuthCategory());
results.push(await testAuthUserAccessCategory());
results.push(await testInvalidCategoryId());
results.push(await testNonExistentCategory());
results.push(await testResponseStructure());
results.push(await testPublicAccessGuestCategory());
results.push(await testPublicAccessAuthCategory());
results.push(await testStatsCalculations());
// Summary
console.log(`\n${colors.cyan}========================================${colors.reset}`);
console.log(`${colors.cyan}Test Summary${colors.reset}`);
console.log(`${colors.cyan}========================================${colors.reset}`);
const passed = results.filter(r => r === true).length;
const failed = results.filter(r => r === false).length;
console.log(`${colors.green}Passed: ${passed}${colors.reset}`);
console.log(`${colors.red}Failed: ${failed}${colors.reset}`);
console.log(`Total: ${results.length}`);
if (failed === 0) {
console.log(`\n${colors.green}✓ All tests passed!${colors.reset}`);
} else {
console.log(`\n${colors.red}✗ Some tests failed${colors.reset}`);
process.exit(1);
}
} catch (error) {
console.error(`${colors.red}Test execution error:${colors.reset}`, error);
process.exit(1);
}
}
// Run tests
runAllTests();

View File

@@ -0,0 +1,242 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
// Helper function to print test results
function printTestResult(testNumber, testName, success, details = '') {
const emoji = success ? '✅' : '❌';
console.log(`\n${emoji} Test ${testNumber}: ${testName}`);
if (details) console.log(details);
}
// Helper function to print section header
function printSection(title) {
console.log('\n' + '='.repeat(60));
console.log(title);
console.log('='.repeat(60));
}
async function runTests() {
console.log('\n╔════════════════════════════════════════════════════════════╗');
console.log('║ Category Management Tests (Task 18) ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
console.log('Make sure the server is running on http://localhost:3000\n');
let userToken = null;
try {
// Test 1: Get all categories as guest (public access)
printSection('Test 1: Get all categories as guest (public)');
try {
const response = await axios.get(`${BASE_URL}/categories`);
if (response.status === 200 && response.data.success) {
const categories = response.data.data;
printTestResult(1, 'Get all categories as guest', true,
`Count: ${response.data.count}\n` +
`Categories: ${categories.map(c => c.name).join(', ')}\n` +
`Message: ${response.data.message}`);
console.log('\nGuest-accessible categories:');
categories.forEach(cat => {
console.log(` - ${cat.icon} ${cat.name} (${cat.questionCount} questions)`);
console.log(` Slug: ${cat.slug}`);
console.log(` Guest Accessible: ${cat.guestAccessible}`);
});
} else {
throw new Error('Unexpected response');
}
} catch (error) {
printTestResult(1, 'Get all categories as guest', false,
`Error: ${error.response?.data?.message || error.message}`);
}
// Test 2: Verify only guest-accessible categories returned
printSection('Test 2: Verify only guest-accessible categories returned');
try {
const response = await axios.get(`${BASE_URL}/categories`);
const categories = response.data.data;
const allGuestAccessible = categories.every(cat => cat.guestAccessible === true);
if (allGuestAccessible) {
printTestResult(2, 'Guest-accessible filter', true,
`All ${categories.length} categories are guest-accessible\n` +
`Expected: JavaScript, Angular, React`);
} else {
printTestResult(2, 'Guest-accessible filter', false,
`Some categories are not guest-accessible`);
}
} catch (error) {
printTestResult(2, 'Guest-accessible filter', false,
`Error: ${error.message}`);
}
// Test 3: Login as user and get all categories
printSection('Test 3: Login as user and get all categories');
try {
// Login first
const loginResponse = await axios.post(`${BASE_URL}/auth/login`, {
email: 'admin@quiz.com',
password: 'Admin@123'
});
userToken = loginResponse.data.data.token;
console.log('✅ Logged in as admin user');
// Now get categories with auth token
const response = await axios.get(`${BASE_URL}/categories`, {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
if (response.status === 200 && response.data.success) {
const categories = response.data.data;
printTestResult(3, 'Get all categories as authenticated user', true,
`Count: ${response.data.count}\n` +
`Categories: ${categories.map(c => c.name).join(', ')}\n` +
`Message: ${response.data.message}`);
console.log('\nAll active categories:');
categories.forEach(cat => {
console.log(` - ${cat.icon} ${cat.name} (${cat.questionCount} questions)`);
console.log(` Guest Accessible: ${cat.guestAccessible ? 'Yes' : 'No'}`);
});
} else {
throw new Error('Unexpected response');
}
} catch (error) {
printTestResult(3, 'Get all categories as authenticated user', false,
`Error: ${error.response?.data?.message || error.message}`);
}
// Test 4: Verify authenticated users see more categories
printSection('Test 4: Compare guest vs authenticated category counts');
try {
const guestResponse = await axios.get(`${BASE_URL}/categories`);
const authResponse = await axios.get(`${BASE_URL}/categories`, {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
const guestCount = guestResponse.data.count;
const authCount = authResponse.data.count;
if (authCount >= guestCount) {
printTestResult(4, 'Category count comparison', true,
`Guest sees: ${guestCount} categories\n` +
`Authenticated sees: ${authCount} categories\n` +
`Difference: ${authCount - guestCount} additional categories for authenticated users`);
} else {
printTestResult(4, 'Category count comparison', false,
`Authenticated user sees fewer categories than guest`);
}
} catch (error) {
printTestResult(4, 'Category count comparison', false,
`Error: ${error.message}`);
}
// Test 5: Verify response structure
printSection('Test 5: Verify response structure and data types');
try {
const response = await axios.get(`${BASE_URL}/categories`);
const hasCorrectStructure =
response.data.success === true &&
typeof response.data.count === 'number' &&
Array.isArray(response.data.data) &&
typeof response.data.message === 'string';
if (hasCorrectStructure && response.data.data.length > 0) {
const category = response.data.data[0];
const categoryHasFields =
category.id &&
category.name &&
category.slug &&
category.description &&
category.icon &&
category.color &&
typeof category.questionCount === 'number' &&
typeof category.displayOrder === 'number' &&
typeof category.guestAccessible === 'boolean';
if (categoryHasFields) {
printTestResult(5, 'Response structure verification', true,
'All required fields present with correct types\n' +
'Category fields: id, name, slug, description, icon, color, questionCount, displayOrder, guestAccessible');
} else {
printTestResult(5, 'Response structure verification', false,
'Missing or incorrect fields in category object');
}
} else {
printTestResult(5, 'Response structure verification', false,
'Missing or incorrect fields in response');
}
} catch (error) {
printTestResult(5, 'Response structure verification', false,
`Error: ${error.message}`);
}
// Test 6: Verify categories are ordered by displayOrder
printSection('Test 6: Verify categories ordered by displayOrder');
try {
const response = await axios.get(`${BASE_URL}/categories`);
const categories = response.data.data;
let isOrdered = true;
for (let i = 1; i < categories.length; i++) {
if (categories[i].displayOrder < categories[i-1].displayOrder) {
isOrdered = false;
break;
}
}
if (isOrdered) {
printTestResult(6, 'Category ordering', true,
`Categories correctly ordered by displayOrder:\n` +
categories.map(c => ` ${c.displayOrder}: ${c.name}`).join('\n'));
} else {
printTestResult(6, 'Category ordering', false,
'Categories not properly ordered by displayOrder');
}
} catch (error) {
printTestResult(6, 'Category ordering', false,
`Error: ${error.message}`);
}
// Test 7: Verify expected guest categories are present
printSection('Test 7: Verify expected guest categories present');
try {
const response = await axios.get(`${BASE_URL}/categories`);
const categories = response.data.data;
const categoryNames = categories.map(c => c.name);
const expectedCategories = ['JavaScript', 'Angular', 'React'];
const allPresent = expectedCategories.every(name => categoryNames.includes(name));
if (allPresent) {
printTestResult(7, 'Expected categories present', true,
`All expected guest categories found: ${expectedCategories.join(', ')}`);
} else {
const missing = expectedCategories.filter(name => !categoryNames.includes(name));
printTestResult(7, 'Expected categories present', false,
`Missing categories: ${missing.join(', ')}`);
}
} catch (error) {
printTestResult(7, 'Expected categories present', false,
`Error: ${error.message}`);
}
} catch (error) {
console.error('\n❌ Fatal error during testing:', error.message);
}
console.log('\n╔════════════════════════════════════════════════════════════╗');
console.log('║ Tests Completed ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
}
// Run tests
runTests();

View File

@@ -0,0 +1,189 @@
// Category Model Tests
const { sequelize, Category } = require('./models');
async function runTests() {
try {
console.log('🧪 Running Category Model Tests\n');
console.log('=====================================\n');
// Test 1: Create a category
console.log('Test 1: Create a category with auto-generated slug');
const category1 = await Category.create({
name: 'JavaScript Fundamentals',
description: 'Basic JavaScript concepts and syntax',
icon: 'js-icon',
color: '#F7DF1E',
isActive: true,
guestAccessible: true,
displayOrder: 1
});
console.log('✅ Category created with ID:', category1.id);
console.log(' Generated slug:', category1.slug);
console.log(' Expected slug: javascript-fundamentals');
console.log(' Match:', category1.slug === 'javascript-fundamentals' ? '✅' : '❌');
// Test 2: Slug generation with special characters
console.log('\nTest 2: Slug generation handles special characters');
const category2 = await Category.create({
name: 'C++ & Object-Oriented Programming!',
description: 'OOP concepts in C++',
color: '#00599C',
displayOrder: 2
});
console.log('✅ Category created with name:', category2.name);
console.log(' Generated slug:', category2.slug);
console.log(' Expected slug: c-object-oriented-programming');
console.log(' Match:', category2.slug === 'c-object-oriented-programming' ? '✅' : '❌');
// Test 3: Custom slug
console.log('\nTest 3: Create category with custom slug');
const category3 = await Category.create({
name: 'Python Programming',
slug: 'python-basics',
description: 'Python fundamentals',
color: '#3776AB',
displayOrder: 3
});
console.log('✅ Category created with custom slug:', category3.slug);
console.log(' Slug matches custom:', category3.slug === 'python-basics' ? '✅' : '❌');
// Test 4: Find active categories
console.log('\nTest 4: Find all active categories');
const activeCategories = await Category.findActiveCategories();
console.log('✅ Found', activeCategories.length, 'active categories');
console.log(' Expected: 3');
console.log(' Match:', activeCategories.length === 3 ? '✅' : '❌');
// Test 5: Find by slug
console.log('\nTest 5: Find category by slug');
const foundCategory = await Category.findBySlug('javascript-fundamentals');
console.log('✅ Found category:', foundCategory ? foundCategory.name : 'null');
console.log(' Expected: JavaScript Fundamentals');
console.log(' Match:', foundCategory?.name === 'JavaScript Fundamentals' ? '✅' : '❌');
// Test 6: Guest accessible categories
console.log('\nTest 6: Find guest-accessible categories');
const guestCategories = await Category.getGuestAccessibleCategories();
console.log('✅ Found', guestCategories.length, 'guest-accessible categories');
console.log(' Expected: 1 (only JavaScript Fundamentals)');
console.log(' Match:', guestCategories.length === 1 ? '✅' : '❌');
// Test 7: Increment question count
console.log('\nTest 7: Increment question count');
const beforeCount = category1.questionCount;
await category1.incrementQuestionCount();
await category1.reload();
console.log('✅ Question count incremented');
console.log(' Before:', beforeCount);
console.log(' After:', category1.questionCount);
console.log(' Match:', category1.questionCount === beforeCount + 1 ? '✅' : '❌');
// Test 8: Decrement question count
console.log('\nTest 8: Decrement question count');
const beforeCount2 = category1.questionCount;
await category1.decrementQuestionCount();
await category1.reload();
console.log('✅ Question count decremented');
console.log(' Before:', beforeCount2);
console.log(' After:', category1.questionCount);
console.log(' Match:', category1.questionCount === beforeCount2 - 1 ? '✅' : '❌');
// Test 9: Increment quiz count
console.log('\nTest 9: Increment quiz count');
const beforeQuizCount = category1.quizCount;
await category1.incrementQuizCount();
await category1.reload();
console.log('✅ Quiz count incremented');
console.log(' Before:', beforeQuizCount);
console.log(' After:', category1.quizCount);
console.log(' Match:', category1.quizCount === beforeQuizCount + 1 ? '✅' : '❌');
// Test 10: Update category name (slug auto-regenerates)
console.log('\nTest 10: Update category name (slug should regenerate)');
const oldSlug = category3.slug;
category3.name = 'Advanced Python';
await category3.save();
await category3.reload();
console.log('✅ Category name updated');
console.log(' Old slug:', oldSlug);
console.log(' New slug:', category3.slug);
console.log(' Expected new slug: advanced-python');
console.log(' Match:', category3.slug === 'advanced-python' ? '✅' : '❌');
// Test 11: Unique constraint on name
console.log('\nTest 11: Unique constraint on category name');
try {
await Category.create({
name: 'JavaScript Fundamentals', // Duplicate name
description: 'Another JS category'
});
console.log('❌ Should have thrown error for duplicate name');
} catch (error) {
console.log('✅ Unique constraint enforced:', error.name === 'SequelizeUniqueConstraintError' ? '✅' : '❌');
}
// Test 12: Unique constraint on slug
console.log('\nTest 12: Unique constraint on slug');
try {
await Category.create({
name: 'Different Name',
slug: 'javascript-fundamentals' // Duplicate slug
});
console.log('❌ Should have thrown error for duplicate slug');
} catch (error) {
console.log('✅ Unique constraint enforced:', error.name === 'SequelizeUniqueConstraintError' ? '✅' : '❌');
}
// Test 13: Color validation (hex format)
console.log('\nTest 13: Color validation (must be hex format)');
try {
await Category.create({
name: 'Invalid Color Category',
color: 'red' // Invalid - should be #RRGGBB
});
console.log('❌ Should have thrown validation error for invalid color');
} catch (error) {
console.log('✅ Color validation enforced:', error.name === 'SequelizeValidationError' ? '✅' : '❌');
}
// Test 14: Slug validation (lowercase alphanumeric with hyphens)
console.log('\nTest 14: Slug validation (must be lowercase with hyphens only)');
try {
await Category.create({
name: 'Valid Name',
slug: 'Invalid_Slug!' // Invalid - has underscore and exclamation
});
console.log('❌ Should have thrown validation error for invalid slug');
} catch (error) {
console.log('✅ Slug validation enforced:', error.name === 'SequelizeValidationError' ? '✅' : '❌');
}
// Test 15: Get categories with stats
console.log('\nTest 15: Get categories with stats');
const categoriesWithStats = await Category.getCategoriesWithStats();
console.log('✅ Retrieved', categoriesWithStats.length, 'categories with stats');
console.log(' First category stats:');
console.log(' - Name:', categoriesWithStats[0].name);
console.log(' - Question count:', categoriesWithStats[0].questionCount);
console.log(' - Quiz count:', categoriesWithStats[0].quizCount);
console.log(' - Guest accessible:', categoriesWithStats[0].guestAccessible);
// Cleanup
console.log('\n=====================================');
console.log('🧹 Cleaning up test data...');
await Category.destroy({ where: {}, truncate: true });
console.log('✅ Test data deleted\n');
await sequelize.close();
console.log('✅ All Category Model Tests Completed!\n');
process.exit(0);
} catch (error) {
console.error('\n❌ Test failed with error:', error.message);
console.error('Error details:', error);
await sequelize.close();
process.exit(1);
}
}
runTests();

View File

@@ -0,0 +1,48 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
async function quickTest() {
console.log('Creating guest session...');
const guestResponse = await axios.post(`${BASE_URL}/guest/start-session`, {
deviceId: `test_${Date.now()}`
});
const guestToken = guestResponse.data.data.sessionToken;
console.log('✅ Guest session created');
console.log('Guest ID:', guestResponse.data.data.guestId);
console.log('\nConverting guest to user...');
try {
const timestamp = Date.now();
const response = await axios.post(`${BASE_URL}/guest/convert`, {
username: `testuser${timestamp}`,
email: `test${timestamp}@example.com`,
password: 'Password123'
}, {
headers: {
'X-Guest-Token': guestToken
},
timeout: 10000 // 10 second timeout
});
console.log('\n✅ Conversion successful!');
console.log('User:', response.data.data.user.username);
console.log('Migration:', response.data.data.migration);
} catch (error) {
console.error('\n❌ Conversion failed:');
if (error.response) {
console.error('Status:', error.response.status);
console.error('Full response data:', JSON.stringify(error.response.data, null, 2));
} else if (error.code === 'ECONNABORTED') {
console.error('Request timeout - server took too long to respond');
} else {
console.error('Error:', error.message);
}
}
}
quickTest();

View File

@@ -0,0 +1,517 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
// Category UUIDs from database
const CATEGORY_IDS = {
JAVASCRIPT: '68b4c87f-db0b-48ea-b8a4-b2f4fce785a2', // Guest accessible
NODEJS: '5e3094ab-ab6d-4f8a-9261-8177b9c979ae', // Auth only
};
let adminToken = '';
let regularUserToken = '';
let createdQuestionIds = [];
let testResults = {
passed: 0,
failed: 0,
total: 0
};
// Test helper
async function runTest(testName, testFn) {
testResults.total++;
try {
await testFn();
testResults.passed++;
console.log(`${testName} - PASSED`);
} catch (error) {
testResults.failed++;
console.log(`${testName} - FAILED`);
console.log(` Error: ${error.message}`);
}
}
// Setup: Login as admin and regular user
async function setup() {
try {
// Login as admin
const adminLogin = await axios.post(`${BASE_URL}/auth/login`, {
email: 'admin@quiz.com',
password: 'Admin@123'
});
adminToken = adminLogin.data.data.token;
console.log('✓ Logged in as admin');
// Create and login as regular user
const timestamp = Date.now();
const regularUser = {
username: `testuser${timestamp}`,
email: `testuser${timestamp}@test.com`,
password: 'Test@123'
};
await axios.post(`${BASE_URL}/auth/register`, regularUser);
const userLogin = await axios.post(`${BASE_URL}/auth/login`, {
email: regularUser.email,
password: regularUser.password
});
regularUserToken = userLogin.data.data.token;
console.log('✓ Created and logged in as regular user\n');
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
process.exit(1);
}
}
// Tests
async function runTests() {
console.log('========================================');
console.log('Testing Create Question API (Admin)');
console.log('========================================\n');
await setup();
// Test 1: Admin can create multiple choice question
await runTest('Test 1: Admin creates multiple choice question', async () => {
const questionData = {
questionText: 'What is a closure in JavaScript?',
questionType: 'multiple',
options: [
{ id: 'a', text: 'A function that returns another function' },
{ id: 'b', text: 'A function with access to outer scope variables' },
{ id: 'c', text: 'A function that closes the program' },
{ id: 'd', text: 'A private variable' }
],
correctAnswer: 'b',
difficulty: 'medium',
explanation: 'A closure is a function that has access to variables in its outer (enclosing) lexical scope.',
categoryId: CATEGORY_IDS.JAVASCRIPT,
tags: ['functions', 'scope', 'closures'],
keywords: ['closure', 'lexical scope', 'outer function']
};
const response = await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.status !== 201) throw new Error(`Expected 201, got ${response.status}`);
if (response.data.success !== true) throw new Error('Response success should be true');
if (!response.data.data.id) throw new Error('Question ID should be returned');
if (response.data.data.questionText !== questionData.questionText) {
throw new Error('Question text mismatch');
}
if (response.data.data.points !== 10) throw new Error('Medium questions should be 10 points');
createdQuestionIds.push(response.data.data.id);
console.log(` Created question: ${response.data.data.id}`);
});
// Test 2: Admin can create trueFalse question
await runTest('Test 2: Admin creates trueFalse question', async () => {
const questionData = {
questionText: 'JavaScript is a statically-typed language',
questionType: 'trueFalse',
correctAnswer: 'false',
difficulty: 'easy',
explanation: 'JavaScript is a dynamically-typed language.',
categoryId: CATEGORY_IDS.JAVASCRIPT,
tags: ['basics', 'types']
};
const response = await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.status !== 201) throw new Error(`Expected 201, got ${response.status}`);
if (response.data.data.questionType !== 'trueFalse') throw new Error('Question type mismatch');
if (response.data.data.points !== 5) throw new Error('Easy questions should be 5 points');
createdQuestionIds.push(response.data.data.id);
console.log(` Created trueFalse question with 5 points`);
});
// Test 3: Admin can create written question
await runTest('Test 3: Admin creates written question', async () => {
const questionData = {
questionText: 'Explain the event loop in Node.js',
questionType: 'written',
correctAnswer: 'Event loop handles async operations',
difficulty: 'hard',
explanation: 'The event loop is what allows Node.js to perform non-blocking I/O operations.',
categoryId: CATEGORY_IDS.NODEJS,
points: 20 // Custom points
};
const response = await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.status !== 201) throw new Error(`Expected 201, got ${response.status}`);
if (response.data.data.questionType !== 'written') throw new Error('Question type mismatch');
if (response.data.data.points !== 20) throw new Error('Custom points not applied');
createdQuestionIds.push(response.data.data.id);
console.log(` Created written question with custom points (20)`);
});
// Test 4: Non-admin cannot create question
await runTest('Test 4: Non-admin blocked from creating question', async () => {
const questionData = {
questionText: 'Test question',
questionType: 'multiple',
options: [{ id: 'a', text: 'Option A' }, { id: 'b', text: 'Option B' }],
correctAnswer: 'a',
difficulty: 'easy',
categoryId: CATEGORY_IDS.JAVASCRIPT
};
try {
await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${regularUserToken}` }
});
throw new Error('Should have returned 403');
} catch (error) {
if (error.response?.status !== 403) throw new Error(`Expected 403, got ${error.response?.status}`);
console.log(` Correctly blocked with 403`);
}
});
// Test 5: Unauthenticated request blocked
await runTest('Test 5: Unauthenticated request blocked', async () => {
const questionData = {
questionText: 'Test question',
questionType: 'multiple',
options: [{ id: 'a', text: 'Option A' }],
correctAnswer: 'a',
difficulty: 'easy',
categoryId: CATEGORY_IDS.JAVASCRIPT
};
try {
await axios.post(`${BASE_URL}/admin/questions`, questionData);
throw new Error('Should have returned 401');
} catch (error) {
if (error.response?.status !== 401) throw new Error(`Expected 401, got ${error.response?.status}`);
console.log(` Correctly blocked with 401`);
}
});
// Test 6: Missing question text
await runTest('Test 6: Missing question text returns 400', async () => {
const questionData = {
questionType: 'multiple',
options: [{ id: 'a', text: 'Option A' }],
correctAnswer: 'a',
difficulty: 'easy',
categoryId: CATEGORY_IDS.JAVASCRIPT
};
try {
await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
if (!error.response.data.message.includes('text')) {
throw new Error('Should mention question text');
}
console.log(` Correctly rejected missing question text`);
}
});
// Test 7: Invalid question type
await runTest('Test 7: Invalid question type returns 400', async () => {
const questionData = {
questionText: 'Test question',
questionType: 'invalid',
correctAnswer: 'a',
difficulty: 'easy',
categoryId: CATEGORY_IDS.JAVASCRIPT
};
try {
await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
if (!error.response.data.message.includes('Invalid question type')) {
throw new Error('Should mention invalid question type');
}
console.log(` Correctly rejected invalid question type`);
}
});
// Test 8: Missing options for multiple choice
await runTest('Test 8: Missing options for multiple choice returns 400', async () => {
const questionData = {
questionText: 'Test question',
questionType: 'multiple',
correctAnswer: 'a',
difficulty: 'easy',
categoryId: CATEGORY_IDS.JAVASCRIPT
};
try {
await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
if (!error.response.data.message.includes('Options')) {
throw new Error('Should mention options');
}
console.log(` Correctly rejected missing options`);
}
});
// Test 9: Insufficient options (less than 2)
await runTest('Test 9: Insufficient options returns 400', async () => {
const questionData = {
questionText: 'Test question',
questionType: 'multiple',
options: [{ id: 'a', text: 'Only one option' }],
correctAnswer: 'a',
difficulty: 'easy',
categoryId: CATEGORY_IDS.JAVASCRIPT
};
try {
await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
if (!error.response.data.message.includes('at least 2')) {
throw new Error('Should mention minimum options');
}
console.log(` Correctly rejected insufficient options`);
}
});
// Test 10: Correct answer not in options
await runTest('Test 10: Correct answer not in options returns 400', async () => {
const questionData = {
questionText: 'Test question',
questionType: 'multiple',
options: [
{ id: 'a', text: 'Option A' },
{ id: 'b', text: 'Option B' }
],
correctAnswer: 'c', // Not in options
difficulty: 'easy',
categoryId: CATEGORY_IDS.JAVASCRIPT
};
try {
await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
if (!error.response.data.message.includes('match one of the option')) {
throw new Error('Should mention correct answer mismatch');
}
console.log(` Correctly rejected invalid correct answer`);
}
});
// Test 11: Invalid difficulty
await runTest('Test 11: Invalid difficulty returns 400', async () => {
const questionData = {
questionText: 'Test question',
questionType: 'trueFalse',
correctAnswer: 'true',
difficulty: 'invalid',
categoryId: CATEGORY_IDS.JAVASCRIPT
};
try {
await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
if (!error.response.data.message.includes('Invalid difficulty')) {
throw new Error('Should mention invalid difficulty');
}
console.log(` Correctly rejected invalid difficulty`);
}
});
// Test 12: Invalid category UUID
await runTest('Test 12: Invalid category UUID returns 400', async () => {
const questionData = {
questionText: 'Test question',
questionType: 'trueFalse',
correctAnswer: 'true',
difficulty: 'easy',
categoryId: 'invalid-uuid'
};
try {
await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
if (!error.response.data.message.includes('Invalid category ID')) {
throw new Error('Should mention invalid category ID');
}
console.log(` Correctly rejected invalid category UUID`);
}
});
// Test 13: Non-existent category
await runTest('Test 13: Non-existent category returns 404', async () => {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
const questionData = {
questionText: 'Test question',
questionType: 'trueFalse',
correctAnswer: 'true',
difficulty: 'easy',
categoryId: fakeUuid
};
try {
await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
throw new Error('Should have returned 404');
} catch (error) {
if (error.response?.status !== 404) throw new Error(`Expected 404, got ${error.response?.status}`);
if (!error.response.data.message.includes('not found')) {
throw new Error('Should mention category not found');
}
console.log(` Correctly returned 404 for non-existent category`);
}
});
// Test 14: Invalid trueFalse answer
await runTest('Test 14: Invalid trueFalse answer returns 400', async () => {
const questionData = {
questionText: 'Test true/false question',
questionType: 'trueFalse',
correctAnswer: 'yes', // Should be 'true' or 'false'
difficulty: 'easy',
categoryId: CATEGORY_IDS.JAVASCRIPT
};
try {
await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
if (!error.response.data.message.includes('true') || !error.response.data.message.includes('false')) {
throw new Error('Should mention true/false requirement');
}
console.log(` Correctly rejected invalid trueFalse answer`);
}
});
// Test 15: Response structure validation
await runTest('Test 15: Response structure validation', async () => {
const questionData = {
questionText: 'Structure test question',
questionType: 'multiple',
options: [
{ id: 'a', text: 'Option A' },
{ id: 'b', text: 'Option B' }
],
correctAnswer: 'a',
difficulty: 'easy',
categoryId: CATEGORY_IDS.JAVASCRIPT,
tags: ['test'],
keywords: ['structure']
};
const response = await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
// Check top-level structure
const requiredFields = ['success', 'data', 'message'];
for (const field of requiredFields) {
if (!(field in response.data)) throw new Error(`Missing field: ${field}`);
}
// Check question data structure
const question = response.data.data;
const questionFields = ['id', 'questionText', 'questionType', 'options', 'difficulty', 'points', 'explanation', 'tags', 'keywords', 'category', 'createdAt'];
for (const field of questionFields) {
if (!(field in question)) throw new Error(`Missing question field: ${field}`);
}
// Check category structure
const categoryFields = ['id', 'name', 'slug', 'icon', 'color'];
for (const field of categoryFields) {
if (!(field in question.category)) throw new Error(`Missing category field: ${field}`);
}
// Verify correctAnswer is NOT exposed
if ('correctAnswer' in question) {
throw new Error('Correct answer should not be exposed in response');
}
createdQuestionIds.push(question.id);
console.log(` Response structure validated`);
});
// Test 16: Tags and keywords validation
await runTest('Test 16: Tags and keywords stored correctly', async () => {
const questionData = {
questionText: 'Test question with tags and keywords',
questionType: 'trueFalse',
correctAnswer: 'true',
difficulty: 'easy',
categoryId: CATEGORY_IDS.JAVASCRIPT,
tags: ['tag1', 'tag2', 'tag3'],
keywords: ['keyword1', 'keyword2']
};
const response = await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (!Array.isArray(response.data.data.tags)) throw new Error('Tags should be an array');
if (!Array.isArray(response.data.data.keywords)) throw new Error('Keywords should be an array');
if (response.data.data.tags.length !== 3) throw new Error('Tag count mismatch');
if (response.data.data.keywords.length !== 2) throw new Error('Keyword count mismatch');
createdQuestionIds.push(response.data.data.id);
console.log(` Tags and keywords stored correctly`);
});
// Summary
console.log('\n========================================');
console.log('Test Summary');
console.log('========================================');
console.log(`Passed: ${testResults.passed}`);
console.log(`Failed: ${testResults.failed}`);
console.log(`Total: ${testResults.total}`);
console.log(`Created Questions: ${createdQuestionIds.length}`);
console.log('========================================\n');
if (testResults.failed === 0) {
console.log('✓ All tests passed!\n');
} else {
console.log('✗ Some tests failed.\n');
process.exit(1);
}
}
// Run tests
runTests().catch(error => {
console.error('Test execution failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,60 @@
require('dotenv').config();
const db = require('./models');
async function testDatabaseConnection() {
console.log('\n🔍 Testing Database Connection...\n');
console.log('Configuration:');
console.log('- Host:', process.env.DB_HOST);
console.log('- Port:', process.env.DB_PORT);
console.log('- Database:', process.env.DB_NAME);
console.log('- User:', process.env.DB_USER);
console.log('- Dialect:', process.env.DB_DIALECT);
console.log('\n');
try {
// Test connection
await db.sequelize.authenticate();
console.log('✅ Connection has been established successfully.\n');
// Get database version
const [results] = await db.sequelize.query('SELECT VERSION() as version');
console.log('📊 MySQL Version:', results[0].version);
// Check if database exists
const [databases] = await db.sequelize.query('SHOW DATABASES');
const dbExists = databases.some(d => d.Database === process.env.DB_NAME);
if (dbExists) {
console.log(`✅ Database '${process.env.DB_NAME}' exists.\n`);
// Show tables in database
const [tables] = await db.sequelize.query(`SHOW TABLES FROM ${process.env.DB_NAME}`);
console.log(`📋 Tables in '${process.env.DB_NAME}':`, tables.length > 0 ? tables.length : 'No tables yet');
if (tables.length > 0) {
tables.forEach(table => {
const tableName = table[`Tables_in_${process.env.DB_NAME}`];
console.log(` - ${tableName}`);
});
}
} else {
console.log(`⚠️ Database '${process.env.DB_NAME}' does not exist.`);
console.log(`\nTo create it, run:`);
console.log(`mysql -u ${process.env.DB_USER} -p -e "CREATE DATABASE ${process.env.DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"`);
}
console.log('\n✅ Database connection test completed successfully!\n');
process.exit(0);
} catch (error) {
console.error('\n❌ Database connection test failed:');
console.error('Error:', error.message);
console.error('\nPlease ensure:');
console.error('1. MySQL server is running');
console.error('2. Database credentials in .env are correct');
console.error('3. Database exists (or create it with the command above)');
console.error('4. User has proper permissions\n');
process.exit(1);
}
}
testDatabaseConnection();

View File

@@ -0,0 +1,40 @@
const { Category } = require('./models');
async function testFindByPk() {
try {
console.log('\n=== Testing Category.findByPk(1) ===\n');
const category = await Category.findByPk(1, {
attributes: [
'id',
'name',
'slug',
'description',
'icon',
'color',
'questionCount',
'displayOrder',
'guestAccessible',
'isActive'
]
});
console.log('Result:', JSON.stringify(category, null, 2));
if (category) {
console.log('\nCategory found:');
console.log(' Name:', category.name);
console.log(' isActive:', category.isActive);
console.log(' guestAccessible:', category.guestAccessible);
} else {
console.log('Category not found!');
}
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
}
testFindByPk();

View File

@@ -0,0 +1,309 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
// Store test data
let testData = {
guestId: null,
sessionToken: null,
userId: null,
userToken: null
};
// Helper function to print test results
function printTestResult(testNumber, testName, success, details = '') {
const emoji = success ? '✅' : '❌';
console.log(`\n${emoji} Test ${testNumber}: ${testName}`);
if (details) console.log(details);
}
// Helper function to print section header
function printSection(title) {
console.log('\n' + '='.repeat(60));
console.log(title);
console.log('='.repeat(60));
}
async function runTests() {
console.log('\n╔════════════════════════════════════════════════════════════╗');
console.log('║ Guest to User Conversion Tests (Task 17) ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
console.log('Make sure the server is running on http://localhost:3000\n');
try {
// Test 1: Create a guest session
printSection('Test 1: Create guest session for testing');
try {
const response = await axios.post(`${BASE_URL}/guest/start-session`, {
deviceId: `test_device_${Date.now()}`
});
if (response.status === 201 && response.data.success) {
testData.guestId = response.data.data.guestId;
testData.sessionToken = response.data.data.sessionToken;
printTestResult(1, 'Guest session created', true,
`Guest ID: ${testData.guestId}\nToken: ${testData.sessionToken.substring(0, 50)}...`);
} else {
throw new Error('Failed to create session');
}
} catch (error) {
printTestResult(1, 'Guest session creation', false,
`Error: ${error.response?.data?.message || error.message}`);
return;
}
// Test 2: Try conversion without required fields
printSection('Test 2: Conversion without required fields (should fail)');
try {
const response = await axios.post(`${BASE_URL}/guest/convert`, {
username: 'testuser'
// Missing email and password
}, {
headers: {
'X-Guest-Token': testData.sessionToken
}
});
printTestResult(2, 'Missing required fields', false,
'Should have returned 400 but got: ' + response.status);
} catch (error) {
if (error.response?.status === 400) {
printTestResult(2, 'Missing required fields', true,
`Correctly returned 400: ${error.response.data.message}`);
} else {
printTestResult(2, 'Missing required fields', false,
`Wrong status code: ${error.response?.status || 'unknown'}`);
}
}
// Test 3: Try conversion with invalid email
printSection('Test 3: Conversion with invalid email (should fail)');
try {
const response = await axios.post(`${BASE_URL}/guest/convert`, {
username: 'testuser',
email: 'invalid-email',
password: 'Password123'
}, {
headers: {
'X-Guest-Token': testData.sessionToken
}
});
printTestResult(3, 'Invalid email format', false,
'Should have returned 400 but got: ' + response.status);
} catch (error) {
if (error.response?.status === 400) {
printTestResult(3, 'Invalid email format', true,
`Correctly returned 400: ${error.response.data.message}`);
} else {
printTestResult(3, 'Invalid email format', false,
`Wrong status code: ${error.response?.status || 'unknown'}`);
}
}
// Test 4: Try conversion with weak password
printSection('Test 4: Conversion with weak password (should fail)');
try {
const response = await axios.post(`${BASE_URL}/guest/convert`, {
username: 'testuser',
email: 'test@example.com',
password: 'weak'
}, {
headers: {
'X-Guest-Token': testData.sessionToken
}
});
printTestResult(4, 'Weak password', false,
'Should have returned 400 but got: ' + response.status);
} catch (error) {
if (error.response?.status === 400) {
printTestResult(4, 'Weak password', true,
`Correctly returned 400: ${error.response.data.message}`);
} else {
printTestResult(4, 'Weak password', false,
`Wrong status code: ${error.response?.status || 'unknown'}`);
}
}
// Test 5: Successful conversion
printSection('Test 5: Successful guest to user conversion');
const timestamp = Date.now();
const conversionData = {
username: `converted${timestamp}`,
email: `converted${timestamp}@test.com`,
password: 'Password123'
};
try {
const response = await axios.post(`${BASE_URL}/guest/convert`, conversionData, {
headers: {
'X-Guest-Token': testData.sessionToken
}
});
if (response.status === 201 && response.data.success) {
testData.userId = response.data.data.user.id;
testData.userToken = response.data.data.token;
printTestResult(5, 'Guest to user conversion', true,
`User ID: ${testData.userId}\n` +
`Username: ${response.data.data.user.username}\n` +
`Email: ${response.data.data.user.email}\n` +
`Quizzes Transferred: ${response.data.data.migration.quizzesTransferred}\n` +
`Token: ${testData.userToken.substring(0, 50)}...`);
console.log('\nMigration Stats:');
const stats = response.data.data.migration.stats;
console.log(` Total Quizzes: ${stats.totalQuizzes}`);
console.log(` Quizzes Passed: ${stats.quizzesPassed}`);
console.log(` Questions Answered: ${stats.totalQuestionsAnswered}`);
console.log(` Correct Answers: ${stats.correctAnswers}`);
console.log(` Accuracy: ${stats.accuracy}%`);
} else {
throw new Error('Unexpected response');
}
} catch (error) {
printTestResult(5, 'Guest to user conversion', false,
`Error: ${error.response?.data?.message || error.message}`);
return;
}
// Test 6: Try to convert the same guest session again (should fail)
printSection('Test 6: Try to convert already converted session (should fail)');
try {
const response = await axios.post(`${BASE_URL}/guest/convert`, {
username: `another${timestamp}`,
email: `another${timestamp}@test.com`,
password: 'Password123'
}, {
headers: {
'X-Guest-Token': testData.sessionToken
}
});
printTestResult(6, 'Already converted session', false,
'Should have returned 410 but got: ' + response.status);
} catch (error) {
if (error.response?.status === 410) {
printTestResult(6, 'Already converted session', true,
`Correctly returned 410: ${error.response.data.message}`);
} else {
printTestResult(6, 'Already converted session', false,
`Wrong status code: ${error.response?.status || 'unknown'}\nMessage: ${error.response?.data?.message}`);
}
}
// Test 7: Try conversion with duplicate email
printSection('Test 7: Create new guest and try conversion with duplicate email');
try {
// Create new guest session
const guestResponse = await axios.post(`${BASE_URL}/guest/start-session`, {
deviceId: `test_device_2_${Date.now()}`
});
const newGuestToken = guestResponse.data.data.sessionToken;
// Try to convert with existing email
const response = await axios.post(`${BASE_URL}/guest/convert`, {
username: `unique${Date.now()}`,
email: conversionData.email, // Use email from Test 5
password: 'Password123'
}, {
headers: {
'X-Guest-Token': newGuestToken
}
});
printTestResult(7, 'Duplicate email rejection', false,
'Should have returned 400 but got: ' + response.status);
} catch (error) {
if (error.response?.status === 400 && error.response.data.message.includes('Email already registered')) {
printTestResult(7, 'Duplicate email rejection', true,
`Correctly returned 400: ${error.response.data.message}`);
} else {
printTestResult(7, 'Duplicate email rejection', false,
`Wrong status code or message: ${error.response?.status || 'unknown'}`);
}
}
// Test 8: Try conversion with duplicate username
printSection('Test 8: Try conversion with duplicate username');
try {
// Create new guest session
const guestResponse = await axios.post(`${BASE_URL}/guest/start-session`, {
deviceId: `test_device_3_${Date.now()}`
});
const newGuestToken = guestResponse.data.data.sessionToken;
// Try to convert with existing username
const response = await axios.post(`${BASE_URL}/guest/convert`, {
username: conversionData.username, // Use username from Test 5
email: `unique${Date.now()}@test.com`,
password: 'Password123'
}, {
headers: {
'X-Guest-Token': newGuestToken
}
});
printTestResult(8, 'Duplicate username rejection', false,
'Should have returned 400 but got: ' + response.status);
} catch (error) {
if (error.response?.status === 400 && error.response.data.message.includes('Username already taken')) {
printTestResult(8, 'Duplicate username rejection', true,
`Correctly returned 400: ${error.response.data.message}`);
} else {
printTestResult(8, 'Duplicate username rejection', false,
`Wrong status code or message: ${error.response?.status || 'unknown'}`);
}
}
// Test 9: Verify user can login with new credentials
printSection('Test 9: Verify converted user can login');
try {
const response = await axios.post(`${BASE_URL}/auth/login`, {
email: conversionData.email,
password: conversionData.password
});
if (response.status === 200 && response.data.success) {
printTestResult(9, 'Login with converted credentials', true,
`Successfully logged in as: ${response.data.data.user.username}\n` +
`User ID matches: ${response.data.data.user.id === testData.userId}`);
} else {
throw new Error('Login failed');
}
} catch (error) {
printTestResult(9, 'Login with converted credentials', false,
`Error: ${error.response?.data?.message || error.message}`);
}
// Test 10: Verify conversion without token (should fail)
printSection('Test 10: Try conversion without guest token (should fail)');
try {
const response = await axios.post(`${BASE_URL}/guest/convert`, {
username: `notoken${Date.now()}`,
email: `notoken${Date.now()}@test.com`,
password: 'Password123'
});
printTestResult(10, 'No guest token provided', false,
'Should have returned 401 but got: ' + response.status);
} catch (error) {
if (error.response?.status === 401) {
printTestResult(10, 'No guest token provided', true,
`Correctly returned 401: ${error.response.data.message}`);
} else {
printTestResult(10, 'No guest token provided', false,
`Wrong status code: ${error.response?.status || 'unknown'}`);
}
}
} catch (error) {
console.error('\n❌ Fatal error during testing:', error.message);
}
console.log('\n╔════════════════════════════════════════════════════════════╗');
console.log('║ Tests Completed ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
}
// Run tests
runTests();

View File

@@ -0,0 +1,334 @@
/**
* Manual Test Script for Guest Session Endpoints
* Task 15: Guest Session Creation
*
* Run this script with: node test-guest-endpoints.js
* Make sure the server is running on http://localhost:3000
*/
const axios = require('axios');
const API_BASE = 'http://localhost:3000/api';
let testGuestId = null;
let testSessionToken = null;
// Helper function for test output
function logTest(testNumber, description) {
console.log(`\n${'='.repeat(60)}`);
console.log(`${testNumber} Testing ${description}`);
console.log('='.repeat(60));
}
function logSuccess(message) {
console.log(`✅ SUCCESS: ${message}`);
}
function logError(message, error = null) {
console.log(`❌ ERROR: ${message}`);
if (error) {
if (error.response && error.response.data) {
console.log(`Response status: ${error.response.status}`);
console.log(`Response data:`, JSON.stringify(error.response.data, null, 2));
} else if (error.message) {
console.log(`Error details: ${error.message}`);
} else {
console.log(`Error:`, error);
}
}
}
// Test 1: Start a guest session
async function test1_StartGuestSession() {
logTest('1⃣', 'POST /api/guest/start-session - Create guest session');
try {
const requestData = {
deviceId: `device_${Date.now()}`
};
console.log('Request:', JSON.stringify(requestData, null, 2));
const response = await axios.post(`${API_BASE}/guest/start-session`, requestData);
console.log('Response:', JSON.stringify(response.data, null, 2));
if (response.data.success && response.data.data.guestId && response.data.data.sessionToken) {
testGuestId = response.data.data.guestId;
testSessionToken = response.data.data.sessionToken;
logSuccess('Guest session created successfully');
console.log('Guest ID:', testGuestId);
console.log('Session Token:', testSessionToken.substring(0, 50) + '...');
console.log('Expires In:', response.data.data.expiresIn);
console.log('Max Quizzes:', response.data.data.restrictions.maxQuizzes);
console.log('Quizzes Remaining:', response.data.data.restrictions.quizzesRemaining);
console.log('Available Categories:', response.data.data.availableCategories.length);
// Check restrictions
const features = response.data.data.restrictions.features;
console.log('\nFeatures:');
console.log(' - Can Take Quizzes:', features.canTakeQuizzes ? '✅' : '❌');
console.log(' - Can View Results:', features.canViewResults ? '✅' : '❌');
console.log(' - Can Bookmark Questions:', features.canBookmarkQuestions ? '✅' : '❌');
console.log(' - Can Track Progress:', features.canTrackProgress ? '✅' : '❌');
console.log(' - Can Earn Achievements:', features.canEarnAchievements ? '✅' : '❌');
} else {
logError('Unexpected response format');
}
} catch (error) {
logError('Failed to create guest session', error);
}
}
// Test 2: Get guest session details
async function test2_GetGuestSession() {
logTest('2⃣', 'GET /api/guest/session/:guestId - Get session details');
if (!testGuestId) {
logError('No guest ID available. Skipping test.');
return;
}
try {
const response = await axios.get(`${API_BASE}/guest/session/${testGuestId}`);
console.log('Response:', JSON.stringify(response.data, null, 2));
if (response.data.success && response.data.data) {
logSuccess('Guest session retrieved successfully');
console.log('Guest ID:', response.data.data.guestId);
console.log('Expires In:', response.data.data.expiresIn);
console.log('Is Expired:', response.data.data.isExpired);
console.log('Quizzes Attempted:', response.data.data.restrictions.quizzesAttempted);
console.log('Quizzes Remaining:', response.data.data.restrictions.quizzesRemaining);
console.log('Can Take Quizzes:', response.data.data.restrictions.features.canTakeQuizzes);
} else {
logError('Unexpected response format');
}
} catch (error) {
logError('Failed to get guest session', error);
}
}
// Test 3: Get non-existent guest session
async function test3_GetNonExistentSession() {
logTest('3⃣', 'GET /api/guest/session/:guestId - Non-existent session (should fail)');
try {
const response = await axios.get(`${API_BASE}/guest/session/guest_nonexistent_12345`);
console.log('Response:', JSON.stringify(response.data, null, 2));
logError('Should have returned 404 for non-existent session');
} catch (error) {
if (error.response && error.response.status === 404) {
console.log('Response:', JSON.stringify(error.response.data, null, 2));
logSuccess('Correctly returned 404 for non-existent session');
} else {
logError('Unexpected error', error);
}
}
}
// Test 4: Start guest session without deviceId (optional field)
async function test4_StartSessionWithoutDeviceId() {
logTest('4⃣', 'POST /api/guest/start-session - Without deviceId');
try {
const response = await axios.post(`${API_BASE}/guest/start-session`, {});
console.log('Response:', JSON.stringify(response.data, null, 2));
if (response.data.success && response.data.data.guestId) {
logSuccess('Guest session created without deviceId (optional field)');
console.log('Guest ID:', response.data.data.guestId);
} else {
logError('Unexpected response format');
}
} catch (error) {
logError('Failed to create guest session', error);
}
}
// Test 5: Verify guest-accessible categories
async function test5_VerifyGuestCategories() {
logTest('5⃣', 'Verify guest-accessible categories');
if (!testGuestId) {
logError('No guest ID available. Skipping test.');
return;
}
try {
const response = await axios.get(`${API_BASE}/guest/session/${testGuestId}`);
const categories = response.data.data.availableCategories;
console.log(`Found ${categories.length} guest-accessible categories:`);
categories.forEach((cat, index) => {
console.log(` ${index + 1}. ${cat.name} (${cat.question_count} questions)`);
});
if (categories.length > 0) {
logSuccess(`${categories.length} guest-accessible categories available`);
// Expected categories from seeder: JavaScript, Angular, React
const expectedCategories = ['JavaScript', 'Angular', 'React'];
const foundCategories = categories.map(c => c.name);
console.log('\nExpected guest-accessible categories:', expectedCategories.join(', '));
console.log('Found categories:', foundCategories.join(', '));
const allFound = expectedCategories.every(cat => foundCategories.includes(cat));
if (allFound) {
logSuccess('All expected categories are accessible to guests');
} else {
logError('Some expected categories are missing');
}
} else {
logError('No guest-accessible categories found (check seeder data)');
}
} catch (error) {
logError('Failed to verify categories', error);
}
}
// Test 6: Verify session restrictions
async function test6_VerifySessionRestrictions() {
logTest('6⃣', 'Verify guest session restrictions');
if (!testGuestId) {
logError('No guest ID available. Skipping test.');
return;
}
try {
const response = await axios.get(`${API_BASE}/guest/session/${testGuestId}`);
const restrictions = response.data.data.restrictions;
const features = restrictions.features;
console.log('Quiz Restrictions:');
console.log(' - Max Quizzes:', restrictions.maxQuizzes);
console.log(' - Quizzes Attempted:', restrictions.quizzesAttempted);
console.log(' - Quizzes Remaining:', restrictions.quizzesRemaining);
console.log('\nFeature Restrictions:');
console.log(' - Can Take Quizzes:', features.canTakeQuizzes ? '✅ Yes' : '❌ No');
console.log(' - Can View Results:', features.canViewResults ? '✅ Yes' : '❌ No');
console.log(' - Can Bookmark Questions:', features.canBookmarkQuestions ? '✅ Yes' : '❌ No');
console.log(' - Can Track Progress:', features.canTrackProgress ? '✅ Yes' : '❌ No');
console.log(' - Can Earn Achievements:', features.canEarnAchievements ? '✅ Yes' : '❌ No');
// Verify expected restrictions
const expectedRestrictions = {
maxQuizzes: 3,
canTakeQuizzes: true,
canViewResults: true,
canBookmarkQuestions: false,
canTrackProgress: false,
canEarnAchievements: false
};
const allCorrect =
restrictions.maxQuizzes === expectedRestrictions.maxQuizzes &&
features.canTakeQuizzes === expectedRestrictions.canTakeQuizzes &&
features.canViewResults === expectedRestrictions.canViewResults &&
features.canBookmarkQuestions === expectedRestrictions.canBookmarkQuestions &&
features.canTrackProgress === expectedRestrictions.canTrackProgress &&
features.canEarnAchievements === expectedRestrictions.canEarnAchievements;
if (allCorrect) {
logSuccess('All restrictions are correctly configured');
} else {
logError('Some restrictions do not match expected values');
}
} catch (error) {
logError('Failed to verify restrictions', error);
}
}
// Test 7: Verify session token is valid JWT
async function test7_VerifySessionToken() {
logTest('7⃣', 'Verify session token format');
if (!testSessionToken) {
logError('No session token available. Skipping test.');
return;
}
try {
// JWT tokens have 3 parts separated by dots
const parts = testSessionToken.split('.');
console.log('Token parts:', parts.length);
console.log('Header:', parts[0].substring(0, 20) + '...');
console.log('Payload:', parts[1].substring(0, 20) + '...');
console.log('Signature:', parts[2].substring(0, 20) + '...');
if (parts.length === 3) {
logSuccess('Session token is in valid JWT format (3 parts)');
// Decode payload (base64)
try {
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
console.log('\nDecoded payload:');
console.log(' - Guest ID:', payload.guestId);
console.log(' - Issued At:', new Date(payload.iat * 1000).toISOString());
console.log(' - Expires At:', new Date(payload.exp * 1000).toISOString());
if (payload.guestId === testGuestId) {
logSuccess('Token contains correct guest ID');
} else {
logError('Token guest ID does not match session guest ID');
}
} catch (decodeError) {
logError('Failed to decode token payload', decodeError);
}
} else {
logError('Session token is not in valid JWT format');
}
} catch (error) {
logError('Failed to verify token', error);
}
}
// Run all tests
async function runAllTests() {
console.log('\n');
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ Guest Session Creation Tests (Task 15) ║');
console.log('╚════════════════════════════════════════════════════════════╝');
console.log('\nMake sure the server is running on http://localhost:3000\n');
await test1_StartGuestSession();
await new Promise(resolve => setTimeout(resolve, 500));
await test2_GetGuestSession();
await new Promise(resolve => setTimeout(resolve, 500));
await test3_GetNonExistentSession();
await new Promise(resolve => setTimeout(resolve, 500));
await test4_StartSessionWithoutDeviceId();
await new Promise(resolve => setTimeout(resolve, 500));
await test5_VerifyGuestCategories();
await new Promise(resolve => setTimeout(resolve, 500));
await test6_VerifySessionRestrictions();
await new Promise(resolve => setTimeout(resolve, 500));
await test7_VerifySessionToken();
console.log('\n');
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ All Tests Completed ║');
console.log('╚════════════════════════════════════════════════════════════╝');
console.log('\n');
}
// Run tests
runAllTests().catch(error => {
console.error('\n❌ Fatal error running tests:', error);
process.exit(1);
});

View File

@@ -0,0 +1,219 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
// Store session data for testing
let testSession = {
guestId: null,
sessionToken: null
};
// Helper function to print test results
function printTestResult(testNumber, testName, success, details = '') {
const emoji = success ? '✅' : '❌';
console.log(`\n${emoji} Test ${testNumber}: ${testName}`);
if (details) console.log(details);
}
// Helper function to print section header
function printSection(title) {
console.log('\n' + '='.repeat(60));
console.log(title);
console.log('='.repeat(60));
}
async function runTests() {
console.log('\n╔════════════════════════════════════════════════════════════╗');
console.log('║ Guest Quiz Limit Tests (Task 16) ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
console.log('Make sure the server is running on http://localhost:3000\n');
try {
// Test 1: Create a guest session first
printSection('Test 1: Create guest session for testing');
try {
const response = await axios.post(`${BASE_URL}/guest/start-session`, {
deviceId: `test_device_${Date.now()}`
});
if (response.status === 201 && response.data.success) {
testSession.guestId = response.data.data.guestId;
testSession.sessionToken = response.data.data.sessionToken;
printTestResult(1, 'Guest session created', true,
`Guest ID: ${testSession.guestId}\nToken: ${testSession.sessionToken.substring(0, 50)}...`);
} else {
throw new Error('Failed to create session');
}
} catch (error) {
printTestResult(1, 'Guest session creation', false,
`Error: ${error.response?.data?.message || error.message}`);
return; // Can't continue without session
}
// Test 2: Check quiz limit with valid token (should have 3 remaining)
printSection('Test 2: Check quiz limit with valid token');
try {
const response = await axios.get(`${BASE_URL}/guest/quiz-limit`, {
headers: {
'X-Guest-Token': testSession.sessionToken
}
});
if (response.status === 200 && response.data.success) {
const { quizLimit, session } = response.data.data;
printTestResult(2, 'Quiz limit check with valid token', true,
`Max Quizzes: ${quizLimit.maxQuizzes}\n` +
`Quizzes Attempted: ${quizLimit.quizzesAttempted}\n` +
`Quizzes Remaining: ${quizLimit.quizzesRemaining}\n` +
`Has Reached Limit: ${quizLimit.hasReachedLimit}\n` +
`Time Remaining: ${session.timeRemaining}`);
} else {
throw new Error('Unexpected response');
}
} catch (error) {
printTestResult(2, 'Quiz limit check with valid token', false,
`Error: ${error.response?.data?.message || error.message}`);
}
// Test 3: Check quiz limit without token (should fail)
printSection('Test 3: Check quiz limit without token (should fail with 401)');
try {
const response = await axios.get(`${BASE_URL}/guest/quiz-limit`);
printTestResult(3, 'No token provided', false,
'Should have returned 401 but got: ' + response.status);
} catch (error) {
if (error.response?.status === 401) {
printTestResult(3, 'No token provided', true,
`Correctly returned 401: ${error.response.data.message}`);
} else {
printTestResult(3, 'No token provided', false,
`Wrong status code: ${error.response?.status || 'unknown'}`);
}
}
// Test 4: Check quiz limit with invalid token (should fail)
printSection('Test 4: Check quiz limit with invalid token (should fail with 401)');
try {
const response = await axios.get(`${BASE_URL}/guest/quiz-limit`, {
headers: {
'X-Guest-Token': 'invalid.token.here'
}
});
printTestResult(4, 'Invalid token provided', false,
'Should have returned 401 but got: ' + response.status);
} catch (error) {
if (error.response?.status === 401) {
printTestResult(4, 'Invalid token provided', true,
`Correctly returned 401: ${error.response.data.message}`);
} else {
printTestResult(4, 'Invalid token provided', false,
`Wrong status code: ${error.response?.status || 'unknown'}`);
}
}
// Test 5: Simulate reaching quiz limit
printSection('Test 5: Simulate quiz limit reached (update database manually)');
console.log('\n To test limit reached scenario:');
console.log(' Run this SQL query:');
console.log(` UPDATE guest_sessions SET quizzes_attempted = 3 WHERE guest_id = '${testSession.guestId}';`);
console.log('\n Then check quiz limit again with this curl command:');
console.log(` curl -H "X-Guest-Token: ${testSession.sessionToken}" ${BASE_URL}/guest/quiz-limit`);
console.log('\n Expected: hasReachedLimit: true, upgradePrompt with benefits');
// Test 6: Check with non-existent guest ID token
printSection('Test 6: Check with token for non-existent guest (should fail with 404)');
try {
// Create a token with fake guest ID
const jwt = require('jsonwebtoken');
const config = require('./config/config');
const fakeToken = jwt.sign(
{ guestId: 'guest_fake_12345' },
config.jwt.secret,
{ expiresIn: '24h' }
);
const response = await axios.get(`${BASE_URL}/guest/quiz-limit`, {
headers: {
'X-Guest-Token': fakeToken
}
});
printTestResult(6, 'Non-existent guest ID', false,
'Should have returned 404 but got: ' + response.status);
} catch (error) {
if (error.response?.status === 404) {
printTestResult(6, 'Non-existent guest ID', true,
`Correctly returned 404: ${error.response.data.message}`);
} else {
printTestResult(6, 'Non-existent guest ID', false,
`Wrong status code: ${error.response?.status || 'unknown'}`);
}
}
// Test 7: Verify response structure
printSection('Test 7: Verify response structure and data types');
try {
const response = await axios.get(`${BASE_URL}/guest/quiz-limit`, {
headers: {
'X-Guest-Token': testSession.sessionToken
}
});
const { data } = response.data;
const hasCorrectStructure =
data.guestId &&
data.quizLimit &&
typeof data.quizLimit.maxQuizzes === 'number' &&
typeof data.quizLimit.quizzesAttempted === 'number' &&
typeof data.quizLimit.quizzesRemaining === 'number' &&
typeof data.quizLimit.hasReachedLimit === 'boolean' &&
data.session &&
data.session.expiresAt &&
data.session.timeRemaining;
if (hasCorrectStructure) {
printTestResult(7, 'Response structure verification', true,
'All required fields present with correct types');
} else {
printTestResult(7, 'Response structure verification', false,
'Missing or incorrect fields in response');
}
} catch (error) {
printTestResult(7, 'Response structure verification', false,
`Error: ${error.message}`);
}
// Test 8: Verify calculations
printSection('Test 8: Verify quiz remaining calculation');
try {
const response = await axios.get(`${BASE_URL}/guest/quiz-limit`, {
headers: {
'X-Guest-Token': testSession.sessionToken
}
});
const { quizLimit } = response.data.data;
const expectedRemaining = quizLimit.maxQuizzes - quizLimit.quizzesAttempted;
if (quizLimit.quizzesRemaining === expectedRemaining) {
printTestResult(8, 'Quiz remaining calculation', true,
`Calculation correct: ${quizLimit.maxQuizzes} - ${quizLimit.quizzesAttempted} = ${quizLimit.quizzesRemaining}`);
} else {
printTestResult(8, 'Quiz remaining calculation', false,
`Expected ${expectedRemaining} but got ${quizLimit.quizzesRemaining}`);
}
} catch (error) {
printTestResult(8, 'Quiz remaining calculation', false,
`Error: ${error.message}`);
}
} catch (error) {
console.error('\n❌ Fatal error during testing:', error.message);
}
console.log('\n╔════════════════════════════════════════════════════════════╗');
console.log('║ Tests Completed ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
}
// Run tests
runTests();

View File

@@ -0,0 +1,227 @@
// GuestSession Model Tests
const { sequelize, GuestSession, User } = require('./models');
async function runTests() {
try {
console.log('🧪 Running GuestSession Model Tests\n');
console.log('=====================================\n');
// Test 1: Create a guest session
console.log('Test 1: Create a new guest session');
const session1 = await GuestSession.createSession({
deviceId: 'device-123',
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
maxQuizzes: 5
});
console.log('✅ Guest session created with ID:', session1.id);
console.log(' Guest ID:', session1.guestId);
console.log(' Session token:', session1.sessionToken.substring(0, 50) + '...');
console.log(' Max quizzes:', session1.maxQuizzes);
console.log(' Expires at:', session1.expiresAt);
console.log(' Match:', session1.guestId.startsWith('guest_') ? '✅' : '❌');
// Test 2: Generate guest ID
console.log('\nTest 2: Generate guest ID with correct format');
const guestId = GuestSession.generateGuestId();
console.log('✅ Generated guest ID:', guestId);
console.log(' Starts with "guest_":', guestId.startsWith('guest_') ? '✅' : '❌');
console.log(' Has timestamp and random:', guestId.split('_').length === 3 ? '✅' : '❌');
// Test 3: Token verification
console.log('\nTest 3: Verify and decode session token');
try {
const decoded = GuestSession.verifyToken(session1.sessionToken);
console.log('✅ Token verified successfully');
console.log(' Guest ID matches:', decoded.guestId === session1.guestId ? '✅' : '❌');
console.log(' Session ID matches:', decoded.sessionId === session1.id ? '✅' : '❌');
console.log(' Token type:', decoded.type);
} catch (error) {
console.log('❌ Token verification failed:', error.message);
}
// Test 4: Find by guest ID
console.log('\nTest 4: Find session by guest ID');
const foundSession = await GuestSession.findByGuestId(session1.guestId);
console.log('✅ Session found by guest ID');
console.log(' ID matches:', foundSession.id === session1.id ? '✅' : '❌');
// Test 5: Find by token
console.log('\nTest 5: Find session by token');
const foundByToken = await GuestSession.findByToken(session1.sessionToken);
console.log('✅ Session found by token');
console.log(' ID matches:', foundByToken.id === session1.id ? '✅' : '❌');
// Test 6: Check if expired
console.log('\nTest 6: Check if session is expired');
const isExpired = session1.isExpired();
console.log('✅ Session expiry checked');
console.log(' Is expired:', isExpired);
console.log(' Should not be expired:', !isExpired ? '✅' : '❌');
// Test 7: Quiz limit check
console.log('\nTest 7: Check quiz limit');
const hasReachedLimit = session1.hasReachedQuizLimit();
const remaining = session1.getRemainingQuizzes();
console.log('✅ Quiz limit checked');
console.log(' Has reached limit:', hasReachedLimit);
console.log(' Remaining quizzes:', remaining);
console.log(' Match expected (5):', remaining === 5 ? '✅' : '❌');
// Test 8: Increment quiz attempt
console.log('\nTest 8: Increment quiz attempt count');
const beforeAttempts = session1.quizzesAttempted;
await session1.incrementQuizAttempt();
await session1.reload();
console.log('✅ Quiz attempt incremented');
console.log(' Before:', beforeAttempts);
console.log(' After:', session1.quizzesAttempted);
console.log(' Match:', session1.quizzesAttempted === beforeAttempts + 1 ? '✅' : '❌');
// Test 9: Multiple quiz attempts until limit
console.log('\nTest 9: Increment attempts until limit reached');
for (let i = 0; i < 4; i++) {
await session1.incrementQuizAttempt();
}
await session1.reload();
console.log('✅ Incremented to limit');
console.log(' Quizzes attempted:', session1.quizzesAttempted);
console.log(' Max quizzes:', session1.maxQuizzes);
console.log(' Has reached limit:', session1.hasReachedQuizLimit() ? '✅' : '❌');
console.log(' Remaining quizzes:', session1.getRemainingQuizzes());
// Test 10: Get session info
console.log('\nTest 10: Get session info object');
const sessionInfo = session1.getSessionInfo();
console.log('✅ Session info retrieved');
console.log(' Guest ID:', sessionInfo.guestId);
console.log(' Quizzes attempted:', sessionInfo.quizzesAttempted);
console.log(' Remaining:', sessionInfo.remainingQuizzes);
console.log(' Has reached limit:', sessionInfo.hasReachedLimit);
console.log(' Match:', typeof sessionInfo === 'object' ? '✅' : '❌');
// Test 11: Extend session
console.log('\nTest 11: Extend session expiry');
const oldExpiry = new Date(session1.expiresAt);
await session1.extend(48); // Extend by 48 hours
await session1.reload();
const newExpiry = new Date(session1.expiresAt);
console.log('✅ Session extended');
console.log(' Old expiry:', oldExpiry);
console.log(' New expiry:', newExpiry);
console.log(' Extended:', newExpiry > oldExpiry ? '✅' : '❌');
// Test 12: Create user and convert session
console.log('\nTest 12: Convert guest session to registered user');
const testUser = await User.create({
username: `converteduser${Date.now()}`,
email: `converted${Date.now()}@test.com`,
password: 'password123',
role: 'user'
});
await session1.convertToUser(testUser.id);
await session1.reload();
console.log('✅ Session converted to user');
console.log(' Is converted:', session1.isConverted);
console.log(' Converted user ID:', session1.convertedUserId);
console.log(' Match:', session1.convertedUserId === testUser.id ? '✅' : '❌');
// Test 13: Find active session (should not find converted one)
console.log('\nTest 13: Find active session (excluding converted)');
const activeSession = await GuestSession.findActiveSession(session1.guestId);
console.log('✅ Active session search completed');
console.log(' Should be null:', activeSession === null ? '✅' : '❌');
// Test 14: Create another session and find active
console.log('\nTest 14: Create new session and find active');
const session2 = await GuestSession.createSession({
deviceId: 'device-456',
maxQuizzes: 3
});
const activeSession2 = await GuestSession.findActiveSession(session2.guestId);
console.log('✅ Found active session');
console.log(' ID matches:', activeSession2.id === session2.id ? '✅' : '❌');
// Test 15: Get active guest count
console.log('\nTest 15: Get active guest count');
const activeCount = await GuestSession.getActiveGuestCount();
console.log('✅ Active guest count:', activeCount);
console.log(' Expected at least 1:', activeCount >= 1 ? '✅' : '❌');
// Test 16: Get conversion rate
console.log('\nTest 16: Calculate conversion rate');
const conversionRate = await GuestSession.getConversionRate();
console.log('✅ Conversion rate:', conversionRate + '%');
console.log(' Expected 50% (1 of 2):', conversionRate === 50 ? '✅' : '❌');
// Test 17: Invalid token verification
console.log('\nTest 17: Verify invalid token');
try {
GuestSession.verifyToken('invalid-token-12345');
console.log('❌ Should have thrown error');
} catch (error) {
console.log('✅ Invalid token rejected:', error.message.includes('Invalid') ? '✅' : '❌');
}
// Test 18: Unique constraints
console.log('\nTest 18: Test unique constraint on guest_id');
try {
await GuestSession.create({
guestId: session1.guestId, // Duplicate guest_id
sessionToken: 'some-unique-token',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
maxQuizzes: 3
});
console.log('❌ Should have thrown unique constraint error');
} catch (error) {
console.log('✅ Unique constraint enforced:', error.name === 'SequelizeUniqueConstraintError' ? '✅' : '❌');
}
// Test 19: Association with User
console.log('\nTest 19: Load session with converted user association');
const sessionWithUser = await GuestSession.findByPk(session1.id, {
include: [{ model: User, as: 'convertedUser' }]
});
console.log('✅ Session loaded with user association');
console.log(' User username:', sessionWithUser.convertedUser?.username);
console.log(' Match:', sessionWithUser.convertedUser?.id === testUser.id ? '✅' : '❌');
// Test 20: Cleanup expired sessions (simulate)
console.log('\nTest 20: Cleanup expired sessions');
// Create an expired session by creating a valid one then updating it
const tempSession = await GuestSession.createSession({ maxQuizzes: 3 });
await tempSession.update({
expiresAt: new Date(Date.now() - 1000) // Set to expired
}, {
validate: false // Skip validation
});
const cleanedCount = await GuestSession.cleanupExpiredSessions();
console.log('✅ Expired sessions cleaned');
console.log(' Sessions deleted:', cleanedCount);
console.log(' Expected at least 1:', cleanedCount >= 1 ? '✅' : '❌');
// Cleanup
console.log('\n=====================================');
console.log('🧹 Cleaning up test data...');
await GuestSession.destroy({ where: {}, force: true });
await User.destroy({ where: {}, force: true });
console.log('✅ Test data deleted\n');
await sequelize.close();
console.log('✅ All GuestSession Model Tests Completed!\n');
process.exit(0);
} catch (error) {
console.error('\n❌ Test failed with error:', error.message);
console.error('Error details:', error);
await sequelize.close();
process.exit(1);
}
}
// Need uuid for test 20
const { v4: uuidv4 } = require('uuid');
runTests();

View File

@@ -0,0 +1,319 @@
const { sequelize } = require('./models');
const { User, Category, Question, GuestSession, QuizSession } = require('./models');
const { QueryTypes } = require('sequelize');
async function runTests() {
console.log('🧪 Running Junction Tables Tests\n');
console.log('=====================================\n');
try {
// Test 1: Verify quiz_answers table exists and structure
console.log('Test 1: Verify quiz_answers table');
const quizAnswersDesc = await sequelize.query(
"DESCRIBE quiz_answers",
{ type: QueryTypes.SELECT }
);
console.log('✅ quiz_answers table exists');
console.log(' Fields:', quizAnswersDesc.length);
console.log(' Expected 10 fields:', quizAnswersDesc.length === 10 ? '✅' : '❌');
// Test 2: Verify quiz_session_questions table
console.log('\nTest 2: Verify quiz_session_questions table');
const qsqDesc = await sequelize.query(
"DESCRIBE quiz_session_questions",
{ type: QueryTypes.SELECT }
);
console.log('✅ quiz_session_questions table exists');
console.log(' Fields:', qsqDesc.length);
console.log(' Expected 6 fields:', qsqDesc.length === 6 ? '✅' : '❌');
// Test 3: Verify user_bookmarks table
console.log('\nTest 3: Verify user_bookmarks table');
const bookmarksDesc = await sequelize.query(
"DESCRIBE user_bookmarks",
{ type: QueryTypes.SELECT }
);
console.log('✅ user_bookmarks table exists');
console.log(' Fields:', bookmarksDesc.length);
console.log(' Expected 6 fields:', bookmarksDesc.length === 6 ? '✅' : '❌');
// Test 4: Verify achievements table
console.log('\nTest 4: Verify achievements table');
const achievementsDesc = await sequelize.query(
"DESCRIBE achievements",
{ type: QueryTypes.SELECT }
);
console.log('✅ achievements table exists');
console.log(' Fields:', achievementsDesc.length);
console.log(' Expected 14 fields:', achievementsDesc.length === 14 ? '✅' : '❌');
// Test 5: Verify user_achievements table
console.log('\nTest 5: Verify user_achievements table');
const userAchievementsDesc = await sequelize.query(
"DESCRIBE user_achievements",
{ type: QueryTypes.SELECT }
);
console.log('✅ user_achievements table exists');
console.log(' Fields:', userAchievementsDesc.length);
console.log(' Expected 7 fields:', userAchievementsDesc.length === 7 ? '✅' : '❌');
// Test 6: Test quiz_answers foreign keys
console.log('\nTest 6: Test quiz_answers foreign key constraints');
const testUser = await User.create({
username: `testuser${Date.now()}`,
email: `test${Date.now()}@test.com`,
password: 'password123'
});
const testCategory = await Category.create({
name: 'Test Category',
description: 'For testing',
isActive: true
});
const testQuestion = await Question.create({
categoryId: testCategory.id,
questionText: 'Test question?',
options: JSON.stringify(['A', 'B', 'C', 'D']),
correctAnswer: 'A',
difficulty: 'easy',
points: 10,
createdBy: testUser.id
});
const testQuizSession = await QuizSession.createSession({
userId: testUser.id,
categoryId: testCategory.id,
quizType: 'practice',
totalQuestions: 1
});
await sequelize.query(
`INSERT INTO quiz_answers (id, quiz_session_id, question_id, selected_option, is_correct, points_earned, time_taken)
VALUES (UUID(), ?, ?, 'A', 1, 10, 5)`,
{ replacements: [testQuizSession.id, testQuestion.id], type: QueryTypes.INSERT }
);
const answers = await sequelize.query(
"SELECT * FROM quiz_answers WHERE quiz_session_id = ?",
{ replacements: [testQuizSession.id], type: QueryTypes.SELECT }
);
console.log('✅ Quiz answer inserted');
console.log(' Answer count:', answers.length);
console.log(' Foreign keys working:', answers.length === 1 ? '✅' : '❌');
// Test 7: Test quiz_session_questions junction
console.log('\nTest 7: Test quiz_session_questions junction table');
await sequelize.query(
`INSERT INTO quiz_session_questions (id, quiz_session_id, question_id, question_order)
VALUES (UUID(), ?, ?, 1)`,
{ replacements: [testQuizSession.id, testQuestion.id], type: QueryTypes.INSERT }
);
const qsqRecords = await sequelize.query(
"SELECT * FROM quiz_session_questions WHERE quiz_session_id = ?",
{ replacements: [testQuizSession.id], type: QueryTypes.SELECT }
);
console.log('✅ Quiz-question link created');
console.log(' Link count:', qsqRecords.length);
console.log(' Question order:', qsqRecords[0].question_order);
console.log(' Junction working:', qsqRecords.length === 1 && qsqRecords[0].question_order === 1 ? '✅' : '❌');
// Test 8: Test user_bookmarks
console.log('\nTest 8: Test user_bookmarks table');
await sequelize.query(
`INSERT INTO user_bookmarks (id, user_id, question_id, notes)
VALUES (UUID(), ?, ?, 'Important question for review')`,
{ replacements: [testUser.id, testQuestion.id], type: QueryTypes.INSERT }
);
const bookmarks = await sequelize.query(
"SELECT * FROM user_bookmarks WHERE user_id = ?",
{ replacements: [testUser.id], type: QueryTypes.SELECT }
);
console.log('✅ Bookmark created');
console.log(' Bookmark count:', bookmarks.length);
console.log(' Notes:', bookmarks[0].notes);
console.log(' Bookmarks working:', bookmarks.length === 1 ? '✅' : '❌');
// Test 9: Test achievements table
console.log('\nTest 9: Test achievements table');
await sequelize.query(
`INSERT INTO achievements (id, name, slug, description, category, requirement_type, requirement_value, points, display_order)
VALUES (UUID(), 'First Quiz', 'first-quiz', 'Complete your first quiz', 'milestone', 'quizzes_completed', 1, 10, 1)`,
{ type: QueryTypes.INSERT }
);
const achievements = await sequelize.query(
"SELECT * FROM achievements WHERE slug = 'first-quiz'",
{ type: QueryTypes.SELECT }
);
console.log('✅ Achievement created');
console.log(' Name:', achievements[0].name);
console.log(' Category:', achievements[0].category);
console.log(' Requirement type:', achievements[0].requirement_type);
console.log(' Points:', achievements[0].points);
console.log(' Achievements working:', achievements.length === 1 ? '✅' : '❌');
// Test 10: Test user_achievements junction
console.log('\nTest 10: Test user_achievements junction table');
const achievementId = achievements[0].id;
await sequelize.query(
`INSERT INTO user_achievements (id, user_id, achievement_id, notified)
VALUES (UUID(), ?, ?, 0)`,
{ replacements: [testUser.id, achievementId], type: QueryTypes.INSERT }
);
const userAchievements = await sequelize.query(
"SELECT * FROM user_achievements WHERE user_id = ?",
{ replacements: [testUser.id], type: QueryTypes.SELECT }
);
console.log('✅ User achievement created');
console.log(' Count:', userAchievements.length);
console.log(' Notified:', userAchievements[0].notified);
console.log(' User achievements working:', userAchievements.length === 1 ? '✅' : '❌');
// Test 11: Test unique constraints on quiz_answers
console.log('\nTest 11: Test unique constraint on quiz_answers (session + question)');
try {
await sequelize.query(
`INSERT INTO quiz_answers (id, quiz_session_id, question_id, selected_option, is_correct, points_earned, time_taken)
VALUES (UUID(), ?, ?, 'B', 0, 0, 3)`,
{ replacements: [testQuizSession.id, testQuestion.id], type: QueryTypes.INSERT }
);
console.log('❌ Should have thrown unique constraint error');
} catch (error) {
console.log('✅ Unique constraint enforced:', error.parent.code === 'ER_DUP_ENTRY' ? '✅' : '❌');
}
// Test 12: Test unique constraint on user_bookmarks
console.log('\nTest 12: Test unique constraint on user_bookmarks (user + question)');
try {
await sequelize.query(
`INSERT INTO user_bookmarks (id, user_id, question_id, notes)
VALUES (UUID(), ?, ?, 'Duplicate bookmark')`,
{ replacements: [testUser.id, testQuestion.id], type: QueryTypes.INSERT }
);
console.log('❌ Should have thrown unique constraint error');
} catch (error) {
console.log('✅ Unique constraint enforced:', error.parent.code === 'ER_DUP_ENTRY' ? '✅' : '❌');
}
// Test 13: Test unique constraint on user_achievements
console.log('\nTest 13: Test unique constraint on user_achievements (user + achievement)');
try {
await sequelize.query(
`INSERT INTO user_achievements (id, user_id, achievement_id, notified)
VALUES (UUID(), ?, ?, 0)`,
{ replacements: [testUser.id, achievementId], type: QueryTypes.INSERT }
);
console.log('❌ Should have thrown unique constraint error');
} catch (error) {
console.log('✅ Unique constraint enforced:', error.parent.code === 'ER_DUP_ENTRY' ? '✅' : '❌');
}
// Test 14: Test CASCADE delete on quiz_answers
console.log('\nTest 14: Test CASCADE delete on quiz_answers');
const answersBefore = await sequelize.query(
"SELECT COUNT(*) as count FROM quiz_answers WHERE quiz_session_id = ?",
{ replacements: [testQuizSession.id], type: QueryTypes.SELECT }
);
await QuizSession.destroy({ where: { id: testQuizSession.id } });
const answersAfter = await sequelize.query(
"SELECT COUNT(*) as count FROM quiz_answers WHERE quiz_session_id = ?",
{ replacements: [testQuizSession.id], type: QueryTypes.SELECT }
);
console.log('✅ Quiz session deleted');
console.log(' Answers before:', answersBefore[0].count);
console.log(' Answers after:', answersAfter[0].count);
console.log(' CASCADE delete working:', answersAfter[0].count === 0 ? '✅' : '❌');
// Test 15: Test CASCADE delete on user_bookmarks
console.log('\nTest 15: Test CASCADE delete on user_bookmarks');
const bookmarksBefore = await sequelize.query(
"SELECT COUNT(*) as count FROM user_bookmarks WHERE user_id = ?",
{ replacements: [testUser.id], type: QueryTypes.SELECT }
);
await User.destroy({ where: { id: testUser.id } });
const bookmarksAfter = await sequelize.query(
"SELECT COUNT(*) as count FROM user_bookmarks WHERE user_id = ?",
{ replacements: [testUser.id], type: QueryTypes.SELECT }
);
console.log('✅ User deleted');
console.log(' Bookmarks before:', bookmarksBefore[0].count);
console.log(' Bookmarks after:', bookmarksAfter[0].count);
console.log(' CASCADE delete working:', bookmarksAfter[0].count === 0 ? '✅' : '❌');
// Test 16: Verify all indexes exist
console.log('\nTest 16: Verify indexes on all tables');
const quizAnswersIndexes = await sequelize.query(
"SHOW INDEX FROM quiz_answers",
{ type: QueryTypes.SELECT }
);
console.log('✅ quiz_answers indexes:', quizAnswersIndexes.length);
const qsqIndexes = await sequelize.query(
"SHOW INDEX FROM quiz_session_questions",
{ type: QueryTypes.SELECT }
);
console.log('✅ quiz_session_questions indexes:', qsqIndexes.length);
const bookmarksIndexes = await sequelize.query(
"SHOW INDEX FROM user_bookmarks",
{ type: QueryTypes.SELECT }
);
console.log('✅ user_bookmarks indexes:', bookmarksIndexes.length);
const achievementsIndexes = await sequelize.query(
"SHOW INDEX FROM achievements",
{ type: QueryTypes.SELECT }
);
console.log('✅ achievements indexes:', achievementsIndexes.length);
const userAchievementsIndexes = await sequelize.query(
"SHOW INDEX FROM user_achievements",
{ type: QueryTypes.SELECT }
);
console.log('✅ user_achievements indexes:', userAchievementsIndexes.length);
console.log(' All indexes created:', 'Match: ✅');
console.log('\n=====================================');
console.log('🧹 Cleaning up test data...');
// Clean up remaining test data
await sequelize.query("DELETE FROM user_achievements");
await sequelize.query("DELETE FROM achievements");
await sequelize.query("DELETE FROM quiz_session_questions");
await sequelize.query("DELETE FROM quiz_answers");
await sequelize.query("DELETE FROM user_bookmarks");
await sequelize.query("DELETE FROM quiz_sessions");
await sequelize.query("DELETE FROM questions");
await sequelize.query("DELETE FROM categories");
await sequelize.query("DELETE FROM users");
console.log('✅ Test data deleted');
console.log('\n✅ All Junction Tables Tests Completed!');
} catch (error) {
console.error('\n❌ Test failed with error:', error.message);
console.error('Error details:', error);
process.exit(1);
} finally {
await sequelize.close();
}
}
runTests();

View File

@@ -0,0 +1,68 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
// Using the guest ID and token from the previous test
const GUEST_ID = 'guest_1762808357017_hy71ynhu';
const SESSION_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJndWVzdElkIjoiZ3Vlc3RfMTc2MjgwODM1NzAxN19oeTcxeW5odSIsImlhdCI6MTc2MjgwODM1NywiZXhwIjoxNzYyODk0NzU3fQ.ZBrIU_V6Nd2OwWdTBGAvSEwqtoF6ihXOJcCL9bRWbco';
async function testLimitReached() {
console.log('\n╔════════════════════════════════════════════════════════════╗');
console.log('║ Testing Quiz Limit Reached Scenario (Task 16) ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
try {
// First, update the guest session to simulate reaching limit
const { GuestSession } = require('./models');
console.log('Step 1: Updating guest session to simulate limit reached...');
const guestSession = await GuestSession.findOne({
where: { guestId: GUEST_ID }
});
if (!guestSession) {
console.error('❌ Guest session not found!');
return;
}
guestSession.quizzesAttempted = 3;
await guestSession.save();
console.log('✅ Updated quizzes_attempted to 3\n');
// Now test the quiz limit endpoint
console.log('Step 2: Checking quiz limit...\n');
const response = await axios.get(`${BASE_URL}/guest/quiz-limit`, {
headers: {
'X-Guest-Token': SESSION_TOKEN
}
});
console.log('Response:', JSON.stringify(response.data, null, 2));
// Verify the response
const { data } = response.data;
console.log('\n' + '='.repeat(60));
console.log('VERIFICATION:');
console.log('='.repeat(60));
console.log(`✅ Has Reached Limit: ${data.quizLimit.hasReachedLimit}`);
console.log(`✅ Quizzes Attempted: ${data.quizLimit.quizzesAttempted}`);
console.log(`✅ Quizzes Remaining: ${data.quizLimit.quizzesRemaining}`);
if (data.upgradePrompt) {
console.log('\n✅ Upgrade Prompt Present:');
console.log(` Message: ${data.upgradePrompt.message}`);
console.log(` Benefits: ${data.upgradePrompt.benefits.length} items`);
data.upgradePrompt.benefits.forEach((benefit, index) => {
console.log(` ${index + 1}. ${benefit}`);
});
console.log(` CTA: ${data.upgradePrompt.callToAction}`);
}
console.log('\n✅ SUCCESS: Limit reached scenario working correctly!\n');
} catch (error) {
console.error('❌ Error:', error.response?.data || error.message);
}
}
testLimitReached();

View File

@@ -0,0 +1,314 @@
/**
* Manual Test Script for Logout and Token Verification
* Task 14: User Logout & Token Verification
*
* Run this script with: node test-logout-verify.js
* Make sure the server is running on http://localhost:3000
*/
const axios = require('axios');
const API_BASE = 'http://localhost:3000/api';
let testToken = null;
let testUserId = null;
// Helper function for test output
function logTest(testNumber, description) {
console.log(`\n${'='.repeat(60)}`);
console.log(`${testNumber} Testing ${description}`);
console.log('='.repeat(60));
}
function logSuccess(message) {
console.log(`✅ SUCCESS: ${message}`);
}
function logError(message, error = null) {
console.log(`❌ ERROR: ${message}`);
if (error) {
if (error.response && error.response.data) {
console.log(`Response status: ${error.response.status}`);
console.log(`Response data:`, JSON.stringify(error.response.data, null, 2));
} else if (error.message) {
console.log(`Error details: ${error.message}`);
} else {
console.log(`Error:`, error);
}
}
}
// Test 1: Register a test user to get a token
async function test1_RegisterUser() {
logTest('1⃣', 'POST /api/auth/register - Get test token');
try {
const userData = {
username: `testuser${Date.now()}`,
email: `test${Date.now()}@example.com`,
password: 'Test@123'
};
console.log('Request:', JSON.stringify(userData, null, 2));
const response = await axios.post(`${API_BASE}/auth/register`, userData);
console.log('Response:', JSON.stringify(response.data, null, 2));
if (response.data.success && response.data.data.token) {
testToken = response.data.data.token;
testUserId = response.data.data.user.id;
logSuccess('User registered successfully, token obtained');
console.log('Token:', testToken.substring(0, 50) + '...');
} else {
logError('Failed to get token from registration');
}
} catch (error) {
logError('Registration failed', error);
}
}
// Test 2: Verify the token
async function test2_VerifyValidToken() {
logTest('2⃣', 'GET /api/auth/verify - Verify valid token');
if (!testToken) {
logError('No token available. Skipping test.');
return;
}
try {
const response = await axios.get(`${API_BASE}/auth/verify`, {
headers: {
'Authorization': `Bearer ${testToken}`
}
});
console.log('Response:', JSON.stringify(response.data, null, 2));
if (response.data.success && response.data.data.user) {
logSuccess('Token verified successfully');
console.log('User ID:', response.data.data.user.id);
console.log('Username:', response.data.data.user.username);
console.log('Email:', response.data.data.user.email);
console.log('Password exposed?', response.data.data.user.password ? 'YES ❌' : 'NO ✅');
} else {
logError('Token verification returned unexpected response');
}
} catch (error) {
logError('Token verification failed', error.response?.data || error.message);
}
}
// Test 3: Verify without token
async function test3_VerifyWithoutToken() {
logTest('3⃣', 'GET /api/auth/verify - Without token (should fail)');
try {
const response = await axios.get(`${API_BASE}/auth/verify`);
console.log('Response:', JSON.stringify(response.data, null, 2));
logError('Should have rejected request without token');
} catch (error) {
if (error.response && error.response.status === 401) {
console.log('Response:', JSON.stringify(error.response.data, null, 2));
logSuccess('Correctly rejected request without token (401)');
} else {
logError('Unexpected error', error.message);
}
}
}
// Test 4: Verify with invalid token
async function test4_VerifyInvalidToken() {
logTest('4⃣', 'GET /api/auth/verify - Invalid token (should fail)');
try {
const response = await axios.get(`${API_BASE}/auth/verify`, {
headers: {
'Authorization': 'Bearer invalid_token_here'
}
});
console.log('Response:', JSON.stringify(response.data, null, 2));
logError('Should have rejected invalid token');
} catch (error) {
if (error.response && error.response.status === 401) {
console.log('Response:', JSON.stringify(error.response.data, null, 2));
logSuccess('Correctly rejected invalid token (401)');
} else {
logError('Unexpected error', error.message);
}
}
}
// Test 5: Verify with malformed Authorization header
async function test5_VerifyMalformedHeader() {
logTest('5⃣', 'GET /api/auth/verify - Malformed header (should fail)');
if (!testToken) {
logError('No token available. Skipping test.');
return;
}
try {
const response = await axios.get(`${API_BASE}/auth/verify`, {
headers: {
'Authorization': testToken // Missing "Bearer " prefix
}
});
console.log('Response:', JSON.stringify(response.data, null, 2));
logError('Should have rejected malformed header');
} catch (error) {
if (error.response && error.response.status === 401) {
console.log('Response:', JSON.stringify(error.response.data, null, 2));
logSuccess('Correctly rejected malformed header (401)');
} else {
logError('Unexpected error', error.message);
}
}
}
// Test 6: Logout
async function test6_Logout() {
logTest('6⃣', 'POST /api/auth/logout - Logout');
try {
const response = await axios.post(`${API_BASE}/auth/logout`);
console.log('Response:', JSON.stringify(response.data, null, 2));
if (response.data.success) {
logSuccess('Logout successful (stateless JWT approach)');
} else {
logError('Logout returned unexpected response');
}
} catch (error) {
logError('Logout failed', error.response?.data || error.message);
}
}
// Test 7: Verify token still works after logout (JWT is stateless)
async function test7_VerifyAfterLogout() {
logTest('7⃣', 'GET /api/auth/verify - After logout (should still work)');
if (!testToken) {
logError('No token available. Skipping test.');
return;
}
try {
const response = await axios.get(`${API_BASE}/auth/verify`, {
headers: {
'Authorization': `Bearer ${testToken}`
}
});
console.log('Response:', JSON.stringify(response.data, null, 2));
if (response.data.success) {
logSuccess('Token still valid after logout (expected for stateless JWT)');
console.log('Note: In production, client should delete the token on logout');
} else {
logError('Token verification failed unexpectedly');
}
} catch (error) {
logError('Token verification failed', error.response?.data || error.message);
}
}
// Test 8: Login and verify new token
async function test8_LoginAndVerify() {
logTest('8⃣', 'POST /api/auth/login + GET /api/auth/verify - Full flow');
try {
// First, we need to use the registered user's credentials
// Get the email from the first test
const loginData = {
email: `test_${testUserId ? testUserId.split('-')[0] : ''}@example.com`,
password: 'Test@123'
};
// This might fail if we don't have the exact email, so let's just create a new user
const registerData = {
username: `logintest${Date.now()}`,
email: `logintest${Date.now()}@example.com`,
password: 'Test@123'
};
console.log('Registering new user for login test...');
const registerResponse = await axios.post(`${API_BASE}/auth/register`, registerData);
const userEmail = registerResponse.data.data.user.email;
console.log('Logging in...');
const loginResponse = await axios.post(`${API_BASE}/auth/login`, {
email: userEmail,
password: 'Test@123'
});
console.log('Login Response:', JSON.stringify(loginResponse.data, null, 2));
const loginToken = loginResponse.data.data.token;
console.log('\nVerifying login token...');
const verifyResponse = await axios.get(`${API_BASE}/auth/verify`, {
headers: {
'Authorization': `Bearer ${loginToken}`
}
});
console.log('Verify Response:', JSON.stringify(verifyResponse.data, null, 2));
if (verifyResponse.data.success) {
logSuccess('Login and token verification flow completed successfully');
} else {
logError('Token verification failed after login');
}
} catch (error) {
logError('Login and verify flow failed', error);
}
}
// Run all tests
async function runAllTests() {
console.log('\n');
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ Logout & Token Verification Endpoint Tests (Task 14) ║');
console.log('╚════════════════════════════════════════════════════════════╝');
console.log('\nMake sure the server is running on http://localhost:3000\n');
await test1_RegisterUser();
await new Promise(resolve => setTimeout(resolve, 500)); // Small delay
await test2_VerifyValidToken();
await new Promise(resolve => setTimeout(resolve, 500));
await test3_VerifyWithoutToken();
await new Promise(resolve => setTimeout(resolve, 500));
await test4_VerifyInvalidToken();
await new Promise(resolve => setTimeout(resolve, 500));
await test5_VerifyMalformedHeader();
await new Promise(resolve => setTimeout(resolve, 500));
await test6_Logout();
await new Promise(resolve => setTimeout(resolve, 500));
await test7_VerifyAfterLogout();
await new Promise(resolve => setTimeout(resolve, 500));
await test8_LoginAndVerify();
console.log('\n');
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ All Tests Completed ║');
console.log('╚════════════════════════════════════════════════════════════╝');
console.log('\n');
}
// Run tests
runAllTests().catch(error => {
console.error('\n❌ Fatal error running tests:', error);
process.exit(1);
});

View File

@@ -0,0 +1,332 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
// Question UUIDs from database
const QUESTION_IDS = {
GUEST_REACT_EASY: '0891122f-cf0f-4fdf-afd8-5bf0889851f7', // React - easy [GUEST]
AUTH_TYPESCRIPT_HARD: '08aa3a33-46fa-4deb-994e-8a2799abcf9f', // TypeScript - hard [AUTH]
GUEST_JS_EASY: '0c414118-fa32-407a-a9d9-4b9f85955e12', // JavaScript - easy [GUEST]
AUTH_SYSTEM_DESIGN: '14ee37fe-061d-4677-b2a5-b092c711539f', // System Design - medium [AUTH]
AUTH_NODEJS_HARD: '22df0824-43bd-48b3-9e1b-c8072ce5e5d5', // Node.js - hard [AUTH]
GUEST_ANGULAR_EASY: '20d1f27b-5ab8-4027-9548-48def7dd9c3a', // Angular - easy [GUEST]
};
let adminToken = '';
let regularUserToken = '';
let testResults = {
passed: 0,
failed: 0,
total: 0
};
// Test helper
async function runTest(testName, testFn) {
testResults.total++;
try {
await testFn();
testResults.passed++;
console.log(`${testName} - PASSED`);
} catch (error) {
testResults.failed++;
console.log(`${testName} - FAILED`);
console.log(` Error: ${error.message}`);
}
}
// Setup: Login as admin and regular user
async function setup() {
try {
// Login as admin
const adminLogin = await axios.post(`${BASE_URL}/auth/login`, {
email: 'admin@quiz.com',
password: 'Admin@123'
});
adminToken = adminLogin.data.data.token;
console.log('✓ Logged in as admin');
// Create and login as regular user
const timestamp = Date.now();
const regularUser = {
username: `testuser${timestamp}`,
email: `testuser${timestamp}@test.com`,
password: 'Test@123'
};
await axios.post(`${BASE_URL}/auth/register`, regularUser);
const userLogin = await axios.post(`${BASE_URL}/auth/login`, {
email: regularUser.email,
password: regularUser.password
});
regularUserToken = userLogin.data.data.token;
console.log('✓ Created and logged in as regular user\n');
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
process.exit(1);
}
}
// Tests
async function runTests() {
console.log('========================================');
console.log('Testing Get Question by ID API');
console.log('========================================\n');
await setup();
// Test 1: Get guest-accessible question without auth
await runTest('Test 1: Get guest-accessible question without auth', async () => {
const response = await axios.get(`${BASE_URL}/questions/${QUESTION_IDS.GUEST_REACT_EASY}`);
if (response.data.success !== true) throw new Error('Response success should be true');
if (!response.data.data) throw new Error('Response should contain data');
if (response.data.data.id !== QUESTION_IDS.GUEST_REACT_EASY) throw new Error('Wrong question ID');
if (!response.data.data.category) throw new Error('Category info should be included');
if (response.data.data.category.name !== 'React') throw new Error('Wrong category');
console.log(` Retrieved question: "${response.data.data.questionText.substring(0, 50)}..."`);
});
// Test 2: Guest blocked from auth-only question
await runTest('Test 2: Guest blocked from auth-only question', async () => {
try {
await axios.get(`${BASE_URL}/questions/${QUESTION_IDS.AUTH_TYPESCRIPT_HARD}`);
throw new Error('Should have returned 403');
} catch (error) {
if (error.response?.status !== 403) throw new Error(`Expected 403, got ${error.response?.status}`);
if (!error.response.data.message.includes('authentication')) {
throw new Error('Error message should mention authentication');
}
console.log(` Correctly blocked with: ${error.response.data.message}`);
}
});
// Test 3: Authenticated user can access auth-only question
await runTest('Test 3: Authenticated user can access auth-only question', async () => {
const response = await axios.get(`${BASE_URL}/questions/${QUESTION_IDS.AUTH_TYPESCRIPT_HARD}`, {
headers: { Authorization: `Bearer ${regularUserToken}` }
});
if (response.data.success !== true) throw new Error('Response success should be true');
if (!response.data.data) throw new Error('Response should contain data');
if (response.data.data.category.name !== 'TypeScript') throw new Error('Wrong category');
if (response.data.data.difficulty !== 'hard') throw new Error('Wrong difficulty');
console.log(` Retrieved auth-only question from ${response.data.data.category.name}`);
});
// Test 4: Invalid question UUID format
await runTest('Test 4: Invalid question UUID format', async () => {
try {
await axios.get(`${BASE_URL}/questions/invalid-uuid-123`);
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
if (!error.response.data.message.includes('Invalid question ID')) {
throw new Error('Should mention invalid ID format');
}
console.log(` Correctly rejected invalid UUID`);
}
});
// Test 5: Non-existent question
await runTest('Test 5: Non-existent question', async () => {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
try {
await axios.get(`${BASE_URL}/questions/${fakeUuid}`);
throw new Error('Should have returned 404');
} catch (error) {
if (error.response?.status !== 404) throw new Error(`Expected 404, got ${error.response?.status}`);
if (!error.response.data.message.includes('not found')) {
throw new Error('Should mention question not found');
}
console.log(` Correctly returned 404 for non-existent question`);
}
});
// Test 6: Response structure validation
await runTest('Test 6: Response structure validation', async () => {
const response = await axios.get(`${BASE_URL}/questions/${QUESTION_IDS.GUEST_JS_EASY}`);
// Check top-level structure
const requiredTopFields = ['success', 'data', 'message'];
for (const field of requiredTopFields) {
if (!(field in response.data)) throw new Error(`Missing field: ${field}`);
}
// Check question data structure
const question = response.data.data;
const requiredQuestionFields = [
'id', 'questionText', 'questionType', 'options', 'difficulty',
'points', 'explanation', 'tags', 'accuracy', 'statistics', 'category'
];
for (const field of requiredQuestionFields) {
if (!(field in question)) throw new Error(`Missing question field: ${field}`);
}
// Check statistics structure
const statsFields = ['timesAttempted', 'timesCorrect', 'accuracy'];
for (const field of statsFields) {
if (!(field in question.statistics)) throw new Error(`Missing statistics field: ${field}`);
}
// Check category structure
const categoryFields = ['id', 'name', 'slug', 'icon', 'color', 'guestAccessible'];
for (const field of categoryFields) {
if (!(field in question.category)) throw new Error(`Missing category field: ${field}`);
}
// Verify correct_answer is NOT exposed
if ('correctAnswer' in question || 'correct_answer' in question) {
throw new Error('Correct answer should not be exposed');
}
console.log(` Response structure validated`);
});
// Test 7: Accuracy calculation present
await runTest('Test 7: Accuracy calculation present', async () => {
const response = await axios.get(`${BASE_URL}/questions/${QUESTION_IDS.GUEST_ANGULAR_EASY}`, {
headers: { Authorization: `Bearer ${regularUserToken}` }
});
if (response.data.success !== true) throw new Error('Response success should be true');
const question = response.data.data;
if (typeof question.accuracy !== 'number') throw new Error('Accuracy should be a number');
if (question.accuracy < 0 || question.accuracy > 100) {
throw new Error(`Invalid accuracy: ${question.accuracy}`);
}
// Check statistics match
if (question.accuracy !== question.statistics.accuracy) {
throw new Error('Accuracy mismatch between root and statistics');
}
console.log(` Accuracy: ${question.accuracy}% (${question.statistics.timesCorrect}/${question.statistics.timesAttempted})`);
});
// Test 8: Multiple question types work
await runTest('Test 8: Question type field present and valid', async () => {
const response = await axios.get(`${BASE_URL}/questions/${QUESTION_IDS.GUEST_JS_EASY}`);
if (response.data.success !== true) throw new Error('Response success should be true');
const question = response.data.data;
if (!question.questionType) throw new Error('Question type should be present');
const validTypes = ['multiple', 'trueFalse', 'written'];
if (!validTypes.includes(question.questionType)) {
throw new Error(`Invalid question type: ${question.questionType}`);
}
console.log(` Question type: ${question.questionType}`);
});
// Test 9: Options field present for multiple choice
await runTest('Test 9: Options field present', async () => {
const response = await axios.get(`${BASE_URL}/questions/${QUESTION_IDS.GUEST_REACT_EASY}`);
if (response.data.success !== true) throw new Error('Response success should be true');
const question = response.data.data;
if (question.questionType === 'multiple' && !question.options) {
throw new Error('Options should be present for multiple choice questions');
}
if (question.options && !Array.isArray(question.options)) {
throw new Error('Options should be an array');
}
console.log(` Options field validated (${question.options?.length || 0} options)`);
});
// Test 10: Difficulty levels represented correctly
await runTest('Test 10: Difficulty levels validated', async () => {
const testQuestions = [
{ id: QUESTION_IDS.GUEST_REACT_EASY, expected: 'easy' },
{ id: QUESTION_IDS.AUTH_SYSTEM_DESIGN, expected: 'medium' },
{ id: QUESTION_IDS.AUTH_NODEJS_HARD, expected: 'hard' },
];
for (const testQ of testQuestions) {
const response = await axios.get(`${BASE_URL}/questions/${testQ.id}`, {
headers: { Authorization: `Bearer ${regularUserToken}` }
});
if (response.data.data.difficulty !== testQ.expected) {
throw new Error(`Expected difficulty ${testQ.expected}, got ${response.data.data.difficulty}`);
}
}
console.log(` All difficulty levels validated (easy, medium, hard)`);
});
// Test 11: Points based on difficulty
await runTest('Test 11: Points correspond to difficulty', async () => {
const response1 = await axios.get(`${BASE_URL}/questions/${QUESTION_IDS.GUEST_REACT_EASY}`);
const response2 = await axios.get(`${BASE_URL}/questions/${QUESTION_IDS.AUTH_SYSTEM_DESIGN}`, {
headers: { Authorization: `Bearer ${regularUserToken}` }
});
const response3 = await axios.get(`${BASE_URL}/questions/${QUESTION_IDS.AUTH_NODEJS_HARD}`, {
headers: { Authorization: `Bearer ${regularUserToken}` }
});
const easyPoints = response1.data.data.points;
const mediumPoints = response2.data.data.points;
const hardPoints = response3.data.data.points;
// Actual point values from database: easy=5, medium=10, hard=15
if (easyPoints !== 5) throw new Error(`Easy should be 5 points, got ${easyPoints}`);
if (mediumPoints !== 10) throw new Error(`Medium should be 10 points, got ${mediumPoints}`);
if (hardPoints !== 15) throw new Error(`Hard should be 15 points, got ${hardPoints}`);
console.log(` Points validated: easy=${easyPoints}, medium=${mediumPoints}, hard=${hardPoints}`);
});
// Test 12: Tags and keywords present
await runTest('Test 12: Tags and keywords fields present', async () => {
const response = await axios.get(`${BASE_URL}/questions/${QUESTION_IDS.GUEST_JS_EASY}`);
if (response.data.success !== true) throw new Error('Response success should be true');
const question = response.data.data;
// Tags should be present (can be null or array)
if (!('tags' in question)) throw new Error('Tags field should be present');
if (question.tags !== null && !Array.isArray(question.tags)) {
throw new Error('Tags should be null or array');
}
// Keywords should be present (can be null or array)
if (!('keywords' in question)) throw new Error('Keywords field should be present');
if (question.keywords !== null && !Array.isArray(question.keywords)) {
throw new Error('Keywords should be null or array');
}
console.log(` Tags: ${question.tags?.length || 0}, Keywords: ${question.keywords?.length || 0}`);
});
// Summary
console.log('\n========================================');
console.log('Test Summary');
console.log('========================================');
console.log(`Passed: ${testResults.passed}`);
console.log(`Failed: ${testResults.failed}`);
console.log(`Total: ${testResults.total}`);
console.log('========================================\n');
if (testResults.failed === 0) {
console.log('✓ All tests passed!\n');
} else {
console.log('✗ Some tests failed.\n');
process.exit(1);
}
}
// Run tests
runTests().catch(error => {
console.error('Test execution failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,265 @@
// Question Model Tests
const { sequelize, Question, Category, User } = require('./models');
async function runTests() {
try {
console.log('🧪 Running Question Model Tests\n');
console.log('=====================================\n');
// Setup: Create test category and user
console.log('Setting up test data...');
const testCategory = await Category.create({
name: 'Test Category',
slug: 'test-category',
description: 'Category for testing',
isActive: true
});
const testUser = await User.create({
username: 'testadmin',
email: 'admin@test.com',
password: 'password123',
role: 'admin'
});
console.log('✅ Test category and user created\n');
// Test 1: Create a multiple choice question
console.log('Test 1: Create a multiple choice question with JSON options');
const question1 = await Question.create({
categoryId: testCategory.id,
createdBy: testUser.id,
questionText: 'What is the capital of France?',
questionType: 'multiple',
options: ['London', 'Berlin', 'Paris', 'Madrid'],
correctAnswer: '2',
explanation: 'Paris is the capital and largest city of France.',
difficulty: 'easy',
points: 10,
keywords: ['geography', 'capital', 'france'],
tags: ['geography', 'europe'],
visibility: 'public',
guestAccessible: true
});
console.log('✅ Multiple choice question created with ID:', question1.id);
console.log(' Options:', question1.options);
console.log(' Keywords:', question1.keywords);
console.log(' Tags:', question1.tags);
console.log(' Match:', Array.isArray(question1.options) ? '✅' : '❌');
// Test 2: Create a true/false question
console.log('\nTest 2: Create a true/false question');
const question2 = await Question.create({
categoryId: testCategory.id,
createdBy: testUser.id,
questionText: 'JavaScript is a compiled language.',
questionType: 'trueFalse',
correctAnswer: 'false',
explanation: 'JavaScript is an interpreted language, not compiled.',
difficulty: 'easy',
visibility: 'registered'
});
console.log('✅ True/False question created with ID:', question2.id);
console.log(' Correct answer:', question2.correctAnswer);
console.log(' Match:', question2.correctAnswer === 'false' ? '✅' : '❌');
// Test 3: Create a written question
console.log('\nTest 3: Create a written question');
const question3 = await Question.create({
categoryId: testCategory.id,
createdBy: testUser.id,
questionText: 'Explain the concept of closure in JavaScript.',
questionType: 'written',
correctAnswer: 'A closure is a function that has access to variables in its outer scope',
explanation: 'Closures allow functions to access variables from an enclosing scope.',
difficulty: 'hard',
points: 30,
visibility: 'registered'
});
console.log('✅ Written question created with ID:', question3.id);
console.log(' Points (auto-set):', question3.points);
console.log(' Match:', question3.points === 30 ? '✅' : '❌');
// Test 4: Find active questions by category
console.log('\nTest 4: Find active questions by category');
const categoryQuestions = await Question.findActiveQuestions({
categoryId: testCategory.id
});
console.log('✅ Found', categoryQuestions.length, 'questions in category');
console.log(' Expected: 3');
console.log(' Match:', categoryQuestions.length === 3 ? '✅' : '❌');
// Test 5: Filter by difficulty
console.log('\nTest 5: Filter questions by difficulty');
const easyQuestions = await Question.findActiveQuestions({
categoryId: testCategory.id,
difficulty: 'easy'
});
console.log('✅ Found', easyQuestions.length, 'easy questions');
console.log(' Expected: 2');
console.log(' Match:', easyQuestions.length === 2 ? '✅' : '❌');
// Test 6: Filter by guest accessibility
console.log('\nTest 6: Filter questions by guest accessibility');
const guestQuestions = await Question.findActiveQuestions({
categoryId: testCategory.id,
guestAccessible: true
});
console.log('✅ Found', guestQuestions.length, 'guest-accessible questions');
console.log(' Expected: 1');
console.log(' Match:', guestQuestions.length === 1 ? '✅' : '❌');
// Test 7: Get random questions
console.log('\nTest 7: Get random questions from category');
const randomQuestions = await Question.getRandomQuestions(testCategory.id, 2);
console.log('✅ Retrieved', randomQuestions.length, 'random questions');
console.log(' Expected: 2');
console.log(' Match:', randomQuestions.length === 2 ? '✅' : '❌');
// Test 8: Increment attempted count
console.log('\nTest 8: Increment attempted count');
const beforeAttempted = question1.timesAttempted;
await question1.incrementAttempted();
await question1.reload();
console.log('✅ Attempted count incremented');
console.log(' Before:', beforeAttempted);
console.log(' After:', question1.timesAttempted);
console.log(' Match:', question1.timesAttempted === beforeAttempted + 1 ? '✅' : '❌');
// Test 9: Increment correct count
console.log('\nTest 9: Increment correct count');
const beforeCorrect = question1.timesCorrect;
await question1.incrementCorrect();
await question1.reload();
console.log('✅ Correct count incremented');
console.log(' Before:', beforeCorrect);
console.log(' After:', question1.timesCorrect);
console.log(' Match:', question1.timesCorrect === beforeCorrect + 1 ? '✅' : '❌');
// Test 10: Calculate accuracy
console.log('\nTest 10: Calculate accuracy');
const accuracy = question1.getAccuracy();
console.log('✅ Accuracy calculated:', accuracy + '%');
console.log(' Times attempted:', question1.timesAttempted);
console.log(' Times correct:', question1.timesCorrect);
console.log(' Expected accuracy: 100%');
console.log(' Match:', accuracy === 100 ? '✅' : '❌');
// Test 11: toSafeJSON hides correct answer
console.log('\nTest 11: toSafeJSON hides correct answer');
const safeJSON = question1.toSafeJSON();
console.log('✅ Safe JSON generated');
console.log(' Has correctAnswer:', 'correctAnswer' in safeJSON ? '❌' : '✅');
console.log(' Has questionText:', 'questionText' in safeJSON ? '✅' : '❌');
// Test 12: Validation - multiple choice needs options
console.log('\nTest 12: Validation - multiple choice needs at least 2 options');
try {
await Question.create({
categoryId: testCategory.id,
questionText: 'Invalid question',
questionType: 'multiple',
options: ['Only one option'],
correctAnswer: '0',
difficulty: 'easy'
});
console.log('❌ Should have thrown validation error');
} catch (error) {
console.log('✅ Validation error caught:', error.message.includes('at least 2 options') ? '✅' : '❌');
}
// Test 13: Validation - trueFalse correct answer
console.log('\nTest 13: Validation - trueFalse must have true/false answer');
try {
await Question.create({
categoryId: testCategory.id,
questionText: 'Invalid true/false',
questionType: 'trueFalse',
correctAnswer: 'maybe',
difficulty: 'easy'
});
console.log('❌ Should have thrown validation error');
} catch (error) {
console.log('✅ Validation error caught:', error.message.includes('true') || error.message.includes('false') ? '✅' : '❌');
}
// Test 14: Points default based on difficulty
console.log('\nTest 14: Points auto-set based on difficulty');
const mediumQuestion = await Question.create({
categoryId: testCategory.id,
questionText: 'What is React?',
questionType: 'multiple',
options: ['Library', 'Framework', 'Language', 'Database'],
correctAnswer: '0',
difficulty: 'medium',
explanation: 'React is a JavaScript library'
});
console.log('✅ Question created with medium difficulty');
console.log(' Points auto-set:', mediumQuestion.points);
console.log(' Expected: 20');
console.log(' Match:', mediumQuestion.points === 20 ? '✅' : '❌');
// Test 15: Association with Category
console.log('\nTest 15: Association with Category');
const questionWithCategory = await Question.findByPk(question1.id, {
include: [{ model: Category, as: 'category' }]
});
console.log('✅ Question loaded with category association');
console.log(' Category name:', questionWithCategory.category.name);
console.log(' Match:', questionWithCategory.category.id === testCategory.id ? '✅' : '❌');
// Test 16: Association with User (creator)
console.log('\nTest 16: Association with User (creator)');
const questionWithCreator = await Question.findByPk(question1.id, {
include: [{ model: User, as: 'creator' }]
});
console.log('✅ Question loaded with creator association');
console.log(' Creator username:', questionWithCreator.creator.username);
console.log(' Match:', questionWithCreator.creator.id === testUser.id ? '✅' : '❌');
// Test 17: Get questions by category with options
console.log('\nTest 17: Get questions by category with filtering options');
const filteredQuestions = await Question.getQuestionsByCategory(testCategory.id, {
difficulty: 'easy',
limit: 2
});
console.log('✅ Retrieved filtered questions');
console.log(' Count:', filteredQuestions.length);
console.log(' Expected: 2');
console.log(' Match:', filteredQuestions.length === 2 ? '✅' : '❌');
// Test 18: Full-text search (if supported)
console.log('\nTest 18: Full-text search');
try {
const searchResults = await Question.searchQuestions('JavaScript', {
limit: 10
});
console.log('✅ Full-text search executed');
console.log(' Results found:', searchResults.length);
console.log(' Contains JavaScript question:', searchResults.length > 0 ? '✅' : '❌');
} catch (error) {
console.log('⚠️ Full-text search requires proper index setup');
}
// Cleanup
console.log('\n=====================================');
console.log('🧹 Cleaning up test data...');
// Delete in correct order (children first, then parents)
await Question.destroy({ where: {}, force: true });
await Category.destroy({ where: {}, force: true });
await User.destroy({ where: {}, force: true });
console.log('✅ Test data deleted\n');
await sequelize.close();
console.log('✅ All Question Model Tests Completed!\n');
process.exit(0);
} catch (error) {
console.error('\n❌ Test failed with error:', error.message);
console.error('Error details:', error);
await sequelize.close();
process.exit(1);
}
}
runTests();

View File

@@ -0,0 +1,342 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
// Category UUIDs from database
const CATEGORY_IDS = {
JAVASCRIPT: '68b4c87f-db0b-48ea-b8a4-b2f4fce785a2', // Guest accessible
ANGULAR: '0033017b-6ecb-4e9d-8f13-06fc222c1dfc', // Guest accessible
REACT: 'd27e3412-6f2b-432d-8d63-1e165ea5fffd', // Guest accessible
NODEJS: '5e3094ab-ab6d-4f8a-9261-8177b9c979ae', // Auth only
TYPESCRIPT: '7a71ab02-a7e4-4a76-a364-de9d6e3f3411', // Auth only
};
let adminToken = '';
let regularUserToken = '';
let testResults = {
passed: 0,
failed: 0,
total: 0
};
// Test helper
async function runTest(testName, testFn) {
testResults.total++;
try {
await testFn();
testResults.passed++;
console.log(`${testName} - PASSED`);
} catch (error) {
testResults.failed++;
console.log(`${testName} - FAILED`);
console.log(` Error: ${error.message}`);
}
}
// Setup: Login as admin and regular user
async function setup() {
try {
// Login as admin
const adminLogin = await axios.post(`${BASE_URL}/auth/login`, {
email: 'admin@quiz.com',
password: 'Admin@123'
});
adminToken = adminLogin.data.data.token;
console.log('✓ Logged in as admin');
// Create and login as regular user
const timestamp = Date.now();
const regularUser = {
username: `testuser${timestamp}`,
email: `testuser${timestamp}@test.com`,
password: 'Test@123'
};
await axios.post(`${BASE_URL}/auth/register`, regularUser);
const userLogin = await axios.post(`${BASE_URL}/auth/login`, {
email: regularUser.email,
password: regularUser.password
});
regularUserToken = userLogin.data.data.token;
console.log('✓ Created and logged in as regular user\n');
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
process.exit(1);
}
}
// Tests
async function runTests() {
console.log('========================================');
console.log('Testing Question Search API');
console.log('========================================\n');
await setup();
// Test 1: Basic search without auth (guest accessible only)
await runTest('Test 1: Basic search without auth', async () => {
const response = await axios.get(`${BASE_URL}/questions/search?q=javascript`);
if (response.data.success !== true) throw new Error('Response success should be true');
if (!Array.isArray(response.data.data)) throw new Error('Response data should be an array');
if (response.data.query !== 'javascript') throw new Error('Query not reflected in response');
if (typeof response.data.total !== 'number') throw new Error('Total should be a number');
console.log(` Found ${response.data.total} results for "javascript" (guest)`);
});
// Test 2: Missing search query
await runTest('Test 2: Missing search query returns 400', async () => {
try {
await axios.get(`${BASE_URL}/questions/search`);
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
if (!error.response.data.message.includes('required')) {
throw new Error('Error message should mention required query');
}
console.log(` Correctly rejected missing query`);
}
});
// Test 3: Empty search query
await runTest('Test 3: Empty search query returns 400', async () => {
try {
await axios.get(`${BASE_URL}/questions/search?q=`);
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
console.log(` Correctly rejected empty query`);
}
});
// Test 4: Authenticated user sees more results
await runTest('Test 4: Authenticated user sees more results', async () => {
const guestResponse = await axios.get(`${BASE_URL}/questions/search?q=node`);
const authResponse = await axios.get(`${BASE_URL}/questions/search?q=node`, {
headers: { Authorization: `Bearer ${regularUserToken}` }
});
if (authResponse.data.total < guestResponse.data.total) {
throw new Error('Authenticated user should see at least as many results as guest');
}
console.log(` Guest: ${guestResponse.data.total} results, Auth: ${authResponse.data.total} results`);
});
// Test 5: Search with category filter
await runTest('Test 5: Search with category filter', async () => {
const response = await axios.get(
`${BASE_URL}/questions/search?q=what&category=${CATEGORY_IDS.JAVASCRIPT}`
);
if (response.data.success !== true) throw new Error('Response success should be true');
if (response.data.filters.category !== CATEGORY_IDS.JAVASCRIPT) {
throw new Error('Category filter not applied');
}
// Verify all results are from JavaScript category
const allFromCategory = response.data.data.every(q => q.category.name === 'JavaScript');
if (!allFromCategory) throw new Error('Not all results are from JavaScript category');
console.log(` Found ${response.data.count} JavaScript questions matching "what"`);
});
// Test 6: Search with difficulty filter
await runTest('Test 6: Search with difficulty filter', async () => {
const response = await axios.get(`${BASE_URL}/questions/search?q=what&difficulty=easy`);
if (response.data.success !== true) throw new Error('Response success should be true');
if (response.data.filters.difficulty !== 'easy') {
throw new Error('Difficulty filter not applied');
}
// Verify all results are easy difficulty
const allEasy = response.data.data.every(q => q.difficulty === 'easy');
if (!allEasy) throw new Error('Not all results are easy difficulty');
console.log(` Found ${response.data.count} easy questions matching "what"`);
});
// Test 7: Search with combined filters
await runTest('Test 7: Search with combined filters', async () => {
const response = await axios.get(
`${BASE_URL}/questions/search?q=what&category=${CATEGORY_IDS.REACT}&difficulty=easy`,
{ headers: { Authorization: `Bearer ${regularUserToken}` } }
);
if (response.data.success !== true) throw new Error('Response success should be true');
if (response.data.filters.category !== CATEGORY_IDS.REACT) {
throw new Error('Category filter not applied');
}
if (response.data.filters.difficulty !== 'easy') {
throw new Error('Difficulty filter not applied');
}
console.log(` Found ${response.data.count} easy React questions matching "what"`);
});
// Test 8: Invalid category UUID
await runTest('Test 8: Invalid category UUID returns 400', async () => {
try {
await axios.get(`${BASE_URL}/questions/search?q=javascript&category=invalid-uuid`);
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
if (!error.response.data.message.includes('Invalid category ID')) {
throw new Error('Should mention invalid category ID');
}
console.log(` Correctly rejected invalid category UUID`);
}
});
// Test 9: Pagination - page 1
await runTest('Test 9: Pagination support (page 1)', async () => {
const response = await axios.get(`${BASE_URL}/questions/search?q=what&limit=3&page=1`);
if (response.data.success !== true) throw new Error('Response success should be true');
if (response.data.page !== 1) throw new Error('Page should be 1');
if (response.data.limit !== 3) throw new Error('Limit should be 3');
if (response.data.data.length > 3) throw new Error('Should return max 3 results');
if (typeof response.data.totalPages !== 'number') throw new Error('totalPages should be present');
console.log(` Page 1: ${response.data.count} results (total: ${response.data.total}, pages: ${response.data.totalPages})`);
});
// Test 10: Pagination - page 2
await runTest('Test 10: Pagination (page 2)', async () => {
const page1 = await axios.get(`${BASE_URL}/questions/search?q=what&limit=2&page=1`);
const page2 = await axios.get(`${BASE_URL}/questions/search?q=what&limit=2&page=2`);
if (page2.data.page !== 2) throw new Error('Page should be 2');
// Verify different results on page 2
const page1Ids = page1.data.data.map(q => q.id);
const page2Ids = page2.data.data.map(q => q.id);
const hasDifferentIds = page2Ids.some(id => !page1Ids.includes(id));
if (!hasDifferentIds && page2.data.data.length > 0) {
throw new Error('Page 2 should have different results than page 1');
}
console.log(` Page 2: ${page2.data.count} results`);
});
// Test 11: Response structure validation
await runTest('Test 11: Response structure validation', async () => {
const response = await axios.get(`${BASE_URL}/questions/search?q=javascript&limit=1`);
// Check top-level structure
const requiredFields = ['success', 'count', 'total', 'page', 'totalPages', 'limit', 'query', 'filters', 'data', 'message'];
for (const field of requiredFields) {
if (!(field in response.data)) throw new Error(`Missing field: ${field}`);
}
// Check filters structure
if (!('category' in response.data.filters)) throw new Error('Missing filters.category');
if (!('difficulty' in response.data.filters)) throw new Error('Missing filters.difficulty');
// Check question structure (if results exist)
if (response.data.data.length > 0) {
const question = response.data.data[0];
const questionFields = ['id', 'questionText', 'highlightedText', 'questionType', 'difficulty', 'points', 'accuracy', 'relevance', 'category'];
for (const field of questionFields) {
if (!(field in question)) throw new Error(`Missing question field: ${field}`);
}
// Check category structure
const categoryFields = ['id', 'name', 'slug', 'icon', 'color'];
for (const field of categoryFields) {
if (!(field in question.category)) throw new Error(`Missing category field: ${field}`);
}
// Verify correct_answer is NOT exposed
if ('correctAnswer' in question || 'correct_answer' in question) {
throw new Error('Correct answer should not be exposed');
}
}
console.log(` Response structure validated`);
});
// Test 12: Text highlighting present
await runTest('Test 12: Text highlighting in results', async () => {
const response = await axios.get(`${BASE_URL}/questions/search?q=javascript`);
if (response.data.success !== true) throw new Error('Response success should be true');
if (response.data.data.length > 0) {
const question = response.data.data[0];
// Check that highlightedText exists
if (!('highlightedText' in question)) throw new Error('highlightedText should be present');
// Check if highlighting was applied (basic check for ** markers)
const hasHighlight = question.highlightedText && question.highlightedText.includes('**');
console.log(` Highlighting ${hasHighlight ? 'applied' : 'not applied (no match in this result)'}`);
} else {
console.log(` No results to check highlighting`);
}
});
// Test 13: Relevance scoring
await runTest('Test 13: Relevance scoring present', async () => {
const response = await axios.get(`${BASE_URL}/questions/search?q=react hooks`);
if (response.data.success !== true) throw new Error('Response success should be true');
if (response.data.data.length > 0) {
// Check that relevance field exists
for (const question of response.data.data) {
if (!('relevance' in question)) throw new Error('relevance should be present');
if (typeof question.relevance !== 'number') throw new Error('relevance should be a number');
}
// Check that results are ordered by relevance (descending)
for (let i = 0; i < response.data.data.length - 1; i++) {
if (response.data.data[i].relevance < response.data.data[i + 1].relevance) {
throw new Error('Results should be ordered by relevance (descending)');
}
}
console.log(` Relevance scores: ${response.data.data.map(q => q.relevance.toFixed(2)).join(', ')}`);
} else {
console.log(` No results to check relevance`);
}
});
// Test 14: Max limit enforcement (100)
await runTest('Test 14: Max limit enforcement (limit=200 should cap at 100)', async () => {
const response = await axios.get(`${BASE_URL}/questions/search?q=what&limit=200`);
if (response.data.success !== true) throw new Error('Response success should be true');
if (response.data.limit > 100) throw new Error('Limit should be capped at 100');
if (response.data.data.length > 100) throw new Error('Should return max 100 results');
console.log(` Limit capped at ${response.data.limit} (requested 200)`);
});
// Summary
console.log('\n========================================');
console.log('Test Summary');
console.log('========================================');
console.log(`Passed: ${testResults.passed}`);
console.log(`Failed: ${testResults.failed}`);
console.log(`Total: ${testResults.total}`);
console.log('========================================\n');
if (testResults.failed === 0) {
console.log('✓ All tests passed!\n');
} else {
console.log('✗ Some tests failed.\n');
process.exit(1);
}
}
// Run tests
runTests().catch(error => {
console.error('Test execution failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,329 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
// Category UUIDs from database
const CATEGORY_IDS = {
JAVASCRIPT: '68b4c87f-db0b-48ea-b8a4-b2f4fce785a2', // Guest accessible
ANGULAR: '0033017b-6ecb-4e9d-8f13-06fc222c1dfc', // Guest accessible
REACT: 'd27e3412-6f2b-432d-8d63-1e165ea5fffd', // Guest accessible
NODEJS: '5e3094ab-ab6d-4f8a-9261-8177b9c979ae', // Auth only
TYPESCRIPT: '7a71ab02-a7e4-4a76-a364-de9d6e3f3411', // Auth only
};
let adminToken = '';
let regularUserToken = '';
let testResults = {
passed: 0,
failed: 0,
total: 0
};
// Test helper
async function runTest(testName, testFn) {
testResults.total++;
try {
await testFn();
testResults.passed++;
console.log(`${testName} - PASSED`);
} catch (error) {
testResults.failed++;
console.log(`${testName} - FAILED`);
console.log(` Error: ${error.message}`);
}
}
// Setup: Login as admin and regular user
async function setup() {
try {
// Login as admin
const adminLogin = await axios.post(`${BASE_URL}/auth/login`, {
email: 'admin@quiz.com',
password: 'Admin@123'
});
adminToken = adminLogin.data.data.token;
console.log('✓ Logged in as admin');
// Create and login as regular user
const timestamp = Date.now();
const regularUser = {
username: `testuser${timestamp}`,
email: `testuser${timestamp}@test.com`,
password: 'Test@123'
};
await axios.post(`${BASE_URL}/auth/register`, regularUser);
const userLogin = await axios.post(`${BASE_URL}/auth/login`, {
email: regularUser.email,
password: regularUser.password
});
regularUserToken = userLogin.data.data.token;
console.log('✓ Created and logged in as regular user\n');
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
process.exit(1);
}
}
// Tests
async function runTests() {
console.log('========================================');
console.log('Testing Get Questions by Category API');
console.log('========================================\n');
await setup();
// Test 1: Get guest-accessible category questions without auth
await runTest('Test 1: Get guest-accessible questions without auth', async () => {
const response = await axios.get(`${BASE_URL}/questions/category/${CATEGORY_IDS.JAVASCRIPT}`);
if (response.data.success !== true) throw new Error('Response success should be true');
if (!Array.isArray(response.data.data)) throw new Error('Response data should be an array');
if (response.data.count !== response.data.data.length) throw new Error('Count mismatch');
if (!response.data.category) throw new Error('Category info should be included');
if (response.data.category.name !== 'JavaScript') throw new Error('Wrong category');
console.log(` Retrieved ${response.data.count} questions from JavaScript (guest)`);
});
// Test 2: Guest blocked from auth-only category
await runTest('Test 2: Guest blocked from auth-only category', async () => {
try {
await axios.get(`${BASE_URL}/questions/category/${CATEGORY_IDS.NODEJS}`);
throw new Error('Should have returned 403');
} catch (error) {
if (error.response?.status !== 403) throw new Error(`Expected 403, got ${error.response?.status}`);
if (!error.response.data.message.includes('authentication')) {
throw new Error('Error message should mention authentication');
}
console.log(` Correctly blocked with: ${error.response.data.message}`);
}
});
// Test 3: Authenticated user can access all categories
await runTest('Test 3: Authenticated user can access auth-only category', async () => {
const response = await axios.get(`${BASE_URL}/questions/category/${CATEGORY_IDS.NODEJS}`, {
headers: { Authorization: `Bearer ${regularUserToken}` }
});
if (response.data.success !== true) throw new Error('Response success should be true');
if (!Array.isArray(response.data.data)) throw new Error('Response data should be an array');
if (response.data.category.name !== 'Node.js') throw new Error('Wrong category');
console.log(` Retrieved ${response.data.count} questions from Node.js (authenticated)`);
});
// Test 4: Invalid category UUID format
await runTest('Test 4: Invalid category UUID format', async () => {
try {
await axios.get(`${BASE_URL}/questions/category/invalid-uuid-123`);
throw new Error('Should have returned 400');
} catch (error) {
if (error.response?.status !== 400) throw new Error(`Expected 400, got ${error.response?.status}`);
if (!error.response.data.message.includes('Invalid category ID')) {
throw new Error('Should mention invalid ID format');
}
console.log(` Correctly rejected invalid UUID`);
}
});
// Test 5: Non-existent category
await runTest('Test 5: Non-existent category', async () => {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
try {
await axios.get(`${BASE_URL}/questions/category/${fakeUuid}`);
throw new Error('Should have returned 404');
} catch (error) {
if (error.response?.status !== 404) throw new Error(`Expected 404, got ${error.response?.status}`);
if (!error.response.data.message.includes('not found')) {
throw new Error('Should mention category not found');
}
console.log(` Correctly returned 404 for non-existent category`);
}
});
// Test 6: Filter by difficulty - easy
await runTest('Test 6: Filter by difficulty (easy)', async () => {
const response = await axios.get(`${BASE_URL}/questions/category/${CATEGORY_IDS.JAVASCRIPT}?difficulty=easy`);
if (response.data.success !== true) throw new Error('Response success should be true');
if (!Array.isArray(response.data.data)) throw new Error('Response data should be an array');
if (response.data.filters.difficulty !== 'easy') throw new Error('Filter not applied');
// Verify all questions are easy
const allEasy = response.data.data.every(q => q.difficulty === 'easy');
if (!allEasy) throw new Error('Not all questions are easy difficulty');
console.log(` Retrieved ${response.data.count} easy questions`);
});
// Test 7: Filter by difficulty - medium
await runTest('Test 7: Filter by difficulty (medium)', async () => {
const response = await axios.get(`${BASE_URL}/questions/category/${CATEGORY_IDS.ANGULAR}?difficulty=medium`, {
headers: { Authorization: `Bearer ${regularUserToken}` }
});
if (response.data.success !== true) throw new Error('Response success should be true');
if (response.data.filters.difficulty !== 'medium') throw new Error('Filter not applied');
// Verify all questions are medium
const allMedium = response.data.data.every(q => q.difficulty === 'medium');
if (!allMedium) throw new Error('Not all questions are medium difficulty');
console.log(` Retrieved ${response.data.count} medium questions`);
});
// Test 8: Filter by difficulty - hard
await runTest('Test 8: Filter by difficulty (hard)', async () => {
const response = await axios.get(`${BASE_URL}/questions/category/${CATEGORY_IDS.REACT}?difficulty=hard`, {
headers: { Authorization: `Bearer ${regularUserToken}` }
});
if (response.data.success !== true) throw new Error('Response success should be true');
if (response.data.filters.difficulty !== 'hard') throw new Error('Filter not applied');
// Verify all questions are hard
const allHard = response.data.data.every(q => q.difficulty === 'hard');
if (!allHard) throw new Error('Not all questions are hard difficulty');
console.log(` Retrieved ${response.data.count} hard questions`);
});
// Test 9: Limit parameter
await runTest('Test 9: Limit parameter (limit=3)', async () => {
const response = await axios.get(`${BASE_URL}/questions/category/${CATEGORY_IDS.JAVASCRIPT}?limit=3`);
if (response.data.success !== true) throw new Error('Response success should be true');
if (response.data.data.length > 3) throw new Error('Limit not respected');
if (response.data.filters.limit !== 3) throw new Error('Limit not reflected in filters');
console.log(` Retrieved ${response.data.count} questions (limited to 3)`);
});
// Test 10: Random selection
await runTest('Test 10: Random selection (random=true)', async () => {
const response1 = await axios.get(`${BASE_URL}/questions/category/${CATEGORY_IDS.JAVASCRIPT}?random=true&limit=5`);
const response2 = await axios.get(`${BASE_URL}/questions/category/${CATEGORY_IDS.JAVASCRIPT}?random=true&limit=5`);
if (response1.data.success !== true) throw new Error('Response success should be true');
if (response1.data.filters.random !== true) throw new Error('Random flag not set');
if (response2.data.filters.random !== true) throw new Error('Random flag not set');
// Check that the order is different (may occasionally fail if random picks same order)
const ids1 = response1.data.data.map(q => q.id);
const ids2 = response2.data.data.map(q => q.id);
const sameOrder = JSON.stringify(ids1) === JSON.stringify(ids2);
console.log(` Random selection enabled (orders ${sameOrder ? 'same' : 'different'})`);
});
// Test 11: Response structure validation
await runTest('Test 11: Response structure validation', async () => {
const response = await axios.get(`${BASE_URL}/questions/category/${CATEGORY_IDS.JAVASCRIPT}?limit=1`);
// Check top-level structure
const requiredFields = ['success', 'count', 'total', 'category', 'filters', 'data', 'message'];
for (const field of requiredFields) {
if (!(field in response.data)) throw new Error(`Missing field: ${field}`);
}
// Check category structure
const categoryFields = ['id', 'name', 'slug', 'icon', 'color'];
for (const field of categoryFields) {
if (!(field in response.data.category)) throw new Error(`Missing category field: ${field}`);
}
// Check filters structure
const filterFields = ['difficulty', 'limit', 'random'];
for (const field of filterFields) {
if (!(field in response.data.filters)) throw new Error(`Missing filter field: ${field}`);
}
// Check question structure (if questions exist)
if (response.data.data.length > 0) {
const question = response.data.data[0];
const questionFields = ['id', 'questionText', 'questionType', 'options', 'difficulty', 'points', 'accuracy', 'tags'];
for (const field of questionFields) {
if (!(field in question)) throw new Error(`Missing question field: ${field}`);
}
// Verify correct_answer is NOT exposed
if ('correctAnswer' in question || 'correct_answer' in question) {
throw new Error('Correct answer should not be exposed');
}
}
console.log(` Response structure validated`);
});
// Test 12: Question accuracy calculation
await runTest('Test 12: Question accuracy calculation', async () => {
const response = await axios.get(`${BASE_URL}/questions/category/${CATEGORY_IDS.JAVASCRIPT}?limit=5`, {
headers: { Authorization: `Bearer ${regularUserToken}` }
});
if (response.data.success !== true) throw new Error('Response success should be true');
// Check each question has accuracy field
for (const question of response.data.data) {
if (typeof question.accuracy !== 'number') throw new Error('Accuracy should be a number');
if (question.accuracy < 0 || question.accuracy > 100) {
throw new Error(`Invalid accuracy: ${question.accuracy}`);
}
}
console.log(` Accuracy calculated for all questions`);
});
// Test 13: Combined filters (difficulty + limit)
await runTest('Test 13: Combined filters (difficulty + limit)', async () => {
const response = await axios.get(
`${BASE_URL}/questions/category/${CATEGORY_IDS.TYPESCRIPT}?difficulty=easy&limit=2`,
{ headers: { Authorization: `Bearer ${regularUserToken}` } }
);
if (response.data.success !== true) throw new Error('Response success should be true');
if (response.data.data.length > 2) throw new Error('Limit not respected');
if (response.data.filters.difficulty !== 'easy') throw new Error('Difficulty filter not applied');
if (response.data.filters.limit !== 2) throw new Error('Limit filter not applied');
const allEasy = response.data.data.every(q => q.difficulty === 'easy');
if (!allEasy) throw new Error('Not all questions are easy difficulty');
console.log(` Retrieved ${response.data.count} easy questions (limited to 2)`);
});
// Test 14: Max limit enforcement (50)
await runTest('Test 14: Max limit enforcement (limit=100 should cap at 50)', async () => {
const response = await axios.get(`${BASE_URL}/questions/category/${CATEGORY_IDS.JAVASCRIPT}?limit=100`);
if (response.data.success !== true) throw new Error('Response success should be true');
if (response.data.data.length > 50) throw new Error('Max limit (50) not enforced');
if (response.data.filters.limit > 50) throw new Error('Limit should be capped at 50');
console.log(` Limit capped at ${response.data.filters.limit} (requested 100)`);
});
// Summary
console.log('\n========================================');
console.log('Test Summary');
console.log('========================================');
console.log(`Passed: ${testResults.passed}`);
console.log(`Failed: ${testResults.failed}`);
console.log(`Total: ${testResults.total}`);
console.log('========================================\n');
if (testResults.failed === 0) {
console.log('✓ All tests passed!\n');
} else {
console.log('✗ Some tests failed.\n');
process.exit(1);
}
}
// Run tests
runTests().catch(error => {
console.error('Test execution failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,382 @@
const { sequelize } = require('./models');
const { User, Category, GuestSession, QuizSession } = require('./models');
async function runTests() {
console.log('🧪 Running QuizSession Model Tests\n');
console.log('=====================================\n');
try {
let testUser, testCategory, testGuestSession, userQuiz, guestQuiz;
// Test 1: Create a quiz session for a registered user
console.log('Test 1: Create quiz session for user');
testUser = await User.create({
username: `quizuser${Date.now()}`,
email: `quizuser${Date.now()}@test.com`,
password: 'password123',
role: 'user'
});
testCategory = await Category.create({
name: 'Test Category for Quiz',
description: 'Category for quiz testing',
isActive: true
});
userQuiz = await QuizSession.createSession({
userId: testUser.id,
categoryId: testCategory.id,
quizType: 'practice',
difficulty: 'medium',
totalQuestions: 10,
passPercentage: 70.00
});
console.log('✅ User quiz session created with ID:', userQuiz.id);
console.log(' User ID:', userQuiz.userId);
console.log(' Category ID:', userQuiz.categoryId);
console.log(' Status:', userQuiz.status);
console.log(' Total questions:', userQuiz.totalQuestions);
console.log(' Match:', userQuiz.status === 'not_started' ? '✅' : '❌');
// Test 2: Create a quiz session for a guest
console.log('\nTest 2: Create quiz session for guest');
testGuestSession = await GuestSession.createSession({
maxQuizzes: 5,
expiryHours: 24
});
guestQuiz = await QuizSession.createSession({
guestSessionId: testGuestSession.id,
categoryId: testCategory.id,
quizType: 'practice',
difficulty: 'easy',
totalQuestions: 5,
passPercentage: 60.00
});
console.log('✅ Guest quiz session created with ID:', guestQuiz.id);
console.log(' Guest session ID:', guestQuiz.guestSessionId);
console.log(' Category ID:', guestQuiz.categoryId);
console.log(' Total questions:', guestQuiz.totalQuestions);
console.log(' Match:', guestQuiz.guestSessionId === testGuestSession.id ? '✅' : '❌');
// Test 3: Start a quiz session
console.log('\nTest 3: Start quiz session');
await userQuiz.start();
await userQuiz.reload();
console.log('✅ Quiz started');
console.log(' Status:', userQuiz.status);
console.log(' Started at:', userQuiz.startedAt);
console.log(' Match:', userQuiz.status === 'in_progress' && userQuiz.startedAt ? '✅' : '❌');
// Test 4: Record correct answer
console.log('\nTest 4: Record correct answer');
const beforeAnswers = userQuiz.questionsAnswered;
const beforeCorrect = userQuiz.correctAnswers;
await userQuiz.recordAnswer(true, 10);
await userQuiz.reload();
console.log('✅ Answer recorded');
console.log(' Questions answered:', userQuiz.questionsAnswered);
console.log(' Correct answers:', userQuiz.correctAnswers);
console.log(' Total points:', userQuiz.totalPoints);
console.log(' Match:', userQuiz.questionsAnswered === beforeAnswers + 1 &&
userQuiz.correctAnswers === beforeCorrect + 1 ? '✅' : '❌');
// Test 5: Record incorrect answer
console.log('\nTest 5: Record incorrect answer');
const beforeAnswers2 = userQuiz.questionsAnswered;
const beforeCorrect2 = userQuiz.correctAnswers;
await userQuiz.recordAnswer(false, 0);
await userQuiz.reload();
console.log('✅ Incorrect answer recorded');
console.log(' Questions answered:', userQuiz.questionsAnswered);
console.log(' Correct answers:', userQuiz.correctAnswers);
console.log(' Match:', userQuiz.questionsAnswered === beforeAnswers2 + 1 &&
userQuiz.correctAnswers === beforeCorrect2 ? '✅' : '❌');
// Test 6: Get quiz progress
console.log('\nTest 6: Get quiz progress');
const progress = userQuiz.getProgress();
console.log('✅ Progress retrieved');
console.log(' Status:', progress.status);
console.log(' Questions answered:', progress.questionsAnswered);
console.log(' Questions remaining:', progress.questionsRemaining);
console.log(' Progress percentage:', progress.progressPercentage + '%');
console.log(' Current accuracy:', progress.currentAccuracy + '%');
console.log(' Match:', progress.questionsAnswered === 2 ? '✅' : '❌');
// Test 7: Update time spent
console.log('\nTest 7: Update time spent');
await userQuiz.updateTimeSpent(120); // 2 minutes
await userQuiz.reload();
console.log('✅ Time updated');
console.log(' Time spent:', userQuiz.timeSpent, 'seconds');
console.log(' Match:', userQuiz.timeSpent === 120 ? '✅' : '❌');
// Test 8: Complete quiz by answering remaining questions
console.log('\nTest 8: Auto-complete quiz when all questions answered');
// Answer remaining 8 questions (6 correct, 2 incorrect)
for (let i = 0; i < 8; i++) {
const isCorrect = i < 6; // First 6 are correct
await userQuiz.recordAnswer(isCorrect, isCorrect ? 10 : 0);
}
await userQuiz.reload();
console.log('✅ Quiz auto-completed');
console.log(' Status:', userQuiz.status);
console.log(' Questions answered:', userQuiz.questionsAnswered);
console.log(' Correct answers:', userQuiz.correctAnswers);
console.log(' Score:', userQuiz.score + '%');
console.log(' Is passed:', userQuiz.isPassed);
console.log(' Match:', userQuiz.status === 'completed' && userQuiz.isPassed === true ? '✅' : '❌');
// Test 9: Get quiz results
console.log('\nTest 9: Get quiz results');
const results = userQuiz.getResults();
console.log('✅ Results retrieved');
console.log(' Total questions:', results.totalQuestions);
console.log(' Correct answers:', results.correctAnswers);
console.log(' Score:', results.score + '%');
console.log(' Is passed:', results.isPassed);
console.log(' Duration:', results.duration, 'seconds');
console.log(' Match:', results.correctAnswers === 8 && results.isPassed === true ? '✅' : '❌');
// Test 10: Calculate score
console.log('\nTest 10: Calculate score');
const calculatedScore = userQuiz.calculateScore();
console.log('✅ Score calculated');
console.log(' Calculated score:', calculatedScore + '%');
console.log(' Expected: 80%');
console.log(' Match:', calculatedScore === 80.00 ? '✅' : '❌');
// Test 11: Create timed quiz
console.log('\nTest 11: Create timed quiz with time limit');
const timedQuiz = await QuizSession.createSession({
userId: testUser.id,
categoryId: testCategory.id,
quizType: 'timed',
difficulty: 'hard',
totalQuestions: 20,
timeLimit: 600, // 10 minutes
passPercentage: 75.00
});
await timedQuiz.start();
console.log('✅ Timed quiz created');
console.log(' Quiz type:', timedQuiz.quizType);
console.log(' Time limit:', timedQuiz.timeLimit, 'seconds');
console.log(' Match:', timedQuiz.quizType === 'timed' && timedQuiz.timeLimit === 600 ? '✅' : '❌');
// Test 12: Timeout a quiz
console.log('\nTest 12: Timeout a quiz');
await timedQuiz.updateTimeSpent(610); // Exceed time limit
await timedQuiz.reload();
console.log('✅ Quiz timed out');
console.log(' Status:', timedQuiz.status);
console.log(' Time spent:', timedQuiz.timeSpent);
console.log(' Match:', timedQuiz.status === 'timed_out' ? '✅' : '❌');
// Test 13: Abandon a quiz
console.log('\nTest 13: Abandon a quiz');
const abandonQuiz = await QuizSession.createSession({
userId: testUser.id,
categoryId: testCategory.id,
quizType: 'practice',
difficulty: 'easy',
totalQuestions: 15
});
await abandonQuiz.start();
await abandonQuiz.recordAnswer(true, 10);
await abandonQuiz.abandon();
await abandonQuiz.reload();
console.log('✅ Quiz abandoned');
console.log(' Status:', abandonQuiz.status);
console.log(' Questions answered:', abandonQuiz.questionsAnswered);
console.log(' Completed at:', abandonQuiz.completedAt);
console.log(' Match:', abandonQuiz.status === 'abandoned' ? '✅' : '❌');
// Test 14: Find active session for user
console.log('\nTest 14: Find active session for user');
const activeQuiz = await QuizSession.createSession({
userId: testUser.id,
categoryId: testCategory.id,
quizType: 'practice',
difficulty: 'medium',
totalQuestions: 10
});
await activeQuiz.start();
const foundActive = await QuizSession.findActiveForUser(testUser.id);
console.log('✅ Active session found');
console.log(' Found ID:', foundActive.id);
console.log(' Created ID:', activeQuiz.id);
console.log(' Match:', foundActive.id === activeQuiz.id ? '✅' : '❌');
// Test 15: Find active session for guest
console.log('\nTest 15: Find active session for guest');
await guestQuiz.start();
const foundGuestActive = await QuizSession.findActiveForGuest(testGuestSession.id);
console.log('✅ Active guest session found');
console.log(' Found ID:', foundGuestActive.id);
console.log(' Created ID:', guestQuiz.id);
console.log(' Match:', foundGuestActive.id === guestQuiz.id ? '✅' : '❌');
// Test 16: Get user quiz history
console.log('\nTest 16: Get user quiz history');
await activeQuiz.complete();
const history = await QuizSession.getUserHistory(testUser.id, 5);
console.log('✅ User history retrieved');
console.log(' History count:', history.length);
console.log(' Expected at least 3: ✅');
// Test 17: Get user statistics
console.log('\nTest 17: Get user statistics');
const stats = await QuizSession.getUserStats(testUser.id);
console.log('✅ User stats calculated');
console.log(' Total quizzes:', stats.totalQuizzes);
console.log(' Average score:', stats.averageScore + '%');
console.log(' Pass rate:', stats.passRate + '%');
console.log(' Total time spent:', stats.totalTimeSpent, 'seconds');
console.log(' Match:', stats.totalQuizzes >= 1 ? '✅' : '❌');
// Test 18: Get category statistics
console.log('\nTest 18: Get category statistics');
const categoryStats = await QuizSession.getCategoryStats(testCategory.id);
console.log('✅ Category stats calculated');
console.log(' Total attempts:', categoryStats.totalAttempts);
console.log(' Average score:', categoryStats.averageScore + '%');
console.log(' Pass rate:', categoryStats.passRate + '%');
console.log(' Match:', categoryStats.totalAttempts >= 1 ? '✅' : '❌');
// Test 19: Check isActive method
console.log('\nTest 19: Check isActive method');
const newQuiz = await QuizSession.createSession({
userId: testUser.id,
categoryId: testCategory.id,
quizType: 'practice',
totalQuestions: 5
});
const isActiveBeforeStart = newQuiz.isActive();
await newQuiz.start();
const isActiveAfterStart = newQuiz.isActive();
await newQuiz.complete();
const isActiveAfterComplete = newQuiz.isActive();
console.log('✅ Active status checked');
console.log(' Before start:', isActiveBeforeStart);
console.log(' After start:', isActiveAfterStart);
console.log(' After complete:', isActiveAfterComplete);
console.log(' Match:', !isActiveBeforeStart && isActiveAfterStart && !isActiveAfterComplete ? '✅' : '❌');
// Test 20: Check isCompleted method
console.log('\nTest 20: Check isCompleted method');
const completionQuiz = await QuizSession.createSession({
userId: testUser.id,
categoryId: testCategory.id,
quizType: 'practice',
totalQuestions: 3
});
const isCompletedBefore = completionQuiz.isCompleted();
await completionQuiz.start();
await completionQuiz.complete();
const isCompletedAfter = completionQuiz.isCompleted();
console.log('✅ Completion status checked');
console.log(' Before completion:', isCompletedBefore);
console.log(' After completion:', isCompletedAfter);
console.log(' Match:', !isCompletedBefore && isCompletedAfter ? '✅' : '❌');
// Test 21: Test validation - require either userId or guestSessionId
console.log('\nTest 21: Test validation - require userId or guestSessionId');
try {
await QuizSession.createSession({
categoryId: testCategory.id,
quizType: 'practice',
totalQuestions: 10
});
console.log('❌ Should have thrown validation error');
} catch (error) {
console.log('✅ Validation error caught:', error.message);
console.log(' Match:', error.message.includes('userId or guestSessionId') ? '✅' : '❌');
}
// Test 22: Test validation - cannot have both userId and guestSessionId
console.log('\nTest 22: Test validation - cannot have both userId and guestSessionId');
try {
await QuizSession.create({
userId: testUser.id,
guestSessionId: testGuestSession.id,
categoryId: testCategory.id,
quizType: 'practice',
totalQuestions: 10
});
console.log('❌ Should have thrown validation error');
} catch (error) {
console.log('✅ Validation error caught:', error.message);
console.log(' Match:', error.message.includes('Cannot have both') ? '✅' : '❌');
}
// Test 23: Test associations - load with user
console.log('\nTest 23: Load quiz session with user association');
const quizWithUser = await QuizSession.findOne({
where: { id: userQuiz.id },
include: [{ model: User, as: 'user' }]
});
console.log('✅ Quiz loaded with user');
console.log(' User username:', quizWithUser.user.username);
console.log(' Match:', quizWithUser.user.id === testUser.id ? '✅' : '❌');
// Test 24: Test associations - load with category
console.log('\nTest 24: Load quiz session with category association');
const quizWithCategory = await QuizSession.findOne({
where: { id: userQuiz.id },
include: [{ model: Category, as: 'category' }]
});
console.log('✅ Quiz loaded with category');
console.log(' Category name:', quizWithCategory.category.name);
console.log(' Match:', quizWithCategory.category.id === testCategory.id ? '✅' : '❌');
// Test 25: Test associations - load with guest session
console.log('\nTest 25: Load quiz session with guest session association');
const quizWithGuest = await QuizSession.findOne({
where: { id: guestQuiz.id },
include: [{ model: GuestSession, as: 'guestSession' }]
});
console.log('✅ Quiz loaded with guest session');
console.log(' Guest ID:', quizWithGuest.guestSession.guestId);
console.log(' Match:', quizWithGuest.guestSession.id === testGuestSession.id ? '✅' : '❌');
// Test 26: Clean up abandoned sessions
console.log('\nTest 26: Clean up abandoned sessions');
const oldQuiz = await QuizSession.create({
userId: testUser.id,
categoryId: testCategory.id,
quizType: 'practice',
totalQuestions: 10,
status: 'abandoned',
createdAt: new Date('2020-01-01')
});
const deletedCount = await QuizSession.cleanupAbandoned(7);
console.log('✅ Cleanup executed');
console.log(' Deleted count:', deletedCount);
console.log(' Expected at least 1:', deletedCount >= 1 ? '✅' : '❌');
console.log('\n=====================================');
console.log('🧹 Cleaning up test data...');
// Clean up test data
await QuizSession.destroy({ where: {} });
await GuestSession.destroy({ where: {} });
await Category.destroy({ where: {} });
await User.destroy({ where: {} });
console.log('✅ Test data deleted');
console.log('\n✅ All QuizSession Model Tests Completed!');
} catch (error) {
console.error('\n❌ Test failed with error:', error.message);
console.error('Error details:', error);
process.exit(1);
} finally {
await sequelize.close();
}
}
runTests();

View File

@@ -0,0 +1,51 @@
const axios = require('axios');
const API_URL = 'http://localhost:3000/api';
const NODEJS_ID = '5e3094ab-ab6d-4f8a-9261-8177b9c979ae';
const testUser = {
email: 'admin@quiz.com',
password: 'Admin@123'
};
async function test() {
try {
// Login
console.log('\n1. Logging in...');
const loginResponse = await axios.post(`${API_URL}/auth/login`, testUser);
const token = loginResponse.data.data.token;
console.log('✓ Logged in successfully');
if (token) {
console.log('Token:', token.substring(0, 20) + '...');
} else {
console.log('No token found!');
}
// Get category
console.log('\n2. Getting Node.js category...');
const response = await axios.get(`${API_URL}/categories/${NODEJS_ID}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
console.log('✓ Success!');
console.log('Category:', response.data.data.category.name);
console.log('Guest Accessible:', response.data.data.category.guestAccessible);
} catch (error) {
console.error('\n✗ Error:');
if (error.response) {
console.error('Status:', error.response.status);
console.error('Data:', error.response.data);
} else if (error.request) {
console.error('No response received');
console.error('Request:', error.request);
} else {
console.error('Error message:', error.message);
}
console.error('Full error:', error);
}
}
test();

View File

@@ -0,0 +1,523 @@
/**
* Test Script: Update and Delete Question API (Admin)
*
* Tests:
* - Update Question (various fields)
* - Delete Question (soft delete)
* - Authorization checks
* - Validation scenarios
*/
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;
// Test data
let testCategoryId = null;
let testQuestionId = null;
let secondCategoryId = 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'
}
};
}
async function runTests() {
console.log('========================================');
console.log('Testing Update/Delete Question API (Admin)');
console.log('========================================\n');
try {
// ==========================================
// Setup: Login as admin and regular user
// ==========================================
// 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: `testuser${timestamp}`,
email: `testuser${timestamp}@test.com`,
password: 'Test@123'
});
userToken = userRes.data.data.token;
console.log('✓ Created and logged in as regular user\n');
// Get test categories
const categoriesRes = await axios.get(`${API_URL}/categories`, authConfig(adminToken));
testCategoryId = categoriesRes.data.data[0].id; // JavaScript
secondCategoryId = categoriesRes.data.data[1].id; // Angular
console.log(`✓ Using test categories: ${testCategoryId}, ${secondCategoryId}\n`);
// Create a test question first
const createRes = await axios.post(`${API_URL}/admin/questions`, {
questionText: 'What is a closure in JavaScript?',
questionType: 'multiple',
options: [
{ id: 'a', text: 'A function inside another function' },
{ id: 'b', text: 'A loop structure' },
{ id: 'c', text: 'A variable declaration' }
],
correctAnswer: 'a',
difficulty: 'medium',
categoryId: testCategoryId,
explanation: 'A closure is a function that has access to its outer scope',
tags: ['closures', 'functions'],
keywords: ['closure', 'scope']
}, authConfig(adminToken));
testQuestionId = createRes.data.data.id;
console.log(`✓ Created test question: ${testQuestionId}\n`);
// ==========================================
// UPDATE QUESTION TESTS
// ==========================================
// Test 1: Admin updates question text
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
questionText: 'What is a closure in JavaScript? (Updated)'
}, authConfig(adminToken));
const passed = res.status === 200
&& res.data.success === true
&& res.data.data.questionText === 'What is a closure in JavaScript? (Updated)';
logTest('Admin updates question text', passed,
passed ? 'Question text updated successfully' : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Admin updates question text', false, error.response?.data?.message || error.message);
}
// Test 2: Admin updates difficulty (points should auto-update)
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
difficulty: 'hard'
}, authConfig(adminToken));
const passed = res.status === 200
&& res.data.data.difficulty === 'hard'
&& res.data.data.points === 15;
logTest('Admin updates difficulty with auto-points', passed,
passed ? `Difficulty: hard, Points auto-set to: ${res.data.data.points}` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Admin updates difficulty with auto-points', false, error.response?.data?.message || error.message);
}
// Test 3: Admin updates custom points
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
points: 25
}, authConfig(adminToken));
const passed = res.status === 200
&& res.data.data.points === 25;
logTest('Admin updates custom points', passed,
passed ? `Custom points: ${res.data.data.points}` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Admin updates custom points', false, error.response?.data?.message || error.message);
}
// Test 4: Admin updates options and correct answer
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
options: [
{ id: 'a', text: 'A function with outer scope access' },
{ id: 'b', text: 'A loop structure' },
{ id: 'c', text: 'A variable declaration' },
{ id: 'd', text: 'A data type' }
],
correctAnswer: 'a'
}, authConfig(adminToken));
const passed = res.status === 200
&& res.data.data.options.length === 4
&& !res.data.data.correctAnswer; // Should not expose correct answer
logTest('Admin updates options and correct answer', passed,
passed ? `Options updated: ${res.data.data.options.length} options` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Admin updates options and correct answer', false, error.response?.data?.message || error.message);
}
// Test 5: Admin updates category
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
categoryId: secondCategoryId
}, authConfig(adminToken));
const passed = res.status === 200
&& res.data.data.category.id === secondCategoryId;
logTest('Admin updates category', passed,
passed ? `Category changed to: ${res.data.data.category.name}` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Admin updates category', false, error.response?.data?.message || error.message);
}
// Test 6: Admin updates explanation, tags, keywords
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
explanation: 'Updated: A closure provides access to outer scope',
tags: ['closures', 'scope', 'functions', 'advanced'],
keywords: ['closure', 'lexical', 'scope']
}, authConfig(adminToken));
const passed = res.status === 200
&& res.data.data.explanation.includes('Updated')
&& res.data.data.tags.length === 4
&& res.data.data.keywords.length === 3;
logTest('Admin updates explanation, tags, keywords', passed,
passed ? `Updated metadata: ${res.data.data.tags.length} tags, ${res.data.data.keywords.length} keywords` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Admin updates explanation, tags, keywords', false, error.response?.data?.message || error.message);
}
// Test 7: Admin updates isActive flag
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
isActive: false
}, authConfig(adminToken));
const passed = res.status === 200
&& res.data.data.isActive === false;
logTest('Admin updates isActive flag', passed,
passed ? 'Question marked as inactive' : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Admin updates isActive flag', false, error.response?.data?.message || error.message);
}
// Reactivate for remaining tests
await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
isActive: true
}, authConfig(adminToken));
// Test 8: Non-admin blocked from updating
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
questionText: 'Hacked question'
}, authConfig(userToken));
logTest('Non-admin blocked from updating question', false, 'Should have returned 403');
} catch (error) {
const passed = error.response?.status === 403;
logTest('Non-admin blocked from updating question', passed,
passed ? 'Correctly blocked with 403' : `Status: ${error.response?.status}`);
}
// Test 9: Unauthenticated request blocked
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
questionText: 'Hacked question'
});
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 10: Invalid UUID format returns 400
try {
const res = await axios.put(`${API_URL}/admin/questions/invalid-uuid`, {
questionText: 'Test'
}, authConfig(adminToken));
logTest('Invalid UUID format returns 400', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Invalid UUID format returns 400', passed,
passed ? 'Correctly rejected invalid UUID' : `Status: ${error.response?.status}`);
}
// Test 11: Non-existent question returns 404
try {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
const res = await axios.put(`${API_URL}/admin/questions/${fakeUuid}`, {
questionText: 'Test'
}, authConfig(adminToken));
logTest('Non-existent question returns 404', false, 'Should have returned 404');
} catch (error) {
const passed = error.response?.status === 404;
logTest('Non-existent question returns 404', passed,
passed ? 'Correctly returned 404 for non-existent question' : `Status: ${error.response?.status}`);
}
// Test 12: Empty question text rejected
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
questionText: ' '
}, authConfig(adminToken));
logTest('Empty question text rejected', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Empty question text rejected', passed,
passed ? 'Correctly rejected empty text' : `Status: ${error.response?.status}`);
}
// Test 13: Invalid question type rejected
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
questionType: 'invalid'
}, authConfig(adminToken));
logTest('Invalid question type rejected', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Invalid question type rejected', passed,
passed ? 'Correctly rejected invalid type' : `Status: ${error.response?.status}`);
}
// Test 14: Invalid difficulty rejected
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
difficulty: 'extreme'
}, authConfig(adminToken));
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 15: Insufficient options rejected (multiple choice)
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
options: [{ id: 'a', text: 'Only one option' }]
}, authConfig(adminToken));
logTest('Insufficient options rejected', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Insufficient options rejected', passed,
passed ? 'Correctly rejected insufficient options' : `Status: ${error.response?.status}`);
}
// Test 16: Too many options rejected (multiple choice)
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
options: [
{ id: 'a', text: 'Option 1' },
{ id: 'b', text: 'Option 2' },
{ id: 'c', text: 'Option 3' },
{ id: 'd', text: 'Option 4' },
{ id: 'e', text: 'Option 5' },
{ id: 'f', text: 'Option 6' },
{ id: 'g', text: 'Option 7' }
]
}, authConfig(adminToken));
logTest('Too many options rejected', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Too many options rejected', passed,
passed ? 'Correctly rejected too many options' : `Status: ${error.response?.status}`);
}
// Test 17: Invalid correct answer for options rejected
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
correctAnswer: 'z' // Not in options
}, authConfig(adminToken));
logTest('Invalid correct answer rejected', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Invalid correct answer rejected', passed,
passed ? 'Correctly rejected invalid answer' : `Status: ${error.response?.status}`);
}
// Test 18: Invalid category UUID rejected
try {
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
categoryId: 'invalid-uuid'
}, authConfig(adminToken));
logTest('Invalid category UUID rejected', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Invalid category UUID rejected', passed,
passed ? 'Correctly rejected invalid category UUID' : `Status: ${error.response?.status}`);
}
// Test 19: Non-existent category rejected
try {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
const res = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
categoryId: fakeUuid
}, authConfig(adminToken));
logTest('Non-existent category rejected', false, 'Should have returned 404');
} catch (error) {
const passed = error.response?.status === 404;
logTest('Non-existent category rejected', passed,
passed ? 'Correctly returned 404 for non-existent category' : `Status: ${error.response?.status}`);
}
// ==========================================
// DELETE QUESTION TESTS
// ==========================================
console.log('\n--- Testing Delete Question ---\n');
// Create another question for delete tests
const deleteTestRes = await axios.post(`${API_URL}/admin/questions`, {
questionText: 'Question to be deleted',
questionType: 'trueFalse',
correctAnswer: 'true',
difficulty: 'easy',
categoryId: testCategoryId
}, authConfig(adminToken));
const deleteQuestionId = deleteTestRes.data.data.id;
console.log(`✓ Created question for delete tests: ${deleteQuestionId}\n`);
// Test 20: Admin deletes question (soft delete)
try {
const res = await axios.delete(`${API_URL}/admin/questions/${deleteQuestionId}`, authConfig(adminToken));
const passed = res.status === 200
&& res.data.success === true
&& res.data.data.id === deleteQuestionId;
logTest('Admin deletes question (soft delete)', passed,
passed ? `Question deleted: ${res.data.data.questionText}` : `Response: ${JSON.stringify(res.data)}`);
} catch (error) {
logTest('Admin deletes question (soft delete)', false, error.response?.data?.message || error.message);
}
// Test 21: Already deleted question rejected
try {
const res = await axios.delete(`${API_URL}/admin/questions/${deleteQuestionId}`, authConfig(adminToken));
logTest('Already deleted question rejected', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Already deleted question rejected', passed,
passed ? 'Correctly rejected already deleted question' : `Status: ${error.response?.status}`);
}
// Test 22: Non-admin blocked from deleting
try {
const res = await axios.delete(`${API_URL}/admin/questions/${testQuestionId}`, authConfig(userToken));
logTest('Non-admin blocked from deleting', false, 'Should have returned 403');
} catch (error) {
const passed = error.response?.status === 403;
logTest('Non-admin blocked from deleting', passed,
passed ? 'Correctly blocked with 403' : `Status: ${error.response?.status}`);
}
// Test 23: Unauthenticated delete blocked
try {
const res = await axios.delete(`${API_URL}/admin/questions/${testQuestionId}`);
logTest('Unauthenticated delete blocked', false, 'Should have returned 401');
} catch (error) {
const passed = error.response?.status === 401;
logTest('Unauthenticated delete blocked', passed,
passed ? 'Correctly blocked with 401' : `Status: ${error.response?.status}`);
}
// Test 24: Invalid UUID format for delete returns 400
try {
const res = await axios.delete(`${API_URL}/admin/questions/invalid-uuid`, authConfig(adminToken));
logTest('Invalid UUID format for delete returns 400', false, 'Should have returned 400');
} catch (error) {
const passed = error.response?.status === 400;
logTest('Invalid UUID format for delete returns 400', passed,
passed ? 'Correctly rejected invalid UUID' : `Status: ${error.response?.status}`);
}
// Test 25: Non-existent question for delete returns 404
try {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
const res = await axios.delete(`${API_URL}/admin/questions/${fakeUuid}`, authConfig(adminToken));
logTest('Non-existent question for delete returns 404', false, 'Should have returned 404');
} catch (error) {
const passed = error.response?.status === 404;
logTest('Non-existent question for delete returns 404', passed,
passed ? 'Correctly returned 404 for non-existent question' : `Status: ${error.response?.status}`);
}
// Test 26: Verify deleted question not in active list
try {
const res = await axios.get(`${API_URL}/questions/${deleteQuestionId}`, authConfig(adminToken));
logTest('Deleted question not accessible', false, 'Should have returned 404');
} catch (error) {
const passed = error.response?.status === 404;
logTest('Deleted question not accessible', passed,
passed ? 'Deleted question correctly hidden from API' : `Status: ${error.response?.status}`);
}
} catch (error) {
console.error('\n❌ Fatal error during tests:', error.message);
if (error.response) {
console.error('Response:', error.response.data);
}
}
// ==========================================
// 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();

153
backend/test-user-model.js Normal file
View File

@@ -0,0 +1,153 @@
require('dotenv').config();
const db = require('./models');
const { User } = db;
async function testUserModel() {
console.log('\n🧪 Testing User Model...\n');
try {
// Test 1: Create a test user
console.log('Test 1: Creating a test user...');
const testUser = await User.create({
username: 'testuser',
email: 'test@example.com',
password: 'password123',
role: 'user'
});
console.log('✅ User created successfully');
console.log(' - ID:', testUser.id);
console.log(' - Username:', testUser.username);
console.log(' - Email:', testUser.email);
console.log(' - Role:', testUser.role);
console.log(' - Password hashed:', testUser.password.substring(0, 20) + '...');
console.log(' - Password length:', testUser.password.length);
// Test 2: Verify password hashing
console.log('\nTest 2: Testing password hashing...');
const isPasswordHashed = testUser.password !== 'password123';
console.log('✅ Password is hashed:', isPasswordHashed);
console.log(' - Original: password123');
console.log(' - Hashed:', testUser.password.substring(0, 30) + '...');
// Test 3: Test password comparison
console.log('\nTest 3: Testing password comparison...');
const isCorrectPassword = await testUser.comparePassword('password123');
const isWrongPassword = await testUser.comparePassword('wrongpassword');
console.log('✅ Correct password:', isCorrectPassword);
console.log('✅ Wrong password rejected:', !isWrongPassword);
// Test 4: Test toJSON (password should be excluded)
console.log('\nTest 4: Testing toJSON (password exclusion)...');
const userJSON = testUser.toJSON();
const hasPassword = 'password' in userJSON;
console.log('✅ Password excluded from JSON:', !hasPassword);
console.log(' JSON keys:', Object.keys(userJSON).join(', '));
// Test 5: Test findByEmail
console.log('\nTest 5: Testing findByEmail...');
const foundUser = await User.findByEmail('test@example.com');
console.log('✅ User found by email:', foundUser ? 'Yes' : 'No');
console.log(' - Username:', foundUser.username);
// Test 6: Test findByUsername
console.log('\nTest 6: Testing findByUsername...');
const foundByUsername = await User.findByUsername('testuser');
console.log('✅ User found by username:', foundByUsername ? 'Yes' : 'No');
// Test 7: Test streak calculation
console.log('\nTest 7: Testing streak calculation...');
testUser.updateStreak();
console.log('✅ Streak updated');
console.log(' - Current streak:', testUser.currentStreak);
console.log(' - Longest streak:', testUser.longestStreak);
console.log(' - Last quiz date:', testUser.lastQuizDate);
// Test 8: Test accuracy calculation
console.log('\nTest 8: Testing accuracy calculation...');
testUser.totalQuestionsAnswered = 10;
testUser.correctAnswers = 8;
const accuracy = testUser.calculateAccuracy();
console.log('✅ Accuracy calculated:', accuracy + '%');
// Test 9: Test pass rate calculation
console.log('\nTest 9: Testing pass rate calculation...');
testUser.totalQuizzes = 5;
testUser.quizzesPassed = 4;
const passRate = testUser.getPassRate();
console.log('✅ Pass rate calculated:', passRate + '%');
// Test 10: Test unique constraints
console.log('\nTest 10: Testing unique constraints...');
try {
await User.create({
username: 'testuser', // Duplicate username
email: 'another@example.com',
password: 'password123'
});
console.log('❌ Unique constraint not working');
} catch (error) {
if (error.name === 'SequelizeUniqueConstraintError') {
console.log('✅ Unique username constraint working');
}
}
// Test 11: Test email validation
console.log('\nTest 11: Testing email validation...');
try {
await User.create({
username: 'invaliduser',
email: 'not-an-email', // Invalid email
password: 'password123'
});
console.log('❌ Email validation not working');
} catch (error) {
if (error.name === 'SequelizeValidationError') {
console.log('✅ Email validation working');
}
}
// Test 12: Test password update
console.log('\nTest 12: Testing password update...');
const oldPassword = testUser.password;
testUser.password = 'newpassword456';
await testUser.save();
const passwordChanged = oldPassword !== testUser.password;
console.log('✅ Password re-hashed on update:', passwordChanged);
const newPasswordWorks = await testUser.comparePassword('newpassword456');
console.log('✅ New password works:', newPasswordWorks);
// Cleanup
console.log('\n🧹 Cleaning up test data...');
await testUser.destroy();
console.log('✅ Test user deleted');
console.log('\n' + '='.repeat(60));
console.log('✅ ALL TESTS PASSED!');
console.log('='.repeat(60));
console.log('\nUser Model Summary:');
console.log('- ✅ User creation with UUID');
console.log('- ✅ Password hashing (bcrypt)');
console.log('- ✅ Password comparison');
console.log('- ✅ toJSON excludes password');
console.log('- ✅ Find by email/username');
console.log('- ✅ Streak calculation');
console.log('- ✅ Accuracy/pass rate calculation');
console.log('- ✅ Unique constraints');
console.log('- ✅ Email validation');
console.log('- ✅ Password update & re-hash');
console.log('\n');
process.exit(0);
} catch (error) {
console.error('\n❌ Test failed:', error.message);
if (error.errors) {
error.errors.forEach(err => {
console.error(' -', err.message);
});
}
console.error('\nStack:', error.stack);
process.exit(1);
}
}
testUserModel();

317
backend/validate-env.js Normal file
View File

@@ -0,0 +1,317 @@
require('dotenv').config();
/**
* Environment Configuration Validator
* Validates all required environment variables and their formats
*/
const REQUIRED_VARS = {
// Server Configuration
NODE_ENV: {
required: true,
type: 'string',
allowedValues: ['development', 'test', 'production'],
default: 'development'
},
PORT: {
required: true,
type: 'number',
min: 1000,
max: 65535,
default: 3000
},
API_PREFIX: {
required: true,
type: 'string',
default: '/api'
},
// Database Configuration
DB_HOST: {
required: true,
type: 'string',
default: 'localhost'
},
DB_PORT: {
required: true,
type: 'number',
default: 3306
},
DB_NAME: {
required: true,
type: 'string',
minLength: 3
},
DB_USER: {
required: true,
type: 'string'
},
DB_PASSWORD: {
required: false, // Optional for development
type: 'string',
warning: 'Database password is not set. This is only acceptable in development.'
},
DB_DIALECT: {
required: true,
type: 'string',
allowedValues: ['mysql', 'postgres', 'sqlite', 'mssql'],
default: 'mysql'
},
// Database Pool Configuration
DB_POOL_MAX: {
required: false,
type: 'number',
default: 10
},
DB_POOL_MIN: {
required: false,
type: 'number',
default: 0
},
DB_POOL_ACQUIRE: {
required: false,
type: 'number',
default: 30000
},
DB_POOL_IDLE: {
required: false,
type: 'number',
default: 10000
},
// JWT Configuration
JWT_SECRET: {
required: true,
type: 'string',
minLength: 32,
warning: 'JWT_SECRET should be a long, random string (64+ characters recommended)'
},
JWT_EXPIRE: {
required: true,
type: 'string',
default: '24h'
},
// Rate Limiting
RATE_LIMIT_WINDOW_MS: {
required: false,
type: 'number',
default: 900000
},
RATE_LIMIT_MAX_REQUESTS: {
required: false,
type: 'number',
default: 100
},
// CORS Configuration
CORS_ORIGIN: {
required: true,
type: 'string',
default: 'http://localhost:4200'
},
// Guest Configuration
GUEST_SESSION_EXPIRE_HOURS: {
required: false,
type: 'number',
default: 24
},
GUEST_MAX_QUIZZES: {
required: false,
type: 'number',
default: 3
},
// Logging
LOG_LEVEL: {
required: false,
type: 'string',
allowedValues: ['error', 'warn', 'info', 'debug'],
default: 'info'
}
};
class ValidationError extends Error {
constructor(variable, message) {
super(`${variable}: ${message}`);
this.variable = variable;
}
}
/**
* Validate a single environment variable
*/
function validateVariable(name, config) {
const value = process.env[name];
const errors = [];
const warnings = [];
// Check if required and missing
if (config.required && !value) {
if (config.default !== undefined) {
warnings.push(`${name} is not set. Using default: ${config.default}`);
process.env[name] = String(config.default);
return { errors, warnings };
}
errors.push(`${name} is required but not set`);
return { errors, warnings };
}
// If not set and not required, use default if available
if (!value && config.default !== undefined) {
process.env[name] = String(config.default);
return { errors, warnings };
}
// Skip further validation if not set and not required
if (!value && !config.required) {
return { errors, warnings };
}
// Type validation
if (config.type === 'number') {
const numValue = Number(value);
if (isNaN(numValue)) {
errors.push(`${name} must be a number. Got: ${value}`);
} else {
if (config.min !== undefined && numValue < config.min) {
errors.push(`${name} must be >= ${config.min}. Got: ${numValue}`);
}
if (config.max !== undefined && numValue > config.max) {
errors.push(`${name} must be <= ${config.max}. Got: ${numValue}`);
}
}
}
// String length validation
if (config.type === 'string' && config.minLength && value.length < config.minLength) {
errors.push(`${name} must be at least ${config.minLength} characters. Got: ${value.length}`);
}
// Allowed values validation
if (config.allowedValues && !config.allowedValues.includes(value)) {
errors.push(`${name} must be one of: ${config.allowedValues.join(', ')}. Got: ${value}`);
}
// Custom warnings
if (config.warning && value) {
const needsWarning = config.minLength ? value.length < 64 : true;
if (needsWarning) {
warnings.push(`${name}: ${config.warning}`);
}
}
// Warning for missing optional password in production
if (name === 'DB_PASSWORD' && !value && process.env.NODE_ENV === 'production') {
errors.push('DB_PASSWORD is required in production');
}
return { errors, warnings };
}
/**
* Validate all environment variables
*/
function validateEnvironment() {
console.log('\n🔍 Validating Environment Configuration...\n');
const allErrors = [];
const allWarnings = [];
let validCount = 0;
// Validate each variable
Object.entries(REQUIRED_VARS).forEach(([name, config]) => {
const { errors, warnings } = validateVariable(name, config);
if (errors.length > 0) {
allErrors.push(...errors);
console.log(`${name}: INVALID`);
errors.forEach(err => console.log(` ${err}`));
} else if (warnings.length > 0) {
allWarnings.push(...warnings);
console.log(`⚠️ ${name}: WARNING`);
warnings.forEach(warn => console.log(` ${warn}`));
validCount++;
} else {
console.log(`${name}: OK`);
validCount++;
}
});
// Summary
console.log('\n' + '='.repeat(60));
console.log('VALIDATION SUMMARY');
console.log('='.repeat(60));
console.log(`Total Variables: ${Object.keys(REQUIRED_VARS).length}`);
console.log(`✅ Valid: ${validCount}`);
console.log(`⚠️ Warnings: ${allWarnings.length}`);
console.log(`❌ Errors: ${allErrors.length}`);
console.log('='.repeat(60));
if (allWarnings.length > 0) {
console.log('\n⚠ WARNINGS:');
allWarnings.forEach(warning => console.log(` - ${warning}`));
}
if (allErrors.length > 0) {
console.log('\n❌ ERRORS:');
allErrors.forEach(error => console.log(` - ${error}`));
console.log('\nPlease fix the above errors before starting the application.\n');
return false;
}
console.log('\n✅ All environment variables are valid!\n');
return true;
}
/**
* Get current environment configuration summary
*/
function getEnvironmentSummary() {
return {
environment: process.env.NODE_ENV || 'development',
server: {
port: process.env.PORT || 3000,
apiPrefix: process.env.API_PREFIX || '/api'
},
database: {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 3306,
name: process.env.DB_NAME,
dialect: process.env.DB_DIALECT || 'mysql',
pool: {
max: parseInt(process.env.DB_POOL_MAX) || 10,
min: parseInt(process.env.DB_POOL_MIN) || 0
}
},
security: {
jwtExpire: process.env.JWT_EXPIRE || '24h',
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:4200'
},
guest: {
maxQuizzes: parseInt(process.env.GUEST_MAX_QUIZZES) || 3,
sessionExpireHours: parseInt(process.env.GUEST_SESSION_EXPIRE_HOURS) || 24
}
};
}
// Run validation if called directly
if (require.main === module) {
const isValid = validateEnvironment();
if (isValid) {
console.log('Current Configuration:');
console.log(JSON.stringify(getEnvironmentSummary(), null, 2));
console.log('\n');
}
process.exit(isValid ? 0 : 1);
}
module.exports = {
validateEnvironment,
getEnvironmentSummary,
REQUIRED_VARS
};

View File

@@ -0,0 +1,88 @@
const { Sequelize } = require('sequelize');
const config = require('./config/database');
const sequelize = new Sequelize(
config.development.database,
config.development.username,
config.development.password,
{
host: config.development.host,
dialect: config.development.dialect,
logging: false
}
);
async function verifyData() {
try {
await sequelize.authenticate();
console.log('✅ Database connection established\n');
// Get counts from each table
const [categories] = await sequelize.query('SELECT COUNT(*) as count FROM categories');
const [users] = await sequelize.query('SELECT COUNT(*) as count FROM users');
const [questions] = await sequelize.query('SELECT COUNT(*) as count FROM questions');
const [achievements] = await sequelize.query('SELECT COUNT(*) as count FROM achievements');
console.log('📊 Seeded Data Summary:');
console.log('========================');
console.log(`Categories: ${categories[0].count} rows`);
console.log(`Users: ${users[0].count} rows`);
console.log(`Questions: ${questions[0].count} rows`);
console.log(`Achievements: ${achievements[0].count} rows`);
console.log('========================\n');
// Verify category names
const [categoryList] = await sequelize.query('SELECT name, slug, guest_accessible FROM categories ORDER BY display_order');
console.log('📁 Categories:');
categoryList.forEach(cat => {
console.log(` - ${cat.name} (${cat.slug}) ${cat.guest_accessible ? '🔓 Guest' : '🔒 Auth'}`);
});
console.log('');
// Verify admin user
const [adminUser] = await sequelize.query("SELECT username, email, role FROM users WHERE email = 'admin@quiz.com'");
if (adminUser.length > 0) {
console.log('👤 Admin User:');
console.log(` - Username: ${adminUser[0].username}`);
console.log(` - Email: ${adminUser[0].email}`);
console.log(` - Role: ${adminUser[0].role}`);
console.log(' - Password: Admin@123');
console.log('');
}
// Verify questions by category
const [questionsByCategory] = await sequelize.query(`
SELECT c.name, COUNT(q.id) as count
FROM categories c
LEFT JOIN questions q ON c.id = q.category_id
GROUP BY c.id, c.name
ORDER BY c.display_order
`);
console.log('❓ Questions by Category:');
questionsByCategory.forEach(cat => {
console.log(` - ${cat.name}: ${cat.count} questions`);
});
console.log('');
// Verify achievements by category
const [achievementsByCategory] = await sequelize.query(`
SELECT category, COUNT(*) as count
FROM achievements
GROUP BY category
ORDER BY category
`);
console.log('🏆 Achievements by Category:');
achievementsByCategory.forEach(cat => {
console.log(` - ${cat.category}: ${cat.count} achievements`);
});
console.log('');
console.log('✅ All data seeded successfully!');
process.exit(0);
} catch (error) {
console.error('❌ Error verifying data:', error.message);
process.exit(1);
}
}
verifyData();

1
frontend Submodule

Submodule frontend added at 8529beecad

2092
interview_quiz_user_story.md Normal file

File diff suppressed because it is too large Load Diff