├── .gitignore
├── .prettierrc
├── babel.config.js
├── .husky
└── pre-commit
├── src
├── constants
│ └── constants.js
├── loggers
│ ├── morgan.logger.js
│ └── winston.logger.js
├── utils
│ ├── asyncHandler.js
│ ├── sendEmail.js
│ ├── appError.js
│ ├── timeUtils.js
│ └── currencyUtils.js
├── config
│ ├── db.js
│ ├── email.config.js
│ ├── config.js
│ ├── passport.js
│ └── redis.js
├── validators
│ ├── common.validator.js
│ ├── user.validator.js
│ ├── cart.validator.js
│ ├── auth.validator.js
│ ├── product.validator.js
│ └── payment.validator.js
├── routes
│ ├── user.routes.js
│ ├── cart.routes.js
│ ├── product.routes.js
│ ├── auth.routes.js
│ └── payment.routes.js
├── controllers
│ ├── user.controller.js
│ ├── cart.controller.js
│ ├── product.controller.js
│ ├── payment.controller.js
│ └── auth.controller.js
├── models
│ ├── product.model.js
│ ├── cart.model.js
│ ├── user.model.js
│ └── payment.model.js
├── app.js
├── middlewares
│ ├── errorHandler.js
│ ├── auth.middleware.js
│ ├── validator.middleware.js
│ └── rateLimiter.middleware.js
├── __tests__
│ ├── currencyUtils.test.js
│ ├── user.test.js
│ ├── auth.test.js
│ └── product.test.js
├── dao
│ ├── user.dao.js
│ ├── product.dao.js
│ ├── cart.dao.js
│ └── payment.dao.js
└── services
│ ├── product.service.js
│ ├── cart.service.js
│ └── user.service.js
├── .vscode
└── settings.json
├── jest.config.js
├── .env.example
├── eslint.config.js
├── Dockerfile
├── .dockerignore
├── .github
└── workflows
│ └── deploy.yml
├── readme.md
├── package.json
├── server.js
└── jest.setup.js
/.gitignore:
--------------------------------------------------------------------------------
1 | /.env
2 | /node_modules
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "trailingComma": "es5"
5 | }
6 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
3 | };
4 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npm run lint
5 | npm run format
6 | npm run test
--------------------------------------------------------------------------------
/src/constants/constants.js:
--------------------------------------------------------------------------------
1 | export const ACCESS_TOKEN_EXPIRATION = '1h';
2 | export const REFRESH_TOKEN_EXPIRATION = '30d';
3 | export const FORGOT_PASSWORD_TOKEN_EXPIRATION = '15m';
4 | export const VERIFICATION_TOKEN_EXPIRATION = '10m';
5 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.defaultFormatter": "esbenp.prettier-vscode",
4 | "workbench.colorTheme": "Night Owl Black (No Italics)",
5 | "editor.codeActionsOnSave": {
6 | "source.fixAll": "always",
7 | "source.fixAll.eslint": "always"
8 | },
9 | "eslint.validate": ["javascript"]
10 | }
11 |
--------------------------------------------------------------------------------
/src/loggers/morgan.logger.js:
--------------------------------------------------------------------------------
1 | import morgan from 'morgan';
2 | import logger from './winston.logger.js';
3 |
4 | // Create a custom token format for the log
5 | const format =
6 | ':remote-addr :method :url :status :res[content-length] - :response-time ms';
7 |
8 | // Create Morgan middleware using Winston for logging
9 | const morganLogger = morgan(format, {
10 | stream: logger.stream,
11 | });
12 |
13 | export default morganLogger;
14 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | // jest.config.js
2 | export default {
3 | testEnvironment: 'node', // Use Node.js environment for testing
4 | transform: {
5 | '^.+\\.js$': 'babel-jest',
6 | },
7 | moduleFileExtensions: ['js', 'json', 'node'],
8 | clearMocks: true,
9 | coverageDirectory: 'coverage',
10 | coveragePathIgnorePatterns: ['/node_modules/', '/logs/', '/coverage/'],
11 | coverageProvider: 'v8',
12 | setupFilesAfterEnv: ['./jest.setup.js'],
13 | testTimeout: 30000,
14 | };
15 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | PORT=3000
2 | DB_URL=mongodb://host:example.com
3 | JWT_SECRET=JWT_SECRET_KEY
4 | GOOGLE_CLIENT_ID=GOOGLE_CLIENT_ID
5 | GOOGLE_CLIENT_SECRET=GOOGLE_CLIENT_SECRET
6 | GMAIL_USER=GOOGLE_GMAIL_USER
7 | GOOGLE_REFRESH_TOKEN=GOOGLE_REFRESH_TOKEN
8 | GOOGLE_CALLBACK_URL=http://localhost:3000/api/v1/auth/google/callback
9 | REDIS_HOST=127.0.0.1
10 | REDIS_PORT=6379
11 | REDIS_PASSWORD=
12 | REDIS_USERNAME=
13 | REDIS_DB=0
14 | AWS_REGION=ap-south-1
15 | RAZORPAY_KEY_ID=rzp_test_key
16 | RAZORPAY_KEY_SECRET=rzp_test_secret
17 |
--------------------------------------------------------------------------------
/src/utils/asyncHandler.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Async handler wrapper to avoid try-catch blocks in route controllers
3 | * Uses promise chaining with .then() and .catch() as requested
4 | *
5 | * @param {Function} fn - The async function to wrap
6 | * @returns {Function} - Express middleware function
7 | */
8 | const asyncHandler = (fn) => (req, res, next) => {
9 | Promise.resolve(fn(req, res, next)).catch((error) => {
10 | next(error); // Pass any errors to Express error handling middleware
11 | });
12 | };
13 |
14 | export default asyncHandler;
15 |
--------------------------------------------------------------------------------
/src/config/db.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 | import config from './config.js';
3 | import logger from '../loggers/winston.logger.js';
4 |
5 | function connectToDatabase() {
6 | const dbURI = config.DB_URL;
7 |
8 | mongoose
9 | .connect(dbURI, {
10 | useNewUrlParser: true,
11 | useUnifiedTopology: true,
12 | })
13 | .then(() => {
14 | logger.info('Connected to MongoDB');
15 | })
16 | .catch((err) => {
17 | logger.error('Error connecting to MongoDB:', err);
18 | });
19 | }
20 |
21 | export default connectToDatabase;
22 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import eslintPluginImport from 'eslint-plugin-import';
2 |
3 | export default [
4 | {
5 | files: ['**/*.js'],
6 | languageOptions: {
7 | ecmaVersion: 'latest',
8 | sourceType: 'module',
9 | },
10 | plugins: {
11 | import: eslintPluginImport,
12 | },
13 | rules: {
14 | 'no-unused-vars': 'warn',
15 | 'no-console': 'warn',
16 | 'import/order': [
17 | 'warn',
18 | { groups: [['builtin', 'external', 'internal']] },
19 | ],
20 | 'import/no-unresolved': 'error',
21 | },
22 | },
23 | ];
24 |
--------------------------------------------------------------------------------
/src/utils/sendEmail.js:
--------------------------------------------------------------------------------
1 | import { createTransporter } from '../config/email.config.js';
2 | import config from '../config/config.js';
3 |
4 | export const sendVerificationEmail = async (to, verificationLink) => {
5 | const transporter = await createTransporter();
6 | const mailOptions = {
7 | from: `"Testdog" <${config.GMAIL_USER}>`,
8 | to,
9 | subject: 'Verify your email',
10 | html: `
11 |
Click the link below to verify your email:
12 | ${verificationLink}
13 | `,
14 | };
15 |
16 | return transporter.sendMail(mailOptions);
17 | };
18 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # 1. Use an official lightweight Node.js runtime image
2 | FROM node:20-alpine
3 |
4 | # 2. Set working directory inside the container
5 | WORKDIR /app
6 |
7 | # 3. Copy package files first to leverage Docker cache
8 | COPY package*.json ./
9 |
10 |
11 | ENV NODE_ENV=production
12 |
13 | # 4. Install only production dependencies
14 | RUN npm ci --omit=dev
15 |
16 | # 5. Copy the rest of the app files
17 | COPY . .
18 |
19 | # 6. Set environment to production
20 | ENV NODE_ENV=production
21 |
22 | # 7. Expose the app port
23 | EXPOSE 3000
24 |
25 | # 8. Default command to run the app
26 | CMD ["npm", "start"]
27 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # .dockerignore
2 |
3 | # Git files
4 | .git
5 | .gitignore
6 |
7 | # Node dependencies
8 | node_modules
9 |
10 | # Build output
11 | dist
12 | *.tsbuildinfo
13 |
14 | # Environment files
15 | .env
16 | *.env.*
17 | !*.env.example
18 |
19 | # Log files
20 | *.log
21 | logs
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 | pnpm-debug.log*
26 | lerna-debug.log*
27 |
28 | # Editor/OS files
29 | .vscode
30 | .idea
31 | *.suo
32 | *.ntvs*
33 | *.njsproj
34 | *.sln
35 | *.sw?
36 | Thumbs.db
37 | .DS_Store
38 |
39 | # Keys (should be injected as secrets/env vars in production)
40 | *.key
41 | *.pem
42 |
43 | # Development specific files
44 | nodemon.json
--------------------------------------------------------------------------------
/src/utils/appError.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Custom AppError class to standardize error handling.
3 | * Can be thrown anywhere in the code to trigger error middleware.
4 | */
5 | class AppError extends Error {
6 | /**
7 | * Create an operational error instance.
8 | * @param {string} message - Error message to show to client.
9 | * @param {number} statusCode - HTTP status code (e.g., 400, 404).
10 | */
11 | constructor(message, statusCode) {
12 | super(message);
13 |
14 | this.statusCode = statusCode;
15 | this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
16 | this.isOperational = true;
17 |
18 | // Capture stack trace excluding constructor call
19 | Error.captureStackTrace(this, this.constructor);
20 | }
21 | }
22 |
23 | export default AppError;
24 |
--------------------------------------------------------------------------------
/src/validators/common.validator.js:
--------------------------------------------------------------------------------
1 | import { param } from 'express-validator';
2 | import mongoose from 'mongoose';
3 |
4 | export const validateMongoId = [
5 | param('id')
6 | .notEmpty()
7 | .withMessage('ID parameter is required')
8 | .custom((value) => {
9 | if (!mongoose.Types.ObjectId.isValid(value)) {
10 | throw new Error('Invalid ID format');
11 | }
12 | return true;
13 | }),
14 | ];
15 |
16 | export const validateObjectId = (idField = 'id') => [
17 | param(idField)
18 | .notEmpty()
19 | .withMessage(`${idField} parameter is required`)
20 | .custom((value) => {
21 | if (!mongoose.Types.ObjectId.isValid(value)) {
22 | throw new Error(`Invalid ${idField} format`);
23 | }
24 | return true;
25 | }),
26 | ];
27 |
--------------------------------------------------------------------------------
/src/config/email.config.js:
--------------------------------------------------------------------------------
1 | import nodemailer from 'nodemailer';
2 | import { google } from 'googleapis';
3 | import config from './config.js';
4 |
5 | const OAuth2 = google.auth.OAuth2;
6 |
7 | const oAuth2Client = new OAuth2(
8 | config.GOOGLE_CLIENT_ID,
9 | config.GOOGLE_CLIENT_SECRET,
10 | 'https://developers.google.com/oauthplayground'
11 | );
12 |
13 | oAuth2Client.setCredentials({ refresh_token: config.GOOGLE_REFRESH_TOKEN });
14 |
15 | export const createTransporter = async () => {
16 | const { token } = await oAuth2Client.getAccessToken();
17 |
18 | return nodemailer.createTransport({
19 | service: 'gmail',
20 | auth: {
21 | type: 'OAuth2',
22 | user: config.GMAIL_USER,
23 | clientId: config.GOOGLE_CLIENT_ID,
24 | clientSecret: config.GOOGLE_CLIENT_SECRET,
25 | refreshToken: config.GOOGLE_REFRESH_TOKEN,
26 | accessToken: token,
27 | },
28 | });
29 | };
30 |
--------------------------------------------------------------------------------
/src/utils/timeUtils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Convert a time string like '30d', '1h', '15m' to seconds
3 | * @param {string} timeString - Time string in format like '30d', '1h', '15m'
4 | * @returns {number} - Time in seconds
5 | */
6 | export function timeStringToSeconds(timeString) {
7 | const regex = /^(\d+)([dhms])$/;
8 | const match = timeString.match(regex);
9 |
10 | if (!match) {
11 | throw new Error(
12 | `Invalid time format: ${timeString}. Expected format like '30d', '1h', '15m', '30s'`
13 | );
14 | }
15 |
16 | const value = parseInt(match[1], 10);
17 | const unit = match[2];
18 |
19 | switch (unit) {
20 | case 'd':
21 | return value * 24 * 60 * 60; // days to seconds
22 | case 'h':
23 | return value * 60 * 60; // hours to seconds
24 | case 'm':
25 | return value * 60; // minutes to seconds
26 | case 's':
27 | return value; // seconds
28 | default:
29 | return value;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/routes/user.routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import userController from '../controllers/user.controller.js';
3 | import { protect, restrictTo } from '../middlewares/auth.middleware.js';
4 | import { validate } from '../middlewares/validator.middleware.js';
5 | import {
6 | getAllUsersValidator,
7 | updateUserValidator,
8 | } from '../validators/user.validator.js';
9 | import { userRateLimiter } from '../middlewares/rateLimiter.middleware.js';
10 |
11 | const router = express.Router();
12 |
13 | // Apply rate limiting to all user routes
14 | router.use(userRateLimiter);
15 |
16 | // User routes
17 | router
18 | .route('/update')
19 | .patch(protect, validate(updateUserValidator), userController.updateUser);
20 |
21 | router.route('/getrandomuser').get(userController.getRandomUser);
22 |
23 | // Protected route - only admins should be able to get all users
24 | router
25 | .route('/all')
26 | .get(
27 | protect,
28 | restrictTo('admin'),
29 | validate(getAllUsersValidator),
30 | userController.getAllUsers
31 | );
32 |
33 | export default router;
34 |
--------------------------------------------------------------------------------
/src/routes/cart.routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import cartController from '../controllers/cart.controller.js';
3 | import { protect } from '../middlewares/auth.middleware.js';
4 | import { validate } from '../middlewares/validator.middleware.js';
5 | import {
6 | addToCartValidator,
7 | updateCartItemValidator,
8 | removeCartItemValidator,
9 | } from '../validators/cart.validator.js';
10 |
11 | const router = express.Router();
12 |
13 | // All cart routes require authentication
14 | router.use(protect);
15 |
16 | // Cart summary - Get quick overview of cart
17 | router.route('/summary').get(cartController.getCartSummary);
18 |
19 | // Main cart routes
20 | router
21 | .route('/')
22 | .get(cartController.getCart) // Get cart items
23 | .post(validate(addToCartValidator), cartController.addToCart) // Add product to cart
24 | .delete(cartController.clearCart); // Clear entire cart
25 |
26 | // Cart item specific routes
27 | router
28 | .route('/:id')
29 | .patch(validate(updateCartItemValidator), cartController.updateCartItem) // Update item quantity
30 | .delete(validate(removeCartItemValidator), cartController.removeCartItem); // Remove item from cart
31 |
32 | export default router;
33 |
--------------------------------------------------------------------------------
/src/validators/user.validator.js:
--------------------------------------------------------------------------------
1 | import { body, query } from 'express-validator';
2 |
3 | export const updateUserValidator = [
4 | body('username')
5 | .optional()
6 | .isLength({ min: 3, max: 30 })
7 | .withMessage('Username must be between 3 and 30 characters')
8 | .trim()
9 | .escape(),
10 | body('name')
11 | .optional()
12 | .isLength({ min: 2, max: 50 })
13 | .withMessage('Name must be between 2 and 50 characters')
14 | .trim(),
15 | body('email')
16 | .optional()
17 | .isEmail()
18 | .withMessage('Please provide a valid email')
19 | .normalizeEmail(),
20 | body('avatar')
21 | .optional()
22 | .isURL()
23 | .withMessage('Avatar must be a valid URL')
24 | .trim(),
25 | // Password field removed as it cannot be updated through this route
26 | // Use dedicated password reset functionality instead
27 | ];
28 |
29 | export const getAllUsersValidator = [
30 | query('page')
31 | .optional()
32 | .isInt({ min: 1 })
33 | .withMessage('Page must be a positive integer starting from 1.'),
34 |
35 | query('limit')
36 | .optional()
37 | .isInt({ min: 1, max: 50 })
38 | .withMessage('Limit must be between 1 and 50.'),
39 | ];
40 |
--------------------------------------------------------------------------------
/src/config/config.js:
--------------------------------------------------------------------------------
1 | import dotenv from 'dotenv';
2 |
3 | dotenv.config();
4 |
5 | const _config = {
6 | NODE_ENV: process.env.NODE_ENV || 'development',
7 | PORT: process.env.PORT || 3000,
8 | DB_URL: process.env.DB_URL || 'mongodb://localhost:27017/mydatabase',
9 | JWT_SECRET: process.env.JWT_SECRET,
10 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
11 | GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
12 | GOOGLE_REFRESH_TOKEN: process.env.GOOGLE_REFRESH_TOKEN,
13 | GOOGLE_CALLBACK_URL: process.env.GOOGLE_CALLBACK_URL,
14 | GMAIL_USER: process.env.GMAIL_USER,
15 | FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:3000',
16 | redis: {
17 | host: process.env.REDIS_HOST || '127.0.0.1',
18 | port: process.env.REDIS_PORT || 6379,
19 | password: process.env.REDIS_PASSWORD || '',
20 | username: process.env.REDIS_USERNAME || '',
21 | db: process.env.REDIS_DB || 0,
22 | },
23 | razorpay: {
24 | keyId: process.env.RAZORPAY_KEY_ID || '',
25 | keySecret: process.env.RAZORPAY_KEY_SECRET || '',
26 | },
27 | };
28 |
29 | let finalConfig;
30 | if (process.env.NODE_ENV === 'testing') {
31 | finalConfig = _config; // Don't freeze in testing mode
32 | } else {
33 | finalConfig = Object.freeze(_config);
34 | }
35 |
36 | export default finalConfig;
37 |
--------------------------------------------------------------------------------
/src/routes/product.routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import productController from '../controllers/product.controller.js';
3 | import { protect, restrictTo } from '../middlewares/auth.middleware.js';
4 | import { validate } from '../middlewares/validator.middleware.js';
5 | import { createProductValidator } from '../validators/product.validator.js';
6 | import { productRateLimiter } from '../middlewares/rateLimiter.middleware.js';
7 |
8 | const router = express.Router();
9 |
10 | // Public routes - no authentication required
11 | router.route('/search').get(productController.searchProducts);
12 | router.route('/autocomplete').get(productController.getAutocomplete);
13 | router.route('/random').get(productController.getRandomProduct);
14 |
15 | // Get all products (with pagination) - Public
16 | router.route('/').get(productController.getAllProducts);
17 |
18 | // Get specific product by ID - Public
19 | router.route('/:id').get(productController.getProduct);
20 |
21 | // Protected routes - authentication required
22 | // Create product - Only authenticated admins
23 | router.route('/').post(
24 | productRateLimiter, // Rate limiting middleware
25 | protect, // Authentication middleware
26 | restrictTo('admin'), // Only admins can create products
27 | validate(createProductValidator), // Validation middleware
28 | productController.createProduct
29 | );
30 |
31 | export default router;
32 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to AWS ECS
2 |
3 | on:
4 | push:
5 | branches: [main]
6 |
7 | jobs:
8 | deploy:
9 | name: Build, Push, and Deploy
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout repo
14 | uses: actions/checkout@v4
15 |
16 | - name: Configure AWS credentials
17 | uses: aws-actions/configure-aws-credentials@v4
18 | with:
19 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
20 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
21 | aws-region: ${{ secrets.AWS_REGION }}
22 |
23 | - name: Login to Amazon ECR
24 | id: login-ecr
25 | uses: aws-actions/amazon-ecr-login@v2
26 |
27 | - name: Build and Push Docker image to ECR
28 | run: |
29 | echo "deploying TestApp to AWS ECS"
30 | aws ecr get-login-password --region ap-south-1 | docker login --username AWS --password-stdin 329599656829.dkr.ecr.ap-south-1.amazonaws.com
31 | docker build -t testdog .
32 | docker tag testdog:latest 329599656829.dkr.ecr.ap-south-1.amazonaws.com/testdog:latest
33 | docker push 329599656829.dkr.ecr.ap-south-1.amazonaws.com/testdog:latest
34 |
35 | aws ecs update-service \
36 | --cluster testdog-cluster \
37 | --service testdog-api \
38 | --force-new-deployment \
39 | --region ap-south-1 \
40 | --task-definition testdog-api
41 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Testdog API Server
2 |
3 | Testdog is an open-source, community-powered API server offering a growing collection of free-to-use APIs for your projects. Whether you're building a side project, a portfolio piece, or an MVP feel free to plug in and go!
4 |
5 | ---
6 |
7 | ## Use These APIs in Your Projects
8 |
9 | All APIs provided by Testdog are **completely free**. You can integrate them into your personal or commercial projects without any restrictions.
10 |
11 | - No API keys required
12 | - No rate limiting (unless explicitly mentioned)
13 | - Easy to explore and consume
14 |
15 | > ✨ Want a specific API? Raise a request or contribute by adding one yourself!
16 |
17 | ---
18 |
19 | ## Open for Contributions
20 |
21 | Testdog is **open-source**, and we're always looking for contributors!
22 |
23 | ### How to Contribute
24 |
25 | 1. Fork the repository
26 | 2. Create a feature branch
27 | 3. Add your API or improve existing ones
28 | 4. Raise a Pull Request (PR)
29 |
30 | If your PR aligns with our development direction and passes checks we’ll merge it. Simple as that.
31 |
32 | > Please make sure to follow best practices and write clean, modular code.
33 |
34 | ---
35 |
36 | ## Found a Bug or Issue?
37 |
38 | If you encounter any problems or have suggestions:
39 |
40 | - [Open an issue](https://github.com/yourusername/testdog/issues) describing the problem.
41 | - Be as detailed as possible so contributors can replicate and resolve it.
42 |
43 | ---
44 |
45 | ## Note
46 |
47 | Testdog is powered entirely by its contributors. The addition of any new API depends on community support and availability. So if something’s missing feel free to build it!
48 |
49 | ---
50 |
--------------------------------------------------------------------------------
/src/loggers/winston.logger.js:
--------------------------------------------------------------------------------
1 | import winston from 'winston';
2 | import config from '../config/config.js';
3 |
4 | // Define custom log levels
5 | const levels = {
6 | error: 0,
7 | warn: 1,
8 | info: 2,
9 | http: 3,
10 | debug: 4,
11 | };
12 |
13 | // Define different log level based on environment
14 | const getLevel = () => {
15 | const env = config.NODE_ENV || 'development';
16 |
17 | switch (env) {
18 | case 'production':
19 | return 'info'; // Only logs info and above in production
20 | case 'testing':
21 | return 'warn'; // Only logs warn and above in testing
22 | default:
23 | return 'debug'; // Logs everything in development
24 | }
25 | };
26 |
27 | // Define colors for each level
28 | const colors = {
29 | error: 'red',
30 | warn: 'yellow',
31 | info: 'blue',
32 | http: 'white',
33 | debug: 'white',
34 | };
35 |
36 | // Add colors to winston
37 | winston.addColors(colors);
38 |
39 | // Define format for console logs with colors
40 | const consoleFormat = winston.format.combine(
41 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
42 | winston.format.colorize({ all: true }),
43 | winston.format.printf(
44 | (info) => `${info.timestamp} [${info.level}]: ${info.message}`
45 | )
46 | );
47 |
48 | // Create the logger with only console transport
49 | const logger = winston.createLogger({
50 | level: getLevel(),
51 | levels,
52 | transports: [
53 | // Console transport only
54 | new winston.transports.Console({
55 | format: consoleFormat,
56 | }),
57 | ],
58 | // Don't exit on uncaught exceptions
59 | exitOnError: false,
60 | });
61 |
62 | // Create a stream object for morgan integration
63 | logger.stream = {
64 | write: (message) => {
65 | logger.http(message.trim());
66 | },
67 | };
68 |
69 | export default logger;
70 |
--------------------------------------------------------------------------------
/src/routes/auth.routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import passport from 'passport';
3 | import authController from '../controllers/auth.controller.js';
4 | import { protect } from '../middlewares/auth.middleware.js';
5 | import { validate } from '../middlewares/validator.middleware.js';
6 | import {
7 | registerRateLimiter,
8 | authRateLimiter,
9 | } from '../middlewares/rateLimiter.middleware.js';
10 | import {
11 | registerValidator,
12 | loginValidator,
13 | verifyEmailValidator,
14 | verifyEmailTokenValidator,
15 | } from '../validators/auth.validator.js';
16 |
17 | const router = express.Router();
18 |
19 | router.use(authRateLimiter); // Apply general auth rate limiting to all routes
20 |
21 | // Authentication routes
22 | router
23 | .route('/register')
24 | .post(
25 | registerRateLimiter,
26 | validate(registerValidator),
27 | authController.register
28 | );
29 | router.route('/login').post(validate(loginValidator), authController.login);
30 | router.route('/logout').get(protect, authController.logout);
31 |
32 | // User data routes
33 | router.route('/getme').get(protect, authController.getMe);
34 |
35 | // Token management routes
36 | router.route('/access-token').get(authController.generateAccessToken);
37 |
38 | // Email verification routes
39 | router
40 | .route('/verify-email')
41 | .post(validate(verifyEmailValidator), authController.verifyEmail)
42 | .get(validate(verifyEmailTokenValidator), authController.verifyEmailToken);
43 |
44 | // OAuth routes
45 | router.route('/google').get(
46 | passport.authenticate('google', {
47 | scope: ['profile', 'email'],
48 | })
49 | );
50 |
51 | router.route('/google/callback').get(
52 | passport.authenticate('google', {
53 | failureRedirect: '/login-failed', // Redirect to failure page if authentication fails
54 | session: false,
55 | }),
56 | authController.googleCallback
57 | );
58 |
59 | export default router;
60 |
--------------------------------------------------------------------------------
/src/routes/payment.routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import paymentController from '../controllers/payment.controller.js';
3 | import { protect } from '../middlewares/auth.middleware.js';
4 | import {
5 | createCartOrderValidator,
6 | createSingleProductOrderValidator,
7 | verifyPaymentValidator,
8 | getPaymentHistoryValidator,
9 | requestRefundValidator,
10 | getPaymentStatsValidator,
11 | getPaymentDetailsValidator,
12 | validate,
13 | } from '../validators/payment.validator.js';
14 |
15 | const router = express.Router();
16 |
17 | // Webhook endpoint (no auth required)
18 | router.post('/webhook', paymentController.handleWebhook);
19 |
20 | // Protected routes - require authentication
21 | router.use(protect);
22 |
23 | // Create orders
24 | router.post(
25 | '/orders/cart',
26 | createCartOrderValidator,
27 | validate,
28 | paymentController.createCartOrder
29 | );
30 |
31 | router.post(
32 | '/orders/single-product',
33 | createSingleProductOrderValidator,
34 | validate,
35 | paymentController.createSingleProductOrder
36 | );
37 |
38 | // Verify payment
39 | router.post(
40 | '/verify',
41 | verifyPaymentValidator,
42 | validate,
43 | paymentController.verifyPayment
44 | );
45 |
46 | // Get payment details
47 | router.get(
48 | '/details/:paymentId',
49 | getPaymentDetailsValidator,
50 | validate,
51 | paymentController.getPaymentDetails
52 | );
53 |
54 | // Get payment history
55 | router.get(
56 | '/history',
57 | getPaymentHistoryValidator,
58 | validate,
59 | paymentController.getPaymentHistory
60 | );
61 |
62 | // Request refund
63 | router.post(
64 | '/refund/:paymentId',
65 | requestRefundValidator,
66 | validate,
67 | paymentController.requestRefund
68 | );
69 |
70 | // Admin only routes
71 | router.get(
72 | '/stats',
73 | getPaymentStatsValidator,
74 | validate,
75 | paymentController.getPaymentStats
76 | );
77 |
78 | export default router;
79 |
--------------------------------------------------------------------------------
/src/config/passport.js:
--------------------------------------------------------------------------------
1 | import passport from 'passport';
2 | import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
3 | import userService from '../services/user.service.js';
4 | import userDao from '../dao/user.dao.js';
5 | import config from './config.js';
6 | import logger from '../loggers/winston.logger.js';
7 |
8 | passport.use(
9 | new GoogleStrategy(
10 | {
11 | clientID: config.GOOGLE_CLIENT_ID,
12 | clientSecret: config.GOOGLE_CLIENT_SECRET,
13 | callbackURL: config.GOOGLE_CALLBACK_URL,
14 | },
15 | async (accessToken, refreshToken, profile, done) => {
16 | try {
17 | logger.debug('Google profile:', JSON.stringify(profile));
18 |
19 | // Find user by googleId or email
20 | let user = await userDao.findByGoogleId(profile.id);
21 |
22 | if (!user && profile.emails && profile.emails.length > 0) {
23 | // If not found by googleId, try to find by email
24 | user = await userDao.findByEmail(profile.emails[0].value);
25 |
26 | if (user) {
27 | // If found by email, link Google account
28 | user.googleId = profile.id;
29 | await user.save();
30 | }
31 | }
32 |
33 | if (!user) {
34 | // Create a new user if not found
35 | user = await userService.registerUser({
36 | googleId: profile.id,
37 | email: profile.emails[0].value,
38 | name: profile.displayName,
39 | avatar:
40 | profile.photos && profile.photos.length > 0
41 | ? profile.photos[0].value
42 | : undefined,
43 | });
44 | logger.info(`New user created via Google: ${user.email}`);
45 | }
46 |
47 | return done(null, user);
48 | } catch (err) {
49 | logger.error('Google authentication error:', err);
50 | return done(err, null);
51 | }
52 | }
53 | )
54 | );
55 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "testdog",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "server.js",
6 | "scripts": {
7 | "dev": "nodemon server.js",
8 | "lint": "eslint .",
9 | "lint:fix": "eslint . --fix",
10 | "format": "prettier --write .",
11 | "test": "cross-env NODE_ENV=testing jest",
12 | "prepare": "test \"$NODE_ENV\" != \"production\" && husky install || exit 0",
13 | "start": "NODE_ENV=production node server.js"
14 | },
15 | "keywords": [],
16 | "author": "",
17 | "license": "ISC",
18 | "type": "module",
19 | "dependencies": {
20 | "bcryptjs": "^3.0.2",
21 | "cookie-parser": "^1.4.7",
22 | "cors": "^2.8.5",
23 | "dotenv": "^16.5.0",
24 | "express": "^5.1.0",
25 | "express-rate-limit": "^7.5.0",
26 | "express-validator": "^7.2.1",
27 | "googleapis": "^150.0.1",
28 | "gravatar": "^1.8.2",
29 | "helmet": "^8.1.0",
30 | "ioredis": "^5.6.1",
31 | "jsonwebtoken": "^9.0.2",
32 | "libphonenumber-js": "^1.12.9",
33 | "mongoose": "^8.15.1",
34 | "morgan": "^1.10.0",
35 | "node-fetch": "^3.3.2",
36 | "nodemailer": "^7.0.3",
37 | "passport": "^0.7.0",
38 | "passport-google-oauth20": "^2.0.0",
39 | "rate-limit-redis": "^4.2.1",
40 | "razorpay": "^2.9.6",
41 | "winston": "^3.17.0"
42 | },
43 | "devDependencies": {
44 | "@babel/core": "^7.27.4",
45 | "@babel/preset-env": "^7.27.2",
46 | "@eslint/js": "^9.28.0",
47 | "babel-jest": "^30.0.0-beta.3",
48 | "cross-env": "^7.0.3",
49 | "eslint": "^9.28.0",
50 | "eslint-config-prettier": "^10.1.5",
51 | "eslint-plugin-import": "^2.31.0",
52 | "eslint-plugin-prettier": "^5.4.1",
53 | "globals": "^16.2.0",
54 | "husky": "^9.1.7",
55 | "ioredis-mock": "^8.9.0",
56 | "jest": "^29.7.0",
57 | "mongodb-memory-server": "^10.1.4",
58 | "nodemon": "^3.1.10",
59 | "prettier": "^3.5.3",
60 | "supertest": "^7.1.1"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/controllers/user.controller.js:
--------------------------------------------------------------------------------
1 | import userServices from '../services/user.service.js';
2 | import asyncHandler from '../utils/asyncHandler.js';
3 |
4 | class UserController {
5 | /**
6 | * Update user details.
7 | * @param {Object} req - Express request object with user ID in the token and updated data in body.
8 | * @param {Object} res - Express response object.
9 | */
10 | updateUser = asyncHandler(async (req, res) => {
11 | const user = await userServices.updateUser(req.user._id, req.body);
12 | if (!user) {
13 | return res
14 | .status(404)
15 | .json({ success: false, message: 'User not found' });
16 | }
17 | res.status(200).json({ success: true, data: user });
18 | });
19 |
20 | /**
21 | * Get a random user.
22 | * @param {Object} req - Express request object.
23 | * @param {Object} res - Express response object.
24 | */
25 | getRandomUser = asyncHandler(async (req, res) => {
26 | const user = await userServices.getRandomUser();
27 | if (!user) {
28 | return res
29 | .status(404)
30 | .json({ success: false, message: 'No users found' });
31 | }
32 | res.status(200).json({ success: true, data: user });
33 | });
34 |
35 | /**
36 | * Get paginated users.
37 | * Handles query parameters: `page`, `limit`
38 | * Delegates pagination, validation, and capping logic to the service layer.
39 | *
40 | * @param {Object} req - Express request object
41 | * @param {Object} res - Express response object
42 | */
43 | getAllUsers = asyncHandler(async (req, res) => {
44 | const { page, limit } = req.query;
45 |
46 | const result = await userServices.getAllUsersPaginated(page, limit);
47 |
48 | res.status(200).json({
49 | success: true,
50 | ...(result.message && { message: result.message }),
51 | data: result.data,
52 | pagination: result.pagination,
53 | });
54 | });
55 | }
56 |
57 | export default new UserController();
58 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | import app from './src/app.js';
2 | import config from './src/config/config.js';
3 | import logger from './src/loggers/winston.logger.js';
4 | import connectToDatabase from './src/config/db.js';
5 | import redisService from './src/config/redis.js';
6 |
7 | // Handle uncaught exceptions
8 | process.on('uncaughtException', (error) => {
9 | logger.error(`Uncaught Exception: ${error.message}`, { stack: error.stack });
10 | process.exit(1);
11 | });
12 |
13 | // Handle unhandled promise rejections
14 | process.on('unhandledRejection', (reason, promise) => {
15 | logger.error(`Unhandled Rejection at: ${promise}, reason: ${reason}`);
16 | process.exit(1);
17 | });
18 |
19 | // Connect to database
20 | connectToDatabase();
21 |
22 | // Connect to Redis explicitly - this ensures a single connection
23 | redisService.connect().then((connected) => {
24 | if (connected) {
25 | logger.info('Redis client successfully connected');
26 | } else {
27 | logger.warn('Failed to connect to Redis - check your configuration');
28 | }
29 | });
30 |
31 | // Start the server
32 | const server = app.listen(config.PORT, () => {
33 | logger.info(`Server is running on port ${config.PORT}`);
34 | logger.debug(`Environment: ${process.env.NODE_ENV || 'development'}`);
35 | });
36 |
37 | // Graceful shutdown
38 | const gracefulShutdown = async () => {
39 | logger.info('Starting graceful shutdown...');
40 |
41 | // Close the server
42 | server.close(async () => {
43 | logger.info('HTTP server closed');
44 |
45 | // Disconnect from Redis
46 | await redisService.disconnect();
47 |
48 | logger.info('Graceful shutdown completed');
49 | process.exit(0);
50 | });
51 |
52 | // Force shutdown after timeout
53 | setTimeout(() => {
54 | logger.error('Forcing shutdown after timeout');
55 | process.exit(1);
56 | }, 10000);
57 | };
58 |
59 | // Listen for termination signals
60 | process.on('SIGTERM', gracefulShutdown);
61 | process.on('SIGINT', gracefulShutdown);
62 |
--------------------------------------------------------------------------------
/src/models/product.model.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 |
3 | const otherAttributeSchema = new mongoose.Schema(
4 | {
5 | name: { type: String, required: true },
6 | value: { type: String, required: true },
7 | },
8 | { _id: false }
9 | );
10 |
11 | const productSchema = new mongoose.Schema(
12 | {
13 | product_name: {
14 | type: String,
15 | required: true,
16 | trim: true,
17 | },
18 | description: {
19 | type: String,
20 | required: true,
21 | },
22 | initial_price: {
23 | type: Number,
24 | required: true,
25 | min: 0,
26 | },
27 | final_price: {
28 | type: Number,
29 | required: true,
30 | min: 0,
31 | },
32 | currency: {
33 | type: String,
34 | required: true,
35 | enum: ['USD', 'INR', 'EUR', 'GBP'], // extend as needed
36 | },
37 | in_stock: {
38 | type: Boolean,
39 | default: true,
40 | },
41 | color: {
42 | type: String,
43 | },
44 | size: {
45 | type: String,
46 | },
47 | main_image: {
48 | type: String,
49 | required: true,
50 | },
51 | category_tree: {
52 | type: [String],
53 | default: [],
54 | },
55 | image_count: {
56 | type: Number,
57 | default: 1,
58 | },
59 | image_urls: {
60 | type: [String],
61 | default: [],
62 | },
63 | other_attributes: {
64 | type: [otherAttributeSchema],
65 | default: [],
66 | },
67 | rating: {
68 | type: Number,
69 | default: 0,
70 | min: 0,
71 | max: 5,
72 | },
73 | root_category: {
74 | type: String,
75 | },
76 | category: {
77 | type: String,
78 | },
79 | all_available_sizes: {
80 | type: [String],
81 | default: [],
82 | },
83 | },
84 | {
85 | timestamps: true,
86 | }
87 | );
88 |
89 | const Product = mongoose.model('Product', productSchema);
90 |
91 | export default Product;
92 |
--------------------------------------------------------------------------------
/src/models/cart.model.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 |
3 | const cartItemSchema = new mongoose.Schema(
4 | {
5 | product: {
6 | type: mongoose.Schema.Types.ObjectId,
7 | ref: 'Product',
8 | required: true,
9 | },
10 | quantity: {
11 | type: Number,
12 | required: true,
13 | min: 1,
14 | max: 50,
15 | default: 1,
16 | },
17 | price: {
18 | type: Number,
19 | required: true,
20 | min: 0,
21 | },
22 | // Store selected product attributes
23 | selectedSize: {
24 | type: String,
25 | },
26 | selectedColor: {
27 | type: String,
28 | },
29 | },
30 | { _id: true }
31 | );
32 |
33 | const cartSchema = new mongoose.Schema(
34 | {
35 | user: {
36 | type: mongoose.Schema.Types.ObjectId,
37 | ref: 'User',
38 | required: true,
39 | unique: true, // One cart per user
40 | index: true, // Single index definition
41 | },
42 | items: [cartItemSchema],
43 | totalItems: {
44 | type: Number,
45 | default: 0,
46 | },
47 | totalAmount: {
48 | type: Number,
49 | default: 0,
50 | },
51 | currency: {
52 | type: String,
53 | default: 'USD',
54 | enum: ['USD', 'INR', 'EUR', 'GBP'],
55 | },
56 | },
57 | {
58 | timestamps: true,
59 | }
60 | );
61 |
62 | // Additional indexes
63 | cartSchema.index({ 'items.product': 1 });
64 |
65 | // Calculate totals before saving
66 | cartSchema.pre('save', function (next) {
67 | if (this.items && this.items.length > 0) {
68 | this.totalItems = this.items.reduce(
69 | (total, item) => total + item.quantity,
70 | 0
71 | );
72 | this.totalAmount = this.items.reduce(
73 | (total, item) => total + item.price * item.quantity,
74 | 0
75 | );
76 | } else {
77 | this.totalItems = 0;
78 | this.totalAmount = 0;
79 | }
80 | next();
81 | });
82 |
83 | const Cart = mongoose.model('Cart', cartSchema);
84 |
85 | export default Cart;
86 |
--------------------------------------------------------------------------------
/src/validators/cart.validator.js:
--------------------------------------------------------------------------------
1 | import { body, param } from 'express-validator';
2 | import mongoose from 'mongoose';
3 |
4 | export const addToCartValidator = [
5 | body('productId')
6 | .notEmpty()
7 | .withMessage('Product ID is required')
8 | .custom((value) => {
9 | if (!mongoose.Types.ObjectId.isValid(value)) {
10 | throw new Error('Invalid product ID format');
11 | }
12 | return true;
13 | }),
14 |
15 | body('quantity')
16 | .optional()
17 | .isInt({ min: 1, max: 50 })
18 | .withMessage('Quantity must be between 1 and 50')
19 | .toInt(),
20 |
21 | body('selectedSize')
22 | .optional()
23 | .isString()
24 | .withMessage('Selected size must be a string')
25 | .isLength({ min: 1, max: 20 })
26 | .withMessage('Selected size must be between 1 and 20 characters')
27 | .trim(),
28 |
29 | body('selectedColor')
30 | .optional()
31 | .isString()
32 | .withMessage('Selected color must be a string')
33 | .isLength({ min: 1, max: 50 })
34 | .withMessage('Selected color must be between 1 and 50 characters')
35 | .trim(),
36 | ];
37 |
38 | export const updateCartItemValidator = [
39 | param('id')
40 | .notEmpty()
41 | .withMessage('Cart item ID is required')
42 | .custom((value) => {
43 | if (!mongoose.Types.ObjectId.isValid(value)) {
44 | throw new Error('Invalid cart item ID format');
45 | }
46 | return true;
47 | }),
48 |
49 | body('quantity')
50 | .notEmpty()
51 | .withMessage('Quantity is required')
52 | .isInt({ min: 1, max: 50 })
53 | .withMessage('Quantity must be between 1 and 50')
54 | .toInt(),
55 | ];
56 |
57 | export const removeCartItemValidator = [
58 | param('id')
59 | .notEmpty()
60 | .withMessage('Cart item ID is required')
61 | .custom((value) => {
62 | if (!mongoose.Types.ObjectId.isValid(value)) {
63 | throw new Error('Invalid cart item ID format');
64 | }
65 | return true;
66 | }),
67 | ];
68 |
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import cors from 'cors';
3 | import helmet from 'helmet';
4 | import cookieParser from 'cookie-parser';
5 | import passport from 'passport';
6 | import morganLogger from './loggers/morgan.logger.js';
7 | import './config/passport.js'; // Ensure passport strategies are loaded
8 | import errorHandler from './middlewares/errorHandler.js';
9 | import config from './config/config.js';
10 | import { generalRateLimiter } from './middlewares/rateLimiter.middleware.js';
11 |
12 | const app = express();
13 |
14 | // Middleware
15 | app.use(cors());
16 | app.use(morganLogger);
17 | app.use(helmet());
18 | app.use(
19 | express.json({
20 | limit: '100kb', // Limit JSON body size to 100KB
21 | })
22 | );
23 | app.use(express.urlencoded({ extended: true, limit: '100kb' })); // Limit URL-encoded body size to 100KB
24 | app.use(cookieParser());
25 |
26 | // Rate limiting
27 | app.use(generalRateLimiter);
28 | app.use(passport.initialize());
29 |
30 | // Routes
31 | import authRoutes from './routes/auth.routes.js';
32 | import userRoutes from './routes/user.routes.js';
33 | import productRoutes from './routes/product.routes.js';
34 | import cartRoutes from './routes/cart.routes.js';
35 | import paymentRoutes from './routes/payment.routes.js';
36 |
37 | app.use('/api/v1/auth', authRoutes);
38 | app.use('/api/v1/users', userRoutes);
39 | app.use('/api/v1/store/products', productRoutes);
40 | app.use('/api/v1/store/cart', cartRoutes);
41 | app.use('/api/v1/payments', paymentRoutes);
42 |
43 | // // Simple route for checking server status
44 | app.get('/', (req, res) => {
45 | res.status(200).json({
46 | status: 'success',
47 | message: 'Welcome to the TestDog API',
48 | environment: config.NODE_ENV,
49 | documentation: 'docs.testdog.in',
50 | });
51 | });
52 |
53 | // // 404 route handler for undefined routes
54 | app.all('*name', (req, res, next) => {
55 | const err = new Error(`Can't find ${req.originalUrl} on this server!`);
56 | err.statusCode = 404;
57 | err.status = 'fail';
58 | next(err);
59 | });
60 |
61 | app.use(errorHandler);
62 |
63 | export default app;
64 |
--------------------------------------------------------------------------------
/src/middlewares/errorHandler.js:
--------------------------------------------------------------------------------
1 | import AppError from '../utils/appError.js';
2 | import logger from '../loggers/winston.logger.js';
3 | import config from '../config/config.js';
4 |
5 | /**
6 | * Global error handling middleware
7 | * Handles errors passed from controllers or thrown in the application
8 | */
9 | const errorHandler = (err, req, res, next) => {
10 | let error = { ...err };
11 | error.message = err.message;
12 |
13 | // Set default values if not available
14 | error.statusCode = error.statusCode || 500;
15 | error.status = error.status || 'error';
16 |
17 | // Choose appropriate log level based on error type
18 | if (error.statusCode >= 500) {
19 | // 5xx errors are server errors and should be logged as errors
20 | logger.error('SERVER ERROR 💥', err);
21 | } else if (error.statusCode >= 400 && error.statusCode < 500) {
22 | // 4xx errors are client errors
23 | if (err.isOperational) {
24 | // Expected operational errors (like validation errors) use info level
25 | logger.info('CLIENT ERROR (Operational) 🔍', err);
26 | } else {
27 | // Unexpected client errors use warn level
28 | logger.warn('CLIENT ERROR 🚨', err);
29 | }
30 | } else {
31 | // Fallback for any other cases
32 | logger.error('UNHANDLED ERROR 💥', err);
33 | }
34 |
35 | // Handle specific error types
36 | if (err.name === 'ValidationError') {
37 | error = new AppError(err.message, 400);
38 | }
39 |
40 | if (err.code === 11000) {
41 | const field = Object.keys(err.keyValue)[0];
42 | error = new AppError(
43 | `Duplicate field value: ${field}. Please use another value.`,
44 | 400
45 | );
46 | }
47 |
48 | // Mongoose bad ObjectId
49 | if (err.name === 'CastError') {
50 | error = new AppError(`Invalid ${err.path}: ${err.value}.`, 400);
51 | }
52 |
53 | // Send error response
54 | res.status(error.statusCode).json({
55 | success: false,
56 | status: error.status,
57 | message: error.message,
58 | stack: config.NODE_ENV === 'development' ? err.stack : undefined,
59 | });
60 | };
61 |
62 | export default errorHandler;
63 |
--------------------------------------------------------------------------------
/src/middlewares/auth.middleware.js:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken';
2 | import { promisify } from 'util';
3 | import AppError from '../utils/appError.js';
4 | import config from '../config/config.js';
5 | import userService from '../services/user.service.js';
6 |
7 | /**
8 | * Middleware to protect routes that require authentication
9 | * Verifies the JWT token from the request headers or cookies
10 | */
11 | export const protect = async (req, res, next) => {
12 | try {
13 | // 1) Get token from authorization header or cookies
14 | let token;
15 |
16 | if (req.cookies && req.cookies.accessToken) {
17 | token = req.cookies.accessToken;
18 | } else if (
19 | req.headers.authorization &&
20 | req.headers.authorization.startsWith('Bearer')
21 | ) {
22 | token = req.headers.authorization.split(' ')[1];
23 | }
24 |
25 | if (!token) {
26 | return next(
27 | new AppError('You are not logged in. Please log in to get access.', 401)
28 | );
29 | }
30 |
31 | // 2) Verify token
32 | const decoded = await promisify(jwt.verify)(token, config.JWT_SECRET);
33 |
34 | // 3) Check if user still exists
35 | const currentUser = await userService.getMe(decoded.id, 'role');
36 |
37 | if (!currentUser) {
38 | return next(
39 | new AppError('The user belonging to this token no longer exists.', 401)
40 | );
41 | }
42 |
43 | // GRANT ACCESS TO PROTECTED ROUTE
44 | req.user = currentUser;
45 | next();
46 | } catch (error) {
47 | if (error instanceof jwt.JsonWebTokenError) {
48 | return next(new AppError('Invalid token. Please log in again.', 401));
49 | }
50 | if (error instanceof jwt.TokenExpiredError) {
51 | return next(
52 | new AppError('Your token has expired. Please log in again.', 401)
53 | );
54 | }
55 | next(error);
56 | }
57 | };
58 |
59 | /**
60 | * Middleware to restrict access based on user roles
61 | * @param {...string} roles - Roles allowed to access the route
62 | */
63 | export const restrictTo = (...roles) => {
64 | return (req, res, next) => {
65 | // roles is an array: ['admin', 'user']
66 | if (!roles.includes(req.user.role)) {
67 | return next(
68 | new AppError('You do not have permission to perform this action', 403)
69 | );
70 | }
71 |
72 | next();
73 | };
74 | };
75 |
--------------------------------------------------------------------------------
/src/middlewares/validator.middleware.js:
--------------------------------------------------------------------------------
1 | import { validationResult } from 'express-validator';
2 | import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js';
3 |
4 | /**
5 | * Middleware to validate request data based on validation rules
6 | * @param {Array} validations - Array of express-validator validation rules
7 | * @returns {Function} Express middleware
8 | */
9 | export const validate = (validations) => {
10 | return async (req, res, next) => {
11 | // Execute all validations
12 | await Promise.all(validations.map((validation) => validation.run(req)));
13 |
14 | // Check if there are validation errors
15 | const errors = validationResult(req);
16 | if (errors.isEmpty()) {
17 | return next();
18 | }
19 |
20 | // Format validation errors
21 | const extractedErrors = {};
22 | errors.array().forEach((err) => {
23 | // Group errors by field
24 | if (!extractedErrors[err.path]) {
25 | extractedErrors[err.path] = err.msg;
26 | }
27 | });
28 |
29 | return res.status(422).json({
30 | success: false,
31 | message: 'Validation failed',
32 | errors: extractedErrors,
33 | });
34 | };
35 | };
36 |
37 | /**
38 | * Helper function to check for specific validation rules
39 | * Can be expanded with more custom validators as needed
40 | */
41 | export const customValidators = {
42 | isStrongPassword: (value) => {
43 | // Check for at least one uppercase, one lowercase, one number, and one special character
44 | const passwordRegex =
45 | /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?^()_+\-=\[\]{}|;:'",.<>\/\\])[A-Za-z\d@$!%*?^()_+\-=\[\]{}|;:'",.<>\/\\]{8,}$/;
46 | return passwordRegex.test(value);
47 | },
48 |
49 | isValidPhone: (value, { req }) => {
50 | // Advanced phone number validation using libphonenumber-js
51 | try {
52 | // If country code is provided in the request, use it for better validation
53 | const countryCode = req?.body?.countryCode || req?.query?.countryCode;
54 |
55 | // If we have a country code, we can do more precise validation
56 | if (countryCode) {
57 | return isValidPhoneNumber(value, countryCode);
58 | }
59 |
60 | // Otherwise try to parse it as an international number
61 | const phoneNumber = parsePhoneNumber(value);
62 | return phoneNumber && phoneNumber.isValid();
63 | } catch (error) {
64 | // If parsing fails, it's not a valid number
65 | return false;
66 | }
67 | },
68 | };
69 |
--------------------------------------------------------------------------------
/src/models/user.model.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 | import bcrypt from 'bcryptjs';
3 |
4 | const userSchema = new mongoose.Schema(
5 | {
6 | username: {
7 | type: String,
8 | required: function () {
9 | return !this.googleId;
10 | }, // Not required if using Google auth
11 | unique: true,
12 | sparse: true, // Allow multiple null values (for Google users without username)
13 | trim: true,
14 | minlength: 3,
15 | maxlength: 30,
16 | },
17 | name: {
18 | type: String,
19 | trim: true,
20 | },
21 | email: {
22 | type: String,
23 | required: [true, 'Email is required'],
24 | unique: true,
25 | lowercase: true,
26 | trim: true,
27 | },
28 |
29 | isEmailVerified: {
30 | type: Boolean,
31 | default: function () {
32 | return Boolean(this.googleId); // Email verification not required for Google users
33 | },
34 | select: false, // Don't return this field by default
35 | },
36 |
37 | password: {
38 | type: String,
39 | required: function () {
40 | return !this.googleId;
41 | }, // Not required if using Google auth
42 | minlength: 6,
43 | select: false,
44 | },
45 | googleId: {
46 | type: String,
47 | unique: true,
48 | sparse: true,
49 | },
50 | role: {
51 | type: String,
52 | enum: ['user', 'admin'],
53 | default: 'user',
54 | select: false,
55 | },
56 | avatar: {
57 | type: String,
58 | default: 'default.jpg',
59 | },
60 | forgotPasswordToken: {
61 | type: String,
62 | select: false,
63 | },
64 |
65 | emailVerificationToken: {
66 | type: String,
67 | select: false,
68 | },
69 |
70 | passwordChangedAt: Date,
71 | },
72 | {
73 | timestamps: true,
74 | }
75 | );
76 |
77 | userSchema.pre('save', async function (next) {
78 | // Ensure googleId is never explicitly null
79 | if (this.googleId === null) {
80 | this.googleId = undefined;
81 | }
82 |
83 | if (!this.password) return next();
84 | if (!this.isModified('password')) return next();
85 | this.password = await bcrypt.hash(this.password, 12);
86 | next();
87 | });
88 |
89 | userSchema.methods.comparePassword = async function (enteredPassword) {
90 | return await bcrypt.compare(enteredPassword, this.password);
91 | };
92 |
93 | userSchema.methods.isPasswordChangedAfter = function (JWTTimestamp) {
94 | if (this.passwordChangedAt) {
95 | const changedTimestamp = parseInt(
96 | this.passwordChangedAt.getTime() / 1000,
97 | 10
98 | );
99 | return JWTTimestamp < changedTimestamp;
100 | }
101 | return false;
102 | };
103 |
104 | const User = mongoose.model('User', userSchema);
105 | export default User;
106 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | import { MongoMemoryServer } from 'mongodb-memory-server';
2 | import mongoose from 'mongoose';
3 | import Redis from 'ioredis-mock'; // Import the mock
4 | import config from './src/config/config.js'; // Your app's config
5 | import redisService from './src/config/redis.js'; // Your redis service
6 |
7 | let mongod;
8 |
9 | // Mock ioredis before any of your modules try to import the real one
10 | // Correct way to mock ioredis
11 | jest.mock('ioredis', () => {
12 | // Require 'ioredis-mock' inside the factory function.
13 | // This ensures it's resolved correctly during Jest's mocking phase.
14 | const IORedisMock = require('ioredis-mock');
15 | return IORedisMock; // The mock will replace the 'ioredis' module
16 | });
17 |
18 | beforeAll(async () => {
19 | mongod = await MongoMemoryServer.create();
20 | const uri = mongod.getUri();
21 | config.DB_URL = uri; // Override DB_URL in your config for tests
22 |
23 | // It's important to disconnect and reconnect mongoose if it was already connected
24 | if (mongoose.connection.readyState !== 0) {
25 | await mongoose.disconnect();
26 | }
27 |
28 | await mongoose.connect(uri);
29 |
30 | // Re-initialize your Redis service with the mock if needed,
31 | // or ensure it uses the mocked 'ioredis' when it initializes.
32 | // Since we globally mocked 'ioredis', your redisService should pick up the mock.
33 | // You might need to adjust your redisService to be more easily testable
34 | // or ensure it re-initializes its client if it's already created one.
35 |
36 | // For your current redis.js, it creates an instance on import.
37 | // We need to ensure it uses the mocked Redis.
38 | // A simple way is to re-assign its client or re-initialize.
39 |
40 | // If your redisService.connect() can be called multiple times or re-initializes the client:
41 | await redisService.disconnect(); // Disconnect if already connected
42 | redisService.client = new Redis({
43 | host: config.redis.host,
44 | port: config.redis.port,
45 | password: config.redis.password,
46 | db: config.redis.db,
47 | }); // Manually set the client to the mock instance
48 | // Or, if your redisService.connect() re-creates the client with the (now mocked) ioredis:
49 | // await redisService.connect();
50 | });
51 |
52 | afterAll(async () => {
53 | await mongoose.disconnect();
54 | if (mongod) {
55 | await mongod.stop();
56 | }
57 | if (redisService && typeof redisService.disconnect === 'function') {
58 | await redisService.disconnect();
59 | }
60 | jest.clearAllMocks(); // Clear all mocks after tests are done
61 | });
62 |
63 | // Optional: Clear database between each test
64 | beforeEach(async () => {
65 | const collections = mongoose.connection.collections;
66 | for (const key in collections) {
67 | const collection = collections[key];
68 | await collection.deleteMany({});
69 | }
70 |
71 | // Clear Redis mock data before each test
72 | if (
73 | redisService.client &&
74 | typeof redisService.client.flushdb === 'function'
75 | ) {
76 | await redisService.client.flushdb();
77 | }
78 | });
79 |
--------------------------------------------------------------------------------
/src/utils/currencyUtils.js:
--------------------------------------------------------------------------------
1 | // Currency conversion utility
2 | // In production, you should use a real-time exchange rate API
3 | // like ExchangeRate-API, CurrencyAPI, or similar services
4 |
5 | const EXCHANGE_RATES = {
6 | USD: {
7 | INR: 83.5, // 1 USD = 83.5 INR (approximate rate)
8 | EUR: 0.92,
9 | GBP: 0.79,
10 | USD: 1,
11 | },
12 | INR: {
13 | USD: 0.012,
14 | EUR: 0.011,
15 | GBP: 0.0095,
16 | INR: 1,
17 | },
18 | EUR: {
19 | USD: 1.09,
20 | INR: 91.0,
21 | GBP: 0.86,
22 | EUR: 1,
23 | },
24 | GBP: {
25 | USD: 1.27,
26 | INR: 105.8,
27 | EUR: 1.16,
28 | GBP: 1,
29 | },
30 | };
31 |
32 | /**
33 | * Convert amount from one currency to another
34 | * @param {number} amount - Amount to convert
35 | * @param {string} fromCurrency - Source currency (USD, INR, EUR, GBP)
36 | * @param {string} toCurrency - Target currency (USD, INR, EUR, GBP)
37 | * @returns {number} Converted amount
38 | */
39 | export const convertCurrency = (amount, fromCurrency, toCurrency) => {
40 | if (fromCurrency === toCurrency) {
41 | return amount;
42 | }
43 |
44 | if (
45 | !EXCHANGE_RATES[fromCurrency] ||
46 | !EXCHANGE_RATES[fromCurrency][toCurrency]
47 | ) {
48 | throw new Error(
49 | `Currency conversion not supported: ${fromCurrency} to ${toCurrency}`
50 | );
51 | }
52 |
53 | const rate = EXCHANGE_RATES[fromCurrency][toCurrency];
54 | return amount * rate;
55 | };
56 |
57 | /**
58 | * Get current exchange rate between two currencies
59 | * @param {string} fromCurrency - Source currency
60 | * @param {string} toCurrency - Target currency
61 | * @returns {number} Exchange rate
62 | */
63 | export const getExchangeRate = (fromCurrency, toCurrency) => {
64 | if (fromCurrency === toCurrency) {
65 | return 1;
66 | }
67 |
68 | if (
69 | !EXCHANGE_RATES[fromCurrency] ||
70 | !EXCHANGE_RATES[fromCurrency][toCurrency]
71 | ) {
72 | throw new Error(
73 | `Exchange rate not available: ${fromCurrency} to ${toCurrency}`
74 | );
75 | }
76 |
77 | return EXCHANGE_RATES[fromCurrency][toCurrency];
78 | };
79 |
80 | /**
81 | * Format amount with currency symbol
82 | * @param {number} amount - Amount to format
83 | * @param {string} currency - Currency code
84 | * @returns {string} Formatted amount
85 | */
86 | export const formatCurrency = (amount, currency) => {
87 | const currencySymbols = {
88 | USD: '$',
89 | INR: '₹',
90 | EUR: '€',
91 | GBP: '£',
92 | };
93 |
94 | const symbol = currencySymbols[currency] || currency;
95 | return `${symbol}${amount.toFixed(2)}`;
96 | };
97 |
98 | /**
99 | * Update exchange rates (in production, this would fetch from an API)
100 | * @param {object} newRates - New exchange rates object
101 | */
102 | export const updateExchangeRates = (newRates) => {
103 | Object.assign(EXCHANGE_RATES, newRates);
104 | };
105 |
106 | export default {
107 | convertCurrency,
108 | getExchangeRate,
109 | formatCurrency,
110 | updateExchangeRates,
111 | EXCHANGE_RATES,
112 | };
113 |
--------------------------------------------------------------------------------
/src/validators/auth.validator.js:
--------------------------------------------------------------------------------
1 | import { body, query } from 'express-validator';
2 | import { customValidators } from '../middlewares/validator.middleware.js';
3 |
4 | export const registerValidator = [
5 | body('username')
6 | .optional({ nullable: true })
7 | .isLength({ min: 3, max: 30 })
8 | .withMessage('Username must be between 3 and 30 characters')
9 | .matches(/^[a-zA-Z0-9_]+$/)
10 | .withMessage('Username can only contain letters, numbers, and underscores')
11 | .trim()
12 | .escape(),
13 | body('name')
14 | .optional()
15 | .isLength({ min: 2, max: 50 })
16 | .withMessage('Name must be between 2 and 50 characters')
17 | .trim(),
18 | body('email')
19 | .notEmpty()
20 | .withMessage('Email is required')
21 | .isEmail()
22 | .withMessage('Please provide a valid email')
23 | .normalizeEmail(),
24 | body('password')
25 | .notEmpty()
26 | .withMessage('Password is required')
27 | .isLength({ min: 8 })
28 | .withMessage('Password must be at least 8 characters')
29 | .custom((value) => {
30 | if (!customValidators.isStrongPassword(value)) {
31 | throw new Error(
32 | 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character'
33 | );
34 | }
35 | return true;
36 | }),
37 | ];
38 |
39 | export const loginValidator = [
40 | body('email')
41 | .notEmpty()
42 | .withMessage('Email is required')
43 | .isEmail()
44 | .withMessage('Please provide a valid email')
45 | .normalizeEmail(),
46 | body('password')
47 | .notEmpty()
48 | .withMessage('Password is required')
49 | .isLength({ min: 8 })
50 | .withMessage('Password must be at least 8 characters'),
51 | ];
52 |
53 | export const verifyEmailValidator = [
54 | body('email')
55 | .notEmpty()
56 | .withMessage('Email is required')
57 | .isEmail()
58 | .withMessage('Please provide a valid email')
59 | .normalizeEmail(),
60 | ];
61 |
62 | export const verifyEmailTokenValidator = [
63 | query('token').notEmpty().withMessage('Token is required'),
64 | ];
65 |
66 | export const forgotPasswordValidator = [
67 | body('email')
68 | .notEmpty()
69 | .withMessage('Email is required')
70 | .isEmail()
71 | .withMessage('Please provide a valid email')
72 | .normalizeEmail(),
73 | ];
74 |
75 | export const resetPasswordValidator = [
76 | body('token').notEmpty().withMessage('Token is required'),
77 | body('password')
78 | .notEmpty()
79 | .withMessage('Password is required')
80 | .isLength({ min: 8 })
81 | .withMessage('Password must be at least 8 characters')
82 | .custom((value) => {
83 | if (!customValidators.isStrongPassword(value)) {
84 | throw new Error(
85 | 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character'
86 | );
87 | }
88 | return true;
89 | }),
90 | body('confirmPassword')
91 | .notEmpty()
92 | .withMessage('Confirm password is required')
93 | .custom((value, { req }) => {
94 | if (value !== req.body.password) {
95 | throw new Error('Passwords do not match');
96 | }
97 | return true;
98 | }),
99 | ];
100 |
--------------------------------------------------------------------------------
/src/__tests__/currencyUtils.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | convertCurrency,
3 | getExchangeRate,
4 | formatCurrency,
5 | } from '../utils/currencyUtils.js';
6 |
7 | describe('Currency Utilities', () => {
8 | describe('convertCurrency', () => {
9 | it('should convert USD to INR correctly', () => {
10 | const result = convertCurrency(100, 'USD', 'INR');
11 | expect(result).toBe(8350); // 100 USD * 83.5 = 8350 INR
12 | });
13 |
14 | it('should convert EUR to INR correctly', () => {
15 | const result = convertCurrency(100, 'EUR', 'INR');
16 | expect(result).toBe(9100); // 100 EUR * 91.0 = 9100 INR
17 | });
18 |
19 | it('should return same amount for same currency', () => {
20 | const result = convertCurrency(100, 'INR', 'INR');
21 | expect(result).toBe(100);
22 | });
23 |
24 | it('should throw error for unsupported currency', () => {
25 | expect(() => {
26 | convertCurrency(100, 'XYZ', 'INR');
27 | }).toThrow('Currency conversion not supported: XYZ to INR');
28 | });
29 | });
30 |
31 | describe('getExchangeRate', () => {
32 | it('should return correct exchange rate', () => {
33 | const rate = getExchangeRate('USD', 'INR');
34 | expect(rate).toBe(83.5);
35 | });
36 |
37 | it('should return 1 for same currency', () => {
38 | const rate = getExchangeRate('USD', 'USD');
39 | expect(rate).toBe(1);
40 | });
41 | });
42 |
43 | describe('formatCurrency', () => {
44 | it('should format USD correctly', () => {
45 | const formatted = formatCurrency(1200.5, 'USD');
46 | expect(formatted).toBe('$1200.50');
47 | });
48 |
49 | it('should format INR correctly', () => {
50 | const formatted = formatCurrency(100200.0, 'INR');
51 | expect(formatted).toBe('₹100200.00');
52 | });
53 |
54 | it('should format EUR correctly', () => {
55 | const formatted = formatCurrency(1100.25, 'EUR');
56 | expect(formatted).toBe('€1100.25');
57 | });
58 |
59 | it('should format GBP correctly', () => {
60 | const formatted = formatCurrency(950.75, 'GBP');
61 | expect(formatted).toBe('£950.75');
62 | });
63 | });
64 |
65 | describe('Real-world scenarios', () => {
66 | it('should handle typical e-commerce cart conversion', () => {
67 | // Scenario: Cart with mixed currency items
68 | const items = [
69 | { price: 500, currency: 'USD', quantity: 2 }, // Gaming headset
70 | { price: 800, currency: 'EUR', quantity: 1 }, // Mechanical keyboard
71 | { price: 2000, currency: 'INR', quantity: 1 }, // Local accessory
72 | ];
73 |
74 | let totalINR = 0;
75 | items.forEach((item) => {
76 | const priceInINR = convertCurrency(item.price, item.currency, 'INR');
77 | totalINR += priceInINR * item.quantity;
78 | });
79 |
80 | // Expected: (500*83.5*2) + (800*91.0*1) + (2000*1*1) = 83500 + 72800 + 2000 = 158300
81 | expect(totalINR).toBe(158300);
82 | });
83 |
84 | it('should handle single product conversion correctly', () => {
85 | // Scenario: Single expensive product in USD
86 | const productPrice = 1299.99; // Gaming laptop
87 | const quantity = 1;
88 |
89 | const priceInINR = convertCurrency(productPrice, 'USD', 'INR');
90 | const totalInINR = priceInINR * quantity;
91 |
92 | // Expected: 1299.99 * 83.5 = 108549.165
93 | expect(priceInINR).toBeCloseTo(108549.165, 2);
94 | expect(totalInINR).toBeCloseTo(108549.165, 2);
95 | });
96 |
97 | it('should handle payment amount calculation in paise', () => {
98 | // Scenario: Converting final amount to paise for Razorpay
99 | const amountInINR = 100200.5;
100 | const amountInPaise = Math.round(amountInINR * 100);
101 |
102 | expect(amountInPaise).toBe(10020050);
103 | });
104 | });
105 | });
106 |
--------------------------------------------------------------------------------
/src/dao/user.dao.js:
--------------------------------------------------------------------------------
1 | import User from '../models/user.model.js';
2 |
3 | /**
4 | * Data Access Object for User operations.
5 | * Handles all direct interactions with the User collection.
6 | */
7 | class UserDAO {
8 | /**
9 | * Create a new user document in the database.
10 | * @param {Object} userData - Contains username, email, password, etc.
11 | * @returns {Promise