diff --git a/.env.example b/.env.example index 0a48e9f..3dff852 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,20 @@ PORT= ******************************** APP_ENV= ******************************** -PDN_DB_NAME= ***************************** + +TEST_DB_HOST= ******************************** +TEST_DB_PORT= ******************************** +TEST_DB_USER= ******************************** +TEST_DB_PASS= ******************************** +TEST_DB_NAME= ******************************** + DEV_DB_HOST= ******************************** DEV_DB_PORT= ******************************** DEV_DB_USER= ******************************** DEV_DB_PASS= ***************************** -DEV_DB_TYPE= ******************************* +DEV_DB_NAME= ******************************* PDN_DB_HOST= ******************************** PDN_DB_PORT= ******************************** PDN_DB_USER= ******************************** -PDN_DB_PASS= ******************************** \ No newline at end of file +PDN_DB_PASS= ******************************** +PDN_DB_NAME= ***************************** \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 7d487c3..2607339 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,10 +3,7 @@ module.exports = { root: true, parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended' - ], + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': [ @@ -28,8 +25,14 @@ module.exports = { 'space-before-function-paren': ['warn', 'always'], 'func-style': ['warn', 'declaration', { allowArrowFunctions: true }], 'camelcase': 'warn', - '@typescript-eslint/explicit-function-return-type': ['warn', { allowExpressions: true }], - '@typescript-eslint/explicit-member-accessibility': ['off', { accessibility: 'explicit' }], + '@typescript-eslint/explicit-function-return-type': [ + 'warn', + { allowExpressions: true }, + ], + '@typescript-eslint/explicit-member-accessibility': [ + 'off', + { accessibility: 'explicit' }, + ], 'no-unused-vars': 'warn', 'no-extra-semi': 'warn', }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99db3dd..e3b23d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,11 +3,11 @@ name: knights-ecomm-be CI on: [push, pull_request] env: - DEV_DB_HOST: ${{secrets.DEV_DB_HOST}} - DEV_DB_PORT: ${{secrets.DEV_DB_PORT}} - DEV_DB_USER: ${{secrets.DEV_DB_USER}} - DEV_DB_PASS: ${{secrets.DEV_DB_PASS}} - DEV_DB_NAME: ${{secrets.DEV_DB_NAME}} + TEST_DB_HOST: ${{secrets.TEST_DB_HOST}} + TEST_DB_PORT: ${{secrets.TEST_DB_PORT}} + TEST_DB_USER: ${{secrets.TEST_DB_USER}} + TEST_DB_PASS: ${{secrets.TEST_DB_PASS}} + TEST_DB_NAME: ${{secrets.TEST_DB_NAME}} jobs: build-lint-test-coverage: diff --git a/README.md b/README.md index 782f032..79c9d64 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # E-commerse Backend API -[![knights-ecomm-be CI](https://github.com/atlp-rwanda/knights-ecomm-be/actions/workflows/ci.yml/badge.svg)](https://github.com/atlp-rwanda/knights-ecomm-be/actions/workflows/ci.yml)    [![Coverage Status](https://coveralls.io/repos/github/atlp-rwanda/knights-ecomm-be/badge.svg?branch=ch-ci-setup)](https://coveralls.io/github/atlp-rwanda/knights-ecomm-be?branch=ch-ci-setup)    [![Version](https://img.shields.io/badge/version-1.0.0-blue)](https://github.com/your-username/your-repo-name/releases/tag/v1.0.0) + +[![knights-ecomm-be CI](https://github.com/atlp-rwanda/knights-ecomm-be/actions/workflows/ci.yml/badge.svg)](https://github.com/atlp-rwanda/knights-ecomm-be/actions/workflows/ci.yml) +   +[![Coverage Status](https://coveralls.io/repos/github/atlp-rwanda/knights-ecomm-be/badge.svg?branch=ch-ci-setup)](https://coveralls.io/github/atlp-rwanda/knights-ecomm-be?branch=ch-ci-setup) +   +[![Version](https://img.shields.io/badge/version-1.0.0-blue)](https://github.com/your-username/your-repo-name/releases/tag/v1.0.0) ## Description diff --git a/migrations/1714595134552-UserMigration.ts b/migrations/1714595134552-UserMigration.ts index 05693f8..7eb7953 100644 --- a/migrations/1714595134552-UserMigration.ts +++ b/migrations/1714595134552-UserMigration.ts @@ -1,9 +1,8 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUserMigration1614495123940 implements MigrationInterface { - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` + public async up (queryRunner: QueryRunner): Promise { + await queryRunner.query(` CREATE TABLE "user" ( "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "firstName" character varying NOT NULL, @@ -22,10 +21,9 @@ export class CreateUserMigration1614495123940 implements MigrationInterface { CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id") ) `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "user"`); - } + } -} \ No newline at end of file + public async down (queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "user"`); + } +} diff --git a/ormconfig.js b/ormconfig.js index a4d78a5..bc7acdf 100644 --- a/ormconfig.js +++ b/ormconfig.js @@ -1,24 +1,39 @@ -module.exports = { - "type": "postgres", - "host": `${process.env.DEV_DB_HOST}`, - "port": `${process.env.DEV_DB_PORT}`, - "username": `${process.env.DEV_DB_USER}`, - "password": `${process.env.DEV_DB_PASS}`, - "database": `${process.env.DEV_DB_NAME}`, - "synchronize": true, - "logging": false, - "entities": [ - "src/entities/**/*.ts" - ], - "migrations": [ - "src/migrations/**/*.ts" - ], - "subscribers": [ - "src/subscribers/**/*.ts" - ], - "cli": { - "entitiesDir": "src/entities", - "migrationsDir": "src/migrations", - "subscribersDir": "src/subscribers" - } -}; \ No newline at end of file +const devConfig = { + type: 'postgres', + host: process.env.DEV_DB_HOST, + port: process.env.DEV_DB_PORT, + username: process.env.DEV_DB_USER, + password: process.env.DEV_DB_PASS, + database: process.env.DEV_DB_NAME, + synchronize: true, + logging: false, + entities: ['src/entities/**/*.ts'], + migrations: ['src/migrations/**/*.ts'], + subscribers: ['src/subscribers/**/*.ts'], + cli: { + entitiesDir: 'src/entities', + migrationsDir: 'src/migrations', + subscribersDir: 'src/subscribers', + }, +}; + +const testConfig = { + type: 'postgres', + host: process.env.TEST_DB_HOST, + port: process.env.TEST_DB_PORT, + username: process.env.TEST_DB_USER, + password: process.env.TEST_DB_PASS, + database: process.env.TEST_DB_NAME, + synchronize: true, + logging: false, + entities: ['src/entities/**/*.ts'], + migrations: ['src/migrations/**/*.ts'], + subscribers: ['src/subscribers/**/*.ts'], + cli: { + entitiesDir: 'src/entities', + migrationsDir: 'src/migrations', + subscribersDir: 'src/subscribers', + }, +}; + +module.exports = process.env.NODE_ENV === 'test' ? testConfig : devConfig; diff --git a/package.json b/package.json index 84a5b11..df34cc1 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "E-commerce backend", "main": "index.js", "scripts": { - "test": "jest --coverage --detectOpenHandles --verbose --runInBand", - "dev": "nodemon src/index.ts", + "test": "cross-env APP_ENV=test jest --coverage --detectOpenHandles --verbose --runInBand ", + "dev": "cross-env APP_ENV=dev nodemon src/index.ts", "build": "tsc -p .", "start": "node dist/index.js", "lint": "eslint .", @@ -24,6 +24,7 @@ "bcrypt": "^5.1.1", "class-validator": "^0.14.1", "cors": "^2.8.5", + "cross-env": "^7.0.3", "dotenv": "^16.4.5", "express": "^4.19.2", "express-winston": "^4.2.0", @@ -75,4 +76,4 @@ "typescript": "^5.4.5", "typescript-eslint": "^7.7.1" } -} \ No newline at end of file +} diff --git a/src/__test__/route.test.ts b/src/__test__/route.test.ts index 6b296a3..6505d02 100644 --- a/src/__test__/route.test.ts +++ b/src/__test__/route.test.ts @@ -1,21 +1,27 @@ import request from 'supertest'; -import { app, server } from '../index'; // update this with the path to your app file +import { app, server } from '../index'; import { createConnection, getConnection, getConnectionOptions } from 'typeorm'; import { User } from '../entities/User'; -import { getRepository, Repository } from 'typeorm'; beforeAll(async () => { // Connect to the test database const connectionOptions = await getConnectionOptions(); + await createConnection({ ...connectionOptions, name: 'testConnection' }); }); afterAll(async () => { - await getConnection('testConnection').close(); - server.close(); -}); + const connection = getConnection('testConnection'); + const userRepository = connection.getRepository(User); + + // Delete all records from the User + await userRepository.clear(); + // Close the connection to the test database + await connection.close(); + server.close(); +}); describe('GET /', () => { it('This is a testing route that returns', done => { @@ -23,17 +29,20 @@ describe('GET /', () => { .get('/api/v1/status') .expect(200) .expect('Content-Type', /json/) - .expect({ - status: 'success', - data: { - code: 202, - message: 'This is a testing route that returns: 202' - } - }, done); + .expect( + { + status: 'success', + data: { + code: 200, + message: 'This is a testing route.', + }, + }, + done + ); }); }); describe('POST /user/register', () => { - it('should register a new user and then delete it', async () => { + it('should register a new user', async () => { // Arrange const newUser = { firstName: 'John', @@ -43,25 +52,20 @@ describe('POST /user/register', () => { gender: 'Male', phoneNumber: '1234567890', userType: 'Buyer', - status: 'active', - verified: true, photoUrl: 'https://example.com/photo.jpg', }; // Act - const res = await request(app) - .post('/user/register') - .send(newUser); + const res = await request(app).post('/user/register').send(newUser); // Assert expect(res.status).toBe(201); - expect(res.body).toEqual({ message: 'User registered successfully' }); - - // Clean up: delete the test user - const userRepository = getRepository(User); - const user = await userRepository.findOne({ where: { email: newUser.email } }); - if (user) { - await userRepository.remove(user); - } + expect(res.body).toEqual({ + status: 'success', + data: { + code: 201, + message: 'User registered successfully', + }, + }); }); -}); \ No newline at end of file +}); diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts index 209a350..6879bc0 100644 --- a/src/controllers/authController.ts +++ b/src/controllers/authController.ts @@ -1,49 +1,55 @@ import { Request, Response } from 'express'; -import { User } from '../entities/User';; +import { User } from '../entities/User'; import bcrypt from 'bcrypt'; import { getRepository } from 'typeorm'; - +import { responseError, responseServerError, responseSuccess } from '../utils/response.utils'; +import { validate } from 'class-validator'; class UserController { - static registerUser = async (req: Request, res: Response) => { - const { firstName, lastName, email, password, gender, phoneNumber, userType, status, verified, photoUrl } = req.body; - - // Validate user input - if (!(firstName && lastName && email && password && gender && phoneNumber && verified && photoUrl)) { - return res.status(400).json({ error: 'Please fill all the fields' }); - } - - const userRepository = getRepository(User); - - - // Check for existing user - const existingUser = await userRepository.findOneBy({ email }); - const existingUserNumber = await userRepository.findOneBy({ phoneNumber }); - - if (existingUser || existingUserNumber) { - return res.status(400).json({ error: 'Email or phone number already in use' }); - } - - const saltRounds = 10; - const hashedPassword = await bcrypt.hash(password, saltRounds); - - // Create user - const user = new User(); - user.firstName = firstName; - user.lastName = lastName; - user.email = email; - user.password = hashedPassword; - user.userType = userType; - user.gender = gender; - user.phoneNumber = phoneNumber; - user.photoUrl = photoUrl; - user.status = status ? status : 'active'; - user.verified = verified; - - // Save user - await userRepository.save(user); - - return res.status(201).json({ message: 'User registered successfully' }); - }; + static registerUser = async (req: Request, res: Response) => { + const { firstName, lastName, email, password, gender, phoneNumber, userType, photoUrl } = req.body; + + // Validate user input + if (!firstName || !lastName || !email || !password || !gender || !phoneNumber || !photoUrl) { + return responseError(res, 400, 'Please fill all the required fields'); + } + + const userRepository = getRepository(User); + + try { + // Check for existing user + const existingUser = await userRepository.findOneBy({ email }); + const existingUserNumber = await userRepository.findOneBy({ phoneNumber }); + + if (existingUser || existingUserNumber) { + return responseError(res, 409, 'Email or phone number already in use'); + } + + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + + // Create user + const user = new User(); + user.firstName = firstName; + user.lastName = lastName; + user.email = email; + user.password = hashedPassword; + user.userType = userType; + user.gender = gender; + user.phoneNumber = phoneNumber; + user.photoUrl = photoUrl; + + // Save user + await userRepository.save(user); + + return responseSuccess(res, 201, 'User registered successfully'); + } catch (error) { + if (error instanceof Error) { + return responseServerError(res, error.message); + } + + return responseServerError(res, 'Unknown error occurred'); + } + }; } -export { UserController }; \ No newline at end of file +export { UserController }; diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 554dd4e..b581bf7 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,3 +1,3 @@ import { UserController } from './authController'; -export{UserController}; \ No newline at end of file +export { UserController }; diff --git a/src/entities/User.ts b/src/entities/User.ts index 2d0557f..f1be411 100644 --- a/src/entities/User.ts +++ b/src/entities/User.ts @@ -1,69 +1,62 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - Unique, - CreateDateColumn, - UpdateDateColumn, - } from 'typeorm'; - import { IsEmail, IsNotEmpty, IsString, IsBoolean, IsIn } from 'class-validator'; - - @Entity() - @Unique(['email']) - export class User { - @PrimaryGeneratedColumn('uuid') - @IsNotEmpty() - id!: string; - - @Column() - @IsNotEmpty() - @IsString() - firstName!: string; - - @Column() - @IsNotEmpty() - @IsString() - lastName!: string; - - @Column() - @IsNotEmpty() - @IsEmail() - email!: string; - - @Column() - @IsNotEmpty() - password!: string; - - @Column() - @IsNotEmpty() - @IsString() - gender!: string; - - @Column() - @IsNotEmpty() - phoneNumber!: string; - - @Column({ nullable: true }) - photoUrl?: string; - - @Column() - @IsNotEmpty() - @IsBoolean() - verified!: boolean; - - @Column() - @IsNotEmpty() - @IsIn(['active', 'suspended']) - status!: 'active' | 'suspended'; - - @Column({ default: "Buyer" }) - @IsNotEmpty() - @IsIn(['Admin', 'Buyer', 'Vendor']) - userType!: 'Admin' | 'Buyer' | 'Vendor'; - - @CreateDateColumn() - createdAt!: Date; - - @UpdateDateColumn() - updatedAt!: Date; - } \ No newline at end of file +import { Entity, PrimaryGeneratedColumn, Column, Unique, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { IsEmail, IsNotEmpty, IsString, IsBoolean, IsIn } from 'class-validator'; + +@Entity() +@Unique(['email']) +export class User { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @Column() + @IsNotEmpty() + @IsString() + firstName!: string; + + @Column() + @IsNotEmpty() + @IsString() + lastName!: string; + + @Column() + @IsNotEmpty() + @IsEmail() + email!: string; + + @Column() + @IsNotEmpty() + password!: string; + + @Column() + @IsNotEmpty() + @IsString() + gender!: string; + + @Column() + @IsNotEmpty() + phoneNumber!: string; + + @Column({ nullable: true }) + photoUrl?: string; + + @Column({ default: false }) + @IsNotEmpty() + @IsBoolean() + verified!: boolean; + + @Column({ default: 'active' }) + @IsNotEmpty() + @IsIn(['active', 'suspended']) + status!: 'active' | 'suspended'; + + @Column({ default: 'Buyer' }) + @IsNotEmpty() + @IsIn(['Admin', 'Buyer', 'Vendor']) + userType!: 'Admin' | 'Buyer' | 'Vendor'; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/index.ts b/src/index.ts index 55443f6..bb5ad3a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,6 @@ import router from './routes'; import { addDocumentation } from './startups/docs'; import 'reflect-metadata'; - import { CustomError, errorHandler } from './middlewares/errorHandler'; import morgan from 'morgan'; import { dbConnection } from './startups/dbConnection'; @@ -26,6 +25,7 @@ app.all('*', (req: Request, res: Response, next) => { app.use(errorHandler); // Start database connection + dbConnection(); //morgan @@ -34,4 +34,4 @@ app.use(morgan(morganFormat)); export const server = app.listen(port, () => { console.log(`[server]: Server is running at http://localhost:${port}`); -}); \ No newline at end of file +}); diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index 8a24a54..5e25feb 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -12,7 +12,7 @@ class CustomError extends Error { } } -const errorHandler = (err: CustomError, req: Request, res: Response,next: NextFunction) => { +const errorHandler = (err: CustomError, req: Request, res: Response, next: NextFunction) => { err.statusCode = err.statusCode || 500; err.status = err.status || 'error'; res.status(err.statusCode).json({ diff --git a/src/routes/UserRoutes.ts b/src/routes/UserRoutes.ts index 734a565..01ce115 100644 --- a/src/routes/UserRoutes.ts +++ b/src/routes/UserRoutes.ts @@ -1,11 +1,10 @@ -import { Router } from 'express'; +import { Router } from 'express'; import { UserController } from '../controllers/index'; - const { registerUser } = UserController; const router = Router(); router.post('/register', registerUser); -export default router; \ No newline at end of file +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 8d1a53a..1d95c3d 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -5,7 +5,7 @@ import { responseSuccess } from '../utils/response.utils'; const router = Router(); router.get('/api/v1/status', (req: Request, res: Response) => { - return responseSuccess(res, 202, 'This is a testing route that returns: 202'); + return responseSuccess(res, 200, 'This is a testing route.'); }); router.use('/user', userRoutes); diff --git a/src/startups/dbConnection.ts b/src/startups/dbConnection.ts index 7fb387c..44887d8 100644 --- a/src/startups/dbConnection.ts +++ b/src/startups/dbConnection.ts @@ -1,9 +1,10 @@ -import { createConnection } from "typeorm"; +import { createConnection } from 'typeorm'; const dbConnection = async () => { try { const connection = await createConnection(); - console.log('Connected to the database'); + console.log(`Connected to the ${process.env.APP_ENV} database`); + return connection; } catch (error) { console.error('Error connecting to the database:', error); } diff --git a/src/utils/response.utils.ts b/src/utils/response.utils.ts index 6f097a7..3be109b 100644 --- a/src/utils/response.utils.ts +++ b/src/utils/response.utils.ts @@ -13,7 +13,7 @@ export const responseSuccess = ( message: string, data?: any ): Response => { - return res.status(200).json( + return res.status(statusCode).json( jsend.success({ code: statusCode, message, @@ -28,7 +28,7 @@ export const responseError = ( message: string, data?: any ): Response => { - return res.status(400).json( + return res.status(statusCode).json( jsend.error({ code: statusCode, message, diff --git a/tsconfig.json b/tsconfig.json index 6e7652b..88326b4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,8 +12,8 @@ "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ - "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ - "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + "experimentalDecorators": true /* Enable experimental support for legacy experimental decorators. */, + "emitDecoratorMetadata": true /* Emit design-type metadata for decorated declarations in source files. */, // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ @@ -29,7 +29,10 @@ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - "types": ["node", "jest"] /* Specify type package names to be included without being referenced in a source file. */, + "types": [ + "node", + "jest" + ] /* Specify type package names to be included without being referenced in a source file. */, // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */