add changes

This commit is contained in:
AD2025
2025-12-26 23:56:32 +02:00
parent 410c3d725f
commit e7d26bc981
127 changed files with 36162 additions and 0 deletions

41
.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
.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
.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')
};

321
ADMIN_QUESTIONS_API.md Normal file
View File

@@ -0,0 +1,321 @@
# Admin Questions API - Pagination & Search Documentation
## Endpoint
```
GET /api/admin/questions
```
**Authentication Required:** Admin only (Bearer token)
## Description
Retrieves all questions with comprehensive pagination, filtering, and search capabilities. This endpoint is designed for admin dashboards to manage questions efficiently.
## Query Parameters
| Parameter | Type | Default | Description | Validation |
|-----------|------|---------|-------------|------------|
| `page` | number | 1 | Page number for pagination | Min: 1 |
| `limit` | number | 10 | Number of results per page | Min: 1, Max: 100 |
| `search` | string | '' | Search term for question text, explanation, or tags | - |
| `category` | UUID | '' | Filter by category UUID | Must be valid UUID |
| `difficulty` | string | '' | Filter by difficulty level | `easy`, `medium`, `hard` |
| `sortBy` | string | 'createdAt' | Field to sort by | See valid fields below |
| `order` | string | 'DESC' | Sort order | `ASC` or `DESC` |
### Valid Sort Fields
- `createdAt` (default)
- `updatedAt`
- `questionText`
- `difficulty`
- `points`
- `timesAttempted`
## Response Structure
```json
{
"success": true,
"count": 10,
"total": 45,
"page": 1,
"totalPages": 5,
"limit": 10,
"filters": {
"search": "javascript",
"category": "68b4c87f-db0b-48ea-b8a4-b2f4fce785a2",
"difficulty": "easy",
"sortBy": "createdAt",
"order": "DESC"
},
"data": [
{
"id": "uuid",
"questionText": "What is a closure in JavaScript?",
"questionType": "multiple",
"options": [
{
"id": "a",
"text": "Option A"
}
],
"correctAnswer": "a",
"difficulty": "medium",
"points": 10,
"explanation": "Detailed explanation...",
"tags": ["closures", "functions"],
"keywords": ["closure", "scope"],
"timesAttempted": 150,
"timesCorrect": 120,
"accuracy": 80,
"isActive": true,
"category": {
"id": "uuid",
"name": "JavaScript",
"slug": "javascript",
"icon": "code",
"color": "#F7DF1E",
"guestAccessible": true
},
"createdAt": "2025-11-19T10:00:00.000Z",
"updatedAt": "2025-11-19T10:00:00.000Z"
}
],
"message": "Retrieved 10 of 45 questions"
}
```
## Usage Examples
### Basic Request
```bash
curl -X GET "http://localhost:3000/api/admin/questions" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
### With Pagination
```bash
curl -X GET "http://localhost:3000/api/admin/questions?page=2&limit=20" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
### Search Questions
```bash
curl -X GET "http://localhost:3000/api/admin/questions?search=async" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
### Filter by Category
```bash
curl -X GET "http://localhost:3000/api/admin/questions?category=68b4c87f-db0b-48ea-b8a4-b2f4fce785a2" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
### Filter by Difficulty
```bash
curl -X GET "http://localhost:3000/api/admin/questions?difficulty=easy" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
### Combined Filters
```bash
curl -X GET "http://localhost:3000/api/admin/questions?search=javascript&difficulty=medium&category=68b4c87f-db0b-48ea-b8a4-b2f4fce785a2&page=1&limit=15" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
### Custom Sorting
```bash
# Sort by points ascending
curl -X GET "http://localhost:3000/api/admin/questions?sortBy=points&order=ASC" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
# Sort by difficulty descending
curl -X GET "http://localhost:3000/api/admin/questions?sortBy=difficulty&order=DESC" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
## JavaScript/Axios Examples
### Basic Request
```javascript
const axios = require('axios');
const response = await axios.get('http://localhost:3000/api/admin/questions', {
headers: { Authorization: `Bearer ${adminToken}` }
});
console.log(`Total questions: ${response.data.total}`);
console.log(`Current page: ${response.data.page}`);
console.log(`Questions:`, response.data.data);
```
### With All Filters
```javascript
const params = {
page: 1,
limit: 20,
search: 'async',
category: '68b4c87f-db0b-48ea-b8a4-b2f4fce785a2',
difficulty: 'medium',
sortBy: 'points',
order: 'DESC'
};
const response = await axios.get('http://localhost:3000/api/admin/questions', {
params,
headers: { Authorization: `Bearer ${adminToken}` }
});
```
### Paginate Through All Questions
```javascript
async function getAllQuestions(adminToken) {
const allQuestions = [];
let currentPage = 1;
let totalPages = 1;
do {
const response = await axios.get('http://localhost:3000/api/admin/questions', {
params: { page: currentPage, limit: 50 },
headers: { Authorization: `Bearer ${adminToken}` }
});
allQuestions.push(...response.data.data);
totalPages = response.data.totalPages;
currentPage++;
} while (currentPage <= totalPages);
return allQuestions;
}
```
## Error Responses
### 401 Unauthorized
```json
{
"success": false,
"message": "Authentication required"
}
```
### 403 Forbidden
```json
{
"success": false,
"message": "Admin access required"
}
```
### 500 Internal Server Error
```json
{
"success": false,
"message": "An error occurred while retrieving questions",
"error": "Error details (in development mode)"
}
```
## Features
### ✅ Pagination
- Efficient offset-based pagination
- Configurable page size (1-100)
- Total count and pages metadata
- Handles out-of-range pages gracefully
### ✅ Search
- Full-text search across question text
- Search in explanations
- Search in tags
- Case-insensitive matching
- Handles special characters
### ✅ Filtering
- Filter by category (UUID)
- Filter by difficulty (easy/medium/hard)
- Combine multiple filters
- Invalid UUIDs handled gracefully
### ✅ Sorting
- Sort by multiple fields
- Ascending or descending order
- Invalid sort fields default to createdAt
- Consistent ordering
### ✅ Response Data
- Calculated accuracy percentage
- Complete question details including correctAnswer (admin only)
- Category information
- Active/inactive status
- Timestamps
## Performance Considerations
1. **Limit:** Maximum 100 questions per page to prevent performance issues
2. **Indexing:** Database indexes on frequently queried fields (categoryId, difficulty, isActive)
3. **Pagination:** Offset-based pagination is efficient for moderate dataset sizes
4. **Search:** Uses SQL LIKE for search - consider full-text indexes for large datasets
## Testing
Run the comprehensive test suite:
```bash
node test-admin-questions-pagination.js
```
The test suite covers:
- ✅ Authorization (35 tests)
- ✅ Pagination (8 tests)
- ✅ Search functionality (4 tests)
- ✅ Filtering (9 tests)
- ✅ Combined filters (4 tests)
- ✅ Sorting (5 tests)
- ✅ Response structure (5 tests)
- ✅ Edge cases and performance
Total: 35 comprehensive test cases
## Related Endpoints
- `POST /api/admin/questions` - Create a new question
- `PUT /api/admin/questions/:id` - Update a question
- `DELETE /api/admin/questions/:id` - Delete a question (soft delete)
- `GET /api/questions/category/:categoryId` - Public endpoint for questions by category
- `GET /api/questions/search` - Public search endpoint with guest filtering
- `GET /api/questions/:id` - Get single question by ID
## Notes
- **Admin Only:** This endpoint requires admin authentication
- **correctAnswer:** Admin responses include the correct answer (unlike public endpoints)
- **isActive:** Includes both active and inactive questions for admin management
- **Accuracy:** Calculated as (timesCorrect / timesAttempted) * 100
- **Category Filtering:** Invalid UUIDs are silently ignored (returns all categories)
- **Search:** Empty search string returns all questions
## Changelog
### Version 1.0.0 (2025-11-19)
- Initial implementation
- Pagination support (page, limit)
- Search functionality (question text, explanation, tags)
- Filtering by category and difficulty
- Sorting by multiple fields
- Comprehensive test suite

299
API_DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,299 @@
# Interview Quiz API Documentation
## Quick Access
**Interactive Documentation**: [http://localhost:3000/api-docs](http://localhost:3000/api-docs)
**OpenAPI Specification**: [http://localhost:3000/api-docs.json](http://localhost:3000/api-docs.json)
## Overview
This API provides comprehensive endpoints for managing an interview quiz application with support for:
- User authentication and authorization
- Guest sessions without registration
- Quiz management and session tracking
- User bookmarks and progress tracking
- Admin dashboard and user management
## API Endpoints Summary
### Authentication (4 endpoints)
- `POST /api/auth/register` - Register a new user account
- `POST /api/auth/login` - Login to user account
- `POST /api/auth/logout` - Logout user
- `GET /api/auth/verify` - Verify JWT token
### Users (3 endpoints)
- `GET /api/users/{userId}/dashboard` - Get user dashboard with statistics
- `GET /api/users/{userId}/history` - Get quiz history with pagination
- `PUT /api/users/{userId}` - Update user profile
### Bookmarks (3 endpoints)
- `GET /api/users/{userId}/bookmarks` - Get user's bookmarked questions
- `POST /api/users/{userId}/bookmarks` - Add a question to bookmarks
- `DELETE /api/users/{userId}/bookmarks/{questionId}` - Remove bookmark
### Categories (5 endpoints)
- `GET /api/categories` - Get all active categories
- `POST /api/categories` - Create new category (Admin)
- `GET /api/categories/{id}` - Get category details
- `PUT /api/categories/{id}` - Update category (Admin)
- `DELETE /api/categories/{id}` - Delete category (Admin)
### Quiz (5 endpoints)
- `POST /api/quiz/start` - Start a new quiz session
- `POST /api/quiz/submit` - Submit an answer
- `POST /api/quiz/complete` - Complete quiz session
- `GET /api/quiz/session/{sessionId}` - Get session details
- `GET /api/quiz/review/{sessionId}` - Review completed quiz
### Guest (4 endpoints)
- `POST /api/guest/start-session` - Start a guest session
- `GET /api/guest/session/{guestId}` - Get guest session details
- `GET /api/guest/quiz-limit` - Check guest quiz limit
- `POST /api/guest/convert` - Convert guest to registered user
### Admin (13 endpoints)
#### Statistics & Analytics
- `GET /api/admin/statistics` - Get system-wide statistics
- `GET /api/admin/guest-analytics` - Get guest user analytics
#### Guest Settings
- `GET /api/admin/guest-settings` - Get guest settings
- `PUT /api/admin/guest-settings` - Update guest settings
#### User Management
- `GET /api/admin/users` - Get all users with pagination
- `GET /api/admin/users/{userId}` - Get user details
- `PUT /api/admin/users/{userId}/role` - Update user role
- `PUT /api/admin/users/{userId}/activate` - Reactivate user
- `DELETE /api/admin/users/{userId}` - Deactivate user
#### Question Management
- `POST /api/admin/questions` - Create new question
- `PUT /api/admin/questions/{id}` - Update question
- `DELETE /api/admin/questions/{id}` - Delete question
## Authentication
### Bearer Token Authentication
Most endpoints require JWT authentication. Include the token in the Authorization header:
```
Authorization: Bearer <your-jwt-token>
```
### Guest Token Authentication
Guest users use a special header for authentication:
```
x-guest-token: <guest-session-uuid>
```
## Response Codes
- `200` - Success
- `201` - Created
- `400` - Bad Request / Validation Error
- `401` - Unauthorized (missing or invalid token)
- `403` - Forbidden (insufficient permissions)
- `404` - Not Found
- `409` - Conflict (duplicate resource)
- `500` - Internal Server Error
## Rate Limiting
API requests are rate-limited to prevent abuse. Default limits:
- Window: 15 minutes
- Max requests: 100 per window
## Example Usage
### Register a New User
```bash
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "johndoe",
"email": "john@example.com",
"password": "password123"
}'
```
### Login
```bash
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "password123"
}'
```
### Start a Quiz (Authenticated User)
```bash
curl -X POST http://localhost:3000/api/quiz/start \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your-token>" \
-d '{
"categoryId": 1,
"questionCount": 10,
"difficulty": "mixed"
}'
```
### Start a Guest Session
```bash
curl -X POST http://localhost:3000/api/guest/start-session \
-H "Content-Type: application/json"
```
### Start a Quiz (Guest User)
```bash
curl -X POST http://localhost:3000/api/quiz/start \
-H "Content-Type: application/json" \
-H "x-guest-token: <guest-session-id>" \
-d '{
"categoryId": 1,
"questionCount": 5
}'
```
## Data Models
### User
```json
{
"id": 1,
"username": "johndoe",
"email": "john@example.com",
"role": "user",
"isActive": true,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
```
### Category
```json
{
"id": 1,
"name": "JavaScript Fundamentals",
"description": "Core JavaScript concepts",
"questionCount": 50,
"createdAt": "2025-01-01T00:00:00.000Z"
}
```
### Quiz Session
```json
{
"id": 1,
"userId": 1,
"categoryId": 1,
"totalQuestions": 10,
"currentQuestionIndex": 5,
"score": 4,
"isCompleted": false,
"startedAt": "2025-01-01T00:00:00.000Z"
}
```
### Guest Session
```json
{
"guestSessionId": "550e8400-e29b-41d4-a716-446655440000",
"convertedUserId": null,
"expiresAt": "2025-01-02T00:00:00.000Z",
"createdAt": "2025-01-01T00:00:00.000Z"
}
```
## Error Handling
All errors follow a consistent format:
```json
{
"message": "Error description",
"error": "Detailed error information (optional)"
}
```
## Pagination
Endpoints that return lists support pagination with these query parameters:
- `page` - Page number (default: 1)
- `limit` - Items per page (default: 10, max: 50)
Response includes pagination metadata:
```json
{
"data": [...],
"pagination": {
"currentPage": 1,
"totalPages": 5,
"totalItems": 50,
"itemsPerPage": 10
}
}
```
## Filtering and Sorting
Many endpoints support filtering and sorting:
- `sortBy` - Field to sort by (e.g., date, score, username)
- `sortOrder` - Sort direction (asc, desc)
- `category` - Filter by category ID
- `difficulty` - Filter by difficulty (easy, medium, hard)
- `role` - Filter by user role (user, admin)
- `isActive` - Filter by active status (true, false)
## Development
### Running the API
```bash
cd backend
npm install
npm start
```
### Accessing Documentation
Once the server is running, visit:
- **Swagger UI**: http://localhost:3000/api-docs
- **OpenAPI JSON**: http://localhost:3000/api-docs.json
- **Health Check**: http://localhost:3000/health
## Production Deployment
Update the server URL in `config/swagger.js`:
```javascript
servers: [
{
url: 'https://api.yourdomain.com/api',
description: 'Production server'
}
]
```
## Support
For API support, contact: support@interviewquiz.com
## License
MIT License

185
DATABASE_REFERENCE.md Normal file
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! ✅

348
ENVIRONMENT_GUIDE.md Normal file
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.

344
OPTIMIZATION_SUMMARY.md Normal file
View File

@@ -0,0 +1,344 @@
# Database Optimization Summary - Task 45
**Status**: ✅ Completed
**Date**: November 2024
**Overall Performance Rating**: ⚡ EXCELLENT
---
## Executive Summary
Successfully completed comprehensive database optimization for the Interview Quiz Application backend. All endpoints now respond in under 50ms with an overall average of 14.70ms. Implemented Redis caching infrastructure with 12 specialized middlewares, verified comprehensive database indexing, and confirmed N+1 query prevention throughout the codebase.
---
## Key Achievements
### 1. Database Indexing ✅
**Status**: Already Optimized
**Findings:**
- Verified 14+ indexes exist on `quiz_sessions` table
- All critical tables properly indexed from previous implementations
- Composite indexes for common query patterns
**QuizSession Indexes:**
- Single column: `user_id`, `guest_session_id`, `category_id`, `status`, `created_at`
- Composite: `[user_id, created_at]`, `[guest_session_id, created_at]`, `[category_id, status]`
**QuizSessionQuestion Indexes:**
- Single: `quiz_session_id`, `question_id`
- Composite: `[quiz_session_id, question_order]`
- Unique constraint: `[quiz_session_id, question_id]`
**Other Models:**
- User, Question, Category, GuestSession, QuizAnswer, UserBookmark all have comprehensive indexes
### 2. Redis Caching Infrastructure ✅
**Status**: Fully Implemented
**Implementation:**
- Package: `ioredis` with connection pooling
- Configuration: Retry strategy (50ms * attempts, max 2000ms)
- Auto-reconnection with graceful fallback
- Event handlers for all connection states
**Helper Functions:**
```javascript
- getCache(key) // Retrieve with JSON parsing
- setCache(key, value, ttl) // Store with TTL (default 300s)
- deleteCache(key) // Pattern-based deletion (supports wildcards)
- clearCache() // Flush database
- getCacheMultiple(keys) // Batch retrieval
- incrementCache(key, increment) // Atomic counters
- cacheExists(key) // Existence check
```
### 3. Cache Middleware System ✅
**Status**: 12 Specialized Middlewares
**TTL Strategy (optimized for data volatility):**
| Endpoint Type | TTL | Rationale |
|--------------|-----|-----------|
| Categories (list & single) | 1 hour | Rarely change |
| Guest Settings | 30 min | Infrequent updates |
| Single Question | 30 min | Static content |
| Questions List | 10 min | Moderate updates |
| Guest Analytics | 10 min | Moderate refresh |
| Statistics | 5 min | Frequently updated |
| User Dashboard | 5 min | Real-time feeling |
| User Bookmarks | 5 min | Immediate feedback |
| User History | 5 min | Recent activity |
**Automatic Cache Invalidation:**
- POST operations → Clear list caches
- PUT operations → Clear specific item + list caches
- DELETE operations → Clear specific item + list caches
- Pattern-based: `user:*`, `category:*`, `question:*`
**Applied to Routes:**
- **Category Routes:**
- `GET /categories` → 1 hour cache
- `GET /categories/:id` → 1 hour cache
- `POST/PUT/DELETE` → Auto-invalidation
- **Admin Routes:**
- `GET /admin/statistics` → 5 min cache
- `GET /admin/guest-settings` → 30 min cache
- `GET /admin/guest-analytics` → 10 min cache
- `PUT /admin/guest-settings` → Auto-invalidation
### 4. N+1 Query Prevention ✅
**Status**: Already Implemented
**Findings:**
- All controllers use Sequelize eager loading with `include`
- Associations properly defined across all models
- No N+1 query issues detected in existing code
**Example from Controllers:**
```javascript
// User Dashboard - Eager loads related data
const quizSessions = await QuizSession.findAll({
where: { user_id: userId },
include: [
{ model: Category, attributes: ['name', 'icon'] },
{ model: QuizSessionQuestion, include: [Question] }
],
order: [['created_at', 'DESC']]
});
```
---
## Performance Benchmark Results
**Test Configuration:**
- Tool: Custom benchmark script (`test-performance.js`)
- Iterations: 10 per endpoint
- Server: Local development (localhost:3000)
- Date: November 2024
### Endpoint Performance
| Endpoint | Average | Min | Max | Rating |
|----------|---------|-----|-----|--------|
| API Documentation (GET) | 3.70ms | 3ms | 5ms | ⚡ Excellent |
| Health Check (GET) | 5.90ms | 5ms | 8ms | ⚡ Excellent |
| Categories List (GET) | 13.60ms | 6ms | 70ms | ⚡ Excellent |
| Guest Session (POST) | 35.60ms | 5ms | 94ms | ⚡ Excellent |
**Performance Ratings:**
- ⚡ Excellent: < 50ms (all endpoints achieved this)
- ✓ Good: < 100ms
- ⚠ Fair: < 200ms
- ⚠️ Needs Optimization: > 200ms
### Overall Statistics
```
Total Endpoints Tested: 4
Total Requests Made: 40
Overall Average: 14.70ms
Fastest Endpoint: API Documentation (3.70ms)
Slowest Endpoint: Guest Session Creation (35.60ms)
Overall Rating: 🎉 EXCELLENT
```
### Cache Effectiveness
**Test**: Categories endpoint (cache miss vs cache hit)
```
First Request (cache miss): 8ms
Second Request (cache hit): 7ms
Cache Improvement: 12.5% faster 🚀
```
**Analysis:**
- Cache working correctly with automatic invalidation
- Noticeable improvement even on already-fast endpoints
- Cached responses consistent with database data
---
## Files Created
### Configuration & Utilities
- **`config/redis.js`** (270 lines)
- Redis connection management
- Retry strategy and auto-reconnection
- Helper functions for all cache operations
- Graceful fallback if Redis unavailable
### Middleware
- **`middleware/cache.js`** (240 lines)
- 12 specialized cache middlewares
- Generic `cacheMiddleware(ttl, keyGenerator)` factory
- Automatic cache invalidation system
- Pattern-based cache clearing
### Testing
- **`test-performance.js`** (200 lines)
- Comprehensive benchmark suite
- 4 endpoint tests with 10 iterations each
- Cache effectiveness testing
- Performance ratings and colorized output
---
## Files Modified
### Models (Index Definitions)
- **`models/QuizSession.js`** - Added 8 index definitions
- **`models/QuizSessionQuestion.js`** - Added 4 index definitions
### Routes (Caching Applied)
- **`routes/category.routes.js`** - Categories caching (1hr) + invalidation
- **`routes/admin.routes.js`** - Statistics (5min) + guest settings (30min) caching
### Server & Configuration
- **`server.js`** - Redis status display on startup (✅ Connected / ⚠️ Not Connected)
- **`validate-env.js`** - Redis environment variables (REDIS_HOST, REDIS_PORT, etc.)
---
## Technical Discoveries
### 1. Database Already Optimized
The database schema was already well-optimized from previous task implementations:
- 14+ indexes on quiz_sessions table alone
- Comprehensive indexes across all models
- Proper composite indexes for common query patterns
- Full-text search indexes on question_text and explanation
### 2. N+1 Queries Already Prevented
All controllers already implemented best practices:
- Eager loading with Sequelize `include`
- Proper model associations
- No sequential queries in loops
### 3. Redis as Optional Feature
Implementation allows system to work without Redis:
- Graceful fallback in all cache operations
- Returns null/false on cache miss when Redis unavailable
- Application continues normally
- No errors or crashes
### 4. Migration Not Required
Attempted database migration for indexes failed with "Duplicate key name" error:
- This is actually a good outcome
- Indicates indexes already exist from model sync
- Verified with SQL query: `SHOW INDEX FROM quiz_sessions`
- No manual migration needed
---
## Environment Variables Added
```env
# Redis Configuration (Optional)
REDIS_HOST=localhost # Default: localhost
REDIS_PORT=6379 # Default: 6379
REDIS_PASSWORD= # Optional
REDIS_DB=0 # Default: 0
```
All Redis variables are optional. System works without Redis (caching disabled).
---
## Cache Key Patterns
**Implemented Patterns:**
```
cache:categories:list # All categories
cache:category:{id} # Single category
cache:guest:settings # Guest settings
cache:admin:statistics # Admin statistics
cache:admin:guest-analytics # Guest analytics
cache:user:{userId}:dashboard # User dashboard
cache:questions:{filters} # Questions with filters
cache:question:{id} # Single question
cache:user:{userId}:bookmarks # User bookmarks
cache:user:{userId}:history:page:{page} # User quiz history
```
**Invalidation Patterns:**
```
cache:category:* # All category caches
cache:user:{userId}:* # All user caches
cache:question:* # All question caches
cache:admin:* # All admin caches
```
---
## Next Steps & Recommendations
### Immediate Actions
1. ✅ Monitor Redis connection in production
2. ✅ Set up Redis persistence (AOF or RDB)
3. ✅ Configure Redis maxmemory policy (allkeys-lru recommended)
4. ✅ Add Redis health checks to monitoring
### Future Optimizations
1. **Implement cache warming** - Preload frequently accessed data on startup
2. **Add cache metrics** - Track hit/miss rates, TTL effectiveness
3. **Optimize TTL values** - Fine-tune based on production usage patterns
4. **Add more endpoints** - Extend caching to user routes, question routes
5. **Implement cache tags** - Better cache invalidation granularity
6. **Add cache compression** - Reduce memory usage for large payloads
### Monitoring Recommendations
1. Track cache hit/miss ratios per endpoint
2. Monitor Redis memory usage
3. Set up alerts for Redis disconnections
4. Log slow queries (> 100ms) for further optimization
5. Benchmark production performance regularly
---
## Success Metrics
### Performance Targets
- ✅ All endpoints < 50ms (Target: < 100ms) - **EXCEEDED**
- ✅ Overall average < 20ms (Target: < 50ms) - **ACHIEVED (14.70ms)**
- ✅ Cache improvement > 10% (Target: > 10%) - **ACHIEVED (12.5%)**
### Infrastructure Targets
- ✅ Redis caching implemented
- ✅ Comprehensive database indexing verified
- ✅ N+1 queries prevented
- ✅ Automatic cache invalidation working
- ✅ Performance benchmarks documented
### Code Quality Targets
- ✅ 12 specialized cache middlewares
- ✅ Pattern-based cache invalidation
- ✅ Graceful degradation without Redis
- ✅ Comprehensive error handling
- ✅ Production-ready configuration
---
## Conclusion
**Task 45 (Database Optimization) successfully completed with outstanding results:**
- 🚀 **Performance**: All endpoints respond in under 50ms (average 14.70ms)
- 💾 **Caching**: Redis infrastructure with 12 specialized middlewares
- 📊 **Indexing**: 14+ indexes verified across critical tables
-**N+1 Prevention**: Eager loading throughout codebase
- 🔄 **Auto-Invalidation**: Cache clears automatically on mutations
- 🎯 **Production Ready**: Graceful fallback, error handling, monitoring support
The application is now highly optimized for performance with a comprehensive caching layer that significantly improves response times while maintaining data consistency through automatic cache invalidation.
**Overall Rating: ⚡ EXCELLENT**
---
*Generated: November 2024*
*Task 45: Database Optimization*
*Status: ✅ Completed*

View File

@@ -0,0 +1,367 @@
# Questions API Implementation Summary
## Overview
Added comprehensive admin questions management endpoint with pagination, search, and filtering capabilities, along with extensive test coverage.
## What Was Implemented
### 1. New Controller Method: `getAllQuestions`
**File:** `backend/controllers/question.controller.js`
**Features:**
- ✅ Pagination (page, limit with max 100)
- ✅ Search across question text, explanation, and tags
- ✅ Filter by category (UUID)
- ✅ Filter by difficulty (easy/medium/hard)
- ✅ Sorting by multiple fields (createdAt, updatedAt, questionText, difficulty, points, timesAttempted)
- ✅ Sort order (ASC/DESC)
- ✅ Includes full question details with correctAnswer for admin
- ✅ Calculated accuracy percentage
- ✅ Complete category information
- ✅ Comprehensive metadata (total count, pages, filters applied)
**Query Parameters:**
```javascript
{
page: 1, // Default: 1, Min: 1
limit: 10, // Default: 10, Min: 1, Max: 100
search: '', // Search term for text/explanation/tags
category: '', // Category UUID
difficulty: '', // easy | medium | hard
sortBy: 'createdAt', // Field to sort by
order: 'DESC' // ASC | DESC
}
```
**Response Structure:**
```javascript
{
success: true,
count: 10, // Number of questions in current page
total: 45, // Total questions matching filters
page: 1, // Current page
totalPages: 5, // Total pages available
limit: 10, // Page size
filters: { // Applied filters
search: 'javascript',
category: 'uuid',
difficulty: 'easy',
sortBy: 'createdAt',
order: 'DESC'
},
data: [...], // Array of questions with full details
message: 'Retrieved 10 of 45 questions'
}
```
### 2. New Route
**File:** `backend/routes/admin.routes.js`
**Route Added:**
```javascript
GET /api/admin/questions
```
**Authentication:** Admin only (verifyToken + isAdmin middleware)
**Position:** Added before POST route to avoid route conflicts
### 3. Comprehensive Test Suite
**File:** `backend/test-admin-questions-pagination.js`
**Test Coverage (35 tests):**
#### Authorization Tests (3 tests)
- ✅ Guest cannot access admin endpoint
- ✅ Regular user cannot access admin endpoint
- ✅ Admin can access endpoint
#### Pagination Tests (8 tests)
- ✅ Default pagination (page 1, limit 10)
- ✅ Custom pagination (page 2, limit 5)
- ✅ Pagination metadata accuracy
- ✅ Maximum limit enforcement (100)
- ✅ Invalid page defaults to 1
- ✅ Page beyond total returns empty array
- ✅ Pagination calculations correct
- ✅ Offset calculations work properly
#### Search Tests (4 tests)
- ✅ Search by question text
- ✅ Search by explanation text
- ✅ Search with no results
- ✅ Search with special characters
#### Filter Tests (6 tests)
- ✅ Filter by difficulty (easy)
- ✅ Filter by difficulty (medium)
- ✅ Filter by difficulty (hard)
- ✅ Filter by category (JavaScript)
- ✅ Filter by category (Node.js)
- ✅ Invalid category UUID handled
#### Combined Filter Tests (4 tests)
- ✅ Search + difficulty filter
- ✅ Search + category filter
- ✅ Category + difficulty filter
- ✅ All filters combined
#### Sorting Tests (5 tests)
- ✅ Sort by createdAt DESC (default)
- ✅ Sort by createdAt ASC
- ✅ Sort by difficulty
- ✅ Sort by points DESC
- ✅ Invalid sort field defaults to createdAt
#### Response Structure Tests (4 tests)
- ✅ Response has correct structure
- ✅ Questions have required fields
- ✅ Category object has required fields
- ✅ Filters reflected in response
#### Edge Cases (3 tests)
- ✅ Empty search string returns all
- ✅ Admin sees correctAnswer field
- ✅ Accuracy calculation correct
**Test Setup:**
- Creates 8 test questions with varying:
- Difficulties (easy, medium, hard)
- Categories (JavaScript, Node.js)
- Types (multiple, written, trueFalse)
- Tags and keywords
- Automatic cleanup after tests
**Run Tests:**
```bash
node test-admin-questions-pagination.js
```
### 4. API Documentation
**File:** `backend/ADMIN_QUESTIONS_API.md`
**Contents:**
- ✅ Complete endpoint documentation
- ✅ Query parameter descriptions with validation rules
- ✅ Response structure with examples
- ✅ Usage examples (cURL and JavaScript/Axios)
- ✅ Error response formats
- ✅ Feature descriptions
- ✅ Performance considerations
- ✅ Related endpoints
- ✅ Testing instructions
## Comparison with Category Controller
Similar to `category.controller.js`, the implementation includes:
### Shared Features
| Feature | Categories | Questions |
|---------|-----------|-----------|
| Pagination | ✅ | ✅ |
| Search | ✅ | ✅ |
| Filtering | ✅ (by status) | ✅ (by category, difficulty) |
| Sorting | ✅ | ✅ |
| Guest/Auth handling | ✅ | ✅ |
| UUID validation | ✅ | ✅ |
| Metadata in response | ✅ | ✅ |
### Questions-Specific Features
- ✅ Multiple filter types (category + difficulty)
- ✅ Search across multiple fields (text, explanation, tags)
- ✅ Calculated accuracy field
- ✅ Admin-only correctAnswer inclusion
- ✅ More sorting options (6 fields vs 2)
- ✅ Question type handling
- ✅ Options array for multiple choice
## Files Modified/Created
### Modified Files
1. `backend/controllers/question.controller.js`
- Added `getAllQuestions` method (130 lines)
- Placed before existing methods
2. `backend/routes/admin.routes.js`
- Added GET route for `/api/admin/questions`
- Positioned before POST to avoid conflicts
### Created Files
1. `backend/test-admin-questions-pagination.js`
- 35 comprehensive test cases
- 750+ lines
- Automated setup and cleanup
2. `backend/ADMIN_QUESTIONS_API.md`
- Complete API documentation
- Usage examples
- Performance notes
## API Endpoints Summary
### All Question Endpoints
| Method | Endpoint | Access | Purpose |
|--------|----------|--------|---------|
| GET | `/api/admin/questions` | Admin | **NEW:** Get all questions with pagination/search |
| POST | `/api/admin/questions` | Admin | Create new question |
| PUT | `/api/admin/questions/:id` | Admin | Update question |
| DELETE | `/api/admin/questions/:id` | Admin | Soft delete question |
| GET | `/api/questions/category/:categoryId` | Public | Get questions by category |
| GET | `/api/questions/search` | Public | Search questions (guest-filtered) |
| GET | `/api/questions/:id` | Public | Get single question |
## Testing Instructions
### 1. Start Backend Server
```bash
cd backend
npm start
```
### 2. Run Test Suite
```bash
node test-admin-questions-pagination.js
```
### Expected Output
```
========================================
Testing Admin Questions Pagination & Search API
========================================
Setting up test data...
✓ Logged in as admin
✓ Created and logged in as regular user
✓ Started guest session
✓ Created 8 test questions
--- Authorization Tests ---
✓ Test 1: Guest cannot access admin questions endpoint - PASSED
✓ Test 2: Regular user cannot access admin questions endpoint - PASSED
✓ Test 3: Admin can access questions endpoint - PASSED
[... 32 more tests ...]
========================================
Test Summary
========================================
Total Tests: 35
Passed: 35 ✓
Failed: 0 ✗
Success Rate: 100.00%
========================================
```
### 3. Manual Testing Examples
#### Get First Page
```bash
curl "http://localhost:3000/api/admin/questions?page=1&limit=10" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
#### Search Questions
```bash
curl "http://localhost:3000/api/admin/questions?search=javascript" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
#### Filter by Difficulty
```bash
curl "http://localhost:3000/api/admin/questions?difficulty=medium" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
#### Combined Filters
```bash
curl "http://localhost:3000/api/admin/questions?search=async&difficulty=medium&sortBy=points&order=DESC&limit=20" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
## Benefits
### For Admin Dashboard
- ✅ Efficient question browsing with pagination
- ✅ Quick search across question content
- ✅ Filter by category for focused management
- ✅ Filter by difficulty for balanced question sets
- ✅ Flexible sorting for different views
- ✅ See correctAnswer for verification
- ✅ View question statistics (attempts, accuracy)
### For Frontend Development
- ✅ Easy integration with Angular Material paginator
- ✅ Real-time search capability
- ✅ Filter chips/dropdowns support
- ✅ Sort headers for data tables
- ✅ Complete metadata for UI state
- ✅ Predictable response structure
### For Performance
- ✅ Limit enforcement (max 100)
- ✅ Offset-based pagination
- ✅ Indexed queries (categoryId, difficulty, isActive)
- ✅ Efficient count queries
- ✅ No N+1 query issues (includes handled)
## Code Quality
### Best Practices Implemented
- ✅ Input validation and sanitization
- ✅ Error handling with appropriate status codes
- ✅ Consistent response format
- ✅ Database query optimization
- ✅ SQL injection prevention (parameterized queries)
- ✅ Authorization checks
- ✅ Comprehensive documentation
- ✅ Extensive test coverage
- ✅ Edge case handling
### Security Features
- ✅ Admin-only access via middleware
- ✅ JWT token verification
- ✅ UUID format validation
- ✅ Input sanitization
- ✅ Safe error messages (no sensitive data leaks)
- ✅ Rate limiting (via adminLimiter)
## Next Steps
### Optional Enhancements
1. **Full-Text Search:** Implement MySQL FULLTEXT indexes for better search performance
2. **Cursor-Based Pagination:** For very large datasets (>10,000 questions)
3. **Export Functionality:** CSV/JSON export with filters applied
4. **Bulk Operations:** Update/delete multiple questions at once
5. **Question Analytics:** More detailed statistics and trends
6. **Advanced Filters:** By tags, keywords, question type, active status
7. **Caching:** Redis cache for frequently accessed pages
### Frontend Integration
1. Create Angular admin questions component
2. Implement Material paginator
3. Add search input with debounce
4. Create filter dropdowns/chips
5. Add sortable table headers
6. Display question statistics
7. Implement edit/delete actions
## Conclusion
Successfully implemented a comprehensive admin questions management endpoint with:
- ✅ Full pagination support
- ✅ Powerful search functionality
- ✅ Multiple filtering options
- ✅ Flexible sorting
- ✅ 35 passing test cases
- ✅ Complete documentation
- ✅ Production-ready code quality
The implementation follows the same patterns as the category controller while adding question-specific features and more advanced filtering capabilities.

239
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

278
TEST_INSTRUCTIONS.md Normal file
View File

@@ -0,0 +1,278 @@
# How to Test the New Admin Questions API
## Quick Start
### 1. Ensure Backend is Running
```bash
cd backend
npm start
```
The server should be running on `http://localhost:3000`
### 2. Run the Test Suite
```bash
node test-admin-questions-pagination.js
```
## What Gets Tested
The test suite automatically:
1. ✅ Logs in as admin user
2. ✅ Creates a regular test user
3. ✅ Starts a guest session
4. ✅ Creates 8 test questions with different properties
5. ✅ Runs 35 comprehensive tests
6. ✅ Cleans up all test data
## Test Categories
### Authorization (3 tests)
- Guest access denial
- Regular user access denial
- Admin access granted
### Pagination (8 tests)
- Default pagination
- Custom page sizes
- Metadata accuracy
- Limit enforcement
- Invalid page handling
### Search (4 tests)
- Search question text
- Search explanations
- No results handling
- Special characters
### Filters (6 tests)
- By difficulty (easy/medium/hard)
- By category
- Invalid UUID handling
### Combined Filters (4 tests)
- Search + difficulty
- Search + category
- Category + difficulty
- All filters together
### Sorting (5 tests)
- By creation date
- By points
- By difficulty
- Invalid sort fields
### Response Structure (4 tests)
- Response format validation
- Required fields check
- Category object structure
- Filter reflection
### Edge Cases (3 tests)
- Empty searches
- Out of range pages
- Accuracy calculations
## Manual Testing
### Get All Questions (First Page)
```bash
curl "http://localhost:3000/api/admin/questions" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
### Search for Questions
```bash
curl "http://localhost:3000/api/admin/questions?search=javascript" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
### Filter by Difficulty
```bash
curl "http://localhost:3000/api/admin/questions?difficulty=medium&limit=20" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
### Filter by Category
```bash
# Replace with actual category UUID from your database
curl "http://localhost:3000/api/admin/questions?category=68b4c87f-db0b-48ea-b8a4-b2f4fce785a2" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
### Paginate Through Questions
```bash
# Page 1
curl "http://localhost:3000/api/admin/questions?page=1&limit=10" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
# Page 2
curl "http://localhost:3000/api/admin/questions?page=2&limit=10" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
### Sort Questions
```bash
# By points (highest first)
curl "http://localhost:3000/api/admin/questions?sortBy=points&order=DESC" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
# By creation date (oldest first)
curl "http://localhost:3000/api/admin/questions?sortBy=createdAt&order=ASC" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
### Complex Query
```bash
curl "http://localhost:3000/api/admin/questions?search=async&difficulty=medium&sortBy=points&order=DESC&page=1&limit=15" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
## Get Admin Token
### Option 1: From Test Output
Run any test file that logs in as admin and look for the token in console.
### Option 2: Login Manually
```bash
curl -X POST "http://localhost:3000/api/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "admin@quiz.com",
"password": "Admin@123"
}'
```
The token will be in `response.data.data.token`
## Expected Test Output
```
========================================
Testing Admin Questions Pagination & Search API
========================================
Setting up test data...
✓ Logged in as admin
✓ Created and logged in as regular user
✓ Started guest session
✓ Created 8 test questions
--- Authorization Tests ---
✓ Test 1: Guest cannot access admin questions endpoint - PASSED
✓ Test 2: Regular user cannot access admin questions endpoint - PASSED
✓ Test 3: Admin can access questions endpoint - PASSED
--- Pagination Tests ---
✓ Test 4: Default pagination (page 1, limit 10) - PASSED
✓ Test 5: Custom pagination (page 2, limit 5) - PASSED
✓ Test 6: Pagination metadata is correct - PASSED
✓ Test 7: Maximum limit enforcement (max 100) - PASSED
✓ Test 8: Invalid page defaults to 1 - PASSED
--- Search Tests ---
✓ Test 9: Search by question text (async) - PASSED
✓ Test 10: Search by explanation text (promise) - PASSED
✓ Test 11: Search with no results - PASSED
✓ Test 12: Search with special characters is handled - PASSED
--- Filter Tests ---
✓ Test 13: Filter by difficulty (easy) - PASSED
✓ Test 14: Filter by difficulty (medium) - PASSED
✓ Test 15: Filter by difficulty (hard) - PASSED
✓ Test 16: Filter by category (JavaScript) - PASSED
✓ Test 17: Filter by category (Node.js) - PASSED
✓ Test 18: Invalid category UUID is ignored - PASSED
--- Combined Filter Tests ---
✓ Test 19: Search + difficulty filter - PASSED
✓ Test 20: Search + category filter - PASSED
✓ Test 21: Category + difficulty filter - PASSED
✓ Test 22: All filters combined - PASSED
--- Sorting Tests ---
✓ Test 23: Sort by createdAt DESC (default) - PASSED
✓ Test 24: Sort by createdAt ASC - PASSED
✓ Test 25: Sort by difficulty - PASSED
✓ Test 26: Sort by points DESC - PASSED
✓ Test 27: Invalid sort field defaults to createdAt - PASSED
--- Response Structure Tests ---
✓ Test 28: Response has correct structure - PASSED
✓ Test 29: Each question has required fields - PASSED
✓ Test 30: Category object has required fields - PASSED
✓ Test 31: Filters object in response matches query - PASSED
✓ Test 32: Admin can see correctAnswer field - PASSED
--- Performance & Edge Cases ---
✓ Test 33: Empty search string returns all questions - PASSED
✓ Test 34: Page beyond total pages returns empty array - PASSED
✓ Test 35: Accuracy is calculated correctly - PASSED
========================================
Cleaning up test data...
========================================
✓ Deleted 8 test questions
========================================
Test Summary
========================================
Total Tests: 35
Passed: 35 ✓
Failed: 0 ✗
Success Rate: 100.00%
========================================
```
## Troubleshooting
### "Setup failed: Network Error"
- Ensure backend server is running on port 3000
- Check if database connection is working
### "Admin login failed"
- Verify admin user exists in database
- Check credentials: email: `admin@quiz.com`, password: `Admin@123`
### "Category not found"
- Run seeders to populate categories
- Check CATEGORY_IDS in test file match your database
### Tests fail with 500 errors
- Check backend logs for detailed error messages
- Ensure all required models are properly defined
- Verify database schema is up to date
## Documentation
- **API Documentation:** See `ADMIN_QUESTIONS_API.md`
- **Implementation Summary:** See `QUESTIONS_API_IMPLEMENTATION_SUMMARY.md`
- **Controller Code:** See `controllers/question.controller.js` - `getAllQuestions` method
- **Route Definition:** See `routes/admin.routes.js`
## Related Test Files
- `test-create-question.js` - Test question creation
- `test-update-delete-question.js` - Test updates and deletions
- `test-questions-by-category.js` - Test public category endpoint
- `test-question-search.js` - Test public search endpoint
- `test-question-by-id.js` - Test single question retrieval
## Next Steps
After successful testing:
1. ✅ Review the API documentation
2. ✅ Integrate with frontend admin dashboard
3. ✅ Implement Angular Material paginator
4. ✅ Add search and filter UI components
5. ✅ Create question management interface

316
__tests__/auth.test.js Normal file
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);
});
});

113
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;

76
config/database.js Normal file
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
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
};

148
config/logger.js Normal file
View File

@@ -0,0 +1,148 @@
const winston = require('winston');
const DailyRotateFile = require('winston-daily-rotate-file');
const path = require('path');
// Define log format
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
);
// Console format for development
const consoleFormat = winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message, stack }) => {
return stack
? `${timestamp} [${level}]: ${message}\n${stack}`
: `${timestamp} [${level}]: ${message}`;
})
);
// Create logs directory if it doesn't exist
const fs = require('fs');
const logsDir = path.join(__dirname, '../logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir);
}
// Daily rotate file transport for error logs
const errorRotateTransport = new DailyRotateFile({
filename: path.join(logsDir, 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'error',
maxSize: '20m',
maxFiles: '14d',
format: logFormat
});
// Daily rotate file transport for combined logs
const combinedRotateTransport = new DailyRotateFile({
filename: path.join(logsDir, 'combined-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '30d',
format: logFormat
});
// Daily rotate file transport for HTTP logs
const httpRotateTransport = new DailyRotateFile({
filename: path.join(logsDir, 'http-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '7d',
format: logFormat
});
// Create the Winston logger
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: logFormat,
defaultMeta: { service: 'interview-quiz-api' },
transports: [
errorRotateTransport,
combinedRotateTransport
],
exceptionHandlers: [
new winston.transports.File({
filename: path.join(logsDir, 'exceptions.log')
})
],
rejectionHandlers: [
new winston.transports.File({
filename: path.join(logsDir, 'rejections.log')
})
]
});
// Add console transport in development
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: consoleFormat
}));
}
// HTTP logger for request logging
const httpLogger = winston.createLogger({
level: 'http',
format: logFormat,
defaultMeta: { service: 'interview-quiz-api' },
transports: [httpRotateTransport]
});
// Stream for Morgan middleware
logger.stream = {
write: (message) => {
httpLogger.http(message.trim());
}
};
// Helper functions for structured logging
logger.logRequest = (req, message) => {
logger.info(message, {
method: req.method,
url: req.originalUrl,
ip: req.ip,
userId: req.user?.id,
userAgent: req.get('user-agent')
});
};
logger.logError = (error, req = null) => {
const errorLog = {
message: error.message,
stack: error.stack,
statusCode: error.statusCode || 500
};
if (req) {
errorLog.method = req.method;
errorLog.url = req.originalUrl;
errorLog.ip = req.ip;
errorLog.userId = req.user?.id;
errorLog.body = req.body;
}
logger.error('Application Error', errorLog);
};
logger.logDatabaseQuery = (query, duration) => {
logger.debug('Database Query', {
query,
duration: `${duration}ms`
});
};
logger.logSecurityEvent = (event, req) => {
logger.warn('Security Event', {
event,
method: req.method,
url: req.originalUrl,
ip: req.ip,
userAgent: req.get('user-agent')
});
};
module.exports = logger;

318
config/redis.js Normal file
View File

@@ -0,0 +1,318 @@
const Redis = require('ioredis');
const logger = require('./logger');
/**
* Redis Connection Configuration
* Supports both single instance and cluster modes
*/
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB) || 0,
retryStrategy: (times) => {
// Stop retrying after 3 attempts in development
if (process.env.NODE_ENV === 'development' && times > 3) {
logger.info('Redis unavailable - caching disabled (optional feature)');
return null; // Stop retrying
}
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
enableReadyCheck: true,
enableOfflineQueue: true,
lazyConnect: true, // Don't connect immediately
connectTimeout: 5000, // Reduced timeout
keepAlive: 30000,
family: 4, // IPv4
// Connection pool settings
minReconnectInterval: 100,
maxReconnectInterval: 3000,
// Reduce logging noise
showFriendlyErrorStack: process.env.NODE_ENV !== 'development'
};
// Create Redis client
let redisClient = null;
let isConnected = false;
try {
redisClient = new Redis(redisConfig);
// Attempt initial connection
redisClient.connect().catch(() => {
// Silently fail if Redis is not available in development
if (process.env.NODE_ENV === 'development') {
logger.info('Redis not available - continuing without cache (optional)');
}
});
// Connection events
redisClient.on('connect', () => {
logger.info('Redis client connecting...');
});
redisClient.on('ready', () => {
isConnected = true;
logger.info('Redis client connected and ready');
});
redisClient.on('error', (err) => {
isConnected = false;
// Only log errors in production or first error
if (process.env.NODE_ENV === 'production' || !errorLogged) {
logger.error('Redis client error:', err.message || err);
errorLogged = true;
}
});
redisClient.on('close', () => {
if (isConnected) {
isConnected = false;
logger.warn('Redis client connection closed');
}
});
redisClient.on('reconnecting', () => {
// Only log once
if (isConnected === false) {
logger.info('Redis client reconnecting...');
}
});
redisClient.on('end', () => {
if (isConnected) {
isConnected = false;
logger.info('Redis connection ended');
}
});
} catch (error) {
logger.error('Failed to create Redis client:', error);
}
// Track if error has been logged
let errorLogged = false;
/**
* Check if Redis is connected
*/
const isRedisConnected = () => {
return isConnected && redisClient && redisClient.status === 'ready';
};
/**
* Get Redis client
*/
const getRedisClient = () => {
if (!isRedisConnected()) {
logger.warn('Redis client not connected');
return null;
}
return redisClient;
};
/**
* Close Redis connection gracefully
*/
const closeRedis = async () => {
if (redisClient) {
await redisClient.quit();
logger.info('Redis connection closed');
}
};
/**
* Cache helper functions
*/
/**
* Get cached data
* @param {string} key - Cache key
* @returns {Promise<any>} - Parsed JSON data or null
*/
const getCache = async (key) => {
try {
if (!isRedisConnected()) {
logger.warn('Redis not connected, cache miss');
return null;
}
const data = await redisClient.get(key);
if (!data) return null;
logger.debug(`Cache hit: ${key}`);
return JSON.parse(data);
} catch (error) {
logger.error(`Cache get error for key ${key}:`, error);
return null;
}
};
/**
* Set cached data
* @param {string} key - Cache key
* @param {any} value - Data to cache
* @param {number} ttl - Time to live in seconds (default: 300 = 5 minutes)
* @returns {Promise<boolean>} - Success status
*/
const setCache = async (key, value, ttl = 300) => {
try {
if (!isRedisConnected()) {
logger.warn('Redis not connected, skipping cache set');
return false;
}
const serialized = JSON.stringify(value);
await redisClient.setex(key, ttl, serialized);
logger.debug(`Cache set: ${key} (TTL: ${ttl}s)`);
return true;
} catch (error) {
logger.error(`Cache set error for key ${key}:`, error);
return false;
}
};
/**
* Delete cached data
* @param {string} key - Cache key or pattern
* @returns {Promise<boolean>} - Success status
*/
const deleteCache = async (key) => {
try {
if (!isRedisConnected()) {
return false;
}
// Support pattern deletion (e.g., "user:*")
if (key.includes('*')) {
const keys = await redisClient.keys(key);
if (keys.length > 0) {
await redisClient.del(...keys);
logger.debug(`Cache deleted: ${keys.length} keys matching ${key}`);
}
} else {
await redisClient.del(key);
logger.debug(`Cache deleted: ${key}`);
}
return true;
} catch (error) {
logger.error(`Cache delete error for key ${key}:`, error);
return false;
}
};
/**
* Clear all cache
* @returns {Promise<boolean>} - Success status
*/
const clearCache = async () => {
try {
if (!isRedisConnected()) {
return false;
}
await redisClient.flushdb();
logger.info('All cache cleared');
return true;
} catch (error) {
logger.error('Cache clear error:', error);
return false;
}
};
/**
* Get multiple keys at once
* @param {string[]} keys - Array of cache keys
* @returns {Promise<object>} - Object with key-value pairs
*/
const getCacheMultiple = async (keys) => {
try {
if (!isRedisConnected() || !keys || keys.length === 0) {
return {};
}
const values = await redisClient.mget(...keys);
const result = {};
keys.forEach((key, index) => {
if (values[index]) {
try {
result[key] = JSON.parse(values[index]);
} catch (err) {
result[key] = null;
}
} else {
result[key] = null;
}
});
return result;
} catch (error) {
logger.error('Cache mget error:', error);
return {};
}
};
/**
* Increment a counter
* @param {string} key - Cache key
* @param {number} increment - Amount to increment (default: 1)
* @param {number} ttl - Time to live in seconds (optional)
* @returns {Promise<number>} - New value
*/
const incrementCache = async (key, increment = 1, ttl = null) => {
try {
if (!isRedisConnected()) {
return 0;
}
const newValue = await redisClient.incrby(key, increment);
if (ttl) {
await redisClient.expire(key, ttl);
}
return newValue;
} catch (error) {
logger.error(`Cache increment error for key ${key}:`, error);
return 0;
}
};
/**
* Check if key exists
* @param {string} key - Cache key
* @returns {Promise<boolean>} - Exists status
*/
const cacheExists = async (key) => {
try {
if (!isRedisConnected()) {
return false;
}
const exists = await redisClient.exists(key);
return exists === 1;
} catch (error) {
logger.error(`Cache exists error for key ${key}:`, error);
return false;
}
};
module.exports = {
redisClient,
isRedisConnected,
getRedisClient,
closeRedis,
getCache,
setCache,
deleteCache,
clearCache,
getCacheMultiple,
incrementCache,
cacheExists
};

348
config/swagger.js Normal file
View File

@@ -0,0 +1,348 @@
const swaggerJsdoc = require('swagger-jsdoc');
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'Interview Quiz Application API',
version: '1.0.0',
description: 'Comprehensive API documentation for the Interview Quiz Application. This API provides endpoints for user authentication, quiz management, guest sessions, bookmarks, and admin operations.',
contact: {
name: 'API Support',
email: 'support@interviewquiz.com'
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
},
servers: [
{
url: 'http://localhost:3000/api',
description: 'Development server'
},
{
url: 'https://api.interviewquiz.com/api',
description: 'Production server'
}
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Enter your JWT token in the format: Bearer {token}'
}
},
schemas: {
Error: {
type: 'object',
properties: {
message: {
type: 'string',
description: 'Error message'
},
error: {
type: 'string',
description: 'Detailed error information'
}
}
},
User: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'User ID'
},
username: {
type: 'string',
description: 'Unique username'
},
email: {
type: 'string',
format: 'email',
description: 'User email address'
},
role: {
type: 'string',
enum: ['user', 'admin'],
description: 'User role'
},
isActive: {
type: 'boolean',
description: 'Account activation status'
},
createdAt: {
type: 'string',
format: 'date-time',
description: 'Account creation timestamp'
},
updatedAt: {
type: 'string',
format: 'date-time',
description: 'Last update timestamp'
}
}
},
Category: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'Category ID'
},
name: {
type: 'string',
description: 'Category name'
},
description: {
type: 'string',
description: 'Category description'
},
questionCount: {
type: 'integer',
description: 'Number of questions in category'
},
createdAt: {
type: 'string',
format: 'date-time'
}
}
},
Question: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'Question ID'
},
categoryId: {
type: 'integer',
description: 'Associated category ID'
},
questionText: {
type: 'string',
description: 'Question content'
},
difficulty: {
type: 'string',
enum: ['easy', 'medium', 'hard'],
description: 'Question difficulty level'
},
options: {
type: 'array',
items: {
type: 'string'
},
description: 'Answer options'
},
correctAnswer: {
type: 'string',
description: 'Correct answer (admin only)'
}
}
},
QuizSession: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'Quiz session ID'
},
userId: {
type: 'integer',
description: 'User ID (null for guest)'
},
guestSessionId: {
type: 'string',
format: 'uuid',
description: 'Guest session ID (null for authenticated user)'
},
categoryId: {
type: 'integer',
description: 'Quiz category ID'
},
totalQuestions: {
type: 'integer',
description: 'Total questions in quiz'
},
currentQuestionIndex: {
type: 'integer',
description: 'Current question position'
},
score: {
type: 'integer',
description: 'Current score'
},
isCompleted: {
type: 'boolean',
description: 'Quiz completion status'
},
completedAt: {
type: 'string',
format: 'date-time',
description: 'Completion timestamp'
},
startedAt: {
type: 'string',
format: 'date-time',
description: 'Start timestamp'
}
}
},
Bookmark: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'Bookmark ID'
},
userId: {
type: 'integer',
description: 'User ID'
},
questionId: {
type: 'integer',
description: 'Bookmarked question ID'
},
notes: {
type: 'string',
description: 'Optional user notes'
},
createdAt: {
type: 'string',
format: 'date-time'
},
Question: {
$ref: '#/components/schemas/Question'
}
}
},
GuestSession: {
type: 'object',
properties: {
guestSessionId: {
type: 'string',
format: 'uuid',
description: 'Unique guest session identifier'
},
convertedUserId: {
type: 'integer',
description: 'User ID after conversion (null if not converted)'
},
expiresAt: {
type: 'string',
format: 'date-time',
description: 'Session expiration timestamp'
},
createdAt: {
type: 'string',
format: 'date-time'
}
}
}
},
responses: {
UnauthorizedError: {
description: 'Authentication token is missing or invalid',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
example: {
message: 'No token provided'
}
}
}
},
ForbiddenError: {
description: 'User does not have permission to access this resource',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
example: {
message: 'Access denied. Admin only.'
}
}
}
},
NotFoundError: {
description: 'The requested resource was not found',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
example: {
message: 'Resource not found'
}
}
}
},
ValidationError: {
description: 'Request validation failed',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
example: {
message: 'Validation error',
error: 'Invalid input data'
}
}
}
}
}
},
security: [
{
bearerAuth: []
}
],
tags: [
{
name: 'Authentication',
description: 'User authentication and authorization endpoints'
},
{
name: 'Users',
description: 'User profile and account management'
},
{
name: 'Categories',
description: 'Quiz category management'
},
{
name: 'Questions',
description: 'Question management and retrieval'
},
{
name: 'Quiz',
description: 'Quiz session lifecycle and answer submission'
},
{
name: 'Bookmarks',
description: 'User question bookmarks'
},
{
name: 'Guest',
description: 'Guest user session management'
},
{
name: 'Admin',
description: 'Administrative operations (admin only)'
}
]
},
apis: ['./routes/*.js'] // Path to the API routes
};
const swaggerSpec = swaggerJsdoc(options);
module.exports = swaggerSpec;

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

29
jest.config.js Normal file
View File

@@ -0,0 +1,29 @@
module.exports = {
testEnvironment: 'node',
coverageDirectory: 'coverage',
collectCoverageFrom: [
'controllers/**/*.js',
'middleware/**/*.js',
'models/**/*.js',
'routes/**/*.js',
'!models/index.js',
'!**/node_modules/**'
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70
}
},
testMatch: [
'**/tests/**/*.test.js',
'**/__tests__/**/*.js'
],
verbose: true,
forceExit: true,
clearMocks: true,
resetMocks: true,
restoreMocks: true
};

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();
}
};

267
middleware/cache.js Normal file
View File

@@ -0,0 +1,267 @@
const { getCache, setCache, deleteCache } = require('../config/redis');
const logger = require('../config/logger');
/**
* Cache middleware for GET requests
* @param {number} ttl - Time to live in seconds (default: 300 = 5 minutes)
* @param {function} keyGenerator - Function to generate cache key from req
*/
const cacheMiddleware = (ttl = 300, keyGenerator = null) => {
return async (req, res, next) => {
// Only cache GET requests
if (req.method !== 'GET') {
return next();
}
try {
// Generate cache key
const cacheKey = keyGenerator
? keyGenerator(req)
: `cache:${req.originalUrl}`;
// Try to get from cache
const cachedData = await getCache(cacheKey);
if (cachedData) {
logger.debug(`Cache hit for: ${cacheKey}`);
return res.status(200).json(cachedData);
}
// Cache miss - store original json method
const originalJson = res.json.bind(res);
// Override json method to cache response
res.json = function(data) {
// Only cache successful responses
if (res.statusCode === 200) {
setCache(cacheKey, data, ttl).catch(err => {
logger.error('Failed to cache response:', err);
});
}
// Call original json method
return originalJson(data);
};
next();
} catch (error) {
logger.error('Cache middleware error:', error);
next(); // Continue even if cache fails
}
};
};
/**
* Cache categories (rarely change)
*/
const cacheCategories = cacheMiddleware(3600, (req) => {
// Cache for 1 hour
return 'cache:categories:list';
});
/**
* Cache single category
*/
const cacheSingleCategory = cacheMiddleware(3600, (req) => {
return `cache:category:${req.params.id}`;
});
/**
* Cache guest settings (rarely change)
*/
const cacheGuestSettings = cacheMiddleware(1800, (req) => {
// Cache for 30 minutes
return 'cache:guest:settings';
});
/**
* Cache system statistics (update frequently)
*/
const cacheStatistics = cacheMiddleware(300, (req) => {
// Cache for 5 minutes
return 'cache:admin:statistics';
});
/**
* Cache guest analytics
*/
const cacheGuestAnalytics = cacheMiddleware(600, (req) => {
// Cache for 10 minutes
return 'cache:admin:guest-analytics';
});
/**
* Cache user dashboard
*/
const cacheUserDashboard = cacheMiddleware(300, (req) => {
// Cache for 5 minutes
const userId = req.params.userId || req.user?.id;
return `cache:user:${userId}:dashboard`;
});
/**
* Cache questions list (with filters)
*/
const cacheQuestions = cacheMiddleware(600, (req) => {
// Cache for 10 minutes
const { categoryId, difficulty, questionType, visibility } = req.query;
const filters = [categoryId, difficulty, questionType, visibility]
.filter(Boolean)
.join(':');
return `cache:questions:${filters || 'all'}`;
});
/**
* Cache single question
*/
const cacheSingleQuestion = cacheMiddleware(1800, (req) => {
return `cache:question:${req.params.id}`;
});
/**
* Cache user bookmarks
*/
const cacheUserBookmarks = cacheMiddleware(300, (req) => {
const userId = req.params.userId || req.user?.id;
return `cache:user:${userId}:bookmarks`;
});
/**
* Cache user history
*/
const cacheUserHistory = cacheMiddleware(300, (req) => {
const userId = req.params.userId || req.user?.id;
const page = req.query.page || 1;
return `cache:user:${userId}:history:page:${page}`;
});
/**
* Invalidate cache patterns
*/
const invalidateCache = {
/**
* Invalidate user-related cache
*/
user: async (userId) => {
await deleteCache(`cache:user:${userId}:*`);
logger.debug(`Invalidated cache for user ${userId}`);
},
/**
* Invalidate category cache
*/
category: async (categoryId = null) => {
if (categoryId) {
await deleteCache(`cache:category:${categoryId}`);
}
await deleteCache('cache:categories:*');
logger.debug('Invalidated category cache');
},
/**
* Invalidate question cache
*/
question: async (questionId = null) => {
if (questionId) {
await deleteCache(`cache:question:${questionId}`);
}
await deleteCache('cache:questions:*');
logger.debug('Invalidated question cache');
},
/**
* Invalidate statistics cache
*/
statistics: async () => {
await deleteCache('cache:admin:statistics');
await deleteCache('cache:admin:guest-analytics');
logger.debug('Invalidated statistics cache');
},
/**
* Invalidate guest settings cache
*/
guestSettings: async () => {
await deleteCache('cache:guest:settings');
logger.debug('Invalidated guest settings cache');
},
/**
* Invalidate all quiz-related cache
*/
quiz: async (userId = null, guestId = null) => {
if (userId) {
await deleteCache(`cache:user:${userId}:*`);
}
if (guestId) {
await deleteCache(`cache:guest:${guestId}:*`);
}
await invalidateCache.statistics();
logger.debug('Invalidated quiz cache');
}
};
/**
* Middleware to invalidate cache after mutations
*/
const invalidateCacheMiddleware = (pattern) => {
return async (req, res, next) => {
// Store original json method
const originalJson = res.json.bind(res);
// Override json method
res.json = async function(data) {
// Only invalidate on successful mutations
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
if (typeof pattern === 'function') {
await pattern(req);
} else {
await deleteCache(pattern);
}
} catch (error) {
logger.error('Cache invalidation error:', error);
}
}
// Call original json method
return originalJson(data);
};
next();
};
};
/**
* Cache warming - preload frequently accessed data
*/
const warmCache = async () => {
try {
logger.info('Warming cache...');
// This would typically fetch and cache common data
// For now, we'll just log the intent
// In a real scenario, you'd fetch categories, popular questions, etc.
logger.info('Cache warming complete');
} catch (error) {
logger.error('Cache warming error:', error);
}
};
module.exports = {
cacheMiddleware,
cacheCategories,
cacheSingleCategory,
cacheGuestSettings,
cacheStatistics,
cacheGuestAnalytics,
cacheUserDashboard,
cacheQuestions,
cacheSingleQuestion,
cacheUserBookmarks,
cacheUserHistory,
invalidateCache,
invalidateCacheMiddleware,
warmCache
};

248
middleware/errorHandler.js Normal file
View File

@@ -0,0 +1,248 @@
const logger = require('../config/logger');
const { AppError } = require('../utils/AppError');
/**
* Handle Sequelize validation errors
*/
const handleSequelizeValidationError = (error) => {
const errors = error.errors.map(err => ({
field: err.path,
message: err.message,
value: err.value
}));
return {
statusCode: 400,
message: 'Validation error',
errors
};
};
/**
* Handle Sequelize unique constraint errors
*/
const handleSequelizeUniqueConstraintError = (error) => {
const field = error.errors[0]?.path;
const value = error.errors[0]?.value;
return {
statusCode: 409,
message: `${field} '${value}' already exists`
};
};
/**
* Handle Sequelize foreign key constraint errors
*/
const handleSequelizeForeignKeyConstraintError = (error) => {
return {
statusCode: 400,
message: 'Invalid reference to related resource'
};
};
/**
* Handle Sequelize database connection errors
*/
const handleSequelizeConnectionError = (error) => {
return {
statusCode: 503,
message: 'Database connection error. Please try again later.'
};
};
/**
* Handle JWT errors
*/
const handleJWTError = () => {
return {
statusCode: 401,
message: 'Invalid token. Please log in again.'
};
};
/**
* Handle JWT expired errors
*/
const handleJWTExpiredError = () => {
return {
statusCode: 401,
message: 'Your token has expired. Please log in again.'
};
};
/**
* Handle Sequelize errors
*/
const handleSequelizeError = (error) => {
// Validation error
if (error.name === 'SequelizeValidationError') {
return handleSequelizeValidationError(error);
}
// Unique constraint violation
if (error.name === 'SequelizeUniqueConstraintError') {
return handleSequelizeUniqueConstraintError(error);
}
// Foreign key constraint violation
if (error.name === 'SequelizeForeignKeyConstraintError') {
return handleSequelizeForeignKeyConstraintError(error);
}
// Database connection error
if (error.name === 'SequelizeConnectionError' ||
error.name === 'SequelizeConnectionRefusedError' ||
error.name === 'SequelizeHostNotFoundError' ||
error.name === 'SequelizeAccessDeniedError') {
return handleSequelizeConnectionError(error);
}
// Generic database error
return {
statusCode: 500,
message: 'Database error occurred'
};
};
/**
* Send error response in development
*/
const sendErrorDev = (err, res) => {
res.status(err.statusCode).json({
status: err.status,
message: err.message,
error: err,
stack: err.stack,
...(err.errors && { errors: err.errors })
});
};
/**
* Send error response in production
*/
const sendErrorProd = (err, res) => {
// Operational, trusted error: send message to client
if (err.isOperational) {
res.status(err.statusCode).json({
status: err.status,
message: err.message,
...(err.errors && { errors: err.errors })
});
}
// Programming or unknown error: don't leak error details
else {
// Log error for debugging
logger.error('ERROR 💥', err);
// Send generic message
res.status(500).json({
status: 'error',
message: 'Something went wrong. Please try again later.'
});
}
};
/**
* Centralized error handling middleware
*/
const errorHandler = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
// Log the error
logger.logError(err, req);
// Handle specific error types
let error = { ...err };
error.message = err.message;
error.stack = err.stack;
// Sequelize errors
if (err.name && err.name.startsWith('Sequelize')) {
const handled = handleSequelizeError(err);
error.statusCode = handled.statusCode;
error.message = handled.message;
error.isOperational = true;
if (handled.errors) error.errors = handled.errors;
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
const handled = handleJWTError();
error.statusCode = handled.statusCode;
error.message = handled.message;
error.isOperational = true;
}
if (err.name === 'TokenExpiredError') {
const handled = handleJWTExpiredError();
error.statusCode = handled.statusCode;
error.message = handled.message;
error.isOperational = true;
}
// Multer errors (file upload)
if (err.name === 'MulterError') {
error.statusCode = 400;
error.message = `File upload error: ${err.message}`;
error.isOperational = true;
}
// Send error response
if (process.env.NODE_ENV === 'development') {
sendErrorDev(error, res);
} else {
sendErrorProd(error, res);
}
};
/**
* Handle async errors (wrap async route handlers)
*/
const catchAsync = (fn) => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
/**
* Handle 404 Not Found errors
*/
const notFoundHandler = (req, res, next) => {
const error = new AppError(
`Cannot find ${req.originalUrl} on this server`,
404
);
next(error);
};
/**
* Log unhandled rejections
*/
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', {
promise,
reason: reason.stack || reason
});
// Optional: Exit process in production
// process.exit(1);
});
/**
* Log uncaught exceptions
*/
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', {
message: error.message,
stack: error.stack
});
// Exit process on uncaught exception
process.exit(1);
});
module.exports = {
errorHandler,
catchAsync,
notFoundHandler
};

View File

@@ -0,0 +1,84 @@
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; // The guest_id string for display/logging
req.guestSessionId = guestSession.id; // The UUID for database foreign keys
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
});
}
}
};

150
middleware/rateLimiter.js Normal file
View File

@@ -0,0 +1,150 @@
const rateLimit = require('express-rate-limit');
const logger = require('../config/logger');
/**
* Create a custom rate limit handler
*/
const createRateLimitHandler = (req, res) => {
logger.logSecurityEvent('Rate limit exceeded', req);
res.status(429).json({
status: 'error',
message: 'Too many requests from this IP, please try again later.',
retryAfter: res.getHeader('Retry-After')
});
};
/**
* General API rate limiter
* 100 requests per 15 minutes per IP
*/
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again after 15 minutes',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
handler: createRateLimitHandler,
skip: (req) => {
// Skip rate limiting for health check endpoint
return req.path === '/health';
}
});
/**
* Strict rate limiter for authentication endpoints
* 5 requests per 15 minutes per IP
*/
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 requests per windowMs
message: 'Too many authentication attempts, please try again after 15 minutes',
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler,
skipSuccessfulRequests: false // Count all requests, including successful ones
});
/**
* Rate limiter for login attempts
* More restrictive - 5 attempts per 15 minutes
*/
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: 'Too many login attempts, please try again after 15 minutes',
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler,
skipFailedRequests: false // Count both successful and failed attempts
});
/**
* Rate limiter for registration
* 3 registrations per hour per IP
*/
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3,
message: 'Too many accounts created from this IP, please try again after an hour',
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler
});
/**
* Rate limiter for password reset
* 3 requests per hour per IP
*/
const passwordResetLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
message: 'Too many password reset attempts, please try again after an hour',
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler
});
/**
* Rate limiter for quiz creation
* 30 quizzes per hour per user
*/
const quizLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 30,
message: 'Too many quizzes started, please try again later',
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler
});
/**
* Rate limiter for admin operations
* 100 requests per 15 minutes
*/
const adminLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: 'Too many admin requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler
});
/**
* Rate limiter for guest session creation
* 5 guest sessions per hour per IP
*/
const guestSessionLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
message: 'Too many guest sessions created, please try again later',
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler
});
/**
* Rate limiter for API documentation
* Prevent abuse of documentation endpoint
*/
const docsLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 50,
message: 'Too many requests to documentation, please try again later',
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler
});
module.exports = {
apiLimiter,
authLimiter,
loginLimiter,
registerLimiter,
passwordResetLimiter,
quizLimiter,
adminLimiter,
guestSessionLimiter,
docsLimiter
};

262
middleware/sanitization.js Normal file
View File

@@ -0,0 +1,262 @@
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');
const hpp = require('hpp');
const { body, param, query, validationResult } = require('express-validator');
const { BadRequestError } = require('../utils/AppError');
/**
* MongoDB NoSQL Injection Prevention
* Removes $ and . characters from user input to prevent NoSQL injection
*/
const sanitizeMongoData = mongoSanitize({
replaceWith: '_',
onSanitize: ({ req, key }) => {
const logger = require('../config/logger');
logger.logSecurityEvent(`MongoDB injection attempt detected in ${key}`, req);
}
});
/**
* XSS (Cross-Site Scripting) Prevention
* Sanitizes user input to prevent XSS attacks
*/
const sanitizeXSS = xss();
/**
* HTTP Parameter Pollution Prevention
* Protects against attacks where parameters are sent multiple times
*/
const preventHPP = hpp({
whitelist: [
// Query parameters that are allowed to have multiple values
'category',
'categoryId',
'difficulty',
'tags',
'keywords',
'sort',
'fields'
]
});
/**
* Sanitize request body, query, and params
* Custom middleware that runs after mongoSanitize and xss
*/
const sanitizeInput = (req, res, next) => {
// Additional sanitization for specific patterns
const sanitizeObject = (obj) => {
if (!obj || typeof obj !== 'object') return obj;
for (const key in obj) {
if (typeof obj[key] === 'string') {
// Remove null bytes
obj[key] = obj[key].replace(/\0/g, '');
// Trim whitespace
obj[key] = obj[key].trim();
// Remove any remaining dangerous patterns
obj[key] = obj[key]
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '');
} else if (typeof obj[key] === 'object') {
sanitizeObject(obj[key]);
}
}
return obj;
};
if (req.body) sanitizeObject(req.body);
if (req.query) sanitizeObject(req.query);
if (req.params) sanitizeObject(req.params);
next();
};
/**
* Validate and sanitize email addresses
*/
const sanitizeEmail = [
body('email')
.trim()
.toLowerCase()
.isEmail().withMessage('Invalid email format')
.normalizeEmail({
gmail_remove_dots: false,
gmail_remove_subaddress: false,
outlookdotcom_remove_subaddress: false,
yahoo_remove_subaddress: false,
icloud_remove_subaddress: false
})
.isLength({ max: 255 }).withMessage('Email too long')
];
/**
* Validate and sanitize passwords
*/
const sanitizePassword = [
body('password')
.trim()
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
.isLength({ max: 128 }).withMessage('Password too long')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
.withMessage('Password must contain uppercase, lowercase, number and special character')
];
/**
* Validate and sanitize usernames
*/
const sanitizeUsername = [
body('username')
.trim()
.isLength({ min: 3, max: 30 }).withMessage('Username must be 3-30 characters')
.matches(/^[a-zA-Z0-9_-]+$/).withMessage('Username can only contain letters, numbers, underscores and hyphens')
];
/**
* Validate and sanitize numeric IDs
*/
const sanitizeId = [
param('id')
.isInt({ min: 1 }).withMessage('Invalid ID')
.toInt()
];
/**
* Validate and sanitize pagination parameters
*/
const sanitizePagination = [
query('page')
.optional()
.isInt({ min: 1, max: 10000 }).withMessage('Invalid page number')
.toInt(),
query('limit')
.optional()
.isInt({ min: 1, max: 100 }).withMessage('Limit must be between 1 and 100')
.toInt(),
query('sort')
.optional()
.trim()
.isIn(['asc', 'desc', 'ASC', 'DESC']).withMessage('Sort must be asc or desc')
];
/**
* Validate and sanitize search queries
*/
const sanitizeSearch = [
query('search')
.optional()
.trim()
.isLength({ max: 200 }).withMessage('Search query too long')
.matches(/^[a-zA-Z0-9\s-_.,!?'"]+$/).withMessage('Search contains invalid characters')
];
/**
* Validate and sanitize quiz parameters
*/
const sanitizeQuizParams = [
body('categoryId')
.isInt({ min: 1 }).withMessage('Invalid category ID')
.toInt(),
body('questionCount')
.optional()
.isInt({ min: 1, max: 50 }).withMessage('Question count must be between 1 and 50')
.toInt(),
body('difficulty')
.optional()
.trim()
.isIn(['easy', 'medium', 'hard']).withMessage('Invalid difficulty level'),
body('quizType')
.optional()
.trim()
.isIn(['practice', 'timed', 'exam']).withMessage('Invalid quiz type')
];
/**
* Middleware to check validation results
*/
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const logger = require('../config/logger');
logger.logSecurityEvent('Validation error', req);
throw new BadRequestError('Validation failed', errors.array().map(err => ({
field: err.path || err.param,
message: err.msg
})));
}
next();
};
/**
* Comprehensive sanitization middleware chain
* Use this for all API routes
*/
const sanitizeAll = [
sanitizeMongoData,
sanitizeXSS,
preventHPP,
sanitizeInput
];
/**
* File upload sanitization
*/
const sanitizeFileUpload = (req, res, next) => {
if (req.file) {
// Validate file type
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedMimeTypes.includes(req.file.mimetype)) {
return res.status(400).json({
status: 'error',
message: 'Invalid file type. Only images are allowed.'
});
}
// Validate file size (5MB max)
if (req.file.size > 5 * 1024 * 1024) {
return res.status(400).json({
status: 'error',
message: 'File too large. Maximum size is 5MB.'
});
}
// Sanitize filename
req.file.originalname = req.file.originalname
.replace(/[^a-zA-Z0-9.-]/g, '_')
.substring(0, 100);
}
next();
};
module.exports = {
// Core sanitization middleware
sanitizeMongoData,
sanitizeXSS,
preventHPP,
sanitizeInput,
sanitizeAll,
// Specific field validators
sanitizeEmail,
sanitizePassword,
sanitizeUsername,
sanitizeId,
sanitizePagination,
sanitizeSearch,
sanitizeQuizParams,
// Validation handler
handleValidationErrors,
// File upload sanitization
sanitizeFileUpload
};

155
middleware/security.js Normal file
View File

@@ -0,0 +1,155 @@
const helmet = require('helmet');
/**
* Helmet security configuration
* Helmet helps secure Express apps by setting various HTTP headers
*/
const helmetConfig = helmet({
// Content Security Policy
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for Swagger UI
scriptSrc: ["'self'", "'unsafe-inline'"], // Allow inline scripts for Swagger UI
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'", "data:"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"]
}
},
// Cross-Origin-Embedder-Policy
crossOriginEmbedderPolicy: false, // Disabled for API compatibility
// Cross-Origin-Opener-Policy
crossOriginOpenerPolicy: { policy: "same-origin" },
// Cross-Origin-Resource-Policy
crossOriginResourcePolicy: { policy: "cross-origin" },
// DNS Prefetch Control
dnsPrefetchControl: { allow: false },
// Expect-CT (deprecated but included for older browsers)
expectCt: { maxAge: 86400 },
// Frameguard (prevent clickjacking)
frameguard: { action: "deny" },
// Hide Powered-By header
hidePoweredBy: true,
// HTTP Strict Transport Security
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
},
// IE No Open
ieNoOpen: true,
// No Sniff (prevent MIME type sniffing)
noSniff: true,
// Origin-Agent-Cluster
originAgentCluster: true,
// Permitted Cross-Domain Policies
permittedCrossDomainPolicies: { permittedPolicies: "none" },
// Referrer Policy
referrerPolicy: { policy: "no-referrer" }
});
/**
* Custom security headers middleware
* Only adds headers not already set by Helmet
*/
const customSecurityHeaders = (req, res, next) => {
// Add Permissions-Policy (not in Helmet)
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
// Prevent caching of sensitive data
if (req.path.includes('/api/auth') || req.path.includes('/api/admin') || req.path.includes('/api/users')) {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('Surrogate-Control', 'no-store');
}
next();
};
/**
* CORS configuration
*/
const getCorsOptions = () => {
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
: ['http://localhost:3000', 'http://localhost:4200', 'http://localhost:5173'];
return {
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, Postman, etc.)
if (!origin) return callback(null, true);
if (allowedOrigins.indexOf(origin) !== -1 || process.env.NODE_ENV === 'development') {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-guest-token'],
exposedHeaders: ['RateLimit-Limit', 'RateLimit-Remaining', 'RateLimit-Reset'],
maxAge: 86400 // 24 hours
};
};
/**
* Security middleware for API routes
*/
const secureApiRoutes = (req, res, next) => {
// Log security-sensitive operations
if (req.method !== 'GET' && req.path.includes('/api/admin')) {
const logger = require('../config/logger');
logger.logSecurityEvent(`Admin ${req.method} request`, req);
}
next();
};
/**
* Prevent parameter pollution
* This middleware should be used after body parser
*/
const preventParameterPollution = (req, res, next) => {
// Whitelist of parameters that can have multiple values
const whitelist = ['category', 'difficulty', 'tags', 'keywords'];
// Check for duplicate parameters
if (req.query) {
for (const param in req.query) {
if (Array.isArray(req.query[param]) && !whitelist.includes(param)) {
return res.status(400).json({
status: 'error',
message: `Parameter pollution detected: '${param}' should not have multiple values`
});
}
}
}
next();
};
module.exports = {
helmetConfig,
customSecurityHeaders,
getCorsOptions,
secureApiRoutes,
preventParameterPollution
};

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');
}
};

View File

@@ -0,0 +1,105 @@
/**
* Migration: Add Database Indexes for Performance Optimization
*
* This migration adds indexes to improve query performance for:
* - QuizSession: userId, guestSessionId, categoryId, status, createdAt
* - QuizSessionQuestion: quizSessionId, questionId
*
* Note: Other models (User, Question, Category, GuestSession, QuizAnswer, UserBookmark)
* already have indexes defined in their models.
*/
module.exports = {
up: async (queryInterface, Sequelize) => {
console.log('Adding performance indexes...');
try {
// QuizSession indexes
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', ['created_at'], {
name: 'idx_quiz_sessions_created_at'
});
// Composite indexes for common queries
await queryInterface.addIndex('quiz_sessions', ['user_id', 'created_at'], {
name: 'idx_quiz_sessions_user_created'
});
await queryInterface.addIndex('quiz_sessions', ['guest_session_id', 'created_at'], {
name: 'idx_quiz_sessions_guest_created'
});
await queryInterface.addIndex('quiz_sessions', ['category_id', 'status'], {
name: 'idx_quiz_sessions_category_status'
});
// QuizSessionQuestion indexes
await queryInterface.addIndex('quiz_session_questions', ['quiz_session_id'], {
name: 'idx_quiz_session_questions_session_id'
});
await queryInterface.addIndex('quiz_session_questions', ['question_id'], {
name: 'idx_quiz_session_questions_question_id'
});
await queryInterface.addIndex('quiz_session_questions', ['quiz_session_id', 'question_order'], {
name: 'idx_quiz_session_questions_session_order'
});
// Unique constraint to prevent duplicate questions in same session
await queryInterface.addIndex('quiz_session_questions', ['quiz_session_id', 'question_id'], {
name: 'idx_quiz_session_questions_session_question_unique',
unique: true
});
console.log('✅ Performance indexes added successfully');
} catch (error) {
console.error('❌ Error adding indexes:', error);
throw error;
}
},
down: async (queryInterface, Sequelize) => {
console.log('Removing performance indexes...');
try {
// Remove QuizSession indexes
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_user_id');
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_guest_session_id');
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_category_id');
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_status');
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_created_at');
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_user_created');
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_guest_created');
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_category_status');
// Remove QuizSessionQuestion indexes
await queryInterface.removeIndex('quiz_session_questions', 'idx_quiz_session_questions_session_id');
await queryInterface.removeIndex('quiz_session_questions', 'idx_quiz_session_questions_question_id');
await queryInterface.removeIndex('quiz_session_questions', 'idx_quiz_session_questions_session_order');
await queryInterface.removeIndex('quiz_session_questions', 'idx_quiz_session_questions_session_question_unique');
console.log('✅ Performance indexes removed successfully');
} catch (error) {
console.error('❌ Error removing indexes:', error);
throw error;
}
}
};

View File

@@ -0,0 +1,61 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
console.log('Creating guest_settings table...');
await queryInterface.createTable('guest_settings', {
id: {
type: Sequelize.CHAR(36),
primaryKey: true,
allowNull: false,
comment: 'UUID primary key'
},
max_quizzes: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 3,
comment: 'Maximum number of quizzes a guest can take'
},
expiry_hours: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 24,
comment: 'Guest session expiry time in hours'
},
public_categories: {
type: Sequelize.JSON,
allowNull: false,
defaultValue: '[]',
comment: 'Array of category UUIDs accessible to guests'
},
feature_restrictions: {
type: Sequelize.JSON,
allowNull: false,
defaultValue: '{"allowBookmarks":false,"allowReview":true,"allowPracticeMode":true,"allowTimedMode":false,"allowExamMode":false}',
comment: 'Feature restrictions for guest users'
},
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')
}
}, {
comment: 'System-wide guest user settings'
});
console.log('✅ guest_settings table created successfully');
},
async down (queryInterface, Sequelize) {
console.log('Dropping guest_settings table...');
await queryInterface.dropTable('guest_settings');
console.log('✅ guest_settings table dropped successfully');
}
};

274
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;
};

330
models/GuestSession.js Normal file
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;
};

114
models/GuestSettings.js Normal file
View File

@@ -0,0 +1,114 @@
const { v4: uuidv4 } = require('uuid');
module.exports = (sequelize, DataTypes) => {
const GuestSettings = sequelize.define('GuestSettings', {
id: {
type: DataTypes.CHAR(36),
primaryKey: true,
defaultValue: () => uuidv4(),
allowNull: false,
comment: 'UUID primary key'
},
maxQuizzes: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 3,
validate: {
min: {
args: [1],
msg: 'Maximum quizzes must be at least 1'
},
max: {
args: [50],
msg: 'Maximum quizzes cannot exceed 50'
}
},
field: 'max_quizzes',
comment: 'Maximum number of quizzes a guest can take'
},
expiryHours: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 24,
validate: {
min: {
args: [1],
msg: 'Expiry hours must be at least 1'
},
max: {
args: [168],
msg: 'Expiry hours cannot exceed 168 (7 days)'
}
},
field: 'expiry_hours',
comment: 'Guest session expiry time in hours'
},
publicCategories: {
type: DataTypes.JSON,
allowNull: false,
defaultValue: [],
get() {
const value = this.getDataValue('publicCategories');
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (e) {
return [];
}
}
return value || [];
},
set(value) {
this.setDataValue('publicCategories', JSON.stringify(value));
},
field: 'public_categories',
comment: 'Array of category UUIDs accessible to guests'
},
featureRestrictions: {
type: DataTypes.JSON,
allowNull: false,
defaultValue: {
allowBookmarks: false,
allowReview: true,
allowPracticeMode: true,
allowTimedMode: false,
allowExamMode: false
},
get() {
const value = this.getDataValue('featureRestrictions');
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (e) {
return {
allowBookmarks: false,
allowReview: true,
allowPracticeMode: true,
allowTimedMode: false,
allowExamMode: false
};
}
}
return value || {
allowBookmarks: false,
allowReview: true,
allowPracticeMode: true,
allowTimedMode: false,
allowExamMode: false
};
},
set(value) {
this.setDataValue('featureRestrictions', JSON.stringify(value));
},
field: 'feature_restrictions',
comment: 'Feature restrictions for guest users'
}
}, {
tableName: 'guest_settings',
timestamps: true,
underscored: true,
comment: 'System-wide guest user settings'
});
return GuestSettings;
};

451
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;
};

134
models/QuizAnswer.js Normal file
View File

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

634
models/QuizSession.js Normal file
View File

@@ -0,0 +1,634 @@
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,
indexes: [
{
fields: ['user_id']
},
{
fields: ['guest_session_id']
},
{
fields: ['category_id']
},
{
fields: ['status']
},
{
fields: ['created_at']
},
{
fields: ['user_id', 'created_at']
},
{
fields: ['guest_session_id', 'created_at']
},
{
fields: ['category_id', 'status']
}
],
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;
};

View File

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

333
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;
};

96
models/UserBookmark.js Normal file
View File

@@ -0,0 +1,96 @@
/**
* UserBookmark Model
* Junction table for user-saved questions
*/
const { DataTypes } = require('sequelize');
const { v4: uuidv4 } = require('uuid');
module.exports = (sequelize) => {
const UserBookmark = sequelize.define('UserBookmark', {
id: {
type: DataTypes.UUID,
defaultValue: () => uuidv4(),
primaryKey: true,
allowNull: false,
comment: 'Primary key UUID'
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onDelete: 'CASCADE',
comment: 'Reference to user who bookmarked'
},
questionId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'questions',
key: 'id'
},
onDelete: 'CASCADE',
comment: 'Reference to bookmarked question'
},
notes: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Optional notes about the bookmark'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at',
comment: 'When the bookmark was created'
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'updated_at',
comment: 'When the bookmark was last updated'
}
}, {
tableName: 'user_bookmarks',
timestamps: true,
underscored: true,
indexes: [
{
unique: true,
fields: ['user_id', 'question_id'],
name: 'idx_user_question_unique'
},
{
fields: ['user_id'],
name: 'idx_user_bookmarks_user'
},
{
fields: ['question_id'],
name: 'idx_user_bookmarks_question'
},
{
fields: ['bookmarked_at'],
name: 'idx_user_bookmarks_date'
}
]
});
// Define associations
UserBookmark.associate = function(models) {
UserBookmark.belongsTo(models.User, {
foreignKey: 'userId',
as: 'User'
});
UserBookmark.belongsTo(models.Question, {
foreignKey: 'questionId',
as: 'Question'
});
};
return UserBookmark;
};

57
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;

80
package.json Normal file
View File

@@ -0,0 +1,80 @@
{
"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",
"test:start-quiz": "node test-start-quiz.js",
"validate:env": "node validate-env.js",
"generate:jwt": "node generate-jwt-secret.js",
"migrate": "npx sequelize-cli db:migrate",
"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-mongo-sanitize": "^2.2.0",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"express-winston": "^4.2.0",
"helmet": "^7.1.0",
"hpp": "^0.2.3",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"mysql2": "^3.6.5",
"sequelize": "^6.35.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^9.0.1",
"winston": "^3.18.3",
"winston-daily-rotate-file": "^5.0.0",
"xss-clean": "^0.1.4"
},
"devDependencies": {
"jest": "^29.7.0",
"nodemon": "^3.0.2",
"sequelize-cli": "^6.6.2",
"supertest": "^6.3.3"
}
}

421
routes/admin.routes.js Normal file
View File

@@ -0,0 +1,421 @@
const express = require('express');
const router = express.Router();
const questionController = require('../controllers/question.controller');
const adminController = require('../controllers/admin.controller');
const { verifyToken, isAdmin } = require('../middleware/auth.middleware');
const { adminLimiter } = require('../middleware/rateLimiter');
const { cacheStatistics, cacheGuestAnalytics, cacheGuestSettings, invalidateCacheMiddleware, invalidateCache } = require('../middleware/cache');
/**
* @swagger
* /admin/statistics:
* get:
* summary: Get system-wide statistics for admin dashboard
* tags: [Admin]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Statistics retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* users:
* type: object
* properties:
* total:
* type: integer
* active:
* type: integer
* inactiveLast7Days:
* type: integer
* quizzes:
* type: object
* properties:
* totalSessions:
* type: integer
* averageScore:
* type: number
* passRate:
* type: number
* content:
* type: object
* properties:
* totalCategories:
* type: integer
* totalQuestions:
* type: integer
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
*
* /admin/guest-settings:
* get:
* summary: Get guest user settings
* tags: [Admin]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Guest settings retrieved successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
* put:
* summary: Update guest user settings
* tags: [Admin]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* maxQuizzes:
* type: integer
* minimum: 1
* maximum: 100
* expiryHours:
* type: integer
* minimum: 1
* maximum: 168
* publicCategories:
* type: array
* items:
* type: integer
* featureRestrictions:
* type: object
* responses:
* 200:
* description: Settings updated successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
*
* /admin/guest-analytics:
* get:
* summary: Get guest user analytics
* tags: [Admin]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Analytics retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* overview:
* type: object
* properties:
* totalGuestSessions:
* type: integer
* activeGuestSessions:
* type: integer
* convertedGuestSessions:
* type: integer
* conversionRate:
* type: number
* quizActivity:
* type: object
* behavior:
* type: object
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
*
* /admin/users:
* get:
* summary: Get all users with pagination and filtering
* tags: [Admin]
* security:
* - bearerAuth: []
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* default: 1
* - in: query
* name: limit
* schema:
* type: integer
* default: 10
* maximum: 100
* - in: query
* name: role
* schema:
* type: string
* enum: [user, admin]
* - in: query
* name: isActive
* schema:
* type: boolean
* - in: query
* name: sortBy
* schema:
* type: string
* enum: [createdAt, username, email]
* - in: query
* name: sortOrder
* schema:
* type: string
* enum: [asc, desc]
* responses:
* 200:
* description: Users retrieved successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
*
* /admin/users/{userId}:
* get:
* summary: Get user details by ID
* tags: [Admin]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: User details retrieved successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
* 404:
* $ref: '#/components/responses/NotFoundError'
*/
// Apply admin rate limiter to all routes
router.use(adminLimiter);
router.get('/statistics', verifyToken, isAdmin, cacheStatistics, adminController.getSystemStatistics);
router.get('/guest-settings', verifyToken, isAdmin, cacheGuestSettings, adminController.getGuestSettings);
router.put('/guest-settings', verifyToken, isAdmin, invalidateCacheMiddleware(() => invalidateCache.guestSettings()), adminController.updateGuestSettings);
router.get('/guest-analytics', verifyToken, isAdmin, cacheGuestAnalytics, adminController.getGuestAnalytics);
router.get('/users', verifyToken, isAdmin, adminController.getAllUsers);
router.get('/users/:userId', verifyToken, isAdmin, adminController.getUserById);
/**
* @swagger
* /admin/users/{userId}/role:
* put:
* summary: Update user role
* tags: [Admin]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - role
* properties:
* role:
* type: string
* enum: [user, admin]
* responses:
* 200:
* description: User role updated successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
* 404:
* $ref: '#/components/responses/NotFoundError'
*
* /admin/users/{userId}/activate:
* put:
* summary: Reactivate deactivated user
* tags: [Admin]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: User reactivated successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
* 404:
* $ref: '#/components/responses/NotFoundError'
*
* /admin/users/{userId}:
* delete:
* summary: Deactivate user (soft delete)
* tags: [Admin]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: User deactivated successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
* 404:
* $ref: '#/components/responses/NotFoundError'
*
* /admin/questions:
* post:
* summary: Create a new question
* tags: [Questions]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - questionText
* - questionType
* - correctAnswer
* - difficulty
* - categoryId
* properties:
* questionText:
* type: string
* questionType:
* type: string
* enum: [multiple_choice, trueFalse, short_answer]
* options:
* type: array
* items:
* type: string
* correctAnswer:
* type: string
* difficulty:
* type: string
* enum: [easy, medium, hard]
* points:
* type: integer
* explanation:
* type: string
* categoryId:
* type: integer
* tags:
* type: array
* items:
* type: string
* keywords:
* type: array
* items:
* type: string
* responses:
* 201:
* description: Question created successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
*
* /admin/questions/{id}:
* put:
* summary: Update a question
* tags: [Questions]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* questionText:
* type: string
* options:
* type: array
* items:
* type: string
* correctAnswer:
* type: string
* difficulty:
* type: string
* enum: [easy, medium, hard]
* isActive:
* type: boolean
* responses:
* 200:
* description: Question updated successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
* 404:
* $ref: '#/components/responses/NotFoundError'
* delete:
* summary: Delete a question (soft delete)
* tags: [Questions]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Question deleted successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
* 404:
* $ref: '#/components/responses/NotFoundError'
*/
// Apply admin rate limiter to all routes
router.use(adminLimiter);
router.put('/users/:userId/role', verifyToken, isAdmin, adminController.updateUserRole);
router.put('/users/:userId/activate', verifyToken, isAdmin, adminController.reactivateUser);
router.delete('/users/:userId', verifyToken, isAdmin, adminController.deactivateUser);
router.get('/questions', verifyToken, isAdmin, questionController.getAllQuestions);
router.get('/questions/:id', verifyToken, isAdmin, questionController.getQuestionByIdAdmin);
router.post('/questions', verifyToken, isAdmin, questionController.createQuestion);
router.put('/questions/:id', verifyToken, isAdmin, questionController.updateQuestion);
router.delete('/questions/:id', verifyToken, isAdmin, questionController.deleteQuestion);
module.exports = router;

201
routes/auth.routes.js Normal file
View File

@@ -0,0 +1,201 @@
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');
const { loginLimiter, registerLimiter, authLimiter } = require('../middleware/rateLimiter');
/**
* @swagger
* /auth/register:
* post:
* summary: Register a new user account
* tags: [Authentication]
* security: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - username
* - email
* - password
* properties:
* username:
* type: string
* minLength: 3
* maxLength: 50
* description: Unique username (3-50 characters)
* example: johndoe
* email:
* type: string
* format: email
* description: Valid email address
* example: john@example.com
* password:
* type: string
* minLength: 6
* description: Password (minimum 6 characters)
* example: password123
* responses:
* 201:
* description: User registered successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: User registered successfully
* user:
* type: object
* properties:
* id:
* type: integer
* example: 1
* username:
* type: string
* example: johndoe
* email:
* type: string
* example: john@example.com
* role:
* type: string
* example: user
* token:
* type: string
* description: JWT authentication token
* example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
* 400:
* $ref: '#/components/responses/ValidationError'
* 409:
* description: Username or email already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* example:
* message: Username already exists
* 500:
* description: Server error
*/
router.post('/register', registerLimiter, validateRegistration, authController.register);
/**
* @swagger
* /auth/login:
* post:
* summary: Login to user account
* tags: [Authentication]
* security: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* properties:
* email:
* type: string
* description: Email or username
* example: john@example.com
* password:
* type: string
* description: Account password
* example: password123
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Login successful
* user:
* $ref: '#/components/schemas/User'
* token:
* type: string
* description: JWT authentication token
* example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
* 400:
* $ref: '#/components/responses/ValidationError'
* 401:
* description: Invalid credentials
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* example:
* message: Invalid credentials
* 403:
* description: Account is deactivated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* example:
* message: Account is deactivated
* 500:
* description: Server error
*/
router.post('/login', loginLimiter, validateLogin, authController.login);
/**
* @swagger
* /auth/logout:
* post:
* summary: Logout user (client-side token removal)
* tags: [Authentication]
* security: []
* responses:
* 200:
* description: Logout successful
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Logout successful
*/
router.post('/logout', authLimiter, authController.logout);
/**
* @swagger
* /auth/verify:
* get:
* summary: Verify JWT token and return user information
* tags: [Authentication]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Token is valid
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Token is valid
* user:
* $ref: '#/components/schemas/User'
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 500:
* description: Server error
*/
router.get('/verify', authLimiter, verifyToken, authController.verifyToken);
module.exports = router;

142
routes/category.routes.js Normal file
View File

@@ -0,0 +1,142 @@
const express = require('express');
const router = express.Router();
const categoryController = require('../controllers/category.controller');
const authMiddleware = require('../middleware/auth.middleware');
const { cacheCategories, cacheSingleCategory, invalidateCacheMiddleware, invalidateCache } = require('../middleware/cache');
/**
* @swagger
* /categories:
* get:
* summary: Get all active categories
* description: Guest users see only guest-accessible categories, authenticated users see all
* tags: [Categories]
* security: []
* responses:
* 200:
* description: Categories retrieved successfully
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Category'
* 500:
* description: Server error
* post:
* summary: Create new category
* tags: [Categories]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - name
* properties:
* name:
* type: string
* example: JavaScript Fundamentals
* description:
* type: string
* example: Core JavaScript concepts and syntax
* requiresAuth:
* type: boolean
* default: false
* isActive:
* type: boolean
* default: true
* responses:
* 201:
* description: Category created successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
*
* /categories/{id}:
* get:
* summary: Get category details with question preview and stats
* tags: [Categories]
* security: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Category ID
* responses:
* 200:
* description: Category details retrieved successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Category'
* 404:
* $ref: '#/components/responses/NotFoundError'
* put:
* summary: Update category
* tags: [Categories]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* description:
* type: string
* requiresAuth:
* type: boolean
* isActive:
* type: boolean
* responses:
* 200:
* description: Category updated successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
* 404:
* $ref: '#/components/responses/NotFoundError'
* delete:
* summary: Delete category (soft delete)
* tags: [Categories]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Category deleted successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
* 404:
* $ref: '#/components/responses/NotFoundError'
*/
router.get('/', authMiddleware.optionalAuth, cacheCategories, categoryController.getAllCategories);
router.get('/:id', authMiddleware.optionalAuth, cacheSingleCategory, categoryController.getCategoryById);
router.post('/', authMiddleware.verifyToken, authMiddleware.isAdmin, invalidateCacheMiddleware(() => invalidateCache.category()), categoryController.createCategory);
router.put('/:id', authMiddleware.verifyToken, authMiddleware.isAdmin, invalidateCacheMiddleware((req) => invalidateCache.category(req.params.id)), categoryController.updateCategory);
router.delete('/:id', authMiddleware.verifyToken, authMiddleware.isAdmin, invalidateCacheMiddleware((req) => invalidateCache.category(req.params.id)), categoryController.deleteCategory);
module.exports = router;

175
routes/guest.routes.js Normal file
View File

@@ -0,0 +1,175 @@
const express = require('express');
const router = express.Router();
const guestController = require('../controllers/guest.controller');
const guestMiddleware = require('../middleware/guest.middleware');
const { guestSessionLimiter } = require('../middleware/rateLimiter');
/**
* @swagger
* /guest/start-session:
* post:
* summary: Start a new guest session
* description: Creates a temporary guest session allowing users to try quizzes without registration
* tags: [Guest]
* security: []
* responses:
* 201:
* description: Guest session created successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Guest session created successfully
* guestSession:
* $ref: '#/components/schemas/GuestSession'
* token:
* type: string
* description: Guest session token for subsequent requests
* example: 550e8400-e29b-41d4-a716-446655440000
* settings:
* type: object
* properties:
* maxQuizzes:
* type: integer
* example: 3
* expiryHours:
* type: integer
* example: 24
* 500:
* description: Server error
*
* /guest/session/{guestId}:
* get:
* summary: Get guest session details
* tags: [Guest]
* security: []
* parameters:
* - in: path
* name: guestId
* required: true
* schema:
* type: string
* format: uuid
* description: Guest session ID
* responses:
* 200:
* description: Guest session retrieved successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/GuestSession'
* 404:
* $ref: '#/components/responses/NotFoundError'
*
* /guest/quiz-limit:
* get:
* summary: Check guest quiz limit and remaining quizzes
* tags: [Guest]
* security: []
* parameters:
* - in: header
* name: x-guest-token
* required: true
* schema:
* type: string
* format: uuid
* description: Guest session token
* responses:
* 200:
* description: Quiz limit information retrieved
* content:
* application/json:
* schema:
* type: object
* properties:
* maxQuizzes:
* type: integer
* example: 3
* quizzesCompleted:
* type: integer
* example: 1
* remainingQuizzes:
* type: integer
* example: 2
* limitReached:
* type: boolean
* example: false
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 404:
* description: Guest session not found or expired
*
* /guest/convert:
* post:
* summary: Convert guest session to registered user account
* description: Converts guest progress to a new user account, preserving quiz history
* tags: [Guest]
* security: []
* parameters:
* - in: header
* name: x-guest-token
* required: true
* schema:
* type: string
* format: uuid
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - username
* - email
* - password
* properties:
* username:
* type: string
* minLength: 3
* maxLength: 50
* example: johndoe
* email:
* type: string
* format: email
* example: john@example.com
* password:
* type: string
* minLength: 6
* example: password123
* responses:
* 201:
* description: Guest converted to user successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Guest account converted successfully
* user:
* $ref: '#/components/schemas/User'
* token:
* type: string
* description: JWT authentication token
* sessionsTransferred:
* type: integer
* example: 2
* 400:
* $ref: '#/components/responses/ValidationError'
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 404:
* description: Guest session not found or expired
* 409:
* description: Username or email already exists
*/
router.post('/start-session', guestSessionLimiter, guestController.startGuestSession);
router.get('/session/:guestId', guestController.getGuestSession);
router.get('/quiz-limit', guestMiddleware.verifyGuestToken, guestController.checkQuizLimit);
router.post('/convert', guestSessionLimiter, guestMiddleware.verifyGuestToken, guestController.convertGuestToUser);
module.exports = router;

35
routes/question.routes.js Normal file
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;

251
routes/quiz.routes.js Normal file
View File

@@ -0,0 +1,251 @@
const express = require('express');
const router = express.Router();
const quizController = require('../controllers/quiz.controller');
const { verifyToken } = require('../middleware/auth.middleware');
const { verifyGuestToken } = require('../middleware/guest.middleware');
const { quizLimiter } = require('../middleware/rateLimiter');
/**
* Middleware to handle both authenticated users and guests
* Tries user auth first, then guest auth
*/
const authenticateUserOrGuest = async (req, res, next) => {
// Try to verify user token first
const authHeader = req.headers['authorization'];
if (authHeader && authHeader.startsWith('Bearer ')) {
try {
await new Promise((resolve, reject) => {
verifyToken(req, res, (err) => {
if (err) reject(err);
else resolve();
});
});
if (req.user) {
return next();
}
} catch (error) {
// User auth failed, continue to guest auth
}
}
// Try to verify guest token
const guestToken = req.headers['x-guest-token'];
console.log(guestToken);
if (guestToken) {
try {
await new Promise((resolve, reject) => {
verifyGuestToken(req, res, (err) => {
if (err) reject(err);
else resolve();
});
});
if (req.guestId) {
return next();
}
} catch (error) {
// Guest auth also failed
}
}
// Neither authentication method worked
return res.status(401).json({
success: false,
message: 'Authentication required. Please login or start a guest session.'
});
};
/**
* @swagger
* /quiz/start:
* post:
* summary: Start a new quiz session
* description: Can be used by authenticated users or guest users
* tags: [Quiz]
* security:
* - bearerAuth: []
* parameters:
* - in: header
* name: x-guest-token
* schema:
* type: string
* format: uuid
* description: Guest session token (for guest users)
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - categoryId
* properties:
* categoryId:
* type: integer
* description: Category ID for the quiz
* example: 1
* questionCount:
* type: integer
* minimum: 1
* maximum: 50
* default: 10
* description: Number of questions in quiz
* difficulty:
* type: string
* enum: [easy, medium, hard, mixed]
* default: mixed
* quizType:
* type: string
* enum: [practice, timed, exam]
* default: practice
* responses:
* 201:
* description: Quiz session started successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* session:
* $ref: '#/components/schemas/QuizSession'
* currentQuestion:
* $ref: '#/components/schemas/Question'
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 404:
* description: Category not found
*
* /quiz/submit:
* post:
* summary: Submit an answer for a quiz question
* tags: [Quiz]
* security:
* - bearerAuth: []
* parameters:
* - in: header
* name: x-guest-token
* schema:
* type: string
* format: uuid
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - quizSessionId
* - questionId
* - userAnswer
* properties:
* quizSessionId:
* type: integer
* questionId:
* type: integer
* userAnswer:
* type: string
* timeSpent:
* type: integer
* description: Time spent on question in seconds
* responses:
* 200:
* description: Answer submitted successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 404:
* description: Session or question not found
*
* /quiz/complete:
* post:
* summary: Complete a quiz session and get final results
* tags: [Quiz]
* security:
* - bearerAuth: []
* parameters:
* - in: header
* name: x-guest-token
* schema:
* type: string
* format: uuid
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* properties:
* sessionId:
* type: integer
* responses:
* 200:
* description: Quiz completed successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 404:
* description: Session not found
*
* /quiz/session/{sessionId}:
* get:
* summary: Get quiz session details with questions and answers
* tags: [Quiz]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: integer
* - in: header
* name: x-guest-token
* schema:
* type: string
* format: uuid
* responses:
* 200:
* description: Session details retrieved successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/QuizSession'
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 404:
* $ref: '#/components/responses/NotFoundError'
*
* /quiz/review/{sessionId}:
* get:
* summary: Review completed quiz with all answers and explanations
* tags: [Quiz]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: integer
* - in: header
* name: x-guest-token
* schema:
* type: string
* format: uuid
* responses:
* 200:
* description: Quiz review retrieved successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 404:
* $ref: '#/components/responses/NotFoundError'
*/
router.post('/start', quizLimiter, authenticateUserOrGuest, quizController.startQuizSession);
router.post('/submit', authenticateUserOrGuest, quizController.submitAnswer);
router.post('/complete', authenticateUserOrGuest, quizController.completeQuizSession);
router.get('/session/:sessionId', authenticateUserOrGuest, quizController.getSessionDetails);
router.get('/review/:sessionId', authenticateUserOrGuest, quizController.reviewQuizSession);
module.exports = router;

336
routes/user.routes.js Normal file
View File

@@ -0,0 +1,336 @@
const express = require('express');
const router = express.Router();
const userController = require('../controllers/user.controller');
const { verifyToken } = require('../middleware/auth.middleware');
/**
* @swagger
* /users/{userId}/dashboard:
* get:
* summary: Get user dashboard with statistics and recent activity
* tags: [Users]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: integer
* description: User ID
* responses:
* 200:
* description: Dashboard data retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* stats:
* type: object
* properties:
* totalQuizzes:
* type: integer
* example: 25
* completedQuizzes:
* type: integer
* example: 20
* averageScore:
* type: number
* example: 85.5
* totalTimeSpent:
* type: integer
* description: Total time in minutes
* example: 120
* recentSessions:
* type: array
* items:
* $ref: '#/components/schemas/QuizSession'
* categoryPerformance:
* type: array
* items:
* type: object
* properties:
* categoryName:
* type: string
* quizCount:
* type: integer
* averageScore:
* type: number
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* description: Can only access own dashboard
* 404:
* $ref: '#/components/responses/NotFoundError'
*/
router.get('/:userId/dashboard', verifyToken, userController.getUserDashboard);
/**
* @swagger
* /users/{userId}/history:
* get:
* summary: Get user quiz history with pagination and filtering
* tags: [Users]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: integer
* description: User ID
* - in: query
* name: page
* schema:
* type: integer
* default: 1
* description: Page number
* - in: query
* name: limit
* schema:
* type: integer
* default: 10
* maximum: 50
* description: Items per page
* - in: query
* name: category
* schema:
* type: integer
* description: Filter by category ID
* - in: query
* name: status
* schema:
* type: string
* enum: [completed, timeout, abandoned]
* description: Filter by quiz status
* - in: query
* name: startDate
* schema:
* type: string
* format: date-time
* description: Filter by start date (ISO 8601)
* - in: query
* name: endDate
* schema:
* type: string
* format: date-time
* description: Filter by end date (ISO 8601)
* - in: query
* name: sortBy
* schema:
* type: string
* enum: [date, score]
* default: date
* description: Sort by field
* - in: query
* name: sortOrder
* schema:
* type: string
* enum: [asc, desc]
* default: desc
* description: Sort order
* responses:
* 200:
* description: Quiz history retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* quizzes:
* type: array
* items:
* $ref: '#/components/schemas/QuizSession'
* pagination:
* type: object
* properties:
* currentPage:
* type: integer
* totalPages:
* type: integer
* totalItems:
* type: integer
* itemsPerPage:
* type: integer
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* description: Can only access own history
*/
router.get('/:userId/history', verifyToken, userController.getQuizHistory);
/**
* @swagger
* /users/{userId}:
* put:
* summary: Update user profile
* tags: [Users]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: integer
* description: User ID
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* username:
* type: string
* minLength: 3
* maxLength: 50
* email:
* type: string
* format: email
* currentPassword:
* type: string
* description: Required if changing password
* newPassword:
* type: string
* minLength: 6
* profileImage:
* type: string
* responses:
* 200:
* description: Profile updated successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* description: Can only update own profile
*/
router.put('/:userId', verifyToken, userController.updateUserProfile);
/**
* @swagger
* /users/{userId}/bookmarks:
* get:
* summary: Get user's bookmarked questions
* tags: [Bookmarks]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: integer
* - in: query
* name: page
* schema:
* type: integer
* default: 1
* - in: query
* name: limit
* schema:
* type: integer
* default: 10
* maximum: 50
* - in: query
* name: category
* schema:
* type: integer
* description: Filter by category ID
* - in: query
* name: difficulty
* schema:
* type: string
* enum: [easy, medium, hard]
* - in: query
* name: sortBy
* schema:
* type: string
* enum: [date, difficulty]
* default: date
* - in: query
* name: sortOrder
* schema:
* type: string
* enum: [asc, desc]
* default: desc
* responses:
* 200:
* description: Bookmarks retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* bookmarks:
* type: array
* items:
* $ref: '#/components/schemas/Bookmark'
* pagination:
* type: object
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* post:
* summary: Add a question to bookmarks
* tags: [Bookmarks]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - questionId
* properties:
* questionId:
* type: integer
* description: Question ID to bookmark
* notes:
* type: string
* description: Optional notes about the bookmark
* responses:
* 201:
* description: Bookmark added successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 409:
* description: Question already bookmarked
*
* /users/{userId}/bookmarks/{questionId}:
* delete:
* summary: Remove a question from bookmarks
* tags: [Bookmarks]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: integer
* - in: path
* name: questionId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Bookmark removed successfully
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 404:
* description: Bookmark not found
*/
router.get('/:userId/bookmarks', verifyToken, userController.getUserBookmarks);
router.post('/:userId/bookmarks', verifyToken, userController.addBookmark);
router.delete('/:userId/bookmarks/:questionId', verifyToken, userController.removeBookmark);
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');
}
};

179
server.js Normal file
View File

@@ -0,0 +1,179 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./config/swagger');
const logger = require('./config/logger');
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
const { testConnection, getDatabaseStats } = require('./config/db');
const { validateEnvironment } = require('./tests/validate-env');
const { isRedisConnected } = require('./config/redis');
// Security middleware
const { helmetConfig, customSecurityHeaders, getCorsOptions } = require('./middleware/security');
const { sanitizeAll } = require('./middleware/sanitization');
const { apiLimiter, docsLimiter } = require('./middleware/rateLimiter');
// 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;
// Trust proxy - important for rate limiting and getting real client IP
app.set('trust proxy', 1);
// Security middleware - order matters!
// 1. Helmet for security headers
app.use(helmetConfig);
// 2. Custom security headers
app.use(customSecurityHeaders);
// 3. CORS configuration
app.use(cors(getCorsOptions()));
// 4. Body parser middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// 5. Input sanitization (NoSQL injection, XSS, HPP)
app.use(sanitizeAll);
// 6. Logging middleware
if (NODE_ENV === 'development') {
app.use(morgan('dev', { stream: logger.stream }));
} else {
app.use(morgan('combined', { stream: logger.stream }));
}
// 7. Log all requests in development
if (NODE_ENV === 'development') {
app.use((req, res, next) => {
logger.info(`${req.method} ${req.originalUrl}`, {
ip: req.ip,
userAgent: req.get('user-agent')
});
next();
});
}
// 8. Global rate limiting for all API routes
app.use(API_PREFIX, apiLimiter);
// API Documentation - with rate limiting
app.use('/api-docs', docsLimiter, swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'Interview Quiz API Documentation',
customfavIcon: '/favicon.ico'
}));
// Swagger JSON endpoint - with rate limiting
app.get('/api-docs.json', docsLimiter, (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
// 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');
const quizRoutes = require('./routes/quiz.routes');
const userRoutes = require('./routes/user.routes');
app.use(`${API_PREFIX}/auth`, authRoutes);
app.use(`${API_PREFIX}/guest`, guestRoutes);
app.use(`${API_PREFIX}/categories`, categoryRoutes);
app.use(`${API_PREFIX}/questions`, questionRoutes);
app.use(`${API_PREFIX}/admin`, adminRoutes);
app.use(`${API_PREFIX}/quiz`, quizRoutes);
app.use(`${API_PREFIX}/users`, userRoutes);
// Root endpoint
app.get('/', (req, res) => {
res.json({
message: 'Welcome to Interview Quiz API',
version: '2.0.0',
documentation: '/api-docs'
});
});
// 404 handler - must be after all routes
app.use(notFoundHandler);
// Global error handler - must be last
app.use(errorHandler);
// Start server
app.listen(PORT, async () => {
logger.info('Server starting up...');
const redisStatus = isRedisConnected() ? '✅ Connected' : '⚠️ Not Connected (Optional)';
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
📚 API Docs: http://localhost:${PORT}/api-docs
📝 Logs: backend/logs/
💾 Cache (Redis): ${redisStatus}
`);
logger.info(`Server started successfully on port ${PORT}`);
// 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.');
}
// Log Redis status
if (isRedisConnected()) {
console.log('💾 Redis cache connected and ready');
logger.info('Redis cache connected');
} else {
console.log('⚠️ Redis not connected - caching disabled (optional feature)');
logger.warn('Redis not connected - caching disabled');
}
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
console.error('Unhandled Promise Rejection:', err);
// Close server & exit process
process.exit(1);
});
module.exports = app;

43
set-admin-role.js Normal file
View File

@@ -0,0 +1,43 @@
const { User } = require('./models');
async function setAdminRole() {
try {
const email = 'admin@example.com';
// Find user by email
const user = await User.findOne({ where: { email } });
if (!user) {
console.log(`User with email ${email} not found`);
console.log('Creating admin user...');
const newUser = await User.create({
email: email,
password: 'Admin123!@#',
username: 'adminuser',
role: 'admin'
});
console.log('✓ Admin user created successfully');
console.log(` ID: ${newUser.id}`);
console.log(` Email: ${newUser.email}`);
console.log(` Role: ${newUser.role}`);
} else {
// Update role to admin
user.role = 'admin';
await user.save();
console.log('✓ User role updated to admin');
console.log(` ID: ${user.id}`);
console.log(` Email: ${user.email}`);
console.log(` Role: ${user.role}`);
}
process.exit(0);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
}
setAdminRole();

View File

@@ -0,0 +1,337 @@
const authController = require('../controllers/auth.controller');
const { User } = require('../models');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
// Mock dependencies
jest.mock('../models');
jest.mock('bcrypt');
jest.mock('jsonwebtoken');
describe('Auth Controller', () => {
let req, res;
beforeEach(() => {
req = {
body: {},
user: null
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis()
};
jest.clearAllMocks();
});
describe('register', () => {
it('should register a new user successfully', async () => {
req.body = {
username: 'testuser',
email: 'test@example.com',
password: 'Test123!@#'
};
User.findOne = jest.fn().mockResolvedValue(null);
User.create = jest.fn().mockResolvedValue({
id: '123',
username: 'testuser',
email: 'test@example.com',
role: 'user'
});
jwt.sign = jest.fn().mockReturnValue('mock-token');
await authController.register(req, res);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
message: 'User registered successfully',
data: expect.objectContaining({
token: 'mock-token',
user: expect.objectContaining({
username: 'testuser',
email: 'test@example.com'
})
})
})
);
});
it('should return 400 if username already exists', async () => {
req.body = {
username: 'existinguser',
email: 'new@example.com',
password: 'Test123!@#'
};
User.findOne = jest.fn().mockResolvedValue({ username: 'existinguser' });
await authController.register(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
message: 'Username already exists'
})
);
});
it('should return 409 if email already exists', async () => {
req.body = {
username: 'newuser',
email: 'existing@example.com',
password: 'Test123!@#'
};
User.findOne = jest.fn()
.mockResolvedValueOnce(null) // username check
.mockResolvedValueOnce({ email: 'existing@example.com' }); // email check
await authController.register(req, res);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
message: 'Email already registered'
})
);
});
it('should return 400 for missing required fields', async () => {
req.body = {
username: 'testuser'
// missing email and password
};
await authController.register(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false
})
);
});
it('should return 500 on database error', async () => {
req.body = {
username: 'testuser',
email: 'test@example.com',
password: 'Test123!@#'
};
User.findOne = jest.fn().mockRejectedValue(new Error('Database error'));
await authController.register(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
message: 'Internal server error'
})
);
});
});
describe('login', () => {
it('should login user successfully with email', async () => {
req.body = {
email: 'test@example.com',
password: 'Test123!@#'
};
const mockUser = {
id: '123',
username: 'testuser',
email: 'test@example.com',
password: 'hashed-password',
role: 'user',
isActive: true,
save: jest.fn()
};
User.findOne = jest.fn().mockResolvedValue(mockUser);
bcrypt.compare = jest.fn().mockResolvedValue(true);
jwt.sign = jest.fn().mockReturnValue('mock-token');
await authController.login(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
message: 'Login successful',
data: expect.objectContaining({
token: 'mock-token'
})
})
);
});
it('should login user successfully with username', async () => {
req.body = {
username: 'testuser',
password: 'Test123!@#'
};
const mockUser = {
id: '123',
username: 'testuser',
email: 'test@example.com',
password: 'hashed-password',
role: 'user',
isActive: true,
save: jest.fn()
};
User.findOne = jest.fn().mockResolvedValue(mockUser);
bcrypt.compare = jest.fn().mockResolvedValue(true);
jwt.sign = jest.fn().mockReturnValue('mock-token');
await authController.login(req, res);
expect(res.status).toHaveBeenCalledWith(200);
});
it('should return 400 if user not found', async () => {
req.body = {
email: 'nonexistent@example.com',
password: 'Test123!@#'
};
User.findOne = jest.fn().mockResolvedValue(null);
await authController.login(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
message: 'Invalid credentials'
})
);
});
it('should return 400 if password is incorrect', async () => {
req.body = {
email: 'test@example.com',
password: 'WrongPassword'
};
User.findOne = jest.fn().mockResolvedValue({
password: 'hashed-password'
});
bcrypt.compare = jest.fn().mockResolvedValue(false);
await authController.login(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
message: 'Invalid credentials'
})
);
});
it('should return 403 if user account is deactivated', async () => {
req.body = {
email: 'test@example.com',
password: 'Test123!@#'
};
User.findOne = jest.fn().mockResolvedValue({
isActive: false,
password: 'hashed-password'
});
bcrypt.compare = jest.fn().mockResolvedValue(true);
await authController.login(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
message: 'Account is deactivated'
})
);
});
it('should return 400 for missing credentials', async () => {
req.body = {};
await authController.login(req, res);
expect(res.status).toHaveBeenCalledWith(400);
});
});
describe('logout', () => {
it('should logout user successfully', async () => {
req.user = { id: '123' };
await authController.logout(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
message: 'Logged out successfully'
})
);
});
it('should handle logout without user context', async () => {
req.user = null;
await authController.logout(req, res);
expect(res.status).toHaveBeenCalledWith(200);
});
});
describe('verifyToken', () => {
it('should verify user successfully', async () => {
req.user = {
id: '123',
username: 'testuser',
email: 'test@example.com',
role: 'user'
};
await authController.verifyToken(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
message: 'Token is valid',
data: expect.objectContaining({
user: expect.objectContaining({
id: '123',
username: 'testuser'
})
})
})
);
});
it('should return 401 if no user in request', async () => {
req.user = null;
await authController.verifyToken(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
message: 'Unauthorized'
})
);
});
});
});

26
tests/check-categories.js Normal file
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();

38
tests/check-questions.js Normal file
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();

24
tests/drop-categories.js Normal file
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();

442
tests/integration.test.js Normal file
View File

@@ -0,0 +1,442 @@
const request = require('supertest');
const app = require('../server');
const { sequelize } = require('../models');
describe('Integration Tests - Complete User Flow', () => {
let server;
const testUser = {
username: 'integrationtest',
email: 'integration@test.com',
password: 'Test123!@#'
};
let userToken = null;
let adminToken = null;
beforeAll(async () => {
// Start server
server = app.listen(0);
// Login as admin for setup
const adminRes = await request(app)
.post('/api/auth/login')
.send({
email: 'admin@example.com',
password: 'Admin123!@#'
});
if (adminRes.status === 200) {
adminToken = adminRes.body.data.token;
}
});
afterAll(async () => {
// Close server and database connections
if (server) {
await new Promise(resolve => server.close(resolve));
}
await sequelize.close();
});
describe('1. User Registration Flow', () => {
it('should register a new user successfully', async () => {
const res = await request(app)
.post('/api/auth/register')
.send(testUser)
.expect(201);
expect(res.body.success).toBe(true);
expect(res.body.data.user.username).toBe(testUser.username);
expect(res.body.data.user.email).toBe(testUser.email);
expect(res.body.data.token).toBeDefined();
userToken = res.body.data.token;
});
it('should not register user with duplicate email', async () => {
const res = await request(app)
.post('/api/auth/register')
.send(testUser)
.expect(409);
expect(res.body.success).toBe(false);
expect(res.body.message).toContain('already');
});
it('should not register user with invalid email', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({
username: 'testuser2',
email: 'invalid-email',
password: 'Test123!@#'
})
.expect(400);
expect(res.body.success).toBe(false);
});
it('should not register user with weak password', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({
username: 'testuser3',
email: 'test3@example.com',
password: '123'
})
.expect(400);
expect(res.body.success).toBe(false);
});
});
describe('2. User Login Flow', () => {
it('should login with correct credentials', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({
email: testUser.email,
password: testUser.password
})
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data.token).toBeDefined();
expect(res.body.data.user.email).toBe(testUser.email);
});
it('should not login with incorrect password', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({
email: testUser.email,
password: 'WrongPassword123'
})
.expect(401);
expect(res.body.success).toBe(false);
});
it('should not login with non-existent email', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({
email: 'nonexistent@example.com',
password: 'Test123!@#'
})
.expect(401);
expect(res.body.success).toBe(false);
});
});
describe('3. Token Verification Flow', () => {
it('should verify valid token', async () => {
const res = await request(app)
.get('/api/auth/verify')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data.user).toBeDefined();
});
it('should reject invalid token', async () => {
const res = await request(app)
.get('/api/auth/verify')
.set('Authorization', 'Bearer invalid-token')
.expect(401);
expect(res.body.success).toBe(false);
});
it('should reject request without token', async () => {
const res = await request(app)
.get('/api/auth/verify')
.expect(401);
expect(res.body.success).toBe(false);
});
});
describe('4. Complete Quiz Flow', () => {
let quizSessionId = null;
let categoryId = null;
it('should get available categories', async () => {
const res = await request(app)
.get('/api/categories')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
if (res.body.data.length > 0) {
categoryId = res.body.data[0].id;
}
});
it('should start a quiz session', async () => {
if (!categoryId) {
console.log('Skipping quiz tests - no categories available');
return;
}
const res = await request(app)
.post('/api/quiz/start')
.set('Authorization', `Bearer ${userToken}`)
.send({
categoryId: categoryId,
quizType: 'practice',
difficulty: 'easy',
questionCount: 5
})
.expect(201);
expect(res.body.success).toBe(true);
expect(res.body.data.sessionId).toBeDefined();
quizSessionId = res.body.data.sessionId;
});
it('should get current quiz session', async () => {
if (!quizSessionId) {
return;
}
const res = await request(app)
.get(`/api/quiz/session/${quizSessionId}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data.id).toBe(quizSessionId);
});
it('should submit an answer', async () => {
if (!quizSessionId) {
return;
}
// Get the first question
const sessionRes = await request(app)
.get(`/api/quiz/session/${quizSessionId}`)
.set('Authorization', `Bearer ${userToken}`);
const questions = sessionRes.body.data.questions;
if (questions && questions.length > 0) {
const questionId = questions[0].id;
const correctOption = questions[0].correctOption;
const res = await request(app)
.post(`/api/quiz/session/${quizSessionId}/answer`)
.set('Authorization', `Bearer ${userToken}`)
.send({
questionId: questionId,
selectedOption: correctOption
})
.expect(200);
expect(res.body.success).toBe(true);
}
});
it('should complete quiz session', async () => {
if (!quizSessionId) {
return;
}
const res = await request(app)
.post(`/api/quiz/session/${quizSessionId}/complete`)
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data.status).toBe('completed');
});
});
describe('5. Authorization Scenarios', () => {
it('should allow authenticated user to access protected route', async () => {
const res = await request(app)
.get('/api/user/profile')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.success).toBe(true);
});
it('should deny unauthenticated access to protected route', async () => {
const res = await request(app)
.get('/api/user/profile')
.expect(401);
expect(res.body.success).toBe(false);
});
it('should deny non-admin access to admin route', async () => {
const res = await request(app)
.get('/api/admin/statistics')
.set('Authorization', `Bearer ${userToken}`)
.expect(403);
expect(res.body.success).toBe(false);
});
it('should allow admin access to admin route', async () => {
if (!adminToken) {
console.log('Skipping admin test - admin not logged in');
return;
}
const res = await request(app)
.get('/api/admin/statistics')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(res.body.success).toBe(true);
});
});
describe('6. Guest User Flow', () => {
let guestToken = null;
let guestId = null;
it('should create guest session', async () => {
const res = await request(app)
.post('/api/guest/session')
.expect(201);
expect(res.body.success).toBe(true);
expect(res.body.data.token).toBeDefined();
expect(res.body.data.guestId).toBeDefined();
guestToken = res.body.data.token;
guestId = res.body.data.guestId;
});
it('should allow guest to access public categories', async () => {
const res = await request(app)
.get('/api/guest/categories')
.set('Authorization', `Bearer ${guestToken}`)
.expect(200);
expect(res.body.success).toBe(true);
});
it('should convert guest to registered user', async () => {
if (!guestToken) {
return;
}
const res = await request(app)
.post('/api/guest/convert')
.set('Authorization', `Bearer ${guestToken}`)
.send({
username: 'convertedguest',
email: 'converted@guest.com',
password: 'Test123!@#'
})
.expect(201);
expect(res.body.success).toBe(true);
expect(res.body.data.user).toBeDefined();
expect(res.body.data.token).toBeDefined();
});
});
describe('7. Error Handling Scenarios', () => {
it('should return 404 for non-existent route', async () => {
const res = await request(app)
.get('/api/nonexistent')
.expect(404);
expect(res.body.success).toBe(false);
});
it('should handle malformed JSON', async () => {
const res = await request(app)
.post('/api/auth/login')
.set('Content-Type', 'application/json')
.send('invalid json{')
.expect(400);
});
it('should validate required fields', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({
username: 'test'
// missing email and password
})
.expect(400);
expect(res.body.success).toBe(false);
});
it('should handle database errors gracefully', async () => {
// Try to access non-existent resource
const res = await request(app)
.get('/api/quiz/session/00000000-0000-0000-0000-000000000000')
.set('Authorization', `Bearer ${userToken}`)
.expect(404);
expect(res.body.success).toBe(false);
});
});
describe('8. User Profile Flow', () => {
it('should get user profile', async () => {
const res = await request(app)
.get('/api/user/profile')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data.username).toBe(testUser.username);
});
it('should get user statistics', async () => {
const res = await request(app)
.get('/api/user/statistics')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data.totalQuizzesTaken).toBeDefined();
});
it('should get quiz history', async () => {
const res = await request(app)
.get('/api/user/quiz-history')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data.sessions)).toBe(true);
});
});
describe('9. Logout Flow', () => {
it('should logout successfully', async () => {
const res = await request(app)
.post('/api/auth/logout')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.success).toBe(true);
});
it('should not access protected route after logout', async () => {
// Note: JWT tokens are stateless, so this test depends on token expiration
// In a real scenario with token blacklisting, this would fail
// For now, we just verify the logout endpoint works
const res = await request(app)
.post('/api/auth/logout')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.success).toBe(true);
});
});
});

View File

@@ -0,0 +1,688 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
// Category UUIDs from database
const CATEGORY_IDS = {
JAVASCRIPT: '68b4c87f-db0b-48ea-b8a4-b2f4fce785a2',
NODEJS: '5e3094ab-ab6d-4f8a-9261-8177b9c979ae',
};
let adminToken = '';
let regularUserToken = '';
let guestToken = '';
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}`);
if (error.response?.data) {
console.log(` Response:`, JSON.stringify(error.response.data, null, 2));
}
}
}
// Setup: Create test questions and login
async function setup() {
try {
console.log('Setting up test data...\n');
// 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 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');
// Start guest session
const deviceId = `test-device-${timestamp}`;
const guestSession = await axios.post(`${BASE_URL}/guest/start-session`, { deviceId });
guestToken = guestSession.data.data.guestToken;
console.log('✓ Started guest session');
// Create test questions with different difficulties and categories
const testQuestions = [
{
questionText: 'What is the purpose of async/await in JavaScript?',
questionType: 'multiple',
options: [
{ id: 'a', text: 'To handle asynchronous operations' },
{ id: 'b', text: 'To create functions' },
{ id: 'c', text: 'To define classes' },
{ id: 'd', text: 'To handle errors' }
],
correctAnswer: 'a',
difficulty: 'easy',
explanation: 'Async/await is syntactic sugar for promises, making asynchronous code easier to read.',
categoryId: CATEGORY_IDS.JAVASCRIPT,
tags: ['async', 'promises', 'es6'],
points: 5
},
{
questionText: 'What is the difference between let and const in JavaScript?',
questionType: 'multiple',
options: [
{ id: 'a', text: 'No difference' },
{ id: 'b', text: 'const cannot be reassigned' },
{ id: 'c', text: 'let is global only' },
{ id: 'd', text: 'const is faster' }
],
correctAnswer: 'b',
difficulty: 'easy',
explanation: 'const creates a read-only reference to a value, while let allows reassignment.',
categoryId: CATEGORY_IDS.JAVASCRIPT,
tags: ['variables', 'es6'],
points: 5
},
{
questionText: 'What is a Promise in JavaScript?',
questionType: 'multiple',
options: [
{ id: 'a', text: 'A commitment to execute code' },
{ id: 'b', text: 'An object representing eventual completion of an async operation' },
{ id: 'c', text: 'A type of loop' },
{ id: 'd', text: 'A conditional statement' }
],
correctAnswer: 'b',
difficulty: 'medium',
explanation: 'A Promise is an object representing the eventual completion or failure of an asynchronous operation.',
categoryId: CATEGORY_IDS.JAVASCRIPT,
tags: ['promises', 'async'],
points: 10
},
{
questionText: 'What is event bubbling in JavaScript?',
questionType: 'multiple',
options: [
{ id: 'a', text: 'Events propagate from child to parent' },
{ id: 'b', text: 'Events disappear' },
{ id: 'c', text: 'Events multiply' },
{ id: 'd', text: 'Events get delayed' }
],
correctAnswer: 'a',
difficulty: 'medium',
explanation: 'Event bubbling is when an event propagates from the target element up through its ancestors.',
categoryId: CATEGORY_IDS.JAVASCRIPT,
tags: ['events', 'dom'],
points: 10
},
{
questionText: 'Explain the prototype chain in JavaScript',
questionType: 'written',
correctAnswer: 'The prototype chain is a mechanism where objects inherit properties from their prototype.',
difficulty: 'hard',
explanation: 'JavaScript uses prototypal inheritance where objects can inherit properties from other objects.',
categoryId: CATEGORY_IDS.JAVASCRIPT,
tags: ['prototypes', 'inheritance', 'oop'],
points: 15
},
{
questionText: 'What is Node.js used for?',
questionType: 'multiple',
options: [
{ id: 'a', text: 'Server-side JavaScript runtime' },
{ id: 'b', text: 'A frontend framework' },
{ id: 'c', text: 'A database' },
{ id: 'd', text: 'A CSS preprocessor' }
],
correctAnswer: 'a',
difficulty: 'easy',
explanation: 'Node.js is a JavaScript runtime built on Chrome\'s V8 engine for server-side development.',
categoryId: CATEGORY_IDS.NODEJS,
tags: ['nodejs', 'runtime'],
points: 5
},
{
questionText: 'What is Express.js in Node.js?',
questionType: 'multiple',
options: [
{ id: 'a', text: 'A web application framework' },
{ id: 'b', text: 'A database' },
{ id: 'c', text: 'A testing library' },
{ id: 'd', text: 'A package manager' }
],
correctAnswer: 'a',
difficulty: 'easy',
explanation: 'Express.js is a minimal and flexible Node.js web application framework.',
categoryId: CATEGORY_IDS.NODEJS,
tags: ['express', 'framework', 'web'],
points: 5
},
{
questionText: 'What is middleware in Express.js?',
questionType: 'multiple',
options: [
{ id: 'a', text: 'Functions that execute during request-response cycle' },
{ id: 'b', text: 'A type of database' },
{ id: 'c', text: 'A routing mechanism' },
{ id: 'd', text: 'A template engine' }
],
correctAnswer: 'a',
difficulty: 'medium',
explanation: 'Middleware functions have access to request, response objects and the next middleware function.',
categoryId: CATEGORY_IDS.NODEJS,
tags: ['express', 'middleware'],
points: 10
}
];
// Create all test questions
for (const questionData of testQuestions) {
const response = await axios.post(`${BASE_URL}/admin/questions`, questionData, {
headers: { Authorization: `Bearer ${adminToken}` }
});
createdQuestionIds.push(response.data.data.id);
}
console.log(`✓ Created ${createdQuestionIds.length} test questions\n`);
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
process.exit(1);
}
}
// Cleanup: Delete test questions
async function cleanup() {
console.log('\n========================================');
console.log('Cleaning up test data...');
console.log('========================================\n');
for (const questionId of createdQuestionIds) {
try {
await axios.delete(`${BASE_URL}/admin/questions/${questionId}`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
} catch (error) {
console.log(`Warning: Could not delete question ${questionId}`);
}
}
console.log(`✓ Deleted ${createdQuestionIds.length} test questions`);
}
// Tests
async function runTests() {
console.log('========================================');
console.log('Testing Admin Questions Pagination & Search API');
console.log('========================================\n');
await setup();
// ========================================
// AUTHORIZATION TESTS
// ========================================
console.log('\n--- Authorization Tests ---\n');
await runTest('Test 1: Guest cannot access admin questions endpoint', async () => {
try {
await axios.get(`${BASE_URL}/admin/questions`, {
headers: { 'x-guest-token': guestToken }
});
throw new Error('Guest should not have access');
} catch (error) {
if (error.response?.status !== 401 && error.response?.status !== 403) {
throw new Error(`Expected 401 or 403, got ${error.response?.status}`);
}
}
});
await runTest('Test 2: Regular user cannot access admin questions endpoint', async () => {
try {
await axios.get(`${BASE_URL}/admin/questions`, {
headers: { Authorization: `Bearer ${regularUserToken}` }
});
throw new Error('Regular user should not have access');
} catch (error) {
if (error.response?.status !== 403) {
throw new Error(`Expected 403, got ${error.response?.status}`);
}
}
});
await runTest('Test 3: Admin can access questions endpoint', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.status !== 200) throw new Error(`Expected 200, got ${response.status}`);
if (!response.data.success) throw new Error('Response should be successful');
});
// ========================================
// PAGINATION TESTS
// ========================================
console.log('\n--- Pagination Tests ---\n');
await runTest('Test 4: Default pagination (page 1, limit 10)', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.page !== 1) throw new Error('Default page should be 1');
if (response.data.limit !== 10) throw new Error('Default limit should be 10');
if (!Array.isArray(response.data.data)) throw new Error('Data should be an array');
if (response.data.count > 10) throw new Error('Count should not exceed limit');
});
await runTest('Test 5: Custom pagination (page 2, limit 5)', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?page=2&limit=5`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.page !== 2) throw new Error('Page should be 2');
if (response.data.limit !== 5) throw new Error('Limit should be 5');
if (response.data.count > 5) throw new Error('Count should not exceed 5');
});
await runTest('Test 6: Pagination metadata is correct', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?page=1&limit=3`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (typeof response.data.total !== 'number') throw new Error('Total should be a number');
if (typeof response.data.totalPages !== 'number') throw new Error('TotalPages should be a number');
if (response.data.totalPages !== Math.ceil(response.data.total / 3)) {
throw new Error('TotalPages calculation is incorrect');
}
});
await runTest('Test 7: Maximum limit enforcement (max 100)', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?limit=200`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.limit > 100) throw new Error('Limit should be capped at 100');
});
await runTest('Test 8: Invalid page defaults to 1', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?page=-5`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.page !== 1) throw new Error('Invalid page should default to 1');
});
// ========================================
// SEARCH TESTS
// ========================================
console.log('\n--- Search Tests ---\n');
await runTest('Test 9: Search by question text (async)', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?search=async`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.count === 0) throw new Error('Should find questions with "async"');
const hasAsyncQuestion = response.data.data.some(q =>
q.questionText.toLowerCase().includes('async') ||
q.tags?.includes('async')
);
if (!hasAsyncQuestion) throw new Error('Results should contain "async" in text or tags');
});
await runTest('Test 10: Search by explanation text (promise)', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?search=promise`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.count === 0) throw new Error('Should find questions about promises');
});
await runTest('Test 11: Search with no results', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?search=xyznonexistent123`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.count !== 0) throw new Error('Should return 0 results for non-existent term');
if (response.data.data.length !== 0) throw new Error('Data array should be empty');
});
await runTest('Test 12: Search with special characters is handled', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?search=%$#@`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.status !== 200) throw new Error('Should handle special characters gracefully');
});
// ========================================
// FILTER TESTS
// ========================================
console.log('\n--- Filter Tests ---\n');
await runTest('Test 13: Filter by difficulty (easy)', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?difficulty=easy`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.count === 0) throw new Error('Should find easy questions');
const allEasy = response.data.data.every(q => q.difficulty === 'easy');
if (!allEasy) throw new Error('All questions should have easy difficulty');
});
await runTest('Test 14: Filter by difficulty (medium)', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?difficulty=medium`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
const allMedium = response.data.data.every(q => q.difficulty === 'medium');
if (!allMedium) throw new Error('All questions should have medium difficulty');
});
await runTest('Test 15: Filter by difficulty (hard)', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?difficulty=hard`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
const allHard = response.data.data.every(q => q.difficulty === 'hard');
if (!allHard) throw new Error('All questions should have hard difficulty');
});
await runTest('Test 16: Filter by category (JavaScript)', async () => {
const response = await axios.get(
`${BASE_URL}/admin/questions?category=${CATEGORY_IDS.JAVASCRIPT}`,
{ headers: { Authorization: `Bearer ${adminToken}` } }
);
if (response.data.count === 0) throw new Error('Should find JavaScript questions');
const allJavaScript = response.data.data.every(
q => q.category.id === CATEGORY_IDS.JAVASCRIPT
);
if (!allJavaScript) throw new Error('All questions should be in JavaScript category');
});
await runTest('Test 17: Filter by category (Node.js)', async () => {
const response = await axios.get(
`${BASE_URL}/admin/questions?category=${CATEGORY_IDS.NODEJS}`,
{ headers: { Authorization: `Bearer ${adminToken}` } }
);
const allNodejs = response.data.data.every(
q => q.category.id === CATEGORY_IDS.NODEJS
);
if (!allNodejs) throw new Error('All questions should be in Node.js category');
});
await runTest('Test 18: Invalid category UUID is ignored', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?category=invalid-uuid`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.status !== 200) throw new Error('Should handle invalid UUID gracefully');
});
// ========================================
// COMBINED FILTER TESTS
// ========================================
console.log('\n--- Combined Filter Tests ---\n');
await runTest('Test 19: Search + difficulty filter', async () => {
const response = await axios.get(
`${BASE_URL}/admin/questions?search=javascript&difficulty=easy`,
{ headers: { Authorization: `Bearer ${adminToken}` } }
);
if (response.status !== 200) throw new Error('Combined filters should work');
const allEasy = response.data.data.every(q => q.difficulty === 'easy');
if (!allEasy) throw new Error('All results should match difficulty filter');
});
await runTest('Test 20: Search + category filter', async () => {
const response = await axios.get(
`${BASE_URL}/admin/questions?search=async&category=${CATEGORY_IDS.JAVASCRIPT}`,
{ headers: { Authorization: `Bearer ${adminToken}` } }
);
const allCorrectCategory = response.data.data.every(
q => q.category.id === CATEGORY_IDS.JAVASCRIPT
);
if (!allCorrectCategory) throw new Error('All results should match category filter');
});
await runTest('Test 21: Category + difficulty filter', async () => {
const response = await axios.get(
`${BASE_URL}/admin/questions?category=${CATEGORY_IDS.JAVASCRIPT}&difficulty=medium`,
{ headers: { Authorization: `Bearer ${adminToken}` } }
);
const allMatch = response.data.data.every(
q => q.category.id === CATEGORY_IDS.JAVASCRIPT && q.difficulty === 'medium'
);
if (!allMatch) throw new Error('All results should match both filters');
});
await runTest('Test 22: All filters combined', async () => {
const response = await axios.get(
`${BASE_URL}/admin/questions?search=event&category=${CATEGORY_IDS.JAVASCRIPT}&difficulty=medium&limit=5`,
{ headers: { Authorization: `Bearer ${adminToken}` } }
);
if (response.status !== 200) throw new Error('All filters should work together');
});
// ========================================
// SORTING TESTS
// ========================================
console.log('\n--- Sorting Tests ---\n');
await runTest('Test 23: Sort by createdAt DESC (default)', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?limit=5`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.data.length < 2) return; // Skip if not enough data
const dates = response.data.data.map(q => new Date(q.createdAt).getTime());
const isSorted = dates.every((date, i) => i === 0 || date <= dates[i - 1]);
if (!isSorted) throw new Error('Questions should be sorted by createdAt DESC');
});
await runTest('Test 24: Sort by createdAt ASC', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?sortBy=createdAt&order=ASC&limit=5`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.data.length < 2) return;
const dates = response.data.data.map(q => new Date(q.createdAt).getTime());
const isSorted = dates.every((date, i) => i === 0 || date >= dates[i - 1]);
if (!isSorted) throw new Error('Questions should be sorted by createdAt ASC');
});
await runTest('Test 25: Sort by difficulty', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?sortBy=difficulty&order=ASC`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.status !== 200) throw new Error('Should be able to sort by difficulty');
});
await runTest('Test 26: Sort by points DESC', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?sortBy=points&order=DESC&limit=5`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.data.length < 2) return;
const points = response.data.data.map(q => q.points);
const isSorted = points.every((point, i) => i === 0 || point <= points[i - 1]);
if (!isSorted) throw new Error('Questions should be sorted by points DESC');
});
await runTest('Test 27: Invalid sort field defaults to createdAt', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?sortBy=invalidField`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.status !== 200) throw new Error('Invalid sort field should be handled gracefully');
if (response.data.filters.sortBy !== 'createdAt') {
throw new Error('Invalid sort field should default to createdAt');
}
});
// ========================================
// RESPONSE STRUCTURE TESTS
// ========================================
console.log('\n--- Response Structure Tests ---\n');
await runTest('Test 28: Response has correct structure', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
const requiredFields = ['success', 'count', 'total', 'page', 'totalPages', 'limit', 'filters', 'data', 'message'];
for (const field of requiredFields) {
if (!(field in response.data)) {
throw new Error(`Response missing required field: ${field}`);
}
}
});
await runTest('Test 29: Each question has required fields', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?limit=1`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.data.length === 0) return;
const question = response.data.data[0];
const requiredFields = [
'id', 'questionText', 'questionType', 'difficulty', 'points',
'explanation', 'category', 'isActive', 'createdAt', 'accuracy'
];
for (const field of requiredFields) {
if (!(field in question)) {
throw new Error(`Question missing required field: ${field}`);
}
}
});
await runTest('Test 30: Category object has required fields', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?limit=1`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.data.length === 0) return;
const category = response.data.data[0].category;
const requiredFields = ['id', 'name', 'slug', 'icon', 'color'];
for (const field of requiredFields) {
if (!(field in category)) {
throw new Error(`Category missing required field: ${field}`);
}
}
});
await runTest('Test 31: Filters object in response matches query', async () => {
const response = await axios.get(
`${BASE_URL}/admin/questions?search=test&difficulty=easy&sortBy=points&order=ASC`,
{ headers: { Authorization: `Bearer ${adminToken}` } }
);
if (response.data.filters.search !== 'test') throw new Error('Search filter not reflected');
if (response.data.filters.difficulty !== 'easy') throw new Error('Difficulty filter not reflected');
if (response.data.filters.sortBy !== 'points') throw new Error('SortBy not reflected');
if (response.data.filters.order !== 'ASC') throw new Error('Order not reflected');
});
await runTest('Test 32: Admin can see correctAnswer field', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?limit=1`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.data.length === 0) return;
const question = response.data.data[0];
if (!('correctAnswer' in question)) {
throw new Error('Admin should see correctAnswer field');
}
});
// ========================================
// PERFORMANCE & EDGE CASES
// ========================================
console.log('\n--- Performance & Edge Cases ---\n');
await runTest('Test 33: Empty search string returns all questions', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?search=`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.status !== 200) throw new Error('Empty search should work');
});
await runTest('Test 34: Page beyond total pages returns empty array', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?page=9999`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.data.length > 0) throw new Error('Page beyond total should return empty');
});
await runTest('Test 35: Accuracy is calculated correctly', async () => {
const response = await axios.get(`${BASE_URL}/admin/questions?limit=1`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (response.data.data.length === 0) return;
const question = response.data.data[0];
if (typeof question.accuracy !== 'number') {
throw new Error('Accuracy should be a number');
}
if (question.accuracy < 0 || question.accuracy > 100) {
throw new Error('Accuracy should be between 0 and 100');
}
});
// Cleanup
await cleanup();
// Print summary
console.log('\n========================================');
console.log('Test Summary');
console.log('========================================');
console.log(`Total Tests: ${testResults.total}`);
console.log(`Passed: ${testResults.passed}`);
console.log(`Failed: ${testResults.failed}`);
console.log(`Success Rate: ${((testResults.passed / testResults.total) * 100).toFixed(2)}%`);
console.log('========================================\n');
process.exit(testResults.failed > 0 ? 1 : 0);
}
// Run tests
runTests().catch(error => {
console.error('Test suite failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,412 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
// Test configuration
const testConfig = {
adminUser: {
email: 'admin@example.com',
password: 'Admin123!@#',
username: 'adminuser'
},
regularUser: {
email: 'stattest@example.com',
password: 'Test123!@#',
username: 'stattest'
}
};
// Test state
let adminToken = null;
let regularToken = null;
// Test results
let passedTests = 0;
let failedTests = 0;
const results = [];
// Helper function to log test results
function logTest(name, passed, error = null) {
results.push({ name, passed, error });
if (passed) {
console.log(`${name}`);
passedTests++;
} else {
console.log(`${name}`);
if (error) console.log(` Error: ${error}`);
failedTests++;
}
}
// Setup function
async function setup() {
console.log('Setting up test data...\n');
try {
// Register/Login admin user
try {
await axios.post(`${BASE_URL}/auth/register`, {
email: testConfig.adminUser.email,
password: testConfig.adminUser.password,
username: testConfig.adminUser.username
});
} catch (error) {
if (error.response?.status === 409) {
console.log('Admin user already registered');
}
}
const adminLoginRes = await axios.post(`${BASE_URL}/auth/login`, {
email: testConfig.adminUser.email,
password: testConfig.adminUser.password
});
adminToken = adminLoginRes.data.data.token;
console.log('✓ Admin user logged in');
// Manually set admin role in database if needed
// This would typically be done through a database migration or admin tool
// For testing, you may need to manually update the user role to 'admin' in the database
// Register/Login regular user
try {
await axios.post(`${BASE_URL}/auth/register`, {
email: testConfig.regularUser.email,
password: testConfig.regularUser.password,
username: testConfig.regularUser.username
});
} catch (error) {
if (error.response?.status === 409) {
console.log('Regular user already registered');
}
}
const userLoginRes = await axios.post(`${BASE_URL}/auth/login`, {
email: testConfig.regularUser.email,
password: testConfig.regularUser.password
});
regularToken = userLoginRes.data.data.token;
console.log('✓ Regular user logged in');
console.log('\n============================================================');
console.log('ADMIN STATISTICS API TESTS');
console.log('============================================================\n');
console.log('NOTE: Admin user must have role="admin" in database');
console.log('If tests fail due to authorization, update user role manually:\n');
console.log(`UPDATE users SET role='admin' WHERE email='${testConfig.adminUser.email}';`);
console.log('\n============================================================\n');
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
process.exit(1);
}
}
// Test functions
async function testGetStatistics() {
try {
const response = await axios.get(`${BASE_URL}/admin/statistics`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
const passed = response.status === 200 &&
response.data.success === true &&
response.data.data !== undefined;
logTest('Get statistics successfully', passed);
return response.data.data;
} catch (error) {
logTest('Get statistics successfully', false, error.response?.data?.message || error.message);
return null;
}
}
async function testStatisticsStructure(stats) {
if (!stats) {
logTest('Statistics structure validation', false, 'No statistics data available');
return;
}
try {
// Check users section
const hasUsers = stats.users &&
typeof stats.users.total === 'number' &&
typeof stats.users.active === 'number' &&
typeof stats.users.inactiveLast7Days === 'number';
// Check quizzes section
const hasQuizzes = stats.quizzes &&
typeof stats.quizzes.totalSessions === 'number' &&
typeof stats.quizzes.averageScore === 'number' &&
typeof stats.quizzes.averageScorePercentage === 'number' &&
typeof stats.quizzes.passRate === 'number' &&
typeof stats.quizzes.passedQuizzes === 'number' &&
typeof stats.quizzes.failedQuizzes === 'number';
// Check content section
const hasContent = stats.content &&
typeof stats.content.totalCategories === 'number' &&
typeof stats.content.totalQuestions === 'number' &&
stats.content.questionsByDifficulty &&
typeof stats.content.questionsByDifficulty.easy === 'number' &&
typeof stats.content.questionsByDifficulty.medium === 'number' &&
typeof stats.content.questionsByDifficulty.hard === 'number';
// Check popular categories
const hasPopularCategories = Array.isArray(stats.popularCategories);
// Check user growth
const hasUserGrowth = Array.isArray(stats.userGrowth);
// Check quiz activity
const hasQuizActivity = Array.isArray(stats.quizActivity);
const passed = hasUsers && hasQuizzes && hasContent &&
hasPopularCategories && hasUserGrowth && hasQuizActivity;
logTest('Statistics structure validation', passed);
} catch (error) {
logTest('Statistics structure validation', false, error.message);
}
}
async function testUsersSection(stats) {
if (!stats) {
logTest('Users section fields', false, 'No statistics data available');
return;
}
try {
const users = stats.users;
const passed = users.total >= 0 &&
users.active >= 0 &&
users.inactiveLast7Days >= 0 &&
users.active + users.inactiveLast7Days === users.total;
logTest('Users section fields', passed);
} catch (error) {
logTest('Users section fields', false, error.message);
}
}
async function testQuizzesSection(stats) {
if (!stats) {
logTest('Quizzes section fields', false, 'No statistics data available');
return;
}
try {
const quizzes = stats.quizzes;
const passed = quizzes.totalSessions >= 0 &&
quizzes.averageScore >= 0 &&
quizzes.averageScorePercentage >= 0 &&
quizzes.averageScorePercentage <= 100 &&
quizzes.passRate >= 0 &&
quizzes.passRate <= 100 &&
quizzes.passedQuizzes >= 0 &&
quizzes.failedQuizzes >= 0 &&
quizzes.passedQuizzes + quizzes.failedQuizzes === quizzes.totalSessions;
logTest('Quizzes section fields', passed);
} catch (error) {
logTest('Quizzes section fields', false, error.message);
}
}
async function testContentSection(stats) {
if (!stats) {
logTest('Content section fields', false, 'No statistics data available');
return;
}
try {
const content = stats.content;
const difficulty = content.questionsByDifficulty;
const totalQuestionsByDifficulty = difficulty.easy + difficulty.medium + difficulty.hard;
const passed = content.totalCategories >= 0 &&
content.totalQuestions >= 0 &&
totalQuestionsByDifficulty === content.totalQuestions;
logTest('Content section fields', passed);
} catch (error) {
logTest('Content section fields', false, error.message);
}
}
async function testPopularCategories(stats) {
if (!stats) {
logTest('Popular categories structure', false, 'No statistics data available');
return;
}
try {
const categories = stats.popularCategories;
if (categories.length === 0) {
logTest('Popular categories structure', true);
return;
}
const firstCategory = categories[0];
const passed = firstCategory.id !== undefined &&
firstCategory.name !== undefined &&
firstCategory.slug !== undefined &&
typeof firstCategory.quizCount === 'number' &&
typeof firstCategory.averageScore === 'number' &&
categories.length <= 5; // Max 5 categories
logTest('Popular categories structure', passed);
} catch (error) {
logTest('Popular categories structure', false, error.message);
}
}
async function testUserGrowth(stats) {
if (!stats) {
logTest('User growth data structure', false, 'No statistics data available');
return;
}
try {
const growth = stats.userGrowth;
if (growth.length === 0) {
logTest('User growth data structure', true);
return;
}
const firstEntry = growth[0];
const passed = firstEntry.date !== undefined &&
typeof firstEntry.newUsers === 'number' &&
growth.length <= 30; // Max 30 days
logTest('User growth data structure', passed);
} catch (error) {
logTest('User growth data structure', false, error.message);
}
}
async function testQuizActivity(stats) {
if (!stats) {
logTest('Quiz activity data structure', false, 'No statistics data available');
return;
}
try {
const activity = stats.quizActivity;
if (activity.length === 0) {
logTest('Quiz activity data structure', true);
return;
}
const firstEntry = activity[0];
const passed = firstEntry.date !== undefined &&
typeof firstEntry.quizzesCompleted === 'number' &&
activity.length <= 30; // Max 30 days
logTest('Quiz activity data structure', passed);
} catch (error) {
logTest('Quiz activity data structure', false, error.message);
}
}
async function testNonAdminBlocked() {
try {
await axios.get(`${BASE_URL}/admin/statistics`, {
headers: { Authorization: `Bearer ${regularToken}` }
});
logTest('Non-admin user blocked', false, 'Regular user should not have access');
} catch (error) {
const passed = error.response?.status === 403;
logTest('Non-admin user blocked', passed,
!passed ? `Expected 403, got ${error.response?.status}` : null);
}
}
async function testUnauthenticated() {
try {
await axios.get(`${BASE_URL}/admin/statistics`);
logTest('Unauthenticated request blocked', false, 'Should require authentication');
} catch (error) {
const passed = error.response?.status === 401;
logTest('Unauthenticated request blocked', passed,
!passed ? `Expected 401, got ${error.response?.status}` : null);
}
}
async function testInvalidToken() {
try {
await axios.get(`${BASE_URL}/admin/statistics`, {
headers: { Authorization: 'Bearer invalid-token-123' }
});
logTest('Invalid token rejected', false, 'Invalid token should be rejected');
} catch (error) {
const passed = error.response?.status === 401;
logTest('Invalid token rejected', passed,
!passed ? `Expected 401, got ${error.response?.status}` : null);
}
}
// Main test runner
async function runTests() {
await setup();
console.log('Running tests...\n');
// Basic functionality tests
const stats = await testGetStatistics();
await new Promise(resolve => setTimeout(resolve, 100));
// Structure validation tests
await testStatisticsStructure(stats);
await new Promise(resolve => setTimeout(resolve, 100));
await testUsersSection(stats);
await new Promise(resolve => setTimeout(resolve, 100));
await testQuizzesSection(stats);
await new Promise(resolve => setTimeout(resolve, 100));
await testContentSection(stats);
await new Promise(resolve => setTimeout(resolve, 100));
await testPopularCategories(stats);
await new Promise(resolve => setTimeout(resolve, 100));
await testUserGrowth(stats);
await new Promise(resolve => setTimeout(resolve, 100));
await testQuizActivity(stats);
await new Promise(resolve => setTimeout(resolve, 100));
// Authorization tests
await testNonAdminBlocked();
await new Promise(resolve => setTimeout(resolve, 100));
await testUnauthenticated();
await new Promise(resolve => setTimeout(resolve, 100));
await testInvalidToken();
// Print results
console.log('\n============================================================');
console.log(`RESULTS: ${passedTests} passed, ${failedTests} failed out of ${passedTests + failedTests} tests`);
console.log('============================================================\n');
if (failedTests > 0) {
console.log('Failed tests:');
results.filter(r => !r.passed).forEach(r => {
console.log(` - ${r.name}`);
if (r.error) console.log(` ${r.error}`);
});
}
process.exit(failedTests > 0 ? 1 : 0);
}
// Run tests
runTests().catch(error => {
console.error('Test execution failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,776 @@
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 credentials (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',
magenta: '\x1b[35m'
};
let adminToken = null;
let regularUserToken = null;
let testCategoryId = null;
let testQuestionId = null;
// Test counters
let totalTests = 0;
let passedTests = 0;
let failedTests = 0;
/**
* Helper: Log test result
*/
function logTestResult(testName, passed, error = null) {
totalTests++;
if (passed) {
passedTests++;
console.log(`${colors.green}${testName}${colors.reset}`);
} else {
failedTests++;
console.log(`${colors.red}${testName}${colors.reset}`);
if (error) {
console.log(` ${colors.red}Error: ${error}${colors.reset}`);
}
}
}
/**
* 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 {
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;
}
}
/**
* Get first active category
*/
async function getFirstCategory() {
try {
const response = await axios.get(`${API_URL}/categories`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
if (response.data.data && response.data.data.length > 0) {
testCategoryId = response.data.data[0].id;
console.log(`${colors.cyan}✓ Got test category: ${testCategoryId}${colors.reset}`);
return testCategoryId;
}
throw new Error('No categories found');
} catch (error) {
console.error(`${colors.red}✗ Failed to get category:${colors.reset}`, error.response?.data || error.message);
throw error;
}
}
/**
* Create a test question
*/
async function createTestQuestion() {
try {
const questionData = {
questionText: 'What is the capital of France?',
questionType: 'multiple',
options: [
{ id: 'a', text: 'Paris' },
{ id: 'b', text: 'London' },
{ id: 'c', text: 'Berlin' },
{ id: 'd', text: 'Madrid' }
],
correctAnswer: 'a',
difficulty: 'easy',
points: 10,
explanation: 'Paris is the capital and largest city of France.',
categoryId: testCategoryId,
tags: ['geography', 'capitals'],
keywords: ['france', 'paris', 'capital']
};
const response = await axios.post(`${API_URL}/admin/questions`, questionData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
testQuestionId = response.data.data.id;
console.log(`${colors.cyan}✓ Created test question: ${testQuestionId}${colors.reset}`);
return testQuestionId;
} catch (error) {
console.error(`${colors.red}✗ Failed to create test question:${colors.reset}`);
console.error('Status:', error.response?.status);
console.error('Data:', JSON.stringify(error.response?.data, null, 2));
console.error('Message:', error.message);
throw error;
}
}
// ============================================================================
// TEST SUITE: UPDATE QUESTION ENDPOINT
// ============================================================================
/**
* Test 1: Unauthenticated request cannot update question (401)
*/
async function test01_UnauthenticatedCannotUpdate() {
console.log(`\n${colors.blue}Test 1: Unauthenticated request cannot update question${colors.reset}`);
try {
const updateData = {
questionText: 'Updated question text'
};
await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData);
logTestResult('Test 1: Unauthenticated request cannot update question', false, 'Should have returned 401');
} catch (error) {
const status = error.response?.status;
const passed = status === 401;
logTestResult('Test 1: Unauthenticated request cannot update question', passed, passed ? null : `Expected 401, got ${status}`);
}
}
/**
* Test 2: Regular user cannot update question (403)
*/
async function test02_UserCannotUpdate() {
console.log(`\n${colors.blue}Test 2: Regular user cannot update question${colors.reset}`);
try {
const updateData = {
questionText: 'Updated question text'
};
await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${regularUserToken}` }
});
logTestResult('Test 2: Regular user cannot update question', false, 'Should have returned 403');
} catch (error) {
const status = error.response?.status;
const passed = status === 403;
logTestResult('Test 2: Regular user cannot update question', passed, passed ? null : `Expected 403, got ${status}`);
}
}
/**
* Test 3: Admin can update question text
*/
async function test03_UpdateQuestionText() {
console.log(`\n${colors.blue}Test 3: Admin can update question text${colors.reset}`);
try {
const updateData = {
questionText: 'What is the capital city of France?'
};
const response = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data } = response.data;
const passed = success &&
data.questionText === updateData.questionText &&
data.id === testQuestionId;
logTestResult('Test 3: Admin can update question text', passed,
passed ? null : 'Question text not updated correctly');
} catch (error) {
logTestResult('Test 3: Admin can update question text', false, error.response?.data?.message || error.message);
}
}
/**
* Test 4: Update difficulty level
*/
async function test04_UpdateDifficulty() {
console.log(`\n${colors.blue}Test 4: Update difficulty level${colors.reset}`);
try {
const updateData = {
difficulty: 'medium'
};
const response = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data } = response.data;
const passed = success && data.difficulty === 'medium';
logTestResult('Test 4: Update difficulty level', passed,
passed ? null : 'Difficulty not updated correctly');
} catch (error) {
logTestResult('Test 4: Update difficulty level', false, error.response?.data?.message || error.message);
}
}
/**
* Test 5: Update points
*/
async function test05_UpdatePoints() {
console.log(`\n${colors.blue}Test 5: Update points${colors.reset}`);
try {
const updateData = {
points: 20
};
const response = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data } = response.data;
const passed = success && data.points === 20;
logTestResult('Test 5: Update points', passed,
passed ? null : 'Points not updated correctly');
} catch (error) {
logTestResult('Test 5: Update points', false, error.response?.data?.message || error.message);
}
}
/**
* Test 6: Update explanation
*/
async function test06_UpdateExplanation() {
console.log(`\n${colors.blue}Test 6: Update explanation${colors.reset}`);
try {
const updateData = {
explanation: 'Paris has been the capital of France since the 12th century.'
};
const response = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data } = response.data;
const passed = success && data.explanation === updateData.explanation;
logTestResult('Test 6: Update explanation', passed,
passed ? null : 'Explanation not updated correctly');
} catch (error) {
logTestResult('Test 6: Update explanation', false, error.response?.data?.message || error.message);
}
}
/**
* Test 7: Update tags
*/
async function test07_UpdateTags() {
console.log(`\n${colors.blue}Test 7: Update tags${colors.reset}`);
try {
const updateData = {
tags: ['geography', 'europe', 'france', 'capitals']
};
const response = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data } = response.data;
const passed = success &&
Array.isArray(data.tags) &&
data.tags.length === 4 &&
data.tags.includes('europe');
logTestResult('Test 7: Update tags', passed,
passed ? null : 'Tags not updated correctly');
} catch (error) {
logTestResult('Test 7: Update tags', false, error.response?.data?.message || error.message);
}
}
/**
* Test 8: Update multiple choice options
*/
async function test08_UpdateOptions() {
console.log(`\n${colors.blue}Test 8: Update multiple choice options${colors.reset}`);
try {
const updateData = {
options: [
{ id: 'a', text: 'Paris' },
{ id: 'b', text: 'London' },
{ id: 'c', text: 'Berlin' },
{ id: 'd', text: 'Madrid' },
{ id: 'e', text: 'Rome' }
]
};
const response = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data } = response.data;
const passed = success &&
Array.isArray(data.options) &&
data.options.length === 5 &&
data.options.some(opt => opt.text === 'Rome');
logTestResult('Test 8: Update multiple choice options', passed,
passed ? null : 'Options not updated correctly');
} catch (error) {
logTestResult('Test 8: Update multiple choice options', false, error.response?.data?.message || error.message);
}
}
/**
* Test 9: Update correct answer
*/
async function test09_UpdateCorrectAnswer() {
console.log(`\n${colors.blue}Test 9: Update correct answer${colors.reset}`);
try {
// First update to add 'Lyon' as an option
await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, {
options: [
{ id: 'a', text: 'Paris' },
{ id: 'b', text: 'London' },
{ id: 'c', text: 'Berlin' },
{ id: 'd', text: 'Madrid' },
{ id: 'e', text: 'Lyon' }
]
}, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
// Note: correctAnswer is not returned in response for security
// We just verify the update succeeds
const updateData = {
correctAnswer: 'a' // Keep as 'a' (Paris) since it's still valid
};
const response = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success } = response.data;
const passed = success;
logTestResult('Test 9: Update correct answer', passed,
passed ? null : 'Update failed');
} catch (error) {
logTestResult('Test 9: Update correct answer', false, error.response?.data?.message || error.message);
}
}
/**
* Test 10: Update isActive status
*/
async function test10_UpdateIsActive() {
console.log(`\n${colors.blue}Test 10: Update isActive status${colors.reset}`);
try {
const updateData = {
isActive: false
};
const response = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data } = response.data;
const passed = success && data.isActive === false;
// Reactivate for other tests
await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, { isActive: true }, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
logTestResult('Test 10: Update isActive status', passed,
passed ? null : 'isActive not updated correctly');
} catch (error) {
logTestResult('Test 10: Update isActive status', false, error.response?.data?.message || error.message);
}
}
/**
* Test 11: Update multiple fields at once
*/
async function test11_UpdateMultipleFields() {
console.log(`\n${colors.blue}Test 11: Update multiple fields at once${colors.reset}`);
try {
const updateData = {
questionText: 'What is the capital and largest city of France?',
difficulty: 'hard',
points: 30,
explanation: 'Paris is both the capital and the most populous city of France.',
tags: ['geography', 'france', 'cities'],
keywords: ['france', 'paris', 'capital', 'city']
};
const response = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data } = response.data;
const passed = success &&
data.questionText === updateData.questionText &&
data.difficulty === 'hard' &&
data.points === 30 &&
data.explanation === updateData.explanation &&
data.tags.length === 3 &&
data.keywords.length === 4;
logTestResult('Test 11: Update multiple fields at once', passed,
passed ? null : 'Multiple fields not updated correctly');
} catch (error) {
logTestResult('Test 11: Update multiple fields at once', false, error.response?.data?.message || error.message);
}
}
/**
* Test 12: Invalid question ID (400)
*/
async function test12_InvalidQuestionId() {
console.log(`\n${colors.blue}Test 12: Invalid question ID${colors.reset}`);
try {
const updateData = {
questionText: 'Updated text'
};
await axios.put(`${API_URL}/admin/questions/invalid-id`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
logTestResult('Test 12: Invalid question ID', false, 'Should have returned 400');
} catch (error) {
const status = error.response?.status;
const passed = status === 400;
logTestResult('Test 12: Invalid question ID', passed, passed ? null : `Expected 400, got ${status}`);
}
}
/**
* Test 13: Non-existent question (404)
*/
async function test13_NonExistentQuestion() {
console.log(`\n${colors.blue}Test 13: Non-existent question${colors.reset}`);
try {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
const updateData = {
questionText: 'Updated text'
};
await axios.put(`${API_URL}/admin/questions/${fakeUuid}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
logTestResult('Test 13: Non-existent question', false, 'Should have returned 404');
} catch (error) {
const status = error.response?.status;
const passed = status === 404;
logTestResult('Test 13: Non-existent question', passed, passed ? null : `Expected 404, got ${status}`);
}
}
/**
* Test 14: Invalid difficulty value (400)
*/
async function test14_InvalidDifficulty() {
console.log(`\n${colors.blue}Test 14: Invalid difficulty value${colors.reset}`);
try {
const updateData = {
difficulty: 'super-hard'
};
await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
logTestResult('Test 14: Invalid difficulty value', false, 'Should have returned 400');
} catch (error) {
const status = error.response?.status;
const passed = status === 400;
logTestResult('Test 14: Invalid difficulty value', passed, passed ? null : `Expected 400, got ${status}`);
}
}
/**
* Test 15: Invalid points value (400)
*/
async function test15_InvalidPoints() {
console.log(`\n${colors.blue}Test 15: Invalid points value${colors.reset}`);
try {
const updateData = {
points: -10
};
await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
logTestResult('Test 15: Invalid points value', false, 'Should have returned 400');
} catch (error) {
const status = error.response?.status;
const passed = status === 400;
logTestResult('Test 15: Invalid points value', passed, passed ? null : `Expected 400, got ${status}`);
}
}
/**
* Test 16: Empty question text (400)
*/
async function test16_EmptyQuestionText() {
console.log(`\n${colors.blue}Test 16: Empty question text${colors.reset}`);
try {
const updateData = {
questionText: ''
};
await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
logTestResult('Test 16: Empty question text', false, 'Should have returned 400');
} catch (error) {
const status = error.response?.status;
const passed = status === 400;
logTestResult('Test 16: Empty question text', passed, passed ? null : `Expected 400, got ${status}`);
}
}
/**
* Test 17: Update with less than 2 options for multiple choice (400)
*/
async function test17_InsufficientOptions() {
console.log(`\n${colors.blue}Test 17: Insufficient options for multiple choice${colors.reset}`);
try {
const updateData = {
options: [{ id: 'a', text: 'Paris' }]
};
await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
logTestResult('Test 17: Insufficient options for multiple choice', false, 'Should have returned 400');
} catch (error) {
const status = error.response?.status;
const passed = status === 400;
logTestResult('Test 17: Insufficient options for multiple choice', passed, passed ? null : `Expected 400, got ${status}`);
}
}
/**
* Test 18: Correct answer not in options (400)
*/
async function test18_CorrectAnswerNotInOptions() {
console.log(`\n${colors.blue}Test 18: Correct answer not in options${colors.reset}`);
try {
const updateData = {
correctAnswer: 'z' // Invalid option ID
};
await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
logTestResult('Test 18: Correct answer not in options', false, 'Should have returned 400');
} catch (error) {
const status = error.response?.status;
const passed = status === 400;
logTestResult('Test 18: Correct answer not in options', passed, passed ? null : `Expected 400, got ${status}`);
}
}
/**
* Test 19: Update category to non-existent category (404)
*/
async function test19_NonExistentCategory() {
console.log(`\n${colors.blue}Test 19: Update to non-existent category${colors.reset}`);
try {
const fakeUuid = '00000000-0000-0000-0000-000000000000';
const updateData = {
categoryId: fakeUuid
};
await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
logTestResult('Test 19: Update to non-existent category', false, 'Should have returned 404');
} catch (error) {
const status = error.response?.status;
const passed = status === 404;
logTestResult('Test 19: Update to non-existent category', passed, passed ? null : `Expected 404, got ${status}`);
}
}
/**
* Test 20: Response doesn't include correctAnswer (security)
*/
async function test20_NoCorrectAnswerInResponse() {
console.log(`\n${colors.blue}Test 20: Response doesn't expose correct answer${colors.reset}`);
try {
const updateData = {
questionText: 'What is the capital of France? (Updated)'
};
const response = await axios.put(`${API_URL}/admin/questions/${testQuestionId}`, updateData, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const { success, data } = response.data;
const passed = success && !data.hasOwnProperty('correctAnswer') && !data.hasOwnProperty('correct_answer');
logTestResult('Test 20: Response doesn\'t expose correct answer', passed,
passed ? null : 'correctAnswer should not be in response');
} catch (error) {
logTestResult('Test 20: Response doesn\'t expose correct answer', false, error.response?.data?.message || error.message);
}
}
// ============================================================================
// CLEANUP
// ============================================================================
/**
* Delete test question
*/
async function deleteTestQuestion() {
try {
if (testQuestionId) {
await axios.delete(`${API_URL}/admin/questions/${testQuestionId}`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
console.log(`${colors.cyan}✓ Deleted test question${colors.reset}`);
}
} catch (error) {
console.error(`${colors.yellow}⚠ Failed to delete test question:${colors.reset}`, error.response?.data || error.message);
}
}
// ============================================================================
// MAIN TEST RUNNER
// ============================================================================
async function runAllTests() {
console.log(`${colors.magenta}
╔════════════════════════════════════════════════════════════╗
║ ADMIN UPDATE QUESTION ENDPOINT - TEST SUITE ║
╚════════════════════════════════════════════════════════════╝
${colors.reset}`);
try {
// Setup
console.log(`${colors.cyan}\n--- Setup Phase ---${colors.reset}`);
await loginAdmin();
await createRegularUser();
await getFirstCategory();
await createTestQuestion();
// Run tests
console.log(`${colors.cyan}\n--- Running Tests ---${colors.reset}`);
// Authorization tests
await test01_UnauthenticatedCannotUpdate();
await test02_UserCannotUpdate();
// Update field tests
await test03_UpdateQuestionText();
await test04_UpdateDifficulty();
await test05_UpdatePoints();
await test06_UpdateExplanation();
await test07_UpdateTags();
await test08_UpdateOptions();
await test09_UpdateCorrectAnswer();
await test10_UpdateIsActive();
await test11_UpdateMultipleFields();
// Error handling tests
await test12_InvalidQuestionId();
await test13_NonExistentQuestion();
await test14_InvalidDifficulty();
await test15_InvalidPoints();
await test16_EmptyQuestionText();
await test17_InsufficientOptions();
await test18_CorrectAnswerNotInOptions();
await test19_NonExistentCategory();
// Security tests
await test20_NoCorrectAnswerInResponse();
// Cleanup
console.log(`${colors.cyan}\n--- Cleanup Phase ---${colors.reset}`);
await deleteTestQuestion();
// Summary
console.log(`${colors.magenta}
╔════════════════════════════════════════════════════════════╗
║ TEST SUMMARY ║
╚════════════════════════════════════════════════════════════╝
${colors.reset}`);
console.log(`Total Tests: ${totalTests}`);
console.log(`${colors.green}Passed: ${passedTests}${colors.reset}`);
console.log(`${colors.red}Failed: ${failedTests}${colors.reset}`);
console.log(`Success Rate: ${((passedTests / totalTests) * 100).toFixed(2)}%\n`);
if (failedTests === 0) {
console.log(`${colors.green}✓ All tests passed!${colors.reset}\n`);
process.exit(0);
} else {
console.log(`${colors.red}✗ Some tests failed${colors.reset}\n`);
process.exit(1);
}
} catch (error) {
console.error(`${colors.red}\n✗ Test suite failed:${colors.reset}`, error.message);
process.exit(1);
}
}
// Run the tests
runAllTests();

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();

411
tests/test-bookmarks.js Normal file
View File

@@ -0,0 +1,411 @@
const axios = require('axios');
const API_URL = 'http://localhost:3000/api';
// Test users
const testUser = {
username: 'bookmarktest',
email: 'bookmarktest@example.com',
password: 'Test123!@#'
};
const secondUser = {
username: 'bookmarktest2',
email: 'bookmarktest2@example.com',
password: 'Test123!@#'
};
let userToken;
let userId;
let secondUserToken;
let secondUserId;
let questionId; // Will get from a real question
let categoryId; // Will get from a real category
// Test setup
async function setup() {
console.log('Setting up test data...\n');
try {
// Register/login user
try {
const registerRes = await axios.post(`${API_URL}/auth/register`, testUser);
userToken = registerRes.data.data.token;
userId = registerRes.data.data.user.id;
console.log('✓ Test user registered');
} catch (error) {
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: testUser.email,
password: testUser.password
});
userToken = loginRes.data.data.token;
userId = loginRes.data.data.user.id;
console.log('✓ Test user logged in');
}
// Register/login second user
try {
const registerRes = await axios.post(`${API_URL}/auth/register`, secondUser);
secondUserToken = registerRes.data.data.token;
secondUserId = registerRes.data.data.user.id;
console.log('✓ Second user registered');
} catch (error) {
const loginRes = await axios.post(`${API_URL}/auth/login`, {
email: secondUser.email,
password: secondUser.password
});
secondUserToken = loginRes.data.data.token;
secondUserId = loginRes.data.data.user.id;
console.log('✓ Second user logged in');
}
// Get a real category with questions
const categoriesRes = await axios.get(`${API_URL}/categories`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (!categoriesRes.data.data || categoriesRes.data.data.length === 0) {
throw new Error('No categories available for testing');
}
// Find a category that has questions
let foundQuestion = false;
for (const category of categoriesRes.data.data) {
if (category.questionCount > 0) {
categoryId = category.id;
console.log(`✓ Found test category: ${category.name} (${category.questionCount} questions)`);
// Get a real question from that category
const questionsRes = await axios.get(`${API_URL}/questions/category/${categoryId}?limit=1`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (questionsRes.data.data.length > 0) {
questionId = questionsRes.data.data[0].id;
console.log(`✓ Found test question`);
foundQuestion = true;
break;
}
}
}
if (!foundQuestion) {
throw new Error('No questions available for testing. Please seed the database first.');
}
console.log('');
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
throw error;
}
}
// Tests
const tests = [
{
name: 'Test 1: Add bookmark successfully',
run: async () => {
const response = await axios.post(`${API_URL}/users/${userId}/bookmarks`, {
questionId
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (!response.data.success) throw new Error('Request failed');
if (response.status !== 201) throw new Error(`Expected 201, got ${response.status}`);
if (!response.data.data.id) throw new Error('Missing bookmark ID');
if (response.data.data.questionId !== questionId) {
throw new Error('Question ID mismatch');
}
if (!response.data.data.bookmarkedAt) throw new Error('Missing bookmarkedAt timestamp');
if (!response.data.data.question) throw new Error('Missing question details');
return '✓ Bookmark added successfully';
}
},
{
name: 'Test 2: Reject duplicate bookmark',
run: async () => {
try {
await axios.post(`${API_URL}/users/${userId}/bookmarks`, {
questionId
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 409) {
throw new Error(`Expected 409, got ${error.response?.status}`);
}
if (!error.response.data.message.toLowerCase().includes('already')) {
throw new Error('Error message should mention already bookmarked');
}
return '✓ Duplicate bookmark rejected';
}
}
},
{
name: 'Test 3: Remove bookmark successfully',
run: async () => {
const response = await axios.delete(`${API_URL}/users/${userId}/bookmarks/${questionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (!response.data.success) throw new Error('Request failed');
if (response.status !== 200) throw new Error(`Expected 200, got ${response.status}`);
if (response.data.data.questionId !== questionId) {
throw new Error('Question ID mismatch');
}
return '✓ Bookmark removed successfully';
}
},
{
name: 'Test 4: Reject removing non-existent bookmark',
run: async () => {
try {
await axios.delete(`${API_URL}/users/${userId}/bookmarks/${questionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 404) {
throw new Error(`Expected 404, got ${error.response?.status}`);
}
if (!error.response.data.message.toLowerCase().includes('not found')) {
throw new Error('Error message should mention not found');
}
return '✓ Non-existent bookmark rejected';
}
}
},
{
name: 'Test 5: Reject missing questionId',
run: async () => {
try {
await axios.post(`${API_URL}/users/${userId}/bookmarks`, {}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
if (!error.response.data.message.toLowerCase().includes('required')) {
throw new Error('Error message should mention required');
}
return '✓ Missing questionId rejected';
}
}
},
{
name: 'Test 6: Reject invalid questionId format',
run: async () => {
try {
await axios.post(`${API_URL}/users/${userId}/bookmarks`, {
questionId: 'invalid-uuid'
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Invalid questionId format rejected';
}
}
},
{
name: 'Test 7: Reject non-existent question',
run: async () => {
try {
const fakeQuestionId = '00000000-0000-0000-0000-000000000000';
await axios.post(`${API_URL}/users/${userId}/bookmarks`, {
questionId: fakeQuestionId
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 404) {
throw new Error(`Expected 404, got ${error.response?.status}`);
}
if (!error.response.data.message.toLowerCase().includes('not found')) {
throw new Error('Error message should mention not found');
}
return '✓ Non-existent question rejected';
}
}
},
{
name: 'Test 8: Reject invalid userId format',
run: async () => {
try {
await axios.post(`${API_URL}/users/invalid-uuid/bookmarks`, {
questionId
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 400) {
throw new Error(`Expected 400, got ${error.response?.status}`);
}
return '✓ Invalid userId format rejected';
}
}
},
{
name: 'Test 9: Reject non-existent user',
run: async () => {
try {
const fakeUserId = '00000000-0000-0000-0000-000000000000';
await axios.post(`${API_URL}/users/${fakeUserId}/bookmarks`, {
questionId
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been rejected');
} catch (error) {
if (error.response?.status !== 404) {
throw new Error(`Expected 404, got ${error.response?.status}`);
}
return '✓ Non-existent user rejected';
}
}
},
{
name: 'Test 10: Cross-user bookmark addition blocked',
run: async () => {
try {
// Try to add bookmark to second user's account using first user's token
await axios.post(`${API_URL}/users/${secondUserId}/bookmarks`, {
questionId
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been blocked');
} catch (error) {
if (error.response?.status !== 403) {
throw new Error(`Expected 403, got ${error.response?.status}`);
}
return '✓ Cross-user bookmark addition blocked';
}
}
},
{
name: 'Test 11: Cross-user bookmark removal blocked',
run: async () => {
try {
// Try to remove bookmark from second user's account using first user's token
await axios.delete(`${API_URL}/users/${secondUserId}/bookmarks/${questionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
throw new Error('Should have been blocked');
} catch (error) {
if (error.response?.status !== 403) {
throw new Error(`Expected 403, got ${error.response?.status}`);
}
return '✓ Cross-user bookmark removal blocked';
}
}
},
{
name: 'Test 12: Unauthenticated add bookmark blocked',
run: async () => {
try {
await axios.post(`${API_URL}/users/${userId}/bookmarks`, {
questionId
});
throw new Error('Should have been blocked');
} catch (error) {
if (error.response?.status !== 401) {
throw new Error(`Expected 401, got ${error.response?.status}`);
}
return '✓ Unauthenticated add bookmark blocked';
}
}
},
{
name: 'Test 13: Unauthenticated remove bookmark blocked',
run: async () => {
try {
await axios.delete(`${API_URL}/users/${userId}/bookmarks/${questionId}`);
throw new Error('Should have been blocked');
} catch (error) {
if (error.response?.status !== 401) {
throw new Error(`Expected 401, got ${error.response?.status}`);
}
return '✓ Unauthenticated remove bookmark blocked';
}
}
},
{
name: 'Test 14: Response structure validation',
run: async () => {
// Add a bookmark for testing response structure
const response = await axios.post(`${API_URL}/users/${userId}/bookmarks`, {
questionId
}, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
if (!response.data.success) throw new Error('success field missing');
if (!response.data.data) throw new Error('data field missing');
if (!response.data.data.id) throw new Error('bookmark id missing');
if (!response.data.data.questionId) throw new Error('questionId missing');
if (!response.data.data.question) throw new Error('question details missing');
if (!response.data.data.question.id) throw new Error('question.id missing');
if (!response.data.data.question.questionText) throw new Error('question.questionText missing');
if (!response.data.data.question.difficulty) throw new Error('question.difficulty missing');
if (!response.data.data.question.category) throw new Error('question.category missing');
if (!response.data.data.bookmarkedAt) throw new Error('bookmarkedAt missing');
if (!response.data.message) throw new Error('message field missing');
// Clean up
await axios.delete(`${API_URL}/users/${userId}/bookmarks/${questionId}`, {
headers: { 'Authorization': `Bearer ${userToken}` }
});
return '✓ Response structure valid';
}
}
];
// Run tests
async function runTests() {
console.log('============================================================');
console.log('BOOKMARK API TESTS');
console.log('============================================================\n');
await setup();
console.log('Running tests...\n');
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
const result = await test.run();
console.log(result);
passed++;
} catch (error) {
console.log(`${test.name}`);
console.log(` Error: ${error.message}`);
if (error.response?.data) {
console.log(` Response:`, JSON.stringify(error.response.data, null, 2));
}
failed++;
}
// Small delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 100));
}
console.log('\n============================================================');
console.log(`RESULTS: ${passed} passed, ${failed} failed out of ${tests.length} tests`);
console.log('============================================================');
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

@@ -0,0 +1,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();

547
tests/test-complete-quiz.js Normal file
View File

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

View File

@@ -0,0 +1,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,215 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
console.log('Testing Error Handling & Logging\n');
console.log('='.repeat(50));
async function testErrorHandling() {
const tests = [
{
name: '404 Not Found',
test: async () => {
try {
await axios.get(`${BASE_URL}/nonexistent-route`);
return { success: false, message: 'Should have thrown 404' };
} catch (error) {
if (error.response?.status === 404) {
return {
success: true,
message: `✓ 404 handled correctly: ${error.response.data.message}`
};
}
return { success: false, message: `✗ Unexpected error: ${error.message}` };
}
}
},
{
name: '401 Unauthorized (No Token)',
test: async () => {
try {
await axios.get(`${BASE_URL}/auth/verify`);
return { success: false, message: 'Should have thrown 401' };
} catch (error) {
if (error.response?.status === 401) {
return {
success: true,
message: `✓ 401 handled correctly: ${error.response.data.message}`
};
}
return { success: false, message: `✗ Unexpected error: ${error.message}` };
}
}
},
{
name: '401 Unauthorized (Invalid Token)',
test: async () => {
try {
await axios.get(`${BASE_URL}/auth/verify`, {
headers: { 'Authorization': 'Bearer invalid-token' }
});
return { success: false, message: 'Should have thrown 401' };
} catch (error) {
if (error.response?.status === 401) {
return {
success: true,
message: `✓ 401 handled correctly: ${error.response.data.message}`
};
}
return { success: false, message: `✗ Unexpected error: ${error.message}` };
}
}
},
{
name: '400 Bad Request (Missing Required Fields)',
test: async () => {
try {
await axios.post(`${BASE_URL}/auth/register`, {
username: 'test'
// missing email and password
});
return { success: false, message: 'Should have thrown 400' };
} catch (error) {
if (error.response?.status === 400) {
return {
success: true,
message: `✓ 400 handled correctly: ${error.response.data.message}`
};
}
return { success: false, message: `✗ Unexpected error: ${error.message}` };
}
}
},
{
name: '400 Bad Request (Invalid Email)',
test: async () => {
try {
await axios.post(`${BASE_URL}/auth/register`, {
username: 'testuser123',
email: 'invalid-email',
password: 'password123'
});
return { success: false, message: 'Should have thrown 400' };
} catch (error) {
if (error.response?.status === 400) {
return {
success: true,
message: `✓ 400 handled correctly: ${error.response.data.message}`
};
}
return { success: false, message: `✗ Unexpected error: ${error.message}` };
}
}
},
{
name: 'Health Check (Success)',
test: async () => {
try {
const response = await axios.get('http://localhost:3000/health');
if (response.status === 200 && response.data.status === 'OK') {
return {
success: true,
message: `✓ Health check passed: ${response.data.message}`
};
}
return { success: false, message: '✗ Health check failed' };
} catch (error) {
return { success: false, message: `✗ Health check error: ${error.message}` };
}
}
},
{
name: 'Successful Login Flow',
test: async () => {
try {
// First, try to register a test user
const timestamp = Date.now();
const testUser = {
username: `errortest${timestamp}`,
email: `errortest${timestamp}@example.com`,
password: 'password123'
};
await axios.post(`${BASE_URL}/auth/register`, testUser);
// Then login
const loginResponse = await axios.post(`${BASE_URL}/auth/login`, {
email: testUser.email,
password: testUser.password
});
if (loginResponse.status === 200 && loginResponse.data.token) {
return {
success: true,
message: `✓ Login successful, token received`
};
}
return { success: false, message: '✗ Login failed' };
} catch (error) {
if (error.response?.status === 409) {
// User already exists, try logging in
return {
success: true,
message: `✓ Validation working (user exists)`
};
}
return { success: false, message: `✗ Error: ${error.message}` };
}
}
},
{
name: 'Check Logs Directory',
test: async () => {
const fs = require('fs');
const path = require('path');
const logsDir = path.join(__dirname, 'logs');
if (fs.existsSync(logsDir)) {
const files = fs.readdirSync(logsDir);
if (files.length > 0) {
return {
success: true,
message: `✓ Logs directory exists with ${files.length} file(s): ${files.join(', ')}`
};
}
return {
success: true,
message: `✓ Logs directory exists (empty)`
};
}
return { success: false, message: '✗ Logs directory not found' };
}
}
];
let passed = 0;
let failed = 0;
for (const test of tests) {
console.log(`\n${test.name}:`);
try {
const result = await test.test();
console.log(` ${result.message}`);
if (result.success) passed++;
else failed++;
} catch (error) {
console.log(` ✗ Test error: ${error.message}`);
failed++;
}
}
console.log('\n' + '='.repeat(50));
console.log(`\nTest Results: ${passed}/${tests.length} passed, ${failed} failed`);
if (failed === 0) {
console.log('\n✅ All error handling tests passed!');
} else {
console.log(`\n⚠️ Some tests failed. Check the logs for details.`);
}
}
// Run tests
testErrorHandling().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});

40
tests/test-find-by-pk.js Normal file
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,379 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api';
// Test configuration
const testConfig = {
adminUser: {
email: 'admin@example.com',
password: 'Admin123!@#'
},
regularUser: {
email: 'stattest@example.com',
password: 'Test123!@#'
}
};
// Test state
let adminToken = null;
let regularToken = null;
// Test results
let passedTests = 0;
let failedTests = 0;
const results = [];
// Helper function to log test results
function logTest(name, passed, error = null) {
results.push({ name, passed, error });
if (passed) {
console.log(`${name}`);
passedTests++;
} else {
console.log(`${name}`);
if (error) console.log(` Error: ${error}`);
failedTests++;
}
}
// Setup function
async function setup() {
console.log('Setting up test data...\n');
try {
// Login admin user
const adminLoginRes = await axios.post(`${BASE_URL}/auth/login`, {
email: testConfig.adminUser.email,
password: testConfig.adminUser.password
});
adminToken = adminLoginRes.data.data.token;
console.log('✓ Admin user logged in');
// Login regular user
const userLoginRes = await axios.post(`${BASE_URL}/auth/login`, {
email: testConfig.regularUser.email,
password: testConfig.regularUser.password
});
regularToken = userLoginRes.data.data.token;
console.log('✓ Regular user logged in');
console.log('\n============================================================');
console.log('GUEST ANALYTICS API TESTS');
console.log('============================================================\n');
} catch (error) {
console.error('Setup failed:', error.response?.data || error.message);
process.exit(1);
}
}
// Test functions
async function testGetGuestAnalytics() {
try {
const response = await axios.get(`${BASE_URL}/admin/guest-analytics`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
const passed = response.status === 200 &&
response.data.success === true &&
response.data.data !== undefined;
logTest('Get guest analytics', passed);
return response.data.data;
} catch (error) {
logTest('Get guest analytics', false, error.response?.data?.message || error.message);
return null;
}
}
async function testOverviewStructure(data) {
if (!data) {
logTest('Overview section structure', false, 'No data available');
return;
}
try {
const overview = data.overview;
const passed = overview !== undefined &&
typeof overview.totalGuestSessions === 'number' &&
typeof overview.activeGuestSessions === 'number' &&
typeof overview.expiredGuestSessions === 'number' &&
typeof overview.convertedGuestSessions === 'number' &&
typeof overview.conversionRate === 'number';
logTest('Overview section structure', passed);
} catch (error) {
logTest('Overview section structure', false, error.message);
}
}
async function testQuizActivityStructure(data) {
if (!data) {
logTest('Quiz activity section structure', false, 'No data available');
return;
}
try {
const quizActivity = data.quizActivity;
const passed = quizActivity !== undefined &&
typeof quizActivity.totalGuestQuizzes === 'number' &&
typeof quizActivity.completedGuestQuizzes === 'number' &&
typeof quizActivity.guestQuizCompletionRate === 'number' &&
typeof quizActivity.avgQuizzesPerGuest === 'number' &&
typeof quizActivity.avgQuizzesBeforeConversion === 'number';
logTest('Quiz activity section structure', passed);
} catch (error) {
logTest('Quiz activity section structure', false, error.message);
}
}
async function testBehaviorStructure(data) {
if (!data) {
logTest('Behavior section structure', false, 'No data available');
return;
}
try {
const behavior = data.behavior;
const passed = behavior !== undefined &&
typeof behavior.bounceRate === 'number' &&
typeof behavior.avgSessionDurationMinutes === 'number';
logTest('Behavior section structure', passed);
} catch (error) {
logTest('Behavior section structure', false, error.message);
}
}
async function testRecentActivityStructure(data) {
if (!data) {
logTest('Recent activity section structure', false, 'No data available');
return;
}
try {
const recentActivity = data.recentActivity;
const passed = recentActivity !== undefined &&
recentActivity.last30Days !== undefined &&
typeof recentActivity.last30Days.newGuestSessions === 'number' &&
typeof recentActivity.last30Days.conversions === 'number';
logTest('Recent activity section structure', passed);
} catch (error) {
logTest('Recent activity section structure', false, error.message);
}
}
async function testConversionRateCalculation(data) {
if (!data) {
logTest('Conversion rate calculation', false, 'No data available');
return;
}
try {
const overview = data.overview;
const expectedRate = overview.totalGuestSessions > 0
? ((overview.convertedGuestSessions / overview.totalGuestSessions) * 100)
: 0;
// Allow small floating point difference
const passed = Math.abs(overview.conversionRate - expectedRate) < 0.01 &&
overview.conversionRate >= 0 &&
overview.conversionRate <= 100;
logTest('Conversion rate calculation', passed);
} catch (error) {
logTest('Conversion rate calculation', false, error.message);
}
}
async function testQuizCompletionRateCalculation(data) {
if (!data) {
logTest('Quiz completion rate calculation', false, 'No data available');
return;
}
try {
const quizActivity = data.quizActivity;
const expectedRate = quizActivity.totalGuestQuizzes > 0
? ((quizActivity.completedGuestQuizzes / quizActivity.totalGuestQuizzes) * 100)
: 0;
// Allow small floating point difference
const passed = Math.abs(quizActivity.guestQuizCompletionRate - expectedRate) < 0.01 &&
quizActivity.guestQuizCompletionRate >= 0 &&
quizActivity.guestQuizCompletionRate <= 100;
logTest('Quiz completion rate calculation', passed);
} catch (error) {
logTest('Quiz completion rate calculation', false, error.message);
}
}
async function testBounceRateRange(data) {
if (!data) {
logTest('Bounce rate in valid range', false, 'No data available');
return;
}
try {
const bounceRate = data.behavior.bounceRate;
const passed = bounceRate >= 0 && bounceRate <= 100;
logTest('Bounce rate in valid range', passed);
} catch (error) {
logTest('Bounce rate in valid range', false, error.message);
}
}
async function testAveragesAreNonNegative(data) {
if (!data) {
logTest('Average values are non-negative', false, 'No data available');
return;
}
try {
const passed = data.quizActivity.avgQuizzesPerGuest >= 0 &&
data.quizActivity.avgQuizzesBeforeConversion >= 0 &&
data.behavior.avgSessionDurationMinutes >= 0;
logTest('Average values are non-negative', passed);
} catch (error) {
logTest('Average values are non-negative', false, error.message);
}
}
async function testSessionCounts(data) {
if (!data) {
logTest('Session counts are consistent', false, 'No data available');
return;
}
try {
const overview = data.overview;
// Total should be >= sum of active, expired, and converted (some might be both expired and converted)
const passed = overview.totalGuestSessions >= 0 &&
overview.activeGuestSessions >= 0 &&
overview.expiredGuestSessions >= 0 &&
overview.convertedGuestSessions >= 0 &&
overview.convertedGuestSessions <= overview.totalGuestSessions;
logTest('Session counts are consistent', passed);
} catch (error) {
logTest('Session counts are consistent', false, error.message);
}
}
async function testQuizCounts(data) {
if (!data) {
logTest('Quiz counts are consistent', false, 'No data available');
return;
}
try {
const quizActivity = data.quizActivity;
const passed = quizActivity.totalGuestQuizzes >= 0 &&
quizActivity.completedGuestQuizzes >= 0 &&
quizActivity.completedGuestQuizzes <= quizActivity.totalGuestQuizzes;
logTest('Quiz counts are consistent', passed);
} catch (error) {
logTest('Quiz counts are consistent', false, error.message);
}
}
async function testNonAdminBlocked() {
try {
await axios.get(`${BASE_URL}/admin/guest-analytics`, {
headers: { Authorization: `Bearer ${regularToken}` }
});
logTest('Non-admin user blocked', false, 'Regular user should not have access');
} catch (error) {
const passed = error.response?.status === 403;
logTest('Non-admin user blocked', passed,
!passed ? `Expected 403, got ${error.response?.status}` : null);
}
}
async function testUnauthenticated() {
try {
await axios.get(`${BASE_URL}/admin/guest-analytics`);
logTest('Unauthenticated request blocked', false, 'Should require authentication');
} catch (error) {
const passed = error.response?.status === 401;
logTest('Unauthenticated request blocked', passed,
!passed ? `Expected 401, got ${error.response?.status}` : null);
}
}
// Main test runner
async function runTests() {
await setup();
console.log('Running tests...\n');
// Get analytics data
const data = await testGetGuestAnalytics();
await new Promise(resolve => setTimeout(resolve, 100));
// Structure validation tests
await testOverviewStructure(data);
await new Promise(resolve => setTimeout(resolve, 100));
await testQuizActivityStructure(data);
await new Promise(resolve => setTimeout(resolve, 100));
await testBehaviorStructure(data);
await new Promise(resolve => setTimeout(resolve, 100));
await testRecentActivityStructure(data);
await new Promise(resolve => setTimeout(resolve, 100));
// Calculation validation tests
await testConversionRateCalculation(data);
await new Promise(resolve => setTimeout(resolve, 100));
await testQuizCompletionRateCalculation(data);
await new Promise(resolve => setTimeout(resolve, 100));
await testBounceRateRange(data);
await new Promise(resolve => setTimeout(resolve, 100));
await testAveragesAreNonNegative(data);
await new Promise(resolve => setTimeout(resolve, 100));
// Consistency tests
await testSessionCounts(data);
await new Promise(resolve => setTimeout(resolve, 100));
await testQuizCounts(data);
await new Promise(resolve => setTimeout(resolve, 100));
// Authorization tests
await testNonAdminBlocked();
await new Promise(resolve => setTimeout(resolve, 100));
await testUnauthenticated();
// Print results
console.log('\n============================================================');
console.log(`RESULTS: ${passedTests} passed, ${failedTests} failed out of ${passedTests + failedTests} tests`);
console.log('============================================================\n');
if (failedTests > 0) {
console.log('Failed tests:');
results.filter(r => !r.passed).forEach(r => {
console.log(` - ${r.name}`);
if (r.error) console.log(` ${r.error}`);
});
}
process.exit(failedTests > 0 ? 1 : 0);
}
// Run tests
runTests().catch(error => {
console.error('Test execution failed:', error);
process.exit(1);
});

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();

Some files were not shown because too many files have changed in this diff Show More