├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── README.md ├── dev.env ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── __test__ │ ├── e2e │ │ ├── config │ │ │ ├── e2e.config.ts │ │ │ ├── e2e.setup.ts │ │ │ ├── e2e.teardown.ts │ │ │ └── jest-e2e.json │ │ ├── index.ts │ │ ├── relations.e2e-spec.ts │ │ ├── roles.e2e-spec.ts │ │ └── users.e2e-spec.ts │ ├── logging.interceptor.spec.ts │ └── object-id.validation.pipe.spec.ts ├── app.module.ts ├── auth │ ├── __test__ │ │ ├── auth.guard.spec.ts │ │ └── auth.middleware.spec.ts │ ├── auth.guard.ts │ ├── auth.middleware.ts │ ├── auth.roles.decorator.ts │ └── index.ts ├── common.ts ├── config │ ├── config.module.ts │ ├── config.service.ts │ └── index.ts ├── constants.ts ├── database │ ├── database.module.ts │ ├── database.providers.ts │ └── index.ts ├── logging.interceptor.ts ├── main.ts ├── object-id.validation.pipe.ts ├── roles │ ├── __test__ │ │ ├── mock.data.ts │ │ └── roles.service.spec.ts │ ├── dto │ │ └── role.dto.ts │ ├── errors │ │ └── errors.ts │ ├── index.ts │ ├── interfaces │ │ └── interfaces.ts │ ├── models │ │ └── role.model.ts │ ├── roles.controller.ts │ ├── roles.module.ts │ ├── roles.repository.ts │ ├── roles.service.ts │ └── schema │ │ └── role.schema.ts ├── user-role-rel │ ├── __test__ │ │ ├── mock.data.ts │ │ └── user-role-rel.service.spec.ts │ ├── dto │ │ └── user-role-rel.dto.ts │ ├── errors │ │ └── errors.ts │ ├── index.ts │ ├── interfaces │ │ └── interfaces.ts │ ├── models │ │ └── user-role-rel.model.ts │ ├── schema │ │ └── user-role-rel.schema.ts │ ├── user-role-rel.controller.ts │ ├── user-role-rel.module.ts │ └── user-role-rel.service.ts └── users │ ├── __test__ │ ├── mock.data.ts │ ├── users.repository.spec.ts │ └── users.service.spec.ts │ ├── dto │ └── user.dto.ts │ ├── errors │ └── errors.ts │ ├── index.ts │ ├── interfaces │ └── interfaces.ts │ ├── models │ └── user.model.ts │ ├── schema │ └── user.schema.ts │ ├── users.controller.ts │ ├── users.module.ts │ ├── users.repository.ts │ └── users.service.ts ├── test.env ├── tsconfig.build.json ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "tabWidth": 2, 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch with transpilation", 9 | "type": "node", 10 | "request": "launch", 11 | "args": ["${workspaceFolder}/src/main.ts"], 12 | "env": { 13 | "TS_NODE_TRANSPILE_ONLY": "true" 14 | }, 15 | "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], 16 | "sourceMaps": true, 17 | "cwd": "${workspaceRoot}", 18 | "protocol": "inspector" 19 | }, 20 | { 21 | "type": "node", 22 | "request": "launch", 23 | "name": "Debug app", 24 | "program": "${workspaceFolder}/dist/src/main.js" 25 | }, 26 | { 27 | "type": "node", 28 | "request": "launch", 29 | "name": "Jest File", 30 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 31 | "args": [ 32 | "--runInBand", 33 | "${fileBasenameNoExtension}", 34 | "--config=${workspaceFolder}/package.json", 35 | "--coverage=false" 36 | ], 37 | "console": "internalConsole", 38 | "internalConsoleOptions": "neverOpen", 39 | "disableOptimisticBPs": true, 40 | "windows": { 41 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 42 | } 43 | }, 44 | { 45 | "type": "node", 46 | "request": "launch", 47 | "name": "Jest File (coverage)", 48 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 49 | "args": [ 50 | "--runInBand", 51 | "${fileBasenameNoExtension}", 52 | "--config=${workspaceFolder}/package.json" 53 | ], 54 | "console": "internalConsole", 55 | "internalConsoleOptions": "neverOpen", 56 | "disableOptimisticBPs": true, 57 | "windows": { 58 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 59 | } 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 |

4 | Nest Logo 5 |

6 | 7 | [travis-image]: https://api.travis-ci.org/nestjs/nest.svg?branch=master 8 | [travis-url]: https://travis-ci.org/nestjs/nest 9 | [linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux 10 | [linux-url]: https://travis-ci.org/nestjs/nest 11 | 12 | NPM Version 13 | Package License 14 | NPM Downloads 15 | Travis 16 | Linux 17 | Coverage 18 | Gitter 19 | Backers on Open Collective 20 | Sponsors on Open Collective 21 | 22 | 23 | 24 | A simple CRUD app built with Nest.js framework and MongoDB. 25 | 26 | Before running the app ensure you have installed Node.js and MongoDB. 27 | 28 | ## Installation 29 | 30 | ```bash 31 | $ npm install 32 | ``` 33 | 34 | ## Build 35 | 36 | ```bash 37 | $ npm run build 38 | ``` 39 | 40 | ## Running the app 41 | 42 | ```bash 43 | $ npm run start 44 | ``` 45 | 46 | ## Tests 47 | 48 | ```bash 49 | # unit tests 50 | $ npm run test 51 | 52 | # e2e tests 53 | $ npm run test:e2e 54 | ``` 55 | 56 | ## License 57 | 58 | Nest is [MIT licensed](LICENSE). 59 | -------------------------------------------------------------------------------- /dev.env: -------------------------------------------------------------------------------- 1 | MONGO_URL = mongodb://localhost:27017/nestjs-test-dev 2 | NODE_ENV = dev 3 | PORT = 3000 -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-example-app", 3 | "version": "0.0.1", 4 | "description": "A sample nest js project for self training purpose", 5 | "author": "Marat Sadykov", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "tsc", 9 | "start": "ts-node -r tsconfig-paths/register src/main.ts", 10 | "start:dev": "tsc-watch -p tsconfig.build.json --onSuccess \"node dist/src/main.js\"", 11 | "lint": "tslint -p tsconfig.json -c tslint.json", 12 | "test": "jest --runInBand --verbose", 13 | "test:e2e": "jest --runInBand --forceExit --verbose --config src/__test__/e2e/config/jest-e2e.json", 14 | "precommit": "npm run lint && npm run build && npm run test" 15 | }, 16 | "dependencies": { 17 | "@hapi/joi": "^17.1.1", 18 | "@nestjs/common": "^6.11.11", 19 | "@nestjs/core": "^6.11.11", 20 | "@nestjs/mongoose": "^6.4.0", 21 | "@nestjs/platform-express": "^6.11.11", 22 | "@nestjs/swagger": "^4.4.0", 23 | "@nestjs/testing": "^6.11.11", 24 | "class-transformer": "^0.3.1", 25 | "class-validator": "^0.11.1", 26 | "dotenv": "^8.2.0", 27 | "express": "^4.17.1", 28 | "faker": "^4.1.0", 29 | "monet": "^0.9.1", 30 | "mongoose": "^5.9.5", 31 | "reflect-metadata": "^0.1.13", 32 | "request-promise-native": "^1.0.8", 33 | "rimraf": "^3.0.2", 34 | "rxjs": "^6.5.4", 35 | "swagger-ui-express": "^4.1.4", 36 | "winston": "^3.2.1" 37 | }, 38 | "devDependencies": { 39 | "@types/dotenv": "^8.2.0", 40 | "@types/express": "4.17.2", 41 | "@types/faker": "^4.1.10", 42 | "@types/hapi__joi": "^16.0.12", 43 | "@types/jest": "25.1.3", 44 | "@types/mongoose": "^5.7.7", 45 | "@types/node": "^13.9.3", 46 | "@types/request-promise-native": "^1.0.17", 47 | "@types/supertest": "2.0.8", 48 | "jest": "25.1.0", 49 | "mongodb-memory-server": "^6.4.1", 50 | "prettier": "1.19.1", 51 | "supertest": "4.0.2", 52 | "ts-jest": "25.2.1", 53 | "ts-node": "8.6.2", 54 | "tsc-watch": "4.2.2", 55 | "tsconfig-paths": "3.9.0", 56 | "tslint": "6.0.0", 57 | "typescript": "^3.8.3" 58 | }, 59 | "jest": { 60 | "moduleFileExtensions": [ 61 | "js", 62 | "json", 63 | "ts" 64 | ], 65 | "rootDir": "src", 66 | "testRegex": ".spec.ts$", 67 | "transform": { 68 | "^.+\\.(t|j)s$": "ts-jest" 69 | }, 70 | "coverageDirectory": "../coverage", 71 | "testEnvironment": "node", 72 | "testPathIgnorePatterns": [ 73 | "/__test__/e2e" 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/__test__/e2e/config/e2e.config.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | 3 | config({ path: 'test.env' }); 4 | const e2eConfig = { 5 | PORT: process.env.PORT, 6 | MONGO_URL: process.env.MONGO_URL, 7 | URL: process.env.URL, 8 | }; 9 | 10 | export { e2eConfig }; 11 | -------------------------------------------------------------------------------- /src/__test__/e2e/config/e2e.setup.ts: -------------------------------------------------------------------------------- 1 | import { bootstrap } from '../../../main'; 2 | import './e2e.config'; 3 | import { getMockUsers } from '../../../users/__test__/mock.data'; 4 | 5 | module.exports = async () => { 6 | Object.assign(process.env, { NODE_ENV: 'test' }); 7 | (global as any).__APP__ = await bootstrap(); 8 | 9 | await getMockUsers(); 10 | }; 11 | -------------------------------------------------------------------------------- /src/__test__/e2e/config/e2e.teardown.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | module.exports = async () => { 4 | try { 5 | await mongoose.connection.db.dropDatabase(); 6 | await (global as any).__APP__.close(); 7 | } catch (exception) { 8 | console.log('Exception: ', exception); 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/__test__/e2e/config/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["ts", "js", "json"], 3 | "rootDir": "../", 4 | "testRegex": "(.spec.ts$)", 5 | "testEnvironment": "node", 6 | "globalSetup": "./config/e2e.setup.ts", 7 | "globalTeardown": "./config/e2e.teardown.ts", 8 | "globals": { 9 | "ts-jest": { 10 | "tsConfig": "./tsconfig.json", 11 | "diagnostics": false, 12 | "isolatedModules": true 13 | } 14 | }, 15 | "transform": { 16 | "^.+\\.ts$": "ts-jest" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/__test__/e2e/index.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'request-promise-native'; 2 | import { ConfigService } from '../../config'; 3 | type Options = request.Options; 4 | type Response = request.FullResponse; 5 | 6 | const config = new ConfigService(process.env.NODE_ENV + '.env'); 7 | const url = config.get('URL'); 8 | const port = config.get('PORT'); 9 | 10 | export const authenticatedRequest = async (options: Options): Promise => 11 | request({ 12 | baseUrl: `${url}:${port}`, 13 | // use admin credentials to perform all api requests 14 | auth: { 15 | username: 'admin', 16 | password: '123', 17 | }, 18 | json: true, 19 | simple: false, 20 | resolveWithFullResponse: true, 21 | ...options, 22 | }); 23 | -------------------------------------------------------------------------------- /src/__test__/e2e/relations.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { authenticatedRequest } from '.'; 2 | import './config/e2e.setup'; 3 | import { UserRoleRelation } from '../../user-role-rel'; 4 | import { sample, ObjectID } from '../../common'; 5 | 6 | describe('User role relations: E2E', () => { 7 | let mockRelations: UserRoleRelation[]; 8 | 9 | beforeAll(async () => { 10 | const response = await authenticatedRequest({ 11 | method: 'GET', 12 | url: '/api/relations', 13 | }); 14 | mockRelations = response.body; 15 | }); 16 | 17 | describe('GET', () => { 18 | it('should get all relations', async () => { 19 | const response = await authenticatedRequest({ 20 | method: 'GET', 21 | url: '/api/relations', 22 | }); 23 | const relations = response.body; 24 | 25 | expect(response.statusCode).toBe(200); 26 | expect(relations.length).toBeGreaterThan(0); 27 | }); 28 | 29 | it('should get role relations assigned to user by userId', async () => { 30 | const userId = sample(mockRelations).userId; 31 | const response = await authenticatedRequest({ 32 | method: 'GET', 33 | url: `/api/relations/user?userId=${userId}`, 34 | }); 35 | const relations: UserRoleRelation[] = response.body; 36 | 37 | expect(response.statusCode).toBe(200); 38 | relations.every(relation => { 39 | expect(relation.userId).toBe(userId); 40 | }); 41 | }); 42 | 43 | it('should return 404 status if role relations assigned to user not found', async () => { 44 | const userId = new ObjectID().toHexString(); 45 | const response = await authenticatedRequest({ 46 | method: 'GET', 47 | url: `/api/relations/user?userId=${userId}`, 48 | }); 49 | 50 | expect(response.statusCode).toBe(404); 51 | expect(response.body).toMatchObject({ 52 | status: 404, 53 | error: 'RoleRelationNotFoundError', 54 | message: `Role relation to user ${userId} not found`, 55 | }); 56 | }); 57 | }); 58 | 59 | describe('CREATE', () => { 60 | it('should assign role to user', async () => { 61 | const relation = { 62 | userId: new ObjectID().toHexString(), 63 | roleId: new ObjectID().toHexString(), 64 | }; 65 | 66 | const response = await authenticatedRequest({ 67 | method: 'POST', 68 | url: '/api/relations', 69 | body: relation, 70 | }); 71 | 72 | expect(response.statusCode).toBe(201); 73 | expect(response.body.userId).toBe(relation.userId); 74 | expect(response.body.roleId).toBe(relation.roleId); 75 | }); 76 | 77 | it('should return 409 status if role relation already exists', async () => { 78 | const existingRelation = sample(mockRelations); 79 | const response = await authenticatedRequest({ 80 | method: 'POST', 81 | url: '/api/relations', 82 | body: existingRelation, 83 | }); 84 | 85 | expect(response.statusCode).toBe(409); 86 | expect(response.body).toMatchObject({ 87 | status: 409, 88 | error: 'RoleRelationAlreadyExistsError', 89 | message: `User role relation with role id:${existingRelation.roleId} already exists`, 90 | }); 91 | }); 92 | }); 93 | 94 | describe('DELETE', () => { 95 | it('should delete specific relation by id', async () => { 96 | const id = sample(mockRelations).id; 97 | const response = await authenticatedRequest({ 98 | method: 'DELETE', 99 | url: `/api/relations/${id}`, 100 | }); 101 | 102 | expect(response.statusCode).toBe(200); 103 | expect(response.body.isDeleted).toBe(true); 104 | }); 105 | 106 | it('should return 404 status if relation for delete not found', async () => { 107 | const id = new ObjectID().toHexString(); 108 | const response = await authenticatedRequest({ 109 | method: 'DELETE', 110 | url: `/api/relations/${id}`, 111 | }); 112 | 113 | expect(response.statusCode).toBe(404); 114 | expect(response.body).toMatchObject({ 115 | status: 404, 116 | error: 'RelationNotFoundError', 117 | message: `Relation ${id} not found`, 118 | }); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/__test__/e2e/roles.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { authenticatedRequest } from '.'; 2 | import './config/e2e.setup'; 3 | import { Role, RoleForCreate } from '../../roles'; 4 | import { sample, ObjectID } from '../../common'; 5 | import * as faker from 'faker'; 6 | 7 | describe('Roles: E2E', () => { 8 | let mockRoles: Role[]; 9 | 10 | beforeAll(async () => { 11 | const result = await authenticatedRequest({ 12 | method: 'GET', 13 | url: '/api/roles', 14 | }); 15 | mockRoles = result.body; 16 | }); 17 | 18 | describe('GET', () => { 19 | it('should get all roles', async () => { 20 | const response = await authenticatedRequest({ 21 | method: 'GET', 22 | url: '/api/roles', 23 | }); 24 | const roles: Role[] = response.body; 25 | 26 | expect(response.statusCode).toBe(200); 27 | expect(roles.length).toBeGreaterThan(0); 28 | }); 29 | 30 | it('should get specific role by id', async () => { 31 | const id = sample(mockRoles).id; 32 | const response = await authenticatedRequest({ 33 | method: 'GET', 34 | url: `/api/roles/${id}`, 35 | }); 36 | 37 | expect(response.statusCode).toBe(200); 38 | expect(response.body.id).toBe(id); 39 | }); 40 | 41 | it('should return 404 if role id not found', async () => { 42 | const id = new ObjectID().toHexString(); 43 | const response = await authenticatedRequest({ 44 | method: 'GET', 45 | url: `/api/roles/${id}`, 46 | }); 47 | 48 | expect(response.statusCode).toBe(404); 49 | expect(response.body).toMatchObject({ 50 | status: 404, 51 | error: 'RoleNotFoundError', 52 | message: `Role id:${id} not found`, 53 | }); 54 | }); 55 | }); 56 | 57 | describe('CREATE', () => { 58 | it('should create new role', async () => { 59 | const role: RoleForCreate = { 60 | name: faker.lorem.word(), 61 | displayName: faker.lorem.word().toUpperCase(), 62 | description: faker.lorem.sentence(), 63 | }; 64 | 65 | const response = await authenticatedRequest({ 66 | method: 'POST', 67 | url: '/api/roles', 68 | body: role, 69 | }); 70 | 71 | expect(response.statusCode).toBe(201); 72 | expect(response.body.name).toBe(role.name); 73 | expect(response.body.displayName).toBe(role.displayName); 74 | expect(response.body.description).toBe(role.description); 75 | }); 76 | 77 | it('should return 409 if role already exists', async () => { 78 | const existingRole = sample(mockRoles); 79 | const response = await authenticatedRequest({ 80 | method: 'POST', 81 | url: '/api/roles', 82 | body: existingRole, 83 | }); 84 | 85 | expect(response.statusCode).toBe(409); 86 | expect(response.body).toMatchObject({ 87 | status: 409, 88 | error: 'RoleAlreadyExistsError', 89 | message: `Role name:${existingRole.name} already exists`, 90 | }); 91 | }); 92 | }); 93 | 94 | describe('PATCH', () => { 95 | it('should update specific role by id', async () => { 96 | const id = sample(mockRoles).id; 97 | const patch = { 98 | name: faker.lorem.word(), 99 | displayName: faker.lorem.word().toUpperCase(), 100 | }; 101 | 102 | const response = await authenticatedRequest({ 103 | method: 'PATCH', 104 | url: `/api/roles/${id}`, 105 | body: patch, 106 | }); 107 | 108 | expect(response.statusCode).toBe(200); 109 | expect(response.body.name).toBe(patch.name); 110 | expect(response.body.displayName).toBe(patch.displayName); 111 | }); 112 | 113 | it('should return 404 status if role for update not found', async () => { 114 | const id = new ObjectID().toHexString(); 115 | const patch = { 116 | name: faker.lorem.word(), 117 | displayName: faker.lorem.word().toUpperCase(), 118 | }; 119 | 120 | const response = await authenticatedRequest({ 121 | method: 'PATCH', 122 | url: `/api/roles/${id}`, 123 | body: patch, 124 | }); 125 | 126 | expect(response.statusCode).toBe(404); 127 | expect(response.body).toMatchObject({ 128 | status: 404, 129 | error: 'RoleNotFoundError', 130 | message: `Role id:${id} not found`, 131 | }); 132 | }); 133 | }); 134 | 135 | describe('DELETE', () => { 136 | it('should delete specific role by id', async () => { 137 | const id = sample(mockRoles).id; 138 | const response = await authenticatedRequest({ 139 | method: 'DELETE', 140 | url: `/api/roles/${id}`, 141 | }); 142 | 143 | expect(response.statusCode).toBe(200); 144 | expect(response.body.isDeleted).toBe(true); 145 | }); 146 | 147 | it('should return 404 if role for delete not found', async () => { 148 | const id = new ObjectID().toHexString(); 149 | const response = await authenticatedRequest({ 150 | method: 'DELETE', 151 | url: `/api/roles/${id}`, 152 | }); 153 | 154 | expect(response.statusCode).toBe(404); 155 | expect(response.body).toMatchObject({ 156 | status: 404, 157 | error: 'RoleNotFoundError', 158 | message: `Role id:${id} not found`, 159 | }); 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/__test__/e2e/users.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { authenticatedRequest } from '.'; 2 | import './config/e2e.setup'; 3 | import { User } from '../../users'; 4 | import { sample, ObjectID } from '../../common'; 5 | import * as faker from 'faker'; 6 | 7 | faker.seed(65); 8 | 9 | describe('Users: E2E', () => { 10 | let mockUsers: User[]; 11 | 12 | beforeAll(async () => { 13 | const result = await authenticatedRequest({ 14 | method: 'GET', 15 | url: '/api/users', 16 | }); 17 | mockUsers = result.body; 18 | }); 19 | 20 | describe('GET', () => { 21 | it('should get all users', async () => { 22 | const response = await authenticatedRequest({ 23 | method: 'GET', 24 | url: '/api/users', 25 | }); 26 | const users: User[] = response.body; 27 | 28 | expect(response.statusCode).toBe(200); 29 | expect(users.length).toBeGreaterThan(0); 30 | }); 31 | 32 | it('should get specific user by id', async () => { 33 | const id = sample(mockUsers).id; 34 | const response = await authenticatedRequest({ 35 | method: 'GET', 36 | url: `/api/users/${id}`, 37 | }); 38 | 39 | expect(response.statusCode).toBe(200); 40 | expect(response.body.id).toBe(id); 41 | }); 42 | 43 | it('should return 404 status if user id not found', async () => { 44 | const id = new ObjectID().toHexString(); 45 | 46 | const response = await authenticatedRequest({ 47 | method: 'GET', 48 | url: `/api/users/${id}`, 49 | }); 50 | 51 | expect(response.statusCode).toBe(404); 52 | expect(response.body).toMatchObject({ 53 | status: 404, 54 | error: 'UserNotFoundError', 55 | message: `User ${id} not found`, 56 | }); 57 | }); 58 | }); 59 | 60 | describe('CREATE', () => { 61 | it('should create new user', async () => { 62 | const user = { 63 | username: faker.internet.userName(), 64 | password: faker.internet.password(), 65 | }; 66 | 67 | const response = await authenticatedRequest({ 68 | method: 'POST', 69 | url: `/api/users/`, 70 | body: user, 71 | }); 72 | 73 | expect(response.statusCode).toBe(201); 74 | expect(response.body.username).toBe(user.username); 75 | }); 76 | 77 | it('should return 409 status if user already exists', async () => { 78 | const existingUser = sample(mockUsers); 79 | 80 | const response = await authenticatedRequest({ 81 | method: 'POST', 82 | url: '/api/users/', 83 | body: existingUser, 84 | }); 85 | 86 | expect(response.statusCode).toBe(409); 87 | expect(response.body).toMatchObject({ 88 | status: 409, 89 | error: 'UserAlreadyExistsError', 90 | message: `User ${existingUser.username} already exists`, 91 | }); 92 | }); 93 | }); 94 | 95 | describe('PATCH', () => { 96 | it('should update specific user by id', async () => { 97 | const id = sample(mockUsers).id; 98 | const patch = { 99 | username: faker.internet.userName(), 100 | }; 101 | 102 | const response = await authenticatedRequest({ 103 | method: 'PATCH', 104 | url: `/api/users/${id}`, 105 | body: patch, 106 | }); 107 | 108 | expect(response.statusCode).toBe(200); 109 | expect(response.body.id).toBe(id); 110 | expect(response.body.username).toBe(patch.username); 111 | }); 112 | 113 | it('should return 404 status if user for update not found', async () => { 114 | const id = new ObjectID().toHexString(); 115 | const patch = { 116 | username: faker.internet.userName(), 117 | password: faker.internet.password(), 118 | }; 119 | 120 | const response = await authenticatedRequest({ 121 | method: 'PATCH', 122 | url: `/api/users/${id}`, 123 | body: patch, 124 | }); 125 | 126 | expect(response.statusCode).toBe(404); 127 | expect(response.body).toMatchObject({ 128 | status: 404, 129 | error: 'UserNotFoundError', 130 | message: `User ${id} not found`, 131 | }); 132 | }); 133 | }); 134 | 135 | describe('DELETE', () => { 136 | it('should delete specific user by id', async () => { 137 | const id = sample(mockUsers).id; 138 | const response = await authenticatedRequest({ 139 | method: 'DELETE', 140 | url: `/api/users/${id}`, 141 | }); 142 | 143 | expect(response.statusCode).toBe(200); 144 | expect(response.body.isDeleted).toBe(true); 145 | }); 146 | 147 | it('should return 404 status if user for delete not found', async () => { 148 | const id = new ObjectID().toHexString(); 149 | const response = await authenticatedRequest({ 150 | method: 'DELETE', 151 | url: `/api/users/${id}`, 152 | }); 153 | 154 | expect(response.statusCode).toBe(404); 155 | expect(response.body).toMatchObject({ 156 | status: 404, 157 | error: 'UserNotFoundError', 158 | message: `User ${id} not found`, 159 | }); 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/__test__/logging.interceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import { LoggingInterceptor } from './../logging.interceptor'; 2 | import { createLogger } from 'winston'; 3 | import * as faker from 'faker'; 4 | import { throwError } from 'rxjs'; 5 | 6 | describe('Logging interceptor', () => { 7 | const request = { url: faker.internet.url(), method: 'PUT' }; 8 | const context: any = { 9 | getHandler: jest.fn(), 10 | switchToHttp: () => ({ getRequest: () => request }), 11 | }; 12 | 13 | const logger = createLogger({ silent: true }); 14 | const interceptor = new LoggingInterceptor(logger); 15 | const log = jest.spyOn(logger, 'error'); 16 | 17 | it('should log error and throw internal server error in case of exception', async () => { 18 | const errorMessage = 'Some error happened'; 19 | const next: any = { 20 | handle: () => throwError(new Error(errorMessage)), // unhandled exception 21 | }; 22 | 23 | interceptor.intercept(context, next).subscribe({ 24 | error(err) { 25 | expect(err.response.statusCode).toBe(500); 26 | expect(err.response.message).toBe('Internal Server Error'); 27 | }, 28 | }); 29 | 30 | const logData: any = log.mock.calls[0][0]; 31 | 32 | expect(logData.url).toBe(request.url); 33 | expect(logData.method).toBe(request.method); 34 | expect(logData.message).toBe(errorMessage); 35 | expect(logData.stack).toBeTruthy(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/__test__/object-id.validation.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { ObjectIdValidationPipe } from './../object-id.validation.pipe'; 2 | import { ObjectID } from './../common'; 3 | 4 | function sample(list: Array) { 5 | return list[Math.floor(Math.random() * list.length)]; 6 | } 7 | 8 | describe('ObjectId validation pipe', () => { 9 | const pipe = new ObjectIdValidationPipe(); 10 | 11 | it('should return valid object id', () => { 12 | const id = new ObjectID().toHexString(); 13 | const result = pipe.transform(id); 14 | 15 | expect(result).toBe(id); 16 | }); 17 | 18 | it('should throw BadRequestException in case of invalid object id', async () => { 19 | try { 20 | const ids = [ 21 | '82defcf324571e70b0521d79cce2bf3fffccd69', 22 | 'c1a050a4cd1556948d41', 23 | 'zzzzzzzzzzzz', 24 | 'South Africa', 25 | ]; 26 | const invalidId = sample(ids); 27 | 28 | await pipe.transform(invalidId); 29 | } catch (exception) { 30 | expect(exception.response.statusCode).toBe(400); 31 | expect(exception.response.message).toBe('ObjectId cast error. Invalid id'); 32 | } 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Module } from '@nestjs/common'; 2 | import { Authenticate } from './auth'; 3 | import { ConfigModule } from './config'; 4 | import { DatabaseModule } from './database'; 5 | import { IRolesService, RolesModule } from './roles'; 6 | import { IUserRoleRelService, UserRoleRelModule } from './user-role-rel'; 7 | import { IUsersService, UsersModule } from './users'; 8 | 9 | @Module({}) 10 | export class AppModule { 11 | static async forRoot(dependencies: { 12 | usersService: IUsersService; 13 | rolesService: IRolesService; 14 | userRoleRelService: IUserRoleRelService; 15 | authenticate: Authenticate; 16 | }): Promise { 17 | return { 18 | module: AppModule, 19 | imports: [ 20 | ConfigModule, 21 | DatabaseModule, 22 | RolesModule.forRoot(dependencies), 23 | UsersModule.forRoot(dependencies), 24 | UserRoleRelModule.forRoot(dependencies), 25 | ], 26 | }; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/auth/__test__/auth.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { AuthGuard } from '../auth.guard'; 3 | import { AuthenticatedRequest, authenticate as _authenticate } from '../auth.middleware'; 4 | import { Right } from 'monet'; 5 | 6 | describe('Auth Guard', () => { 7 | const reflector = { 8 | get: jest.fn(), 9 | }; 10 | const authGuard = new AuthGuard(reflector as any); 11 | const userService = { 12 | getUserCredentials: jest.fn(), 13 | }; 14 | const authenticate = _authenticate(userService as any); 15 | const response = {}; 16 | const next = jest.fn(); 17 | const context = { 18 | getHandler: jest.fn(), 19 | switchToHttp: jest.fn(), 20 | }; 21 | const roles = ['admin', 'guest']; 22 | 23 | let mockReflectorSpy: any = jest.fn(); 24 | 25 | async function mockAuthenticateUserImplementationOnce(mockUser: any) { 26 | const request: AuthenticatedRequest = { 27 | headers: { 28 | authorization: 'Basic ' + Buffer.from('username:password').toString('base64'), 29 | }, 30 | }; 31 | 32 | mockReflectorSpy = jest.spyOn(reflector, 'get').mockReturnValueOnce(roles); 33 | 34 | context.switchToHttp.mockImplementationOnce(() => { 35 | return { 36 | getRequest: () => request, 37 | }; 38 | }); 39 | 40 | userService.getUserCredentials.mockResolvedValueOnce(Promise.resolve(Right(mockUser))); 41 | 42 | await authenticate(request, response as any, next); 43 | } 44 | 45 | it('should return true if user has roles assigned', async () => { 46 | const user = { 47 | username: 'username', 48 | roles: ['admin'], 49 | }; 50 | 51 | await mockAuthenticateUserImplementationOnce(user); 52 | const canActivate = authGuard.canActivate(context as any); 53 | 54 | expect(canActivate).toBe(true); 55 | mockReflectorSpy.mockReset(); 56 | }); 57 | 58 | it('should return false if user has no roles assigned', async () => { 59 | const user = { 60 | username: 'username', 61 | roles: [], 62 | }; 63 | 64 | await mockAuthenticateUserImplementationOnce(user); 65 | const canActivate = authGuard.canActivate(context as any); 66 | 67 | expect(canActivate).toBe(false); 68 | mockReflectorSpy.mockReset(); 69 | }); 70 | 71 | it('should return false if user has invalid roles assigned', async () => { 72 | const user = { 73 | username: 'username', 74 | roles: ['non_existing_role'], 75 | }; 76 | 77 | await mockAuthenticateUserImplementationOnce(user); 78 | const canActivate = authGuard.canActivate(context as any); 79 | 80 | expect(canActivate).toBe(false); 81 | mockReflectorSpy.mockReset(); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/auth/__test__/auth.middleware.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { authenticate as _authenticate, AuthenticatedRequest } from '../auth.middleware'; 3 | import { Right, Left } from 'monet'; 4 | 5 | describe('Auth middleware', () => { 6 | const userService = { 7 | getUserCredentials: jest.fn(), 8 | }; 9 | const authenticate = _authenticate(userService as any); 10 | const response = {}; 11 | const next = jest.fn(); 12 | 13 | it('should modify request globally with authenticated user', async () => { 14 | const request: AuthenticatedRequest = { 15 | headers: { 16 | authorization: 'Basic ' + Buffer.from(`username:password`).toString('base64'), 17 | }, 18 | }; 19 | 20 | jest.spyOn(userService, 'getUserCredentials').mockImplementationOnce(async () => 21 | Right( 22 | (request.user = { 23 | username: 'username', 24 | roles: ['role'], 25 | }), 26 | ), 27 | ); 28 | 29 | await authenticate(request, response as any, next); 30 | 31 | expect(request.user).toMatchObject({ 32 | username: 'username', 33 | roles: ['role'], 34 | }); 35 | }); 36 | 37 | it('should throw Unauthorized exception if user has no authorization headers', async () => { 38 | const request: AuthenticatedRequest = { 39 | headers: {}, 40 | }; 41 | 42 | jest.spyOn(userService, 'getUserCredentials').mockImplementationOnce(async () => 43 | Right( 44 | (request.user = { 45 | username: 'username', 46 | roles: ['role'], 47 | }), 48 | ), 49 | ); 50 | 51 | try { 52 | await authenticate(request, response as any, next); 53 | } catch (error) { 54 | expect(error.status).toBe(401); 55 | expect(error.response).toMatchObject({ 56 | statusCode: 401, 57 | error: 'Unauthorized', 58 | }); 59 | } 60 | }); 61 | 62 | it('should throw Unauthorized exception if user provided wrong login or password', async () => { 63 | const request: AuthenticatedRequest = { 64 | headers: { 65 | authorization: 'Basic ' + Buffer.from(`wronguser:wrongpassword`).toString('base64'), 66 | }, 67 | }; 68 | 69 | jest.spyOn(userService, 'getUserCredentials').mockImplementationOnce(async () => 70 | Left( 71 | (request.user = { 72 | username: 'username', 73 | roles: ['role'], 74 | }), 75 | ), 76 | ); 77 | 78 | try { 79 | await authenticate(request, response as any, next); 80 | } catch (error) { 81 | expect(error.status).toBe(401); 82 | expect(error.response).toMatchObject({ 83 | statusCode: 401, 84 | error: 'Unauthorized', 85 | }); 86 | } 87 | }); 88 | 89 | it('should throw Unauthorized exception if user does not exists in system', async () => { 90 | const request: AuthenticatedRequest = { 91 | headers: { 92 | authorization: 'Basic ' + Buffer.from(`notexists:pwd`).toString('base64'), 93 | }, 94 | }; 95 | 96 | jest.spyOn(userService, 'getUserCredentials').mockImplementationOnce(async () => 97 | Left( 98 | (request.user = { 99 | username: 'username', 100 | roles: ['role'], 101 | }), 102 | ), 103 | ); 104 | 105 | try { 106 | await authenticate(request, response as any, next); 107 | } catch (error) { 108 | expect(error.status).toBe(401); 109 | expect(error.response).toMatchObject({ 110 | statusCode: 401, 111 | error: 'Unauthorized', 112 | }); 113 | } 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/auth/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | 4 | @Injectable() 5 | export class AuthGuard implements CanActivate { 6 | constructor(private readonly reflector: Reflector) {} 7 | 8 | canActivate(context: ExecutionContext): boolean { 9 | const roles = this.reflector.get('roles', context.getHandler()); 10 | 11 | if (!roles) { 12 | return false; 13 | } 14 | 15 | const request = context.switchToHttp().getRequest(); 16 | const user = request.user; 17 | 18 | if (!user) { 19 | return false; 20 | } 21 | 22 | return user.roles.some((role: string) => roles.includes(role)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/auth/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import { UnauthorizedException } from '@nestjs/common'; 2 | import { Response, NextFunction } from 'express'; 3 | import { IUsersService } from '../users/'; 4 | 5 | export type AuthenticatedRequest = { 6 | [key: string]: any; 7 | }; 8 | 9 | export type Authenticate = ( 10 | req: AuthenticatedRequest, 11 | _: Response, 12 | next: NextFunction, 13 | ) => Promise; 14 | 15 | export function authenticate(usersService: IUsersService): Authenticate { 16 | return async (req, _, next) => { 17 | const authHeaders = req.headers.authorization; 18 | 19 | if (!authHeaders) { 20 | throw new UnauthorizedException(); 21 | } 22 | 23 | const [username, password] = getAuthorizationCredentials(authHeaders); 24 | const user = await usersService.getUserCredentials(username, password); 25 | 26 | if (user.isLeft()) { 27 | throw new UnauthorizedException(); 28 | } 29 | 30 | req.user = user.right(); 31 | 32 | next(); 33 | }; 34 | } 35 | 36 | function getAuthorizationCredentials(header: string) { 37 | const base64Credentials = header.split(' ')[1]; 38 | const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); 39 | 40 | return credentials.split(':'); 41 | } 42 | -------------------------------------------------------------------------------- /src/auth/auth.roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const Roles = (roles: string[]) => SetMetadata('roles', roles); 4 | -------------------------------------------------------------------------------- /src/auth/index.ts: -------------------------------------------------------------------------------- 1 | export { AuthGuard } from './auth.guard'; 2 | export { Authenticate, authenticate } from './auth.middleware'; 3 | export { Roles } from './auth.roles.decorator'; 4 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | // tslint:disable-next-line: no-implicit-dependencies 3 | import { MongoMemoryServer } from 'mongodb-memory-server'; 4 | import { User } from './users'; 5 | import { Role } from './roles'; 6 | import { UserRoleRelation } from './user-role-rel'; 7 | 8 | export type ObjectId = mongoose.Schema.Types.ObjectId; 9 | export const ObjectID = mongoose.mongo.ObjectId; 10 | 11 | // testing 12 | const mongoMemoryServer = new MongoMemoryServer(); 13 | 14 | export async function establishDbConnection() { 15 | const options = { 16 | useNewUrlParser: true, 17 | useUnifiedTopology: true, 18 | useCreateIndex: true, 19 | useFindAndModify: false, 20 | }; 21 | 22 | const uri = await mongoMemoryServer.getUri(); 23 | await mongoose.connect(uri, options); 24 | } 25 | 26 | export async function closeDbConnection() { 27 | await mongoose.disconnect(); 28 | await mongoMemoryServer.stop(); 29 | } 30 | 31 | // lodash sample 32 | export function sample(list: Array): UserRoleRelation; 33 | export function sample(list: Array): Role; 34 | export function sample(list: Array): User; 35 | export function sample(list: Array) { 36 | return list[Math.floor(Math.random() * list.length)]; 37 | } 38 | -------------------------------------------------------------------------------- /src/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigService } from './config.service'; 3 | 4 | @Module({ 5 | providers: [ 6 | { 7 | provide: ConfigService, 8 | useValue: new ConfigService(`${process.env.NODE_ENV || 'dev'}.env`), 9 | }, 10 | ], 11 | exports: [ConfigService], 12 | }) 13 | export class ConfigModule {} 14 | -------------------------------------------------------------------------------- /src/config/config.service.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import * as Joi from '@hapi/joi'; 3 | import * as fs from 'fs'; 4 | 5 | type EnvConfig = { 6 | [key: string]: string; 7 | }; 8 | 9 | export class ConfigService { 10 | private readonly envConfig: EnvConfig; 11 | 12 | constructor(filePath: string) { 13 | const config = dotenv.parse(fs.readFileSync(filePath)); 14 | this.envConfig = this.validateInput(config); 15 | } 16 | 17 | private validateInput(envConfig: EnvConfig): EnvConfig { 18 | const envVarSchema: Joi.ObjectSchema = Joi.object({ 19 | NODE_ENV: Joi.string().valid('dev', 'prod', 'test'), 20 | // .default('dev'), 21 | MONGO_URL: Joi.string(), 22 | PORT: Joi.number(), 23 | URL: Joi.string(), 24 | }); 25 | 26 | const { error, value: validatedEnvConfig } = envVarSchema.validate(envConfig); 27 | 28 | if (error) { 29 | throw new Error(`Config validation error: ${error.message}`); 30 | } 31 | 32 | return validatedEnvConfig; 33 | } 34 | 35 | get(key: string): string { 36 | return this.envConfig[key]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export { ConfigModule } from './config.module'; 2 | export { ConfigService } from './config.service'; 3 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; 2 | 3 | export const ROLE_MODEL = 'ROLE_MODEL'; 4 | export const USER_ROLE_RELATION_MODEL = 'USER_ROLE_RELATION_MODEL'; 5 | export const USER_MODEL = 'USER_MODEL'; 6 | 7 | export const ROLES_SERVICE = 'ROLES_SERVICE'; 8 | export const USER_ROLE_RELATION_SERVICE = 'USER_ROLE_RELATION_SERVICE'; 9 | export const USERS_SERVICE = 'USERS_SERVICE'; 10 | 11 | export const AUTHENTICATE = 'AUTHENTICATE'; 12 | -------------------------------------------------------------------------------- /src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { databaseProviders } from './database.providers'; 3 | import { ConfigModule } from '../config'; 4 | 5 | @Module({ 6 | imports: [ConfigModule], 7 | providers: [...databaseProviders], 8 | exports: [...databaseProviders], 9 | }) 10 | export class DatabaseModule {} 11 | -------------------------------------------------------------------------------- /src/database/database.providers.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import { DATABASE_CONNECTION } from '../constants'; 3 | import { ConfigService } from '../config'; 4 | 5 | export const databaseProviders = [ 6 | { 7 | provide: DATABASE_CONNECTION, 8 | useFactory: (config: ConfigService): Promise => { 9 | const MONGO_URL = config.get('MONGO_URL'); 10 | const options = { 11 | useNewUrlParser: true, 12 | useUnifiedTopology: true, 13 | useCreateIndex: true, 14 | useFindAndModify: false, 15 | }; 16 | 17 | return mongoose.connect(MONGO_URL, options); 18 | }, 19 | inject: [ConfigService], 20 | }, 21 | ]; 22 | -------------------------------------------------------------------------------- /src/database/index.ts: -------------------------------------------------------------------------------- 1 | export { DatabaseModule } from './database.module'; 2 | -------------------------------------------------------------------------------- /src/logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | InternalServerErrorException, 7 | HttpException, 8 | HttpStatus, 9 | } from '@nestjs/common'; 10 | import { Logger } from 'winston'; 11 | import { Request } from 'express'; 12 | import { Observable } from 'rxjs'; 13 | import { catchError } from 'rxjs/operators'; 14 | 15 | @Injectable() 16 | export class LoggingInterceptor implements NestInterceptor { 17 | constructor(private readonly logger: Logger) {} 18 | 19 | intercept(context: ExecutionContext, next: CallHandler): Observable { 20 | const request: Request = context.switchToHttp().getRequest(); 21 | const { url, method } = request; 22 | 23 | return next.handle().pipe( 24 | catchError(error => { 25 | if (error instanceof HttpException) { 26 | throw error; 27 | } 28 | 29 | this.logger.error({ 30 | url, 31 | method, 32 | message: error.message, 33 | stack: error.stack, 34 | }); 35 | 36 | throw new InternalServerErrorException({ 37 | statusCode: HttpStatus.INTERNAL_SERVER_ERROR, 38 | message: 'Internal Server Error', 39 | }); 40 | }), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory, Reflector } from '@nestjs/core'; 3 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 4 | import { createLogger, format, Logger, transports } from 'winston'; 5 | import { AppModule } from './app.module'; 6 | import { authenticate as _authenticate, AuthGuard } from './auth'; 7 | import { LoggingInterceptor } from './logging.interceptor'; 8 | import { RolesMapper, rolesModel, RolesRepository, RolesService } from './roles'; 9 | import { UserRoleRelationMapper, userRoleRelModel, UserRoleRelService } from './user-role-rel'; 10 | import { UsersMapper, usersModel, UsersRepository, UsersService } from './users'; 11 | 12 | export { bootstrap }; 13 | 14 | async function bootstrap() { 15 | const mapper = new UserRoleRelationMapper(); 16 | const userRoleRelService = new UserRoleRelService(userRoleRelModel, mapper); 17 | 18 | const rolesMapper = new RolesMapper(); 19 | const rolesRepository = new RolesRepository(rolesModel, rolesMapper); 20 | const rolesService = new RolesService(rolesRepository); 21 | 22 | const usersMapper = new UsersMapper(); 23 | const usersRepository = new UsersRepository(usersModel, usersMapper); 24 | const usersService = new UsersService(usersRepository, rolesService, userRoleRelService); 25 | 26 | const authenticate = _authenticate(usersService); 27 | 28 | const app = await NestFactory.create( 29 | AppModule.forRoot({ usersService, rolesService, userRoleRelService, authenticate }), 30 | ); 31 | 32 | const logger: Logger = createLogger({ 33 | level: 'info', 34 | format: format.json(), 35 | defaultMeta: { service: 'app-module' }, 36 | transports: [new transports.Console()], 37 | }); 38 | 39 | const reflector = new Reflector(); 40 | 41 | app.useGlobalPipes(new ValidationPipe()); 42 | app.useGlobalGuards(new AuthGuard(reflector)); 43 | app.useGlobalInterceptors(new LoggingInterceptor(logger)); 44 | 45 | const options = new DocumentBuilder() 46 | .setTitle('Nest.js example app') 47 | .setDescription('Use auto generated admin user credentials to authenticate into swagger') 48 | .setVersion('1.0') 49 | .addBasicAuth() 50 | .build(); 51 | 52 | app.setGlobalPrefix('/api'); 53 | 54 | const document = SwaggerModule.createDocument(app, options); 55 | SwaggerModule.setup('api', app, document); 56 | 57 | const port = process.env.PORT || 3000; 58 | 59 | await app.listen(port); 60 | 61 | console.log(`Listening on port: ${port}`); 62 | console.log(`Explore api on http://localhost:${port}/api`); 63 | 64 | return app; 65 | } 66 | 67 | if (require.main === module) { 68 | bootstrap(); 69 | } 70 | -------------------------------------------------------------------------------- /src/object-id.validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, PipeTransform, BadRequestException } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class ObjectIdValidationPipe implements PipeTransform { 5 | transform(value: any) { 6 | /** 7 | * mongoose validation does not provide proper validation of objectId 8 | * ex: "South Africa" or "zzzzzzzzzzzz" or any 12 character string is considered as valid id 9 | * issue: https://github.com/Automattic/mongoose/issues/1959 10 | */ 11 | const objectIdRegEx = new RegExp('^[0-9a-fA-F]{24}$'); 12 | 13 | if (!objectIdRegEx.test(value)) { 14 | throw new BadRequestException('ObjectId cast error. Invalid id'); 15 | } 16 | 17 | return value; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/roles/__test__/mock.data.ts: -------------------------------------------------------------------------------- 1 | import { Role, RoleForCreate } from '../models/role.model'; 2 | import { RolesMapper } from '../roles.repository'; 3 | import { rolesModel } from '../schema/role.schema'; 4 | import * as faker from 'faker'; 5 | 6 | faker.seed(111); 7 | 8 | const mapper = new RolesMapper(); 9 | 10 | export async function getMockRoles() { 11 | const roles: Role[] = []; 12 | 13 | for (let index = 1; index < 10; index++) { 14 | const role: RoleForCreate = { 15 | name: `role_${index}`, 16 | displayName: faker.lorem.word().toUpperCase(), 17 | description: faker.lorem.sentence(), 18 | }; 19 | 20 | const mockRole = await rolesModel.create(role); 21 | roles.push(mapper.fromEntity(mockRole)); 22 | } 23 | 24 | return roles; 25 | } 26 | -------------------------------------------------------------------------------- /src/roles/__test__/roles.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { RolesService } from '../roles.service'; 2 | import { establishDbConnection, closeDbConnection, sample, ObjectID } from '../../common'; 3 | import { Role, RoleForCreate, RoleForUpdate } from '../models/role.model'; 4 | import { getMockRoles } from './mock.data'; 5 | import { RolesRepository, RolesMapper } from '../roles.repository'; 6 | import { rolesModel } from '../schema/role.schema'; 7 | import * as faker from 'faker'; 8 | import { RoleAlreadyExistsError, RoleNotFoundError } from '../errors/errors'; 9 | 10 | faker.seed(478); 11 | 12 | describe('Roles service', () => { 13 | let rolesService: RolesService; 14 | let mockRoles: Role[]; 15 | 16 | beforeAll(async () => { 17 | await establishDbConnection(); 18 | mockRoles = await getMockRoles(); 19 | }); 20 | 21 | afterAll(async () => { 22 | await closeDbConnection(); 23 | }); 24 | 25 | beforeEach(() => { 26 | const mapper = new RolesMapper(); 27 | const repository = new RolesRepository(rolesModel, mapper); 28 | 29 | rolesService = new RolesService(repository); 30 | }); 31 | 32 | describe('create', () => { 33 | it('should create role', async () => { 34 | const mockRole: RoleForCreate = { 35 | name: faker.lorem.word(), 36 | displayName: faker.lorem.word().toUpperCase(), 37 | description: faker.lorem.sentence(), 38 | }; 39 | 40 | const result = await rolesService.create(mockRole); 41 | const role = result.right(); 42 | 43 | expect(role.name).toBe(mockRole.name); 44 | expect(role.displayName).toBe(mockRole.displayName); 45 | expect(role.description).toBe(mockRole.description); 46 | }); 47 | 48 | it('should return "RoleAlreadyExistsError" in case if same role exists', async () => { 49 | const role = sample(mockRoles); 50 | 51 | const result = await rolesService.create(role); 52 | const error = result.left(); 53 | 54 | expect(error).toBeInstanceOf(RoleAlreadyExistsError); 55 | }); 56 | }); 57 | 58 | describe('get', () => { 59 | it('should get all roles', async () => { 60 | const roles = await rolesService.getAll(); 61 | 62 | expect(roles.length).toBeGreaterThan(0); 63 | }); 64 | 65 | it('should get specific role by id', async () => { 66 | const id = sample(mockRoles).id; 67 | 68 | const result = await rolesService.findOne(id); 69 | const role = result.right(); 70 | 71 | expect(role.id).toBe(id); 72 | }); 73 | 74 | it('should return "RoleNotFoundError" in case if role is not found', async () => { 75 | const id = new ObjectID().toHexString(); 76 | 77 | const result = await rolesService.findOne(id); 78 | const error = result.left(); 79 | 80 | expect(error).toBeInstanceOf(RoleNotFoundError); 81 | }); 82 | }); 83 | 84 | describe('update', () => { 85 | it('should update specific role by id', async () => { 86 | const id = sample(mockRoles).id; 87 | const patch: RoleForUpdate = { 88 | name: 'role_for_update', 89 | displayName: faker.lorem.word().toUpperCase(), 90 | description: faker.lorem.text(), 91 | }; 92 | 93 | const result = await rolesService.update(id, patch); 94 | const updatedRole = result.right(); 95 | 96 | expect(updatedRole.name).toBe(patch.name); 97 | expect(updatedRole.displayName).toBe(patch.displayName); 98 | expect(updatedRole.description).toBe(patch.description); 99 | }); 100 | 101 | it('should return "RoleNotFoundError" in case if role for update is not exist', async () => { 102 | const id = new ObjectID().toHexString(); 103 | const patch: RoleForUpdate = { 104 | description: 'some_text', 105 | }; 106 | 107 | const result = await rolesService.update(id, patch); 108 | const error = result.left(); 109 | 110 | expect(error).toBeInstanceOf(RoleNotFoundError); 111 | }); 112 | }); 113 | 114 | describe('delete', () => { 115 | it('should remove specific role by id', async () => { 116 | const id = sample(mockRoles).id; 117 | 118 | const result = await rolesService.delete(id); 119 | const role = result.right(); 120 | 121 | expect(role.isDeleted).toBe(true); 122 | }); 123 | 124 | it('should return "RoleNotFoundError" in case if role for delete is not exist', async () => { 125 | const id = new ObjectID().toHexString(); 126 | 127 | const result = await rolesService.delete(id); 128 | const error = result.left(); 129 | 130 | expect(error).toBeInstanceOf(RoleNotFoundError); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/roles/dto/role.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; 2 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 3 | 4 | export class CreateRoleDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty() 8 | readonly name: string; 9 | 10 | @IsString() 11 | @IsNotEmpty() 12 | @ApiProperty() 13 | readonly displayName: string; 14 | 15 | @IsString() 16 | @IsNotEmpty() 17 | @ApiProperty() 18 | readonly description: string; 19 | } 20 | 21 | export class UpdateRoleDto { 22 | @IsString() 23 | @IsOptional() 24 | @ApiPropertyOptional() 25 | readonly name?: string; 26 | 27 | @IsString() 28 | @IsOptional() 29 | @ApiPropertyOptional() 30 | readonly displayName?: string; 31 | 32 | @IsString() 33 | @IsOptional() 34 | @ApiPropertyOptional() 35 | readonly description?: string; 36 | } 37 | 38 | export class RolePresentationDto { 39 | @ApiProperty() 40 | id: string; 41 | 42 | @ApiProperty() 43 | name: string; 44 | 45 | @ApiProperty() 46 | displayName: string; 47 | 48 | @ApiProperty() 49 | description: string; 50 | 51 | @ApiProperty() 52 | isDeleted: boolean; 53 | } 54 | -------------------------------------------------------------------------------- /src/roles/errors/errors.ts: -------------------------------------------------------------------------------- 1 | export class RoleNotFoundError extends Error { 2 | constructor(id: string) { 3 | super(`Role id:${id} not found`); 4 | 5 | this.name = 'RoleNotFoundError'; 6 | } 7 | } 8 | 9 | export class RoleAlreadyExistsError extends Error { 10 | constructor(name: string) { 11 | super(`Role name:${name} already exists`); 12 | 13 | this.name = 'RoleAlreadyExistsError'; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/roles/index.ts: -------------------------------------------------------------------------------- 1 | export * from './models/role.model'; 2 | export * from './errors/errors'; 3 | export * from './schema/role.schema'; 4 | export * from './interfaces/interfaces'; 5 | export { RolesMapper, RolesRepository } from './roles.repository'; 6 | export { RolesService } from './roles.service'; 7 | export { RolesModule } from './roles.module'; 8 | -------------------------------------------------------------------------------- /src/roles/interfaces/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { Role, RoleForCreate, RoleForUpdate } from '../models/role.model'; 2 | import { RoleNotFoundError, RoleAlreadyExistsError } from '../errors/errors'; 3 | import { Either } from 'monet'; 4 | 5 | export interface IRolesRepository { 6 | create(role: RoleForCreate): Promise; 7 | 8 | getAll(): Promise; 9 | 10 | findOne(id: string): Promise>; 11 | 12 | isRoleAlreadyExists(name: string): Promise; 13 | 14 | update(id: string, patch: RoleForUpdate): Promise>; 15 | 16 | delete(id: string): Promise>; 17 | } 18 | 19 | export interface IRolesMapper { 20 | fromEntity(entity: any): Role; 21 | } 22 | 23 | export interface IRolesService { 24 | create(role: RoleForCreate): Promise>; 25 | 26 | getAll(): Promise; 27 | 28 | findOne(id: string): Promise>; 29 | 30 | update(id: string, patch: RoleForUpdate): Promise>; 31 | 32 | delete(id: string): Promise>; 33 | } 34 | -------------------------------------------------------------------------------- /src/roles/models/role.model.ts: -------------------------------------------------------------------------------- 1 | export type Role = { 2 | readonly id: string; 3 | name: string; 4 | displayName: string; 5 | description: string; 6 | isDeleted: boolean; 7 | }; 8 | 9 | export type RoleForCreate = Pick; 10 | 11 | export type RoleForUpdate = { 12 | name?: string; 13 | displayName?: string; 14 | description?: string; 15 | }; 16 | -------------------------------------------------------------------------------- /src/roles/roles.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Param, 5 | Post, 6 | Body, 7 | Patch, 8 | Delete, 9 | Inject, 10 | HttpStatus, 11 | NotFoundException, 12 | ConflictException, 13 | } from '@nestjs/common'; 14 | import { CreateRoleDto, UpdateRoleDto, RolePresentationDto } from './dto/role.dto'; 15 | import { 16 | ApiTags, 17 | ApiResponse, 18 | ApiBasicAuth, 19 | ApiOperation, 20 | ApiParam, 21 | ApiForbiddenResponse, 22 | ApiNotFoundResponse, 23 | ApiConflictResponse, 24 | } from '@nestjs/swagger'; 25 | import { Roles } from '../auth'; 26 | import { IRolesService } from './interfaces/interfaces'; 27 | import { ROLES_SERVICE } from '../constants'; 28 | import { identity } from 'rxjs'; 29 | import { ObjectIdValidationPipe } from '../object-id.validation.pipe'; 30 | 31 | @ApiBasicAuth() 32 | @ApiTags('roles') 33 | @Controller('roles') 34 | export class RolesController { 35 | constructor( 36 | @Inject(ROLES_SERVICE) 37 | private readonly rolesService: IRolesService, 38 | ) {} 39 | 40 | @Get() 41 | @Roles(['admin', 'guest']) 42 | @ApiResponse({ status: 200, type: [RolePresentationDto] }) 43 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 44 | @ApiOperation({ 45 | summary: 'Get roles', 46 | description: 'Get all existing roles', 47 | }) 48 | async getAll(): Promise { 49 | return this.rolesService.getAll(); 50 | } 51 | 52 | @Get(':id') 53 | @Roles(['admin', 'guest']) 54 | @ApiResponse({ status: 200, type: RolePresentationDto }) 55 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 56 | @ApiNotFoundResponse({ status: 404, description: 'Not Found' }) 57 | @ApiParam({ 58 | name: 'id', 59 | type: String, 60 | example: 'id: 5e5eb0418aa9340f913008e5', 61 | }) 62 | @ApiOperation({ 63 | summary: 'Get role', 64 | description: 'Get specific role by id', 65 | }) 66 | async findOne(@Param('id', ObjectIdValidationPipe) id: string): Promise { 67 | const result = await this.rolesService.findOne(id); 68 | 69 | return result.cata(error => { 70 | throw new NotFoundException({ 71 | status: HttpStatus.NOT_FOUND, 72 | error: error.name, 73 | message: error.message, 74 | }); 75 | }, identity); 76 | } 77 | 78 | @Post() 79 | @Roles(['admin']) 80 | @ApiResponse({ status: 201, type: RolePresentationDto }) 81 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 82 | @ApiConflictResponse({ status: 409, description: 'Role is already exists' }) 83 | @ApiOperation({ 84 | summary: 'Add new role', 85 | description: 'Create new role', 86 | }) 87 | async createRole(@Body() createRoleDto: CreateRoleDto): Promise { 88 | const result = await this.rolesService.create(createRoleDto); 89 | 90 | return result.cata(error => { 91 | throw new ConflictException({ 92 | status: HttpStatus.CONFLICT, 93 | error: error.name, 94 | message: error.message, 95 | }); 96 | }, identity); 97 | } 98 | 99 | @Patch(':id') 100 | @Roles(['admin']) 101 | @ApiResponse({ status: 200, type: RolePresentationDto }) 102 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 103 | @ApiNotFoundResponse({ status: 404, description: 'Not Found' }) 104 | @ApiParam({ 105 | name: 'id', 106 | type: String, 107 | example: 'id: 5e5eb0418aa9340f913008e5', 108 | }) 109 | @ApiOperation({ 110 | summary: 'Update role', 111 | description: 'Update specific role by id', 112 | }) 113 | async updateRole( 114 | @Param('id', ObjectIdValidationPipe) id: string, 115 | @Body() updateRoleDto: UpdateRoleDto, 116 | ): Promise { 117 | const result = await this.rolesService.update(id, updateRoleDto); 118 | 119 | return result.cata(error => { 120 | throw new NotFoundException({ 121 | status: HttpStatus.NOT_FOUND, 122 | error: error.name, 123 | message: error.message, 124 | }); 125 | }, identity); 126 | } 127 | 128 | @Delete(':id') 129 | @Roles(['admin']) 130 | @ApiResponse({ status: 200, type: RolePresentationDto }) 131 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 132 | @ApiNotFoundResponse({ status: 404, description: 'Not Found' }) 133 | @ApiParam({ 134 | name: 'id', 135 | type: String, 136 | example: 'id: 5e5eb0418aa9340f913008e5', 137 | }) 138 | @ApiOperation({ 139 | summary: 'Delete role', 140 | description: 'Remove specific role by id', 141 | }) 142 | async removeRole(@Param('id', ObjectIdValidationPipe) id: string): Promise { 143 | const result = await this.rolesService.delete(id); 144 | 145 | return result.cata(error => { 146 | throw new NotFoundException({ 147 | status: HttpStatus.NOT_FOUND, 148 | error: error.name, 149 | message: error.message, 150 | }); 151 | }, identity); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/roles/roles.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, DynamicModule, NestModule, MiddlewareConsumer, Inject } from '@nestjs/common'; 2 | import { RolesController } from './roles.controller'; 3 | import { IRolesService } from './interfaces/interfaces'; 4 | import { ROLES_SERVICE, AUTHENTICATE } from '../constants'; 5 | import { Authenticate } from '../auth'; 6 | 7 | @Module({}) 8 | export class RolesModule implements NestModule { 9 | static forRoot(dependencies: { 10 | rolesService: IRolesService; 11 | authenticate: Authenticate; 12 | }): DynamicModule { 13 | return { 14 | module: RolesModule, 15 | controllers: [RolesController], 16 | providers: [ 17 | { 18 | provide: ROLES_SERVICE, 19 | useValue: dependencies.rolesService, 20 | }, 21 | { 22 | provide: AUTHENTICATE, 23 | useValue: dependencies.authenticate, 24 | }, 25 | ], 26 | }; 27 | } 28 | 29 | constructor(@Inject('AUTHENTICATE') private readonly authenticate: Authenticate) {} 30 | 31 | configure(consumer: MiddlewareConsumer) { 32 | consumer.apply(this.authenticate).forRoutes(RolesController); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/roles/roles.repository.ts: -------------------------------------------------------------------------------- 1 | import { Role, RoleForCreate, RoleForUpdate } from './models/role.model'; 2 | import { Model } from 'mongoose'; 3 | import { RoleDocument } from './schema/role.schema'; 4 | import { IRolesMapper, IRolesRepository } from './interfaces/interfaces'; 5 | import { RoleNotFoundError } from './errors/errors'; 6 | import { Either, Left, Right } from 'monet'; 7 | 8 | export class RolesMapper implements IRolesMapper { 9 | fromEntity(entity: any): Role { 10 | return { 11 | id: entity._id.toString(), 12 | name: entity.name, 13 | description: entity.description, 14 | displayName: entity.displayName, 15 | isDeleted: entity.isDeleted, 16 | }; 17 | } 18 | } 19 | 20 | export class RolesRepository implements IRolesRepository { 21 | constructor( 22 | private readonly database: Model, 23 | private readonly mapper: IRolesMapper, 24 | ) {} 25 | 26 | async create(role: RoleForCreate): Promise { 27 | const createdRole = await this.database.create(role); 28 | return this.mapper.fromEntity(createdRole); 29 | } 30 | 31 | async getAll(): Promise { 32 | const roles = await this.database.find(); 33 | return roles.map(this.mapper.fromEntity); 34 | } 35 | 36 | async findOne(id: string): Promise> { 37 | const role = await this.database.findById(id); 38 | 39 | if (!role) { 40 | return Left(new RoleNotFoundError(id)); 41 | } 42 | return Right(this.mapper.fromEntity(role)); 43 | } 44 | 45 | async isRoleAlreadyExists(name: string): Promise { 46 | const [role] = await this.database.find({ name }); 47 | return role ? true : false; 48 | } 49 | 50 | async update(id: string, patch: RoleForUpdate): Promise> { 51 | const role = await this.findOne(id); 52 | 53 | if (!role.isRight()) { 54 | return Left(new RoleNotFoundError(id)); 55 | } 56 | 57 | const updated = await this.database.findByIdAndUpdate(id, patch, { 58 | new: true, 59 | }); 60 | return Right(this.mapper.fromEntity(updated)); 61 | } 62 | 63 | async delete(id: string): Promise> { 64 | const role = await this.findOne(id); 65 | 66 | if (!role.isRight()) { 67 | return Left(new RoleNotFoundError(id)); 68 | } 69 | 70 | const deleted = await this.database.findByIdAndUpdate(id, { isDeleted: true }, { new: true }); 71 | return Right(this.mapper.fromEntity(deleted)); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/roles/roles.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { RoleForCreate, Role, RoleForUpdate } from './models/role.model'; 3 | import { IRolesRepository, IRolesService } from './interfaces/interfaces'; 4 | import { RoleNotFoundError, RoleAlreadyExistsError } from './errors/errors'; 5 | import { Either, Left, Right } from 'monet'; 6 | 7 | @Injectable() 8 | export class RolesService implements IRolesService { 9 | constructor(private readonly rolesRepo: IRolesRepository) {} 10 | 11 | async create(role: RoleForCreate): Promise> { 12 | const isRoleExists = await this.rolesRepo.isRoleAlreadyExists(role.name); 13 | 14 | if (isRoleExists) { 15 | return Left(new RoleAlreadyExistsError(role.name)); 16 | } 17 | 18 | const createdRole = await this.rolesRepo.create(role); 19 | return Right(createdRole); 20 | } 21 | 22 | async getAll(): Promise { 23 | return this.rolesRepo.getAll(); 24 | } 25 | 26 | async findOne(id: string): Promise> { 27 | return this.rolesRepo.findOne(id); 28 | } 29 | 30 | async update(id: string, patch: RoleForUpdate): Promise> { 31 | return this.rolesRepo.update(id, patch); 32 | } 33 | 34 | async delete(id: string): Promise> { 35 | return this.rolesRepo.delete(id); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/roles/schema/role.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, connection } from 'mongoose'; 2 | import { ObjectId } from '../../common'; 3 | import { ROLE_MODEL } from '../../constants'; 4 | 5 | export interface RoleDocument extends Document { 6 | readonly _id: ObjectId; 7 | readonly name: string; 8 | readonly displayName: string; 9 | readonly description: string; 10 | readonly isDeleted: boolean; 11 | } 12 | 13 | export const RoleSchema = new Schema({ 14 | name: { type: String, required: true, unique: true }, 15 | displayName: { type: String, required: true }, 16 | description: { type: String, required: true }, 17 | isDeleted: { type: Boolean, default: false }, 18 | }); 19 | 20 | export const rolesModel = connection.model(ROLE_MODEL, RoleSchema, 'identity-roles'); 21 | -------------------------------------------------------------------------------- /src/user-role-rel/__test__/mock.data.ts: -------------------------------------------------------------------------------- 1 | // import { UserRoleRelationForCreate } from '../models/user-role-rel.model'; 2 | import { ObjectID } from '../../common'; 3 | import { userRoleRelModel } from '../schema/user-role-rel.schema'; 4 | import { UserRoleRelationMapper } from '../user-role-rel.service'; 5 | 6 | const mapper = new UserRoleRelationMapper(); 7 | 8 | export async function getMockUserRoleRelations() { 9 | const relations = []; 10 | 11 | for (let index = 0; index < 20; index++) { 12 | const rel = { 13 | roleId: new ObjectID(), 14 | userId: new ObjectID(), 15 | }; 16 | 17 | const mockRelation = await userRoleRelModel.create(rel); 18 | 19 | relations.push(mapper.fromEntity(mockRelation)); 20 | } 21 | 22 | return relations; 23 | } 24 | -------------------------------------------------------------------------------- /src/user-role-rel/__test__/user-role-rel.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { UserRoleRelationMapper, UserRoleRelService } from '../user-role-rel.service'; 2 | import { userRoleRelModel } from '../schema/user-role-rel.schema'; 3 | import { getMockUserRoleRelations } from './mock.data'; 4 | import { establishDbConnection, closeDbConnection, sample, ObjectID } from '../../common'; 5 | import { UserRoleRelation, UserRoleRelationForCreate } from '../models/user-role-rel.model'; 6 | import { 7 | RoleRelationNotFoundError, 8 | RelationNotFoundError, 9 | RoleRelationAlreadyExistsError, 10 | } from '../errors/errors'; 11 | 12 | describe('UserRoleRelations service', () => { 13 | let userRoleRelService: UserRoleRelService; 14 | let mockRelations: UserRoleRelation[]; 15 | 16 | beforeEach(() => { 17 | const mapper = new UserRoleRelationMapper(); 18 | userRoleRelService = new UserRoleRelService(userRoleRelModel, mapper); 19 | }); 20 | 21 | beforeAll(async () => { 22 | await establishDbConnection(); 23 | mockRelations = await getMockUserRoleRelations(); 24 | }); 25 | 26 | afterAll(async () => { 27 | await closeDbConnection(); 28 | }); 29 | 30 | describe('get', () => { 31 | it('should get all relations', async () => { 32 | const relations = await userRoleRelService.getAll(); 33 | 34 | expect(relations.length).toBeGreaterThan(0); 35 | }); 36 | 37 | it('should get specific relation by userId', async () => { 38 | const userId = sample(mockRelations).userId; 39 | 40 | const result = await userRoleRelService.getByAccount(userId); 41 | const relations = result.right(); 42 | 43 | expect(relations.length).toBeGreaterThan(0); 44 | relations.every(relation => { 45 | expect(relation.userId).toBe(userId); 46 | }); 47 | }); 48 | 49 | it('should return "RoleRelationNotFoundError" in case if relation not found', async () => { 50 | const userId = new ObjectID().toHexString(); 51 | 52 | const result = await userRoleRelService.getByAccount(userId); 53 | const error = result.left(); 54 | 55 | expect(error).toBeInstanceOf(RoleRelationNotFoundError); 56 | }); 57 | }); 58 | 59 | describe('create', () => { 60 | it('should create new relation', async () => { 61 | const rel: UserRoleRelationForCreate = { 62 | roleId: new ObjectID().toHexString(), 63 | userId: new ObjectID().toHexString(), 64 | }; 65 | 66 | const result = await userRoleRelService.create(rel); 67 | const createdRel = result.right(); 68 | 69 | expect(createdRel.roleId).toBe(rel.roleId); 70 | expect(createdRel.userId).toBe(rel.userId); 71 | }); 72 | 73 | it('should return "RoleRelationAlreadyExistsError" if relation already exists', async () => { 74 | const existingRelation = sample(mockRelations); 75 | 76 | const result = await userRoleRelService.create(existingRelation); 77 | const error = result.left(); 78 | 79 | expect(error).toBeInstanceOf(RoleRelationAlreadyExistsError); 80 | }); 81 | }); 82 | 83 | describe('delete', () => { 84 | it('should remove specific relation by id', async () => { 85 | const id = sample(mockRelations).id; 86 | 87 | const result = await userRoleRelService.delete(id); 88 | const removedRelation = result.right(); 89 | 90 | expect(removedRelation.isDeleted).toBe(true); 91 | }); 92 | 93 | it('should return "RelationNotFoundError" if relation for delete not found', async () => { 94 | const id = new ObjectID().toHexString(); 95 | 96 | const result = await userRoleRelService.delete(id); 97 | const error = result.left(); 98 | 99 | expect(error).toBeInstanceOf(RelationNotFoundError); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/user-role-rel/dto/user-role-rel.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, IsNotEmpty } from 'class-validator'; 3 | 4 | export class CreateRelationDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty() 8 | userId: string; 9 | 10 | @IsString() 11 | @IsNotEmpty() 12 | @ApiProperty() 13 | roleId: string; 14 | } 15 | 16 | export class UserRoleRelPresentationDto { 17 | @ApiProperty() 18 | id: string; 19 | 20 | @ApiProperty() 21 | userId: string; 22 | 23 | @ApiProperty() 24 | roleId: string; 25 | 26 | @ApiProperty() 27 | isDeleted: boolean; 28 | } 29 | -------------------------------------------------------------------------------- /src/user-role-rel/errors/errors.ts: -------------------------------------------------------------------------------- 1 | export class RoleRelationNotFoundError extends Error { 2 | constructor(id: string) { 3 | super(`Role relation to user ${id} not found`); 4 | this.name = 'RoleRelationNotFoundError'; 5 | } 6 | } 7 | 8 | export class RelationNotFoundError extends Error { 9 | constructor(id: string) { 10 | super(`Relation ${id} not found`); 11 | this.name = 'RelationNotFoundError'; 12 | } 13 | } 14 | 15 | export class RoleRelationAlreadyExistsError extends Error { 16 | constructor(roleId: string) { 17 | super(`User role relation with role id:${roleId} already exists`); 18 | this.name = 'RoleRelationAlreadyExistsError'; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/user-role-rel/index.ts: -------------------------------------------------------------------------------- 1 | export * from './errors/errors'; 2 | export * from './interfaces/interfaces'; 3 | export * from './models/user-role-rel.model'; 4 | export * from './schema/user-role-rel.schema'; 5 | export { UserRoleRelController } from './user-role-rel.controller'; 6 | export { UserRoleRelModule } from './user-role-rel.module'; 7 | export { UserRoleRelService, UserRoleRelationMapper } from './user-role-rel.service'; 8 | -------------------------------------------------------------------------------- /src/user-role-rel/interfaces/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { UserRoleRelation, UserRoleRelationForCreate } from '../models/user-role-rel.model'; 2 | import { Either } from 'monet'; 3 | import { 4 | RoleRelationNotFoundError, 5 | RelationNotFoundError, 6 | RoleRelationAlreadyExistsError, 7 | } from '../errors/errors'; 8 | 9 | export interface IUserRoleRelationMapper { 10 | fromEntity(entity: any): UserRoleRelation; 11 | } 12 | 13 | export interface IUserRoleRelService { 14 | getAll(): Promise; 15 | 16 | getByAccount(userId: string): Promise>; 17 | 18 | create( 19 | relation: UserRoleRelationForCreate, 20 | ): Promise>; 21 | 22 | delete(id: string): Promise>; 23 | } 24 | -------------------------------------------------------------------------------- /src/user-role-rel/models/user-role-rel.model.ts: -------------------------------------------------------------------------------- 1 | export type UserRoleRelation = { 2 | readonly id: string; 3 | userId: string; 4 | roleId: string; 5 | isDeleted: boolean; 6 | }; 7 | 8 | export type UserRoleRelationForCreate = Omit< 9 | UserRoleRelation, 10 | 'id' | 'isDeleted' 11 | >; 12 | -------------------------------------------------------------------------------- /src/user-role-rel/schema/user-role-rel.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, connection } from 'mongoose'; 2 | import { ObjectId } from '../../common'; 3 | import { USER_ROLE_RELATION_MODEL } from '../../constants'; 4 | 5 | export interface UserRoleRelDocument extends Document { 6 | readonly id: ObjectId; 7 | readonly userId: string; 8 | readonly roleId: string; 9 | } 10 | 11 | export const UserRoleRelSchema: Schema = new Schema({ 12 | userId: { type: String, required: true }, 13 | roleId: { type: String, required: true }, 14 | isDeleted: { type: Boolean, default: false }, 15 | }); 16 | 17 | export const userRoleRelModel = connection.model( 18 | USER_ROLE_RELATION_MODEL, 19 | UserRoleRelSchema, 20 | 'identity-role-relations', 21 | ); 22 | -------------------------------------------------------------------------------- /src/user-role-rel/user-role-rel.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Param, 5 | Delete, 6 | Inject, 7 | NotFoundException, 8 | HttpStatus, 9 | Post, 10 | Body, 11 | ConflictException, 12 | Query, 13 | } from '@nestjs/common'; 14 | import { 15 | ApiTags, 16 | ApiResponse, 17 | ApiBasicAuth, 18 | ApiForbiddenResponse, 19 | ApiNotFoundResponse, 20 | ApiOperation, 21 | ApiParam, 22 | ApiConflictResponse, 23 | ApiQuery, 24 | } from '@nestjs/swagger'; 25 | import { UserRoleRelPresentationDto, CreateRelationDto } from './dto/user-role-rel.dto'; 26 | import { Roles } from '../auth'; 27 | import { IUserRoleRelService } from './interfaces/interfaces'; 28 | import { USER_ROLE_RELATION_SERVICE } from '../constants'; 29 | import { identity } from 'rxjs'; 30 | import { ObjectIdValidationPipe } from '../object-id.validation.pipe'; 31 | 32 | @ApiBasicAuth() 33 | @ApiTags('relations') 34 | @Controller('relations') 35 | export class UserRoleRelController { 36 | constructor( 37 | @Inject(USER_ROLE_RELATION_SERVICE) 38 | private readonly userRoleRelService: IUserRoleRelService, 39 | ) {} 40 | 41 | @Get() 42 | @Roles(['admin', 'guest']) 43 | @ApiResponse({ status: 200, type: [UserRoleRelPresentationDto] }) 44 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 45 | @ApiOperation({ 46 | summary: 'Get relations', 47 | description: 'Get all existing relations', 48 | }) 49 | async getAll(): Promise { 50 | return this.userRoleRelService.getAll(); 51 | } 52 | 53 | @Get('user') 54 | @Roles(['admin', 'guest']) 55 | @ApiResponse({ status: 200, type: UserRoleRelPresentationDto }) 56 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 57 | @ApiNotFoundResponse({ status: 404, description: 'Not Found' }) 58 | @ApiQuery({ 59 | name: 'userId', 60 | required: true, 61 | example: 'userId: 5e5eb0418aa9340f913008e5', 62 | type: String, 63 | }) 64 | @ApiOperation({ 65 | summary: 'Get relation', 66 | description: 'Get specific relation by userId', 67 | }) 68 | async getUserRoleRelByUserId( 69 | @Query('userId', ObjectIdValidationPipe) userId: string, 70 | ): Promise { 71 | const result = await this.userRoleRelService.getByAccount(userId); 72 | 73 | return result.cata(error => { 74 | throw new NotFoundException({ 75 | status: HttpStatus.NOT_FOUND, 76 | error: error.name, 77 | message: error.message, 78 | }); 79 | }, identity); 80 | } 81 | 82 | @Post() 83 | @Roles(['admin']) 84 | @ApiResponse({ status: 201, type: UserRoleRelPresentationDto }) 85 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 86 | @ApiConflictResponse({ status: 409, description: 'Relation is already exists' }) 87 | @ApiOperation({ 88 | summary: 'Assign role', 89 | description: 'Assign role to specific user', 90 | }) 91 | async create(@Body() createRelationDto: CreateRelationDto) { 92 | const result = await this.userRoleRelService.create(createRelationDto); 93 | return result.cata(error => { 94 | throw new ConflictException({ 95 | status: HttpStatus.CONFLICT, 96 | error: error.name, 97 | message: error.message, 98 | }); 99 | }, identity); 100 | } 101 | 102 | @Delete(':id') 103 | @Roles(['admin']) 104 | @ApiResponse({ status: 200, type: UserRoleRelPresentationDto }) 105 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 106 | @ApiNotFoundResponse({ status: 404, description: 'Not Found' }) 107 | @ApiParam({ 108 | name: 'id', 109 | type: String, 110 | example: 'id: 5e5eb0418aa9340f913008e5', 111 | }) 112 | @ApiOperation({ 113 | summary: 'Remove relation', 114 | description: 'Remove specific relation by id', 115 | }) 116 | async removeRel( 117 | @Param('id', ObjectIdValidationPipe) id: string, 118 | ): Promise { 119 | const result = await this.userRoleRelService.delete(id); 120 | 121 | return result.cata(error => { 122 | throw new NotFoundException({ 123 | status: HttpStatus.NOT_FOUND, 124 | error: error.name, 125 | message: error.message, 126 | }); 127 | }, identity); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/user-role-rel/user-role-rel.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, DynamicModule, NestModule, Inject, MiddlewareConsumer } from '@nestjs/common'; 2 | import { UserRoleRelController } from './user-role-rel.controller'; 3 | import { IUserRoleRelService } from './interfaces/interfaces'; 4 | import { USER_ROLE_RELATION_SERVICE, AUTHENTICATE } from '../constants'; 5 | import { Authenticate } from '../auth'; 6 | 7 | @Module({}) 8 | export class UserRoleRelModule implements NestModule { 9 | static forRoot(dependencies: { 10 | userRoleRelService: IUserRoleRelService; 11 | authenticate: Authenticate; 12 | }): DynamicModule { 13 | return { 14 | module: UserRoleRelModule, 15 | controllers: [UserRoleRelController], 16 | providers: [ 17 | { 18 | provide: USER_ROLE_RELATION_SERVICE, 19 | useValue: dependencies.userRoleRelService, 20 | }, 21 | { 22 | provide: AUTHENTICATE, 23 | useValue: dependencies.authenticate, 24 | }, 25 | ], 26 | }; 27 | } 28 | 29 | constructor(@Inject(AUTHENTICATE) private readonly authenticate: Authenticate) {} 30 | 31 | configure(consumer: MiddlewareConsumer) { 32 | consumer.apply(this.authenticate).forRoutes(UserRoleRelController); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/user-role-rel/user-role-rel.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { UserRoleRelation, UserRoleRelationForCreate } from './models/user-role-rel.model'; 3 | import { Model } from 'mongoose'; 4 | import { UserRoleRelDocument } from './schema/user-role-rel.schema'; 5 | import { IUserRoleRelationMapper, IUserRoleRelService } from './interfaces/interfaces'; 6 | import { Either, Left, Right } from 'monet'; 7 | import { 8 | RoleRelationNotFoundError, 9 | RelationNotFoundError, 10 | RoleRelationAlreadyExistsError, 11 | } from './errors/errors'; 12 | 13 | export class UserRoleRelationMapper implements IUserRoleRelationMapper { 14 | fromEntity(entity: any): UserRoleRelation { 15 | return { 16 | id: entity._id.toString(), 17 | roleId: entity.roleId, 18 | userId: entity.userId, 19 | isDeleted: entity.isDeleted, 20 | }; 21 | } 22 | } 23 | 24 | @Injectable() 25 | export class UserRoleRelService implements IUserRoleRelService { 26 | constructor( 27 | private readonly relationModel: Model, 28 | private readonly mapper: IUserRoleRelationMapper, 29 | ) {} 30 | 31 | private async checkExistence(relation: UserRoleRelationForCreate): Promise { 32 | const relations = await this.relationModel.find({ 33 | roleId: relation.roleId, 34 | userId: relation.userId, 35 | }); 36 | return relations.length ? true : false; 37 | } 38 | 39 | async create( 40 | relation: UserRoleRelationForCreate, 41 | ): Promise> { 42 | const isRelationExists = await this.checkExistence(relation); 43 | 44 | if (isRelationExists) { 45 | return Left(new RoleRelationAlreadyExistsError(relation.roleId)); 46 | } 47 | 48 | const created = await this.relationModel.create(relation); 49 | return Right(this.mapper.fromEntity(created)); 50 | } 51 | 52 | async getAll(): Promise { 53 | const relations = await this.relationModel.find(); 54 | return relations.map(this.mapper.fromEntity); 55 | } 56 | 57 | async getByAccount(id: string): Promise> { 58 | const relations = await this.relationModel.find({ userId: id }); 59 | 60 | if (!relations.length) { 61 | return Left(new RoleRelationNotFoundError(id)); 62 | } 63 | return Right(relations.map(this.mapper.fromEntity)); 64 | } 65 | 66 | async delete(id: string): Promise> { 67 | const userRoleRel = await this.relationModel.findById(id); 68 | 69 | if (!userRoleRel) { 70 | return Left(new RelationNotFoundError(id)); 71 | } 72 | 73 | const deleted = await this.relationModel.findByIdAndUpdate( 74 | id, 75 | { isDeleted: true }, 76 | { new: true }, 77 | ); 78 | 79 | return Right(this.mapper.fromEntity(deleted)); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/users/__test__/mock.data.ts: -------------------------------------------------------------------------------- 1 | import { usersModel } from '../schema/user.schema'; 2 | import { UserForCreate, User } from '../models/user.model'; 3 | import { UsersMapper } from '../users.repository'; 4 | import * as faker from 'faker'; 5 | import { userRoleRelModel } from '../../user-role-rel/schema/user-role-rel.schema'; 6 | import { RoleForCreate } from '../../roles/models/role.model'; 7 | import { rolesModel } from '../../roles/schema/role.schema'; 8 | 9 | faker.seed(678); 10 | 11 | const mapper = new UsersMapper(); 12 | 13 | export async function getMockUsers() { 14 | const users: User[] = []; 15 | 16 | for (let index = 1; index < 20; index++) { 17 | const user: UserForCreate = { 18 | username: faker.internet.userName(), 19 | password: faker.internet.password(), 20 | }; 21 | 22 | const role: RoleForCreate = { 23 | name: `role_${index}`, 24 | displayName: faker.lorem.word().toUpperCase(), 25 | description: faker.lorem.sentence(), 26 | }; 27 | 28 | const mockRole = await rolesModel.create(role); 29 | const mockUser = await usersModel.create(user); 30 | users.push(mapper.fromEntity(mockUser)); 31 | 32 | const relation = { 33 | userId: mockUser._id.toString(), 34 | roleId: mockRole._id.toString(), 35 | }; 36 | await userRoleRelModel.create(relation); 37 | } 38 | 39 | return users; 40 | } 41 | -------------------------------------------------------------------------------- /src/users/__test__/users.repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { UsersRepository, UsersMapper } from '../users.repository'; 2 | import { usersModel } from '../schema/user.schema'; 3 | import { UserForCreate, UserForUpdate, User } from '../models/user.model'; 4 | import { establishDbConnection, closeDbConnection, sample, ObjectID } from '../../common'; 5 | import { getMockUsers } from './mock.data'; 6 | import * as faker from 'faker'; 7 | import { UserNotFoundError } from '../errors/errors'; 8 | 9 | faker.seed(347); 10 | 11 | describe('Users repository', () => { 12 | const mapper = new UsersMapper(); 13 | 14 | let repository: UsersRepository; 15 | let mockUsers: User[]; 16 | 17 | beforeAll(async () => { 18 | await establishDbConnection(); 19 | mockUsers = await getMockUsers(); 20 | }); 21 | 22 | afterAll(async () => { 23 | await closeDbConnection(); 24 | }); 25 | 26 | beforeEach(() => { 27 | repository = new UsersRepository(usersModel, mapper); 28 | }); 29 | 30 | describe('create', () => { 31 | it('should create user', async () => { 32 | const user: UserForCreate = { 33 | username: faker.internet.userName(), 34 | password: faker.internet.password(), 35 | }; 36 | 37 | const createdUser = await repository.create(user); 38 | 39 | expect(createdUser.id).toBeDefined(); 40 | expect(createdUser.username).toBe(user.username); 41 | expect(createdUser.password).toBe(user.password); 42 | }); 43 | }); 44 | 45 | describe('get', () => { 46 | it('should get all users', async () => { 47 | const users = await repository.getAll(); 48 | 49 | expect(users.length).toBeGreaterThan(0); 50 | }); 51 | 52 | it('should get non deleted users', async () => { 53 | const users = await repository.getAll({ isDeleted: false }); 54 | 55 | users.map(user => { 56 | expect(user.isDeleted).toBe(false); 57 | }); 58 | }); 59 | 60 | it('should get user by id', async () => { 61 | const userForCreate = { 62 | id: '5e4c3dbfc074d01fe5dac20f', 63 | username: faker.internet.userName(), 64 | password: faker.internet.password(), 65 | }; 66 | 67 | const user = await repository.create(userForCreate); 68 | const result = await repository.findOne(user.id); 69 | const foundUser = result.right(); 70 | 71 | expect(foundUser.id).toBe(user.id); 72 | }); 73 | 74 | it('should return "UserNotFoundError" if user not found', async () => { 75 | const id = new ObjectID().toHexString(); 76 | 77 | const result = await repository.findOne(id); 78 | const error = result.left(); 79 | 80 | expect(error).toBeInstanceOf(UserNotFoundError); 81 | }); 82 | }); 83 | 84 | describe('update', () => { 85 | it('should update user', async () => { 86 | const id = sample(mockUsers).id; 87 | const patch: UserForUpdate = { 88 | username: faker.internet.userName(), 89 | }; 90 | 91 | const result = await repository.update(id, patch); 92 | const updatedUser = result.right(); 93 | 94 | expect(updatedUser.username).toBe(patch.username); 95 | }); 96 | 97 | it('should return "UserNotFoundError" if user for update not found', async () => { 98 | const id = new ObjectID().toHexString(); 99 | const patch: UserForUpdate = { 100 | username: faker.internet.userName(), 101 | }; 102 | 103 | const result = await repository.update(id, patch); 104 | const error = result.left(); 105 | 106 | expect(error).toBeInstanceOf(UserNotFoundError); 107 | }); 108 | }); 109 | 110 | describe('delete', () => { 111 | it('should remove user by id', async () => { 112 | const id = sample(mockUsers).id; 113 | 114 | const result = await repository.delete(id); 115 | const user = result.right(); 116 | 117 | expect(user.isDeleted).toBe(true); 118 | }); 119 | 120 | it('should return "UserNotFoundError" if user for delete not found', async () => { 121 | const id = new ObjectID().toHexString(); 122 | 123 | const result = await repository.delete(id); 124 | const error = result.left(); 125 | 126 | expect(error).toBeInstanceOf(UserNotFoundError); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /src/users/__test__/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { closeDbConnection, establishDbConnection, sample, ObjectID } from '../../common'; 2 | import { 3 | RolesMapper, 4 | rolesModel, 5 | RolesRepository, 6 | RolesService, 7 | RoleNotFoundError, 8 | } from '../../roles'; 9 | import { 10 | UserRoleRelationMapper, 11 | userRoleRelModel, 12 | UserRoleRelService, 13 | RoleRelationNotFoundError, 14 | } from '../../user-role-rel'; 15 | import { User, UserForCreate } from '../models/user.model'; 16 | import { usersModel } from '../schema/user.schema'; 17 | import { UsersMapper, UsersRepository } from '../users.repository'; 18 | import { UsersService } from '../users.service'; 19 | import { getMockUsers } from './mock.data'; 20 | import { UserNotFoundError, UserAlreadyExistsError } from '../errors/errors'; 21 | import { Left } from 'monet'; 22 | 23 | describe('Users service', () => { 24 | let usersService: UsersService; 25 | let userRoleRelService: UserRoleRelService; 26 | let rolesService: RolesService; 27 | let mockUsers: User[]; 28 | 29 | beforeEach(() => { 30 | const rolesMapper = new RolesMapper(); 31 | const rolesRepo = new RolesRepository(rolesModel, rolesMapper); 32 | rolesService = new RolesService(rolesRepo); 33 | 34 | const roleRelMapper = new UserRoleRelationMapper(); 35 | userRoleRelService = new UserRoleRelService(userRoleRelModel, roleRelMapper); 36 | 37 | const usersMapper = new UsersMapper(); 38 | const usersRepo = new UsersRepository(usersModel, usersMapper); 39 | usersService = new UsersService(usersRepo, rolesService, userRoleRelService); 40 | }); 41 | 42 | beforeAll(async () => { 43 | await establishDbConnection(); 44 | mockUsers = await getMockUsers(); 45 | }); 46 | 47 | afterAll(async () => { 48 | await closeDbConnection(); 49 | }); 50 | 51 | describe('On module init', () => { 52 | it('should add admin user on module init', async () => { 53 | await usersService.onModuleInit(); 54 | 55 | const [result] = await usersService.getAll({ username: 'admin' }); 56 | 57 | expect(result).toMatchObject({ 58 | username: 'admin', 59 | password: '123', 60 | }); 61 | }); 62 | }); 63 | 64 | describe('get', () => { 65 | it('should get all users', async () => { 66 | const users = await usersService.getAll(); 67 | 68 | expect(users.length).toBeGreaterThan(0); 69 | }); 70 | 71 | it('should get specific user by id', async () => { 72 | const id = sample(mockUsers).id; 73 | const result = await usersService.findOne(id); 74 | const user = result.right(); 75 | 76 | expect(user.id).toBe(id); 77 | }); 78 | 79 | it('should return "UserNotFoundError" if user not found', async () => { 80 | const id = new ObjectID().toHexString(); 81 | 82 | const result = await usersService.findOne(id); 83 | const error = result.left(); 84 | 85 | expect(error).toBeInstanceOf(UserNotFoundError); 86 | }); 87 | 88 | it('should get user credentials', async () => { 89 | const { username, password } = sample(mockUsers); 90 | 91 | const result = await usersService.getUserCredentials(username, password); 92 | const credentials = result.right(); 93 | const roles = credentials.roles; 94 | 95 | expect(credentials.username).toBe(username); 96 | expect(roles.length).toBeGreaterThan(0); 97 | }); 98 | 99 | it('should return "RoleRelationNotFoundError" if relation to get credentials not found', async () => { 100 | const { username, password } = sample(mockUsers); 101 | const id = new ObjectID().toHexString(); 102 | 103 | jest 104 | .spyOn(userRoleRelService, 'getByAccount') 105 | .mockImplementationOnce(async () => Left(new RoleRelationNotFoundError(id))); 106 | 107 | const result = await usersService.getUserCredentials(username, password); 108 | const error = result.left(); 109 | 110 | expect(error).toBeInstanceOf(RoleRelationNotFoundError); 111 | }); 112 | 113 | it('should return "RoleNotFoundError" if role to get credentials not found', async () => { 114 | const { username, password } = sample(mockUsers); 115 | const id = new ObjectID().toHexString(); 116 | 117 | jest 118 | .spyOn(rolesService, 'findOne') 119 | .mockImplementationOnce(async () => Left(new RoleNotFoundError(id))); 120 | 121 | const result = await usersService.getUserCredentials(username, password); 122 | const error = result.left(); 123 | 124 | expect(error).toBeInstanceOf(RoleNotFoundError); 125 | }); 126 | }); 127 | 128 | describe('create', () => { 129 | it('should create new user', async () => { 130 | const userForCreate: UserForCreate = { 131 | username: 'test_user', 132 | password: 'pwd', 133 | }; 134 | 135 | const result = await usersService.addUser(userForCreate); 136 | const user = result.right(); 137 | 138 | expect(user.username).toBe(userForCreate.username); 139 | expect(user.password).toBe(userForCreate.password); 140 | }); 141 | 142 | it('should return "UserAlreadyExistsError" if user already exists', async () => { 143 | const existingUser = sample(mockUsers); 144 | 145 | const result = await usersService.addUser(existingUser); 146 | const error = result.left(); 147 | 148 | expect(error).toBeInstanceOf(UserAlreadyExistsError); 149 | }); 150 | }); 151 | 152 | describe('update', () => { 153 | it('should update specific user by id', async () => { 154 | const id = sample(mockUsers).id; 155 | const patch = { 156 | username: 'updated_user', 157 | }; 158 | 159 | const result = await usersService.updateUser(id, patch); 160 | const updatedUser = result.right(); 161 | 162 | expect(updatedUser.id).toBe(id); 163 | expect(updatedUser.username).toBe(patch.username); 164 | }); 165 | 166 | it('should return "UserNotFoundError" if user for update not found', async () => { 167 | const id = new ObjectID().toHexString(); 168 | const patch = { 169 | username: 'updated_user', 170 | }; 171 | 172 | const result = await usersService.updateUser(id, patch); 173 | const error = result.left(); 174 | 175 | expect(error).toBeInstanceOf(UserNotFoundError); 176 | }); 177 | }); 178 | 179 | describe('delete', () => { 180 | it('should remove specific user by id', async () => { 181 | const id = sample(mockUsers).id; 182 | 183 | const result = await usersService.removeUser(id); 184 | const deletedUser = result.right(); 185 | 186 | expect(deletedUser.isDeleted).toBe(true); 187 | }); 188 | 189 | it('should return "UserNotFoundError" if user for delete not found', async () => { 190 | const id = new ObjectID().toHexString(); 191 | 192 | const result = await usersService.removeUser(id); 193 | const error = result.left(); 194 | 195 | expect(error).toBeInstanceOf(UserNotFoundError); 196 | }); 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /src/users/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; 2 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 3 | 4 | export class CreateUserDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty() 8 | readonly username: string; 9 | 10 | @IsString() 11 | @IsNotEmpty() 12 | @ApiProperty() 13 | readonly password: string; 14 | } 15 | 16 | export class UpdateUserDto { 17 | @IsString() 18 | @IsOptional() 19 | @ApiPropertyOptional() 20 | readonly username?: string; 21 | 22 | @IsString() 23 | @IsOptional() 24 | @ApiPropertyOptional() 25 | readonly password?: string; 26 | } 27 | 28 | export class UserPresentationDto { 29 | @ApiProperty() 30 | id: string; 31 | 32 | @ApiProperty() 33 | username: string; 34 | 35 | @ApiProperty() 36 | password: string; 37 | 38 | @ApiProperty() 39 | isDeleted: boolean; 40 | } 41 | -------------------------------------------------------------------------------- /src/users/errors/errors.ts: -------------------------------------------------------------------------------- 1 | export class UserNotFoundError extends Error { 2 | constructor(id: string) { 3 | super(`User ${id} not found`); 4 | this.name = 'UserNotFoundError'; 5 | } 6 | } 7 | 8 | export class UserAlreadyExistsError extends Error { 9 | constructor(username: string) { 10 | super(`User ${username} already exists`); 11 | this.name = 'UserAlreadyExistsError'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/users/index.ts: -------------------------------------------------------------------------------- 1 | export * from './errors/errors'; 2 | export * from './interfaces/interfaces'; 3 | export * from './models/user.model'; 4 | export * from './schema/user.schema'; 5 | export { UsersController } from './users.controller'; 6 | export { UsersModule } from './users.module'; 7 | export { UsersMapper, UsersRepository } from './users.repository'; 8 | export { UsersService } from './users.service'; 9 | -------------------------------------------------------------------------------- /src/users/interfaces/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { UserForCreate, User, UserForUpdate, AuthenticatedUser } from '../models/user.model'; 2 | import { Either } from 'monet'; 3 | import { UserNotFoundError, UserAlreadyExistsError } from '../errors/errors'; 4 | import { RoleRelationNotFoundError } from '../../user-role-rel'; 5 | import { RoleNotFoundError } from '../../roles'; 6 | 7 | export type QueryParams = { 8 | username?: string; 9 | password?: string; 10 | isDeleted?: boolean; 11 | }; 12 | 13 | export interface IUsersRepository { 14 | create(user: UserForCreate): Promise; 15 | 16 | getAll(query?: QueryParams): Promise; 17 | 18 | findOne(id: string): Promise>; 19 | 20 | update(id: string, patch: UserForUpdate): Promise>; 21 | 22 | delete(id: string): Promise>; 23 | } 24 | 25 | export interface IUsersMapper { 26 | fromEntity(entity: any): User; 27 | } 28 | 29 | export interface IUsersService { 30 | getUserCredentials( 31 | username: string, 32 | password: string, 33 | ): Promise>; 34 | 35 | getAll(query?: QueryParams): Promise; 36 | 37 | findOne(id: string): Promise>; 38 | 39 | addUser(user: UserForCreate): Promise>; 40 | updateUser(id: string, patch: UserForUpdate): Promise>; 41 | removeUser(id: string): Promise>; 42 | } 43 | -------------------------------------------------------------------------------- /src/users/models/user.model.ts: -------------------------------------------------------------------------------- 1 | export interface AuthenticatedUser { 2 | readonly username: string; 3 | readonly roles: string[]; 4 | } 5 | 6 | export type User = { 7 | readonly id: string; 8 | username: string; 9 | password: string; 10 | isDeleted: boolean; 11 | }; 12 | 13 | export type UserForCreate = Omit; 14 | 15 | export type UserForUpdate = { 16 | username?: string; 17 | password?: string; 18 | }; 19 | -------------------------------------------------------------------------------- /src/users/schema/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, connection } from 'mongoose'; 2 | import { USER_MODEL } from '../../constants'; 3 | 4 | export interface UserDocument extends Document { 5 | readonly username: string; 6 | readonly password: string; 7 | } 8 | 9 | export const UserSchema: Schema = new Schema({ 10 | username: { type: String, required: true, unique: true }, 11 | password: { type: String, required: true }, 12 | isDeleted: { type: Boolean, default: false }, 13 | }); 14 | 15 | export const usersModel = connection.model(USER_MODEL, UserSchema, 'identity-users'); 16 | -------------------------------------------------------------------------------- /src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Param, 6 | Body, 7 | Patch, 8 | Delete, 9 | Inject, 10 | HttpStatus, 11 | NotFoundException, 12 | ConflictException, 13 | Query, 14 | } from '@nestjs/common'; 15 | import { 16 | ApiTags, 17 | ApiResponse, 18 | ApiBasicAuth, 19 | ApiOperation, 20 | ApiParam, 21 | ApiForbiddenResponse, 22 | ApiNotFoundResponse, 23 | ApiConflictResponse, 24 | ApiQuery, 25 | } from '@nestjs/swagger'; 26 | import { CreateUserDto, UpdateUserDto, UserPresentationDto } from './dto/user.dto'; 27 | import { Roles } from '../auth'; 28 | import { IUsersService } from './interfaces/interfaces'; 29 | import { USERS_SERVICE } from '../constants'; 30 | import { identity } from 'rxjs'; 31 | import { ObjectIdValidationPipe } from '../object-id.validation.pipe'; 32 | 33 | @ApiBasicAuth() 34 | @ApiTags('users') 35 | @Controller('users') 36 | export class UsersController { 37 | constructor( 38 | @Inject(USERS_SERVICE) 39 | private readonly usersService: IUsersService, 40 | ) {} 41 | 42 | @Get() 43 | @Roles(['admin', 'guest']) 44 | @ApiResponse({ status: 200, type: [UserPresentationDto] }) 45 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 46 | @ApiOperation({ 47 | summary: 'Get users', 48 | description: 'Get all existing users', 49 | }) 50 | @ApiQuery({ name: 'username', required: false }) 51 | async getAll(@Query('query') query?: object): Promise { 52 | return this.usersService.getAll(query); 53 | } 54 | 55 | @Get(':id') 56 | @Roles(['admin', 'guest']) 57 | @ApiResponse({ status: 200, type: UserPresentationDto }) 58 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 59 | @ApiNotFoundResponse({ status: 404, description: 'Not Found' }) 60 | @ApiParam({ 61 | name: 'id', 62 | type: String, 63 | example: 'id: 5e5eb0418aa9340f913008e5', 64 | }) 65 | @ApiOperation({ 66 | summary: 'Get user', 67 | description: 'Get specific user by id', 68 | }) 69 | async findOne(@Param('id', ObjectIdValidationPipe) id: string): Promise { 70 | const result = await this.usersService.findOne(id); 71 | return result.cata(error => { 72 | throw new NotFoundException({ 73 | status: HttpStatus.NOT_FOUND, 74 | error: error.name, 75 | message: error.message, 76 | }); 77 | }, identity); 78 | } 79 | 80 | @Post() 81 | @Roles(['admin']) 82 | @ApiResponse({ status: 201, type: UserPresentationDto }) 83 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 84 | @ApiNotFoundResponse({ status: 404, description: 'Not Found' }) 85 | @ApiConflictResponse({ status: 409, description: 'User already exists' }) 86 | @ApiOperation({ 87 | summary: 'Add new user', 88 | description: 'Create new user', 89 | }) 90 | async createUser(@Body() createUserDto: CreateUserDto): Promise { 91 | const result = await this.usersService.addUser(createUserDto); 92 | return result.cata(error => { 93 | throw new ConflictException({ 94 | status: HttpStatus.CONFLICT, 95 | error: error.name, 96 | message: error.message, 97 | }); 98 | }, identity); 99 | } 100 | 101 | @Patch(':id') 102 | @Roles(['admin']) 103 | @ApiResponse({ status: 200, type: UserPresentationDto }) 104 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 105 | @ApiNotFoundResponse({ status: 404, description: 'Not Found' }) 106 | @ApiParam({ 107 | name: 'id', 108 | type: String, 109 | example: 'id: 5e5eb0418aa9340f913008e5', 110 | }) 111 | @ApiOperation({ 112 | summary: 'Update user', 113 | description: 'Update specific user by id', 114 | }) 115 | async updateUser( 116 | @Param('id', ObjectIdValidationPipe) id: string, 117 | @Body() updateUserDto: UpdateUserDto, 118 | ): Promise { 119 | const result = await this.usersService.updateUser(id, updateUserDto); 120 | return result.cata(error => { 121 | throw new NotFoundException({ 122 | status: HttpStatus.NOT_FOUND, 123 | error: error.name, 124 | message: error.message, 125 | }); 126 | }, identity); 127 | } 128 | 129 | @Delete(':id') 130 | @Roles(['admin']) 131 | @ApiResponse({ status: 200, type: UserPresentationDto }) 132 | @ApiForbiddenResponse({ status: 403, description: 'Forbidden' }) 133 | @ApiNotFoundResponse({ status: 404, description: 'Not Found' }) 134 | @ApiParam({ 135 | name: 'id', 136 | type: String, 137 | example: 'id: 5e5eb0418aa9340f913008e5', 138 | }) 139 | @ApiOperation({ 140 | summary: 'Remove user', 141 | description: 'Delete specific user by id', 142 | }) 143 | async removeUser(@Param('id', ObjectIdValidationPipe) id: string): Promise { 144 | const result = await this.usersService.removeUser(id); 145 | return result.cata(error => { 146 | throw new NotFoundException({ 147 | status: HttpStatus.NOT_FOUND, 148 | error: error.name, 149 | message: error.message, 150 | }); 151 | }, identity); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, DynamicModule, Inject, NestModule, MiddlewareConsumer } from '@nestjs/common'; 2 | import { UsersController } from './users.controller'; 3 | import { IUsersService } from './interfaces/interfaces'; 4 | import { IRolesService } from '../roles/'; 5 | import { IUserRoleRelService } from '../user-role-rel'; 6 | import { 7 | ROLES_SERVICE, 8 | USER_ROLE_RELATION_SERVICE, 9 | USERS_SERVICE, 10 | AUTHENTICATE, 11 | } from '../constants'; 12 | import { Authenticate } from '../auth'; 13 | 14 | @Module({}) 15 | export class UsersModule implements NestModule { 16 | static forRoot(dependencies: { 17 | usersService: IUsersService; 18 | rolesService: IRolesService; 19 | userRoleRelService: IUserRoleRelService; 20 | authenticate: Authenticate; 21 | }): DynamicModule { 22 | return { 23 | module: UsersModule, 24 | controllers: [UsersController], 25 | providers: [ 26 | { 27 | provide: USERS_SERVICE, 28 | useValue: dependencies.usersService, 29 | }, 30 | { 31 | provide: ROLES_SERVICE, 32 | useValue: dependencies.rolesService, 33 | }, 34 | { 35 | provide: USER_ROLE_RELATION_SERVICE, 36 | useValue: dependencies.userRoleRelService, 37 | }, 38 | { 39 | provide: AUTHENTICATE, 40 | useValue: dependencies.authenticate, 41 | }, 42 | ], 43 | }; 44 | } 45 | 46 | constructor(@Inject(AUTHENTICATE) private readonly authenticate: Authenticate) {} 47 | 48 | configure(consumer: MiddlewareConsumer) { 49 | consumer.apply(this.authenticate).forRoutes(UsersController); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/users/users.repository.ts: -------------------------------------------------------------------------------- 1 | import { UserForCreate, User, UserForUpdate } from './models/user.model'; 2 | import { UserDocument } from './schema/user.schema'; 3 | import { Model } from 'mongoose'; 4 | import { QueryParams, IUsersMapper, IUsersRepository } from './interfaces/interfaces'; 5 | import { Either, Left, Right } from 'monet'; 6 | import { UserNotFoundError } from './errors/errors'; 7 | 8 | export class UsersMapper implements IUsersMapper { 9 | fromEntity(entity: any): User { 10 | return { 11 | id: entity._id.toString(), 12 | username: entity.username, 13 | password: entity.password, 14 | isDeleted: entity.isDeleted, 15 | }; 16 | } 17 | } 18 | 19 | export class UsersRepository implements IUsersRepository { 20 | constructor(private readonly database: Model, private mapper: IUsersMapper) {} 21 | 22 | async create(user: UserForCreate): Promise { 23 | const createdUser = await this.database.create(user); 24 | return this.mapper.fromEntity(createdUser); 25 | } 26 | 27 | async getAll(query?: QueryParams): Promise { 28 | let users; 29 | 30 | if (query) { 31 | users = await this.database.find(query); 32 | } else { 33 | users = await this.database.find(); 34 | } 35 | 36 | return users.map(this.mapper.fromEntity); 37 | } 38 | 39 | async findOne(id: string): Promise> { 40 | const user = await this.database.findById(id); 41 | 42 | if (!user) { 43 | return Left(new UserNotFoundError(id)); 44 | } 45 | return Right(this.mapper.fromEntity(user)); 46 | } 47 | 48 | async update(id: string, patch: UserForUpdate): Promise> { 49 | const eitherGetUser = await this.findOne(id); 50 | 51 | if (eitherGetUser.isLeft()) { 52 | return eitherGetUser; 53 | } 54 | 55 | const updated = await this.database.findByIdAndUpdate(id, patch, { 56 | new: true, 57 | }); 58 | return Right(this.mapper.fromEntity(updated)); 59 | } 60 | 61 | async delete(id: string): Promise> { 62 | const eitherGetUser = await this.findOne(id); 63 | 64 | if (eitherGetUser.isLeft()) { 65 | return eitherGetUser; 66 | } 67 | 68 | const deleted = await this.database.findByIdAndUpdate(id, { isDeleted: true }, { new: true }); 69 | return Right(this.mapper.fromEntity(deleted)); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; 2 | import { Either, Left, Right } from 'monet'; 3 | import { ROLES_SERVICE, USER_ROLE_RELATION_SERVICE } from '../constants'; 4 | import { 5 | IRolesService, 6 | Role, 7 | RoleAlreadyExistsError, 8 | RoleForCreate, 9 | RoleNotFoundError, 10 | } from '../roles'; 11 | import { IUserRoleRelService, RoleRelationNotFoundError } from '../user-role-rel'; 12 | import { UserAlreadyExistsError, UserNotFoundError } from './errors/errors'; 13 | import { IUsersRepository, IUsersService, QueryParams } from './interfaces/interfaces'; 14 | import { AuthenticatedUser, User, UserForCreate, UserForUpdate } from './models/user.model'; 15 | 16 | @Injectable() 17 | export class UsersService implements IUsersService, OnModuleInit { 18 | constructor( 19 | private readonly usersRepo: IUsersRepository, 20 | @Inject(ROLES_SERVICE) 21 | private readonly rolesService: IRolesService, 22 | @Inject(USER_ROLE_RELATION_SERVICE) 23 | private readonly userRoleRelService: IUserRoleRelService, 24 | ) {} 25 | 26 | async onModuleInit() { 27 | const existingAdmin = await this.isUserAlreadyExists('admin'); 28 | 29 | if (existingAdmin) { 30 | return; 31 | } 32 | 33 | const user: UserForCreate = { 34 | username: 'admin', 35 | password: '123', 36 | }; 37 | 38 | const eitherCreate = await this.createAdminUser(user); 39 | return eitherCreate.map(() => { 40 | console.info(`Admin user created with password ${user.password}`); 41 | }); 42 | } 43 | 44 | async getUserCredentials( 45 | username: string, 46 | password: string, 47 | ): Promise> { 48 | const query = { username, password }; 49 | const [user] = await this.usersRepo.getAll(query); 50 | 51 | type GetRoleResult = Either; 52 | const eitherGetByAccount = await this.userRoleRelService.getByAccount(user.id); 53 | const eitherGetRole = await eitherGetByAccount.cata( 54 | async (error): Promise => Left(error), 55 | relations => { 56 | const [relation] = relations; 57 | return this.rolesService.findOne(relation.roleId); 58 | }, 59 | ); 60 | 61 | return eitherGetRole.map(role => ({ 62 | username: user.username, 63 | roles: [role.name], 64 | })); 65 | } 66 | 67 | private async isUserAlreadyExists(username: string): Promise { 68 | const query = { username }; 69 | const [user] = await this.usersRepo.getAll(query); 70 | 71 | return Boolean(user); 72 | } 73 | 74 | private async createAdminUser( 75 | user: UserForCreate, 76 | ): Promise> { 77 | const roleForCreate: RoleForCreate = { 78 | name: 'admin', 79 | displayName: 'ADMIN', 80 | description: 'admin role with access to all API routes', 81 | }; 82 | 83 | const createdUser = await this.usersRepo.create(user); 84 | const eitherCreate = await this.rolesService.create(roleForCreate); 85 | 86 | eitherCreate.map(async role => { 87 | const relation = { 88 | userId: createdUser.id, 89 | roleId: role.id, 90 | }; 91 | 92 | await this.userRoleRelService.create(relation); 93 | }); 94 | 95 | return Right(createdUser); 96 | } 97 | 98 | async getAll(query?: QueryParams): Promise { 99 | return this.usersRepo.getAll(query); 100 | } 101 | 102 | async findOne(id: string): Promise> { 103 | return this.usersRepo.findOne(id); 104 | } 105 | 106 | async addUser(user: UserForCreate): Promise> { 107 | const { username } = user; 108 | const isUserExists = await this.isUserAlreadyExists(username); 109 | 110 | if (isUserExists) { 111 | return Left(new UserAlreadyExistsError(username)); 112 | } 113 | 114 | const createdUser = await this.usersRepo.create(user); 115 | return Right(createdUser); 116 | } 117 | 118 | async updateUser(id: string, patch: UserForUpdate): Promise> { 119 | return this.usersRepo.update(id, patch); 120 | } 121 | 122 | async removeUser(id: string): Promise> { 123 | return this.usersRepo.delete(id); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /test.env: -------------------------------------------------------------------------------- 1 | MONGO_URL = mongodb://localhost:27017/nestjs-e2e 2 | NODE_ENV = test 3 | PORT = 5000 4 | URL = http://localhost -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true, 13 | "noEmitOnError": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true 19 | }, 20 | "exclude": ["node_modules", "dist"] 21 | } 22 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single", "avoid-escape"], 9 | "member-access": [false], 10 | "ordered-imports": [false], 11 | "max-line-length": [true, 150], 12 | "member-ordering": [false], 13 | "interface-name": [false], 14 | "arrow-parens": false, 15 | "object-literal-sort-keys": false, 16 | "interface-over-type-literal": false, 17 | "max-classes-per-file": false, 18 | "no-console": false, 19 | "no-implicit-dependencies": true, 20 | "object-literal-key-quotes": false, 21 | "semicolon": [true, "always", "strict-bound-class-methods"], 22 | "no-return-await": true, 23 | "array-type": false 24 | }, 25 | "rulesDirectory": [] 26 | } 27 | --------------------------------------------------------------------------------