├── uploads └── .gitkeep ├── controllers ├── quizController_mysql.js ├── analyticsController.js └── achievementController.js ├── client ├── public │ ├── favicon.png │ ├── web_logo.png │ └── vite.svg ├── postcss.config.js ├── src │ ├── lib │ │ └── utils.js │ ├── main.jsx │ ├── components │ │ ├── Layout.jsx │ │ ├── ProtectedRoute.jsx │ │ ├── ui │ │ │ ├── label.jsx │ │ │ ├── input.jsx │ │ │ ├── progress.jsx │ │ │ ├── badge.jsx │ │ │ ├── password-input.jsx │ │ │ ├── card.jsx │ │ │ ├── button.jsx │ │ │ └── dialog.jsx │ │ └── FunctionalBugs │ │ │ └── GuestLoginModal.jsx │ ├── App.css │ ├── services │ │ └── categoryService.js │ ├── index.css │ ├── context │ │ └── AuthContext.jsx │ ├── assets │ │ └── react.svg │ └── pages │ │ └── Register.jsx ├── .env.production ├── .gitignore ├── index.html ├── vite.config.js ├── eslint.config.js ├── README.md ├── tailwind.config.js └── package.json ├── client_backup ├── public │ ├── favicon.png │ ├── web_logo.png │ └── vite.svg ├── postcss.config.js ├── src │ ├── lib │ │ └── utils.js │ ├── main.jsx │ ├── components │ │ ├── Layout.jsx │ │ ├── ProtectedRoute.jsx │ │ ├── ui │ │ │ ├── label.jsx │ │ │ ├── progress.jsx │ │ │ ├── input.jsx │ │ │ ├── badge.jsx │ │ │ ├── card.jsx │ │ │ ├── button.jsx │ │ │ └── dialog.jsx │ │ └── FunctionalBugs │ │ │ └── GuestLoginModal.jsx │ ├── App.css │ ├── services │ │ ├── categoryService.js │ │ └── api.js │ ├── index.css │ ├── context │ │ └── AuthContext.jsx │ ├── assets │ │ └── react.svg │ ├── pages │ │ ├── Register.jsx │ │ └── Login.jsx │ └── App.jsx ├── vite.config.js ├── .gitignore ├── index.html ├── eslint.config.js ├── package.json └── tailwind.config.js ├── routes ├── analytics.js ├── achievements.js ├── leaderboard.js ├── progress.js ├── quiz.js ├── questions.js ├── arenaAuth.js ├── admin.js ├── functionalBugs.js ├── questionUpload.js └── auth.js ├── utils ├── errorResponse.js ├── helpers.js └── validators.js ├── middleware ├── validation.js ├── rateLimiter.js ├── errorHandler.js ├── auth.js └── arenaAuth.js ├── models ├── mysql │ ├── BugTestingTip.js │ ├── QuestionTag.js │ ├── BugPreventionTip.js │ ├── QuizAnswer.js │ ├── BugHint.js │ ├── BugStep.js │ ├── QuestionOption.js │ ├── QuestionOptionTranslation.js │ ├── AchievementTranslation.js │ ├── SiteVisit.js │ ├── RecentActivity.js │ ├── UserAchievement.js │ ├── QuizQuestion.js │ ├── QuestionTranslation.js │ ├── DifficultyProgress.js │ ├── Progress.js │ ├── CategoryProgress.js │ ├── Achievement.js │ ├── ArenaUser.js │ ├── FunctionalBug.js │ ├── Question.js │ ├── Quiz.js │ └── User.js ├── Achievement.js ├── ArenaUser.js ├── FunctionalBugStats.js ├── UserFunctionalBugProgress.js ├── Quiz.js ├── Progress.js ├── FunctionalBug.js ├── Question.js └── User.js ├── tests ├── setup.js ├── questions.test.js ├── auth.test.js └── quiz.test.js ├── scripts ├── reseedFunctionalBugs.js ├── testSmtp.js └── createDatabase.js ├── config ├── database.js ├── mysqlDatabase.js └── mysql.config.js ├── .env.example ├── package.json ├── seedAchievements.js ├── .gitignore └── server.js /uploads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /controllers/quizController_mysql.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhydwharn/qaarena/HEAD/client/public/favicon.png -------------------------------------------------------------------------------- /client/public/web_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhydwharn/qaarena/HEAD/client/public/web_logo.png -------------------------------------------------------------------------------- /client_backup/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhydwharn/qaarena/HEAD/client_backup/public/favicon.png -------------------------------------------------------------------------------- /client_backup/public/web_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhydwharn/qaarena/HEAD/client_backup/public/web_logo.png -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client_backup/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client/src/lib/utils.js: -------------------------------------------------------------------------------- 1 | import { clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs) { 5 | return twMerge(clsx(inputs)) 6 | } -------------------------------------------------------------------------------- /client/.env.production: -------------------------------------------------------------------------------- 1 | # Production Environment Variables 2 | # Replace with your actual production backend URL 3 | VITE_API_URL=https://qaarena.online/api 4 | # NODE_ENV=production 5 | -------------------------------------------------------------------------------- /client_backup/src/lib/utils.js: -------------------------------------------------------------------------------- 1 | import { clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs) { 5 | return twMerge(clsx(inputs)) 6 | } -------------------------------------------------------------------------------- /client_backup/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /client/src/main.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.jsx' 5 | 6 | createRoot(document.getElementById('root')).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /client_backup/src/main.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.jsx' 5 | 6 | createRoot(document.getElementById('root')).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /client/src/components/Layout.jsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | import Navbar from './Navbar'; 3 | 4 | export default function Layout() { 5 | return ( 6 |
7 | 8 |
9 | 10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /client_backup/src/components/Layout.jsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | import Navbar from './Navbar'; 3 | 4 | export default function Layout() { 5 | return ( 6 |
7 | 8 |
9 | 10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /routes/analytics.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { recordVisit } = require('../controllers/analyticsController'); 4 | const { optionalAuth } = require('../middleware/auth'); 5 | 6 | // POST /api/analytics/visit 7 | router.post('/visit', optionalAuth, recordVisit); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /client_backup/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /utils/errorResponse.js: -------------------------------------------------------------------------------- 1 | // utils/errorResponse.js 2 | class ErrorResponse extends Error { 3 | constructor(message, statusCode) { 4 | super(message); 5 | this.statusCode = statusCode; 6 | this.isOperational = true; // This is to distinguish operational errors from programming errors 7 | 8 | Error.captureStackTrace(this, this.constructor); 9 | } 10 | } 11 | 12 | module.exports = ErrorResponse; -------------------------------------------------------------------------------- /client_backup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | client 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .env.production 26 | .env.development 27 | .env.local 28 | dist -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | QA ARENA | The QA One-Stop Platform 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /middleware/validation.js: -------------------------------------------------------------------------------- 1 | const { validationResult } = require('express-validator'); 2 | 3 | exports.validate = (req, res, next) => { 4 | const errors = validationResult(req); 5 | 6 | if (!errors.isEmpty()) { 7 | return res.status(400).json({ 8 | status: 'error', 9 | message: 'Validation failed', 10 | errors: errors.array().map(err => ({ 11 | field: err.path, 12 | message: err.msg 13 | })) 14 | }); 15 | } 16 | 17 | next(); 18 | }; -------------------------------------------------------------------------------- /routes/achievements.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { 4 | getAllAchievements, 5 | getUserAchievements, 6 | checkAchievements 7 | } = require('../controllers/achievementController'); 8 | const { protect } = require('../middleware/auth'); 9 | 10 | router.get('/', protect, getAllAchievements); 11 | router.get('/user', protect, getUserAchievements); 12 | router.post('/check', protect, checkAchievements); 13 | 14 | module.exports = router; -------------------------------------------------------------------------------- /routes/leaderboard.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { 4 | getGlobalLeaderboard, 5 | getCategoryLeaderboard, 6 | getUserRank 7 | } = require('../controllers/leaderboardController'); 8 | const { protect, optionalAuth } = require('../middleware/auth'); 9 | 10 | router.get('/global', optionalAuth, getGlobalLeaderboard); 11 | router.get('/category/:category', optionalAuth, getCategoryLeaderboard); 12 | router.get('/rank', protect, getUserRank); 13 | 14 | module.exports = router; -------------------------------------------------------------------------------- /client/src/components/ProtectedRoute.jsx: -------------------------------------------------------------------------------- 1 | import { Navigate } from 'react-router-dom'; 2 | import { useAuth } from '../context/AuthContext'; 3 | 4 | export default function ProtectedRoute({ children }) { 5 | const { user, loading } = useAuth(); 6 | 7 | if (loading) { 8 | return ( 9 |
10 |
11 |
12 | ); 13 | } 14 | 15 | if (!user) { 16 | return ; 17 | } 18 | 19 | return children; 20 | } 21 | -------------------------------------------------------------------------------- /client_backup/src/components/ProtectedRoute.jsx: -------------------------------------------------------------------------------- 1 | import { Navigate } from 'react-router-dom'; 2 | import { useAuth } from '../context/AuthContext'; 3 | 4 | export default function ProtectedRoute({ children }) { 5 | const { user, loading } = useAuth(); 6 | 7 | if (loading) { 8 | return ( 9 |
10 |
11 |
12 | ); 13 | } 14 | 15 | if (!user) { 16 | return ; 17 | } 18 | 19 | return children; 20 | } 21 | -------------------------------------------------------------------------------- /routes/progress.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { 4 | getProgress, 5 | getCategoryProgress, 6 | getWeakAreas, 7 | getStudyStreak, 8 | getRecentActivity 9 | } = require('../controllers/progressController'); 10 | const { protect } = require('../middleware/auth'); 11 | 12 | router.get('/', protect, getProgress); 13 | router.get('/categories', protect, getCategoryProgress); 14 | router.get('/weak-areas', protect, getWeakAreas); 15 | router.get('/streak', protect, getStudyStreak); 16 | router.get('/activity', protect, getRecentActivity); 17 | 18 | module.exports = router; -------------------------------------------------------------------------------- /client/src/components/ui/label.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva } from "class-variance-authority" 4 | import { cn } from "../../lib/utils" 5 | 6 | const labelVariants = cva( 7 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 8 | ) 9 | 10 | const Label = React.forwardRef(({ className, ...props }, ref) => ( 11 | 16 | )) 17 | Label.displayName = LabelPrimitive.Root.displayName 18 | 19 | export { Label } 20 | -------------------------------------------------------------------------------- /client_backup/src/components/ui/label.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva } from "class-variance-authority" 4 | import { cn } from "../../lib/utils" 5 | 6 | const labelVariants = cva( 7 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 8 | ) 9 | 10 | const Label = React.forwardRef(({ className, ...props }, ref) => ( 11 | 16 | )) 17 | Label.displayName = LabelPrimitive.Root.displayName 18 | 19 | export { Label } 20 | -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig(() => ({ 5 | plugins: [react()], 6 | // Use root base in both dev and build so the app works at https://qaarena.online/ 7 | base: '/', 8 | server: { 9 | port: 5173, 10 | strictPort: true, 11 | proxy: { 12 | '/api': { 13 | // In development, proxy API calls to the local backend 14 | target: 'http://localhost:5001', 15 | changeOrigin: true, 16 | secure: false, 17 | // Keep /api prefix as-is (matches backend routes: /api/*) 18 | } 19 | } 20 | } 21 | })); -------------------------------------------------------------------------------- /routes/quiz.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { 4 | startQuiz, 5 | answerQuestion, 6 | completeQuiz, 7 | getQuiz, 8 | getUserQuizzes, 9 | getInProgressQuiz 10 | } = require('../controllers/quizController'); 11 | const { protect } = require('../middleware/auth'); 12 | const { quizLimiter } = require('../middleware/rateLimiter'); 13 | 14 | router.post('/start', protect, startQuiz); 15 | router.post('/answer', protect, quizLimiter, answerQuestion); 16 | router.post('/:id/complete', protect, completeQuiz); 17 | router.get('/in-progress', protect, getInProgressQuiz); 18 | router.get('/:id', protect, getQuiz); 19 | router.get('/user/history', protect, getUserQuizzes); 20 | 21 | module.exports = router; -------------------------------------------------------------------------------- /client/src/components/ui/input.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cn } from "../../lib/utils" 3 | 4 | const Input = React.forwardRef(({ className, type, ...props }, ref) => { 5 | return ( 6 | 15 | ) 16 | }) 17 | Input.displayName = "Input" 18 | 19 | export { Input } 20 | -------------------------------------------------------------------------------- /client/src/components/ui/progress.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ProgressPrimitive from "@radix-ui/react-progress" 3 | import { cn } from "../../lib/utils" 4 | 5 | const Progress = React.forwardRef(({ className, value, ...props }, ref) => ( 6 | 14 | 18 | 19 | )) 20 | Progress.displayName = ProgressPrimitive.Root.displayName 21 | 22 | export { Progress } 23 | -------------------------------------------------------------------------------- /client_backup/src/components/ui/progress.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ProgressPrimitive from "@radix-ui/react-progress" 3 | import { cn } from "../../lib/utils" 4 | 5 | const Progress = React.forwardRef(({ className, value, ...props }, ref) => ( 6 | 14 | 18 | 19 | )) 20 | Progress.displayName = ProgressPrimitive.Root.displayName 21 | 22 | export { Progress } 23 | -------------------------------------------------------------------------------- /routes/questions.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { 4 | getQuestions, 5 | getQuestion, 6 | createQuestion, 7 | updateQuestion, 8 | deleteQuestion, 9 | voteQuestion, 10 | flagQuestion 11 | } = require('../controllers/questionController'); 12 | const { protect, authorize, optionalAuth } = require('../middleware/auth'); 13 | 14 | router.get('/', optionalAuth, getQuestions); 15 | router.get('/:id', getQuestion); 16 | router.post('/', protect, authorize('admin', 'moderator'), createQuestion); 17 | router.put('/:id', protect, updateQuestion); 18 | router.delete('/:id', protect, deleteQuestion); 19 | router.post('/:id/vote', protect, voteQuestion); 20 | router.post('/:id/flag', protect, flagQuestion); 21 | 22 | module.exports = router; -------------------------------------------------------------------------------- /client_backup/src/components/ui/input.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cn } from "../../lib/utils" 3 | 4 | const Input = React.forwardRef(({ className, type, ...props }, ref) => { 5 | return ( 6 | 15 | ) 16 | }) 17 | Input.displayName = "Input" 18 | 19 | export { Input } 20 | -------------------------------------------------------------------------------- /routes/arenaAuth.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const arenaAuthController = require('../controllers/arenaAuthController'); 4 | const { verifyUser } = require('../middleware/arenaAuth'); 5 | 6 | // POST /api/arena-auth/signup 7 | router.post('/signup', arenaAuthController.signup); 8 | 9 | // POST /api/arena-auth/verify-otp 10 | router.post('/verify-otp', arenaAuthController.verifyOTP); 11 | 12 | // POST /api/arena-auth/verify-token 13 | router.post('/verify-token', arenaAuthController.verifyToken); 14 | 15 | // POST /api/arena-auth/signin 16 | router.post('/signin', arenaAuthController.signin); 17 | 18 | // GET /api/arena-auth/verify-user - Check if token is valid 19 | router.get('/verify-user', verifyUser); 20 | 21 | module.exports = router; 22 | -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /client_backup/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /models/mysql/BugTestingTip.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const BugTestingTip = sequelize.define('BugTestingTip', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | bugId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'bug_id', 14 | references: { 15 | model: 'functional_bugs', 16 | key: 'id' 17 | } 18 | }, 19 | tipText: { 20 | type: DataTypes.TEXT, 21 | allowNull: false, 22 | field: 'tip_text' 23 | } 24 | }, { 25 | tableName: 'bug_testing_tips', 26 | timestamps: false, 27 | underscored: true, 28 | indexes: [ 29 | { fields: ['bug_id'] } 30 | ] 31 | }); 32 | 33 | module.exports = BugTestingTip; 34 | -------------------------------------------------------------------------------- /models/mysql/QuestionTag.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const QuestionTag = sequelize.define('QuestionTag', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | questionId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'question_id', 14 | references: { 15 | model: 'questions', 16 | key: 'id' 17 | } 18 | }, 19 | tag: { 20 | type: DataTypes.STRING(50), 21 | allowNull: false 22 | } 23 | }, { 24 | tableName: 'question_tags', 25 | timestamps: false, 26 | underscored: true, 27 | indexes: [ 28 | { fields: ['question_id'] }, 29 | { fields: ['tag'] } 30 | ] 31 | }); 32 | 33 | module.exports = QuestionTag; 34 | -------------------------------------------------------------------------------- /models/mysql/BugPreventionTip.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const BugPreventionTip = sequelize.define('BugPreventionTip', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | bugId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'bug_id', 14 | references: { 15 | model: 'functional_bugs', 16 | key: 'id' 17 | } 18 | }, 19 | tipText: { 20 | type: DataTypes.TEXT, 21 | allowNull: false, 22 | field: 'tip_text' 23 | } 24 | }, { 25 | tableName: 'bug_prevention_tips', 26 | timestamps: false, 27 | underscored: true, 28 | indexes: [ 29 | { fields: ['bug_id'] } 30 | ] 31 | }); 32 | 33 | module.exports = BugPreventionTip; 34 | -------------------------------------------------------------------------------- /models/mysql/QuizAnswer.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const QuizAnswer = sequelize.define('QuizAnswer', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | quizQuestionId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'quiz_question_id', 14 | references: { 15 | model: 'quiz_questions', 16 | key: 'id' 17 | } 18 | }, 19 | optionIndex: { 20 | type: DataTypes.INTEGER, 21 | allowNull: false, 22 | field: 'option_index' 23 | } 24 | }, { 25 | tableName: 'quiz_answers', 26 | timestamps: false, 27 | underscored: true, 28 | indexes: [ 29 | { fields: ['quiz_question_id'] } 30 | ] 31 | }); 32 | 33 | module.exports = QuizAnswer; 34 | -------------------------------------------------------------------------------- /client/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import { defineConfig, globalIgnores } from 'eslint/config' 6 | 7 | export default defineConfig([ 8 | globalIgnores(['dist']), 9 | { 10 | files: ['**/*.{js,jsx}'], 11 | extends: [ 12 | js.configs.recommended, 13 | reactHooks.configs['recommended-latest'], 14 | reactRefresh.configs.vite, 15 | ], 16 | languageOptions: { 17 | ecmaVersion: 2020, 18 | globals: globals.browser, 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | ecmaFeatures: { jsx: true }, 22 | sourceType: 'module', 23 | }, 24 | }, 25 | rules: { 26 | 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], 27 | }, 28 | }, 29 | ]) 30 | -------------------------------------------------------------------------------- /client_backup/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import { defineConfig, globalIgnores } from 'eslint/config' 6 | 7 | export default defineConfig([ 8 | globalIgnores(['dist']), 9 | { 10 | files: ['**/*.{js,jsx}'], 11 | extends: [ 12 | js.configs.recommended, 13 | reactHooks.configs['recommended-latest'], 14 | reactRefresh.configs.vite, 15 | ], 16 | languageOptions: { 17 | ecmaVersion: 2020, 18 | globals: globals.browser, 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | ecmaFeatures: { jsx: true }, 22 | sourceType: 'module', 23 | }, 24 | }, 25 | rules: { 26 | 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], 27 | }, 28 | }, 29 | ]) 30 | -------------------------------------------------------------------------------- /models/mysql/BugHint.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const BugHint = sequelize.define('BugHint', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | bugId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'bug_id', 14 | references: { 15 | model: 'functional_bugs', 16 | key: 'id' 17 | } 18 | }, 19 | hintNumber: { 20 | type: DataTypes.INTEGER, 21 | allowNull: false, 22 | field: 'hint_number' 23 | }, 24 | hintText: { 25 | type: DataTypes.TEXT, 26 | allowNull: false, 27 | field: 'hint_text' 28 | } 29 | }, { 30 | tableName: 'bug_hints', 31 | timestamps: false, 32 | underscored: true, 33 | indexes: [ 34 | { fields: ['bug_id'] } 35 | ] 36 | }); 37 | 38 | module.exports = BugHint; 39 | -------------------------------------------------------------------------------- /models/mysql/BugStep.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const BugStep = sequelize.define('BugStep', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | bugId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'bug_id', 14 | references: { 15 | model: 'functional_bugs', 16 | key: 'id' 17 | } 18 | }, 19 | stepNumber: { 20 | type: DataTypes.INTEGER, 21 | allowNull: false, 22 | field: 'step_number' 23 | }, 24 | stepDescription: { 25 | type: DataTypes.TEXT, 26 | allowNull: false, 27 | field: 'step_description' 28 | } 29 | }, { 30 | tableName: 'bug_steps', 31 | timestamps: false, 32 | underscored: true, 33 | indexes: [ 34 | { fields: ['bug_id'] } 35 | ] 36 | }); 37 | 38 | module.exports = BugStep; 39 | -------------------------------------------------------------------------------- /models/mysql/QuestionOption.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const QuestionOption = sequelize.define('QuestionOption', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | questionId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'question_id', 14 | references: { 15 | model: 'questions', 16 | key: 'id' 17 | } 18 | }, 19 | optionIndex: { 20 | type: DataTypes.INTEGER, 21 | allowNull: false, 22 | field: 'option_index' 23 | }, 24 | isCorrect: { 25 | type: DataTypes.BOOLEAN, 26 | allowNull: false, 27 | field: 'is_correct' 28 | } 29 | }, { 30 | tableName: 'question_options', 31 | timestamps: false, 32 | underscored: true, 33 | indexes: [ 34 | { fields: ['question_id'] } 35 | ] 36 | }); 37 | 38 | module.exports = QuestionOption; 39 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | // Set test environment BEFORE any imports 4 | process.env.NODE_ENV = 'test'; 5 | process.env.JWT_SECRET = process.env.JWT_SECRET || 'test_jwt_secret_key'; 6 | process.env.MONGODB_URI = 'mongodb://localhost:27017/istqb_test'; 7 | 8 | beforeAll(async () => { 9 | // Disconnect any existing connections 10 | if (mongoose.connection.readyState !== 0) { 11 | await mongoose.disconnect(); 12 | } 13 | 14 | const mongoUri = 'mongodb://localhost:27017/istqb_test'; 15 | await mongoose.connect(mongoUri); 16 | }); 17 | 18 | afterAll(async () => { 19 | try { 20 | await mongoose.connection.dropDatabase(); 21 | await mongoose.disconnect(); 22 | } catch (error) { 23 | console.error('Error in afterAll:', error); 24 | } 25 | }); 26 | 27 | afterEach(async () => { 28 | const collections = mongoose.connection.collections; 29 | for (const key in collections) { 30 | await collections[key].deleteMany({}); 31 | } 32 | }); -------------------------------------------------------------------------------- /models/Achievement.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const achievementSchema = new mongoose.Schema({ 4 | name: { type: Map, of: String, required: true }, 5 | description: { type: Map, of: String, required: true }, 6 | icon: { type: String, required: true }, 7 | type: { 8 | type: String, 9 | enum: ['quiz', 'streak', 'score', 'category', 'special'], 10 | required: true 11 | }, 12 | criteria: { 13 | metric: { type: String, required: true }, 14 | threshold: { type: Number, required: true }, 15 | category: String 16 | }, 17 | rarity: { 18 | type: String, 19 | enum: ['common', 'rare', 'epic', 'legendary'], 20 | default: 'common' 21 | }, 22 | points: { type: Number, default: 10 }, 23 | isActive: { type: Boolean, default: true }, 24 | unlockedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }], 25 | createdAt: { type: Date, default: Date.now } 26 | }, { timestamps: true }); 27 | 28 | module.exports = mongoose.model('Achievement', achievementSchema); -------------------------------------------------------------------------------- /routes/admin.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { 4 | getStats, 5 | getAllUsers, 6 | updateUserRole, 7 | deactivateUser, 8 | getFlaggedQuestions, 9 | reviewQuestion, 10 | createAchievement, 11 | updateAchievement, 12 | deleteAchievement, 13 | getSiteVisitStats 14 | } = require('../controllers/adminController'); 15 | const { protect, authorize } = require('../middleware/auth'); 16 | 17 | router.use(protect); 18 | router.use(authorize('admin')); 19 | 20 | router.get('/stats', getStats); 21 | router.get('/users', getAllUsers); 22 | router.put('/users/:id/role', updateUserRole); 23 | router.put('/users/:id/deactivate', deactivateUser); 24 | router.get('/questions/flagged', getFlaggedQuestions); 25 | router.put('/questions/:id/review', reviewQuestion); 26 | router.post('/achievements', createAchievement); 27 | router.put('/achievements/:id', updateAchievement); 28 | router.delete('/achievements/:id', deleteAchievement); 29 | router.get('/site-visits', getSiteVisitStats); 30 | 31 | module.exports = router; -------------------------------------------------------------------------------- /models/mysql/QuestionOptionTranslation.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const QuestionOptionTranslation = sequelize.define('QuestionOptionTranslation', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | optionId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'option_id', 14 | references: { 15 | model: 'question_options', 16 | key: 'id' 17 | } 18 | }, 19 | language: { 20 | type: DataTypes.STRING(10), 21 | allowNull: false 22 | }, 23 | optionText: { 24 | type: DataTypes.TEXT, 25 | allowNull: false, 26 | field: 'option_text' 27 | } 28 | }, { 29 | tableName: 'question_option_translations', 30 | timestamps: false, 31 | underscored: true, 32 | indexes: [ 33 | { 34 | unique: true, 35 | fields: ['option_id', 'language'], 36 | name: 'unique_option_language' 37 | } 38 | ] 39 | }); 40 | 41 | module.exports = QuestionOptionTranslation; 42 | -------------------------------------------------------------------------------- /client/src/services/categoryService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001/api'; 4 | 5 | // Get all unique categories from the database 6 | export const getCategories = async () => { 7 | try { 8 | const response = await axios.get(`${API_URL}/questions-upload/categories`); 9 | return response.data.data; 10 | } catch (error) { 11 | console.error('Error fetching categories:', error); 12 | // Return default categories as fallback 13 | return [ 14 | 'fundamentals', 15 | 'testing-throughout-sdlc', 16 | 'static-testing', 17 | 'test-techniques', 18 | 'test-management', 19 | 'tool-support', 20 | 'agile-testing', 21 | 'test-automation' 22 | ]; 23 | } 24 | }; 25 | 26 | // Format categories for select dropdowns 27 | export const formatCategoriesForSelect = (categories) => { 28 | return categories.map(cat => ({ 29 | value: cat, 30 | label: cat.split('-').map(word => 31 | word.charAt(0).toUpperCase() + word.slice(1) 32 | ).join(' ') 33 | })); 34 | }; 35 | -------------------------------------------------------------------------------- /client_backup/src/services/categoryService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001/api'; 4 | 5 | // Get all unique categories from the database 6 | export const getCategories = async () => { 7 | try { 8 | const response = await axios.get(`${API_URL}/questions-upload/categories`); 9 | return response.data.data; 10 | } catch (error) { 11 | console.error('Error fetching categories:', error); 12 | // Return default categories as fallback 13 | return [ 14 | 'fundamentals', 15 | 'testing-throughout-sdlc', 16 | 'static-testing', 17 | 'test-techniques', 18 | 'test-management', 19 | 'tool-support', 20 | 'agile-testing', 21 | 'test-automation' 22 | ]; 23 | } 24 | }; 25 | 26 | // Format categories for select dropdowns 27 | export const formatCategoriesForSelect = (categories) => { 28 | return categories.map(cat => ({ 29 | value: cat, 30 | label: cat.split('-').map(word => 31 | word.charAt(0).toUpperCase() + word.slice(1) 32 | ).join(' ') 33 | })); 34 | }; 35 | -------------------------------------------------------------------------------- /models/mysql/AchievementTranslation.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const AchievementTranslation = sequelize.define('AchievementTranslation', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | achievementId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'achievement_id', 14 | references: { 15 | model: 'achievements', 16 | key: 'id' 17 | } 18 | }, 19 | language: { 20 | type: DataTypes.STRING(10), 21 | allowNull: false 22 | }, 23 | name: { 24 | type: DataTypes.STRING(255), 25 | allowNull: false 26 | }, 27 | description: { 28 | type: DataTypes.TEXT, 29 | allowNull: false 30 | } 31 | }, { 32 | tableName: 'achievement_translations', 33 | timestamps: false, 34 | underscored: true, 35 | indexes: [ 36 | { 37 | unique: true, 38 | fields: ['achievement_id', 'language'], 39 | name: 'unique_achievement_language' 40 | } 41 | ] 42 | }); 43 | 44 | module.exports = AchievementTranslation; 45 | -------------------------------------------------------------------------------- /client/src/components/ui/badge.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva } from "class-variance-authority" 3 | 4 | import { cn } from "../../lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | function Badge({ 27 | className, 28 | variant, 29 | ...props 30 | }) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /models/ArenaUser.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const ArenaUserSchema = new mongoose.Schema({ 4 | firstName: { 5 | type: String, 6 | required: [true, 'First name is required'], 7 | trim: true 8 | }, 9 | lastName: { 10 | type: String, 11 | required: [true, 'Last name is required'], 12 | trim: true 13 | }, 14 | email: { 15 | type: String, 16 | required: [true, 'Email is required'], 17 | unique: true, 18 | lowercase: true, 19 | trim: true, 20 | match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email'] 21 | }, 22 | password: { 23 | type: String, 24 | required: [true, 'Password is required'], 25 | minlength: 6 26 | }, 27 | authMode: { 28 | type: String, 29 | enum: ['otp', 'token'], 30 | required: true, 31 | default: 'otp' 32 | }, 33 | isVerified: { 34 | type: Boolean, 35 | default: false 36 | }, 37 | verificationOTP: String, 38 | verificationToken: String, 39 | verificationExpiry: Date, 40 | createdAt: { 41 | type: Date, 42 | default: Date.now 43 | } 44 | }); 45 | 46 | module.exports = mongoose.model('ArenaUser', ArenaUserSchema); 47 | -------------------------------------------------------------------------------- /client_backup/src/components/ui/badge.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva } from "class-variance-authority" 3 | 4 | import { cn } from "../../lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | function Badge({ 27 | className, 28 | variant, 29 | ...props 30 | }) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /models/mysql/SiteVisit.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const SiteVisit = sequelize.define('SiteVisit', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | visitedAt: { 11 | type: DataTypes.DATE, 12 | allowNull: false, 13 | defaultValue: DataTypes.NOW, 14 | field: 'visited_at' 15 | }, 16 | path: { 17 | type: DataTypes.STRING(255), 18 | allowNull: true 19 | }, 20 | ipHash: { 21 | type: DataTypes.STRING(64), 22 | allowNull: true, 23 | field: 'ip_hash' 24 | }, 25 | userAgent: { 26 | type: DataTypes.STRING(255), 27 | allowNull: true, 28 | field: 'user_agent' 29 | }, 30 | userId: { 31 | type: DataTypes.INTEGER, 32 | allowNull: true, 33 | field: 'user_id', 34 | references: { 35 | model: 'users', 36 | key: 'id' 37 | } 38 | } 39 | }, { 40 | tableName: 'site_visits', 41 | timestamps: false, 42 | underscored: true, 43 | indexes: [ 44 | { fields: ['visited_at'] }, 45 | { fields: ['path'] } 46 | ] 47 | }); 48 | 49 | module.exports = SiteVisit; 50 | -------------------------------------------------------------------------------- /models/mysql/RecentActivity.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const RecentActivity = sequelize.define('RecentActivity', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | progressId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'progress_id', 14 | references: { 15 | model: 'progress', 16 | key: 'id' 17 | } 18 | }, 19 | activityDate: { 20 | type: DataTypes.DATEONLY, 21 | allowNull: false, 22 | field: 'activity_date' 23 | }, 24 | questionsAnswered: { 25 | type: DataTypes.INTEGER, 26 | defaultValue: 0, 27 | field: 'questions_answered' 28 | }, 29 | score: { 30 | type: DataTypes.DECIMAL(5, 2), 31 | defaultValue: 0 32 | }, 33 | timeSpent: { 34 | type: DataTypes.INTEGER, 35 | defaultValue: 0, 36 | field: 'time_spent' 37 | } 38 | }, { 39 | tableName: 'recent_activity', 40 | timestamps: false, 41 | underscored: true, 42 | indexes: [ 43 | { fields: ['progress_id', 'activity_date'] } 44 | ] 45 | }); 46 | 47 | module.exports = RecentActivity; 48 | -------------------------------------------------------------------------------- /routes/functionalBugs.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { 4 | getAllBugs, 5 | getBugById, 6 | startBugScenario, 7 | getHint, 8 | submitAnswer, 9 | getUserProgress, 10 | getLeaderboard, 11 | getBugStats, 12 | createBug, 13 | updateBug, 14 | deleteBug 15 | } = require('../controllers/functionalBugController'); 16 | const { protect, authorize } = require('../middleware/auth'); 17 | 18 | // Public routes 19 | router.get('/', getAllBugs); 20 | router.get('/leaderboard', getLeaderboard); 21 | router.get('/:bugId', getBugById); 22 | router.get('/:bugId/stats', getBugStats); 23 | 24 | // Protected routes (require authentication) 25 | router.post('/:bugId/start', protect, startBugScenario); 26 | router.post('/:bugId/hint', protect, getHint); 27 | router.post('/:bugId/submit', protect, submitAnswer); 28 | router.get('/user/progress', protect, getUserProgress); 29 | 30 | // Admin routes 31 | router.post('/', protect, authorize('admin', 'moderator'), createBug); 32 | router.put('/:bugId', protect, authorize('admin', 'moderator'), updateBug); 33 | router.delete('/:bugId', protect, authorize('admin'), deleteBug); 34 | 35 | module.exports = router; 36 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## React Compiler 11 | 12 | The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). 13 | 14 | ## Expanding the ESLint configuration 15 | 16 | If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. 17 | -------------------------------------------------------------------------------- /scripts/reseedFunctionalBugs.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const mongoose = require('mongoose'); 3 | const FunctionalBug = require('../models/FunctionalBug'); 4 | const FunctionalBugStats = require('../models/FunctionalBugStats'); 5 | 6 | const connectDB = async () => { 7 | try { 8 | await mongoose.connect(process.env.MONGODB_URI); 9 | console.log('✅ MongoDB Connected'); 10 | } catch (error) { 11 | console.error('❌ MongoDB Connection Error:', error.message); 12 | process.exit(1); 13 | } 14 | }; 15 | 16 | const reseedBugs = async () => { 17 | try { 18 | await connectDB(); 19 | 20 | console.log('🗑️ Deleting existing functional bugs...'); 21 | const deletedBugs = await FunctionalBug.deleteMany({}); 22 | const deletedStats = await FunctionalBugStats.deleteMany({}); 23 | console.log(`✅ Deleted ${deletedBugs.deletedCount} bugs and ${deletedStats.deletedCount} stats`); 24 | 25 | console.log('\n📦 Now run: node scripts/seedFunctionalBugs.js'); 26 | console.log(' to seed the new bugs with simulators!\n'); 27 | 28 | process.exit(0); 29 | } catch (error) { 30 | console.error('❌ Error:', error); 31 | process.exit(1); 32 | } 33 | }; 34 | 35 | reseedBugs(); 36 | -------------------------------------------------------------------------------- /models/mysql/UserAchievement.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const UserAchievement = sequelize.define('UserAchievement', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | userId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'user_id', 14 | references: { 15 | model: 'users', 16 | key: 'id' 17 | } 18 | }, 19 | achievementId: { 20 | type: DataTypes.INTEGER, 21 | allowNull: false, 22 | field: 'achievement_id', 23 | references: { 24 | model: 'achievements', 25 | key: 'id' 26 | } 27 | }, 28 | unlockedAt: { 29 | type: DataTypes.DATE, 30 | defaultValue: DataTypes.NOW, 31 | field: 'unlocked_at' 32 | } 33 | }, { 34 | tableName: 'user_achievements', 35 | timestamps: false, 36 | underscored: true, 37 | indexes: [ 38 | { 39 | unique: true, 40 | fields: ['user_id', 'achievement_id'], 41 | name: 'unique_user_achievement' 42 | }, 43 | { fields: ['user_id'] }, 44 | { fields: ['achievement_id'] } 45 | ] 46 | }); 47 | 48 | module.exports = UserAchievement; 49 | -------------------------------------------------------------------------------- /client/src/components/ui/password-input.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Eye, EyeOff } from "lucide-react"; 3 | import { Input } from "./input"; 4 | import { Button } from "./button"; 5 | import { cn } from "../../lib/utils"; 6 | 7 | export const PasswordInput = React.forwardRef( 8 | ({ className, toggleAriaLabel = "Toggle password visibility", ...props }, ref) => { 9 | const [visible, setVisible] = React.useState(false); 10 | 11 | const type = visible ? "text" : "password"; 12 | 13 | return ( 14 |
15 | 21 | 31 |
32 | ); 33 | } 34 | ); 35 | 36 | PasswordInput.displayName = "PasswordInput"; 37 | -------------------------------------------------------------------------------- /scripts/testSmtp.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const nodemailer = require('nodemailer'); 3 | 4 | async function main() { 5 | console.log('Using SMTP config:'); 6 | console.log({ 7 | host: process.env.SMTP_HOST, 8 | port: process.env.SMTP_PORT, 9 | secure: process.env.SMTP_SECURE === 'true', 10 | user: process.env.SMTP_USER, 11 | from: process.env.SMTP_FROM, 12 | }); 13 | 14 | const transporter = nodemailer.createTransport({ 15 | host: process.env.SMTP_HOST, 16 | port: Number(process.env.SMTP_PORT) || 587, 17 | secure: process.env.SMTP_SECURE === 'true', // false for STARTTLS 18 | auth: { 19 | user: process.env.SMTP_USER, 20 | pass: process.env.SMTP_PASS, 21 | }, 22 | }); 23 | 24 | const info = await transporter.sendMail({ 25 | from: process.env.SMTP_FROM, 26 | to: process.env.SMTP_TEST_TO || process.env.SMTP_USER, // set a test recipient 27 | subject: 'SMTP Test from QA Arena backend', 28 | text: 'This is a test email confirming SMTP credentials work.', 29 | }); 30 | 31 | console.log('Message sent:', info.messageId); 32 | } 33 | 34 | main().catch((err) => { 35 | console.error('SMTP test failed:', err.message); 36 | console.error(err); 37 | process.exit(1); 38 | }); -------------------------------------------------------------------------------- /models/mysql/QuizQuestion.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const QuizQuestion = sequelize.define('QuizQuestion', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | quizId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'quiz_id', 14 | references: { 15 | model: 'quizzes', 16 | key: 'id' 17 | } 18 | }, 19 | questionId: { 20 | type: DataTypes.INTEGER, 21 | allowNull: false, 22 | field: 'question_id', 23 | references: { 24 | model: 'questions', 25 | key: 'id' 26 | } 27 | }, 28 | isCorrect: { 29 | type: DataTypes.BOOLEAN, 30 | allowNull: true, 31 | field: 'is_correct' 32 | }, 33 | timeSpent: { 34 | type: DataTypes.INTEGER, 35 | defaultValue: 0, 36 | field: 'time_spent' 37 | }, 38 | answeredAt: { 39 | type: DataTypes.DATE, 40 | allowNull: true, 41 | field: 'answered_at' 42 | } 43 | }, { 44 | tableName: 'quiz_questions', 45 | timestamps: false, 46 | underscored: true, 47 | indexes: [ 48 | { fields: ['quiz_id'] }, 49 | { fields: ['question_id'] } 50 | ] 51 | }); 52 | 53 | module.exports = QuizQuestion; 54 | -------------------------------------------------------------------------------- /config/database.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | /** 4 | * Legacy MongoDB connection helper. 5 | * 6 | * The main application now uses MySQL via Sequelize. This function is kept 7 | * only so older scripts or deployments that still import `config/database` 8 | * do not crash when `MONGODB_URI` is missing. 9 | * 10 | * Behaviour: 11 | * - If `DISABLE_MONGO` is set to `true` (string) OR `MONGODB_URI` is absent, 12 | * this becomes a no-op and simply logs that Mongo is skipped. 13 | * - Otherwise, it attempts a normal mongoose connection. 14 | */ 15 | const connectDB = async () => { 16 | const disableMongo = String(process.env.DISABLE_MONGO || '').toLowerCase() === 'true'; 17 | const uri = process.env.MONGODB_URI; 18 | 19 | if (disableMongo || !uri) { 20 | console.log('ℹ️ MongoDB connection skipped (legacy). Using MySQL only.'); 21 | return null; 22 | } 23 | 24 | try { 25 | const conn = await mongoose.connect(uri); 26 | console.log(`✅ MongoDB Connected (legacy): ${conn.connection.host}`); 27 | return conn; 28 | } catch (error) { 29 | console.error(`❌ MongoDB Connection Error (legacy): ${error.message}`); 30 | // Do not crash the process in MySQL-only mode; just log the failure. 31 | return null; 32 | } 33 | }; 34 | 35 | module.exports = connectDB; -------------------------------------------------------------------------------- /models/mysql/QuestionTranslation.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const QuestionTranslation = sequelize.define('QuestionTranslation', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | questionId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'question_id', 14 | references: { 15 | model: 'questions', 16 | key: 'id' 17 | } 18 | }, 19 | language: { 20 | type: DataTypes.STRING(10), 21 | allowNull: false, 22 | validate: { 23 | notEmpty: true 24 | } 25 | }, 26 | questionText: { 27 | type: DataTypes.TEXT, 28 | allowNull: false, 29 | field: 'question_text', 30 | validate: { 31 | notEmpty: { 32 | msg: 'Question text is required' 33 | } 34 | } 35 | }, 36 | explanation: { 37 | type: DataTypes.TEXT, 38 | allowNull: true 39 | } 40 | }, { 41 | tableName: 'question_translations', 42 | timestamps: false, 43 | underscored: true, 44 | indexes: [ 45 | { 46 | unique: true, 47 | fields: ['question_id', 'language'], 48 | name: 'unique_question_language' 49 | } 50 | ] 51 | }); 52 | 53 | module.exports = QuestionTranslation; 54 | -------------------------------------------------------------------------------- /models/mysql/DifficultyProgress.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const DifficultyProgress = sequelize.define('DifficultyProgress', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | progressId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'progress_id', 14 | references: { 15 | model: 'progress', 16 | key: 'id' 17 | } 18 | }, 19 | difficulty: { 20 | type: DataTypes.STRING(50), 21 | allowNull: false 22 | }, 23 | questionsAttempted: { 24 | type: DataTypes.INTEGER, 25 | defaultValue: 0, 26 | field: 'questions_attempted' 27 | }, 28 | questionsCorrect: { 29 | type: DataTypes.INTEGER, 30 | defaultValue: 0, 31 | field: 'questions_correct' 32 | }, 33 | averageScore: { 34 | type: DataTypes.DECIMAL(5, 2), 35 | defaultValue: 0, 36 | field: 'average_score' 37 | } 38 | }, { 39 | tableName: 'difficulty_progress', 40 | timestamps: false, 41 | underscored: true, 42 | indexes: [ 43 | { 44 | unique: true, 45 | fields: ['progress_id', 'difficulty'], 46 | name: 'unique_progress_difficulty' 47 | } 48 | ] 49 | }); 50 | 51 | module.exports = DifficultyProgress; 52 | -------------------------------------------------------------------------------- /utils/helpers.js: -------------------------------------------------------------------------------- 1 | exports.shuffleArray = (array) => { 2 | const shuffled = [...array]; 3 | for (let i = shuffled.length - 1; i > 0; i--) { 4 | const j = Math.floor(Math.random() * (i + 1)); 5 | [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 6 | } 7 | return shuffled; 8 | }; 9 | 10 | exports.calculatePercentage = (correct, total) => { 11 | if (total === 0) return 0; 12 | return Math.round((correct / total) * 100); 13 | }; 14 | 15 | exports.formatDuration = (seconds) => { 16 | const hours = Math.floor(seconds / 3600); 17 | const minutes = Math.floor((seconds % 3600) / 60); 18 | const secs = seconds % 60; 19 | 20 | if (hours > 0) { 21 | return `${hours}h ${minutes}m ${secs}s`; 22 | } else if (minutes > 0) { 23 | return `${minutes}m ${secs}s`; 24 | } 25 | return `${secs}s`; 26 | }; 27 | 28 | exports.getDateRange = (period) => { 29 | const now = new Date(); 30 | const ranges = { 31 | today: new Date(now.setHours(0, 0, 0, 0)), 32 | week: new Date(now.setDate(now.getDate() - 7)), 33 | month: new Date(now.setMonth(now.getMonth() - 1)), 34 | year: new Date(now.setFullYear(now.getFullYear() - 1)) 35 | }; 36 | return ranges[period] || null; 37 | }; 38 | 39 | exports.sanitizeUser = (user) => { 40 | const { password, ...sanitized } = user.toObject(); 41 | return sanitized; 42 | }; -------------------------------------------------------------------------------- /utils/validators.js: -------------------------------------------------------------------------------- 1 | const { body } = require('express-validator'); 2 | 3 | exports.questionValidation = [ 4 | body('questionText').notEmpty().withMessage('Question text is required'), 5 | body('type').isIn(['single-choice', 'multiple-choice', 'true-false']).withMessage('Invalid question type'), 6 | body('options').isArray({ min: 2 }).withMessage('At least 2 options are required'), 7 | body('category').notEmpty().withMessage('Category is required'), 8 | body('difficulty').optional().isIn(['foundation', 'advanced', 'expert']).withMessage('Invalid difficulty') 9 | ]; 10 | 11 | exports.quizValidation = [ 12 | body('mode').isIn(['practice', 'exam', 'timed', 'category']).withMessage('Invalid quiz mode'), 13 | body('numberOfQuestions').optional().isInt({ min: 1, max: 100 }).withMessage('Number of questions must be between 1 and 100') 14 | ]; 15 | 16 | exports.achievementValidation = [ 17 | body('name').notEmpty().withMessage('Achievement name is required'), 18 | body('description').notEmpty().withMessage('Achievement description is required'), 19 | body('type').isIn(['quiz', 'streak', 'score', 'category', 'special']).withMessage('Invalid achievement type'), 20 | body('criteria.metric').notEmpty().withMessage('Achievement metric is required'), 21 | body('criteria.threshold').isNumeric().withMessage('Threshold must be a number') 22 | ]; -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=2001 3 | 4 | # Database 5 | # MONGODB_URI=mongodb://localhost:27017/istqb_practice 6 | # For MongoDB Atlas: mongodb+srv://username:password@cluster.mongodb.net/istqb_practice 7 | MONGODB_URI=mongodb+srv://istqb_opensource:cSAib3OUQhW3dA5B@istqbpractice.el6nw8z.mongodb.net/?appName=istqbpractice 8 | 9 | # JWT Secret 10 | JWT_SECRET=your_super_secret_jwt_key_change_this_in_production 11 | # JWT_EXPIRE: Token expiration time 12 | # Recommended: 7d (7 days) for good balance between security and UX 13 | # Options: 1h, 24h, 7d, 30d 14 | JWT_EXPIRE=7d 15 | 16 | # CORS 17 | CORS_ORIGIN=http://localhost:5173 18 | 19 | # Rate Limiting 20 | RATE_LIMIT_WINDOW_MS=9000 21 | RATE_LIMIT_MAX_REQUESTS=100 22 | 23 | # Email Configuration (Ethereal Email for Testing) 24 | # Run: node scripts/setupEtherealEmail.js to generate credentials 25 | SMTP_HOST=smtp.ethereal.email 26 | SMTP_PORT=587 27 | SMTP_SECURE=false 28 | SMTP_USER=your-ethereal-username@ethereal.email 29 | SMTP_PASS=your-ethereal-password 30 | SMTP_FROM="Test Automation Arena " 31 | 32 | # Frontend URL (for email verification links) 33 | FRONTEND_URL=http://localhost:5173 34 | 35 | # Seed accounts 36 | ADMIN_EMAIL=admin@istqb.com 37 | ADMIN_PASSWORD=Admin123! 38 | TEST_USER_USERNAME=testuser 39 | TEST_USER_EMAIL=test@example.com 40 | TEST_USER_PASSWORD=Test123! -------------------------------------------------------------------------------- /models/mysql/Progress.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const Progress = sequelize.define('Progress', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | userId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | unique: true, 14 | field: 'user_id', 15 | references: { 16 | model: 'users', 17 | key: 'id' 18 | } 19 | }, 20 | 21 | // Study Streak 22 | currentStreak: { 23 | type: DataTypes.INTEGER, 24 | defaultValue: 0, 25 | field: 'current_streak' 26 | }, 27 | longestStreak: { 28 | type: DataTypes.INTEGER, 29 | defaultValue: 0, 30 | field: 'longest_streak' 31 | }, 32 | lastStudyDate: { 33 | type: DataTypes.DATEONLY, 34 | allowNull: true, 35 | field: 'last_study_date' 36 | }, 37 | 38 | totalTimeSpent: { 39 | type: DataTypes.INTEGER, 40 | defaultValue: 0, 41 | field: 'total_time_spent' 42 | }, 43 | lastUpdated: { 44 | type: DataTypes.DATE, 45 | defaultValue: DataTypes.NOW, 46 | field: 'last_updated' 47 | } 48 | }, { 49 | tableName: 'progress', 50 | timestamps: true, 51 | underscored: true, 52 | indexes: [ 53 | { fields: ['user_id'], unique: true } 54 | ] 55 | }); 56 | 57 | module.exports = Progress; 58 | -------------------------------------------------------------------------------- /config/mysqlDatabase.js: -------------------------------------------------------------------------------- 1 | const { Sequelize } = require('sequelize'); 2 | const config = require('./mysql.config'); 3 | 4 | const env = process.env.NODE_ENV || 'development'; 5 | const dbConfig = config[env]; 6 | 7 | // Create Sequelize instance 8 | const sequelize = new Sequelize( 9 | dbConfig.database, 10 | dbConfig.username, 11 | dbConfig.password, 12 | { 13 | host: dbConfig.host, 14 | port: dbConfig.port, 15 | dialect: dbConfig.dialect, 16 | logging: dbConfig.logging, 17 | pool: dbConfig.pool, 18 | define: dbConfig.define 19 | } 20 | ); 21 | 22 | // Test connection 23 | const testConnection = async () => { 24 | try { 25 | await sequelize.authenticate(); 26 | console.log('✅ MySQL Connection has been established successfully.'); 27 | return true; 28 | } catch (error) { 29 | console.error('❌ Unable to connect to MySQL database:', error.message); 30 | return false; 31 | } 32 | }; 33 | 34 | // Sync database (create tables if they don't exist) 35 | const syncDatabase = async (options = {}) => { 36 | try { 37 | await sequelize.sync(options); 38 | console.log('✅ MySQL Database synchronized successfully.'); 39 | } catch (error) { 40 | console.error('❌ Error synchronizing MySQL database:', error.message); 41 | throw error; 42 | } 43 | }; 44 | 45 | module.exports = { 46 | sequelize, 47 | testConnection, 48 | syncDatabase, 49 | Sequelize 50 | }; 51 | -------------------------------------------------------------------------------- /models/mysql/CategoryProgress.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const CategoryProgress = sequelize.define('CategoryProgress', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | progressId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'progress_id', 14 | references: { 15 | model: 'progress', 16 | key: 'id' 17 | } 18 | }, 19 | category: { 20 | type: DataTypes.STRING(100), 21 | allowNull: false 22 | }, 23 | questionsAttempted: { 24 | type: DataTypes.INTEGER, 25 | defaultValue: 0, 26 | field: 'questions_attempted' 27 | }, 28 | questionsCorrect: { 29 | type: DataTypes.INTEGER, 30 | defaultValue: 0, 31 | field: 'questions_correct' 32 | }, 33 | averageScore: { 34 | type: DataTypes.DECIMAL(5, 2), 35 | defaultValue: 0, 36 | field: 'average_score' 37 | }, 38 | lastAttempted: { 39 | type: DataTypes.DATE, 40 | allowNull: true, 41 | field: 'last_attempted' 42 | } 43 | }, { 44 | tableName: 'category_progress', 45 | timestamps: false, 46 | underscored: true, 47 | indexes: [ 48 | { 49 | unique: true, 50 | fields: ['progress_id', 'category'], 51 | name: 'unique_progress_category' 52 | } 53 | ] 54 | }); 55 | 56 | module.exports = CategoryProgress; 57 | -------------------------------------------------------------------------------- /client/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client_backup/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /models/mysql/Achievement.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const Achievement = sequelize.define('Achievement', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | icon: { 11 | type: DataTypes.STRING(255), 12 | allowNull: false 13 | }, 14 | type: { 15 | type: DataTypes.ENUM('quiz', 'streak', 'score', 'category', 'special'), 16 | allowNull: false 17 | }, 18 | 19 | // Criteria 20 | criteriaMetric: { 21 | type: DataTypes.STRING(100), 22 | allowNull: false, 23 | field: 'criteria_metric' 24 | }, 25 | criteriaThreshold: { 26 | type: DataTypes.INTEGER, 27 | allowNull: false, 28 | field: 'criteria_threshold' 29 | }, 30 | criteriaCategory: { 31 | type: DataTypes.STRING(100), 32 | allowNull: true, 33 | field: 'criteria_category' 34 | }, 35 | 36 | rarity: { 37 | type: DataTypes.ENUM('common', 'rare', 'epic', 'legendary'), 38 | defaultValue: 'common' 39 | }, 40 | points: { 41 | type: DataTypes.INTEGER, 42 | defaultValue: 10 43 | }, 44 | isActive: { 45 | type: DataTypes.BOOLEAN, 46 | defaultValue: true, 47 | field: 'is_active' 48 | } 49 | }, { 50 | tableName: 'achievements', 51 | timestamps: true, 52 | underscored: true, 53 | indexes: [ 54 | { fields: ['type'] }, 55 | { fields: ['is_active'] } 56 | ] 57 | }); 58 | 59 | module.exports = Achievement; 60 | -------------------------------------------------------------------------------- /client_backup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-avatar": "^1.1.10", 14 | "@radix-ui/react-dialog": "^1.1.15", 15 | "@radix-ui/react-dropdown-menu": "^2.1.16", 16 | "@radix-ui/react-label": "^2.1.7", 17 | "@radix-ui/react-progress": "^1.1.7", 18 | "@radix-ui/react-select": "^2.2.6", 19 | "@radix-ui/react-slot": "^1.2.3", 20 | "@radix-ui/react-tabs": "^1.1.13", 21 | "@radix-ui/react-toast": "^1.2.15", 22 | "axios": "^1.13.1", 23 | "class-variance-authority": "^0.7.1", 24 | "clsx": "^2.1.1", 25 | "lucide-react": "^0.552.0", 26 | "prop-types": "^15.8.1", 27 | "react": "^19.1.1", 28 | "react-dom": "^19.1.1", 29 | "react-router-dom": "^7.9.5", 30 | "tailwind-merge": "^3.3.1", 31 | "tailwindcss-animate": "^1.0.7" 32 | }, 33 | "devDependencies": { 34 | "@eslint/js": "^9.36.0", 35 | "@tailwindcss/postcss": "^4.1.17", 36 | "@types/react": "^19.1.16", 37 | "@types/react-dom": "^19.1.9", 38 | "@vitejs/plugin-react": "^5.0.4", 39 | "autoprefixer": "^10.4.21", 40 | "eslint": "^9.36.0", 41 | "eslint-plugin-react-hooks": "^5.2.0", 42 | "eslint-plugin-react-refresh": "^0.4.22", 43 | "globals": "^16.4.0", 44 | "postcss": "^8.5.6", 45 | "tailwindcss": "^4.1.16", 46 | "vite": "^6.0.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /routes/questionUpload.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const multer = require('multer'); 3 | const { 4 | uploadQuestionsFromExcel, 5 | downloadTemplate, 6 | getUploadStats, 7 | getCategories 8 | } = require('../controllers/questionUploadController'); 9 | const { protect, authorize } = require('../middleware/auth'); 10 | 11 | const router = express.Router(); 12 | 13 | // Configure multer for memory storage 14 | const storage = multer.memoryStorage(); 15 | const upload = multer({ 16 | storage, 17 | limits: { 18 | fileSize: 10 * 1024 * 1024 // 10MB limit 19 | }, 20 | fileFilter: (req, file, cb) => { 21 | // Accept only Excel files 22 | if ( 23 | file.mimetype === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || 24 | file.mimetype === 'application/vnd.ms-excel' || 25 | file.originalname.endsWith('.xlsx') || 26 | file.originalname.endsWith('.xls') 27 | ) { 28 | cb(null, true); 29 | } else { 30 | cb(new Error('Only Excel files (.xlsx, .xls) are allowed'), false); 31 | } 32 | } 33 | }); 34 | 35 | // Public routes 36 | router.get('/categories', getCategories); 37 | 38 | // Protected routes (admin only) 39 | router.use(protect); 40 | router.use(authorize('admin')); 41 | 42 | // Upload questions from Excel 43 | router.post('/upload', upload.single('file'), uploadQuestionsFromExcel); 44 | 45 | // Download template 46 | router.get('/template', downloadTemplate); 47 | 48 | // Get upload statistics 49 | router.get('/stats', getUploadStats); 50 | 51 | module.exports = router; 52 | -------------------------------------------------------------------------------- /models/FunctionalBugStats.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const functionalBugStatsSchema = new mongoose.Schema({ 4 | bugId: { 5 | type: String, 6 | required: true, 7 | unique: true 8 | }, 9 | totalAttempts: { 10 | type: Number, 11 | default: 0 12 | }, 13 | totalCompletions: { 14 | type: Number, 15 | default: 0 16 | }, 17 | correctIdentifications: { 18 | type: Number, 19 | default: 0 20 | }, 21 | averageTimeSpent: { 22 | type: Number, 23 | default: 0 24 | }, 25 | averageHintsUsed: { 26 | type: Number, 27 | default: 0 28 | }, 29 | successRate: { 30 | type: Number, 31 | default: 0 32 | }, 33 | updatedAt: { 34 | type: Date, 35 | default: Date.now 36 | } 37 | }); 38 | 39 | // Method to update stats 40 | functionalBugStatsSchema.methods.updateStats = async function(isCorrect, timeSpent, hintsUsed) { 41 | this.totalAttempts += 1; 42 | this.totalCompletions += 1; 43 | 44 | if (isCorrect) { 45 | this.correctIdentifications += 1; 46 | } 47 | 48 | // Update averages 49 | this.averageTimeSpent = ((this.averageTimeSpent * (this.totalCompletions - 1)) + timeSpent) / this.totalCompletions; 50 | this.averageHintsUsed = ((this.averageHintsUsed * (this.totalCompletions - 1)) + hintsUsed) / this.totalCompletions; 51 | this.successRate = (this.correctIdentifications / this.totalCompletions) * 100; 52 | this.updatedAt = Date.now(); 53 | 54 | await this.save(); 55 | }; 56 | 57 | module.exports = mongoose.model('FunctionalBugStats', functionalBugStatsSchema); 58 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class"], 4 | content: [ 5 | "./index.html", 6 | "./src/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | extend: { 10 | colors: { 11 | border: "hsl(var(--border))", 12 | input: "hsl(var(--input))", 13 | ring: "hsl(var(--ring))", 14 | background: "hsl(var(--background))", 15 | foreground: "hsl(var(--foreground))", 16 | primary: { 17 | DEFAULT: "hsl(var(--primary))", 18 | foreground: "hsl(var(--primary-foreground))", 19 | }, 20 | secondary: { 21 | DEFAULT: "hsl(var(--secondary))", 22 | foreground: "hsl(var(--secondary-foreground))", 23 | }, 24 | destructive: { 25 | DEFAULT: "hsl(var(--destructive))", 26 | foreground: "hsl(var(--destructive-foreground))", 27 | }, 28 | muted: { 29 | DEFAULT: "hsl(var(--muted))", 30 | foreground: "hsl(var(--muted-foreground))", 31 | }, 32 | accent: { 33 | DEFAULT: "hsl(var(--accent))", 34 | foreground: "hsl(var(--accent-foreground))", 35 | }, 36 | card: { 37 | DEFAULT: "hsl(var(--card))", 38 | foreground: "hsl(var(--card-foreground))", 39 | }, 40 | }, 41 | borderRadius: { 42 | lg: "var(--radius)", 43 | md: "calc(var(--radius) - 2px)", 44 | sm: "calc(var(--radius) - 4px)", 45 | }, 46 | }, 47 | }, 48 | plugins: [require("tailwindcss-animate")], 49 | } -------------------------------------------------------------------------------- /client_backup/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class"], 4 | content: [ 5 | "./index.html", 6 | "./src/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | extend: { 10 | colors: { 11 | border: "hsl(var(--border))", 12 | input: "hsl(var(--input))", 13 | ring: "hsl(var(--ring))", 14 | background: "hsl(var(--background))", 15 | foreground: "hsl(var(--foreground))", 16 | primary: { 17 | DEFAULT: "hsl(var(--primary))", 18 | foreground: "hsl(var(--primary-foreground))", 19 | }, 20 | secondary: { 21 | DEFAULT: "hsl(var(--secondary))", 22 | foreground: "hsl(var(--secondary-foreground))", 23 | }, 24 | destructive: { 25 | DEFAULT: "hsl(var(--destructive))", 26 | foreground: "hsl(var(--destructive-foreground))", 27 | }, 28 | muted: { 29 | DEFAULT: "hsl(var(--muted))", 30 | foreground: "hsl(var(--muted-foreground))", 31 | }, 32 | accent: { 33 | DEFAULT: "hsl(var(--accent))", 34 | foreground: "hsl(var(--accent-foreground))", 35 | }, 36 | card: { 37 | DEFAULT: "hsl(var(--card))", 38 | foreground: "hsl(var(--card-foreground))", 39 | }, 40 | }, 41 | borderRadius: { 42 | lg: "var(--radius)", 43 | md: "calc(var(--radius) - 2px)", 44 | sm: "calc(var(--radius) - 4px)", 45 | }, 46 | }, 47 | }, 48 | plugins: [require("tailwindcss-animate")], 49 | } -------------------------------------------------------------------------------- /routes/auth.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { body } = require('express-validator'); 4 | const { register, login, getMe, updateProfile, changePassword, refreshToken, extendToken } = require('../controllers/authController'); 5 | const { protect } = require('../middleware/auth'); 6 | const { validate } = require('../middleware/validation'); 7 | const { authLimiter } = require('../middleware/rateLimiter'); 8 | 9 | router.post('/register', authLimiter, [ 10 | body('username').trim().isLength({ min: 3, max: 30 }).withMessage('Username must be 3-30 characters'), 11 | body('email').isEmail().normalizeEmail().withMessage('Valid email is required'), 12 | body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'), 13 | validate 14 | ], register); 15 | 16 | router.post('/login', authLimiter, [ 17 | body('email').isEmail().normalizeEmail().withMessage('Valid email is required'), 18 | body('password').notEmpty().withMessage('Password is required'), 19 | validate 20 | ], login); 21 | 22 | router.get('/me', protect, getMe); 23 | router.put('/profile', protect, updateProfile); 24 | router.put('/change-password', protect, [ 25 | body('currentPassword').notEmpty().withMessage('Current password is required'), 26 | body('newPassword').isLength({ min: 6 }).withMessage('New password must be at least 6 characters'), 27 | validate 28 | ], changePassword); 29 | 30 | // Token management routes 31 | router.post('/refresh', protect, refreshToken); 32 | router.post('/extend', protect, extendToken); 33 | 34 | module.exports = router; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "istqb-practice-backend", 3 | "version": "0.1.0", 4 | "description": "Backend API for ISTQB Practice Q&A Platform", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "dev": "nodemon server.js", 9 | "test": "jest --coverage --detectOpenHandles", 10 | "test:watch": "source /home/korrektt/nodevenv/qaarena/24/bin/activate && cd /home/korrektt/qaarena", 11 | "seed": "node scripts/seedDatabase.js", 12 | "setup-email": "node scripts/setupEtherealEmail.js" 13 | }, 14 | "keywords": [ 15 | "istqb", 16 | "testing", 17 | "certification", 18 | "quiz", 19 | "education" 20 | ], 21 | "author": "ISTQB Community", 22 | "license": "MIT", 23 | "dependencies": { 24 | "axios": "^1.13.2", 25 | "bcryptjs": "^2.4.3", 26 | "compression": "^1.7.4", 27 | "cors": "^2.8.5", 28 | "dotenv": "^16.3.1", 29 | "express": "^4.18.2", 30 | "express-rate-limit": "^7.1.5", 31 | "express-validator": "^7.0.1", 32 | "helmet": "^7.1.0", 33 | "jsonwebtoken": "^9.0.2", 34 | "mongoose": "^8.0.3", 35 | "morgan": "^1.10.0", 36 | "multer": "^2.0.2", 37 | "mysql2": "^3.15.3", 38 | "nodemailer": "^7.0.11", 39 | "sequelize": "^6.37.7", 40 | "sequelize-cli": "^6.6.3", 41 | "xlsx": "^0.18.5" 42 | }, 43 | "devDependencies": { 44 | "jest": "^29.7.0", 45 | "nodemon": "^3.0.2", 46 | "supertest": "^6.3.3" 47 | }, 48 | "jest": { 49 | "testEnvironment": "node", 50 | "coveragePathIgnorePatterns": [ 51 | "/node_modules/" 52 | ], 53 | "testTimeout": 30000 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "build:prod": "vite build --mode production", 11 | "preview:prod": "vite preview --port 4173" 12 | }, 13 | "dependencies": { 14 | "@radix-ui/react-avatar": "^1.1.10", 15 | "@radix-ui/react-dialog": "^1.1.15", 16 | "@radix-ui/react-dropdown-menu": "^2.1.16", 17 | "@radix-ui/react-label": "^2.1.7", 18 | "@radix-ui/react-progress": "^1.1.7", 19 | "@radix-ui/react-select": "^2.2.6", 20 | "@radix-ui/react-slot": "^1.2.3", 21 | "@radix-ui/react-tabs": "^1.1.13", 22 | "@radix-ui/react-toast": "^1.2.15", 23 | "@tanstack/react-query": "^5.90.12", 24 | "axios": "^1.13.1", 25 | "class-variance-authority": "^0.7.1", 26 | "clsx": "^2.1.1", 27 | "lucide-react": "^0.552.0", 28 | "prop-types": "^15.8.1", 29 | "react": "^19.1.1", 30 | "react-dom": "^19.1.1", 31 | "react-router-dom": "^7.9.5", 32 | "tailwind-merge": "^3.3.1", 33 | "tailwindcss-animate": "^1.0.7" 34 | }, 35 | "devDependencies": { 36 | "@eslint/js": "^9.36.0", 37 | "@types/react": "^19.1.16", 38 | "@types/react-dom": "^19.1.9", 39 | "@vitejs/plugin-react": "^5.0.4", 40 | "autoprefixer": "^10.4.21", 41 | "eslint": "^9.36.0", 42 | "eslint-plugin-react-hooks": "^5.2.0", 43 | "eslint-plugin-react-refresh": "^0.4.22", 44 | "globals": "^16.4.0", 45 | "postcss": "^8.5.6", 46 | "tailwindcss": "^3.4.18", 47 | "vite": "^7.1.7" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /middleware/rateLimiter.js: -------------------------------------------------------------------------------- 1 | const rateLimit = require('express-rate-limit'); 2 | 3 | const isProd = process.env.NODE_ENV === 'production'; 4 | 5 | exports.apiLimiter = rateLimit({ 6 | windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000, 7 | max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100, 8 | message: { 9 | status: 'error', 10 | message: 'Too many requests from this IP, please try again later' 11 | }, 12 | standardHeaders: true, 13 | legacyHeaders: false, 14 | }); 15 | 16 | // Auth limiter configuration (can be tuned/disabled via env) 17 | const rawAuthWindow = parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 10); 18 | const rawAuthMax = parseInt(process.env.AUTH_RATE_LIMIT_MAX_REQUESTS, 10); 19 | const authLimitDisabled = process.env.AUTH_RATE_LIMIT_DISABLED === 'true'; 20 | 21 | const authWindowMs = Number.isNaN(rawAuthWindow) 22 | ? (isProd ? 15 * 60 * 1000 : 60 * 1000) 23 | : rawAuthWindow; 24 | 25 | const authMax = Number.isNaN(rawAuthMax) 26 | ? (isProd ? 5 : 1000) 27 | : rawAuthMax; 28 | 29 | exports.authLimiter = authLimitDisabled 30 | ? (req, res, next) => next() 31 | : rateLimit({ 32 | windowMs: authWindowMs, 33 | max: authMax, 34 | message: { 35 | status: 'error', 36 | message: 'Too many authentication attempts, please try again later' 37 | }, 38 | skipSuccessfulRequests: true, 39 | }); 40 | 41 | exports.quizLimiter = rateLimit({ 42 | windowMs: 60 * 1000, 43 | max: 100, // Increased for testing 44 | message: { 45 | status: 'error', 46 | message: 'Too many quiz submissions, please slow down' 47 | }, 48 | }); -------------------------------------------------------------------------------- /config/mysql.config.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | module.exports = { 4 | development: { 5 | username: process.env.MYSQL_USER || 'root', 6 | password: process.env.MYSQL_PASSWORD || '', 7 | database: process.env.MYSQL_DATABASE || 'qa_arena', 8 | host: process.env.MYSQL_HOST || 'localhost', 9 | port: process.env.MYSQL_PORT || 3306, 10 | dialect: 'mysql', 11 | logging: console.log, 12 | pool: { 13 | max: 10, 14 | min: 0, 15 | acquire: 60000, 16 | idle: 10000, 17 | evict: 10000, 18 | handleDisconnects: true 19 | }, 20 | define: { 21 | timestamps: true, 22 | underscored: true, 23 | freezeTableName: true 24 | } 25 | }, 26 | test: { 27 | username: process.env.MYSQL_USER || 'root', 28 | password: process.env.MYSQL_PASSWORD || '', 29 | database: process.env.MYSQL_TEST_DATABASE || 'qa_arena_test', 30 | host: process.env.MYSQL_HOST || 'localhost', 31 | port: process.env.MYSQL_PORT || 3306, 32 | dialect: 'mysql', 33 | logging: false, 34 | pool: { 35 | max: 5, 36 | min: 0, 37 | acquire: 30000, 38 | idle: 10000 39 | } 40 | }, 41 | production: { 42 | username: process.env.MYSQL_USER, 43 | password: process.env.MYSQL_PASSWORD, 44 | database: process.env.MYSQL_DATABASE, 45 | host: process.env.MYSQL_HOST, 46 | port: process.env.MYSQL_PORT || 3306, 47 | dialect: 'mysql', 48 | logging: false, 49 | pool: { 50 | max: 10, 51 | min: 2, 52 | acquire: 30000, 53 | idle: 10000 54 | }, 55 | define: { 56 | timestamps: true, 57 | underscored: true, 58 | freezeTableName: true 59 | } 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /models/UserFunctionalBugProgress.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const userFunctionalBugProgressSchema = new mongoose.Schema({ 4 | user: { 5 | type: mongoose.Schema.Types.ObjectId, 6 | ref: 'User', 7 | required: true 8 | }, 9 | bugId: { 10 | type: String, 11 | required: true 12 | }, 13 | domain: { 14 | type: String, 15 | required: true, 16 | enum: ['fintech', 'ecommerce', 'ordering', 'grading'] 17 | }, 18 | difficulty: { 19 | type: String, 20 | enum: ['beginner', 'intermediate', 'advanced'] 21 | }, 22 | attempts: { 23 | type: Number, 24 | default: 0 25 | }, 26 | completed: { 27 | type: Boolean, 28 | default: false 29 | }, 30 | pointsEarned: { 31 | type: Number, 32 | default: 0 33 | }, 34 | timeSpent: { 35 | type: Number, 36 | default: 0 37 | // in seconds 38 | }, 39 | hintsUsed: { 40 | type: Number, 41 | default: 0 42 | }, 43 | identifiedCorrectly: { 44 | type: Boolean, 45 | default: false 46 | }, 47 | userAnswer: { 48 | bugType: String, 49 | description: String, 50 | confidence: Number, 51 | submittedAt: Date 52 | }, 53 | startedAt: { 54 | type: Date, 55 | default: Date.now 56 | }, 57 | completedAt: Date, 58 | createdAt: { 59 | type: Date, 60 | default: Date.now 61 | } 62 | }); 63 | 64 | // Compound index for efficient queries and uniqueness 65 | userFunctionalBugProgressSchema.index({ user: 1, bugId: 1 }, { unique: true }); 66 | userFunctionalBugProgressSchema.index({ user: 1, domain: 1 }); 67 | userFunctionalBugProgressSchema.index({ user: 1, completed: 1 }); 68 | 69 | module.exports = mongoose.model('UserFunctionalBugProgress', userFunctionalBugProgressSchema); 70 | -------------------------------------------------------------------------------- /client/src/components/ui/card.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cn } from "../../lib/utils" 3 | 4 | const Card = React.forwardRef(({ className, ...props }, ref) => ( 5 |
13 | )) 14 | Card.displayName = "Card" 15 | 16 | const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( 17 |
22 | )) 23 | CardHeader.displayName = "CardHeader" 24 | 25 | const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( 26 |

34 | )) 35 | CardTitle.displayName = "CardTitle" 36 | 37 | const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( 38 |

43 | )) 44 | CardDescription.displayName = "CardDescription" 45 | 46 | const CardContent = React.forwardRef(({ className, ...props }, ref) => ( 47 |

48 | )) 49 | CardContent.displayName = "CardContent" 50 | 51 | const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( 52 |
57 | )) 58 | CardFooter.displayName = "CardFooter" 59 | 60 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 61 | -------------------------------------------------------------------------------- /client/src/components/ui/button.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva } from "class-variance-authority" 4 | import { cn } from "../../lib/utils" 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", 13 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 14 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | ghost: "hover:bg-accent hover:text-accent-foreground", 16 | link: "text-primary underline-offset-4 hover:underline", 17 | }, 18 | size: { 19 | default: "h-10 px-4 py-2", 20 | sm: "h-9 rounded-md px-3", 21 | lg: "h-11 rounded-md px-8", 22 | icon: "h-10 w-10", 23 | }, 24 | }, 25 | defaultVariants: { 26 | variant: "default", 27 | size: "default", 28 | }, 29 | } 30 | ) 31 | 32 | const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { 33 | const Comp = asChild ? Slot : "button" 34 | return ( 35 | 40 | ) 41 | }) 42 | Button.displayName = "Button" 43 | 44 | export { Button, buttonVariants } 45 | -------------------------------------------------------------------------------- /client_backup/src/components/ui/card.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cn } from "../../lib/utils" 3 | 4 | const Card = React.forwardRef(({ className, ...props }, ref) => ( 5 |
13 | )) 14 | Card.displayName = "Card" 15 | 16 | const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( 17 |
22 | )) 23 | CardHeader.displayName = "CardHeader" 24 | 25 | const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( 26 |

34 | )) 35 | CardTitle.displayName = "CardTitle" 36 | 37 | const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( 38 |

43 | )) 44 | CardDescription.displayName = "CardDescription" 45 | 46 | const CardContent = React.forwardRef(({ className, ...props }, ref) => ( 47 |

48 | )) 49 | CardContent.displayName = "CardContent" 50 | 51 | const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( 52 |
57 | )) 58 | CardFooter.displayName = "CardFooter" 59 | 60 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 61 | -------------------------------------------------------------------------------- /client_backup/src/components/ui/button.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva } from "class-variance-authority" 4 | import { cn } from "../../lib/utils" 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", 13 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 14 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | ghost: "hover:bg-accent hover:text-accent-foreground", 16 | link: "text-primary underline-offset-4 hover:underline", 17 | }, 18 | size: { 19 | default: "h-10 px-4 py-2", 20 | sm: "h-9 rounded-md px-3", 21 | lg: "h-11 rounded-md px-8", 22 | icon: "h-10 w-10", 23 | }, 24 | }, 25 | defaultVariants: { 26 | variant: "default", 27 | size: "default", 28 | }, 29 | } 30 | ) 31 | 32 | const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { 33 | const Comp = asChild ? Slot : "button" 34 | return ( 35 | 40 | ) 41 | }) 42 | Button.displayName = "Button" 43 | 44 | export { Button, buttonVariants } 45 | -------------------------------------------------------------------------------- /middleware/errorHandler.js: -------------------------------------------------------------------------------- 1 | const ErrorResponse = require('../utils/errorResponse'); 2 | 3 | const errorHandler = (err, req, res, next) => { 4 | let error = { ...err }; 5 | error.message = err.message; 6 | 7 | // Log to console for dev 8 | console.error('Error:', err); 9 | 10 | // Mongoose bad ObjectId 11 | if (err.name === 'CastError') { 12 | const message = `Resource not found with id of ${err.value}`; 13 | error = new ErrorResponse(message, 404); 14 | } 15 | 16 | // Mongoose duplicate key 17 | if (err.code === 11000) { 18 | const field = Object.keys(err.keyValue)[0]; 19 | const value = err.keyValue[field]; 20 | const message = `Duplicate field value: ${field} '${value}' already exists. Please use another value.`; 21 | error = new ErrorResponse(message, 400); 22 | } 23 | 24 | // Mongoose validation error 25 | if (err.name === 'ValidationError') { 26 | const messages = Object.values(err.errors).map(val => val.message); 27 | error = new ErrorResponse(messages.join(', '), 400); 28 | } 29 | 30 | // JWT errors 31 | if (err.name === 'JsonWebTokenError') { 32 | const message = 'Not authorized, token failed'; 33 | error = new ErrorResponse(message, 401); 34 | } 35 | 36 | // JWT expired 37 | if (err.name === 'TokenExpiredError') { 38 | const message = 'Your session has expired. Please log in again.'; 39 | error = new ErrorResponse(message, 401); 40 | } 41 | 42 | res.status(error.statusCode || 500).json({ 43 | success: false, 44 | message: error.message || 'An unexpected error occurred. Please try again later.', 45 | // Only include stack trace in development mode (not in test or production) 46 | ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) 47 | }); 48 | }; 49 | 50 | module.exports = errorHandler; -------------------------------------------------------------------------------- /seedAchievements.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Achievement = require('./models/Achievement'); 3 | const path = require('path'); 4 | require('dotenv').config({ path: path.join(__dirname, '.env') }); 5 | 6 | const achievements = [ 7 | { 8 | name: { en: 'First Steps' }, 9 | description: { en: 'Complete your first quiz' }, 10 | icon: 'trophy', 11 | type: 'quiz', 12 | criteria: { metric: 'totalQuizzes', threshold: 1 }, 13 | rarity: 'common', 14 | points: 10 15 | }, 16 | { 17 | name: { en: 'Getting Started' }, 18 | description: { en: 'Complete 5 quizzes' }, 19 | icon: 'star', 20 | type: 'quiz', 21 | criteria: { metric: 'totalQuizzes', threshold: 5 }, 22 | rarity: 'common', 23 | points: 25 24 | }, 25 | { 26 | name: { en: 'Point Collector' }, 27 | description: { en: 'Earn a total of 300 points' }, 28 | icon: 'award', 29 | type: 'score', 30 | criteria: { metric: 'totalScore', threshold: 300 }, 31 | rarity: 'rare', 32 | points: 60 33 | } 34 | ]; 35 | 36 | async function seedAchievements() { 37 | try { 38 | const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/istqb_practice'; 39 | console.log('Connecting to MongoDB...'); 40 | await mongoose.connect(mongoUri); 41 | console.log('✅ MongoDB Connected'); 42 | 43 | // Clear existing achievements 44 | await Achievement.deleteMany({}); 45 | console.log('🗑️ Cleared existing achievements'); 46 | 47 | // Insert new achievements 48 | await Achievement.insertMany(achievements); 49 | console.log(`✅ Added ${achievements.length} achievements`); 50 | 51 | console.log('\n📋 Achievements created:'); 52 | achievements.forEach(a => { 53 | console.log(` - ${a.name.en}: ${a.description.en}`); 54 | }); 55 | 56 | process.exit(0); 57 | } catch (error) { 58 | console.error('❌ Error:', error); 59 | process.exit(1); 60 | } 61 | } 62 | 63 | seedAchievements(); 64 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 222.2 84% 4.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 84% 4.9%; 13 | --primary: 173 80% 40%; 14 | --primary-foreground: 0 0% 100%; 15 | --secondary: 210 40% 96.1%; 16 | --secondary-foreground: 222.2 47.4% 11.2%; 17 | --muted: 210 40% 96.1%; 18 | --muted-foreground: 215.4 16.3% 46.9%; 19 | --accent: 210 40% 96.1%; 20 | --accent-foreground: 222.2 47.4% 11.2%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 40% 98%; 23 | --border: 214.3 31.8% 91.4%; 24 | --input: 214.3 31.8% 91.4%; 25 | --ring: 173 80% 40%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 222.2 84% 4.9%; 31 | --foreground: 210 40% 98%; 32 | --card: 222.2 84% 4.9%; 33 | --card-foreground: 210 40% 98%; 34 | --popover: 222.2 84% 4.9%; 35 | --popover-foreground: 210 40% 98%; 36 | --primary: 173 80% 50%; 37 | --primary-foreground: 0 0% 100%; 38 | --secondary: 217.2 32.6% 17.5%; 39 | --secondary-foreground: 210 40% 98%; 40 | --muted: 217.2 32.6% 17.5%; 41 | --muted-foreground: 215 20.2% 65.1%; 42 | --accent: 217.2 32.6% 17.5%; 43 | --accent-foreground: 210 40% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 210 40% 98%; 46 | --border: 217.2 32.6% 17.5%; 47 | --input: 217.2 32.6% 17.5%; 48 | --ring: 173 80% 50%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground; 58 | } 59 | 60 | /* Prevent horizontal scrolling */ 61 | html, body { 62 | overflow-x: hidden; 63 | max-width: 100vw; 64 | } 65 | 66 | /* Ensure all containers respect viewport width */ 67 | * { 68 | box-sizing: border-box; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /client_backup/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 222.2 84% 4.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 84% 4.9%; 13 | --primary: 173 80% 40%; 14 | --primary-foreground: 0 0% 100%; 15 | --secondary: 210 40% 96.1%; 16 | --secondary-foreground: 222.2 47.4% 11.2%; 17 | --muted: 210 40% 96.1%; 18 | --muted-foreground: 215.4 16.3% 46.9%; 19 | --accent: 210 40% 96.1%; 20 | --accent-foreground: 222.2 47.4% 11.2%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 40% 98%; 23 | --border: 214.3 31.8% 91.4%; 24 | --input: 214.3 31.8% 91.4%; 25 | --ring: 173 80% 40%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 222.2 84% 4.9%; 31 | --foreground: 210 40% 98%; 32 | --card: 222.2 84% 4.9%; 33 | --card-foreground: 210 40% 98%; 34 | --popover: 222.2 84% 4.9%; 35 | --popover-foreground: 210 40% 98%; 36 | --primary: 173 80% 50%; 37 | --primary-foreground: 0 0% 100%; 38 | --secondary: 217.2 32.6% 17.5%; 39 | --secondary-foreground: 210 40% 98%; 40 | --muted: 217.2 32.6% 17.5%; 41 | --muted-foreground: 215 20.2% 65.1%; 42 | --accent: 217.2 32.6% 17.5%; 43 | --accent-foreground: 210 40% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 210 40% 98%; 46 | --border: 217.2 32.6% 17.5%; 47 | --input: 217.2 32.6% 17.5%; 48 | --ring: 173 80% 50%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground; 58 | } 59 | 60 | /* Prevent horizontal scrolling */ 61 | html, body { 62 | overflow-x: hidden; 63 | max-width: 100vw; 64 | } 65 | 66 | /* Ensure all containers respect viewport width */ 67 | * { 68 | box-sizing: border-box; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /middleware/auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | // MySQL Model 3 | const { User } = require('../models/mysql'); 4 | 5 | exports.protect = async (req, res, next) => { 6 | try { 7 | let token; 8 | if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { 9 | token = req.headers.authorization.split(' ')[1]; 10 | } 11 | 12 | if (!token) { 13 | return res.status(401).json({ status: 'error', message: 'Not authorized to access this route' }); 14 | } 15 | 16 | try { 17 | const decoded = jwt.verify(token, process.env.JWT_SECRET); 18 | req.user = await User.findByPk(decoded.id); 19 | 20 | if (!req.user) { 21 | return res.status(401).json({ status: 'error', message: 'User not found' }); 22 | } 23 | 24 | if (!req.user.isActive) { 25 | return res.status(401).json({ status: 'error', message: 'User account is deactivated' }); 26 | } 27 | 28 | next(); 29 | } catch (err) { 30 | return res.status(401).json({ status: 'error', message: 'Token is invalid or expired' }); 31 | } 32 | } catch (error) { 33 | next(error); 34 | } 35 | }; 36 | 37 | exports.authorize = (...roles) => { 38 | return (req, res, next) => { 39 | if (!roles.includes(req.user.role)) { 40 | return res.status(403).json({ 41 | status: 'error', 42 | message: `User role '${req.user.role}' is not authorized to access this route` 43 | }); 44 | } 45 | next(); 46 | }; 47 | }; 48 | 49 | exports.optionalAuth = async (req, res, next) => { 50 | try { 51 | let token; 52 | if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { 53 | token = req.headers.authorization.split(' ')[1]; 54 | } 55 | 56 | if (token) { 57 | try { 58 | const decoded = jwt.verify(token, process.env.JWT_SECRET); 59 | req.user = await User.findByPk(decoded.id); 60 | } catch (err) { 61 | req.user = null; 62 | } 63 | } 64 | next(); 65 | } catch (error) { 66 | next(error); 67 | } 68 | }; -------------------------------------------------------------------------------- /models/Quiz.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const quizSchema = new mongoose.Schema({ 4 | user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, 5 | mode: { 6 | type: String, 7 | enum: ['practice', 'exam', 'timed', 'category'], 8 | required: true 9 | }, 10 | questions: [{ 11 | question: { type: mongoose.Schema.Types.ObjectId, ref: 'Question', required: true }, 12 | userAnswer: [{ type: Number }], 13 | isCorrect: Boolean, 14 | timeSpent: { type: Number, default: 0 }, 15 | answeredAt: Date 16 | }], 17 | settings: { 18 | language: { type: String, default: 'en' }, 19 | category: String, 20 | difficulty: String, 21 | numberOfQuestions: Number, 22 | timeLimit: Number, 23 | randomOrder: { type: Boolean, default: true } 24 | }, 25 | score: { 26 | correct: { type: Number, default: 0 }, 27 | incorrect: { type: Number, default: 0 }, 28 | unanswered: { type: Number, default: 0 }, 29 | percentage: { type: Number, default: 0 }, 30 | totalPoints: { type: Number, default: 0 } 31 | }, 32 | status: { 33 | type: String, 34 | enum: ['in-progress', 'completed', 'abandoned'], 35 | default: 'in-progress' 36 | }, 37 | startedAt: { type: Date, default: Date.now }, 38 | completedAt: Date, 39 | totalTime: { type: Number, default: 0 } 40 | }, { timestamps: true }); 41 | 42 | quizSchema.index({ user: 1, status: 1 }); 43 | quizSchema.index({ createdAt: -1 }); 44 | 45 | quizSchema.pre('save', function(next) { 46 | if (this.status === 'completed') { 47 | const totalQuestions = this.questions.length; 48 | this.score.correct = this.questions.filter(q => q.isCorrect === true).length; 49 | this.score.incorrect = this.questions.filter(q => q.isCorrect === false).length; 50 | this.score.unanswered = this.questions.filter(q => q.isCorrect === undefined).length; 51 | if (totalQuestions > 0) { 52 | this.score.percentage = Math.round((this.score.correct / totalQuestions) * 100); 53 | } 54 | } 55 | next(); 56 | }); 57 | 58 | module.exports = mongoose.model('Quiz', quizSchema); -------------------------------------------------------------------------------- /models/Progress.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const progressSchema = new mongoose.Schema({ 4 | user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, unique: true }, 5 | categoryProgress: [{ 6 | category: { type: String, required: true }, 7 | questionsAttempted: { type: Number, default: 0 }, 8 | questionsCorrect: { type: Number, default: 0 }, 9 | averageScore: { type: Number, default: 0 }, 10 | lastAttempted: Date 11 | }], 12 | difficultyProgress: [{ 13 | difficulty: { type: String, required: true }, 14 | questionsAttempted: { type: Number, default: 0 }, 15 | questionsCorrect: { type: Number, default: 0 }, 16 | averageScore: { type: Number, default: 0 } 17 | }], 18 | weakAreas: [{ 19 | category: String, 20 | successRate: Number, 21 | needsImprovement: Boolean 22 | }], 23 | strongAreas: [{ 24 | category: String, 25 | successRate: Number 26 | }], 27 | studyStreak: { 28 | current: { type: Number, default: 0 }, 29 | longest: { type: Number, default: 0 }, 30 | lastStudyDate: Date 31 | }, 32 | milestones: [{ 33 | type: { type: String, required: true }, 34 | achieved: { type: Boolean, default: false }, 35 | achievedAt: Date, 36 | value: Number 37 | }], 38 | recentActivity: [{ 39 | date: { type: Date, default: Date.now }, 40 | questionsAnswered: Number, 41 | score: Number, 42 | timeSpent: Number 43 | }], 44 | totalTimeSpent: { type: Number, default: 0 }, 45 | lastUpdated: { type: Date, default: Date.now } 46 | }, { timestamps: true }); 47 | 48 | progressSchema.methods.updateAreas = function() { 49 | this.weakAreas = this.categoryProgress 50 | .filter(cp => cp.averageScore < 60 && cp.questionsAttempted >= 5) 51 | .map(cp => ({ category: cp.category, successRate: cp.averageScore, needsImprovement: true })) 52 | .sort((a, b) => a.successRate - b.successRate); 53 | 54 | this.strongAreas = this.categoryProgress 55 | .filter(cp => cp.averageScore >= 80 && cp.questionsAttempted >= 5) 56 | .map(cp => ({ category: cp.category, successRate: cp.averageScore })) 57 | .sort((a, b) => b.successRate - a.successRate); 58 | }; 59 | 60 | module.exports = mongoose.model('Progress', progressSchema); -------------------------------------------------------------------------------- /models/FunctionalBug.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const functionalBugSchema = new mongoose.Schema({ 4 | bugId: { 5 | type: String, 6 | required: true, 7 | unique: true, 8 | // Format: FB001, FB002, etc. 9 | }, 10 | domain: { 11 | type: String, 12 | required: true, 13 | enum: ['fintech', 'ecommerce', 'ordering', 'grading', 'authentication'] 14 | }, 15 | title: { 16 | type: String, 17 | required: true 18 | }, 19 | difficulty: { 20 | type: String, 21 | required: true, 22 | enum: ['beginner', 'intermediate', 'advanced'] 23 | }, 24 | severity: { 25 | type: String, 26 | required: true, 27 | enum: ['low', 'medium', 'high', 'critical'] 28 | }, 29 | category: { 30 | type: String, 31 | required: true, 32 | // Business Logic, Calculation Error, Validation Error, etc. 33 | }, 34 | scenario: { 35 | description: String, 36 | steps: [String], 37 | initialState: mongoose.Schema.Types.Mixed, 38 | // Initial state for the simulator 39 | }, 40 | expected: { 41 | type: String, 42 | required: true 43 | }, 44 | actual: { 45 | type: String, 46 | required: true 47 | }, 48 | bugType: { 49 | type: String, 50 | required: true 51 | }, 52 | rootCause: { 53 | type: String, 54 | required: true 55 | }, 56 | fix: { 57 | type: String, 58 | required: true 59 | }, 60 | preventionTips: [String], 61 | testingTips: [String], 62 | points: { 63 | type: Number, 64 | default: 100 65 | }, 66 | hints: [String], 67 | isActive: { 68 | type: Boolean, 69 | default: true 70 | }, 71 | createdBy: { 72 | type: mongoose.Schema.Types.ObjectId, 73 | ref: 'User' 74 | }, 75 | createdAt: { 76 | type: Date, 77 | default: Date.now 78 | }, 79 | updatedAt: { 80 | type: Date, 81 | default: Date.now 82 | } 83 | }); 84 | 85 | // Index for efficient queries 86 | functionalBugSchema.index({ domain: 1, difficulty: 1 }); 87 | functionalBugSchema.index({ isActive: 1 }); 88 | functionalBugSchema.index({ bugId: 1 }); 89 | 90 | // Update timestamp on save 91 | functionalBugSchema.pre('save', function(next) { 92 | this.updatedAt = Date.now(); 93 | next(); 94 | }); 95 | 96 | module.exports = mongoose.model('FunctionalBug', functionalBugSchema); 97 | -------------------------------------------------------------------------------- /models/Question.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const questionSchema = new mongoose.Schema({ 4 | questionText: { 5 | type: Map, 6 | of: String, 7 | required: [true, 'Question text is required'] 8 | }, 9 | type: { 10 | type: String, 11 | enum: ['single-choice', 'multiple-choice', 'true-false'], 12 | required: [true, 'Question type is required'] 13 | }, 14 | options: [{ 15 | text: { type: Map, of: String, required: true }, 16 | isCorrect: { type: Boolean, required: true } 17 | }], 18 | explanation: { type: Map, of: String }, 19 | category: { 20 | type: String, 21 | required: [true, 'Category is required'], 22 | lowercase: true, 23 | trim: true 24 | }, 25 | difficulty: { 26 | type: String, 27 | enum: ['foundation', 'advanced', 'expert'], 28 | default: 'foundation' 29 | }, 30 | syllabus: { type: String, default: 'ISTQB-CTFL-2018' }, 31 | tags: [String], 32 | points: { type: Number, default: 1, min: 1, max: 10 }, 33 | statistics: { 34 | timesAnswered: { type: Number, default: 0 }, 35 | timesCorrect: { type: Number, default: 0 }, 36 | averageTime: { type: Number, default: 0 } 37 | }, 38 | status: { 39 | type: String, 40 | enum: ['draft', 'published', 'archived', 'flagged'], 41 | default: 'published' 42 | }, 43 | createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, 44 | contributors: [{ 45 | user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, 46 | contribution: String, 47 | date: { type: Date, default: Date.now } 48 | }], 49 | flags: [{ 50 | user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, 51 | reason: String, 52 | date: { type: Date, default: Date.now } 53 | }], 54 | votes: { 55 | upvotes: { type: Number, default: 0 }, 56 | downvotes: { type: Number, default: 0 } 57 | } 58 | }, { timestamps: true }); 59 | 60 | questionSchema.index({ category: 1, difficulty: 1, status: 1 }); 61 | questionSchema.index({ createdBy: 1 }); 62 | questionSchema.index({ tags: 1 }); 63 | 64 | questionSchema.virtual('successRate').get(function() { 65 | if (this.statistics.timesAnswered === 0) return 0; 66 | return Math.round((this.statistics.timesCorrect / this.statistics.timesAnswered) * 100); 67 | }); 68 | 69 | module.exports = mongoose.model('Question', questionSchema); -------------------------------------------------------------------------------- /scripts/createDatabase.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const mysql = require('mysql2/promise'); 3 | 4 | async function createDatabase() { 5 | console.log('🔧 Creating MySQL Database...\n'); 6 | 7 | const config = { 8 | host: process.env.MYSQL_HOST || 'localhost', 9 | port: process.env.MYSQL_PORT || 3306, 10 | user: process.env.MYSQL_USER || 'root', 11 | password: process.env.MYSQL_PASSWORD || '' 12 | }; 13 | 14 | const dbName = process.env.MYSQL_DATABASE || 'qa_arena'; 15 | 16 | try { 17 | console.log('Step 1: Connecting to MySQL server...'); 18 | console.log(` Host: ${config.host}`); 19 | console.log(` Port: ${config.port}`); 20 | console.log(` User: ${config.user}\n`); 21 | 22 | // Connect without specifying database 23 | const connection = await mysql.createConnection(config); 24 | 25 | console.log('✅ Connected to MySQL server!\n'); 26 | 27 | // Create database if it doesn't exist 28 | console.log(`Step 2: Creating database '${dbName}'...`); 29 | await connection.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`); 30 | 31 | console.log(`✅ Database '${dbName}' created successfully!\n`); 32 | 33 | // Show databases 34 | console.log('Step 3: Listing databases...'); 35 | const [databases] = await connection.query('SHOW DATABASES'); 36 | console.log('\nAvailable databases:'); 37 | databases.forEach((db, index) => { 38 | const dbName = db.Database; 39 | console.log(` ${index + 1}. ${dbName}`); 40 | }); 41 | 42 | await connection.end(); 43 | 44 | console.log('\n🎉 Database setup complete!\n'); 45 | console.log('Next step: Run the connection test'); 46 | console.log(' node scripts/testMySQLConnection.js\n'); 47 | 48 | } catch (error) { 49 | console.error('\n❌ Error:', error.message); 50 | 51 | if (error.code === 'ECONNREFUSED') { 52 | console.error('\n💡 MySQL server is not running. Please start it:'); 53 | console.error(' Mac: brew services start mysql'); 54 | console.error(' Or: mysql.server start\n'); 55 | } else if (error.code === 'ER_ACCESS_DENIED_ERROR') { 56 | console.error('\n💡 Access denied. Check your credentials in .env file\n'); 57 | } 58 | 59 | process.exit(1); 60 | } 61 | } 62 | 63 | createDatabase(); 64 | -------------------------------------------------------------------------------- /models/mysql/ArenaUser.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const ArenaUser = sequelize.define('ArenaUser', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | firstName: { 11 | type: DataTypes.STRING(100), 12 | allowNull: false, 13 | field: 'first_name', 14 | validate: { 15 | notEmpty: { 16 | msg: 'First name is required' 17 | } 18 | } 19 | }, 20 | lastName: { 21 | type: DataTypes.STRING(100), 22 | allowNull: false, 23 | field: 'last_name', 24 | validate: { 25 | notEmpty: { 26 | msg: 'Last name is required' 27 | } 28 | } 29 | }, 30 | email: { 31 | type: DataTypes.STRING(255), 32 | allowNull: false, 33 | unique: true, 34 | validate: { 35 | isEmail: { 36 | msg: 'Please provide a valid email' 37 | }, 38 | notEmpty: { 39 | msg: 'Email is required' 40 | } 41 | }, 42 | set(value) { 43 | this.setDataValue('email', value.toLowerCase().trim()); 44 | } 45 | }, 46 | password: { 47 | type: DataTypes.STRING(255), 48 | allowNull: false, 49 | validate: { 50 | len: { 51 | args: [6, 255], 52 | msg: 'Password must be at least 6 characters' 53 | }, 54 | notEmpty: { 55 | msg: 'Password is required' 56 | } 57 | } 58 | }, 59 | authMode: { 60 | type: DataTypes.ENUM('otp', 'token'), 61 | defaultValue: 'otp', 62 | field: 'auth_mode' 63 | }, 64 | isVerified: { 65 | type: DataTypes.BOOLEAN, 66 | defaultValue: false, 67 | field: 'is_verified' 68 | }, 69 | verificationOtp: { 70 | type: DataTypes.STRING(10), 71 | allowNull: true, 72 | field: 'verification_otp' 73 | }, 74 | verificationToken: { 75 | type: DataTypes.STRING(255), 76 | allowNull: true, 77 | field: 'verification_token' 78 | }, 79 | verificationExpiry: { 80 | type: DataTypes.DATE, 81 | allowNull: true, 82 | field: 'verification_expiry' 83 | } 84 | }, { 85 | tableName: 'arena_users', 86 | timestamps: true, 87 | underscored: true, 88 | createdAt: 'created_at', 89 | updatedAt: false, 90 | indexes: [ 91 | { fields: ['email'], unique: true }, 92 | { fields: ['is_verified'] } 93 | ] 94 | }); 95 | 96 | module.exports = ArenaUser; 97 | -------------------------------------------------------------------------------- /controllers/analyticsController.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const { Op } = require('sequelize'); 3 | const { SiteVisit } = require('../models/mysql'); 4 | 5 | // Public endpoint to record a visit 6 | exports.recordVisit = async (req, res, next) => { 7 | try { 8 | const path = req.body.path || req.originalUrl; 9 | const rawIp = 10 | req.headers['x-forwarded-for']?.split(',')[0]?.trim() || 11 | req.ip || 12 | req.connection?.remoteAddress || 13 | null; 14 | 15 | const ipHash = rawIp 16 | ? crypto.createHash('sha256').update(rawIp).digest('hex') 17 | : null; 18 | 19 | await SiteVisit.create({ 20 | path, 21 | ipHash, 22 | userAgent: (req.headers['user-agent'] || '').slice(0, 255), 23 | userId: req.user?.id || null, 24 | }); 25 | 26 | return res.status(204).end(); 27 | } catch (error) { 28 | return next(error); 29 | } 30 | }; 31 | 32 | // Admin-only: aggregated visit stats 33 | exports.getVisitStats = async (req, res, next) => { 34 | try { 35 | const { from, to, groupBy = 'day' } = req.query; 36 | 37 | let format; 38 | switch (groupBy) { 39 | case 'hour': 40 | format = '%Y-%m-%d %H:00:00'; 41 | break; 42 | case 'month': 43 | format = '%Y-%m-01'; 44 | break; 45 | case 'year': 46 | format = '%Y-01-01'; 47 | break; 48 | default: 49 | format = '%Y-%m-%d'; 50 | } 51 | 52 | const where = {}; 53 | if (from) { 54 | where.visitedAt = { [Op.gte]: new Date(from) }; 55 | } 56 | if (to) { 57 | where.visitedAt = { 58 | ...(where.visitedAt || {}), 59 | [Op.lte]: new Date(to), 60 | }; 61 | } 62 | 63 | const buckets = await SiteVisit.findAll({ 64 | where, 65 | attributes: [ 66 | [SiteVisit.sequelize.fn('DATE_FORMAT', SiteVisit.sequelize.col('visited_at'), format), 'bucket'], 67 | [SiteVisit.sequelize.fn('COUNT', SiteVisit.sequelize.col('*')), 'count'], 68 | ], 69 | group: ['bucket'], 70 | order: [[SiteVisit.sequelize.literal('bucket'), 'ASC']], 71 | raw: true, 72 | }); 73 | 74 | const total = await SiteVisit.count({ where }); 75 | 76 | return res.status(200).json({ 77 | status: 'success', 78 | data: { 79 | total, 80 | buckets, 81 | }, 82 | }); 83 | } catch (error) { 84 | return next(error); 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /models/User.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const bcrypt = require('bcryptjs'); 3 | 4 | const userSchema = new mongoose.Schema({ 5 | username: { 6 | type: String, 7 | required: [true, 'Username is required'], 8 | unique: true, 9 | trim: true, 10 | minlength: [3, 'Username must be at least 3 characters'], 11 | maxlength: [30, 'Username cannot exceed 30 characters'] 12 | }, 13 | email: { 14 | type: String, 15 | required: [true, 'Email is required'], 16 | unique: true, 17 | lowercase: true, 18 | trim: true, 19 | match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email'] 20 | }, 21 | password: { 22 | type: String, 23 | required: [true, 'Password is required'], 24 | minlength: [6, 'Password must be at least 6 characters'], 25 | select: false 26 | }, 27 | role: { 28 | type: String, 29 | enum: ['user', 'admin', 'moderator'], 30 | default: 'user' 31 | }, 32 | profile: { 33 | firstName: String, 34 | lastName: String, 35 | country: String, 36 | preferredLanguage: { type: String, default: 'en' }, 37 | avatar: String 38 | }, 39 | stats: { 40 | totalQuizzes: { type: Number, default: 0 }, 41 | totalQuestions: { type: Number, default: 0 }, 42 | correctAnswers: { type: Number, default: 0 }, 43 | totalScore: { type: Number, default: 0 }, 44 | averageScore: { type: Number, default: 0 }, 45 | streak: { type: Number, default: 0 }, 46 | lastActiveDate: Date 47 | }, 48 | achievements: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Achievement' }], 49 | isActive: { type: Boolean, default: true }, 50 | isEmailVerified: { type: Boolean, default: false }, 51 | lastLogin: Date, 52 | createdAt: { type: Date, default: Date.now } 53 | }, { timestamps: true }); 54 | 55 | userSchema.pre('save', async function(next) { 56 | if (!this.isModified('password')) return next(); 57 | const salt = await bcrypt.genSalt(10); 58 | this.password = await bcrypt.hash(this.password, salt); 59 | next(); 60 | }); 61 | 62 | userSchema.methods.comparePassword = async function(candidatePassword) { 63 | return await bcrypt.compare(candidatePassword, this.password); 64 | }; 65 | 66 | userSchema.methods.updateAverageScore = function() { 67 | if (this.stats.totalQuestions > 0) { 68 | this.stats.averageScore = Math.round((this.stats.correctAnswers / this.stats.totalQuestions) * 100); 69 | } 70 | }; 71 | 72 | module.exports = mongoose.model('User', userSchema); -------------------------------------------------------------------------------- /middleware/arenaAuth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | /** 4 | * Middleware to verify JWT token for Arena Auth Simulator 5 | */ 6 | exports.verifyArenaToken = (req, res, next) => { 7 | try { 8 | // Get token from header 9 | const token = req.header('Authorization')?.replace('Bearer ', '') || 10 | req.header('x-auth-token'); 11 | 12 | if (!token) { 13 | return res.status(401).json({ 14 | success: false, 15 | message: 'No token provided', 16 | expired: false 17 | }); 18 | } 19 | 20 | // Verify token 21 | const decoded = jwt.verify(token, process.env.JWT_SECRET || 'arena-secret-key'); 22 | 23 | // Attach user info to request 24 | req.user = decoded; 25 | next(); 26 | } catch (error) { 27 | // Check if token is expired 28 | if (error.name === 'TokenExpiredError') { 29 | return res.status(401).json({ 30 | success: false, 31 | message: 'Token has expired. Please sign in again.', 32 | expired: true 33 | }); 34 | } 35 | 36 | // Invalid token 37 | return res.status(401).json({ 38 | success: false, 39 | message: 'Invalid token', 40 | expired: false 41 | }); 42 | } 43 | }; 44 | 45 | /** 46 | * Verify token and return user info (for dashboard access) 47 | */ 48 | exports.verifyUser = async (req, res) => { 49 | try { 50 | const token = req.header('Authorization')?.replace('Bearer ', '') || 51 | req.header('x-auth-token'); 52 | 53 | if (!token) { 54 | return res.status(401).json({ 55 | success: false, 56 | message: 'No token provided', 57 | expired: false 58 | }); 59 | } 60 | 61 | // Verify token 62 | const decoded = jwt.verify(token, process.env.JWT_SECRET || 'arena-secret-key'); 63 | 64 | res.json({ 65 | success: true, 66 | user: { 67 | id: decoded.userId, 68 | email: decoded.email 69 | } 70 | }); 71 | } catch (error) { 72 | // Check if token is expired 73 | if (error.name === 'TokenExpiredError') { 74 | return res.status(401).json({ 75 | success: false, 76 | message: 'Token has expired. Please sign in again.', 77 | expired: true 78 | }); 79 | } 80 | 81 | // Invalid token 82 | return res.status(401).json({ 83 | success: false, 84 | message: 'Invalid token', 85 | expired: false 86 | }); 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /client/src/context/AuthContext.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, useEffect } from 'react'; 2 | import { authAPI } from '../services/api'; 3 | 4 | const AuthContext = createContext(null); 5 | 6 | export const AuthProvider = ({ children }) => { 7 | const [user, setUser] = useState(null); 8 | const [loading, setLoading] = useState(true); 9 | 10 | useEffect(() => { 11 | const token = localStorage.getItem('token'); 12 | if (token) { 13 | loadUser(); 14 | } else { 15 | setLoading(false); 16 | } 17 | }, []); 18 | 19 | const loadUser = async () => { 20 | try { 21 | const response = await authAPI.getMe(); 22 | setUser(response.data.data.user); 23 | } catch (error) { 24 | localStorage.removeItem('token'); 25 | localStorage.removeItem('user'); 26 | } finally { 27 | setLoading(false); 28 | } 29 | }; 30 | 31 | const login = async (email, password) => { 32 | const response = await authAPI.login({ email, password }); 33 | const { token, user } = response.data.data; 34 | localStorage.setItem('token', token); 35 | localStorage.setItem('user', JSON.stringify(user)); 36 | setUser(user); 37 | return response.data; 38 | }; 39 | 40 | const register = async (userData) => { 41 | const response = await authAPI.register(userData); 42 | const { token, user } = response.data.data; 43 | localStorage.setItem('token', token); 44 | localStorage.setItem('user', JSON.stringify(user)); 45 | setUser(user); 46 | return response.data; 47 | }; 48 | 49 | const logout = () => { 50 | // Clear main app tokens 51 | localStorage.removeItem('token'); 52 | localStorage.removeItem('user'); 53 | 54 | // Clear simulator tokens 55 | localStorage.removeItem('arena_sim_token'); 56 | localStorage.removeItem('arena_sim_user'); 57 | 58 | // Clear any other arena-related tokens 59 | localStorage.removeItem('arena_auth_token'); 60 | localStorage.removeItem('arena_user'); 61 | 62 | // Clear user state 63 | setUser(null); 64 | 65 | console.log('✅ Logout complete - all session data cleared'); 66 | }; 67 | 68 | return ( 69 | 70 | {children} 71 | 72 | ); 73 | }; 74 | 75 | export const useAuth = () => { 76 | const context = useContext(AuthContext); 77 | if (!context) { 78 | throw new Error('useAuth must be used within AuthProvider'); 79 | } 80 | return context; 81 | }; 82 | -------------------------------------------------------------------------------- /client_backup/src/context/AuthContext.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, useEffect } from 'react'; 2 | import { authAPI } from '../services/api'; 3 | 4 | const AuthContext = createContext(null); 5 | 6 | export const AuthProvider = ({ children }) => { 7 | const [user, setUser] = useState(null); 8 | const [loading, setLoading] = useState(true); 9 | 10 | useEffect(() => { 11 | const token = localStorage.getItem('token'); 12 | if (token) { 13 | loadUser(); 14 | } else { 15 | setLoading(false); 16 | } 17 | }, []); 18 | 19 | const loadUser = async () => { 20 | try { 21 | const response = await authAPI.getMe(); 22 | setUser(response.data.data.user); 23 | } catch (error) { 24 | localStorage.removeItem('token'); 25 | localStorage.removeItem('user'); 26 | } finally { 27 | setLoading(false); 28 | } 29 | }; 30 | 31 | const login = async (email, password) => { 32 | const response = await authAPI.login({ email, password }); 33 | const { token, user } = response.data.data; 34 | localStorage.setItem('token', token); 35 | localStorage.setItem('user', JSON.stringify(user)); 36 | setUser(user); 37 | return response.data; 38 | }; 39 | 40 | const register = async (userData) => { 41 | const response = await authAPI.register(userData); 42 | const { token, user } = response.data.data; 43 | localStorage.setItem('token', token); 44 | localStorage.setItem('user', JSON.stringify(user)); 45 | setUser(user); 46 | return response.data; 47 | }; 48 | 49 | const logout = () => { 50 | // Clear main app tokens 51 | localStorage.removeItem('token'); 52 | localStorage.removeItem('user'); 53 | 54 | // Clear simulator tokens 55 | localStorage.removeItem('arena_sim_token'); 56 | localStorage.removeItem('arena_sim_user'); 57 | 58 | // Clear any other arena-related tokens 59 | localStorage.removeItem('arena_auth_token'); 60 | localStorage.removeItem('arena_user'); 61 | 62 | // Clear user state 63 | setUser(null); 64 | 65 | console.log('✅ Logout complete - all session data cleared'); 66 | }; 67 | 68 | return ( 69 | 70 | {children} 71 | 72 | ); 73 | }; 74 | 75 | export const useAuth = () => { 76 | const context = useContext(AuthContext); 77 | if (!context) { 78 | throw new Error('useAuth must be used within AuthProvider'); 79 | } 80 | return context; 81 | }; 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | 9 | # OS 10 | .DS_Store 11 | Thumbs.db 12 | 13 | # Node modules 14 | node_modules/ 15 | client/node_modules/ 16 | client/client/node_modules/ 17 | 18 | # Build outputs 19 | build/ 20 | dist/ 21 | client/build/ 22 | client/dist/ 23 | 24 | # Coverage and test 25 | coverage/ 26 | *.lcov 27 | 28 | # Env files (sensitive) 29 | .env 30 | .env.local 31 | .env.*.local 32 | client/.env 33 | client/.env.local 34 | client/client/.env 35 | client/client/.env.local 36 | 37 | # Allow production env files (non-sensitive) 38 | !.env.production 39 | !.env.example 40 | !client/.env.production 41 | !client/client/.env.production 42 | 43 | # Local data 44 | *.sqlite 45 | *.db 46 | 47 | # Cache 48 | .cache/ 49 | .next/ 50 | .vite/ 51 | client/.vite/ 52 | 53 | # IDE 54 | .vscode/ 55 | .idea/ 56 | *.swp 57 | 58 | # Misc 59 | *.local 60 | *.pid 61 | *.seed 62 | 63 | # Generated assets 64 | public/*.map 65 | client/public/*.map 66 | node_modules/ 67 | package-lock.json 68 | yarn.lock 69 | .env 70 | .env.local 71 | .env.*.local 72 | logs/ 73 | *.log 74 | npm-debug.log* 75 | pids/ 76 | *.pid 77 | coverage/ 78 | .nyc_output/ 79 | .vscode/ 80 | .idea/ 81 | *.swp 82 | .DS_Store 83 | dist/ 84 | build/ 85 | uploads/* 86 | !uploads/.gitkeep 87 | tmp/ 88 | temp/ 89 | 90 | # Word temporary files 91 | ~$* 92 | 93 | # Archived documentation (consolidated into main docs) 94 | docs/archive/ 95 | 96 | # Old test files 97 | QA_Unplugged_Haven_Test_Cases.xlsx 98 | 99 | # Temporary fix/setup documentation (use main README instead) 100 | FIX_*.md 101 | PRODUCTION_*.md 102 | *-fix.sh 103 | *-setup.sh 104 | quick-*.sh 105 | deploy-to-*.sh 106 | 107 | AUTH_SIMULATOR_BACKEND_GUIDE.md 108 | DATA_CY_LOCATORS.md 109 | deploy-prepare.sh 110 | DOCUMENTATION_CLEANUP_SUMMARY.md 111 | functional-bugs-data.json 112 | SOFTWARE_REQUIREMENTS_DOCUMENT.md 113 | TEST_AUTOMATION_ARENA.md 114 | ARENA_AUTH_IMPLEMENTATION_COMPLETE.md 115 | ARENA_AUTH_SETUP_COMPLETE.md 116 | AUTH_SIMULATOR_BACKEND_GUIDE.md 117 | DATA_CY_LOCATORS.md 118 | ETHEREAL_EMAIL_SETUP.md 119 | GMAIL_SETUP_GUIDE.md 120 | SENDGRID_SETUP_GUIDE.md 121 | FIX_*.md 122 | scripts/setupEtherealEmail.js 123 | scripts/testEmail.js 124 | TOKEN_EXPIRATION_IMPLEMENTATION.md 125 | DEMO_VS_REAL_MODE_IMPLEMENTATION.md 126 | LOGIN_PAGE_NAVIGATION_UPDATE.md 127 | LOGOUT_COMPLETE_SESSION_CLEANUP.md 128 | LANDING_PAGE_LOGGED_IN_UX.md 129 | LEADERBOARD_TIE_RANKING_FIX.md 130 | migrateCoreDataToMysql.js 131 | migrateFunctionalBugsToMysql.js 132 | migrateUsersToMysql.js -------------------------------------------------------------------------------- /models/mysql/FunctionalBug.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const FunctionalBug = sequelize.define('FunctionalBug', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | bugId: { 11 | type: DataTypes.STRING(20), 12 | allowNull: false, 13 | unique: true, 14 | field: 'bug_id' 15 | }, 16 | domain: { 17 | type: DataTypes.ENUM('fintech', 'ecommerce', 'ordering', 'grading', 'authentication'), 18 | allowNull: false 19 | }, 20 | title: { 21 | type: DataTypes.STRING(255), 22 | allowNull: false 23 | }, 24 | difficulty: { 25 | type: DataTypes.ENUM('beginner', 'intermediate', 'advanced'), 26 | allowNull: false 27 | }, 28 | severity: { 29 | type: DataTypes.ENUM('low', 'medium', 'high', 'critical'), 30 | allowNull: false 31 | }, 32 | category: { 33 | type: DataTypes.STRING(100), 34 | allowNull: false 35 | }, 36 | 37 | // Scenario 38 | scenarioDescription: { 39 | type: DataTypes.TEXT, 40 | allowNull: true, 41 | field: 'scenario_description' 42 | }, 43 | initialState: { 44 | type: DataTypes.JSON, 45 | allowNull: true, 46 | field: 'initial_state' 47 | }, 48 | 49 | expectedResult: { 50 | type: DataTypes.TEXT, 51 | allowNull: false, 52 | field: 'expected_result' 53 | }, 54 | actualResult: { 55 | type: DataTypes.TEXT, 56 | allowNull: false, 57 | field: 'actual_result' 58 | }, 59 | bugType: { 60 | type: DataTypes.STRING(100), 61 | allowNull: false, 62 | field: 'bug_type' 63 | }, 64 | rootCause: { 65 | type: DataTypes.TEXT, 66 | allowNull: false, 67 | field: 'root_cause' 68 | }, 69 | fixDescription: { 70 | type: DataTypes.TEXT, 71 | allowNull: false, 72 | field: 'fix_description' 73 | }, 74 | 75 | points: { 76 | type: DataTypes.INTEGER, 77 | defaultValue: 100 78 | }, 79 | isActive: { 80 | type: DataTypes.BOOLEAN, 81 | defaultValue: true, 82 | field: 'is_active' 83 | }, 84 | createdBy: { 85 | type: DataTypes.INTEGER, 86 | allowNull: true, 87 | field: 'created_by', 88 | references: { 89 | model: 'users', 90 | key: 'id' 91 | } 92 | } 93 | }, { 94 | tableName: 'functional_bugs', 95 | timestamps: true, 96 | underscored: true, 97 | indexes: [ 98 | { fields: ['bug_id'], unique: true }, 99 | { fields: ['domain', 'difficulty'] }, 100 | { fields: ['is_active'] } 101 | ] 102 | }); 103 | 104 | module.exports = FunctionalBug; 105 | -------------------------------------------------------------------------------- /models/mysql/Question.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const Question = sequelize.define('Question', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | type: { 11 | type: DataTypes.ENUM('single-choice', 'multiple-choice', 'true-false'), 12 | allowNull: false, 13 | validate: { 14 | notEmpty: { 15 | msg: 'Question type is required' 16 | } 17 | } 18 | }, 19 | category: { 20 | type: DataTypes.STRING(100), 21 | allowNull: false, 22 | validate: { 23 | notEmpty: { 24 | msg: 'Category is required' 25 | } 26 | }, 27 | set(value) { 28 | this.setDataValue('category', value.toLowerCase().trim()); 29 | } 30 | }, 31 | difficulty: { 32 | type: DataTypes.ENUM('foundation', 'advanced', 'expert'), 33 | defaultValue: 'foundation' 34 | }, 35 | syllabus: { 36 | type: DataTypes.STRING(100), 37 | defaultValue: 'ISTQB-CTFL-2018' 38 | }, 39 | points: { 40 | type: DataTypes.INTEGER, 41 | defaultValue: 1, 42 | validate: { 43 | min: 1, 44 | max: 10 45 | } 46 | }, 47 | 48 | // Statistics 49 | timesAnswered: { 50 | type: DataTypes.INTEGER, 51 | defaultValue: 0, 52 | field: 'times_answered' 53 | }, 54 | timesCorrect: { 55 | type: DataTypes.INTEGER, 56 | defaultValue: 0, 57 | field: 'times_correct' 58 | }, 59 | averageTime: { 60 | type: DataTypes.DECIMAL(10, 2), 61 | defaultValue: 0, 62 | field: 'average_time' 63 | }, 64 | 65 | // Status 66 | status: { 67 | type: DataTypes.ENUM('draft', 'published', 'archived', 'flagged'), 68 | defaultValue: 'published' 69 | }, 70 | 71 | // Votes 72 | upvotes: { 73 | type: DataTypes.INTEGER, 74 | defaultValue: 0 75 | }, 76 | downvotes: { 77 | type: DataTypes.INTEGER, 78 | defaultValue: 0 79 | }, 80 | 81 | // Foreign key 82 | createdBy: { 83 | type: DataTypes.INTEGER, 84 | allowNull: false, 85 | field: 'created_by', 86 | references: { 87 | model: 'users', 88 | key: 'id' 89 | } 90 | } 91 | }, { 92 | tableName: 'questions', 93 | timestamps: true, 94 | underscored: true, 95 | indexes: [ 96 | { fields: ['category', 'difficulty'] }, 97 | { fields: ['status'] }, 98 | { fields: ['created_by'] } 99 | ] 100 | }); 101 | 102 | // Virtual field for success rate 103 | Question.prototype.getSuccessRate = function() { 104 | if (this.timesAnswered === 0) return 0; 105 | return Math.round((this.timesCorrect / this.timesAnswered) * 100); 106 | }; 107 | 108 | module.exports = Question; 109 | -------------------------------------------------------------------------------- /models/mysql/Quiz.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const { sequelize } = require('../../config/mysqlDatabase'); 3 | 4 | const Quiz = sequelize.define('Quiz', { 5 | id: { 6 | type: DataTypes.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true 9 | }, 10 | userId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | field: 'user_id', 14 | references: { 15 | model: 'users', 16 | key: 'id' 17 | } 18 | }, 19 | mode: { 20 | type: DataTypes.ENUM('practice', 'exam', 'timed', 'category'), 21 | allowNull: false 22 | }, 23 | 24 | // Settings 25 | language: { 26 | type: DataTypes.STRING(10), 27 | defaultValue: 'en' 28 | }, 29 | category: { 30 | type: DataTypes.STRING(100), 31 | allowNull: true 32 | }, 33 | difficulty: { 34 | type: DataTypes.STRING(50), 35 | allowNull: true 36 | }, 37 | numberOfQuestions: { 38 | type: DataTypes.INTEGER, 39 | allowNull: true, 40 | field: 'number_of_questions' 41 | }, 42 | timeLimit: { 43 | type: DataTypes.INTEGER, 44 | allowNull: true, 45 | field: 'time_limit' 46 | }, 47 | randomOrder: { 48 | type: DataTypes.BOOLEAN, 49 | defaultValue: true, 50 | field: 'random_order' 51 | }, 52 | 53 | // Score 54 | correctCount: { 55 | type: DataTypes.INTEGER, 56 | defaultValue: 0, 57 | field: 'correct_count' 58 | }, 59 | incorrectCount: { 60 | type: DataTypes.INTEGER, 61 | defaultValue: 0, 62 | field: 'incorrect_count' 63 | }, 64 | unansweredCount: { 65 | type: DataTypes.INTEGER, 66 | defaultValue: 0, 67 | field: 'unanswered_count' 68 | }, 69 | percentage: { 70 | type: DataTypes.DECIMAL(5, 2), 71 | defaultValue: 0 72 | }, 73 | totalPoints: { 74 | type: DataTypes.INTEGER, 75 | defaultValue: 0, 76 | field: 'total_points' 77 | }, 78 | 79 | // Status 80 | status: { 81 | type: DataTypes.ENUM('in-progress', 'completed', 'abandoned'), 82 | defaultValue: 'in-progress' 83 | }, 84 | 85 | startedAt: { 86 | type: DataTypes.DATE, 87 | defaultValue: DataTypes.NOW, 88 | field: 'started_at' 89 | }, 90 | completedAt: { 91 | type: DataTypes.DATE, 92 | allowNull: true, 93 | field: 'completed_at' 94 | }, 95 | totalTime: { 96 | type: DataTypes.INTEGER, 97 | defaultValue: 0, 98 | field: 'total_time' 99 | } 100 | }, { 101 | tableName: 'quizzes', 102 | timestamps: true, 103 | underscored: true, 104 | indexes: [ 105 | { fields: ['user_id', 'status'] }, 106 | { fields: ['created_at'] } 107 | ], 108 | hooks: { 109 | beforeUpdate: (quiz) => { 110 | if (quiz.status === 'completed' && quiz.changed('status')) { 111 | quiz.completedAt = new Date(); 112 | } 113 | } 114 | } 115 | }); 116 | 117 | module.exports = Quiz; 118 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const express = require('express'); 3 | const cors = require('cors'); 4 | const helmet = require('helmet'); 5 | const morgan = require('morgan'); 6 | const compression = require('compression'); 7 | const { testConnection } = require('./config/mysqlDatabase'); 8 | const errorHandler = require('./middleware/errorHandler'); 9 | 10 | const authRoutes = require('./routes/auth'); 11 | const questionRoutes = require('./routes/questions'); 12 | const quizRoutes = require('./routes/quiz'); 13 | const progressRoutes = require('./routes/progress'); 14 | const leaderboardRoutes = require('./routes/leaderboard'); 15 | const achievementRoutes = require('./routes/achievements'); 16 | const adminRoutes = require('./routes/admin'); 17 | const functionalBugRoutes = require('./routes/functionalBugs'); 18 | const questionUploadRoutes = require('./routes/questionUpload'); 19 | const arenaAuthRoutes = require('./routes/arenaAuth'); 20 | const analyticsRoutes = require('./routes/analytics'); 21 | 22 | const app = express(); 23 | 24 | // Only connect to DB if not in test mode (tests handle their own connection) 25 | if (process.env.NODE_ENV !== 'test') { 26 | testConnection(); 27 | } 28 | 29 | app.use(helmet()); 30 | app.use(cors({ 31 | origin: process.env.CORS_ORIGIN || 'http://localhost:5173', 32 | credentials: true 33 | })); 34 | app.use(compression()); 35 | app.use(morgan('dev')); 36 | app.use(express.json()); 37 | app.use(express.urlencoded({ extended: true })); 38 | 39 | // Support both with and without /qaarena prefix 40 | const basePath = process.env.BASE_PATH || ''; 41 | 42 | app.get(`${basePath}/api/health`, (req, res) => { 43 | res.status(200).json({ 44 | status: 'success', 45 | message: 'ISTQB Practice API is running', 46 | timestamp: new Date().toISOString() 47 | }); 48 | }); 49 | 50 | app.use(`${basePath}/api/auth`, authRoutes); 51 | app.use(`${basePath}/api/questions`, questionRoutes); 52 | app.use(`${basePath}/api/quiz`, quizRoutes); 53 | app.use(`${basePath}/api/progress`, progressRoutes); 54 | app.use(`${basePath}/api/leaderboard`, leaderboardRoutes); 55 | app.use(`${basePath}/api/achievements`, achievementRoutes); 56 | app.use(`${basePath}/api/admin`, adminRoutes); 57 | app.use(`${basePath}/api/functional-bugs`, functionalBugRoutes); 58 | app.use(`${basePath}/api/questions-upload`, questionUploadRoutes); 59 | app.use(`${basePath}/api/arena-auth`, arenaAuthRoutes); 60 | app.use(`${basePath}/api/analytics`, analyticsRoutes); 61 | 62 | app.use((req, res) => { 63 | res.status(404).json({ 64 | status: 'error', 65 | message: 'Route not found' 66 | }); 67 | }); 68 | 69 | app.use(errorHandler); 70 | 71 | const PORT = process.env.PORT || 5001; 72 | 73 | // Only start server if not in test mode 74 | if (process.env.NODE_ENV !== 'test') { 75 | const server = app.listen(PORT, () => { 76 | console.log(`🚀 Server running in ${process.env.NODE_ENV} mode on port ${PORT}`); 77 | }); 78 | 79 | // Handle port already in use error 80 | server.on('error', (err) => { 81 | if (err.code === 'EADDRINUSE') { 82 | console.error(`❌ Port ${PORT} is already in use. Please kill the existing process or use a different port.`); 83 | console.error(` Run: lsof -ti:${PORT} | xargs kill -9`); 84 | process.exit(1); 85 | } else { 86 | console.error('Server error:', err); 87 | process.exit(1); 88 | } 89 | }); 90 | 91 | process.on('unhandledRejection', (err) => { 92 | console.error('Unhandled Rejection:', err); 93 | server.close(() => process.exit(1)); 94 | }); 95 | } 96 | 97 | module.exports = app; -------------------------------------------------------------------------------- /client/src/components/ui/dialog.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { X } from "lucide-react" 4 | 5 | import { cn } from "../../lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( 16 | 24 | )) 25 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 26 | 27 | const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => ( 28 | 29 | 30 | 38 | {children} 39 | 40 | 41 | Close 42 | 43 | 44 | 45 | )) 46 | DialogContent.displayName = DialogPrimitive.Content.displayName 47 | 48 | const DialogHeader = ({ 49 | className, 50 | ...props 51 | }) => ( 52 |
59 | ) 60 | DialogHeader.displayName = "DialogHeader" 61 | 62 | const DialogFooter = ({ 63 | className, 64 | ...props 65 | }) => ( 66 |
73 | ) 74 | DialogFooter.displayName = "DialogFooter" 75 | 76 | const DialogTitle = React.forwardRef(({ className, ...props }, ref) => ( 77 | 85 | )) 86 | DialogTitle.displayName = DialogPrimitive.Title.displayName 87 | 88 | const DialogDescription = React.forwardRef(({ className, ...props }, ref) => ( 89 | 94 | )) 95 | DialogDescription.displayName = DialogPrimitive.Description.displayName 96 | 97 | export { 98 | Dialog, 99 | DialogPortal, 100 | DialogOverlay, 101 | DialogClose, 102 | DialogTrigger, 103 | DialogContent, 104 | DialogHeader, 105 | DialogFooter, 106 | DialogTitle, 107 | DialogDescription, 108 | } 109 | -------------------------------------------------------------------------------- /client_backup/src/components/ui/dialog.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { X } from "lucide-react" 4 | 5 | import { cn } from "../../lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( 16 | 24 | )) 25 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 26 | 27 | const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => ( 28 | 29 | 30 | 38 | {children} 39 | 40 | 41 | Close 42 | 43 | 44 | 45 | )) 46 | DialogContent.displayName = DialogPrimitive.Content.displayName 47 | 48 | const DialogHeader = ({ 49 | className, 50 | ...props 51 | }) => ( 52 |
59 | ) 60 | DialogHeader.displayName = "DialogHeader" 61 | 62 | const DialogFooter = ({ 63 | className, 64 | ...props 65 | }) => ( 66 |
73 | ) 74 | DialogFooter.displayName = "DialogFooter" 75 | 76 | const DialogTitle = React.forwardRef(({ className, ...props }, ref) => ( 77 | 85 | )) 86 | DialogTitle.displayName = DialogPrimitive.Title.displayName 87 | 88 | const DialogDescription = React.forwardRef(({ className, ...props }, ref) => ( 89 | 94 | )) 95 | DialogDescription.displayName = DialogPrimitive.Description.displayName 96 | 97 | export { 98 | Dialog, 99 | DialogPortal, 100 | DialogOverlay, 101 | DialogClose, 102 | DialogTrigger, 103 | DialogContent, 104 | DialogHeader, 105 | DialogFooter, 106 | DialogTitle, 107 | DialogDescription, 108 | } 109 | -------------------------------------------------------------------------------- /client/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client_backup/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/questions.test.js: -------------------------------------------------------------------------------- 1 | require('./setup'); 2 | const request = require('supertest'); 3 | const app = require('../server'); 4 | const User = require('../models/User'); 5 | const Question = require('../models/Question'); 6 | 7 | describe('Question Tests', () => { 8 | let adminToken; 9 | let adminUser; 10 | let userToken; 11 | let regularUser; 12 | 13 | beforeEach(async () => { 14 | 15 | regularUser = await User.create({ 16 | username: 'user', 17 | email: 'user@example.com', 18 | password: 'User123!', 19 | role: 'user' 20 | }); 21 | 22 | 23 | 24 | const userRes = await request(app) 25 | .post('/api/auth/login') 26 | .send({ email: 'user@example.com', password: 'User123!' }); 27 | userToken = userRes.body.data.token; 28 | }); 29 | 30 | describe('POST /api/questions', () => { 31 | it('should create question as admin', async () => { 32 | const res = await request(app) 33 | .post('/api/questions') 34 | .set('Authorization', `Bearer ${adminToken}`) 35 | .send({ 36 | questionText: { en: 'What is testing?' }, 37 | type: 'single-choice', 38 | options: [ 39 | { text: { en: 'Option 1' }, isCorrect: true }, 40 | { text: { en: 'Option 2' }, isCorrect: false } 41 | ], 42 | category: 'fundamentals', 43 | difficulty: 'foundation' 44 | }); 45 | 46 | expect(res.statusCode).toBe(201); 47 | expect(res.body.status).toBe('success'); 48 | expect(res.body.data.question).toHaveProperty('_id'); 49 | }); 50 | 51 | it('should not create question as regular user', async () => { 52 | const res = await request(app) 53 | .post('/api/questions') 54 | .set('Authorization', `Bearer ${userToken}`) 55 | .send({ 56 | questionText: { en: 'What is testing?' }, 57 | type: 'single-choice', 58 | options: [ 59 | { text: { en: 'Option 1' }, isCorrect: true }, 60 | { text: { en: 'Option 2' }, isCorrect: false } 61 | ], 62 | category: 'fundamentals' 63 | }); 64 | 65 | expect(res.statusCode).toBe(403); 66 | expect(res.body.status).toBe('error'); 67 | }); 68 | }); 69 | 70 | describe('GET /api/questions', () => { 71 | beforeEach(async () => { 72 | await Question.create({ 73 | questionText: new Map([['en', 'Test Question 1']]), 74 | type: 'single-choice', 75 | options: [ 76 | { text: new Map([['en', 'Option 1']]), isCorrect: true }, 77 | { text: new Map([['en', 'Option 2']]), isCorrect: false } 78 | ], 79 | category: 'fundamentals', 80 | difficulty: 'foundation', 81 | createdBy: adminUser._id, 82 | status: 'published' 83 | }); 84 | 85 | await Question.create({ 86 | questionText: new Map([['en', 'Test Question 2']]), 87 | type: 'true-false', 88 | options: [ 89 | { text: new Map([['en', 'True']]), isCorrect: true }, 90 | { text: new Map([['en', 'False']]), isCorrect: false } 91 | ], 92 | category: 'test-techniques', 93 | difficulty: 'advanced', 94 | createdBy: adminUser._id, 95 | status: 'published' 96 | }); 97 | }); 98 | 99 | it('should get all published questions', async () => { 100 | const res = await request(app).get('/api/questions'); 101 | 102 | expect(res.statusCode).toBe(200); 103 | expect(res.body.status).toBe('success'); 104 | expect(res.body.data.questions).toHaveLength(2); 105 | }); 106 | 107 | it('should filter questions by category', async () => { 108 | const res = await request(app).get('/api/questions?category=fundamentals'); 109 | 110 | expect(res.statusCode).toBe(200); 111 | expect(res.body.data.questions).toHaveLength(1); 112 | expect(res.body.data.questions[0].category).toBe('fundamentals'); 113 | }); 114 | 115 | it('should filter questions by difficulty', async () => { 116 | const res = await request(app).get('/api/questions?difficulty=advanced'); 117 | 118 | expect(res.statusCode).toBe(200); 119 | expect(res.body.data.questions).toHaveLength(1); 120 | expect(res.body.data.questions[0].difficulty).toBe('advanced'); 121 | }); 122 | }); 123 | }); -------------------------------------------------------------------------------- /models/mysql/User.js: -------------------------------------------------------------------------------- 1 | const { DataTypes } = require('sequelize'); 2 | const bcrypt = require('bcryptjs'); 3 | const { sequelize } = require('../../config/mysqlDatabase'); 4 | 5 | const User = sequelize.define('User', { 6 | id: { 7 | type: DataTypes.INTEGER, 8 | primaryKey: true, 9 | autoIncrement: true 10 | }, 11 | username: { 12 | type: DataTypes.STRING(30), 13 | allowNull: false, 14 | unique: true, 15 | validate: { 16 | len: { 17 | args: [3, 30], 18 | msg: 'Username must be between 3 and 30 characters' 19 | }, 20 | notEmpty: { 21 | msg: 'Username is required' 22 | } 23 | } 24 | }, 25 | email: { 26 | type: DataTypes.STRING(255), 27 | allowNull: false, 28 | unique: true, 29 | validate: { 30 | isEmail: { 31 | msg: 'Please provide a valid email' 32 | }, 33 | notEmpty: { 34 | msg: 'Email is required' 35 | } 36 | }, 37 | set(value) { 38 | this.setDataValue('email', value.toLowerCase().trim()); 39 | } 40 | }, 41 | password: { 42 | type: DataTypes.STRING(255), 43 | allowNull: false, 44 | validate: { 45 | len: { 46 | args: [6, 255], 47 | msg: 'Password must be at least 6 characters' 48 | }, 49 | notEmpty: { 50 | msg: 'Password is required' 51 | } 52 | } 53 | }, 54 | role: { 55 | type: DataTypes.ENUM('user', 'admin', 'moderator'), 56 | defaultValue: 'user' 57 | }, 58 | 59 | // Profile fields 60 | firstName: { 61 | type: DataTypes.STRING(100), 62 | allowNull: true, 63 | field: 'first_name' 64 | }, 65 | lastName: { 66 | type: DataTypes.STRING(100), 67 | allowNull: true, 68 | field: 'last_name' 69 | }, 70 | country: { 71 | type: DataTypes.STRING(100), 72 | allowNull: true 73 | }, 74 | preferredLanguage: { 75 | type: DataTypes.STRING(10), 76 | defaultValue: 'en', 77 | field: 'preferred_language' 78 | }, 79 | avatar: { 80 | type: DataTypes.STRING(255), 81 | allowNull: true 82 | }, 83 | 84 | // Stats fields 85 | totalQuizzes: { 86 | type: DataTypes.INTEGER, 87 | defaultValue: 0, 88 | field: 'total_quizzes' 89 | }, 90 | totalQuestions: { 91 | type: DataTypes.INTEGER, 92 | defaultValue: 0, 93 | field: 'total_questions' 94 | }, 95 | correctAnswers: { 96 | type: DataTypes.INTEGER, 97 | defaultValue: 0, 98 | field: 'correct_answers' 99 | }, 100 | totalScore: { 101 | type: DataTypes.INTEGER, 102 | defaultValue: 0, 103 | field: 'total_score' 104 | }, 105 | averageScore: { 106 | type: DataTypes.DECIMAL(5, 2), 107 | defaultValue: 0, 108 | field: 'average_score' 109 | }, 110 | streak: { 111 | type: DataTypes.INTEGER, 112 | defaultValue: 0 113 | }, 114 | lastActiveDate: { 115 | type: DataTypes.DATE, 116 | allowNull: true, 117 | field: 'last_active_date' 118 | }, 119 | 120 | // Status fields 121 | isActive: { 122 | type: DataTypes.BOOLEAN, 123 | defaultValue: true, 124 | field: 'is_active' 125 | }, 126 | isEmailVerified: { 127 | type: DataTypes.BOOLEAN, 128 | defaultValue: false, 129 | field: 'is_email_verified' 130 | }, 131 | lastLogin: { 132 | type: DataTypes.DATE, 133 | allowNull: true, 134 | field: 'last_login' 135 | } 136 | }, { 137 | tableName: 'users', 138 | timestamps: true, 139 | underscored: true, 140 | indexes: [ 141 | { fields: ['username'] }, 142 | { fields: ['email'] }, 143 | { fields: ['role'] } 144 | ], 145 | hooks: { 146 | beforeCreate: async (user) => { 147 | if (user.password) { 148 | const salt = await bcrypt.genSalt(10); 149 | user.password = await bcrypt.hash(user.password, salt); 150 | } 151 | }, 152 | beforeUpdate: async (user) => { 153 | if (user.changed('password')) { 154 | const salt = await bcrypt.genSalt(10); 155 | user.password = await bcrypt.hash(user.password, salt); 156 | } 157 | } 158 | } 159 | }); 160 | 161 | // Instance methods 162 | User.prototype.comparePassword = async function(candidatePassword) { 163 | return await bcrypt.compare(candidatePassword, this.password); 164 | }; 165 | 166 | User.prototype.updateAverageScore = function() { 167 | if (this.totalQuestions > 0) { 168 | this.averageScore = Math.round((this.correctAnswers / this.totalQuestions) * 100); 169 | } 170 | }; 171 | 172 | // Exclude password from JSON responses 173 | User.prototype.toJSON = function() { 174 | const values = Object.assign({}, this.get()); 175 | delete values.password; 176 | return values; 177 | }; 178 | 179 | module.exports = User; 180 | -------------------------------------------------------------------------------- /tests/auth.test.js: -------------------------------------------------------------------------------- 1 | require('./setup'); 2 | const request = require('supertest'); 3 | const app = require('../server'); 4 | const User = require('../models/User'); 5 | const Progress = require('../models/Progress'); 6 | 7 | describe('Authentication Tests', () => { 8 | describe('POST /api/auth/register', () => { 9 | it('should register a new user', async () => { 10 | const res = await request(app) 11 | .post('/api/auth/register') 12 | .send({ 13 | username: 'testuser', 14 | email: 'test@example.com', 15 | password: 'Test123!', 16 | firstName: 'Test', 17 | lastName: 'User' 18 | }); 19 | 20 | expect(res.statusCode).toBe(201); 21 | expect(res.body.status).toBe('success'); 22 | expect(res.body.data).toHaveProperty('token'); 23 | expect(res.body.data.user).toHaveProperty('username', 'testuser'); 24 | 25 | const user = await User.findOne({ email: 'test@example.com' }); 26 | expect(user).toBeTruthy(); 27 | 28 | const progress = await Progress.findOne({ user: user._id }); 29 | expect(progress).toBeTruthy(); 30 | }); 31 | 32 | it('should not register user with duplicate email', async () => { 33 | await User.create({ 34 | username: 'existing', 35 | email: 'test@example.com', 36 | password: 'Test123!' 37 | }); 38 | 39 | const res = await request(app) 40 | .post('/api/auth/register') 41 | .send({ 42 | username: 'newuser', 43 | email: 'test@example.com', 44 | password: 'Test123!' 45 | }); 46 | 47 | expect(res.statusCode).toBe(400); 48 | expect(res.body.status).toBe('error'); 49 | }); 50 | 51 | it('should validate required fields', async () => { 52 | const res = await request(app) 53 | .post('/api/auth/register') 54 | .send({ 55 | username: 'te', 56 | email: 'invalid-email', 57 | password: '123' 58 | }); 59 | 60 | expect(res.statusCode).toBe(400); 61 | expect(res.body.status).toBe('error'); 62 | }); 63 | }); 64 | 65 | describe('POST /api/auth/login', () => { 66 | beforeEach(async () => { 67 | await User.create({ 68 | username: 'testuser', 69 | email: 'test@example.com', 70 | password: 'Test123!' 71 | }); 72 | }); 73 | 74 | it('should login with valid credentials', async () => { 75 | const res = await request(app) 76 | .post('/api/auth/login') 77 | .send({ 78 | email: 'test@example.com', 79 | password: 'Test123!' 80 | }); 81 | 82 | expect(res.statusCode).toBe(200); 83 | expect(res.body.status).toBe('success'); 84 | expect(res.body.data).toHaveProperty('token'); 85 | expect(res.body.data.user).toHaveProperty('email', 'test@example.com'); 86 | }); 87 | 88 | it('should not login with invalid password', async () => { 89 | const res = await request(app) 90 | .post('/api/auth/login') 91 | .send({ 92 | email: 'test@example.com', 93 | password: 'WrongPassword' 94 | }); 95 | 96 | expect(res.statusCode).toBe(401); 97 | expect(res.body.status).toBe('error'); 98 | }); 99 | 100 | it('should not login with non-existent email', async () => { 101 | const res = await request(app) 102 | .post('/api/auth/login') 103 | .send({ 104 | email: 'nonexistent@example.com', 105 | password: 'Test123!' 106 | }); 107 | 108 | expect(res.statusCode).toBe(401); 109 | expect(res.body.status).toBe('error'); 110 | }); 111 | }); 112 | 113 | describe('GET /api/auth/me', () => { 114 | let token; 115 | let user; 116 | 117 | beforeEach(async () => { 118 | user = await User.create({ 119 | username: 'testuser', 120 | email: 'test@example.com', 121 | password: 'Test123!' 122 | }); 123 | 124 | const res = await request(app) 125 | .post('/api/auth/login') 126 | .send({ 127 | email: 'test@example.com', 128 | password: 'Test123!' 129 | }); 130 | 131 | token = res.body.data.token; 132 | }); 133 | 134 | it('should get current user with valid token', async () => { 135 | const res = await request(app) 136 | .get('/api/auth/me') 137 | .set('Authorization', `Bearer ${token}`); 138 | 139 | expect(res.statusCode).toBe(200); 140 | expect(res.body.status).toBe('success'); 141 | expect(res.body.data.user).toHaveProperty('email', 'test@example.com'); 142 | }); 143 | 144 | it('should not get user without token', async () => { 145 | const res = await request(app).get('/api/auth/me'); 146 | 147 | expect(res.statusCode).toBe(401); 148 | expect(res.body.status).toBe('error'); 149 | }); 150 | }); 151 | }); -------------------------------------------------------------------------------- /controllers/achievementController.js: -------------------------------------------------------------------------------- 1 | // MySQL Models 2 | const { Achievement, AchievementTranslation, UserAchievement, User } = require('../models/mysql'); 3 | const { Op } = require('sequelize'); 4 | 5 | exports.getAllAchievements = async (req, res, next) => { 6 | try { 7 | const achievements = await Achievement.findAll({ 8 | where: { isActive: true }, 9 | include: [{ model: AchievementTranslation, as: 'translations' }] 10 | }); 11 | 12 | const userAchievements = await UserAchievement.findAll({ 13 | where: { userId: req.user.id }, 14 | attributes: ['achievementId'] 15 | }); 16 | const unlockedIds = userAchievements.map(ua => ua.achievementId); 17 | 18 | const achievementsWithStatus = achievements.map(achievement => ({ 19 | id: achievement.id, 20 | name: achievement.translations?.find(t => t.language === 'en')?.name || '', 21 | description: achievement.translations?.find(t => t.language === 'en')?.description || '', 22 | icon: achievement.icon, 23 | type: achievement.type, 24 | criteriaMetric: achievement.criteriaMetric, 25 | criteriaThreshold: achievement.criteriaThreshold, 26 | rarity: achievement.rarity, 27 | points: achievement.points, 28 | unlocked: unlockedIds.includes(achievement.id) 29 | })); 30 | 31 | res.status(200).json({ 32 | status: 'success', 33 | data: { achievements: achievementsWithStatus } 34 | }); 35 | } catch (error) { 36 | next(error); 37 | } 38 | }; 39 | 40 | exports.getUserAchievements = async (req, res, next) => { 41 | try { 42 | const userAchievements = await UserAchievement.findAll({ 43 | where: { userId: req.user.id }, 44 | include: [{ 45 | model: Achievement, 46 | as: 'achievement', 47 | include: [{ model: AchievementTranslation, as: 'translations' }] 48 | }] 49 | }); 50 | 51 | const achievements = userAchievements.map(ua => ({ 52 | id: ua.achievement.id, 53 | name: ua.achievement.translations?.find(t => t.language === 'en')?.name || '', 54 | description: ua.achievement.translations?.find(t => t.language === 'en')?.description || '', 55 | icon: ua.achievement.icon, 56 | type: ua.achievement.type, 57 | rarity: ua.achievement.rarity, 58 | points: ua.achievement.points, 59 | unlockedAt: ua.unlockedAt 60 | })); 61 | 62 | res.status(200).json({ 63 | status: 'success', 64 | data: { achievements } 65 | }); 66 | } catch (error) { 67 | next(error); 68 | } 69 | }; 70 | 71 | exports.checkAchievements = async (req, res, next) => { 72 | try { 73 | const user = await User.findByPk(req.user.id); 74 | const achievements = await Achievement.findAll({ 75 | where: { isActive: true }, 76 | include: [{ model: AchievementTranslation, as: 'translations' }] 77 | }); 78 | 79 | const userAchievements = await UserAchievement.findAll({ 80 | where: { userId: req.user.id }, 81 | attributes: ['achievementId'] 82 | }); 83 | const unlockedIds = userAchievements.map(ua => ua.achievementId); 84 | 85 | const newAchievements = []; 86 | 87 | for (const achievement of achievements) { 88 | const alreadyUnlocked = unlockedIds.includes(achievement.id); 89 | 90 | if (!alreadyUnlocked) { 91 | let unlocked = false; 92 | 93 | switch (achievement.criteriaMetric) { 94 | case 'totalQuizzes': 95 | unlocked = user.totalQuizzes >= achievement.criteriaThreshold; 96 | break; 97 | case 'totalScore': 98 | unlocked = user.totalScore >= achievement.criteriaThreshold; 99 | break; 100 | case 'averageScore': 101 | unlocked = parseFloat(user.averageScore) >= achievement.criteriaThreshold; 102 | break; 103 | case 'streak': 104 | unlocked = user.streak >= achievement.criteriaThreshold; 105 | break; 106 | case 'correctAnswers': 107 | unlocked = user.correctAnswers >= achievement.criteriaThreshold; 108 | break; 109 | } 110 | 111 | if (unlocked) { 112 | await UserAchievement.create({ 113 | userId: user.id, 114 | achievementId: achievement.id, 115 | unlockedAt: new Date() 116 | }); 117 | newAchievements.push({ 118 | id: achievement.id, 119 | name: achievement.translations?.find(t => t.language === 'en')?.name || '', 120 | description: achievement.translations?.find(t => t.language === 'en')?.description || '', 121 | icon: achievement.icon, 122 | points: achievement.points 123 | }); 124 | } 125 | } 126 | } 127 | 128 | res.status(200).json({ 129 | status: 'success', 130 | data: { newAchievements } 131 | }); 132 | } catch (error) { 133 | next(error); 134 | } 135 | }; -------------------------------------------------------------------------------- /tests/quiz.test.js: -------------------------------------------------------------------------------- 1 | require('./setup'); 2 | const request = require('supertest'); 3 | const app = require('../server'); 4 | const User = require('../models/User'); 5 | const Question = require('../models/Question'); 6 | const Quiz = require('../models/Quiz'); 7 | const Progress = require('../models/Progress'); 8 | 9 | describe('Quiz Tests', () => { 10 | let token; 11 | let user; 12 | let question; 13 | 14 | beforeEach(async () => { 15 | user = await User.create({ 16 | username: 'testuser', 17 | email: 'test@example.com', 18 | password: 'Test123!' 19 | }); 20 | 21 | await Progress.create({ user: user._id }); 22 | 23 | const res = await request(app) 24 | .post('/api/auth/login') 25 | .send({ email: 'test@example.com', password: 'Test123!' }); 26 | token = res.body.data.token; 27 | 28 | question = await Question.create({ 29 | questionText: new Map([['en', 'Test Question']]), 30 | type: 'single-choice', 31 | options: [ 32 | { text: new Map([['en', 'Correct Answer']]), isCorrect: true }, 33 | { text: new Map([['en', 'Wrong Answer']]), isCorrect: false } 34 | ], 35 | category: 'fundamentals', 36 | difficulty: 'foundation', 37 | createdBy: user._id, 38 | status: 'published' 39 | }); 40 | }); 41 | 42 | describe('POST /api/quiz/start', () => { 43 | it('should start a new quiz', async () => { 44 | const res = await request(app) 45 | .post('/api/quiz/start') 46 | .set('Authorization', `Bearer ${token}`) 47 | .send({ 48 | mode: 'practice', 49 | category: 'fundamentals', 50 | numberOfQuestions: 1 51 | }); 52 | 53 | expect(res.statusCode).toBe(201); 54 | expect(res.body.status).toBe('success'); 55 | expect(res.body.data.quiz).toHaveProperty('_id'); 56 | expect(res.body.data.quiz.questions).toHaveLength(1); 57 | expect(res.body.data.quiz.status).toBe('in-progress'); 58 | }); 59 | 60 | it('should return error when no questions match criteria', async () => { 61 | const res = await request(app) 62 | .post('/api/quiz/start') 63 | .set('Authorization', `Bearer ${token}`) 64 | .send({ 65 | mode: 'practice', 66 | category: 'non-existent-category', 67 | numberOfQuestions: 10 68 | }); 69 | 70 | expect(res.statusCode).toBe(404); 71 | expect(res.body.status).toBe('error'); 72 | }); 73 | }); 74 | 75 | describe('POST /api/quiz/answer', () => { 76 | let quiz; 77 | 78 | beforeEach(async () => { 79 | quiz = await Quiz.create({ 80 | user: user._id, 81 | mode: 'practice', 82 | questions: [{ question: question._id }], 83 | settings: { language: 'en' } 84 | }); 85 | }); 86 | 87 | it('should answer a question correctly', async () => { 88 | const res = await request(app) 89 | .post('/api/quiz/answer') 90 | .set('Authorization', `Bearer ${token}`) 91 | .send({ 92 | quizId: quiz._id, 93 | questionId: question._id, 94 | answer: 0, 95 | timeSpent: 30 96 | }); 97 | 98 | expect(res.statusCode).toBe(200); 99 | expect(res.body.status).toBe('success'); 100 | expect(res.body.data.isCorrect).toBe(true); 101 | }); 102 | 103 | it('should answer a question incorrectly', async () => { 104 | const res = await request(app) 105 | .post('/api/quiz/answer') 106 | .set('Authorization', `Bearer ${token}`) 107 | .send({ 108 | quizId: quiz._id, 109 | questionId: question._id, 110 | answer: 1, 111 | timeSpent: 25 112 | }); 113 | 114 | expect(res.statusCode).toBe(200); 115 | expect(res.body.status).toBe('success'); 116 | expect(res.body.data.isCorrect).toBe(false); 117 | }); 118 | }); 119 | 120 | describe('POST /api/quiz/:id/complete', () => { 121 | let quiz; 122 | 123 | beforeEach(async () => { 124 | quiz = await Quiz.create({ 125 | user: user._id, 126 | mode: 'practice', 127 | questions: [ 128 | { 129 | question: question._id, 130 | userAnswer: [0], 131 | isCorrect: true, 132 | timeSpent: 30 133 | } 134 | ], 135 | settings: { language: 'en', category: 'fundamentals' } 136 | }); 137 | }); 138 | 139 | it('should complete a quiz and update stats', async () => { 140 | const res = await request(app) 141 | .post(`/api/quiz/${quiz._id}/complete`) 142 | .set('Authorization', `Bearer ${token}`); 143 | 144 | expect(res.statusCode).toBe(200); 145 | expect(res.body.status).toBe('success'); 146 | expect(res.body.data.quiz.status).toBe('completed'); 147 | expect(res.body.data.quiz.score.correct).toBe(1); 148 | 149 | const updatedUser = await User.findById(user._id); 150 | expect(updatedUser.stats.totalQuizzes).toBe(1); 151 | expect(updatedUser.stats.correctAnswers).toBe(1); 152 | }); 153 | }); 154 | }); -------------------------------------------------------------------------------- /client_backup/src/services/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_URL = import.meta.env.VITE_API_URL || import.meta.env.VITE_API_BASE || 'http://localhost:5001/api'; 4 | 5 | const api = axios.create({ 6 | baseURL: API_URL, 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | }, 10 | }); 11 | 12 | // Request interceptor to add token 13 | api.interceptors.request.use( 14 | (config) => { 15 | const token = localStorage.getItem('token'); 16 | if (token) { 17 | config.headers.Authorization = `Bearer ${token}`; 18 | } 19 | return config; 20 | }, 21 | (error) => Promise.reject(error) 22 | ); 23 | 24 | // Response interceptor for error handling 25 | api.interceptors.response.use( 26 | (response) => response, 27 | (error) => { 28 | if (error.response?.status === 401) { 29 | // Only redirect to login if we're on a protected route 30 | const publicRoutes = ['/', '/login', '/register', '/bug-hunting', '/functional-bug-hunting', '/events']; 31 | const currentPath = window.location.pathname; 32 | const isPublicRoute = publicRoutes.some(route => currentPath.startsWith(route)); 33 | 34 | if (!isPublicRoute) { 35 | localStorage.removeItem('token'); 36 | localStorage.removeItem('user'); 37 | window.location.href = '/login'; 38 | } 39 | } 40 | return Promise.reject(error); 41 | } 42 | ); 43 | 44 | // Auth API 45 | export const authAPI = { 46 | register: (data) => api.post('/auth/register', data), 47 | login: (data) => api.post('/auth/login', data), 48 | getMe: () => api.get('/auth/me'), 49 | updateProfile: (data) => api.put('/auth/profile', data), 50 | changePassword: (data) => api.put('/auth/change-password', data), 51 | }; 52 | 53 | // Questions API 54 | export const questionsAPI = { 55 | getAll: (params) => api.get('/questions', { params }), 56 | getById: (id) => api.get(`/questions/${id}`), 57 | create: (data) => api.post('/questions', data), 58 | update: (id, data) => api.put(`/questions/${id}`, data), 59 | delete: (id) => api.delete(`/questions/${id}`), 60 | vote: (id, voteType) => api.post(`/questions/${id}/vote`, { voteType }), 61 | flag: (id, reason) => api.post(`/questions/${id}/flag`, { reason }), 62 | }; 63 | 64 | // Quiz API 65 | export const quizAPI = { 66 | start: (data) => api.post('/quiz/start', data), 67 | answer: (data) => api.post('/quiz/answer', data), 68 | complete: (id) => api.post(`/quiz/${id}/complete`), 69 | getById: (id) => api.get(`/quiz/${id}`), 70 | getUserQuizzes: (params) => api.get('/quiz/user/history', { params }), 71 | getInProgress: () => api.get('/quiz/in-progress'), 72 | }; 73 | 74 | // Progress API 75 | export const progressAPI = { 76 | get: () => api.get('/progress'), 77 | getCategories: () => api.get('/progress/categories'), 78 | getWeakAreas: () => api.get('/progress/weak-areas'), 79 | getStreak: () => api.get('/progress/streak'), 80 | getActivity: () => api.get('/progress/activity'), 81 | }; 82 | 83 | // Leaderboard API 84 | export const leaderboardAPI = { 85 | getGlobal: (params) => api.get('/leaderboard/global', { params }), 86 | getCategory: (category, params) => api.get(`/leaderboard/category/${category}`, { params }), 87 | getUserRank: () => api.get('/leaderboard/rank'), 88 | }; 89 | 90 | // Achievements API 91 | export const achievementsAPI = { 92 | getAll: () => api.get('/achievements'), 93 | getUserAchievements: () => api.get('/achievements/user'), 94 | check: () => api.post('/achievements/check'), 95 | }; 96 | 97 | // Admin API 98 | export const adminAPI = { 99 | getStats: () => api.get('/admin/stats'), 100 | getUsers: (params) => api.get('/admin/users', { params }), 101 | updateUserRole: (id, role) => api.put(`/admin/users/${id}/role`, { role }), 102 | deactivateUser: (id) => api.put(`/admin/users/${id}/deactivate`), 103 | getFlaggedQuestions: () => api.get('/admin/questions/flagged'), 104 | reviewQuestion: (id, status) => api.put(`/admin/questions/${id}/review`, { status }), 105 | createAchievement: (data) => api.post('/admin/achievements', data), 106 | updateAchievement: (id, data) => api.put(`/admin/achievements/${id}`, data), 107 | deleteAchievement: (id) => api.delete(`/admin/achievements/${id}`), 108 | }; 109 | 110 | // Functional Bugs API 111 | export const functionalBugsAPI = { 112 | getAll: (params) => api.get('/functional-bugs', { params }), 113 | getById: (bugId) => api.get(`/functional-bugs/${bugId}`), 114 | start: (bugId) => api.post(`/functional-bugs/${bugId}/start`), 115 | getHint: (bugId) => api.post(`/functional-bugs/${bugId}/hint`), 116 | submit: (bugId, data) => api.post(`/functional-bugs/${bugId}/submit`, data), 117 | getUserProgress: (params) => api.get('/functional-bugs/user/progress', { params }), 118 | getLeaderboard: (params) => api.get('/functional-bugs/leaderboard', { params }), 119 | getStats: (bugId) => api.get(`/functional-bugs/${bugId}/stats`), 120 | create: (data) => api.post('/functional-bugs', data), 121 | update: (bugId, data) => api.put(`/functional-bugs/${bugId}`, data), 122 | delete: (bugId) => api.delete(`/functional-bugs/${bugId}`), 123 | }; 124 | 125 | export default api; -------------------------------------------------------------------------------- /client/src/components/FunctionalBugs/GuestLoginModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { X, Trophy, User, LogIn, UserPlus } from 'lucide-react'; 4 | 5 | const GuestLoginModal = ({ isOpen, onClose, onContinueAsGuest }) => { 6 | const navigate = useNavigate(); 7 | 8 | if (!isOpen) return null; 9 | 10 | const handleLogin = () => { 11 | navigate('/login'); 12 | }; 13 | 14 | const handleRegister = () => { 15 | navigate('/register'); 16 | }; 17 | 18 | return ( 19 |
20 |
21 | {/* Header */} 22 |
23 | 29 |
30 | 31 |

Earn Points & Track Progress!

32 |
33 |

34 | Create an account to save your progress and compete on the leaderboard 35 |

36 |
37 | 38 | {/* Content */} 39 |
40 | {/* Benefits Section */} 41 |
42 |

43 | 44 | With an Account: 45 |

46 |
    47 |
  • 48 | 49 | Earn points for every bug you find 50 |
  • 51 |
  • 52 | 53 | Track your progress across all bugs 54 |
  • 55 |
  • 56 | 57 | Compete on the leaderboard 58 |
  • 59 |
  • 60 | 61 | Save your achievements 62 |
  • 63 |
  • 64 | 65 | Access your stats anytime 66 |
  • 67 |
68 |
69 | 70 | {/* Action Buttons */} 71 |
72 | 79 | 80 | 87 | 88 |
89 |
90 |
91 |
92 |
93 | or 94 |
95 |
96 | 97 | 104 |
105 | 106 | {/* Guest Info */} 107 |
108 |

109 | Note: Guest progress is saved only for this session and won't earn points or appear on the leaderboard. 110 |

111 |
112 |
113 |
114 |
115 | ); 116 | }; 117 | 118 | export default GuestLoginModal; 119 | -------------------------------------------------------------------------------- /client_backup/src/components/FunctionalBugs/GuestLoginModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { X, Trophy, User, LogIn, UserPlus } from 'lucide-react'; 4 | 5 | const GuestLoginModal = ({ isOpen, onClose, onContinueAsGuest }) => { 6 | const navigate = useNavigate(); 7 | 8 | if (!isOpen) return null; 9 | 10 | const handleLogin = () => { 11 | navigate('/login'); 12 | }; 13 | 14 | const handleRegister = () => { 15 | navigate('/register'); 16 | }; 17 | 18 | return ( 19 |
20 |
21 | {/* Header */} 22 |
23 | 29 |
30 | 31 |

Earn Points & Track Progress!

32 |
33 |

34 | Create an account to save your progress and compete on the leaderboard 35 |

36 |
37 | 38 | {/* Content */} 39 |
40 | {/* Benefits Section */} 41 |
42 |

43 | 44 | With an Account: 45 |

46 |
    47 |
  • 48 | 49 | Earn points for every bug you find 50 |
  • 51 |
  • 52 | 53 | Track your progress across all bugs 54 |
  • 55 |
  • 56 | 57 | Compete on the leaderboard 58 |
  • 59 |
  • 60 | 61 | Save your achievements 62 |
  • 63 |
  • 64 | 65 | Access your stats anytime 66 |
  • 67 |
68 |
69 | 70 | {/* Action Buttons */} 71 |
72 | 79 | 80 | 87 | 88 |
89 |
90 |
91 |
92 |
93 | or 94 |
95 |
96 | 97 | 104 |
105 | 106 | {/* Guest Info */} 107 |
108 |

109 | Note: Guest progress is saved only for this session and won't earn points or appear on the leaderboard. 110 |

111 |
112 |
113 |
114 |
115 | ); 116 | }; 117 | 118 | export default GuestLoginModal; 119 | -------------------------------------------------------------------------------- /client/src/pages/Register.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useNavigate, Link } from 'react-router-dom'; 3 | import { useAuth } from '../context/AuthContext'; 4 | import { Button } from '../components/ui/button'; 5 | import { Input } from '../components/ui/input'; 6 | import { PasswordInput } from '../components/ui/password-input'; 7 | import { Label } from '../components/ui/label'; 8 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; 9 | import { BookOpen } from 'lucide-react'; 10 | 11 | export default function Register() { 12 | const [formData, setFormData] = useState({ 13 | username: '', 14 | email: '', 15 | password: '', 16 | confirmPassword: '', 17 | }); 18 | const [error, setError] = useState(''); 19 | const [loading, setLoading] = useState(false); 20 | const { register } = useAuth(); 21 | const navigate = useNavigate(); 22 | 23 | const handleChange = (e) => { 24 | setFormData({ ...formData, [e.target.name]: e.target.value }); 25 | }; 26 | 27 | const handleSubmit = async (e) => { 28 | e.preventDefault(); 29 | setError(''); 30 | 31 | if (formData.password !== formData.confirmPassword) { 32 | setError('Passwords do not match'); 33 | return; 34 | } 35 | 36 | if (formData.password.length < 6) { 37 | setError('Password must be at least 6 characters'); 38 | return; 39 | } 40 | 41 | setLoading(true); 42 | 43 | try { 44 | await register({ 45 | username: formData.username, 46 | email: formData.email, 47 | password: formData.password, 48 | }); 49 | navigate('/dashboard'); 50 | } catch (err) { 51 | setError(err.response?.data?.message || 'Registration failed. Please try again.'); 52 | } finally { 53 | setLoading(false); 54 | } 55 | }; 56 | 57 | return ( 58 |
59 | 60 | 61 |
62 |
63 | 64 |
65 |
66 | Create Account 67 | Sign up to start practicing ISTQB questions 68 |
69 | 70 |
71 | {error && ( 72 |
73 | {error} 74 |
75 | )} 76 |
77 | 78 | 87 |
88 |
89 | 90 | 100 |
101 |
102 | 103 | 112 |
113 |
114 | 115 | 124 |
125 | 128 |
129 |
130 | Already have an account? 131 | 132 | Sign in 133 | 134 |
135 |
136 |
137 |
138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /client_backup/src/pages/Register.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useNavigate, Link } from 'react-router-dom'; 3 | import { useAuth } from '../context/AuthContext'; 4 | import { Button } from '../components/ui/button'; 5 | import { Input } from '../components/ui/input'; 6 | import { Label } from '../components/ui/label'; 7 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; 8 | import { BookOpen } from 'lucide-react'; 9 | 10 | export default function Register() { 11 | const [formData, setFormData] = useState({ 12 | username: '', 13 | email: '', 14 | password: '', 15 | confirmPassword: '', 16 | }); 17 | const [error, setError] = useState(''); 18 | const [loading, setLoading] = useState(false); 19 | const { register } = useAuth(); 20 | const navigate = useNavigate(); 21 | 22 | const handleChange = (e) => { 23 | setFormData({ ...formData, [e.target.name]: e.target.value }); 24 | }; 25 | 26 | const handleSubmit = async (e) => { 27 | e.preventDefault(); 28 | setError(''); 29 | 30 | if (formData.password !== formData.confirmPassword) { 31 | setError('Passwords do not match'); 32 | return; 33 | } 34 | 35 | if (formData.password.length < 6) { 36 | setError('Password must be at least 6 characters'); 37 | return; 38 | } 39 | 40 | setLoading(true); 41 | 42 | try { 43 | await register({ 44 | username: formData.username, 45 | email: formData.email, 46 | password: formData.password, 47 | }); 48 | navigate('/dashboard'); 49 | } catch (err) { 50 | setError(err.response?.data?.message || 'Registration failed. Please try again.'); 51 | } finally { 52 | setLoading(false); 53 | } 54 | }; 55 | 56 | return ( 57 |
58 | 59 | 60 |
61 |
62 | 63 |
64 |
65 | Create Account 66 | Sign up to start practicing ISTQB questions 67 |
68 | 69 |
70 | {error && ( 71 |
72 | {error} 73 |
74 | )} 75 |
76 | 77 | 86 |
87 |
88 | 89 | 99 |
100 |
101 | 102 | 112 |
113 |
114 | 115 | 125 |
126 | 129 |
130 |
131 | Already have an account? 132 | 133 | Sign in 134 | 135 |
136 |
137 |
138 |
139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /client_backup/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; 2 | import { lazy, Suspense } from 'react'; 3 | import { AuthProvider, useAuth } from './context/AuthContext'; 4 | import ProtectedRoute from './components/ProtectedRoute'; 5 | import Layout from './components/Layout'; 6 | import Landing from './pages/Landing'; 7 | import Login from './pages/Login'; 8 | import Register from './pages/Register'; 9 | import Dashboard from './pages/Dashboard'; 10 | import Questions from './pages/Questions'; 11 | import Quiz from './pages/Quiz'; 12 | import Progress from './pages/Progress'; 13 | import Leaderboard from './pages/Leaderboard'; 14 | import Achievements from './pages/Achievements'; 15 | import Events from './pages/Events'; 16 | import BugHuntingHub from './pages/BugHuntingHub'; 17 | import BugHunting from './pages/BugHunting'; 18 | import BugScenario from './pages/BugScenario'; 19 | import FunctionalBugHunting from './pages/FunctionalBugHunting'; 20 | import FunctionalBugScenario from './pages/FunctionalBugScenario'; 21 | import QuestionUpload from './pages/QuestionUpload'; 22 | 23 | // Test Automation Arena imports 24 | import ArenaLanding from './pages/automation-arena/ArenaLanding'; 25 | import ArenaSignUp from './pages/automation-arena/ArenaSignUp'; 26 | import ArenaSignIn from './pages/automation-arena/ArenaSignIn'; 27 | import ArenaDashboard from './pages/automation-arena/ArenaDashboard'; 28 | import EcommerceSimulator from './pages/automation-arena/simulators/EcommerceSimulator'; 29 | import SchoolManagementSimulator from './pages/automation-arena/simulators/SchoolManagementSimulator'; 30 | import ATMSimulator from './pages/automation-arena/simulators/ATMSimulator'; 31 | import FundsTransferSimulator from './pages/automation-arena/simulators/FundsTransferSimulator'; 32 | import AuthSimulator from './pages/automation-arena/simulators/AuthSimulator'; 33 | 34 | // Lazy load Admin page 35 | const Admin = lazy(() => import('./pages/Admin')); 36 | 37 | // Admin route protection 38 | function AdminRoute({ children }) { 39 | const { user } = useAuth(); 40 | 41 | if (!user) { 42 | return ; 43 | } 44 | 45 | if (user.role !== 'admin') { 46 | return ; 47 | } 48 | 49 | return children; 50 | } 51 | 52 | function App() { 53 | return ( 54 | 55 | 56 | 57 | } /> 58 | } /> 59 | } /> 60 | } /> 61 | 62 | {/* Test Automation Arena - Public Routes */} 63 | } /> 64 | } /> 65 | } /> 66 | } /> 67 | } /> 68 | } /> 69 | } /> 70 | } /> 71 | } /> 72 | 73 | {/* Public Bug Hunting Routes with Navbar */} 74 | }> 75 | } /> 76 | } /> 77 | } /> 78 | } /> 79 | } /> 80 | 81 | 82 | {/* Protected Routes */} 83 | 86 | 87 | 88 | } 89 | > 90 | } /> 91 | } /> 92 | } /> 93 | } /> 94 | } /> 95 | } /> 96 | 100 | 102 |
103 |
104 | }> 105 | 106 | 107 | 108 | } 109 | /> 110 | 114 | 115 | 116 | } 117 | /> 118 | 119 | 120 | 121 | 122 | ); 123 | } 124 | 125 | export default App; 126 | -------------------------------------------------------------------------------- /client_backup/src/pages/Login.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useNavigate, Link } from 'react-router-dom'; 3 | import { useAuth } from '../context/AuthContext'; 4 | import { Button } from '../components/ui/button'; 5 | import { Input } from '../components/ui/input'; 6 | import { Label } from '../components/ui/label'; 7 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; 8 | import { BookOpen, AlertCircle, Home, ArrowLeft } from 'lucide-react'; 9 | 10 | export default function Login() { 11 | const [email, setEmail] = useState(''); 12 | const [password, setPassword] = useState(''); 13 | const [error, setError] = useState(''); 14 | const [loading, setLoading] = useState(false); 15 | const { login } = useAuth(); 16 | const navigate = useNavigate(); 17 | 18 | const handleSubmit = async (e) => { 19 | e.preventDefault(); 20 | setError(''); 21 | setLoading(true); 22 | 23 | try { 24 | await login(email, password); 25 | navigate('/dashboard'); 26 | } catch (err) { 27 | setError(err.response?.data?.message || 'Login failed. Please try again.'); 28 | } finally { 29 | setLoading(false); 30 | } 31 | }; 32 | 33 | return ( 34 |
35 | {/* Navigation Bar */} 36 | 59 | 60 | {/* Login Form */} 61 |
62 | 63 | 64 | {/* Back to Home Button */} 65 |
66 | 67 | 71 | 72 |
73 |
74 |
75 | 76 |
77 |
78 | Welcome Back 79 | Sign in to your QA ARENA account 80 |
81 | 82 |
83 | {error && ( 84 |
85 | 86 | {error} 87 |
88 | )} 89 |
90 | 91 | setEmail(e.target.value)} 97 | required 98 | data-cy="login-email-input" 99 | /> 100 |
101 |
102 | 103 | setPassword(e.target.value)} 109 | required 110 | data-cy="login-password-input" 111 | /> 112 |
113 | 116 |
117 |
118 | Don't have an account? 119 | 120 | Sign up 121 | 122 |
123 | {/*
124 |

Demo Credentials:

125 |

User: test@example.com / Test123!

126 |
*/} 127 |
128 |
129 |
130 |
131 | ); 132 | } 133 | --------------------------------------------------------------------------------