├── .editorconfig ├── .gitignore ├── .nvmrc ├── .travis.yml ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── README.md ├── db-scripts └── create_database.sql ├── docker-compose.yml ├── package-lock.json ├── package.json ├── src ├── container.ts ├── entities │ ├── index.ts │ ├── task.ts │ └── user.ts ├── errors.ts ├── index.ts ├── lib │ ├── authentication │ │ └── index.ts │ ├── database │ │ ├── index.ts │ │ └── migrations │ │ │ └── 20180330164632_create_schema.ts │ ├── hasher │ │ └── index.ts │ └── health │ │ └── index.ts ├── managers │ ├── index.ts │ ├── task-manager.ts │ └── user-manager.ts ├── repositories │ ├── index.ts │ ├── task-repository.ts │ └── user-repository.ts └── server │ ├── health │ ├── controller.ts │ └── index.ts │ ├── index.ts │ ├── middlewares │ ├── authentication.ts │ ├── authorization.ts │ ├── error-handler.ts │ ├── index.ts │ ├── log-request.ts │ ├── response-time.ts │ └── validator.ts │ ├── tasks │ ├── controller.ts │ ├── index.ts │ ├── model.ts │ └── validators.ts │ └── users │ ├── controller.ts │ ├── index.ts │ ├── model.ts │ └── validators.ts ├── test ├── integration │ ├── database-utils.ts │ ├── global-hooks.test.ts │ ├── server-utils.ts │ └── server │ │ ├── health │ │ └── health-check.test.ts │ │ ├── tasks │ │ ├── create-task.test.ts │ │ ├── delete-task.test.ts │ │ ├── get-all-tasks.test.ts │ │ ├── get-task.test.ts │ │ └── update-task.test.ts │ │ └── users │ │ ├── change-password.test.ts │ │ ├── create-user.test.ts │ │ ├── delete-user.test.ts │ │ ├── login.test.ts │ │ ├── update-user.test.ts │ │ └── user-me.test.ts └── unit │ ├── lib │ ├── hasher.test.ts │ └── health.test.ts │ └── server │ └── middlewares │ ├── authentication.test.ts │ ├── authorization.test.ts │ ├── error-handler.test.ts │ ├── log-request.test.ts │ ├── response-time.test.ts │ └── validator.test.ts ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.json] 17 | indent_style = space 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | .DS_Store 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | reports 17 | .nyc_output 18 | 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directory 30 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 31 | node_modules 32 | 33 | #Ignore dist folder 34 | dist 35 | 36 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12 -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | env: 5 | global: 6 | - PORT=8080 7 | - DB_HOST=127.0.0.1 8 | - DB_PORT=3306 9 | - DB_USER=travis 10 | - DB_PASSWORD=secret 11 | 12 | services: 13 | - mysql 14 | 15 | before_install: 16 | - mysql -u root -e "SET PASSWORD FOR 'travis'@'localhost' = PASSWORD('secret')" 17 | - mysql -u travis --password="secret" < db-scripts/create_database.sql 18 | 19 | install: 20 | - npm install 21 | - npm install -g codecov 22 | 23 | script: 24 | - ./node_modules/nyc/bin/nyc.js --exclude dist/test --reporter lcovonly npm run test:all 25 | - codecov 26 | -------------------------------------------------------------------------------- /.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 | "type": "node", 9 | "request": "launch", 10 | "name": "Mocha Tests", 11 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 12 | "args": [ 13 | "${workspaceRoot}/dist/test/**/*.js" 14 | ], 15 | "internalConsoleOptions": "openOnSessionStart", 16 | "remoteRoot": "/app", 17 | "outFiles": [ 18 | "${workspaceRoot}/dist/**/*.js" 19 | ] 20 | }, 21 | { 22 | "type": "node", 23 | "request": "attach", 24 | "name": "Docker: Attach to Node", 25 | "port": 5858, 26 | "address": "localhost", 27 | "localRoot": "${workspaceFolder}", 28 | "remoteRoot": "/app", 29 | "protocol": "inspector", 30 | "outFiles": [ 31 | "${workspaceRoot}/dist/**/*.js" 32 | ] 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.10-alpine 2 | 3 | USER nobody 4 | 5 | # specify the working directory 6 | WORKDIR app 7 | 8 | # expose server and debug port 9 | EXPOSE 8080 5858 10 | 11 | # run application 12 | CMD ["node", "dist/src/index.js"] 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Talento90 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typescript-node [![Build Status](https://travis-ci.org/Talento90/typescript-node.svg?branch=master)](https://travis-ci.org/Talento90/typescript-node) [![codecov](https://codecov.io/gh/Talento90/typescript-node/branch/master/graph/badge.svg)](https://codecov.io/gh/Talento90/typescript-node) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/7e1b73f83bf7485c9d75e8ea9f853d36)](https://www.codacy.com/app/Talento90/typescript-node?utm_source=github.com&utm_medium=referral&utm_content=Talento90/typescript-node&utm_campaign=Badge_Grade) 2 | 3 | 4 | Template for building nodejs and typescript services. The main goal of this boilerplate is to offer a good Developer Experience (eg: debugging, watch and recompile) by providing the following features out of the box: 5 | 6 | ***Features*** 7 | 8 | * Language - [TypeScript](https://www.typescriptlang.org/) 9 | * REST API - [koa2](http://koajs.com/) 10 | * Graceful Shutdown - [Pattern](https://nemethgergely.com/nodejs-healthcheck-graceful-shutdown/) 11 | * HealthCheck - [Pattern /health](http://microservices.io/patterns/observability/health-check-api.html) 12 | * SQL Database & Migrations - [knex](http://knexjs.org/) 13 | * Authentication and Authorization - [JWT](https://github.com/auth0/node-jsonwebtoken) 14 | * Validation - [Joi](https://github.com/hapijs/joi) 15 | * Testing - [Mocha](https://mochajs.org/) [Chai](http://www.chaijs.com/) + [Sinon](http://sinonjs.org/) [Coverage](https://istanbul.js.org/) 16 | * Code Style - [Prettier](https://prettier.io/) 17 | * Git Hooks - [Husky](https://github.com/typicode/husky) 18 | 19 | ## Installation & Run 20 | 21 | * *npm install* - Install dependencies 22 | * *npm run start* - Start application (It needs a mysql database) 23 | 24 | ### Running with Docker 25 | 26 | * *docker-compose up* (compose and run, it also creates the mysql database) 27 | * *docker-compose down* (Destroy application and mysql containers) 28 | 29 | ## Useful npm commands 30 | 31 | * *npm run build* - Transpile TypeScript code 32 | * *npm run clean* - Remove dist, node_modules, coverage folders 33 | * *npm run coverage* - Run NYC coverage 34 | * *npm run lint* - Lint your TypeScript code 35 | * *npm run start:dev* - Run application in dev mode (debug & watch). Debug mode is running on port 5858 (open `chrome://inspect/#devices`). 36 | * *npm run test* - Run unit tests 37 | * *npm run test:integration* - Run integration tests 38 | * *npm run test:all* - Run Unit and Integration tests 39 | -------------------------------------------------------------------------------- /db-scripts/create_database.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE IF NOT EXISTS task_manager 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | services: 3 | app: 4 | build: . 5 | command: npm run start:dev 6 | environment: 7 | - PORT=8080 8 | - DB_HOST=mysql 9 | - DB_PORT=3306 10 | - DB_USER=root 11 | - DB_PASSWORD=secret 12 | ports: 13 | - "8080:8080" 14 | - "5858:5858" 15 | links: 16 | - mysql 17 | volumes: 18 | - .:/app/ 19 | network_mode: bridge 20 | mysql: 21 | image: mysql:5.6 22 | environment: 23 | - MYSQL_DATABASE=task_manager 24 | - MYSQL_ROOT_PASSWORD=secret 25 | ports: 26 | - "3306:3306" 27 | volumes: 28 | - ./db-scripts:/docker-entrypoint-initdb.d 29 | network_mode: bridge 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-node", 3 | "version": "2.1.0", 4 | "description": "TypeScript template for backend applications.", 5 | "license": "MIT", 6 | "repository": { 7 | "url": "https://github.com/Talento90/typescript-node.git" 8 | }, 9 | "author": "Talento90", 10 | "keywords": [ 11 | "typescript", 12 | "nodejs", 13 | "backend" 14 | ], 15 | "scripts": { 16 | "build": "rm -rf dist && tsc", 17 | "build:watch": "rm -rf dist && tsc -w", 18 | "clean": "rm -rf node_modules coverage dist .nyc_output", 19 | "coverage": "nyc --exclude dist/test --reporter=html npm run test:all", 20 | "lint": "tslint 'src/**/*.ts' 'test/**/*.test.ts'", 21 | "start": "node dist/src/index.js", 22 | "start:dev": "tsc-watch --onSuccess 'node --inspect=0.0.0.0:5858 dist/src/index.js'", 23 | "test": "npm run build && mocha --exit --recursive dist/test/unit", 24 | "test:integration": "npm run build && mocha --exit --recursive dist/test/integration", 25 | "test:all": "npm run build && mocha --exit --recursive dist/test" 26 | }, 27 | "dependencies": { 28 | "async": "^3.1.0", 29 | "bcryptjs": "^2.4.3", 30 | "joi": "^14.3.1", 31 | "jsonwebtoken": "^8.5.1", 32 | "knex": "^0.19.5", 33 | "koa": "^2.8.1", 34 | "koa-bodyparser": "^4.2.1", 35 | "koa-helmet": "^5.1.0", 36 | "koa-router": "^7.4.0", 37 | "moment": "^2.24.0", 38 | "mysql2": "^1.7.0", 39 | "pino": "^5.13.3" 40 | }, 41 | "devDependencies": { 42 | "@types/async": "^3.0.1", 43 | "@types/bcryptjs": "^2.4.2", 44 | "@types/chai": "^4.2.3", 45 | "@types/joi": "^14.3.3", 46 | "@types/jsonwebtoken": "^8.3.3", 47 | "@types/knex": "^0.16.1", 48 | "@types/koa": "^2.0.49", 49 | "@types/koa-bodyparser": "^4.3.0", 50 | "@types/koa-helmet": "^3.1.2", 51 | "@types/koa-router": "^7.0.42", 52 | "@types/mocha": "^5.2.7", 53 | "@types/node": "^12.7.5", 54 | "@types/pino": "^5.8.10", 55 | "@types/sinon": "^7.0.13", 56 | "@types/supertest": "^2.0.8", 57 | "chai": "^4.2.0", 58 | "husky": "^3.0.5", 59 | "mocha": "^6.2.0", 60 | "nyc": "^14.1.1", 61 | "prettier": "^1.18.2", 62 | "sinon": "^7.4.2", 63 | "supertest": "^4.0.2", 64 | "tsc-watch": "^3.0.1", 65 | "tslint": "^5.20.0", 66 | "tslint-config-prettier": "^1.18.0", 67 | "tslint-plugin-prettier": "^2.0.1", 68 | "typescript": "^3.6.3" 69 | }, 70 | "engines": { 71 | "node": ">=12.10.0" 72 | }, 73 | "husky": { 74 | "hooks": { 75 | "pre-commit": "npm run lint && npm test" 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/container.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from 'pino' 2 | import { Authenticator, JWTAuthenticator } from './lib/authentication' 3 | import { MySql } from './lib/database' 4 | import { BCryptHasher, Hasher } from './lib/hasher' 5 | import { HealthMonitor } from './lib/health' 6 | import { TaskManager, UserManager } from './managers' 7 | import { TaskRepository, UserRepository } from './repositories' 8 | 9 | export interface ServiceContainer { 10 | health: HealthMonitor 11 | logger: Logger 12 | lib: { 13 | hasher: Hasher 14 | authenticator: Authenticator 15 | } 16 | repositories: { 17 | task: TaskRepository 18 | user: UserRepository 19 | } 20 | managers: { 21 | task: TaskManager 22 | user: UserManager 23 | } 24 | } 25 | 26 | export function createContainer(db: MySql, logger: Logger): ServiceContainer { 27 | const taskRepo = new TaskRepository(db) 28 | const userRepo = new UserRepository(db) 29 | const hasher = new BCryptHasher() 30 | const authenticator = new JWTAuthenticator(userRepo) 31 | const healthMonitor = new HealthMonitor() 32 | 33 | return { 34 | health: healthMonitor, 35 | logger, 36 | lib: { 37 | hasher, 38 | authenticator 39 | }, 40 | repositories: { 41 | task: taskRepo, 42 | user: userRepo 43 | }, 44 | managers: { 45 | task: new TaskManager(taskRepo), 46 | user: new UserManager(userRepo, hasher, authenticator) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/entities/index.ts: -------------------------------------------------------------------------------- 1 | export { User } from './user' 2 | export { Task } from './task' 3 | -------------------------------------------------------------------------------- /src/entities/task.ts: -------------------------------------------------------------------------------- 1 | export interface Task { 2 | id?: number 3 | name: string 4 | description: string 5 | done: boolean 6 | userId: number 7 | created: Date 8 | updated: Date 9 | } 10 | -------------------------------------------------------------------------------- /src/entities/user.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id?: number 3 | email: string 4 | password: string 5 | role: string 6 | firstName: string 7 | lastName: string 8 | created: Date 9 | updated: Date 10 | } 11 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export class AppError extends Error { 2 | public code: number 3 | public error: Error 4 | 5 | constructor(code: number, message: string, error?: Error) { 6 | super(message) 7 | 8 | this.code = code 9 | this.error = error 10 | } 11 | 12 | public toModel() { 13 | return { 14 | code: this.code, 15 | message: this.message 16 | } 17 | } 18 | } 19 | 20 | export class NotFoundError extends AppError { 21 | constructor(message: string) { 22 | super(20000, message) 23 | } 24 | } 25 | 26 | export class ValidationError extends AppError { 27 | constructor(message: string, error?: Error) { 28 | super(30000, message, error) 29 | } 30 | } 31 | 32 | export class FieldValidationError extends AppError { 33 | public fields: FieldError[] 34 | 35 | constructor(message: string, fields: FieldError[], error?: Error) { 36 | super(30001, message, error) 37 | this.fields = fields 38 | } 39 | 40 | public toModel() { 41 | return { 42 | code: this.code, 43 | message: this.message, 44 | fields: this.fields 45 | } 46 | } 47 | } 48 | 49 | export class UnauthorizedError extends AppError { 50 | constructor(error?: Error) { 51 | super(30002, 'Unauthorized user', error) 52 | } 53 | } 54 | 55 | export class PermissionError extends AppError { 56 | constructor(error?: Error) { 57 | super(30003, 'Permission denied', error) 58 | } 59 | } 60 | 61 | export interface FieldError { 62 | message: string 63 | type: string 64 | path: string[] 65 | } 66 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as pino from 'pino' 2 | import { createContainer } from './container' 3 | import { MySql } from './lib/database' 4 | import { HealthMonitor } from './lib/health' 5 | import { AppServer, createServer } from './server' 6 | 7 | export async function init() { 8 | const logger = pino() 9 | 10 | try { 11 | // Starting the HTTP server 12 | logger.info('Starting HTTP server') 13 | 14 | const db = new MySql({ 15 | database: 'task_manager', 16 | host: process.env.DB_HOST, 17 | port: Number(process.env.DB_PORT) || 3306, 18 | user: process.env.DB_USER, 19 | password: process.env.DB_PASSWORD, 20 | debug: process.env.ENV !== 'production' 21 | }) 22 | 23 | logger.info('Apply database migration') 24 | await db.schemaMigration() 25 | 26 | const port = Number(process.env.PORT) || 8080 27 | const container = createContainer(db, logger) 28 | const app = createServer(container) 29 | const health = container.health 30 | 31 | app.listen(port) 32 | 33 | // Register global process events and graceful shutdown 34 | registerProcessEvents(logger, app, db, health) 35 | 36 | logger.info(`Application running on port: ${port}`) 37 | } catch (e) { 38 | logger.error(e, 'An error occurred while initializing application.') 39 | } 40 | } 41 | 42 | function registerProcessEvents( 43 | logger: pino.Logger, 44 | app: AppServer, 45 | db: MySql, 46 | health: HealthMonitor 47 | ) { 48 | process.on('uncaughtException', (error: Error) => { 49 | logger.error('UncaughtException', error) 50 | }) 51 | 52 | process.on('unhandledRejection', (reason: any, promise: any) => { 53 | logger.info(reason, promise) 54 | }) 55 | 56 | process.on('SIGTERM', async () => { 57 | logger.info('Starting graceful shutdown') 58 | 59 | health.shuttingDown() 60 | 61 | let exitCode = 0 62 | const shutdown = [app.closeServer(), db.closeDatabase()] 63 | 64 | for (const s of shutdown) { 65 | try { 66 | await s 67 | } catch (e) { 68 | logger.error('Error in graceful shutdown ', e) 69 | exitCode = 1 70 | } 71 | } 72 | 73 | process.exit(exitCode) 74 | }) 75 | } 76 | 77 | init() 78 | -------------------------------------------------------------------------------- /src/lib/authentication/index.ts: -------------------------------------------------------------------------------- 1 | import * as jwt from 'jsonwebtoken' 2 | import { User } from '../../entities' 3 | import { UnauthorizedError } from '../../errors' 4 | import { UserRepository } from '../../repositories' 5 | 6 | export interface AuthUser { 7 | id: number 8 | email: string 9 | role: Role 10 | } 11 | 12 | export enum Role { 13 | user = 'user', 14 | admin = 'admin' 15 | } 16 | 17 | export interface Authenticator { 18 | validate(token: string): Promise 19 | authenticate(user: User): string 20 | } 21 | 22 | export class JWTAuthenticator implements Authenticator { 23 | private userRepo: UserRepository 24 | private secret: string 25 | 26 | constructor(userRepo: UserRepository) { 27 | this.userRepo = userRepo 28 | this.secret = process.env.SECRET_KEY || 'secret' 29 | } 30 | 31 | public async validate(token: string): Promise { 32 | try { 33 | const decode: any = jwt.verify(token, this.secret) 34 | const user = await this.userRepo.findByEmail(decode.email) 35 | 36 | return { 37 | id: user.id, 38 | email: user.email, 39 | role: user.role as Role 40 | } 41 | } catch (err) { 42 | throw new UnauthorizedError(err) 43 | } 44 | } 45 | 46 | public authenticate(user: User): string { 47 | return jwt.sign( 48 | { id: user.id, email: user.email, role: user.role }, 49 | this.secret, 50 | { 51 | expiresIn: 60 * 60 52 | } 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/database/index.ts: -------------------------------------------------------------------------------- 1 | import { AsyncResultCallback, retry } from 'async' 2 | import * as knex from 'knex' 3 | import * as path from 'path' 4 | 5 | export interface Configuration { 6 | host: string 7 | port: number 8 | user: string 9 | password: string 10 | database: string 11 | debug: boolean 12 | } 13 | 14 | export class MySql { 15 | private config: Configuration 16 | private connection: knex | undefined 17 | private retryDbConnectionPromise: Promise | undefined 18 | 19 | constructor(config: Configuration) { 20 | this.config = config 21 | } 22 | 23 | public async getConnection(): Promise { 24 | if (!this.connection) { 25 | this.connection = await this.retryDbConnection() 26 | } 27 | 28 | return this.connection 29 | } 30 | 31 | public async getTransaction(): Promise { 32 | const connection = await this.getConnection() 33 | 34 | return new Promise((resolve, reject) => { 35 | try { 36 | connection.transaction((trx: knex.Transaction) => { 37 | resolve(trx) 38 | }) 39 | } catch (err) { 40 | reject(err) 41 | } 42 | }) 43 | } 44 | 45 | public async closeDatabase(): Promise { 46 | if (this.connection) { 47 | await this.connection.destroy() 48 | this.connection = undefined 49 | } 50 | } 51 | 52 | public async schemaMigration() { 53 | const connection = await this.getConnection() 54 | 55 | await connection.migrate.latest({ 56 | directory: path.resolve(__dirname, './migrations') 57 | }) 58 | } 59 | 60 | private async createConnection(): Promise { 61 | const config: knex.Config = { 62 | client: 'mysql2', 63 | connection: { 64 | host: this.config.host, 65 | port: this.config.port, 66 | user: this.config.user, 67 | password: this.config.password, 68 | database: this.config.database 69 | }, 70 | debug: this.config.debug, 71 | migrations: { 72 | tableName: 'migrations' 73 | } 74 | } 75 | 76 | const db = knex(config) 77 | 78 | // Test database connectivity! 79 | await db.raw('select 1') 80 | 81 | return db 82 | } 83 | 84 | private retryDbConnection(): Promise { 85 | if (this.retryDbConnectionPromise instanceof Promise) { 86 | return this.retryDbConnectionPromise 87 | } 88 | 89 | const methodToRetry = (cb: AsyncResultCallback) => { 90 | this.createConnection() 91 | .then((db: knex) => { 92 | cb(undefined, db) 93 | }) 94 | .catch((err: Error) => { 95 | cb(err, undefined) 96 | }) 97 | } 98 | 99 | this.retryDbConnectionPromise = new Promise((resolve, reject) => { 100 | retry( 101 | { times: 3, interval: 1000 }, 102 | methodToRetry, 103 | (err: Error | undefined, db: knex) => { 104 | if (err) { 105 | reject(err) 106 | } else { 107 | resolve(db) 108 | } 109 | 110 | this.retryDbConnectionPromise = undefined 111 | } 112 | ) 113 | }) 114 | 115 | return this.retryDbConnectionPromise 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/lib/database/migrations/20180330164632_create_schema.ts: -------------------------------------------------------------------------------- 1 | import * as knex from 'knex' 2 | 3 | export function up(db: knex) { 4 | return db.schema 5 | .createTable('user', table => { 6 | table.increments('id').primary() 7 | table.string('email', 64).unique() 8 | table.string('password', 256).notNullable() 9 | table.enum('role', ['user', 'admin']).notNullable() 10 | table.string('first_name', 64).notNullable() 11 | table.string('last_name', 64).notNullable() 12 | table.dateTime('created').notNullable() 13 | table.dateTime('updated').notNullable() 14 | }) 15 | .then(() => { 16 | return db.schema.createTable('task', table => { 17 | table.increments('id').primary() 18 | table.string('name', 64).notNullable() 19 | table.string('description').notNullable() 20 | table.boolean('done').notNullable() 21 | table.dateTime('created').notNullable() 22 | table.dateTime('updated').notNullable() 23 | table 24 | .integer('user_id') 25 | .notNullable() 26 | .unsigned() 27 | .references('id') 28 | .inTable('user') 29 | }) 30 | }) 31 | } 32 | 33 | export function down(db: knex) { 34 | return db.schema.dropTable('task').dropTable('user') 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/hasher/index.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcryptjs' 2 | 3 | export interface Hasher { 4 | hashPassword(password: string): Promise 5 | verifyPassword(password: string, hash: string): Promise 6 | } 7 | 8 | export class BCryptHasher implements Hasher { 9 | public async hashPassword(password: string): Promise { 10 | const salt = bcrypt.genSaltSync(10) 11 | 12 | return bcrypt.hash(password, salt) 13 | } 14 | 15 | public verifyPassword(password: string, hash: string): Promise { 16 | return bcrypt.compare(password, hash) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/health/index.ts: -------------------------------------------------------------------------------- 1 | import * as moment from 'moment' 2 | 3 | export interface Status { 4 | startTime: string 5 | upTime: string 6 | isShuttingDown: boolean 7 | } 8 | 9 | export class HealthMonitor { 10 | private startTime: number 11 | private isShuttingDown: boolean 12 | 13 | constructor() { 14 | this.isShuttingDown = false 15 | this.startTime = Date.now() 16 | } 17 | 18 | public shuttingDown() { 19 | this.isShuttingDown = true 20 | } 21 | 22 | public getStatus(): Status { 23 | return { 24 | startTime: new Date(this.startTime).toISOString(), 25 | upTime: moment(this.startTime).fromNow(true), 26 | isShuttingDown: this.isShuttingDown 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/managers/index.ts: -------------------------------------------------------------------------------- 1 | export { UserManager } from './user-manager' 2 | export { TaskManager } from './task-manager' 3 | -------------------------------------------------------------------------------- /src/managers/task-manager.ts: -------------------------------------------------------------------------------- 1 | import { Task } from '../entities' 2 | import { TaskRepository } from '../repositories' 3 | 4 | export class TaskManager { 5 | private repo: TaskRepository 6 | 7 | constructor(repo: TaskRepository) { 8 | this.repo = repo 9 | } 10 | 11 | public find(userId: number, id: number): Promise { 12 | return this.repo.find(userId, id) 13 | } 14 | 15 | public async findUserTasks( 16 | userId: number, 17 | limit: number, 18 | offset: number 19 | ): Promise { 20 | return this.repo.findByUser(userId, limit, offset) 21 | } 22 | 23 | public create(task: Task): Promise { 24 | return this.repo.insert(task) 25 | } 26 | 27 | public update(task: Task): Promise { 28 | return this.repo.update(task) 29 | } 30 | 31 | public delete(userId: number, taskId: number): Promise { 32 | return this.repo.delete(userId, taskId) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/managers/user-manager.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../entities' 2 | import { ValidationError } from '../errors' 3 | import { Authenticator } from '../lib/authentication' 4 | import { Hasher } from '../lib/hasher' 5 | import { UserRepository } from '../repositories' 6 | 7 | export class UserManager { 8 | private repo: UserRepository 9 | private hasher: Hasher 10 | private auth: Authenticator 11 | 12 | constructor(repo: UserRepository, hasher: Hasher, auth: Authenticator) { 13 | this.repo = repo 14 | this.hasher = hasher 15 | this.auth = auth 16 | } 17 | 18 | public async findByEmail(email: string): Promise { 19 | return this.repo.findByEmail(email) 20 | } 21 | 22 | public async create(user: User): Promise { 23 | const hashPassword = await this.hasher.hashPassword(user.password) 24 | 25 | user.password = hashPassword 26 | 27 | return this.repo.insert(user) 28 | } 29 | 30 | public async login(email: string, password: string): Promise { 31 | const user = await this.repo.findByEmail(email) 32 | 33 | if (await this.hasher.verifyPassword(password, user.password)) { 34 | return this.auth.authenticate(user) 35 | } 36 | 37 | throw new ValidationError('Wrong credentials') 38 | } 39 | 40 | public update(user: User): Promise { 41 | return this.repo.update(user) 42 | } 43 | 44 | public async changePassword( 45 | email: string, 46 | newPassword: string, 47 | oldPassword: string 48 | ): Promise { 49 | const user = await this.repo.findByEmail(email) 50 | const validPassword = await this.hasher.verifyPassword( 51 | oldPassword, 52 | user.password 53 | ) 54 | 55 | if (!validPassword) { 56 | throw new ValidationError('Old password is not correct') 57 | } 58 | 59 | const hashPassword = await this.hasher.hashPassword(newPassword) 60 | 61 | return this.repo.changePassword(email, hashPassword) 62 | } 63 | 64 | public delete(userId: number): Promise { 65 | return this.repo.delete(userId) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export { UserRepository } from './user-repository' 2 | export { TaskRepository } from './task-repository' 3 | -------------------------------------------------------------------------------- /src/repositories/task-repository.ts: -------------------------------------------------------------------------------- 1 | import { Task } from '../entities' 2 | import { NotFoundError } from '../errors' 3 | import { MySql } from '../lib/database' 4 | 5 | export class TaskRepository { 6 | private readonly TABLE: string = 'task' 7 | private db: MySql 8 | 9 | constructor(db: MySql) { 10 | this.db = db 11 | } 12 | 13 | public async find(userId: number, id: number): Promise { 14 | const conn = await this.db.getConnection() 15 | const row = await conn 16 | .select() 17 | .from(this.TABLE) 18 | .where({ id, user_id: userId }) 19 | .first() 20 | 21 | if (!row) { 22 | throw new NotFoundError('Task does not exist') 23 | } 24 | 25 | return this.transform(row) 26 | } 27 | 28 | public async findByUser( 29 | userId: number, 30 | limit: number, 31 | offset: number 32 | ): Promise { 33 | const conn = await this.db.getConnection() 34 | const results = await conn 35 | .select() 36 | .from(this.TABLE) 37 | .where({ user_id: userId }) 38 | .orderBy('updated', 'DESC') 39 | .offset(offset) 40 | .limit(limit) 41 | 42 | return results.map((r: any) => this.transform(r)) 43 | } 44 | 45 | public async insert(task: Task): Promise { 46 | task.created = new Date() 47 | task.updated = new Date() 48 | 49 | const conn = await this.db.getConnection() 50 | const result = await conn.table(this.TABLE).insert({ 51 | name: task.name, 52 | description: task.description, 53 | done: task.done, 54 | created: task.created, 55 | updated: task.updated, 56 | user_id: task.userId 57 | }) 58 | 59 | task.id = result[0] 60 | 61 | return task 62 | } 63 | 64 | public async update(task: Task): Promise { 65 | task.updated = new Date() 66 | 67 | const conn = await this.db.getConnection() 68 | 69 | await conn 70 | .table(this.TABLE) 71 | .update({ 72 | name: task.name, 73 | description: task.description, 74 | done: task.done 75 | }) 76 | .where({ user_id: task.userId, id: task.id }) 77 | 78 | return task 79 | } 80 | 81 | public async delete(userId: number, taskId: number): Promise { 82 | const conn = await this.db.getConnection() 83 | 84 | const result = await conn 85 | .from(this.TABLE) 86 | .delete() 87 | .where({ id: taskId, user_id: userId }) 88 | 89 | if (result === 0) { 90 | throw new NotFoundError('Task does not exist') 91 | } 92 | } 93 | 94 | private transform(row: any): Task { 95 | return { 96 | id: row.id, 97 | name: row.name, 98 | description: row.description, 99 | userId: row.user_id, 100 | done: row.done === 1, 101 | created: row.created, 102 | updated: row.updated 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/repositories/user-repository.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../entities' 2 | import { NotFoundError, ValidationError } from '../errors' 3 | import { MySql } from '../lib/database' 4 | 5 | export class UserRepository { 6 | private readonly TABLE: string = 'user' 7 | private db: MySql 8 | 9 | constructor(db: MySql) { 10 | this.db = db 11 | } 12 | 13 | public async findByEmail(email: string): Promise { 14 | const conn = await this.db.getConnection() 15 | const row = await conn 16 | .table(this.TABLE) 17 | .where({ email }) 18 | .first() 19 | 20 | if (!row) { 21 | throw new NotFoundError('User does not exist') 22 | } 23 | 24 | return this.transform(row) 25 | } 26 | 27 | public async insert(user: User): Promise { 28 | user.created = new Date() 29 | user.updated = new Date() 30 | 31 | const conn = await this.db.getConnection() 32 | 33 | try { 34 | const result = await conn.table(this.TABLE).insert({ 35 | email: user.email, 36 | password: user.password, 37 | role: user.role, 38 | first_name: user.firstName, 39 | last_name: user.lastName, 40 | created: user.created, 41 | updated: user.updated 42 | }) 43 | 44 | user.id = result[0] 45 | 46 | return user 47 | } catch (err) { 48 | if (err.code === 'ER_DUP_ENTRY') { 49 | throw new ValidationError(`Email ${user.email} already exists`, err) 50 | } 51 | 52 | throw err 53 | } 54 | } 55 | 56 | public async update(user: User): Promise { 57 | user.updated = new Date() 58 | 59 | const conn = await this.db.getConnection() 60 | 61 | await conn.table(this.TABLE).update({ 62 | first_name: user.firstName, 63 | last_name: user.lastName, 64 | password: user.password 65 | }) 66 | 67 | return user 68 | } 69 | 70 | public async changePassword( 71 | email: string, 72 | newPassword: string 73 | ): Promise { 74 | const conn = await this.db.getConnection() 75 | 76 | await conn 77 | .table(this.TABLE) 78 | .update({ 79 | password: newPassword, 80 | updated: new Date() 81 | }) 82 | .where('email', email) 83 | } 84 | 85 | public async delete(userId: number): Promise { 86 | const trx = await this.db.getTransaction() 87 | 88 | try { 89 | await trx 90 | .from('task') 91 | .delete() 92 | .where({ user_id: userId }) 93 | 94 | await trx 95 | .from(this.TABLE) 96 | .delete() 97 | .where({ id: userId }) 98 | 99 | await trx.commit() 100 | } catch (error) { 101 | trx.rollback(error) 102 | throw error 103 | } 104 | } 105 | 106 | private transform(row: any): User { 107 | return { 108 | id: row.id, 109 | email: row.email, 110 | password: row.password, 111 | role: row.role, 112 | firstName: row.first_name, 113 | lastName: row.last_name, 114 | created: row.created, 115 | updated: row.updated 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/server/health/controller.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa' 2 | import { HealthMonitor } from '../../lib/health' 3 | 4 | export default class HealthController { 5 | private health: HealthMonitor 6 | 7 | constructor(health: HealthMonitor) { 8 | this.health = health 9 | } 10 | 11 | public getHealth(ctx: Context) { 12 | const status = this.health.getStatus() 13 | 14 | ctx.body = status 15 | ctx.status = status.isShuttingDown ? 503 : 200 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/server/health/index.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa' 2 | import * as Router from 'koa-router' 3 | import { ServiceContainer } from '../../container' 4 | import HealthController from './controller' 5 | 6 | export function init(server: Koa, container: ServiceContainer) { 7 | const controller = new HealthController(container.health) 8 | const router = new Router() 9 | 10 | router.get('/health', controller.getHealth.bind(controller)) 11 | 12 | server.use(router.routes()) 13 | } 14 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import { ErrorCallback, retry } from 'async' 2 | import { Server } from 'http' 3 | import * as Koa from 'koa' 4 | import * as helmet from 'koa-helmet' 5 | import { ServiceContainer } from '../container' 6 | import { AppError } from '../errors' 7 | import * as health from './health' 8 | import * as middlewares from './middlewares' 9 | import * as task from './tasks' 10 | import * as user from './users' 11 | 12 | export class AppServer { 13 | private app: Koa 14 | private server: Server 15 | 16 | constructor(app: Koa) { 17 | this.app = app 18 | } 19 | 20 | public listen(port: number): Server { 21 | this.server = this.app.listen(port) 22 | return this.server 23 | } 24 | 25 | public getServer(): Server { 26 | return this.server 27 | } 28 | 29 | public closeServer(): Promise { 30 | if (this.server === undefined) { 31 | throw new AppError(10001, 'Server is not initialized.') 32 | } 33 | 34 | const checkPendingRequests = ( 35 | callback: ErrorCallback 36 | ) => { 37 | this.server.getConnections( 38 | (err: Error | null, pendingRequests: number) => { 39 | if (err) { 40 | callback(err) 41 | } else if (pendingRequests > 0) { 42 | callback(Error(`Number of pending requests: ${pendingRequests}`)) 43 | } else { 44 | callback(undefined) 45 | } 46 | } 47 | ) 48 | } 49 | 50 | return new Promise((resolve, reject) => { 51 | retry( 52 | { times: 10, interval: 1000 }, 53 | checkPendingRequests.bind(this), 54 | ((error: Error | undefined) => { 55 | if (error) { 56 | this.server.close(() => reject(error)) 57 | } else { 58 | this.server.close(() => resolve()) 59 | } 60 | }).bind(this) 61 | ) 62 | }) 63 | } 64 | } 65 | 66 | export function createServer(container: ServiceContainer): AppServer { 67 | const app = new Koa() 68 | const appSrv = new AppServer(app) 69 | 70 | // Register Middlewares 71 | app.use(helmet()) 72 | app.use(middlewares.responseTime) 73 | app.use(middlewares.logRequest(container.logger)) 74 | app.use(middlewares.errorHandler(container.logger)) 75 | 76 | // Register routes 77 | health.init(app, container) 78 | user.init(app, container) 79 | task.init(app, container) 80 | 81 | return appSrv 82 | } 83 | -------------------------------------------------------------------------------- /src/server/middlewares/authentication.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa' 2 | import { IMiddleware } from 'koa-router' 3 | import { Authenticator } from '../../lib/authentication' 4 | 5 | export function authentication(authenticator: Authenticator): IMiddleware { 6 | return async (ctx: Context, next: () => Promise) => { 7 | const token = ctx.headers.authorization 8 | const user = await authenticator.validate(token) 9 | 10 | ctx.state.user = user 11 | await next() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/server/middlewares/authorization.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa' 2 | import { IMiddleware } from 'koa-router' 3 | import { PermissionError } from '../../errors' 4 | import { AuthUser, Role } from '../../lib/authentication' 5 | 6 | export function authorization(roles: Role[]): IMiddleware { 7 | return async (ctx: Context, next: () => Promise) => { 8 | const user: AuthUser = ctx.state.user 9 | 10 | if (roles.indexOf(user.role) < 0) { 11 | throw new PermissionError() 12 | } 13 | 14 | await next() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/server/middlewares/error-handler.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa' 2 | import { IMiddleware } from 'koa-router' 3 | import { Logger } from 'pino' 4 | import { AppError } from '../../errors' 5 | 6 | const httpCodes = { 7 | 10000: 500, 8 | 20000: 404, 9 | 30000: 400, 10 | 30001: 400, 11 | 30002: 401, 12 | 30003: 403 13 | } 14 | 15 | export function errorHandler(logger: Logger): IMiddleware { 16 | return async (ctx: Context, next: () => Promise) => { 17 | try { 18 | await next() 19 | } catch (err) { 20 | logger.error('Error Handler:', err) 21 | 22 | if (err instanceof AppError) { 23 | ctx.body = err.toModel() 24 | ctx.status = httpCodes[err.code] ? httpCodes[err.code] : 500 25 | } else { 26 | ctx.body = new AppError(10000, 'Internal Error Server') 27 | ctx.status = 500 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/server/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export { authentication } from './authentication' 2 | export { authorization } from './authorization' 3 | export { errorHandler } from './error-handler' 4 | export { logRequest } from './log-request' 5 | export { validate } from './validator' 6 | export { responseTime } from './response-time' 7 | -------------------------------------------------------------------------------- /src/server/middlewares/log-request.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa' 2 | import { IMiddleware } from 'koa-router' 3 | import { Logger } from 'pino' 4 | 5 | export function logRequest(logger: Logger): IMiddleware { 6 | return async (ctx: Context, next: () => Promise) => { 7 | const start = Date.now() 8 | 9 | await next() 10 | 11 | const message = `[${ctx.status}] ${ctx.method} ${ctx.path}` 12 | const logData: any = { 13 | method: ctx.method, 14 | path: ctx.path, 15 | statusCode: ctx.status, 16 | timeMs: Date.now() - start 17 | } 18 | 19 | if (ctx.status >= 400) { 20 | logger.error(message, logData, ctx.body) 21 | } else { 22 | logger.info(message, logData) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/server/middlewares/response-time.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa' 2 | 3 | export async function responseTime(ctx: Context, next: () => Promise) { 4 | const start = Date.now() 5 | 6 | await next() 7 | 8 | ctx.set('X-Response-Time', (Date.now() - start).toString()) 9 | } 10 | -------------------------------------------------------------------------------- /src/server/middlewares/validator.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi' 2 | import { Context } from 'koa' 3 | import { IMiddleware } from 'koa-router' 4 | import { FieldValidationError } from '../../errors' 5 | 6 | export interface SchemaMap { 7 | params?: { [key: string]: Joi.SchemaLike } 8 | 9 | request?: { 10 | body?: { [key: string]: Joi.SchemaLike } | Joi.ArraySchema 11 | headers?: { [key: string]: Joi.SchemaLike } 12 | } 13 | 14 | response?: { 15 | body?: { [key: string]: Joi.SchemaLike } | Joi.ArraySchema 16 | headers?: { [key: string]: Joi.SchemaLike } 17 | } 18 | } 19 | 20 | export function validate(schema: SchemaMap): IMiddleware { 21 | return async (ctx: Context, next: () => Promise) => { 22 | const valResult = Joi.validate(ctx, schema, { 23 | allowUnknown: true, 24 | abortEarly: false 25 | }) 26 | 27 | if (valResult.error) { 28 | throw new FieldValidationError( 29 | valResult.error.message, 30 | valResult.error.details.map(f => ({ 31 | message: f.message, 32 | path: f.path, 33 | type: f.type 34 | })), 35 | valResult.error 36 | ) 37 | } 38 | 39 | await next() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/server/tasks/controller.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa' 2 | import { Task } from '../../entities' 3 | import { AuthUser } from '../../lib/authentication' 4 | import { TaskManager } from '../../managers' 5 | import { TaskModel } from './model' 6 | 7 | export class TaskController { 8 | private manager: TaskManager 9 | 10 | constructor(manager: TaskManager) { 11 | this.manager = manager 12 | } 13 | 14 | public async get(ctx: Context) { 15 | const authUser: AuthUser = ctx.state.user 16 | const task = await this.manager.find(authUser.id, ctx.params.id) 17 | 18 | ctx.body = new TaskModel(task) 19 | ctx.status = 200 20 | } 21 | 22 | public async getAll(ctx: Context) { 23 | const authUser: AuthUser = ctx.state.user 24 | const limit = isNaN(ctx.query.limit) ? 10 : parseInt(ctx.query.limit, 10) 25 | const offset = isNaN(ctx.query.offset) ? 0 : parseInt(ctx.query.offset, 10) 26 | const tasks = await this.manager.findUserTasks(authUser.id, limit, offset) 27 | 28 | ctx.body = tasks.map((t: Task) => new TaskModel(t)) 29 | ctx.status = 200 30 | } 31 | 32 | public async create(ctx: Context) { 33 | const authUser: AuthUser = ctx.state.user 34 | const task: Task = ctx.request.body 35 | 36 | task.userId = authUser.id 37 | task.done = false 38 | 39 | const newTask = await this.manager.create(task) 40 | 41 | ctx.body = new TaskModel(newTask) 42 | ctx.status = 201 43 | ctx.set('location', `/api/v1/tasks/${newTask.id}`) 44 | } 45 | 46 | public async update(ctx: Context) { 47 | const taskDto = ctx.request.body 48 | const authUser: AuthUser = ctx.state.user 49 | const task = await this.manager.find(authUser.id, ctx.params.id) 50 | 51 | task.name = taskDto.name 52 | task.description = taskDto.description 53 | task.done = taskDto.done 54 | 55 | const updatedTask = await this.manager.update(task) 56 | 57 | ctx.body = new TaskModel(updatedTask) 58 | ctx.status = 200 59 | } 60 | 61 | public async delete(ctx: Context) { 62 | const authUser: AuthUser = ctx.state.user 63 | const id: number = ctx.params.id 64 | 65 | await this.manager.delete(authUser.id, id) 66 | 67 | ctx.status = 204 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/server/tasks/index.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi' 2 | import * as Koa from 'koa' 3 | import * as bodyParser from 'koa-bodyparser' 4 | import * as Router from 'koa-router' 5 | import { ServiceContainer } from '../../container' 6 | import { Role } from '../../lib/authentication' 7 | import * as middleware from '../middlewares' 8 | import { TaskController } from './controller' 9 | import * as validators from './validators' 10 | 11 | export function init(server: Koa, container: ServiceContainer) { 12 | const router = new Router({ prefix: '/api/v1/tasks' }) 13 | const controller = new TaskController(container.managers.task) 14 | 15 | router.get( 16 | '/:id', 17 | middleware.authentication(container.lib.authenticator), 18 | middleware.authorization([Role.user, Role.admin]), 19 | controller.get.bind(controller) 20 | ) 21 | 22 | router.get( 23 | '/', 24 | middleware.authentication(container.lib.authenticator), 25 | middleware.authorization([Role.user, Role.admin]), 26 | controller.getAll.bind(controller) 27 | ) 28 | 29 | router.post( 30 | '/', 31 | bodyParser(), 32 | middleware.authentication(container.lib.authenticator), 33 | middleware.authorization([Role.user, Role.admin]), 34 | middleware.validate({ request: { body: validators.createTask } }), 35 | controller.create.bind(controller) 36 | ) 37 | 38 | router.put( 39 | '/:id', 40 | bodyParser(), 41 | middleware.authentication(container.lib.authenticator), 42 | middleware.authorization([Role.user, Role.admin]), 43 | middleware.validate({ 44 | params: { id: Joi.number().required() }, 45 | request: { 46 | body: validators.updateTask 47 | } 48 | }), 49 | controller.update.bind(controller) 50 | ) 51 | 52 | router.delete( 53 | '/:id', 54 | middleware.authentication(container.lib.authenticator), 55 | middleware.authorization([Role.user, Role.admin]), 56 | middleware.validate({ params: { id: Joi.number().required() } }), 57 | controller.delete.bind(controller) 58 | ) 59 | 60 | server.use(router.routes()) 61 | } 62 | -------------------------------------------------------------------------------- /src/server/tasks/model.ts: -------------------------------------------------------------------------------- 1 | import { Task } from '../../entities' 2 | 3 | export interface CreateTask { 4 | name: string 5 | description: string 6 | } 7 | 8 | export class TaskModel { 9 | public id?: number 10 | public name: string 11 | public description: string 12 | public done: boolean 13 | public created: Date 14 | public updated: Date 15 | 16 | constructor(task: Task) { 17 | this.id = task.id 18 | this.name = task.name 19 | this.description = task.description 20 | this.done = task.done 21 | this.created = task.created 22 | this.updated = task.updated 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/server/tasks/validators.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi' 2 | 3 | export const updateTask: Joi.SchemaMap = { 4 | name: Joi.string().required(), 5 | description: Joi.string().required(), 6 | done: Joi.boolean().required() 7 | } 8 | 9 | export const createTask: Joi.SchemaMap = { 10 | name: Joi.string().required(), 11 | description: Joi.string().required() 12 | } 13 | -------------------------------------------------------------------------------- /src/server/users/controller.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa' 2 | import { User } from '../../entities' 3 | import { AuthUser } from '../../lib/authentication' 4 | import { UserManager } from '../../managers' 5 | import { CreateUser, UserModel } from './model' 6 | 7 | export class UserController { 8 | private manager: UserManager 9 | 10 | constructor(manager: UserManager) { 11 | this.manager = manager 12 | } 13 | 14 | public async create(ctx: Context) { 15 | const userDto: CreateUser = ctx.request.body 16 | const newUser = await this.manager.create(userDto as User) 17 | 18 | ctx.body = new UserModel(newUser) 19 | ctx.status = 201 20 | ctx.set('location', '/api/v1/users/me') 21 | } 22 | 23 | public async login(ctx: Context) { 24 | ctx.body = { 25 | accessToken: await this.manager.login( 26 | ctx.request.body.email, 27 | ctx.request.body.password 28 | ) 29 | } 30 | } 31 | 32 | public async update(ctx: Context) { 33 | const userDto = ctx.request.body 34 | const user = await this.manager.findByEmail(ctx.state.user.email) 35 | 36 | user.firstName = userDto.firstName 37 | user.lastName = userDto.lastName 38 | 39 | const updatedUser = await this.manager.update(user) 40 | 41 | ctx.body = new UserModel(updatedUser) 42 | ctx.status = 200 43 | } 44 | 45 | public async changePassword(ctx: Context) { 46 | const newPassword = ctx.request.body.newPassword 47 | const oldPassword = ctx.request.body.oldPassword 48 | 49 | await this.manager.changePassword( 50 | ctx.state.user.email, 51 | newPassword, 52 | oldPassword 53 | ) 54 | 55 | ctx.status = 204 56 | } 57 | 58 | public async get(ctx: Context) { 59 | const authUser: AuthUser = ctx.state.user 60 | const user = await this.manager.findByEmail(authUser.email) 61 | 62 | ctx.body = new UserModel(user) 63 | ctx.status = 200 64 | } 65 | 66 | public async delete(ctx: Context) { 67 | await this.manager.delete(ctx.params.id) 68 | 69 | ctx.status = 204 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/server/users/index.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi' 2 | import * as Koa from 'koa' 3 | import * as bodyParser from 'koa-bodyparser' 4 | import * as Router from 'koa-router' 5 | import { ServiceContainer } from '../../container' 6 | import { Role } from '../../lib/authentication' 7 | import * as middleware from '../middlewares' 8 | import { UserController } from './controller' 9 | import * as validators from './validators' 10 | 11 | export function init(server: Koa, container: ServiceContainer) { 12 | const router = new Router({ prefix: '/api/v1/users' }) 13 | const controller = new UserController(container.managers.user) 14 | 15 | router.get( 16 | '/me', 17 | middleware.authentication(container.lib.authenticator), 18 | middleware.authorization([Role.user, Role.admin]), 19 | controller.get.bind(controller) 20 | ) 21 | 22 | router.post( 23 | '/', 24 | bodyParser(), 25 | middleware.validate({ request: { body: validators.createUser } }), 26 | controller.create.bind(controller) 27 | ) 28 | 29 | router.post( 30 | '/login', 31 | bodyParser(), 32 | middleware.validate({ request: { body: validators.login } }), 33 | controller.login.bind(controller) 34 | ) 35 | 36 | router.put( 37 | '/', 38 | bodyParser(), 39 | middleware.authentication(container.lib.authenticator), 40 | middleware.authorization([Role.user, Role.admin]), 41 | middleware.validate({ request: { body: validators.updateUser } }), 42 | controller.update.bind(controller) 43 | ) 44 | 45 | router.put( 46 | '/password', 47 | bodyParser(), 48 | middleware.authentication(container.lib.authenticator), 49 | middleware.authorization([Role.user, Role.admin]), 50 | middleware.validate({ 51 | request: { 52 | body: validators.changePassword 53 | } 54 | }), 55 | controller.changePassword.bind(controller) 56 | ) 57 | 58 | router.delete( 59 | '/:id', 60 | middleware.authentication(container.lib.authenticator), 61 | middleware.authorization([Role.admin]), 62 | middleware.validate({ 63 | params: { id: Joi.number().required() } 64 | }), 65 | controller.delete.bind(controller) 66 | ) 67 | 68 | server.use(router.routes()) 69 | } 70 | -------------------------------------------------------------------------------- /src/server/users/model.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../entities' 2 | 3 | export interface CreateUser { 4 | email: string 5 | password: string 6 | firstName: string 7 | lastName: string 8 | } 9 | 10 | export class UserModel { 11 | public id: number 12 | public email: string 13 | public firstName: string 14 | public lastName: string 15 | public created: Date 16 | public updated: Date 17 | 18 | constructor(user: User) { 19 | this.id = user.id 20 | this.email = user.email 21 | this.firstName = user.firstName 22 | this.lastName = user.lastName 23 | this.created = user.created 24 | this.updated = user.updated 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/server/users/validators.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi' 2 | 3 | export const createUser: Joi.SchemaMap = { 4 | email: Joi.string() 5 | .email() 6 | .trim() 7 | .required(), 8 | password: Joi.string() 9 | .trim() 10 | .required(), 11 | firstName: Joi.string().required(), 12 | lastName: Joi.string().required() 13 | } 14 | 15 | export const updateUser: Joi.SchemaMap = { 16 | firstName: Joi.string().required(), 17 | lastName: Joi.string().required() 18 | } 19 | 20 | export const changePassword: Joi.SchemaMap = { 21 | oldPassword: Joi.string().required(), 22 | newPassword: Joi.string().required() 23 | } 24 | 25 | export const login: Joi.SchemaMap = { 26 | email: Joi.string() 27 | .email() 28 | .trim() 29 | .required(), 30 | password: Joi.string() 31 | .trim() 32 | .required() 33 | } 34 | -------------------------------------------------------------------------------- /test/integration/database-utils.ts: -------------------------------------------------------------------------------- 1 | import { Role } from '../../src/lib/authentication' 2 | import { Configuration, MySql } from '../../src/lib/database' 3 | 4 | const testMysqlConfig: Configuration = { 5 | database: 'task_manager', 6 | host: process.env.DB_HOST || '127.0.0.1', 7 | port: Number(process.env.DB_PORT) || 3306, 8 | user: process.env.DB_USER || 'root', 9 | password: process.env.DB_PASSWORD || 'secret', 10 | debug: false 11 | } 12 | 13 | export const database: MySql = new MySql(testMysqlConfig) 14 | 15 | export async function truncateTables(tables: string[]) { 16 | const conn = await database.getConnection() 17 | 18 | for (const table of tables) { 19 | await conn.raw(`DELETE FROM ${table}`) 20 | } 21 | } 22 | 23 | export async function setAdminMode(email: string): Promise { 24 | const conn = await database.getConnection() 25 | 26 | await conn 27 | .table('user') 28 | .update({ 29 | role: Role.admin 30 | }) 31 | .where({ email }) 32 | } 33 | -------------------------------------------------------------------------------- /test/integration/global-hooks.test.ts: -------------------------------------------------------------------------------- 1 | import { database } from './database-utils' 2 | import { appServer } from './server-utils' 3 | 4 | before(async function() { 5 | this.timeout(5000) 6 | console.info('Initializing database migration.') 7 | await database.schemaMigration() 8 | }) 9 | 10 | after(async () => { 11 | const shutdowns = [appServer.closeServer(), database.closeDatabase()] 12 | 13 | console.info('Start cleaning test resources.') 14 | 15 | for (const shutdown of shutdowns) { 16 | try { 17 | await shutdown 18 | } catch (e) { 19 | console.error('Error in graceful shutdown ', e) 20 | } 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /test/integration/server-utils.ts: -------------------------------------------------------------------------------- 1 | import * as pino from 'pino' 2 | import * as supertest from 'supertest' 3 | import { createContainer } from '../../src/container' 4 | import { createServer } from '../../src/server' 5 | import { CreateTask, TaskModel } from '../../src/server/tasks/model' 6 | import { CreateUser, UserModel } from '../../src/server/users/model' 7 | import { database } from './database-utils' 8 | 9 | const logger = pino({ name: 'test', level: 'silent' }) 10 | const container = createContainer(database, logger) 11 | const port = Number(process.env.PORT) || 8080 12 | 13 | export const appServer = createServer(container) 14 | export const testServer = appServer.listen(port) 15 | 16 | export async function createUserTest(user: CreateUser): Promise { 17 | const res = await supertest(testServer) 18 | .post('/api/v1/users') 19 | .send(user) 20 | .expect(201) 21 | 22 | return res.body 23 | } 24 | 25 | export function shuttingDown(): void { 26 | container.health.shuttingDown() 27 | } 28 | 29 | export async function createTaskTest( 30 | task: CreateTask, 31 | token: string 32 | ): Promise { 33 | const res = await supertest(testServer) 34 | .post('/api/v1/tasks') 35 | .set('Authorization', token) 36 | .send(task) 37 | .expect(201) 38 | 39 | return res.body 40 | } 41 | 42 | export async function getLoginToken( 43 | email: string, 44 | password: string 45 | ): Promise { 46 | const res = await supertest(testServer) 47 | .post('/api/v1/users/login') 48 | .send({ email, password }) 49 | .expect(200) 50 | 51 | return res.body.accessToken 52 | } 53 | -------------------------------------------------------------------------------- /test/integration/server/health/health-check.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as supertest from 'supertest' 3 | import { shuttingDown, testServer } from '../../server-utils' 4 | 5 | describe('GET /health', () => { 6 | it('Should return 200 when server is running healthy', async () => { 7 | const res = await supertest(testServer) 8 | .get('/health') 9 | .expect(200) 10 | 11 | expect(res.body.isShuttingDown).equals(false) 12 | }) 13 | 14 | it('Should return 503 when server is shutting down', async () => { 15 | shuttingDown() 16 | 17 | const res = await supertest(testServer) 18 | .get('/health') 19 | .expect(503) 20 | 21 | expect(res.body.isShuttingDown).equals(true) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /test/integration/server/tasks/create-task.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as supertest from 'supertest' 3 | import { truncateTables } from '../../database-utils' 4 | import { createUserTest, getLoginToken, testServer } from '../../server-utils' 5 | 6 | describe('POST /api/v1/tasks', () => { 7 | let token: string 8 | 9 | before(async () => { 10 | await truncateTables(['task', 'user']) 11 | 12 | const user = { 13 | email: 'dude@gmail.com', 14 | firstName: 'super', 15 | lastName: 'mocha', 16 | password: 'secret' 17 | } 18 | 19 | await createUserTest(user) 20 | token = await getLoginToken('dude@gmail.com', 'secret') 21 | }) 22 | 23 | it('Should create a task and return 201', async () => { 24 | const task = { 25 | name: 'Do homework', 26 | description: 'Exercise 1 and 2' 27 | } 28 | 29 | const res = await supertest(testServer) 30 | .post('/api/v1/tasks') 31 | .set('Authorization', token) 32 | .send(task) 33 | .expect(201) 34 | 35 | expect(res.header.location).equals(`/api/v1/tasks/${res.body.id}`) 36 | expect(res.body).include({ 37 | name: 'Do homework', 38 | description: 'Exercise 1 and 2', 39 | done: false 40 | }) 41 | }) 42 | 43 | it('Should return 400 when missing body data', async () => { 44 | const task = { 45 | name: 'Do something' 46 | } 47 | 48 | const res = await supertest(testServer) 49 | .post('/api/v1/tasks') 50 | .set('Authorization', token) 51 | .send(task) 52 | .expect(400) 53 | 54 | expect(res.body.code).equals(30001) 55 | expect(res.body.fields.length).equals(1) 56 | expect(res.body.fields[0].message).eql('"description" is required') 57 | }) 58 | 59 | it('Should return unauthorized when token is not valid', async () => { 60 | const res = await supertest(testServer) 61 | .post('/api/v1/tasks') 62 | .set('Authorization', 'wrong token') 63 | .expect(401) 64 | 65 | expect(res.body.code).equals(30002) 66 | }) 67 | 68 | it('Should return unauthorized when token is missing', async () => { 69 | const res = await supertest(testServer) 70 | .post('/api/v1/tasks') 71 | .expect(401) 72 | 73 | expect(res.body.code).equals(30002) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /test/integration/server/tasks/delete-task.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as supertest from 'supertest' 3 | import { truncateTables } from '../../database-utils' 4 | import { 5 | createTaskTest, 6 | createUserTest, 7 | getLoginToken, 8 | testServer 9 | } from '../../server-utils' 10 | 11 | describe('DELETE /api/v1/tasks/:id', () => { 12 | let token: string 13 | 14 | before(async () => { 15 | await truncateTables(['task', 'user']) 16 | 17 | const user = { 18 | email: 'dude@gmail.com', 19 | firstName: 'super', 20 | lastName: 'mocha', 21 | password: 'secret' 22 | } 23 | 24 | await createUserTest(user) 25 | token = await getLoginToken('dude@gmail.com', 'secret') 26 | }) 27 | 28 | it('Should delete a task and return 204', async () => { 29 | const task = { 30 | name: 'Do Something', 31 | description: 'Some random description' 32 | } 33 | 34 | const createdTask = await createTaskTest(task, token) 35 | 36 | await supertest(testServer) 37 | .delete(`/api/v1/tasks/${createdTask.id}`) 38 | .set('Authorization', token) 39 | .expect(204) 40 | 41 | await supertest(testServer) 42 | .get(`/api/v1/tasks/${createdTask.id}`) 43 | .set('Authorization', token) 44 | .expect(404) 45 | }) 46 | 47 | it('Should return 404 when task does not exist', async () => { 48 | await supertest(testServer) 49 | .delete(`/api/v1/tasks/1000000`) 50 | .set('Authorization', token) 51 | .expect(404) 52 | }) 53 | 54 | it('Should return unauthorized when token is not valid', async () => { 55 | const res = await supertest(testServer) 56 | .delete(`/api/v1/tasks/1000000`) 57 | .set('Authorization', 'wrong token') 58 | .expect(401) 59 | 60 | expect(res.body.code).equals(30002) 61 | }) 62 | 63 | it('Should return unauthorized when token is missing', async () => { 64 | const res = await supertest(testServer) 65 | .delete(`/api/v1/tasks/1000000`) 66 | .expect(401) 67 | 68 | expect(res.body.code).equals(30002) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /test/integration/server/tasks/get-all-tasks.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as supertest from 'supertest' 3 | import { truncateTables } from '../../database-utils' 4 | import { 5 | createTaskTest, 6 | createUserTest, 7 | getLoginToken, 8 | testServer 9 | } from '../../server-utils' 10 | 11 | describe('GET /api/v1/tasks', () => { 12 | let token: string 13 | 14 | before(async () => { 15 | await truncateTables(['task', 'user']) 16 | 17 | const user = { 18 | email: 'dude@gmail.com', 19 | firstName: 'super', 20 | lastName: 'mocha', 21 | password: 'secret' 22 | } 23 | 24 | await createUserTest(user) 25 | token = await getLoginToken('dude@gmail.com', 'secret') 26 | }) 27 | 28 | it('Should return a list of tasks', async () => { 29 | const task1 = { 30 | name: 'Clean Room', 31 | description: 'Mom said that I need to clean my room.' 32 | } 33 | 34 | const task2 = { 35 | name: 'Do Homework', 36 | description: 'Math homework.' 37 | } 38 | 39 | await createTaskTest(task1, token) 40 | await createTaskTest(task2, token) 41 | 42 | const res = await supertest(testServer) 43 | .get('/api/v1/tasks') 44 | .set('Authorization', token) 45 | .expect(200) 46 | 47 | expect(res.body.length).equals(2) 48 | expect(res.body[0].name).equals('Clean Room') 49 | expect(res.body[1].name).equals('Do Homework') 50 | }) 51 | 52 | it('Should return unauthorized when token is not valid', async () => { 53 | const res = await supertest(testServer) 54 | .get(`/api/v1/tasks`) 55 | .set('Authorization', 'wrong token') 56 | .expect(401) 57 | 58 | expect(res.body.code).equals(30002) 59 | }) 60 | 61 | it('Should return unauthorized when token is missing', async () => { 62 | const res = await supertest(testServer) 63 | .get(`/api/v1/tasks`) 64 | .expect(401) 65 | 66 | expect(res.body.code).equals(30002) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /test/integration/server/tasks/get-task.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as supertest from 'supertest' 3 | import { truncateTables } from '../../database-utils' 4 | import { 5 | createTaskTest, 6 | createUserTest, 7 | getLoginToken, 8 | testServer 9 | } from '../../server-utils' 10 | 11 | describe('GET /api/v1/tasks/:id', () => { 12 | let token: string 13 | 14 | before(async () => { 15 | await truncateTables(['task', 'user']) 16 | 17 | const user = { 18 | email: 'dude@gmail.com', 19 | firstName: 'super', 20 | lastName: 'mocha', 21 | password: 'secret' 22 | } 23 | 24 | await createUserTest(user) 25 | token = await getLoginToken('dude@gmail.com', 'secret') 26 | }) 27 | 28 | it('Should return a single task', async () => { 29 | const task = { 30 | name: 'Clean Room', 31 | description: 'Mom said that I need to clean my room.' 32 | } 33 | 34 | const createdTask = await createTaskTest(task, token) 35 | 36 | const res = await supertest(testServer) 37 | .get(`/api/v1/tasks/${createdTask.id}`) 38 | .set('Authorization', token) 39 | .expect(200) 40 | 41 | expect(res.body).includes({ 42 | name: 'Clean Room', 43 | description: 'Mom said that I need to clean my room.', 44 | done: false 45 | }) 46 | }) 47 | 48 | it('Should return 404 when task does not exist', async () => { 49 | await supertest(testServer) 50 | .get(`/api/v1/tasks/111111111`) 51 | .set('Authorization', token) 52 | .expect(404) 53 | }) 54 | 55 | it('Should return unauthorized when token is not valid', async () => { 56 | const res = await supertest(testServer) 57 | .get('/api/v1/tasks/1') 58 | .set('Authorization', 'wrong token') 59 | .expect(401) 60 | 61 | expect(res.body.code).equals(30002) 62 | }) 63 | 64 | it('Should return unauthorized when token is missing', async () => { 65 | const res = await supertest(testServer) 66 | .get('/api/v1/tasks/1') 67 | .expect(401) 68 | 69 | expect(res.body.code).equals(30002) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /test/integration/server/tasks/update-task.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as supertest from 'supertest' 3 | import { truncateTables } from '../../database-utils' 4 | import { 5 | createTaskTest, 6 | createUserTest, 7 | getLoginToken, 8 | testServer 9 | } from '../../server-utils' 10 | 11 | describe('PUT /api/v1/tasks/:id', () => { 12 | let token: string 13 | 14 | before(async () => { 15 | await truncateTables(['task', 'user']) 16 | 17 | const user = { 18 | email: 'dude@gmail.com', 19 | firstName: 'super', 20 | lastName: 'mocha', 21 | password: 'secret' 22 | } 23 | 24 | await createUserTest(user) 25 | token = await getLoginToken('dude@gmail.com', 'secret') 26 | }) 27 | 28 | beforeEach(async () => { 29 | await truncateTables(['task']) 30 | }) 31 | 32 | it('Should update a task', async () => { 33 | const task = await createTaskTest( 34 | { name: 'Do homework', description: 'Exercise 1 and 2' }, 35 | token 36 | ) 37 | 38 | const res = await supertest(testServer) 39 | .put(`/api/v1/tasks/${task.id}`) 40 | .set('Authorization', token) 41 | .send({ name: 'Do TPC', description: 'Some job', done: true }) 42 | .expect(200) 43 | 44 | expect(res.body).include({ 45 | name: 'Do TPC', 46 | description: 'Some job', 47 | done: true 48 | }) 49 | }) 50 | 51 | it('Should return 400 when missing body data', async () => { 52 | const task = await createTaskTest( 53 | { name: 'Do homework', description: 'Exercise 1 and 2' }, 54 | token 55 | ) 56 | 57 | const res = await supertest(testServer) 58 | .put(`/api/v1/tasks/${task.id}`) 59 | .set('Authorization', token) 60 | .send({ name: 'Do TPC', description: 'Some job' }) 61 | .expect(400) 62 | 63 | expect(res.body.code).equals(30001) 64 | expect(res.body.fields.length).equals(1) 65 | expect(res.body.fields[0].message).eql('"done" is required') 66 | }) 67 | 68 | it('Should return unauthorized when token is not valid', async () => { 69 | const res = await supertest(testServer) 70 | .put('/api/v1/tasks/1') 71 | .set('Authorization', 'wrong token') 72 | .expect(401) 73 | 74 | expect(res.body.code).equals(30002) 75 | }) 76 | 77 | it('Should return unauthorized when token is missing', async () => { 78 | const res = await supertest(testServer) 79 | .put('/api/v1/tasks/1') 80 | .expect(401) 81 | 82 | expect(res.body.code).equals(30002) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /test/integration/server/users/change-password.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as supertest from 'supertest' 3 | import { truncateTables } from '../../database-utils' 4 | import { createUserTest, getLoginToken, testServer } from '../../server-utils' 5 | 6 | describe('PUT /api/v1/users/password', () => { 7 | let token: string 8 | 9 | beforeEach(async () => { 10 | await truncateTables(['user']) 11 | 12 | const user = { 13 | email: 'dude@gmail.com', 14 | firstName: 'super', 15 | lastName: 'mocha', 16 | password: 'secret' 17 | } 18 | 19 | await createUserTest(user) 20 | token = await getLoginToken('dude@gmail.com', 'secret') 21 | }) 22 | 23 | it('Should update user password and login successfully', async () => { 24 | let res = await supertest(testServer) 25 | .put('/api/v1/users/password') 26 | .set('Authorization', token) 27 | .send({ newPassword: 'newPassord', oldPassword: 'secret' }) 28 | .expect(204) 29 | 30 | res = await supertest(testServer) 31 | .post('/api/v1/users/login') 32 | .send({ email: 'dude@gmail.com', password: 'newPassord' }) 33 | .expect(200) 34 | 35 | expect(res.body).keys(['accessToken']) 36 | }) 37 | 38 | it('Should update user password but fail on login', async () => { 39 | let res = await supertest(testServer) 40 | .put('/api/v1/users/password') 41 | .set('Authorization', token) 42 | .send({ newPassword: 'newPassord', oldPassword: 'secret' }) 43 | .expect(204) 44 | 45 | res = await supertest(testServer) 46 | .post('/api/v1/users/login') 47 | .send({ email: 'dude@gmail.com', password: 'secret' }) 48 | .expect(400) 49 | 50 | expect(res.body.code).equals(30000) 51 | }) 52 | 53 | it('Should return 400 when missing body data', async () => { 54 | const res = await supertest(testServer) 55 | .put('/api/v1/users/password') 56 | .set('Authorization', token) 57 | .send({ newPassword: 'newPassord' }) 58 | .expect(400) 59 | 60 | expect(res.body.code).equals(30001) 61 | expect(res.body.fields.length).equals(1) 62 | expect(res.body.fields[0].message).eql('"oldPassword" is required') 63 | }) 64 | 65 | it('Should return unauthorized when token is not valid', async () => { 66 | const res = await supertest(testServer) 67 | .put('/api/v1/users/password') 68 | .set('Authorization', 'wrong token') 69 | .expect(401) 70 | 71 | expect(res.body.code).equals(30002) 72 | }) 73 | 74 | it('Should return unauthorized when token is missing', async () => { 75 | const res = await supertest(testServer) 76 | .put('/api/v1/users/password') 77 | .expect(401) 78 | 79 | expect(res.body.code).equals(30002) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /test/integration/server/users/create-user.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as supertest from 'supertest' 3 | import { CreateUser } from '../../../../src/server/users/model' 4 | import { truncateTables } from '../../database-utils' 5 | import { testServer } from '../../server-utils' 6 | 7 | describe('POST /api/v1/users', () => { 8 | beforeEach(async () => { 9 | await truncateTables(['user']) 10 | }) 11 | 12 | it('Should create a valid user and return 201', async () => { 13 | const user: CreateUser = { 14 | email: 'dummy@gmail.com', 15 | firstName: 'super', 16 | lastName: 'test', 17 | password: '123123123' 18 | } 19 | 20 | const res = await supertest(testServer) 21 | .post('/api/v1/users') 22 | .send(user) 23 | .expect(201) 24 | 25 | expect(res.header.location).equals('/api/v1/users/me') 26 | expect(res.body).includes({ 27 | email: 'dummy@gmail.com', 28 | firstName: 'super', 29 | lastName: 'test' 30 | }) 31 | }) 32 | 33 | it('Should return 400 when duplicated email', async () => { 34 | const user: CreateUser = { 35 | email: 'dummy@gmail.com', 36 | firstName: 'super', 37 | lastName: 'test', 38 | password: '123123123' 39 | } 40 | 41 | let res = await supertest(testServer) 42 | .post('/api/v1/users') 43 | .send(user) 44 | .expect(201) 45 | 46 | res = await supertest(testServer) 47 | .post('/api/v1/users') 48 | .send(user) 49 | .expect(400) 50 | 51 | expect(res.body).eql({ 52 | code: 30000, 53 | message: 'Email dummy@gmail.com already exists' 54 | }) 55 | }) 56 | 57 | it('Should return 400 when missing fields', async () => { 58 | const user = { 59 | email: 'dummy1@gmail.com', 60 | firstName: 'super', 61 | lastName: 'test' 62 | } 63 | 64 | const res = await supertest(testServer) 65 | .post('/api/v1/users') 66 | .send(user) 67 | .expect(400) 68 | 69 | expect(res.body.code).equals(30001) 70 | expect(res.body.fields.length).equals(1) 71 | expect(res.body.fields[0].message).eql('"password" is required') 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /test/integration/server/users/delete-user.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as supertest from 'supertest' 3 | import { database, setAdminMode, truncateTables } from '../../database-utils' 4 | import { 5 | createTaskTest, 6 | createUserTest, 7 | getLoginToken, 8 | testServer 9 | } from '../../server-utils' 10 | 11 | describe('DELETE /api/v1/users/:id', () => { 12 | beforeEach(async () => { 13 | await truncateTables(['task', 'user']) 14 | }) 15 | 16 | it('Should delete a user', async () => { 17 | await createUserTest({ 18 | email: 'god@gmail.com', 19 | firstName: 'Jesus', 20 | lastName: 'Christ', 21 | password: 'godmode' 22 | }) 23 | 24 | await setAdminMode('god@gmail.com') 25 | const adminToken = await getLoginToken('god@gmail.com', 'godmode') 26 | 27 | const user = await createUserTest({ 28 | email: 'user@gmail.com', 29 | firstName: 'super', 30 | lastName: 'test', 31 | password: 'test' 32 | }) 33 | 34 | const userToken = await getLoginToken('user@gmail.com', 'test') 35 | await createTaskTest( 36 | { 37 | name: 'Do Something', 38 | description: 'Some random description' 39 | }, 40 | userToken 41 | ) 42 | 43 | await createTaskTest( 44 | { 45 | name: 'Do Something', 46 | description: 'Some random description' 47 | }, 48 | userToken 49 | ) 50 | 51 | await supertest(testServer) 52 | .delete(`/api/v1/users/${user.id}`) 53 | .set('Authorization', adminToken) 54 | .expect(204) 55 | 56 | const conn = await database.getConnection() 57 | 58 | const users = await conn.from('user').select() 59 | 60 | expect(users.length).eql(1) 61 | expect(users[0].email).eql('god@gmail.com') 62 | 63 | const tasks = await conn.from('task').count() 64 | 65 | expect(tasks[0]['count(*)']).eql(0) 66 | }) 67 | 68 | it('Should return not allowed error', async () => { 69 | await createUserTest({ 70 | email: 'god@gmail.com', 71 | firstName: 'Jesus', 72 | lastName: 'Christ', 73 | password: 'godmode' 74 | }) 75 | 76 | const user = await createUserTest({ 77 | email: 'dude@gmail.com', 78 | firstName: 'super', 79 | lastName: 'test', 80 | password: 'test' 81 | }) 82 | 83 | const token = await getLoginToken('god@gmail.com', 'godmode') 84 | 85 | await supertest(testServer) 86 | .delete(`/api/v1/users/${user.id}`) 87 | .set('Authorization', token) 88 | .expect(403) 89 | }) 90 | 91 | it('Should return unauthorized when token is not valid', async () => { 92 | const res = await supertest(testServer) 93 | .delete('/api/v1/users/${user.id}') 94 | .set('Authorization', 'wrong token') 95 | .expect(401) 96 | 97 | expect(res.body.code).equals(30002) 98 | }) 99 | 100 | it('Should return unauthorized when token is missing', async () => { 101 | const res = await supertest(testServer) 102 | .delete('/api/v1/users/1') 103 | .expect(401) 104 | 105 | expect(res.body.code).equals(30002) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /test/integration/server/users/login.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as supertest from 'supertest' 3 | import { truncateTables } from '../../database-utils' 4 | import { createUserTest, testServer } from '../../server-utils' 5 | 6 | describe('POST /api/v1/users/login', () => { 7 | beforeEach(async () => { 8 | await truncateTables(['user']) 9 | 10 | const user = { 11 | email: 'dude@gmail.com', 12 | firstName: 'super', 13 | lastName: 'test', 14 | password: 'test' 15 | } 16 | 17 | await createUserTest(user) 18 | }) 19 | 20 | it('Should return a valid token', async () => { 21 | const res = await supertest(testServer) 22 | .post('/api/v1/users/login') 23 | .send({ email: 'dude@gmail.com', password: 'test' }) 24 | .expect(200) 25 | 26 | expect(res.body).keys(['accessToken']) 27 | }) 28 | 29 | it('Should return 400 when missing password', async () => { 30 | const res = await supertest(testServer) 31 | .post('/api/v1/users/login') 32 | .send({ email: 'dude@mail.com' }) 33 | .expect(400) 34 | 35 | expect(res.body.code).equals(30001) 36 | expect(res.body.fields.length).equals(1) 37 | expect(res.body.fields[0].message).eql('"password" is required') 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /test/integration/server/users/update-user.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as supertest from 'supertest' 3 | import { truncateTables } from '../../database-utils' 4 | import { createUserTest, getLoginToken, testServer } from '../../server-utils' 5 | 6 | describe('PUT /api/v1/users', () => { 7 | let token: string 8 | 9 | beforeEach(async () => { 10 | await truncateTables(['user']) 11 | 12 | const user = { 13 | email: 'dude@gmail.com', 14 | firstName: 'super', 15 | lastName: 'mocha', 16 | password: 'test' 17 | } 18 | 19 | await createUserTest(user) 20 | 21 | token = await getLoginToken('dude@gmail.com', 'test') 22 | }) 23 | 24 | it('Should update first and last name', async () => { 25 | const res = await supertest(testServer) 26 | .put('/api/v1/users') 27 | .set('Authorization', token) 28 | .send({ firstName: 'dude', lastName: 'test' }) 29 | .expect(200) 30 | 31 | expect(res.body).include({ 32 | firstName: 'dude', 33 | lastName: 'test' 34 | }) 35 | }) 36 | 37 | it('Should return 400 when missing lastName data', async () => { 38 | const res = await supertest(testServer) 39 | .put('/api/v1/users') 40 | .set('Authorization', token) 41 | .send({ firstName: 'dude' }) 42 | .expect(400) 43 | 44 | expect(res.body.code).equals(30001) 45 | expect(res.body.fields.length).equals(1) 46 | expect(res.body.fields[0].message).eql('"lastName" is required') 47 | }) 48 | 49 | it('Should return unauthorized when token is not valid', async () => { 50 | const res = await supertest(testServer) 51 | .put('/api/v1/users') 52 | .set('Authorization', 'wrong token') 53 | .expect(401) 54 | 55 | expect(res.body.code).equals(30002) 56 | }) 57 | 58 | it('Should return unauthorized when token is missing', async () => { 59 | const res = await supertest(testServer) 60 | .put('/api/v1/users') 61 | .expect(401) 62 | 63 | expect(res.body.code).equals(30002) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /test/integration/server/users/user-me.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as supertest from 'supertest' 3 | import { truncateTables } from '../../database-utils' 4 | import { createUserTest, getLoginToken, testServer } from '../../server-utils' 5 | 6 | describe('GET /api/v1/users/me', () => { 7 | beforeEach(async () => { 8 | await truncateTables(['user']) 9 | 10 | const user = { 11 | email: 'dude@gmail.com', 12 | firstName: 'super', 13 | lastName: 'test', 14 | password: 'test' 15 | } 16 | 17 | await createUserTest(user) 18 | }) 19 | 20 | it('Should return user information', async () => { 21 | const token = await getLoginToken('dude@gmail.com', 'test') 22 | const res = await supertest(testServer) 23 | .get('/api/v1/users/me') 24 | .set('Authorization', token) 25 | .expect(200) 26 | 27 | expect(res.body).keys([ 28 | 'id', 29 | 'email', 30 | 'firstName', 31 | 'lastName', 32 | 'created', 33 | 'updated' 34 | ]) 35 | }) 36 | 37 | it('Should return unauthorized when token is not valid', async () => { 38 | const res = await supertest(testServer) 39 | .get('/api/v1/users/me') 40 | .set('Authorization', 'wrong token') 41 | .expect(401) 42 | 43 | expect(res.body.code).equals(30002) 44 | }) 45 | 46 | it('Should return unauthorized when token is missing', async () => { 47 | const res = await supertest(testServer) 48 | .get('/api/v1/users/me') 49 | .expect(401) 50 | 51 | expect(res.body.code).equals(30002) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /test/unit/lib/hasher.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { BCryptHasher } from '../../../src/lib/hasher' 3 | 4 | describe('BCryptHasher', () => { 5 | it('Should return validate password', async () => { 6 | const hasher = new BCryptHasher() 7 | const hashedPassword = await hasher.hashPassword('password') 8 | 9 | const verify = await hasher.verifyPassword('password', hashedPassword) 10 | 11 | expect(verify).equals(true) 12 | }) 13 | 14 | it('Should return false when password is not valid', async () => { 15 | const hasher = new BCryptHasher() 16 | const hashedPassword = await hasher.hashPassword('password') 17 | 18 | const verify = await hasher.verifyPassword('password123', hashedPassword) 19 | 20 | expect(verify).equals(false) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/unit/lib/health.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { HealthMonitor } from '../../../src/lib/health' 3 | 4 | describe('HealthMonitor', () => { 5 | describe('getStatus', () => { 6 | it('Should return isShuttingDown true', async () => { 7 | const health = new HealthMonitor() 8 | let status = health.getStatus() 9 | 10 | expect(status.isShuttingDown).equals(false) 11 | 12 | health.shuttingDown() 13 | 14 | status = health.getStatus() 15 | 16 | expect(status.isShuttingDown).equals(true) 17 | }) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /test/unit/server/middlewares/authentication.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as sinon from 'sinon' 3 | import { UnauthorizedError } from '../../../../src/errors' 4 | import { Role } from '../../../../src/lib/authentication' 5 | import { authentication } from '../../../../src/server/middlewares' 6 | 7 | describe('authentication', () => { 8 | const sandbox = sinon.createSandbox() 9 | 10 | afterEach(() => { 11 | sandbox.restore() 12 | }) 13 | 14 | it('Should set context with the user data', async () => { 15 | const ctx: any = { 16 | headers: { 17 | authorization: 'jwt token' 18 | }, 19 | state: {} 20 | } 21 | 22 | const fakeAuthenticator: any = { 23 | validate: sandbox.stub().returns({ 24 | id: 1, 25 | email: 'me@mail.com', 26 | role: Role.admin 27 | }) 28 | } 29 | 30 | const spy = sandbox.spy() 31 | const authenticationMiddleware = authentication(fakeAuthenticator) 32 | 33 | await authenticationMiddleware(ctx, spy) 34 | 35 | expect(fakeAuthenticator.validate.calledOnce).equals(true) 36 | expect(ctx.state.user).eql({ 37 | id: 1, 38 | email: 'me@mail.com', 39 | role: Role.admin 40 | }) 41 | expect(spy.calledOnce).eql(true) 42 | }) 43 | 44 | it('Should throw UnauthorizedError', async () => { 45 | const ctx: any = { 46 | headers: { 47 | authorization: 'jwt token' 48 | }, 49 | state: {} 50 | } 51 | 52 | const fakeAuthenticator: any = { 53 | validate: sandbox.stub().throws(new UnauthorizedError()) 54 | } 55 | 56 | const spy = sandbox.spy() 57 | const authenticationMiddleware = authentication(fakeAuthenticator) 58 | 59 | try { 60 | await authenticationMiddleware(ctx, spy) 61 | } catch (error) { 62 | expect(error).instanceof(UnauthorizedError) 63 | } 64 | 65 | expect(fakeAuthenticator.validate.calledOnce).equals(true) 66 | expect(spy.calledOnce).eql(false) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /test/unit/server/middlewares/authorization.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as sinon from 'sinon' 3 | import { PermissionError } from '../../../../src/errors' 4 | import { Role } from '../../../../src/lib/authentication' 5 | import { authorization } from '../../../../src/server/middlewares' 6 | 7 | describe('authorization', () => { 8 | const sandbox = sinon.createSandbox() 9 | 10 | afterEach(() => { 11 | sandbox.restore() 12 | }) 13 | 14 | it('Should pass when user contains permission access', async () => { 15 | const ctx: any = { 16 | state: { 17 | user: { 18 | role: Role.user 19 | } 20 | } 21 | } 22 | 23 | const authorizationMiddleware = authorization([Role.user, Role.admin]) 24 | const spy = sandbox.spy() 25 | 26 | await authorizationMiddleware(ctx, spy) 27 | 28 | expect(spy.calledOnce).equals(true) 29 | }) 30 | 31 | it('Should throw PermissionError when user is not allowed', async () => { 32 | const ctx: any = { 33 | state: { 34 | user: { 35 | role: Role.user 36 | } 37 | } 38 | } 39 | 40 | const authorizationMiddleware = authorization([Role.admin]) 41 | const spy = sandbox.spy() 42 | 43 | try { 44 | await authorizationMiddleware(ctx, spy) 45 | 46 | expect.fail('Should throw an exception') 47 | } catch (error) { 48 | expect(error).instanceof(PermissionError) 49 | } 50 | 51 | expect(spy.calledOnce).equals(false) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /test/unit/server/middlewares/error-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as pino from 'pino' 3 | import * as sinon from 'sinon' 4 | import { NotFoundError } from '../../../../src/errors' 5 | import { errorHandler } from '../../../../src/server/middlewares' 6 | 7 | describe('errorHandler', () => { 8 | const sandbox = sinon.createSandbox() 9 | 10 | afterEach(() => { 11 | sandbox.restore() 12 | }) 13 | 14 | it('Should create an Internal Error Server when error is a unknown error', async () => { 15 | const ctx: any = {} 16 | const logger = pino({ name: 'test', level: 'silent' }) 17 | const spy = sinon.spy(logger, 'error') 18 | const errorHandlerMiddleware = errorHandler(logger) 19 | 20 | await errorHandlerMiddleware(ctx, () => Promise.reject('Unknown error')) 21 | 22 | expect(spy.calledOnce).equals(true) 23 | expect(spy.args[0][1]).equals('Unknown error') 24 | expect(ctx.status).equals(500) 25 | expect(ctx.body).includes({ 26 | code: 10000, 27 | message: 'Internal Error Server' 28 | }) 29 | }) 30 | 31 | it('Should handle the error when is a AppError', async () => { 32 | const ctx: any = {} 33 | const logger = pino({ name: 'test', level: 'silent' }) 34 | const spy = sinon.spy(logger, 'error') 35 | const errorHandlerMiddleware = errorHandler(logger) 36 | 37 | await errorHandlerMiddleware(ctx, () => 38 | Promise.reject(new NotFoundError('Test Not Found')) 39 | ) 40 | 41 | expect(spy.calledOnce).equals(true) 42 | expect(spy.args[0][1]).instanceof(NotFoundError) 43 | expect(ctx.status).equals(404) 44 | expect(ctx.body).eql({ 45 | code: 20000, 46 | message: 'Test Not Found' 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /test/unit/server/middlewares/log-request.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as pino from 'pino' 3 | import * as sinon from 'sinon' 4 | import { logRequest } from '../../../../src/server/middlewares' 5 | 6 | describe('logRequest', () => { 7 | const sandbox = sinon.createSandbox() 8 | 9 | afterEach(() => { 10 | sandbox.restore() 11 | }) 12 | 13 | it('Should log info level when no errors', async () => { 14 | const ctx: any = {} 15 | const logger = pino({ name: 'test', level: 'silent' }) 16 | const spy = sinon.spy(logger, 'info') 17 | const logMiddleware = logRequest(logger) 18 | 19 | await logMiddleware(ctx, () => Promise.resolve()) 20 | 21 | expect(spy.calledOnce).equals(true) 22 | expect(spy.args[0].length).equals(2) 23 | }) 24 | 25 | it('Should log error level when status code is >= 400', async () => { 26 | const ctx: any = { status: 500 } 27 | const logger = pino({ name: 'test', level: 'silent' }) 28 | const spy = sinon.spy(logger, 'error') 29 | const logMiddleware = logRequest(logger) 30 | 31 | await logMiddleware(ctx, () => Promise.resolve()) 32 | 33 | expect(spy.calledOnce).equals(true) 34 | expect(spy.args[0].length).equals(3) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /test/unit/server/middlewares/response-time.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as sinon from 'sinon' 3 | import { responseTime } from '../../../../src/server/middlewares' 4 | 5 | describe('responseTime', () => { 6 | const sandbox = sinon.createSandbox() 7 | 8 | afterEach(() => { 9 | sandbox.restore() 10 | }) 11 | 12 | it('Should set header x-response-time', async () => { 13 | const ctx: any = { 14 | set: () => { 15 | return 16 | } 17 | } 18 | 19 | const spy = sinon.spy(ctx, 'set') 20 | 21 | await responseTime(ctx, () => Promise.resolve()) 22 | 23 | expect(spy.calledOnce).equals(true) 24 | expect(spy.args[0][0]).equals('X-Response-Time') 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /test/unit/server/middlewares/validator.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Joi from 'joi' 3 | import { FieldValidationError } from '../../../../src/errors' 4 | import { validate } from '../../../../src/server/middlewares' 5 | 6 | describe('validate', () => { 7 | it('Should not throw an error when body valid', async () => { 8 | const ctx: any = { 9 | request: { 10 | body: { name: 'test' } 11 | } 12 | } 13 | 14 | const schema = { request: { body: { name: Joi.string().required() } } } 15 | 16 | const validateMiddleware = validate(schema) 17 | 18 | await validateMiddleware(ctx, () => Promise.resolve()) 19 | }) 20 | 21 | it('Should throw an error when body is not valid', async () => { 22 | const ctx: any = { 23 | request: { 24 | body: {} 25 | } 26 | } 27 | 28 | const schema = { request: { body: { name: Joi.string().required() } } } 29 | const validateMiddleware = validate(schema) 30 | 31 | try { 32 | await validateMiddleware(ctx, () => Promise.resolve()) 33 | expect.fail('Should not reach this point') 34 | } catch (error) { 35 | expect(error).instanceof(FieldValidationError) 36 | expect(error.fields[0].message).equals('"name" is required') 37 | } 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "es2017", 5 | "module": "commonjs", 6 | "sourceMap": true, 7 | "noUnusedLocals": true, 8 | "noUnusedParameters": true, 9 | "typeRoots": [ 10 | "node_modules/@types" 11 | ] 12 | }, 13 | "include": [ 14 | "src/**/**.ts", 15 | "test/**/**.ts" 16 | ], 17 | "exclude": [ 18 | "node_modules" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-config-prettier" 6 | ], 7 | "jsRules": {}, 8 | "rulesDirectory": [ 9 | "tslint-plugin-prettier" 10 | ], 11 | "rules": { 12 | "prettier": [ 13 | true, 14 | { 15 | "semi": false, 16 | "singleQuote": true 17 | } 18 | ], 19 | "no-console": false, 20 | "interface-name": false, 21 | "object-literal-sort-keys": false, 22 | "max-classes-per-file": false 23 | } 24 | } 25 | --------------------------------------------------------------------------------