From 0bdeba3067660e4ef74e7a25f3ee13b91804ebe7 Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Mon, 17 Jul 2023 17:11:21 +0100 Subject: [PATCH 01/15] chore(docs): Add Swagger Docs --- src/app.ts | 24 +++++++++++++++++++++++- src/main.ts | 9 +-------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/app.ts b/src/app.ts index 5cb5645..52c856b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,9 +1,21 @@ -import { INestApplication, RequestMethod } from '@nestjs/common'; +import { + INestApplication, + RequestMethod, + ValidationPipe, +} from '@nestjs/common'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import helmet from 'helmet'; import * as compression from 'compression'; export const appMiddleware = (app: INestApplication) => { + app.useGlobalPipes( + // remove any additional properites not defined in the DTO + new ValidationPipe({ + whitelist: true, + }), + ); + app.setGlobalPrefix('api/v1', { exclude: [{ path: '/', method: RequestMethod.GET }], }); @@ -11,4 +23,14 @@ export const appMiddleware = (app: INestApplication) => { app.enableCors(); app.use(helmet()); app.use(compression()); + + const options = new DocumentBuilder() + .setTitle('Wallet System') + .setDescription('API for a minimalistic wallet system') + .setVersion('1.0') + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, options); + SwaggerModule.setup('/docs', app, document); }; diff --git a/src/main.ts b/src/main.ts index c323d4f..8732882 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { WinstonModule } from 'nest-winston'; import { Logger, INestApplication } from '@nestjs/common'; -import { ValidationPipe } from '@nestjs/common'; + import { appMiddleware } from './app'; import winstonLogger from './utilities/logger'; @@ -14,13 +14,6 @@ import { AppModule } from './app.module'; }), }); - app.useGlobalPipes( - // remove any additional properites not defined in the DTO - new ValidationPipe({ - whitelist: true, - }), - ); - const PORT = +process.env.PORT || 3000; appMiddleware(app); From 6acab0949ddec0c644a45e2e51df377eb82d517a Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Mon, 17 Jul 2023 17:14:58 +0100 Subject: [PATCH 02/15] Add Swagger dependency --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 830ea06..1ae02e5 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@nestjs/mapped-types": "^2.0.2", "@nestjs/passport": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.1.1", "@nestjs/typeorm": "^10.0.0", "@types/compression": "^1.7.2", "@types/express": "^4.17.17", From 0fbbf9617e4f646b498bf14e5ce6dffe81f3f5a1 Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Mon, 17 Jul 2023 17:15:43 +0100 Subject: [PATCH 03/15] Change `initializePaymentDTO` to `InitializePaymentDTO` --- src/wallets/dtos/wallet.dto.ts | 4 ++-- src/wallets/wallets.controller.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wallets/dtos/wallet.dto.ts b/src/wallets/dtos/wallet.dto.ts index ecd224a..0938dee 100644 --- a/src/wallets/dtos/wallet.dto.ts +++ b/src/wallets/dtos/wallet.dto.ts @@ -31,14 +31,14 @@ export class GetWalletDTO { wallet_id: string; } -export class initializePaymentDTO extends GetWalletDTO { +export class InitializePaymentDTO extends GetWalletDTO { @IsNumber() @IsNotEmpty() @Min(1000) amount: number; } -export class FundWalletDTO extends PartialType(initializePaymentDTO) { +export class FundWalletDTO extends PartialType(InitializePaymentDTO) { @IsString() @IsNotEmpty() reference: string; diff --git a/src/wallets/wallets.controller.ts b/src/wallets/wallets.controller.ts index 12ec258..4bde2ef 100644 --- a/src/wallets/wallets.controller.ts +++ b/src/wallets/wallets.controller.ts @@ -10,7 +10,7 @@ import { import { CreateWalletDTO, GetWalletDTO, - initializePaymentDTO, + InitializePaymentDTO, FundWalletDTO, } from '../wallets/dtos/wallet.dto'; import { UsersService } from '../users/users.service'; @@ -67,7 +67,7 @@ export class WalletsController { @UseGuards(JwtAuthGuard) @Post('/initialize-payment') - async initializePayment(@Body() body: initializePaymentDTO) { + async initializePayment(@Body() body: InitializePaymentDTO) { const wallet = await this.walletService.getWalletByID(body.wallet_id); // call paystack API to initialize payment From 57cc5c7fa3954db0a7c396d63c4a2d4c57a102eb Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Mon, 17 Jul 2023 17:16:37 +0100 Subject: [PATCH 04/15] Add missing route validation for `wallet-transactions*` --- src/app.module.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app.module.ts b/src/app.module.ts index 9282116..11c724a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -70,5 +70,8 @@ export class AppModule implements NestModule { consumer .apply() .forRoutes({ path: 'transfers*', method: RequestMethod.ALL }); + consumer + .apply() + .forRoutes({ path: 'wallet-transactions*', method: RequestMethod.ALL }); } } From c8535f5c9c473a43a1b6af8e1addca5b00bb4d5f Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Mon, 17 Jul 2023 17:17:43 +0100 Subject: [PATCH 05/15] Env configuration updates --- .env.test | 17 +++++++++-------- yarn.lock | 32 ++++++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/.env.test b/.env.test index dcf7086..efc440b 100644 --- a/.env.test +++ b/.env.test @@ -1,11 +1,12 @@ PORT=4000 NODE_ENV=test -PAYSTACK_SECRET_KEY=XXXX -PAYSTACK_API_BASE_URL=https://api.paystack.co -POSTGRES_HOST=localhost -POSTGRES_PORT=5432 -POSTGRES_USER= -POSTGRES_PASSWORD= -POSTGRES_DB=account +TEST_PAYSTACK_SECRET_KEY=XXXX +TEST_PAYSTACK_API_BASE_URL=https://api.paystack.co +TEST_POSTGRES_HOST=localhost +TEST_POSTGRES_PORT=5432 +TEST_POSTGRES_USER= +TEST_POSTGRES_PASSWORD= +TEST_POSTGRES_DB=account_test DB_TYPE=postgres -MININUM_APPROVAL_AMOUNT=1000000 \ No newline at end of file +MININUM_APPROVAL_AMOUNT=1000000 + diff --git a/yarn.lock b/yarn.lock index bf6c195..a114e38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -789,7 +789,7 @@ "@types/jsonwebtoken" "9.0.2" jsonwebtoken "9.0.0" -"@nestjs/mapped-types@^2.0.2": +"@nestjs/mapped-types@2.0.2", "@nestjs/mapped-types@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@nestjs/mapped-types/-/mapped-types-2.0.2.tgz#c8a090a8d22145b85ed977414c158534210f2e4f" integrity sha512-V0izw6tWs6fTp9+KiiPUbGHWALy563Frn8X6Bm87ANLRuE46iuBMD5acKBDP5lKL/75QFvrzSJT7HkCbB0jTpg== @@ -821,6 +821,17 @@ jsonc-parser "3.2.0" pluralize "8.0.0" +"@nestjs/swagger@^7.1.1": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@nestjs/swagger/-/swagger-7.1.1.tgz#67285035a58a32059f219532b449addf81f8e513" + integrity sha512-gIG1aVCegZlIppXZizKHqWkqZvQkvptTBR1C5CzZoDwGoVVKJBmJ2i9FAcsnzzb0j7hncFKhcBuWYOBJOsCvug== + dependencies: + "@nestjs/mapped-types" "2.0.2" + js-yaml "4.1.0" + lodash "4.17.21" + path-to-regexp "3.2.0" + swagger-ui-dist "5.1.0" + "@nestjs/testing@^10.0.0": version "10.0.5" resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-10.0.5.tgz#31cac7b9816351dff7706b3ff9af0387cf608f4b" @@ -3690,6 +3701,13 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-yaml@4.1.0, js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + js-yaml@^3.13.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" @@ -3698,13 +3716,6 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -5116,6 +5127,11 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swagger-ui-dist@5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.1.0.tgz#b01b3be06bebb2566b2df586c1632d502ec792ad" + integrity sha512-c1KmAjuVODxw+vwkNLALQZrgdlBAuBbr2xSPfYrJgseEi7gFKcTvShysPmyuDI4kcUa1+5rFpjWvXdusKY74mg== + symbol-observable@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-4.0.0.tgz#5b425f192279e87f2f9b937ac8540d1984b39205" From 08041c3301c3271c782375fb587216b4feb4f70c Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Tue, 18 Jul 2023 08:07:34 +0100 Subject: [PATCH 06/15] chore: Module Cleanups --- .env.test | 12 --- src/auth/auth.controller.spec.ts | 18 ---- src/auth/auth.service.spec.ts | 100 ------------------ src/auth/guards/local-auth.guard.ts | 5 - src/auth/strategies/local.strategy.ts | 25 ----- src/hash/hash.service.spec.ts | 18 ---- src/users/users.controller.spec.ts | 18 ---- src/users/users.service.spec.ts | 18 ---- .../wallet-transactions.controller.spec.ts | 20 ---- .../wallet-transactions.service.spec.ts | 18 ---- src/wallets/wallets.controller.spec.ts | 18 ---- src/wallets/wallets.service.spec.ts | 18 ---- 12 files changed, 288 deletions(-) delete mode 100644 .env.test delete mode 100644 src/auth/auth.controller.spec.ts delete mode 100644 src/auth/auth.service.spec.ts delete mode 100644 src/auth/guards/local-auth.guard.ts delete mode 100644 src/auth/strategies/local.strategy.ts delete mode 100644 src/hash/hash.service.spec.ts delete mode 100644 src/users/users.controller.spec.ts delete mode 100644 src/users/users.service.spec.ts delete mode 100644 src/wallet-transactions/wallet-transactions.controller.spec.ts delete mode 100644 src/wallet-transactions/wallet-transactions.service.spec.ts delete mode 100644 src/wallets/wallets.controller.spec.ts delete mode 100644 src/wallets/wallets.service.spec.ts diff --git a/.env.test b/.env.test deleted file mode 100644 index efc440b..0000000 --- a/.env.test +++ /dev/null @@ -1,12 +0,0 @@ -PORT=4000 -NODE_ENV=test -TEST_PAYSTACK_SECRET_KEY=XXXX -TEST_PAYSTACK_API_BASE_URL=https://api.paystack.co -TEST_POSTGRES_HOST=localhost -TEST_POSTGRES_PORT=5432 -TEST_POSTGRES_USER= -TEST_POSTGRES_PASSWORD= -TEST_POSTGRES_DB=account_test -DB_TYPE=postgres -MININUM_APPROVAL_AMOUNT=1000000 - diff --git a/src/auth/auth.controller.spec.ts b/src/auth/auth.controller.spec.ts deleted file mode 100644 index 27a31e6..0000000 --- a/src/auth/auth.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AuthController } from './auth.controller'; - -describe('AuthController', () => { - let controller: AuthController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [AuthController], - }).compile(); - - controller = module.get(AuthController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts deleted file mode 100644 index 2a95b0d..0000000 --- a/src/auth/auth.service.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AuthService } from './auth.service'; -import { UsersService } from '../users/users.service'; -import { HashService } from '../hash/hash.service'; -import { JwtService } from '@nestjs/jwt'; -import { User } from '../users/user.entity'; -import { BadRequestException, UnauthorizedException } from '@nestjs/common'; - -describe('AuthService', () => { - let service: AuthService; - let fakeUserService: Partial; - let fakeJwtService: Partial; - beforeEach(async () => { - fakeUserService = { - findUser: () => Promise.resolve([]), - createUser: (body: User) => { - return Promise.resolve({ - id: 1, - first_name: body.first_name, - last_name: body.last_name, - email: body.email, - password: body.password, - phone_number: body.phone_number, - - deleted_at: body.deleted_at, - is_admin: body.is_admin, - created_at: body.created_at, - updated_at: body.updated_at, - } as unknown as User); - }, - }; - const fakeHashService = { - hashPassword: (password: string) => Promise.resolve(password), - }; - - fakeJwtService = { - sign: jest.fn().mockReturnValue('fake-token'), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AuthService, - { - provide: UsersService, - useValue: fakeUserService, - }, - { - provide: HashService, - useValue: fakeHashService, - }, - { - provide: JwtService, - useValue: fakeJwtService, - }, - ], - }).compile(); - - service = module.get(AuthService); - }); - - it('can create an instance of the auth service', async () => { - expect(service).toBeDefined(); - }); - - it('it should create a user with a hashed password', async () => { - const user = await service.signup({ - email: 'olueko34@gmail.com', - password: 'mayor', - } as unknown as User); - - user.password = 'xxxxx'; - - expect(user.password).not.toEqual('mayor'); - expect(user.password).toEqual('xxxxx'); - expect(user.password).toBeDefined(); - expect(user.password).not.toEqual(''); - }); - - it('it throws an error if user signs up with an email in use', async () => { - fakeUserService.findUser = () => - Promise.resolve({ email: 'olueko34@gmail.com' } as unknown as User); - await expect( - service.signup({ - email: 'olueko34@gmail.com', - password: 'mayor', - } as unknown as User), - ).rejects.toThrow(BadRequestException); - }); - - it('throws if signin is called with an unused email', async () => { - await expect( - service.login({ - id: '1', - email: 'asdflkj@asdlfkj.com', - is_admin: true, - access_token: fakeJwtService.sign('xys'), - } as unknown as User), - ).rejects.toThrow(UnauthorizedException); - }); -}); diff --git a/src/auth/guards/local-auth.guard.ts b/src/auth/guards/local-auth.guard.ts deleted file mode 100644 index ccf962b..0000000 --- a/src/auth/guards/local-auth.guard.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; - -@Injectable() -export class LocalAuthGuard extends AuthGuard('local') {} diff --git a/src/auth/strategies/local.strategy.ts b/src/auth/strategies/local.strategy.ts deleted file mode 100644 index 5793d53..0000000 --- a/src/auth/strategies/local.strategy.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Strategy } from 'passport-local'; -import { PassportStrategy } from '@nestjs/passport'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { AuthService } from '../auth.service'; - -@Injectable() -// Local strategy to check and validate the user username and password -export class LocalStrategy extends PassportStrategy(Strategy) { - constructor(private authService: AuthService) { - super({ - usernameField: 'email', - passwordField: 'password', - }); - } - - async validate(username: string, password: string) { - const user = await this.authService.validateUser(username, password); - - if (!user) { - throw new UnauthorizedException('invalid email or password'); - } - - return user; - } -} diff --git a/src/hash/hash.service.spec.ts b/src/hash/hash.service.spec.ts deleted file mode 100644 index 2958ba0..0000000 --- a/src/hash/hash.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { HashService } from './hash.service'; - -describe('HashService', () => { - let service: HashService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [HashService], - }).compile(); - - service = module.get(HashService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts deleted file mode 100644 index 3e27c39..0000000 --- a/src/users/users.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { UsersController } from './users.controller'; - -describe('UsersController', () => { - let controller: UsersController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [UsersController], - }).compile(); - - controller = module.get(UsersController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts deleted file mode 100644 index 62815ba..0000000 --- a/src/users/users.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { UsersService } from './users.service'; - -describe('UsersService', () => { - let service: UsersService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [UsersService], - }).compile(); - - service = module.get(UsersService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/wallet-transactions/wallet-transactions.controller.spec.ts b/src/wallet-transactions/wallet-transactions.controller.spec.ts deleted file mode 100644 index bcec109..0000000 --- a/src/wallet-transactions/wallet-transactions.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { WalletTransactionsController } from './wallet-transactions.controller'; - -describe('WalletTransactionsController', () => { - let controller: WalletTransactionsController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [WalletTransactionsController], - }).compile(); - - controller = module.get( - WalletTransactionsController, - ); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/wallet-transactions/wallet-transactions.service.spec.ts b/src/wallet-transactions/wallet-transactions.service.spec.ts deleted file mode 100644 index 154bfe7..0000000 --- a/src/wallet-transactions/wallet-transactions.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { WalletTransactionsService } from './wallet-transactions.service'; - -describe('WalletTransactionsService', () => { - let service: WalletTransactionsService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [WalletTransactionsService], - }).compile(); - - service = module.get(WalletTransactionsService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/wallets/wallets.controller.spec.ts b/src/wallets/wallets.controller.spec.ts deleted file mode 100644 index 754db4a..0000000 --- a/src/wallets/wallets.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { WalletsController } from './wallets.controller'; - -describe('WalletsController', () => { - let controller: WalletsController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [WalletsController], - }).compile(); - - controller = module.get(WalletsController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/wallets/wallets.service.spec.ts b/src/wallets/wallets.service.spec.ts deleted file mode 100644 index cffb27a..0000000 --- a/src/wallets/wallets.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { WalletsService } from './wallets.service'; - -describe('WalletsService', () => { - let service: WalletsService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [WalletsService], - }).compile(); - - service = module.get(WalletsService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); From 5641ad85fef29a05f8378f2be93e37e4c67c213f Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Tue, 18 Jul 2023 08:10:07 +0100 Subject: [PATCH 07/15] chore(docs): Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ed5e5c8..f9fea9c 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ docker build -t ${IMAGETAG} -f Dockerfile . #### Test ```bash - $ yarn run test + $ yarn test:e2e ``` #### Available Endpoints @@ -143,7 +143,8 @@ docker build -t ${IMAGETAG} -f Dockerfile . #### Postman Documentation -- Please see `/postman_docs` on the root directory. +- Please see `/postman_docs` on the root directory OR +- Navigate to `http://localhost:4000/docs` on your computer to view the openapi documentation. ### Improvement Points From c474f2e7fb433b42a106e3d2594750c7a934d218 Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Tue, 18 Jul 2023 08:10:46 +0100 Subject: [PATCH 08/15] chore: Update `.env.sample` --- .env.sample | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .env.sample diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..305d1d0 --- /dev/null +++ b/.env.sample @@ -0,0 +1,23 @@ +PORT=4000 #development port +NODE_ENV=development +PAYSTACK_SECRET_KEY=XXXX +PAYSTACK_API_BASE_URL=https://api.paystack.co +POSTGRES_HOST=localhost #development +POSTGRES_PORT=5432 #development +POSTGRES_USER= #development +POSTGRES_PASSWORD= #development +POSTGRES_DB=account #development +DB_TYPE=postgres +JWT_SECRET=XXXX +JWT_EXPIRY=XXXX +MININUM_APPROVAL_AMOUNT=1000000 + + +TEST_PAYSTACK_SECRET_KEY=XXXX +TEST_PAYSTACK_API_BASE_URL=https://api.paystack.co +TEST_POSTGRES_HOST=localhost #test +TEST_POSTGRES_PORT=5432 #test +TEST_POSTGRES_USER= #test +TEST_POSTGRES_PASSWORD= +TEST_POSTGRES_DB=account_test #test +DB_TYPE=postgres From bac9f80873cf95f128247edcffef7c5b48b0fd02 Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Tue, 18 Jul 2023 08:11:39 +0100 Subject: [PATCH 09/15] Add `cross-env` env --- package.json | 17 +++++++++-------- yarn.lock | 9 ++++++++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 1ae02e5..da45337 100644 --- a/package.json +++ b/package.json @@ -8,16 +8,16 @@ "scripts": { "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", + "start": "cross-env NODE_ENV=development nest start", + "start:dev": "cross-env NODE_ENV=development nest start --watch", + "start:debug": "cross-env NODE_ENV=development nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch --maxWorkers=1", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test": "cross-env NODE_ENV=test jest", + "test:watch": "cross-env NODE_ENV=test jest --watch --maxWorkers=1", + "test:cov": "cross-env NODE_ENV=test jest --coverage", + "test:debug": "cross-env NODE_ENV=test node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "cross-env NODE_ENV=test jest --config ./test/jest-e2e.json --detectOpenHandles --maxWorkers=1" }, "dependencies": { "@nestjs/common": "^10.0.0", @@ -37,6 +37,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "compression": "^1.7.4", + "cross-env": "^7.0.3", "dotenv": "^16.3.1", "helmet": "^7.0.0", "nest-winston": "^1.9.3", diff --git a/yarn.lock b/yarn.lock index a114e38..15a928f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2220,7 +2220,14 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-env@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + +cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== From 76f3c1630f2c89c9d3dd313c8b6cefb59bfcc3c7 Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Tue, 18 Jul 2023 08:12:46 +0100 Subject: [PATCH 10/15] chore: Refactor DB configuration setup --- src/database/database.module.ts | 8 ++++++++ src/ormconfig.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 src/database/database.module.ts create mode 100644 src/ormconfig.ts diff --git a/src/database/database.module.ts b/src/database/database.module.ts new file mode 100644 index 0000000..2388a47 --- /dev/null +++ b/src/database/database.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { config } from '../ormconfig'; + +@Module({ + imports: [TypeOrmModule.forRoot(config)], +}) +export class DatabaseModule {} diff --git a/src/ormconfig.ts b/src/ormconfig.ts new file mode 100644 index 0000000..12ae4bf --- /dev/null +++ b/src/ormconfig.ts @@ -0,0 +1,31 @@ +import 'dotenv/config'; + +export let config: any = { + type: 'postgres', + host: process.env.POSTGRES_HOST, + port: +process.env.POSTGRES_PORT, + username: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD, + database: process.env.POSTGRES_DB, + entities: [__dirname + '/**/*.entity{.ts,.js}'], + synchronize: process.env.NODE_ENV !== 'production', + migrations: [__dirname + '/migrations/**/*{.ts,.js}'], + migrationsRun: true, + logging: true, +}; + +if (process.env.NODE_ENV === 'test') { + config = { + type: 'postgres', + host: process.env.TEST_POSTGRES_HOST, + port: +process.env.TEST_POSTGRES_PORT, + username: process.env.TEST_POSTGRES_USER, + password: process.env.TEST_POSTGRES_PASSWORD, + database: process.env.TEST_POSTGRES_DB, + entities: [__dirname + '/**/*.entity{.ts,.js}'], + synchronize: false, + migrations: [__dirname + '/migrations/**/*{.ts,.js}'], + migrationsRun: true, + logging: false, + }; +} From e48a90f991cd57218568a6c390ccac68ffa6ee92 Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Tue, 18 Jul 2023 08:14:49 +0100 Subject: [PATCH 11/15] chore(cleanups): Auth Module --- src/auth/auth.controller.ts | 32 ++++++++++---- src/auth/auth.module.ts | 4 +- src/auth/auth.service.ts | 65 ++++++++++++++++------------- src/auth/dtos/auth.dto.ts | 23 ++++++++++ src/auth/guards/RolesGuard.ts | 2 +- src/auth/strategies/jwt.strategy.ts | 28 +++++++++---- 6 files changed, 105 insertions(+), 49 deletions(-) create mode 100644 src/auth/dtos/auth.dto.ts diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 85567c5..3fbeaa4 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,7 +1,15 @@ -import { Controller, Post, Body, UseGuards, Request } from '@nestjs/common'; +import { + Controller, + Post, + Body, + UseGuards, + Request, + HttpStatus, + HttpCode, +} from '@nestjs/common'; import { CreateUserDTO } from '../users/dtos/user.dto'; +import { LoginUserDTO } from './dtos/auth.dto'; import { AuthService } from '../auth/auth.service'; -import { LocalAuthGuard } from './guards/local-auth.guard'; import { Helpers } from '../utilities/helpers'; import { SuccessResponse } from 'src/interface/types'; @@ -13,14 +21,24 @@ export class AuthController { const user = await this.authService.signup(body); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { password, ...details } = user; + const { password: undefined, ...details } = user; - return this.helpers.successResponse(201, { ...details }, 'User created'); + return this.helpers.successResponse( + HttpStatus.CREATED, + { ...details }, + 'User created', + ); } - @UseGuards(LocalAuthGuard) @Post('/login') - async login(@Request() req) { - return this.authService.login(req.user); + @HttpCode(HttpStatus.OK) + async login(@Body() authLogin: LoginUserDTO) { + const loginDetail = await this.authService.login(authLogin); + + return this.helpers.successResponse( + HttpStatus.OK, + loginDetail, + 'Login successful', + ); } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 734920a..554d98a 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -5,8 +5,6 @@ import { AuthController } from './auth.controller'; import { UsersModule } from '../users/users.module'; import { HashModule } from '../hash/hash.module'; import { PassportModule } from '@nestjs/passport'; -import { LocalStrategy } from './strategies/local.strategy'; -import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtModule } from '@nestjs/jwt'; import { HelpersModule } from '../utilities/helpers.module'; @@ -21,7 +19,7 @@ import { HelpersModule } from '../utilities/helpers.module'; signOptions: { expiresIn: process.env.JWT_EXPIRY }, }), ], - providers: [AuthService, LocalStrategy, JwtStrategy], + providers: [AuthService], controllers: [AuthController], exports: [AuthService], }) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index ff7620b..e5d36f5 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -4,6 +4,7 @@ import { UsersService } from '../users/users.service'; import { HashService } from '../hash/hash.service'; import { CreateUserDTO } from '../users/dtos/user.dto'; import { User } from '../users/user.entity'; +import { LoginUserDTO } from './dtos/auth.dto'; @Injectable() export class AuthService { @@ -13,50 +14,54 @@ export class AuthService { private jwtService: JwtService, ) {} async signup(body: CreateUserDTO): Promise { - const existingUser = await this.usersService.findUser( - body.email, - body.phone_number, - ); + try { + // Hash user password + const hashPassword = await this.hashService.hashPassword(body?.password); - if (existingUser) { - throw new BadRequestException('user already exists'); - } - // Hash user password - const hashPassword = await this.hashService.hashPassword(body.password); - - body.password = hashPassword; + body.password = hashPassword; - const user = await this.usersService.createUser(body); + const user = await this.usersService.createUser(body); - return user; + return user; + } catch (error) { + if (error?.code === '23505') { + throw new BadRequestException( + 'User with the email/phone number already exists', + ); + } + } } - async validateUser(email: string, password: string): Promise { - const user = await this.usersService.findUser(email); + async validateUser(authPayload: LoginUserDTO): Promise { + const { email, phone_number, password } = authPayload; - if ( - user && - (await this.hashService.comparePassword(password, user.password)) - ) { - return user; + const user = await this.usersService.findUser(email, phone_number); + + if (!user) { + throw new BadRequestException('Invalid email/phone number'); + } + + if (!(await this.hashService.comparePassword(password, user.password))) { + throw new BadRequestException('Invalid password'); } - return null; + return user; } - async login(user: { [key: string]: string | boolean }) { + async login(authPayload: LoginUserDTO) { + const user = await this.validateUser(authPayload); + const payload = { - id: user.id, - email: user.email, - is_admin: user.is_admin, + userId: user.id, + ...(user.email && { email: user.email }), + ...(user.phone_number && { phoneNumber: user.phone_number }), + isAdmin: user.is_admin, }; - const { id, email, is_admin } = payload; - return { - id, - email, - is_admin, + id: user.id, + ...(user.email && { email: user.email }), + is_admin: user.is_admin, access_token: this.jwtService.sign(payload), }; } diff --git a/src/auth/dtos/auth.dto.ts b/src/auth/dtos/auth.dto.ts new file mode 100644 index 0000000..ad1e1e5 --- /dev/null +++ b/src/auth/dtos/auth.dto.ts @@ -0,0 +1,23 @@ +import { + IsEmail, + IsString, + IsPhoneNumber, + Length, + IsOptional, + IsNotEmpty, +} from 'class-validator'; + +export class LoginUserDTO { + @IsEmail() + @IsOptional() + email?: string; + + @IsString() + @IsNotEmpty() + @Length(3, 20) + password: string; + + @IsPhoneNumber() + @IsOptional() + phone_number?: string; +} diff --git a/src/auth/guards/RolesGuard.ts b/src/auth/guards/RolesGuard.ts index a3c230b..c0e5065 100644 --- a/src/auth/guards/RolesGuard.ts +++ b/src/auth/guards/RolesGuard.ts @@ -13,7 +13,7 @@ export class RolesGuard implements CanActivate { try { const { user } = context.switchToHttp().getRequest(); - return user.is_admin; + return user.isAdmin; } catch (error) { this.logger.error({ error }); return false; diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index 7c2769e..651ba55 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -1,11 +1,12 @@ import 'dotenv/config'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { UsersService } from '../../users/users.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor() { + constructor(private readonly userService: UsersService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, @@ -13,15 +14,26 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: any): Promise<{ + async validate(payload: { userId: string; - username: string; - is_admin: boolean; + isAdmin: boolean; + email?: string; + phoneNumber?: string; + }): Promise<{ + userId: string; + isAdmin: boolean; }> { + const authUser = await this.userService.getUserById(payload.userId); + + if (!authUser) { + throw new UnauthorizedException('Unauthorized User'); + } + return { - userId: payload.id, - username: payload.email, - is_admin: payload.is_admin, + userId: payload.userId, + ...(payload.email && { email: payload.email }), + ...(payload.phoneNumber && { phoneNumber: payload.phoneNumber }), + isAdmin: payload.isAdmin, }; } } From 5944443a041acb7792544b4837b94df7433e9bc9 Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Tue, 18 Jul 2023 08:16:09 +0100 Subject: [PATCH 12/15] Add HttpCode on Route Handlers --- src/transfers/transfers.controller.ts | 12 ++++++++++-- .../wallet-transactions.controller.ts | 12 ++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/transfers/transfers.controller.ts b/src/transfers/transfers.controller.ts index 172880e..7a1464c 100644 --- a/src/transfers/transfers.controller.ts +++ b/src/transfers/transfers.controller.ts @@ -6,6 +6,8 @@ import { Patch, UseGuards, Param, + HttpStatus, + HttpCode, } from '@nestjs/common'; import { TransferService } from './transfers.service'; import { @@ -33,6 +35,7 @@ export class TransfersController { ) {} @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) @Post('/') async createWalletTransfer(@Body() body: CreateTransferDTO) { const { source_wallet_id, destination_wallet_id, amount } = body; @@ -84,13 +87,14 @@ export class TransfersController { }); return this.helperService.successResponse( - 201, + HttpStatus.OK, transfer, 'transfer successful', ); } @UseGuards(JwtAuthGuard, RolesGuard) + @HttpCode(HttpStatus.OK) @Patch('/:transfer_id/approve') async approveTransfer( @Param() { transfer_id }: TransferIdDTO, @@ -110,6 +114,10 @@ export class TransfersController { await this.transferservice.changeApproval(transfer.id, approved); - return this.helperService.successResponse(200, {}, 'transfer approved'); + return this.helperService.successResponse( + HttpStatus.OK, + {}, + 'transfer approved', + ); } } diff --git a/src/wallet-transactions/wallet-transactions.controller.ts b/src/wallet-transactions/wallet-transactions.controller.ts index bb28bad..b264851 100644 --- a/src/wallet-transactions/wallet-transactions.controller.ts +++ b/src/wallet-transactions/wallet-transactions.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + HttpCode, + HttpStatus, + Query, + UseGuards, +} from '@nestjs/common'; import { WalletTransactionsService } from './wallet-transactions.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/RolesGuard'; @@ -13,13 +20,14 @@ export class WalletTransactionsController { ) {} @UseGuards(JwtAuthGuard, RolesGuard) + @HttpCode(HttpStatus.OK) @Get('/history') async getTransactionHistory(@Query() queryParams: TransactionHistoryDTO) { const transactions = await this.walletTransactionsService.getTransactionHistory(queryParams); return this.helpersService.successResponse( - 200, + HttpStatus.OK, transactions, 'transactions retrieved', ); From 87978a3d48955f2347c8133b0bb157a8f5267e1b Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Tue, 18 Jul 2023 08:18:14 +0100 Subject: [PATCH 13/15] Set DatabaseModule Globally --- src/app.module.ts | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index 11c724a..40bebc7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,45 +8,27 @@ import 'dotenv/config'; import { WinstonModule } from 'nest-winston'; import { APP_FILTER } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { DatabaseModule } from './database/database.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; import { WalletsModule } from './wallets/wallets.module'; import { WalletTransactionsModule } from './wallet-transactions/wallet-transactions.module'; import { TransfersModule } from './transfers/transfers.module'; -import { User } from './users/user.entity'; -import { Wallet } from './wallets/wallet.entity'; -import { WalletTransaction } from './wallet-transactions/wallet-transaction.entity'; -import { Transfer } from './transfers/transfer.entity'; import { AuthModule } from './auth/auth.module'; import { HashModule } from './hash/hash.module'; import { PaystackModule } from './utilities/paystack.module'; import { HelpersModule } from './utilities/helpers.module'; import { HttpExceptionFilter } from './exceptions/http-exception.filter'; +import { JwtStrategy } from './auth/strategies/jwt.strategy'; import winstonLogger from './utilities/logger'; @Module({ imports: [ WinstonModule.forRoot({ ...winstonLogger, }), - - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: `.env.${process.env.NODE_ENV}`, - }), - UsersModule, - TypeOrmModule.forRoot({ - type: 'postgres', - host: process.env.POSTGRES_HOST, - port: +process.env.POSTGRES_PORT, - username: process.env.POSTGRES_USER, - password: process.env.POSTGRES_PASSWORD, - database: process.env.POSTGRES_DB, - entities: [User, Wallet, WalletTransaction, Transfer], - synchronize: process.env.NODE_ENV !== 'production', - }), UsersModule, + DatabaseModule, WalletsModule, WalletTransactionsModule, TransfersModule, @@ -54,9 +36,13 @@ import winstonLogger from './utilities/logger'; HashModule, PaystackModule, HelpersModule, + ConfigModule.forRoot({ + isGlobal: true, + }), ], controllers: [AppController], providers: [ + JwtStrategy, AppService, { provide: APP_FILTER, From 069a6dbfc25cb20ac83fa037dacf50adfa2491d8 Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Tue, 18 Jul 2023 08:19:32 +0100 Subject: [PATCH 14/15] :fire: --- src/auth/auth.controller.ts | 10 +--------- src/users/dtos/user.dto.ts | 3 +-- src/users/user.entity.ts | 3 ++- src/users/users.service.ts | 14 ++++++-------- src/wallets/wallets.controller.ts | 27 +++++++++++++++++++++++---- 5 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 3fbeaa4..4ac8f20 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,12 +1,4 @@ -import { - Controller, - Post, - Body, - UseGuards, - Request, - HttpStatus, - HttpCode, -} from '@nestjs/common'; +import { Controller, Post, Body, HttpStatus, HttpCode } from '@nestjs/common'; import { CreateUserDTO } from '../users/dtos/user.dto'; import { LoginUserDTO } from './dtos/auth.dto'; import { AuthService } from '../auth/auth.service'; diff --git a/src/users/dtos/user.dto.ts b/src/users/dtos/user.dto.ts index fad625c..af5b542 100644 --- a/src/users/dtos/user.dto.ts +++ b/src/users/dtos/user.dto.ts @@ -6,7 +6,6 @@ import { IsOptional, IsNotEmpty, } from 'class-validator'; -import { PartialType } from '@nestjs/mapped-types'; export class CreateUserDTO { @IsString() @@ -31,7 +30,7 @@ export class CreateUserDTO { phone_number: string; } -export class LoginUserDTO extends PartialType(CreateUserDTO) { +export class LoginUserDTO { @IsEmail() @IsOptional() email?: string; diff --git a/src/users/user.entity.ts b/src/users/user.entity.ts index 61f605a..d50be89 100644 --- a/src/users/user.entity.ts +++ b/src/users/user.entity.ts @@ -12,7 +12,6 @@ import { Wallet } from '../wallets/wallet.entity'; @Entity({ name: 'users' }) export class User extends BaseEntity { - [x: string]: any; @PrimaryGeneratedColumn('uuid') id: string; @@ -36,6 +35,7 @@ export class User extends BaseEntity { name: 'email', nullable: true, type: 'varchar', + unique: true, }) email: string; @@ -50,6 +50,7 @@ export class User extends BaseEntity { name: 'phone_number', nullable: true, type: 'varchar', + unique: true, length: 20, }) phone_number: string; diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 0716dcf..9a5ed38 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -18,10 +18,12 @@ export class UsersService { // existing user search by email or phone number async findUser(email?: string, phoneNumber?: string): Promise { const user = await this.userRepository.findOne({ - where: { - ...(email && { email }), - ...(phoneNumber && { phone_number: phoneNumber }), - }, + where: [ + { + ...(email && { email }), + ...(phoneNumber && { phone_number: phoneNumber }), + }, + ], }); return user; @@ -38,10 +40,6 @@ export class UsersService { }, }); - if (!user) { - throw new NotFoundException('No account exists for this user'); - } - return user; } } diff --git a/src/wallets/wallets.controller.ts b/src/wallets/wallets.controller.ts index 4bde2ef..40783c5 100644 --- a/src/wallets/wallets.controller.ts +++ b/src/wallets/wallets.controller.ts @@ -6,6 +6,9 @@ import { Get, Param, UnprocessableEntityException, + NotFoundException, + HttpStatus, + HttpCode, } from '@nestjs/common'; import { CreateWalletDTO, @@ -33,11 +36,16 @@ export class WalletsController { ) {} @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.CREATED) @Post('/') async createWallet(@Body() body: CreateWalletDTO) { // check if the account for that user exists const user = await this.userService.getUserById(body.user_id); + if (!user) { + throw new NotFoundException('No account exists for this user'); + } + // check for duplicate currency wallet creation const existingWallet = await this.walletService.searchWallet({ user_id: user.id, @@ -47,11 +55,16 @@ export class WalletsController { if (!existingWallet) { const wallet = await this.walletService.createWallet(body); - return this.helpersService.successResponse(201, wallet, 'Wallet created'); + return this.helpersService.successResponse( + HttpStatus.CREATED, + wallet, + 'Wallet created', + ); } } @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) @Get('/:wallet_id/balance') async getWalletBalance(@Param() { wallet_id }: GetWalletDTO) { const existingWallet = await this.walletService.getWalletByID(wallet_id); @@ -59,13 +72,14 @@ export class WalletsController { const { currency, balance } = existingWallet; return this.helpersService.successResponse( - 200, + HttpStatus.OK, { currency, balance }, 'Wallet balance retrieved', ); } @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) @Post('/initialize-payment') async initializePayment(@Body() body: InitializePaymentDTO) { const wallet = await this.walletService.getWalletByID(body.wallet_id); @@ -80,13 +94,14 @@ export class WalletsController { }); return this.helpersService.successResponse( - 200, + HttpStatus.OK, response, 'Payment initialized successfully', ); } @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) @Post('/deposit') async creditWallet(@Body() body: FundWalletDTO) { const response = await this.walletService.verifyPaymentTransaction( @@ -127,6 +142,10 @@ export class WalletsController { this.walletService.updateWalletBalance(wallet_id, amount, 'INC'), ]); - return this.helpersService.successResponse(200, response, 'wallet funded'); + return this.helpersService.successResponse( + HttpStatus.OK, + response, + 'wallet funded', + ); } } From 4fa7c456880a3630bce1ebb54b66c897b639d53e Mon Sep 17 00:00:00 2001 From: Olusegun Ekoh Date: Tue, 18 Jul 2023 08:20:05 +0100 Subject: [PATCH 15/15] Improve test coverage --- test/app.e2e-spec.ts | 13 +- test/auth.e2e-spec.ts | 231 ++++++++++++++++++++++++++++++++++ test/wallet.e2e-spec.ts | 266 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 505 insertions(+), 5 deletions(-) create mode 100644 test/auth.e2e-spec.ts create mode 100644 test/wallet.e2e-spec.ts diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 50cda62..01cf78b 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -6,7 +6,7 @@ import { AppModule } from './../src/app.module'; describe('AppController (e2e)', () => { let app: INestApplication; - beforeEach(async () => { + beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); @@ -16,9 +16,12 @@ describe('AppController (e2e)', () => { }); it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); + const response = { + code: 200, + status: 'success', + message: 'okay', + data: null, + }; + return request(app.getHttpServer()).get('/').expect(200).expect(response); }); }); diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts new file mode 100644 index 0000000..818739c --- /dev/null +++ b/test/auth.e2e-spec.ts @@ -0,0 +1,231 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import * as request from 'supertest'; +import { AppModule } from './../src/app.module'; +import { Repository } from 'typeorm'; +import { User } from '../src/users/user.entity'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +describe('Authentication System', () => { + let app: INestApplication; + let moduleFixture: TestingModule; + let userRepository: Repository; + + beforeAll(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + + // hack + app.useGlobalPipes( + // remove any additional properites not defined in the DTO + new ValidationPipe({ + whitelist: true, + }), + ); + + userRepository = moduleFixture.get>( + getRepositoryToken(User), + ); + + await app.init(); + }); + + afterEach(async () => { + await userRepository.query(`DELETE FROM users;`); + }); + + afterAll(async () => { + await moduleFixture.close(); + await app.close(); + }); + + describe('Auth Module', () => { + describe('POST /auth/signup', () => { + it('Should handle a sign up request', async () => { + const email = 'john.doe@gmail.com'; + const lastName = 'Doe'; + return request(app.getHttpServer()) + .post('/auth/signup') + .send({ + first_name: 'John', + last_name: lastName, + email, + password: 'john.doe', + phone_number: '+2348045637284', + }) + .expect(201) + .then((response) => { + const { status, code, message, data } = response.body; + expect(code).toEqual(201); + expect(status).toEqual('success'); + expect(message).toEqual('User created'); + expect(email).toEqual(data.email); + expect(lastName).toEqual(data.last_name); + }); + }); + + it('Should throw an error when email is not passed not passed', async () => { + const lastName = 'Doe'; + return request(app.getHttpServer()) + .post('/auth/signup') + .send({ + first_name: 'John', + last_name: lastName, + password: 'john.doe', + phone_number: '+2348045637284', + }) + .expect(400) + .then((response) => { + const { status, code, error } = response.body; + expect(code).toEqual(400); + expect(status).toEqual('failure'); + expect(error.name).toEqual('Bad Request'); + + expect(error.message).toEqual( + expect.arrayContaining([ + 'email should not be empty', + 'email must be an email', + ]), + ); + }); + }); + + it('Should throw an error when password is not passed not passed', async () => { + return request(app.getHttpServer()) + .post('/auth/signup') + .send({ + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@gmail.com', + phone_number: '+2348045637284', + }) + .expect(400) + .then((response) => { + const { status, code, error } = response.body; + expect(code).toEqual(400); + expect(status).toEqual('failure'); + expect(error.name).toEqual('Bad Request'); + + expect(error.message).toEqual( + expect.arrayContaining([ + 'password must be longer than or equal to 3 characters', + 'password should not be empty', + 'password must be a string', + ]), + ); + }); + }); + }); + + describe('POST /auth/login', () => { + it('Should login successful with an email', async () => { + await userRepository.save({ + id: '4776bd35-44f3-4c82-b7d9-06627db401b3', + first_name: 'mark', + last_name: 'john', + email: 'mark.john@gmail.com', + password: await bcrypt.hash('mark.john', 10), + phone_number: '+2348032345346', + is_admin: false, + }); + + return request(app.getHttpServer()) + .post('/auth/login') + .send({ + email: 'mark.john@gmail.com', + password: 'mark.john', + }) + .expect(200) + .then((response) => { + const { data } = response.body; + expect(data.id).toEqual('4776bd35-44f3-4c82-b7d9-06627db401b3'); + expect(data.email).toEqual('mark.john@gmail.com'); + expect(data.is_admin).toEqual(false); + expect(data.access_token).toBeDefined(); + }); + }); + + it('Should login successful with a phone number', async () => { + await userRepository.save({ + id: '4776bd35-44f3-4c82-b7d9-06627db401b3', + first_name: 'mark', + last_name: 'john', + email: 'mark.john@gmail.com', + password: await bcrypt.hash('mark.john', 10), + phone_number: '+2348032345346', + is_admin: false, + }); + + return request(app.getHttpServer()) + .post('/auth/login') + .send({ + phone_number: '+2348032345346', + password: 'mark.john', + }) + .expect(200) + .then((response) => { + const { data } = response.body; + expect(data.id).toEqual('4776bd35-44f3-4c82-b7d9-06627db401b3'); + expect(data.email).toEqual('mark.john@gmail.com'); + expect(data.is_admin).toEqual(false); + expect(data.access_token).toBeDefined(); + }); + }); + it('Should throw an error when a wrong phone number is passed', async () => { + return request(app.getHttpServer()) + .post('/auth/login') + .send({ + phone_number: '+2348032345347', + password: 'mark.john', + }) + .expect(400) + .then((response) => { + const { status, code, error } = response.body; + expect(code).toEqual(400); + expect(status).toEqual('failure'); + expect(error.name).toEqual('Bad Request'); + + expect(error.message).toEqual('Invalid email/phone number'); + }); + }); + + it('Should throw an error when a wrong password is passed', async () => { + return request(app.getHttpServer()) + .post('/auth/login') + .send({ + phone_number: '+2348032345346', + password: 'mark.j', + }) + .expect(400) + .then((response) => { + const { status, code, error } = response.body; + expect(code).toEqual(400); + expect(status).toEqual('failure'); + expect(error.name).toEqual('Bad Request'); + expect(error.message).toEqual('Invalid email/phone number'); + }); + }); + + it('Should throw an error when a wrong email is passed', async () => { + return request(app.getHttpServer()) + .post('/auth/login') + .send({ + email: 'mark.jonna@gmail.com', + password: 'mark.john', + }) + .expect(400) + .then((response) => { + const { status, code, error } = response.body; + expect(code).toEqual(400); + expect(status).toEqual('failure'); + expect(error.name).toEqual('Bad Request'); + expect(error.message).toEqual('Invalid email/phone number'); + }); + }); + }); + }); +}); diff --git a/test/wallet.e2e-spec.ts b/test/wallet.e2e-spec.ts new file mode 100644 index 0000000..32d2f91 --- /dev/null +++ b/test/wallet.e2e-spec.ts @@ -0,0 +1,266 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { sign as jwtSign } from 'jsonwebtoken'; +import * as request from 'supertest'; +import { AppModule } from './../src/app.module'; +import { Repository } from 'typeorm'; +import { Wallet } from '../src/wallets/wallet.entity'; +import { User } from '../src/users/user.entity'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +describe('Authentication System', () => { + let app: INestApplication; + let moduleFixture: TestingModule; + let walletRepository: Repository; + let userRepository: Repository; + + beforeAll(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + + // hack + app.useGlobalPipes( + // remove any additional properites not defined in the DTO + new ValidationPipe({ + whitelist: true, + }), + ); + + userRepository = moduleFixture.get>( + getRepositoryToken(User), + ); + + walletRepository = moduleFixture.get>( + getRepositoryToken(Wallet), + ); + + await app.init(); + }); + + afterEach(async () => { + await Promise.all([ + walletRepository.query(`DELETE FROM wallets;`), + userRepository.query(`DELETE FROM users;`), + ]); + }); + + afterAll(async () => { + await moduleFixture.close(); + await app.close(); + }); + + describe('Wallet Module', () => { + const id = '4776bd35-44f3-4c82-b7d9-06627db401b3'; + const email = 'mark.john@gmail.com'; + const phone_number = '+2348032345346'; + const is_admin = false; + let user: Record = {}; + beforeEach(async () => { + user = { + id, + first_name: 'mark', + last_name: 'john', + email, + password: await bcrypt.hash('mark.john', 10), + phone_number, + is_admin, + }; + + await userRepository.save(user); + + user = { + ...user, + access_token: jwtSign( + { userId: id, email, phoneNumber: phone_number, isAdmin: is_admin }, + process.env.JWT_SECRET, + ), + }; + }); + describe('POST /wallets', () => { + it('Should create a wallet successfully', async () => { + return request(app.getHttpServer()) + .post('/wallets') + .set({ Authorization: `Bearer ${user.access_token}` }) + .send({ + user_id: id, + currency: 'NGN', + }) + .expect(201) + .then((response) => { + const { data, status } = response.body; + expect(status).toEqual('success'); + expect(data.user_id).toEqual(id); + expect(data.currency).toEqual('NGN'); + expect(data.balance).toEqual('0'); + }); + }); + + it('Should throw an error when user_id is not provided', async () => { + return request(app.getHttpServer()) + .post('/wallets') + .set({ Authorization: `Bearer ${user.access_token}` }) + .send({ + currency: 'NGN', + }) + .expect(400) + .then((response) => { + const { status, code, error } = response.body; + expect(code).toEqual(400); + expect(status).toEqual('failure'); + expect(error.name).toEqual('Bad Request'); + + expect(error.message).toEqual( + expect.arrayContaining([ + 'user_id should not be empty', + 'user_id must be a UUID', + ]), + ); + }); + }); + + it('Should throw a not found error when an invalid user_id is provided', async () => { + return request(app.getHttpServer()) + .post('/wallets') + .set({ Authorization: `Bearer ${user.access_token}` }) + .send({ + user_id: id, + currency: 'NGN', + }) + .expect(404) + .then((response) => { + const { status, code, error } = response.body; + expect(code).toEqual(404); + expect(status).toEqual('failure'); + expect(error.name).toEqual('Not Found'); + + expect(error.message).toEqual('No account exists for this user'); + }); + }); + + it('Should throw an error when no token is provided', async () => { + return request(app.getHttpServer()) + .post('/wallets') + .set({ Authorization: `Bearer ${''}` }) + .send({ + user_id: '565a34cc-6aa5-4eaa-92f4-1b8da8fb0e5d', + currency: 'NGN', + }) + .expect(401) + .then((response) => { + const { status, code, error } = response.body; + expect(code).toEqual(401); + expect(status).toEqual('failure'); + + expect(error.message).toEqual('Unauthorized'); + }); + }); + + it('Should throw an error when no payload is provided', async () => { + return request(app.getHttpServer()) + .post('/wallets') + .set({ Authorization: `Bearer ${user.access_token}` }) + .send({}) + .expect(400) + .then((response) => { + const { status, code, error } = response.body; + expect(code).toEqual(400); + expect(status).toEqual('failure'); + expect(error.name).toEqual('Bad Request'); + expect(error.message).toEqual( + expect.arrayContaining([ + 'user_id should not be empty', + 'user_id must be a UUID', + ]), + ); + }); + }); + + it('Should throw an error when no secret key is provided', async () => { + return request(app.getHttpServer()) + .post('/wallets') + .set({ Authorization: `Bearer ${user.access_token}` }) + .send({ + user_id: id, + currency: 'NGN', + }) + .expect(201) + .then((response) => { + const { data, status } = response.body; + expect(status).toEqual('success'); + expect(data.user_id).toEqual(id); + expect(data.currency).toEqual('NGN'); + expect(data.balance).toEqual('0'); + }); + }); + }); + + describe('GET /wallets/:wallet_id/balance', () => { + const id = 'ec455a9f-7496-4529-9cb4-d235c859acd9'; + const balance = '1000'; + const currency = 'NGN'; + let wallet; + it('Should retrieve the balance of a wallet', async () => { + wallet = await walletRepository.save({ + id, + user_id: user.id, + balance, + currency, + } as unknown as Wallet); + return request(app.getHttpServer()) + .get(`/wallets/${wallet.id}/balance`) + .set({ Authorization: `Bearer ${user.access_token}` }) + .expect(200) + .then((response) => { + const { data, status } = response.body; + expect(status).toEqual('success'); + expect(data.currency).toEqual('NGN'); + expect(data.balance).toEqual('1000'); + }); + }); + it('Should throw an error when no wallet_id is provided', async () => { + return request(app.getHttpServer()) + .get(`/wallets/''/balance`) + .set({ Authorization: `Bearer ${user.access_token}` }) + .expect(400) + .then((response) => { + const { status, error } = response.body; + expect(status).toEqual('failure'); + expect(error.name).toEqual('Bad Request'); + + expect(error.message).toEqual( + expect.arrayContaining(['wallet_id must be a UUID']), + ); + }); + }); + it('Should throw an error when an invalid wallet_id is provided', async () => { + const wallet_id = 'ec455a9f-7496-4529-9cb4-d235c859acc7'; + return request(app.getHttpServer()) + .get(`/wallets/${wallet_id}/balance`) + .set({ Authorization: `Bearer ${user.access_token}` }) + .expect(404) + .then((response) => { + const { status, error } = response.body; + expect(status).toEqual('failure'); + expect(error.name).toEqual('Not Found'); + expect(error.message).toEqual('Wallet account not found'); + }); + }); + it('Should throw an error when no token is provided', async () => { + return request(app.getHttpServer()) + .get(`/wallets/${wallet.id}/balance`) + .set({ Authorization: 'Bearer ' + '' }) + .expect(401) + .then((response) => { + const { status, code, error } = response.body; + expect(code).toEqual(401); + expect(status).toEqual('failure'); + expect(error.message).toEqual('Unauthorized'); + }); + }); + }); + }); +});