diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f28476..6a3a051 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: node-version: ${{ env.NODE_VERSION }} - name: Install dependencies - run: npm install + run: npm ci - name: Run build run: npm run build @@ -31,4 +31,4 @@ jobs: run: npm run lint - name: Check format - run: npm run check-format + run: npm run format diff --git a/Dockerfile b/Dockerfile index 5044863..3a1efbb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,8 @@ WORKDIR /usr/src/app # Copy package.json and package-lock.json COPY package*.json ./ -# Install global and app dependencies -RUN npm install -g typescript tsx -RUN npm install +# Install app dependencies +RUN npm ci # Bundle app source COPY . . diff --git a/README.md b/README.md index dfd789c..6d563ad 100644 --- a/README.md +++ b/README.md @@ -58,31 +58,27 @@ Developed to streamline backend development, this boilerplate is your solution f . ├── common │ ├── middleware -│ │ ├── compressFilter.ts │ │ ├── errorHandler.ts │ │ ├── rateLimiter.ts │ │ └── requestLogger.ts │ ├── models │ │ └── serviceResponse.ts │ └── utils +│ ├── commonValidation.ts │ ├── envConfig.ts -│ └── responseHandler.ts +│ └── httpHandlers.ts ├── index.ts ├── modules │ ├── healthCheck -│ │ ├── healthCheckRoutes.ts -│ │ └── tests -│ │ └── healthCheckRoutes.test.ts +│ │ └── healthCheckRoutes.ts │ └── user -│ ├── tests -│ │ └── userRoutes.test.ts │ ├── userModel.ts │ ├── userRepository.ts │ ├── userRoutes.ts │ └── userService.ts └── server.ts -10 directories, 16 files +8 directories, 14 files ``` ## 🤝 Feedback and Contributions diff --git a/jest.config.js b/jest.config.js index 958213f..dbc5d5e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,7 +5,7 @@ const { compilerOptions } = require('./tsconfig.json'); module.exports = { preset: 'ts-jest', testEnvironment: 'node', - roots: ['/src'], + roots: ['/src', '/tests'], testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'], moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/', @@ -19,6 +19,6 @@ module.exports = { coverageDirectory: 'coverage', testPathIgnorePatterns: ['/lib/', '/node_modules/', '/img/', '/dist/'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - modulePaths: ['src'], + modulePaths: ['/src'], moduleDirectories: ['node_modules'], }; diff --git a/package-lock.json b/package-lock.json index d5f6696..0a337fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,22 +9,24 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "compression": "^1.7.4", "cors": "^2.8.5", "crypto": "^1.0.1", "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", - "pino-http": "^9.0.0" + "http-status-codes": "^2.3.0", + "lodash": "^4.17.21", + "pino-http": "^9.0.0", + "zod": "^3.22.4" }, "devDependencies": { "@tsconfig/node-lts-strictest-esm": "^18.12.1", - "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/dotenv": "^8.2.0", "@types/express": "^4.17.21", "@types/jest": "^29.5.11", + "@types/lodash": "^4.14.202", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^6.18.1", "@typescript-eslint/parser": "^6.18.1", @@ -1931,15 +1933,6 @@ "@types/node": "*" } }, - "node_modules/@types/compression": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz", - "integrity": "sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==", - "dev": true, - "dependencies": { - "@types/express": "*" - } - }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2053,6 +2046,12 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.14.202", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", + "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==", + "dev": true + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -3242,47 +3241,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", - "dependencies": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", - "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/compression/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4613,6 +4571,11 @@ "node": ">= 0.8" } }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==" + }, "node_modules/human-signals": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", @@ -6033,6 +5996,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -6439,14 +6407,6 @@ "node": ">= 0.8" } }, - "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8440,6 +8400,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index e6e0acc..4fe2622 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,12 @@ "description": "An Express.js boilerplate backend", "main": "index.ts", "scripts": { - "pretty": "pino-pretty", - "dev": "tsx watch --clear-screen=false src/index.ts | npm run pretty", - "build": "rimraf build && tsc", - "start": "NODE_ENV=production npm run build && tsx build/index.js | npm run pretty", - "lint": "eslint 'src/**/*.{ts,js}'", - "lint:fix": "eslint --fix --ext .ts,.js src/", - "format": "prettier --write 'src/**/*.{ts,js,json}'", - "check-format": "prettier --check 'src/**/*.{ts,js,json}'", "ts:check": "tsc --noEmit", + "dev": "tsx watch --clear-screen=false src/index.ts | pino-pretty", + "build": "rimraf build && tsc", + "start": "NODE_ENV=production npm run build && tsx build/src/index.js", + "lint": "eslint --fix --ext .ts,.js src/ tests/", + "format": "prettier --write 'src/**/*.{ts,js,json}' 'tests/**/*.{ts,js,json}'", "test": "jest", "prepare": "husky install", "docker:build": "docker-compose build --no-cache", @@ -20,22 +17,24 @@ "docker:stop": "docker-compose down" }, "dependencies": { - "compression": "^1.7.4", "cors": "^2.8.5", "crypto": "^1.0.1", "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", - "pino-http": "^9.0.0" + "http-status-codes": "^2.3.0", + "lodash": "^4.17.21", + "pino-http": "^9.0.0", + "zod": "^3.22.4" }, "devDependencies": { "@tsconfig/node-lts-strictest-esm": "^18.12.1", - "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/dotenv": "^8.2.0", "@types/express": "^4.17.21", "@types/jest": "^29.5.11", + "@types/lodash": "^4.14.202", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^6.18.1", "@typescript-eslint/parser": "^6.18.1", @@ -56,9 +55,8 @@ }, "lint-staged": { "**/*.{ts,js}": [ - "npm run lint:fix", - "npm run format", - "git add" + "npm run lint", + "npm run format" ], "**/*.{ts,js,json,css,md}": "npm run format" }, diff --git a/src/common/middleware/compressFilter.ts b/src/common/middleware/compressFilter.ts deleted file mode 100644 index ef8af41..0000000 --- a/src/common/middleware/compressFilter.ts +++ /dev/null @@ -1,14 +0,0 @@ -import compression from 'compression'; -import type { Request, Response } from 'express'; - -const compressFilter = (req: Request, res: Response): boolean => { - if (req.headers['x-no-compression']) { - // don't compress responses with this request header - return false; - } - - // fallback to standard filter function - return compression.filter(req, res); -}; - -export default compressFilter; diff --git a/src/common/middleware/errorHandler.ts b/src/common/middleware/errorHandler.ts index 3da0b6b..40fd33e 100644 --- a/src/common/middleware/errorHandler.ts +++ b/src/common/middleware/errorHandler.ts @@ -1,7 +1,8 @@ import { ErrorRequestHandler, RequestHandler } from 'express'; +import { StatusCodes } from 'http-status-codes'; const unexpectedRequest: RequestHandler = (_req, res) => { - res.sendStatus(404); + res.sendStatus(StatusCodes.NOT_FOUND); }; const addErrorToRequestLog: ErrorRequestHandler = (err, _req, res, next) => { @@ -10,7 +11,7 @@ const addErrorToRequestLog: ErrorRequestHandler = (err, _req, res, next) => { }; const defaultErrorRequestHandler: ErrorRequestHandler = (_err, _req, res) => { - res.sendStatus(500); + res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR); }; export default () => [unexpectedRequest, addErrorToRequestLog, defaultErrorRequestHandler]; diff --git a/src/common/models/serviceResponse.ts b/src/common/models/serviceResponse.ts index b978e3d..1670f34 100644 --- a/src/common/models/serviceResponse.ts +++ b/src/common/models/serviceResponse.ts @@ -1,13 +1,20 @@ -export class ServiceResponse { +import _ from 'lodash'; + +export enum ResponseStatus { + Success, + Failed, +} + +export class ServiceResponse { success: boolean; message: string; - responseObject: T | null; - errors?: unknown; + responseObject: T; + statusCode: number; - constructor(success: boolean, message: string, responseObject: T | null, errors?: unknown) { - this.success = success; + constructor(success: ResponseStatus, message: string, responseObject: T, statusCode: number) { + this.success = _.isEqual(success, ResponseStatus.Success); this.message = message; this.responseObject = responseObject; - this.errors = errors; + this.statusCode = statusCode; } } diff --git a/src/common/utils/commonValidation.ts b/src/common/utils/commonValidation.ts new file mode 100644 index 0000000..5c90223 --- /dev/null +++ b/src/common/utils/commonValidation.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const commonValidations = { + id: z.string().regex(/^\d+$/, 'ID must be a number').transform(Number), + // ... other common validations +}; diff --git a/src/common/utils/envConfig.ts b/src/common/utils/envConfig.ts index df45b89..6277061 100644 --- a/src/common/utils/envConfig.ts +++ b/src/common/utils/envConfig.ts @@ -2,7 +2,7 @@ export const getPort = () => getEnvVar('PORT', 'number'); export const getNodeEnv = () => getEnvVar('NODE_ENV', 'string'); export const getCorsOrigin = () => getEnvVar('CORS_ORIGIN', 'string'); -function getEnvVar(key: string, type: 'string' | 'number'): T { +export function getEnvVar(key: string, type: 'string' | 'number'): T { const value = process.env[key]; if (value == null) { throw new Error(`Unknown process.env.${key}: ${value}. Is your .env file setup?`); diff --git a/src/common/utils/httpHandlers.ts b/src/common/utils/httpHandlers.ts new file mode 100644 index 0000000..3150d81 --- /dev/null +++ b/src/common/utils/httpHandlers.ts @@ -0,0 +1,20 @@ +import { NextFunction, Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { ZodError, ZodSchema } from 'zod'; + +import { ResponseStatus, ServiceResponse } from '@common/models/serviceResponse'; + +export const handleServiceResponse = (serviceResponse: ServiceResponse, response: Response) => { + return response.status(serviceResponse.statusCode).send(serviceResponse); +}; + +export const validateRequest = (schema: ZodSchema) => (req: Request, res: Response, next: NextFunction) => { + try { + schema.parse({ body: req.body, query: req.query, params: req.params }); + next(); + } catch (err) { + const errorMessage = `Invalid input: ${(err as ZodError).errors.map((e) => e.message).join(' ')}`; + const statusCode = StatusCodes.BAD_REQUEST; + res.status(statusCode).send(new ServiceResponse(ResponseStatus.Failed, errorMessage, null, statusCode)); + } +}; diff --git a/src/common/utils/responseHandler.ts b/src/common/utils/responseHandler.ts deleted file mode 100644 index 753fe2e..0000000 --- a/src/common/utils/responseHandler.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Response } from 'express'; - -import { ServiceResponse } from '@common/models/serviceResponse'; - -export const handleServiceResponse = (serviceResponse: ServiceResponse, response: Response) => { - if (!serviceResponse.success) { - return response.status(500).send(serviceResponse); - } - return response.status(200).send(serviceResponse); -}; diff --git a/src/modules/healthCheck/healthCheckRoutes.ts b/src/modules/healthCheck/healthCheckRoutes.ts index 7f49e69..57e1374 100644 --- a/src/modules/healthCheck/healthCheckRoutes.ts +++ b/src/modules/healthCheck/healthCheckRoutes.ts @@ -1,13 +1,14 @@ import express, { Request, Response, Router } from 'express'; +import { StatusCodes } from 'http-status-codes'; -import { ServiceResponse } from '@common/models/serviceResponse'; -import { handleServiceResponse } from '@common/utils/responseHandler'; +import { ResponseStatus, ServiceResponse } from '@common/models/serviceResponse'; +import { handleServiceResponse } from '@common/utils/httpHandlers'; export const healthCheckRouter: Router = (() => { const router = express.Router(); router.get('/', (_req: Request, res: Response) => { - const serviceResponse = new ServiceResponse(true, 'Service is healthy.', null); + const serviceResponse = new ServiceResponse(ResponseStatus.Success, 'Service is healthy', null, StatusCodes.OK); handleServiceResponse(serviceResponse, res); }); diff --git a/src/modules/user/tests/userRoutes.test.ts b/src/modules/user/tests/userRoutes.test.ts deleted file mode 100644 index 1402642..0000000 --- a/src/modules/user/tests/userRoutes.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import request from 'supertest'; - -import { ServiceResponse } from '@common/models/serviceResponse'; -import { User } from '@modules/user/userModel'; -import { app } from '@src/server'; - -describe('User API endpoints', () => { - it('GET /users - success', async () => { - const response = await request(app).get('/users'); - const result: ServiceResponse = response.body; - - expect(response.statusCode).toEqual(200); - expect(result.success).toBeTruthy(); - expect(result.responseObject).toBeInstanceOf(Array); - - if (result.responseObject && result.responseObject.length > 0) { - expect(result.responseObject[0]).toHaveProperty('id'); - expect(result.responseObject[0]).toHaveProperty('name'); - } - }); - - it('GET /users/:id - success', async () => { - const response = await request(app).get('/users/1'); - const result: ServiceResponse = response.body; - - expect(response.statusCode).toEqual(200); - expect(result.success).toBeTruthy(); - expect(result.responseObject).toHaveProperty('id', 1); - }); -}); diff --git a/src/modules/user/userModel.ts b/src/modules/user/userModel.ts index 631361e..5b554d9 100644 --- a/src/modules/user/userModel.ts +++ b/src/modules/user/userModel.ts @@ -1,8 +1,18 @@ -export type User = { - id: number; - name: string; - email: string; - age: number; - createdAt: Date; - updatedAt: Date; -}; +import { z } from 'zod'; + +import { commonValidations } from '@common/utils/commonValidation'; + +export type User = z.infer; +export const UserSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().email(), + age: z.number(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +// Schema for 'GET users/:id' endpoint +export const GetUserSchema = z.object({ + params: z.object({ id: commonValidations.id }), +}); diff --git a/src/modules/user/userRepository.ts b/src/modules/user/userRepository.ts index 69be143..24ac5af 100644 --- a/src/modules/user/userRepository.ts +++ b/src/modules/user/userRepository.ts @@ -1,16 +1,16 @@ import { User } from '@modules/user/userModel'; -const users: User[] = [ +export const users: User[] = [ { id: 1, name: 'Alice', email: 'alice@example.com', age: 42, createdAt: new Date(), updatedAt: new Date() }, { id: 2, name: 'Bob', email: 'bob@example.com', age: 21, createdAt: new Date(), updatedAt: new Date() }, ]; export const userRepository = { - findByIdAsync: async (id: number): Promise => { - return users.find((user) => user.id === id) || null; + findAllAsync: async (): Promise => { + return users; }, - findAllAsync: async (): Promise => { - return users || []; + findByIdAsync: async (id: number): Promise => { + return users.find((user) => user.id === id) || null; }, }; diff --git a/src/modules/user/userRoutes.ts b/src/modules/user/userRoutes.ts index a59c8d4..97abd4f 100644 --- a/src/modules/user/userRoutes.ts +++ b/src/modules/user/userRoutes.ts @@ -1,8 +1,10 @@ import express, { Request, Response, Router } from 'express'; -import { handleServiceResponse } from '@common/utils/responseHandler'; +import { handleServiceResponse, validateRequest } from '@common/utils/httpHandlers'; import { userService } from '@modules/user/userService'; +import { GetUserSchema } from './userModel'; + export const userRouter: Router = (() => { const router = express.Router(); @@ -11,7 +13,7 @@ export const userRouter: Router = (() => { handleServiceResponse(serviceResponse, res); }); - router.get('/:id', async (req: Request, res: Response) => { + router.get('/:id', validateRequest(GetUserSchema), async (req: Request, res: Response) => { const id = parseInt(req.params.id as string, 10); const serviceResponse = await userService.findById(id); handleServiceResponse(serviceResponse, res); diff --git a/src/modules/user/userService.ts b/src/modules/user/userService.ts index 12177e1..2b58a82 100644 --- a/src/modules/user/userService.ts +++ b/src/modules/user/userService.ts @@ -1,4 +1,6 @@ -import { ServiceResponse } from '@common/models/serviceResponse'; +import { StatusCodes } from 'http-status-codes'; + +import { ResponseStatus, ServiceResponse } from '@common/models/serviceResponse'; import { User } from '@modules/user/userModel'; import { userRepository } from '@modules/user/userRepository'; import { logger } from '@src/server'; @@ -8,10 +10,14 @@ export const userService = { findAll: async (): Promise> => { try { const users = await userRepository.findAllAsync(); - return new ServiceResponse(true, 'Users found.', users); + if (!users) { + return new ServiceResponse(ResponseStatus.Failed, 'No Users found', null, StatusCodes.NOT_FOUND); + } + return new ServiceResponse(ResponseStatus.Success, 'Users found', users, StatusCodes.OK); } catch (ex) { - logger.error(ex); - return new ServiceResponse(false, 'Error finding all users.', [], ex); + const errorMessage = `Error finding all users: $${(ex as Error).message}`; + logger.error(errorMessage); + return new ServiceResponse(ResponseStatus.Failed, errorMessage, null, StatusCodes.INTERNAL_SERVER_ERROR); } }, @@ -20,12 +26,13 @@ export const userService = { try { const user = await userRepository.findByIdAsync(id); if (!user) { - return new ServiceResponse(false, 'User not found.', null); + return new ServiceResponse(ResponseStatus.Failed, 'User not found', null, StatusCodes.NOT_FOUND); } - return new ServiceResponse(true, 'User found.', user); + return new ServiceResponse(ResponseStatus.Success, 'User found', user, StatusCodes.OK); } catch (ex) { - logger.error(`Error finding user with id ${id}:`, ex); - return new ServiceResponse(false, 'Error finding user.', null, ex); + const errorMessage = `Error finding user with id ${id}:, ${(ex as Error).message}`; + logger.error(errorMessage); + return new ServiceResponse(ResponseStatus.Failed, errorMessage, null, StatusCodes.INTERNAL_SERVER_ERROR); } }, }; diff --git a/src/server.ts b/src/server.ts index aee5bd8..b151a71 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,3 @@ -import compression from 'compression'; import cors from 'cors'; import dotenv from 'dotenv'; import express, { Express } from 'express'; @@ -6,7 +5,6 @@ import helmet from 'helmet'; import path from 'path'; import { pino } from 'pino'; -import compressFilter from '@common/middleware/compressFilter'; import errorHandler from '@common/middleware/errorHandler'; import rateLimiter from '@common/middleware/rateLimiter'; import requestLogger from '@common/middleware/requestLogger'; @@ -25,7 +23,6 @@ const corsOrigin = getCorsOrigin(); // Middlewares app.use(cors({ origin: [corsOrigin], credentials: true })); app.use(helmet()); -app.use(compression({ filter: compressFilter })); app.use(rateLimiter); // Request logging diff --git a/tests/common/middleware/errorHandler.test.ts b/tests/common/middleware/errorHandler.test.ts new file mode 100644 index 0000000..7bb8734 --- /dev/null +++ b/tests/common/middleware/errorHandler.test.ts @@ -0,0 +1,27 @@ +import express from 'express'; +import { StatusCodes } from 'http-status-codes'; +import request from 'supertest'; + +import errorHandler from '@common/middleware/errorHandler'; + +describe('Error Handler Middleware', () => { + const app = express(); + + // Setup a route that throws an error + app.get('/error', () => { + throw new Error('Test error'); + }); + + // Use your error handler middleware + app.use(errorHandler()); + + it('should return 404 for unknown routes', async () => { + const response = await request(app).get('/unknown'); + expect(response.status).toBe(StatusCodes.NOT_FOUND); + }); + + it('should handle thrown errors with 500 status code', async () => { + const response = await request(app).get('/error'); + expect(response.status).toBe(StatusCodes.INTERNAL_SERVER_ERROR); + }); +}); diff --git a/tests/common/utils/envConfig.test.ts b/tests/common/utils/envConfig.test.ts new file mode 100644 index 0000000..7ed9342 --- /dev/null +++ b/tests/common/utils/envConfig.test.ts @@ -0,0 +1,27 @@ +import { getEnvVar } from '@common/utils/envConfig'; + +describe('Environment Configuration', () => { + afterEach(() => { + jest.resetModules(); + }); + + it('should correctly get a string environment variable', () => { + process.env.TEST_VAR = 'test'; + expect(getEnvVar('TEST_VAR', 'string')).toBe('test'); + }); + + it('should correctly get a number environment variable', () => { + process.env.TEST_VAR = '3000'; + expect(getEnvVar('TEST_VAR', 'number')).toBe(3000); + }); + + it('should throw an error for missing environment variable', () => { + delete process.env.TEST_VAR; + expect(() => getEnvVar('TEST_VAR', 'string')).toThrow(); + }); + + it('should throw an error for invalid number environment variable', () => { + process.env.TEST_VAR = 'invalid'; + expect(() => getEnvVar('TEST_VAR', 'number')).toThrow(); + }); +}); diff --git a/src/modules/healthCheck/tests/healthCheckRoutes.test.ts b/tests/modules/healthCheck/healthCheckRoutes.test.ts similarity index 78% rename from src/modules/healthCheck/tests/healthCheckRoutes.test.ts rename to tests/modules/healthCheck/healthCheckRoutes.test.ts index 9b57bb4..4fe9b42 100644 --- a/src/modules/healthCheck/tests/healthCheckRoutes.test.ts +++ b/tests/modules/healthCheck/healthCheckRoutes.test.ts @@ -6,11 +6,11 @@ import { app } from '@src/server'; describe('Health Check API endpoints', () => { it('GET / - success', async () => { const response = await request(app).get('/health-check'); - const result: ServiceResponse = response.body; + const result: ServiceResponse = response.body; expect(response.statusCode).toEqual(200); expect(result.success).toBeTruthy(); expect(result.responseObject).toBeNull(); - expect(result.message).toEqual('Service is healthy.'); + expect(result.message).toEqual('Service is healthy'); }); }); diff --git a/tests/modules/user/userRoutes.test.ts b/tests/modules/user/userRoutes.test.ts new file mode 100644 index 0000000..fd9e133 --- /dev/null +++ b/tests/modules/user/userRoutes.test.ts @@ -0,0 +1,84 @@ +import { StatusCodes } from 'http-status-codes'; +import request from 'supertest'; + +import { ServiceResponse } from '@common/models/serviceResponse'; +import { User } from '@modules/user/userModel'; +import { users } from '@modules/user/userRepository'; +import { app } from '@src/server'; + +describe('User API Endpoints', () => { + describe('GET /users', () => { + it('should return a list of users', async () => { + // Act + const response = await request(app).get('/users'); + const responseBody: ServiceResponse = response.body; + + // Assert + expect(response.statusCode).toEqual(StatusCodes.OK); + expect(responseBody.success).toBeTruthy(); + expect(responseBody.message).toContain('Users found'); + expect(responseBody.responseObject.length).toEqual(users.length); + responseBody.responseObject.forEach((user, index) => compareUsers(users[index] as User, user)); + }); + }); + + describe('GET /users/:id', () => { + it('should return a user for a valid ID', async () => { + // Arrange + const testId = 1; + const expectedUser = users.find((user) => user.id === testId); + + // Act + const response = await request(app).get(`/users/${testId}`); + const responseBody: ServiceResponse = response.body; + + // Assert + expect(response.statusCode).toEqual(StatusCodes.OK); + expect(responseBody.success).toBeTruthy(); + expect(responseBody.message).toContain('User found'); + if (!expectedUser) fail('Expected user not found in test data'); + compareUsers(expectedUser, responseBody.responseObject); + }); + + it('should return a not found error for non-existent ID', async () => { + // Arrange + const testId = Number.MAX_SAFE_INTEGER; + + // Act + const response = await request(app).get(`/users/${testId}`); + const responseBody: ServiceResponse = response.body; + + // Assert + expect(response.statusCode).toEqual(StatusCodes.NOT_FOUND); + expect(responseBody.success).toBeFalsy(); + expect(responseBody.message).toContain('User not found'); + expect(responseBody.responseObject).toEqual(null); + }); + + it('should return a bad request for invalid ID format', async () => { + // Act + const invalidInput = 'abc'; + const response = await request(app).get(`/users/${invalidInput}`); + const responseBody: ServiceResponse = response.body; + + // Assert + expect(response.statusCode).toEqual(StatusCodes.BAD_REQUEST); + expect(responseBody.success).toBeFalsy(); + expect(responseBody.message).toContain('Invalid input'); + expect(responseBody.responseObject).toEqual(null); + }); + }); +}); + +function compareUsers(mockUser: User, responseUser: User) { + if (!mockUser || !responseUser) { + fail('Invalid test data: mockUser or responseUser is undefined'); + } + + expect(responseUser.id).toEqual(mockUser.id); + expect(responseUser.name).toEqual(mockUser.name); + expect(responseUser.email).toEqual(mockUser.email); + expect(responseUser.age).toEqual(mockUser.age); + expect(new Date(responseUser.createdAt)).toEqual(mockUser.createdAt); + expect(new Date(responseUser.updatedAt)).toEqual(mockUser.updatedAt); +} diff --git a/tests/modules/user/userService.test.ts b/tests/modules/user/userService.test.ts new file mode 100644 index 0000000..420670e --- /dev/null +++ b/tests/modules/user/userService.test.ts @@ -0,0 +1,110 @@ +import { StatusCodes } from 'http-status-codes'; + +import { User } from '@modules/user/userModel'; +import { userRepository } from '@modules/user/userRepository'; +import { userService } from '@modules/user/userService'; + +jest.mock('@modules/user/userRepository'); + +describe('userService', () => { + const mockUsers: User[] = [ + { id: 1, name: 'Alice', email: 'alice@example.com', age: 42, createdAt: new Date(), updatedAt: new Date() }, + { id: 2, name: 'Bob', email: 'bob@example.com', age: 21, createdAt: new Date(), updatedAt: new Date() }, + ]; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('findAll', () => { + it('return all users', async () => { + // Arrange + (userRepository.findAllAsync as jest.Mock).mockReturnValue(mockUsers); + + // Act + const result = await userService.findAll(); + + // Assert + expect(result.statusCode).toEqual(StatusCodes.OK); + expect(result.success).toBeTruthy(); + expect(result.message).toContain('Users found'); + expect(result.responseObject).toEqual(mockUsers); + }); + + it('returns a not found error for no users found', async () => { + // Arrange + (userRepository.findAllAsync as jest.Mock).mockReturnValue(null); + + // Act + const result = await userService.findAll(); + + // Assert + expect(result.statusCode).toEqual(StatusCodes.NOT_FOUND); + expect(result.success).toBeFalsy(); + expect(result.message).toContain('No Users found'); + expect(result.responseObject).toEqual(null); + }); + + it('handles errors for findAllAsync', async () => { + // Arrange + (userRepository.findAllAsync as jest.Mock).mockRejectedValue(new Error('Database error')); + + // Act + const result = await userService.findAll(); + + // Assert + expect(result.statusCode).toEqual(StatusCodes.INTERNAL_SERVER_ERROR); + expect(result.success).toBeFalsy(); + expect(result.message).toContain('Error finding all users'); + expect(result.responseObject).toEqual(null); + }); + }); + + describe('findById', () => { + it('returns a user for a valid ID', async () => { + // Arrange + const testId = 1; + const mockUser = mockUsers.find((user) => user.id === testId); + (userRepository.findByIdAsync as jest.Mock).mockReturnValue(mockUser); + + // Act + const result = await userService.findById(testId); + + // Assert + expect(result.statusCode).toEqual(StatusCodes.OK); + expect(result.success).toBeTruthy(); + expect(result.message).toContain('User found'); + expect(result.responseObject).toEqual(mockUser); + }); + + it('handles errors for findByIdAsync', async () => { + // Arrange + const testId = 1; + (userRepository.findByIdAsync as jest.Mock).mockRejectedValue(new Error('Database error')); + + // Act + const result = await userService.findById(testId); + + // Assert + expect(result.statusCode).toEqual(StatusCodes.INTERNAL_SERVER_ERROR); + expect(result.success).toBeFalsy(); + expect(result.message).toContain(`Error finding user with id ${testId}`); + expect(result.responseObject).toEqual(null); + }); + + it('returns a not found error for non-existent ID', async () => { + // Arrange + const testId = 1; + (userRepository.findByIdAsync as jest.Mock).mockReturnValue(null); + + // Act + const result = await userService.findById(testId); + + // Assert + expect(result.statusCode).toEqual(StatusCodes.NOT_FOUND); + expect(result.success).toBeFalsy(); + expect(result.message).toContain('User not found'); + expect(result.responseObject).toEqual(null); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 83a2753..2497be7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,6 @@ "target": "ES6", "module": "CommonJS", "baseUrl": ".", - "rootDir": "./src", "paths": { "@modules/*": ["./src/modules/*"], "@common/*": ["./src/common/*"], @@ -21,5 +20,5 @@ }, "extends": "@tsconfig/node-lts-strictest-esm/tsconfig.json", "exclude": ["node_modules"], - "include": ["src/**/*.ts", "./src/**/*.d.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts"] }