├── .DS_Store ├── .babelrc ├── .eslintrc ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .prettierrc ├── README.md ├── config ├── default.json ├── nacl.json └── test.json ├── package-lock.json ├── package.json ├── src ├── app.js ├── controllers │ ├── products.js │ └── users.js ├── database.js ├── middlewares │ └── auth.js ├── models │ ├── product.js │ └── user.js ├── routes │ ├── index.js │ ├── products.js │ └── users.js ├── server.js └── services │ └── auth.js └── test ├── integration ├── global.js ├── helpers.js ├── mocha.opts └── routes │ ├── products_spec.js │ └── users_spec.js └── unit ├── controllers ├── products_spec.js └── users_spec.js ├── helpers.js ├── middlewares └── auth_spec.js ├── mocha.opts └── services └── auth_spec.js /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waldemarnt/building-testable-apis-with-nodejs-code/5e801aefbea4c29d49a2a1831b419d6d12a0913d/.DS_Store -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": true 8 | } 9 | } 10 | ] 11 | ] 12 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true, 4 | "node": true, 5 | "es6": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:node/recommended" 10 | ], 11 | "rules": { 12 | "node/no-unsupported-features/es-syntax": [ 13 | "error", 14 | { 15 | "version": ">=8.0.0", 16 | "ignores": [ 17 | "modules" 18 | ] 19 | } 20 | ], 21 | "no-console": "off", 22 | "no-process-exit": "off" 23 | }, 24 | "parser": "babel-eslint", 25 | "globals": { 26 | "expect": true, 27 | "request": true, 28 | "setupApp": true, 29 | "supertest": true 30 | }, 31 | "parserOptions": { 32 | "sourceType": "module" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | services: 8 | mongodb: 9 | image: mongo:3.4.23 10 | ports: 11 | - 27017:27017 12 | 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [12.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v1 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - name: npm install, build, and test 26 | run: | 27 | npm ci 28 | npm test 29 | env: 30 | CI: true 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | *.pid.lock 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # nyc test coverage 19 | .nyc_output 20 | 21 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 22 | .grunt 23 | 24 | # node-waf configuration 25 | .lock-wscript 26 | 27 | # Compiled binary addons (http://nodejs.org/api/addons.html) 28 | build/Release 29 | 30 | # Dependency directories 31 | node_modules 32 | jspm_packages 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional eslint cache 38 | .eslintcache 39 | 40 | # Optional REPL history 41 | .node_repl_history 42 | 43 | # Output of 'npm pack' 44 | *.tgz 45 | 46 | # Yarn Integrity file 47 | .yarn-integrity 48 | 49 | # dist 50 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📖 Código oficial do livro Construindo APIs testáveis com Node.js 2 | ![Build](https://github.com/waldemarnt/building-testable-apis-with-nodejs-code/workflows/Node%20CI/badge.svg) 3 | 4 | 5 | Publicado gratuitamente no Leanpub: [Construindo APIs testáveis com Node.js](https://leanpub.com/construindo-apis-testaveis-com-nodejs/) 6 | 7 | Pull Requests e contribuições são bem vindas. 8 | 9 | Acesso ao livro escrito no [GitHub](https://github.com/waldemarnt/building-testable-apis-with-nodejs) 10 | 11 | ### 💥O Livro foi finalizado oficialmente em 2020 🚀🚀🚀 12 | 13 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "mongoUrl": "mongodb://localhost:27017/shop" 4 | }, 5 | "auth": { 6 | "key": "thisisaverysecurekey", 7 | "tokenExpiresIn": "7d" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /config/nacl.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "group": "admin", 4 | "permissions": [ 5 | { 6 | "resource": "*", 7 | "methods": "*", 8 | "action": "allow" 9 | } 10 | ] 11 | }, 12 | { 13 | "group": "user", 14 | "permissions": [ 15 | { 16 | "resource": "products", 17 | "methods": [ 18 | "GET" 19 | ], 20 | "action": "allow" 21 | } 22 | ] 23 | } 24 | ] -------------------------------------------------------------------------------- /config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "mongoUrl": "mongodb://localhost:27017/test" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-book", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir dist", 8 | "start": "npm run build && node dist/server.js", 9 | "start:dev": "babel-node src/server.js", 10 | "test:integration": "NODE_ENV=test mocha --opts test/integration/mocha.opts test/integration/**/*_spec.js", 11 | "test:unit": "NODE_ENV=test mocha --opts test/unit/mocha.opts test/unit/**/*_spec.js", 12 | "test": "npm run test:unit && npm run test:integration", 13 | "lint": "eslint src --ext .js", 14 | "lint:fix": "eslint src --fix --ext .js", 15 | "prettier:list": "prettier --check 'src/**/*.js'", 16 | "prettier:fix": "prettier --write 'src/**/*.js'", 17 | "style:fix": "npm run lint:fix & npm run prettier:fix" 18 | }, 19 | "author": "", 20 | "license": "ISC", 21 | "devDependencies": { 22 | "@babel/cli": "^7.7.4", 23 | "@babel/core": "^7.7.4", 24 | "@babel/node": "^7.7.4", 25 | "@babel/preset-env": "^7.7.4", 26 | "babel-eslint": "^10.0.3", 27 | "chai": "^4.2.0", 28 | "eslint": "^6.7.2", 29 | "eslint-plugin-node": "^10.0.0", 30 | "mocha": "^6.2.3", 31 | "prettier": "^1.19.1", 32 | "sinon": "^7.5.0", 33 | "supertest": "^4.0.2" 34 | }, 35 | "dependencies": { 36 | "bcrypt": "^5.0.0", 37 | "body-parser": "^1.15.2", 38 | "config": "^1.29.4", 39 | "express": "^4.14.0", 40 | "express-acl": "^2.0.2", 41 | "jsonwebtoken": "^8.3.0", 42 | "mongoose": "^5.7.13" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import bodyParser from 'body-parser'; 3 | import routes from './routes'; 4 | import database from './database'; 5 | import acl from 'express-acl'; 6 | import authMiddleware from './middlewares/auth'; 7 | 8 | const app = express(); 9 | 10 | acl.config({ 11 | baseUrl: '/', 12 | path: 'config' 13 | }); 14 | 15 | const configureExpress = () => { 16 | app.use(bodyParser.json()); 17 | app.use(authMiddleware); 18 | app.use(acl.authorize.unless({ path: ['/users/authenticate'] })); 19 | 20 | app.use('/', routes); 21 | app.database = database; 22 | 23 | return app; 24 | }; 25 | 26 | export default async () => { 27 | const app = configureExpress(); 28 | await app.database.connect(); 29 | 30 | return app; 31 | }; 32 | -------------------------------------------------------------------------------- /src/controllers/products.js: -------------------------------------------------------------------------------- 1 | class ProductsController { 2 | constructor(Product) { 3 | this.Product = Product; 4 | } 5 | 6 | async get(req, res) { 7 | try { 8 | const products = await this.Product.find({}); 9 | res.send(products); 10 | } catch (err) { 11 | res.status(400).send(err.message); 12 | } 13 | } 14 | 15 | async getById(req, res) { 16 | const { 17 | params: { id } 18 | } = req; 19 | 20 | try { 21 | const product = await this.Product.find({ _id: id }); 22 | res.send(product); 23 | } catch (err) { 24 | res.status(400).send(err.message); 25 | } 26 | } 27 | async create(req, res) { 28 | const product = new this.Product(req.body); 29 | try { 30 | await product.save(); 31 | res.status(201).send(product); 32 | } catch (err) { 33 | res.status(422).send(err.message); 34 | } 35 | } 36 | 37 | async update(req, res) { 38 | try { 39 | await this.Product.updateOne({ _id: req.params.id }, req.body); 40 | res.sendStatus(200); 41 | } catch (err) { 42 | res.status(422).send(err.message); 43 | } 44 | } 45 | 46 | async remove(req, res) { 47 | try { 48 | await this.Product.deleteOne({ _id: req.params.id }); 49 | res.sendStatus(204); 50 | } catch (err) { 51 | res.status(400).send(err.message); 52 | } 53 | } 54 | } 55 | 56 | export default ProductsController; 57 | -------------------------------------------------------------------------------- /src/controllers/users.js: -------------------------------------------------------------------------------- 1 | class UsersController { 2 | constructor(User, AuthService) { 3 | this.User = User; 4 | this.AuthService = AuthService; 5 | } 6 | 7 | async get(req, res) { 8 | try { 9 | const users = await this.User.find({}); 10 | res.send(users); 11 | } catch (err) { 12 | res.status(400).send(err.message); 13 | } 14 | } 15 | 16 | async getById(req, res) { 17 | const { 18 | params: { id } 19 | } = req; 20 | 21 | try { 22 | const user = await this.User.find({ _id: id }); 23 | res.send(user); 24 | } catch (err) { 25 | res.status(400).send(err.message); 26 | } 27 | } 28 | 29 | async create(req, res) { 30 | const user = new this.User(req.body); 31 | 32 | try { 33 | await user.save(); 34 | res.status(201).send(user); 35 | } catch (err) { 36 | res.status(422).send(err.message); 37 | } 38 | } 39 | 40 | async update(req, res) { 41 | const body = req.body; 42 | try { 43 | const user = await this.User.findById(req.params.id); 44 | 45 | user.name = body.name; 46 | user.email = body.email; 47 | user.role = body.role; 48 | if (body.password) { 49 | user.password = body.password; 50 | } 51 | await user.save(); 52 | 53 | res.sendStatus(200); 54 | } catch (err) { 55 | res.status(422).send(err.message); 56 | } 57 | } 58 | 59 | async remove(req, res) { 60 | try { 61 | await this.User.deleteOne({ _id: req.params.id }); 62 | res.sendStatus(204); 63 | } catch (err) { 64 | res.status(400).send(err.message); 65 | } 66 | } 67 | 68 | async authenticate(req, res) { 69 | const authService = new this.AuthService(this.User); 70 | const user = await authService.authenticate(req.body); 71 | if (!user) { 72 | return res.sendStatus(401); 73 | } 74 | const token = this.AuthService.generateToken({ 75 | name: user.name, 76 | email: user.email, 77 | password: user.password, 78 | role: user.role 79 | }); 80 | return res.send({ token }); 81 | } 82 | } 83 | 84 | export default UsersController; 85 | -------------------------------------------------------------------------------- /src/database.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import config from 'config'; 3 | 4 | const mongodbUrl = config.get('database.mongoUrl'); 5 | 6 | const connect = () => 7 | mongoose.connect(mongodbUrl, { 8 | useNewUrlParser: true, 9 | useUnifiedTopology: true 10 | }); 11 | 12 | export default { 13 | connect, 14 | connection: mongoose.connection 15 | }; 16 | -------------------------------------------------------------------------------- /src/middlewares/auth.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import config from 'config'; 3 | 4 | export default (req, res, next) => { 5 | const token = req.headers['x-access-token']; 6 | if (!token) { 7 | return next(); 8 | } 9 | jwt.verify(token, config.get('auth.key'), (err, decoded) => { 10 | req.decoded = decoded; 11 | next(err); 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /src/models/product.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const schema = new mongoose.Schema({ 4 | name: String, 5 | description: String, 6 | price: Number 7 | }); 8 | const Product = mongoose.model('Product', schema); 9 | 10 | export default Product; 11 | -------------------------------------------------------------------------------- /src/models/user.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import Util from 'util'; 3 | import bcrypt from 'bcrypt'; 4 | 5 | const hashAsync = Util.promisify(bcrypt.hash); 6 | const schema = new mongoose.Schema({ 7 | name: String, 8 | email: String, 9 | password: String, 10 | role: String 11 | }); 12 | 13 | schema.pre('save', async function(next) { 14 | if (!this.password || !this.isModified('password')) { 15 | return next(); 16 | } 17 | try { 18 | const hashedPassword = await hashAsync(this.password, 10); 19 | this.password = hashedPassword; 20 | } catch (err) { 21 | next(err); 22 | } 23 | }); 24 | 25 | schema.set('toJSON', { 26 | transform: (doc, ret) => ({ 27 | _id: ret._id, 28 | email: ret.email, 29 | name: ret.name, 30 | role: ret.role 31 | }) 32 | }); 33 | 34 | const User = mongoose.model('User', schema); 35 | 36 | export default User; 37 | -------------------------------------------------------------------------------- /src/routes/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import productsRoute from './products'; 3 | import usersRoute from './users'; 4 | 5 | const router = express.Router(); 6 | 7 | router.use('/products', productsRoute); 8 | router.use('/users', usersRoute); 9 | router.get('/', (req, res) => res.send('Hello World!')); 10 | 11 | export default router; 12 | -------------------------------------------------------------------------------- /src/routes/products.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import ProductsController from '../controllers/products'; 3 | import Product from '../models/product'; 4 | 5 | const router = express.Router(); 6 | const productsController = new ProductsController(Product); 7 | router.get('/', (req, res) => productsController.get(req, res)); 8 | router.get('/:id', (req, res) => productsController.getById(req, res)); 9 | router.post('/', (req, res) => productsController.create(req, res)); 10 | router.put('/:id', (req, res) => productsController.update(req, res)); 11 | router.delete('/:id', (req, res) => productsController.remove(req, res)); 12 | 13 | export default router; 14 | -------------------------------------------------------------------------------- /src/routes/users.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import UsersController from '../controllers/users'; 3 | import User from '../models/user'; 4 | import AuthService from '../services/auth'; 5 | 6 | const router = express.Router(); 7 | const usersController = new UsersController(User, AuthService); 8 | router.get('/', (req, res) => usersController.get(req, res)); 9 | router.get('/:id', (req, res) => usersController.getById(req, res)); 10 | router.post('/', (req, res) => usersController.create(req, res)); 11 | router.put('/:id', (req, res) => usersController.update(req, res)); 12 | router.delete('/:id', (req, res) => usersController.remove(req, res)); 13 | router.post('/authenticate', (req, res) => 14 | usersController.authenticate(req, res) 15 | ); 16 | 17 | export default router; 18 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import setupApp from './app'; 2 | const port = 3000; 3 | 4 | (async () => { 5 | try { 6 | const app = await setupApp(); 7 | const server = app.listen(port, () => 8 | console.info(`app running on port ${port}`) 9 | ); 10 | 11 | const exitSignals = ['SIGINT', 'SIGTERM', 'SIGQUIT']; 12 | exitSignals.map(sig => 13 | process.on(sig, () => 14 | server.close(err => { 15 | if (err) { 16 | console.error(err); 17 | process.exit(1); 18 | } 19 | app.database.connection.close(function() { 20 | console.info('Database connection closed!'); 21 | process.exit(0); 22 | }); 23 | }) 24 | ) 25 | ); 26 | } catch (error) { 27 | console.error(error); 28 | process.exit(1); 29 | } 30 | })(); 31 | -------------------------------------------------------------------------------- /src/services/auth.js: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import jwt from 'jsonwebtoken'; 3 | import config from 'config'; 4 | 5 | class Auth { 6 | constructor(User) { 7 | this.User = User; 8 | } 9 | 10 | async authenticate(data) { 11 | const user = await this.User.findOne({ email: data.email }); 12 | 13 | if (!user || !(await bcrypt.compare(data.password, user.password))) { 14 | return false; 15 | } 16 | 17 | return user; 18 | } 19 | 20 | static generateToken(payload) { 21 | return jwt.sign(payload, config.get('auth.key'), { 22 | expiresIn: config.get('auth.tokenExpiresIn') 23 | }); 24 | } 25 | } 26 | 27 | export default Auth; 28 | -------------------------------------------------------------------------------- /test/integration/global.js: -------------------------------------------------------------------------------- 1 | before(async () => { 2 | const app = await setupApp(); 3 | global.app = app; 4 | global.request = supertest(app); 5 | }); 6 | 7 | after(async () => await app.database.connection.close()); 8 | -------------------------------------------------------------------------------- /test/integration/helpers.js: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import chai from 'chai'; 3 | import setupApp from '../../src/app.js'; 4 | 5 | global.setupApp = setupApp; 6 | global.supertest = supertest; 7 | global.expect = chai.expect; 8 | -------------------------------------------------------------------------------- /test/integration/mocha.opts: -------------------------------------------------------------------------------- 1 | --require @babel/register 2 | --require test/integration/helpers.js test/integration/global.js 3 | --reporter spec 4 | --slow 5000 5 | --timeout 5000 -------------------------------------------------------------------------------- /test/integration/routes/products_spec.js: -------------------------------------------------------------------------------- 1 | import Product from '../../../src/models/product'; 2 | import AuthService from '../../../src/services/auth'; 3 | 4 | describe('Routes: Products', () => { 5 | const defaultId = '56cb91bdc3464f14678934ca'; 6 | const defaultProduct = { 7 | name: 'Default product', 8 | description: 'product description', 9 | price: 100 10 | }; 11 | const expectedProduct = { 12 | __v: 0, 13 | _id: defaultId, 14 | name: 'Default product', 15 | description: 'product description', 16 | price: 100 17 | }; 18 | const expectedAdminUser = { 19 | _id: defaultId, 20 | name: 'Jhon Doe', 21 | email: 'jhon@mail.com', 22 | role: 'admin' 23 | }; 24 | const authToken = AuthService.generateToken(expectedAdminUser); 25 | 26 | beforeEach(async () => { 27 | await Product.deleteMany(); 28 | 29 | const product = new Product(defaultProduct); 30 | product._id = '56cb91bdc3464f14678934ca'; 31 | return await product.save(); 32 | }); 33 | 34 | afterEach(async () => await Product.deleteMany()); 35 | 36 | describe('GET /products', () => { 37 | it('should return a list of products', done => { 38 | request 39 | .get('/products') 40 | .set({ 'x-access-token': authToken }) 41 | .end((err, res) => { 42 | expect(res.body).to.eql([expectedProduct]); 43 | done(err); 44 | }); 45 | }); 46 | 47 | context('when an id is specified', done => { 48 | it('should return 200 with one product', done => { 49 | request 50 | .get(`/products/${defaultId}`) 51 | .set({ 'x-access-token': authToken }) 52 | .end((err, res) => { 53 | expect(res.statusCode).to.eql(200); 54 | expect(res.body).to.eql([expectedProduct]); 55 | done(err); 56 | }); 57 | }); 58 | }); 59 | }); 60 | 61 | describe('POST /products', () => { 62 | context('when posting a product', () => { 63 | it('should return a new product with status code 201', done => { 64 | const customId = '56cb91bdc3464f14678934ba'; 65 | const newProduct = Object.assign( 66 | {}, 67 | { _id: customId, __v: 0 }, 68 | defaultProduct 69 | ); 70 | const expectedSavedProduct = { 71 | __v: 0, 72 | _id: customId, 73 | name: 'Default product', 74 | description: 'product description', 75 | price: 100 76 | }; 77 | 78 | request 79 | .post('/products') 80 | .set({ 'x-access-token': authToken }) 81 | .send(newProduct) 82 | .end((err, res) => { 83 | expect(res.statusCode).to.eql(201); 84 | expect(res.body).to.eql(expectedSavedProduct); 85 | done(err); 86 | }); 87 | }); 88 | }); 89 | }); 90 | 91 | describe('PUT /products/:id', () => { 92 | context('when editing a product', () => { 93 | it('should update the product and return 200 as status code', done => { 94 | const customProduct = { 95 | name: 'Custom name' 96 | }; 97 | const updatedProduct = Object.assign({}, customProduct, defaultProduct); 98 | 99 | request 100 | .put(`/products/${defaultId}`) 101 | .set({ 'x-access-token': authToken }) 102 | .send(updatedProduct) 103 | .end((err, res) => { 104 | expect(res.status).to.eql(200); 105 | done(err); 106 | }); 107 | }); 108 | }); 109 | }); 110 | 111 | describe('DELETE /products/:id', () => { 112 | context('when deleting a product', () => { 113 | it('should delete a product and return 204 as status code', done => { 114 | request 115 | .delete(`/products/${defaultId}`) 116 | .set({ 'x-access-token': authToken }) 117 | .end((err, res) => { 118 | expect(res.status).to.eql(204); 119 | done(err); 120 | }); 121 | }); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /test/integration/routes/users_spec.js: -------------------------------------------------------------------------------- 1 | import User from '../../../src/models/user'; 2 | import AuthService from '../../../src/services/auth'; 3 | 4 | describe('Routes: Users', () => { 5 | const defaultId = '56cb91bdc3464f14678934ca'; 6 | const defaultAdmin = { 7 | name: 'Jhon Doe', 8 | email: 'jhon@mail.com', 9 | password: '123password', 10 | role: 'admin' 11 | }; 12 | const expectedAdminUser = { 13 | _id: defaultId, 14 | name: 'Jhon Doe', 15 | email: 'jhon@mail.com', 16 | role: 'admin' 17 | }; 18 | const authToken = AuthService.generateToken(expectedAdminUser); 19 | 20 | beforeEach(async () => { 21 | const user = new User(defaultAdmin); 22 | user._id = '56cb91bdc3464f14678934ca'; 23 | await User.deleteMany({}); 24 | await user.save(); 25 | }); 26 | 27 | afterEach(async () => await User.deleteMany({})); 28 | 29 | describe('GET /users', () => { 30 | it('should return a list of users', done => { 31 | request 32 | .get('/users') 33 | .set({ 'x-access-token': authToken }) 34 | .end((err, res) => { 35 | expect(res.body).to.eql([expectedAdminUser]); 36 | done(err); 37 | }); 38 | }); 39 | 40 | context('when an id is specified', done => { 41 | it('should return 200 with one user', done => { 42 | request 43 | .get(`/users/${defaultId}`) 44 | .set({ 'x-access-token': authToken }) 45 | .end((err, res) => { 46 | expect(res.statusCode).to.eql(200); 47 | expect(res.body).to.eql([expectedAdminUser]); 48 | done(err); 49 | }); 50 | }); 51 | }); 52 | }); 53 | 54 | describe('POST /users', () => { 55 | context('when posting an user', () => { 56 | it('should return a new user with status code 201', done => { 57 | const customId = '56cb91bdc3464f14678934ba'; 58 | const newUser = Object.assign( 59 | {}, 60 | { _id: customId, __v: 0 }, 61 | defaultAdmin 62 | ); 63 | const expectedSavedUser = { 64 | _id: customId, 65 | name: 'Jhon Doe', 66 | email: 'jhon@mail.com', 67 | role: 'admin' 68 | }; 69 | 70 | request 71 | .post('/users') 72 | .set({ 'x-access-token': authToken }) 73 | .send(newUser) 74 | .end((err, res) => { 75 | expect(res.statusCode).to.eql(201); 76 | expect(res.body).to.eql(expectedSavedUser); 77 | done(err); 78 | }); 79 | }); 80 | }); 81 | }); 82 | 83 | describe('PUT /users/:id', () => { 84 | context('when editing an user', () => { 85 | it('should update the user and return 200 as status code', done => { 86 | const customUser = { 87 | name: 'Din Doe' 88 | }; 89 | const updatedUser = Object.assign({}, defaultAdmin, customUser); 90 | 91 | request 92 | .put(`/users/${defaultId}`) 93 | .set({ 'x-access-token': authToken }) 94 | .send(updatedUser) 95 | .end((err, res) => { 96 | expect(res.status).to.eql(200); 97 | done(err); 98 | }); 99 | }); 100 | }); 101 | }); 102 | 103 | describe('DELETE /users/:id', () => { 104 | context('when deleting an user', () => { 105 | it('should delete an user and return 204 as status code', done => { 106 | request 107 | .delete(`/users/${defaultId}`) 108 | .set({ 'x-access-token': authToken }) 109 | .end((err, res) => { 110 | expect(res.status).to.eql(204); 111 | done(err); 112 | }); 113 | }); 114 | }); 115 | }); 116 | 117 | context('when authenticating an user', () => { 118 | it('should generate a valid token', done => { 119 | request 120 | .post(`/users/authenticate`) 121 | .send({ 122 | email: 'jhon@mail.com', 123 | password: '123password' 124 | }) 125 | .end((err, res) => { 126 | expect(res.body).to.have.key('token'); 127 | expect(res.status).to.eql(200); 128 | done(err); 129 | }); 130 | }); 131 | 132 | it('should return unauthorized when the password does not match', done => { 133 | request 134 | .post(`/users/authenticate`) 135 | .send({ 136 | email: 'jhon@mail.com', 137 | password: 'wrongpassword' 138 | }) 139 | .end((err, res) => { 140 | expect(res.status).to.eql(401); 141 | done(err); 142 | }); 143 | }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /test/unit/controllers/products_spec.js: -------------------------------------------------------------------------------- 1 | import ProductsController from '../../../src/controllers/products'; 2 | import sinon from 'sinon'; 3 | import Product from '../../../src/models/product'; 4 | 5 | describe('Controller: Products', () => { 6 | const defaultProduct = [ 7 | { 8 | __v: 0, 9 | _id: '56cb91bdc3464f14678934ca', 10 | name: 'Default product', 11 | description: 'product description', 12 | price: 100 13 | } 14 | ]; 15 | 16 | const defaultRequest = { 17 | params: {} 18 | }; 19 | 20 | describe('get() products', () => { 21 | it('should return a list of products', async () => { 22 | const response = { 23 | send: sinon.spy() 24 | }; 25 | 26 | Product.find = sinon.stub(); 27 | Product.find.withArgs({}).resolves(defaultProduct); 28 | 29 | const productsController = new ProductsController(Product); 30 | 31 | await productsController.get(defaultRequest, response); 32 | 33 | sinon.assert.calledWith(response.send, defaultProduct); 34 | }); 35 | 36 | it('should return 400 when an error occurs', async () => { 37 | const request = {}; 38 | const response = { 39 | send: sinon.spy(), 40 | status: sinon.stub() 41 | }; 42 | 43 | response.status.withArgs(400).returns(response); 44 | Product.find = sinon.stub(); 45 | Product.find.withArgs({}).rejects({ message: 'Error' }); 46 | 47 | const productsController = new ProductsController(Product); 48 | 49 | await productsController.get(request, response); 50 | 51 | sinon.assert.calledWith(response.send, 'Error'); 52 | }); 53 | }); 54 | 55 | describe('getById()', () => { 56 | it('should return one product', async () => { 57 | const fakeId = 'a-fake-id'; 58 | const request = { 59 | params: { 60 | id: fakeId 61 | } 62 | }; 63 | const response = { 64 | send: sinon.spy() 65 | }; 66 | 67 | Product.find = sinon.stub(); 68 | Product.find.withArgs({ _id: fakeId }).resolves(defaultProduct); 69 | 70 | const productsController = new ProductsController(Product); 71 | await productsController.getById(request, response); 72 | 73 | sinon.assert.calledWith(response.send, defaultProduct); 74 | }); 75 | }); 76 | 77 | describe('create() product', () => { 78 | it('should save a new product successfully', async () => { 79 | const requestWithBody = Object.assign( 80 | {}, 81 | { body: defaultProduct[0] }, 82 | defaultRequest 83 | ); 84 | const response = { 85 | send: sinon.spy(), 86 | status: sinon.stub() 87 | }; 88 | class fakeProduct { 89 | save() {} 90 | } 91 | 92 | response.status.withArgs(201).returns(response); 93 | sinon 94 | .stub(fakeProduct.prototype, 'save') 95 | .withArgs() 96 | .resolves(); 97 | 98 | const productsController = new ProductsController(fakeProduct); 99 | 100 | await productsController.create(requestWithBody, response); 101 | sinon.assert.calledWith(response.send); 102 | }); 103 | 104 | context('when an error occurs', () => { 105 | it('should return 422', async () => { 106 | const response = { 107 | send: sinon.spy(), 108 | status: sinon.stub() 109 | }; 110 | 111 | class fakeProduct { 112 | save() {} 113 | } 114 | 115 | response.status.withArgs(422).returns(response); 116 | sinon 117 | .stub(fakeProduct.prototype, 'save') 118 | .withArgs() 119 | .rejects({ message: 'Error' }); 120 | 121 | const productsController = new ProductsController(fakeProduct); 122 | 123 | await productsController.create(defaultRequest, response); 124 | sinon.assert.calledWith(response.status, 422); 125 | }); 126 | }); 127 | }); 128 | 129 | describe('update() product', () => { 130 | it('should respond with 200 when the product has been updated', async () => { 131 | const fakeId = 'a-fake-id'; 132 | const updatedProduct = { 133 | _id: fakeId, 134 | name: 'Updated product', 135 | description: 'Updated description', 136 | price: 150 137 | }; 138 | const request = { 139 | params: { 140 | id: fakeId 141 | }, 142 | body: updatedProduct 143 | }; 144 | const response = { 145 | sendStatus: sinon.spy() 146 | }; 147 | 148 | class fakeProduct { 149 | static updateOne() {} 150 | } 151 | 152 | const updateOneStub = sinon.stub(fakeProduct, 'updateOne'); 153 | updateOneStub 154 | .withArgs({ _id: fakeId }, updatedProduct) 155 | .resolves(updatedProduct); 156 | 157 | const productsController = new ProductsController(fakeProduct); 158 | 159 | await productsController.update(request, response); 160 | sinon.assert.calledWith(response.sendStatus, 200); 161 | }); 162 | 163 | context('when an error occurs', () => { 164 | it('should return 422', async () => { 165 | const fakeId = 'a-fake-id'; 166 | const updatedProduct = { 167 | _id: fakeId, 168 | name: 'Updated product', 169 | description: 'Updated description', 170 | price: 150 171 | }; 172 | const request = { 173 | params: { 174 | id: fakeId 175 | }, 176 | body: updatedProduct 177 | }; 178 | const response = { 179 | send: sinon.spy(), 180 | status: sinon.stub() 181 | }; 182 | 183 | class fakeProduct { 184 | static updateOne() {} 185 | } 186 | 187 | const updateOneStub = sinon.stub(fakeProduct, 'updateOne'); 188 | updateOneStub 189 | .withArgs({ _id: fakeId }, updatedProduct) 190 | .rejects({ message: 'Error' }); 191 | response.status.withArgs(422).returns(response); 192 | 193 | const productsController = new ProductsController(fakeProduct); 194 | 195 | await productsController.update(request, response); 196 | sinon.assert.calledWith(response.send, 'Error'); 197 | }); 198 | }); 199 | }); 200 | 201 | describe('delete() product', () => { 202 | it('should respond with 204 when the product has been deleted', async () => { 203 | const fakeId = 'a-fake-id'; 204 | const request = { 205 | params: { 206 | id: fakeId 207 | } 208 | }; 209 | const response = { 210 | sendStatus: sinon.spy() 211 | }; 212 | 213 | class fakeProduct { 214 | static deleteOne() {} 215 | } 216 | 217 | const deleteOneStub = sinon.stub(fakeProduct, 'deleteOne'); 218 | 219 | deleteOneStub.withArgs({ _id: fakeId }).resolves(); 220 | 221 | const productsController = new ProductsController(fakeProduct); 222 | 223 | await productsController.remove(request, response); 224 | sinon.assert.calledWith(response.sendStatus, 204); 225 | }); 226 | 227 | context('when an error occurs', () => { 228 | it('should return 400', async () => { 229 | const fakeId = 'a-fake-id'; 230 | const request = { 231 | params: { 232 | id: fakeId 233 | } 234 | }; 235 | const response = { 236 | send: sinon.spy(), 237 | status: sinon.stub() 238 | }; 239 | 240 | class fakeProduct { 241 | static deleteOne() {} 242 | } 243 | 244 | const deleteOneStub = sinon.stub(fakeProduct, 'deleteOne'); 245 | 246 | deleteOneStub.withArgs({ _id: fakeId }).rejects({ message: 'Error' }); 247 | response.status.withArgs(400).returns(response); 248 | 249 | const productsController = new ProductsController(fakeProduct); 250 | 251 | await productsController.remove(request, response); 252 | sinon.assert.calledWith(response.send, 'Error'); 253 | }); 254 | }); 255 | }); 256 | }); 257 | -------------------------------------------------------------------------------- /test/unit/controllers/users_spec.js: -------------------------------------------------------------------------------- 1 | import UsersController from '../../../src/controllers/users'; 2 | import sinon from 'sinon'; 3 | import jwt from 'jsonwebtoken'; 4 | import config from 'config'; 5 | import bcrypt from 'bcrypt'; 6 | import User from '../../../src/models/user'; 7 | 8 | describe('Controller: Users', () => { 9 | const defaultUser = [ 10 | { 11 | __v: 0, 12 | _id: '56cb91bdc3464f14678934ca', 13 | name: 'Default User', 14 | email: 'user@mail.com', 15 | password: 'password', 16 | role: 'user' 17 | } 18 | ]; 19 | 20 | const defaultRequest = { 21 | params: {} 22 | }; 23 | 24 | describe('get() users', () => { 25 | it('should return a list of users', async () => { 26 | const response = { 27 | send: sinon.spy() 28 | }; 29 | User.find = sinon.stub(); 30 | 31 | User.find.withArgs({}).resolves(defaultUser); 32 | 33 | const usersController = new UsersController(User); 34 | 35 | await usersController.get(defaultRequest, response); 36 | sinon.assert.calledWith(response.send, defaultUser); 37 | }); 38 | 39 | it('should return 400 when an error occurs', async () => { 40 | const request = {}; 41 | const response = { 42 | send: sinon.spy(), 43 | status: sinon.stub() 44 | }; 45 | 46 | response.status.withArgs(400).returns(response); 47 | User.find = sinon.stub(); 48 | User.find.withArgs({}).rejects({ message: 'Error' }); 49 | 50 | const usersController = new UsersController(User); 51 | 52 | await usersController.get(request, response); 53 | sinon.assert.calledWith(response.send, 'Error'); 54 | }); 55 | }); 56 | 57 | describe('getById()', () => { 58 | it('should call send with one user', async () => { 59 | const fakeId = 'a-fake-id'; 60 | const request = { 61 | params: { 62 | id: fakeId 63 | } 64 | }; 65 | const response = { 66 | send: sinon.spy() 67 | }; 68 | 69 | User.find = sinon.stub(); 70 | User.find.withArgs({ _id: fakeId }).resolves(defaultUser); 71 | 72 | const usersController = new UsersController(User); 73 | 74 | await usersController.getById(request, response); 75 | sinon.assert.calledWith(response.send, defaultUser); 76 | }); 77 | }); 78 | 79 | describe('create() user', () => { 80 | it('should call send with a new user', async () => { 81 | const requestWithBody = Object.assign( 82 | {}, 83 | { body: defaultUser[0] }, 84 | defaultRequest 85 | ); 86 | const response = { 87 | send: sinon.spy(), 88 | status: sinon.stub() 89 | }; 90 | class fakeUser { 91 | save() {} 92 | } 93 | 94 | response.status.withArgs(201).returns(response); 95 | sinon 96 | .stub(fakeUser.prototype, 'save') 97 | .withArgs() 98 | .resolves(); 99 | 100 | const usersController = new UsersController(fakeUser); 101 | 102 | await usersController.create(requestWithBody, response); 103 | sinon.assert.calledWith(response.send); 104 | }); 105 | 106 | context('when an error occurs', () => { 107 | it('should return 422', async () => { 108 | const response = { 109 | send: sinon.spy(), 110 | status: sinon.stub() 111 | }; 112 | 113 | class fakeUser { 114 | save() {} 115 | } 116 | 117 | response.status.withArgs(422).returns(response); 118 | sinon 119 | .stub(fakeUser.prototype, 'save') 120 | .withArgs() 121 | .rejects({ message: 'Error' }); 122 | 123 | const usersController = new UsersController(fakeUser); 124 | 125 | await usersController.create(defaultRequest, response); 126 | sinon.assert.calledWith(response.status, 422); 127 | }); 128 | }); 129 | }); 130 | 131 | describe('update() user', () => { 132 | it('should respond with 200 when the user has been updated', async () => { 133 | const fakeId = 'a-fake-id'; 134 | const updatedUser = { 135 | _id: fakeId, 136 | name: 'Updated User', 137 | email: 'user@mail.com', 138 | password: 'password', 139 | role: 'user' 140 | }; 141 | const request = { 142 | params: { 143 | id: fakeId 144 | }, 145 | body: updatedUser 146 | }; 147 | const response = { 148 | sendStatus: sinon.spy() 149 | }; 150 | class fakeUser { 151 | static findById() {} 152 | save() {} 153 | } 154 | const fakeUserInstance = new fakeUser(); 155 | 156 | const saveSpy = sinon.spy(fakeUser.prototype, 'save'); 157 | const findByIdStub = sinon.stub(fakeUser, 'findById'); 158 | findByIdStub.withArgs(fakeId).resolves(fakeUserInstance); 159 | 160 | const usersController = new UsersController(fakeUser); 161 | 162 | await usersController.update(request, response); 163 | sinon.assert.calledWith(response.sendStatus, 200); 164 | sinon.assert.calledOnce(saveSpy); 165 | }); 166 | 167 | context('when an error occurs', () => { 168 | it('should return 422', async () => { 169 | const fakeId = 'a-fake-id'; 170 | const updatedUser = { 171 | _id: fakeId, 172 | name: 'Updated User', 173 | email: 'user@mail.com', 174 | password: 'password', 175 | role: 'user' 176 | }; 177 | const request = { 178 | params: { 179 | id: fakeId 180 | }, 181 | body: updatedUser 182 | }; 183 | const response = { 184 | send: sinon.spy(), 185 | status: sinon.stub() 186 | }; 187 | 188 | class fakeUser { 189 | static findById() {} 190 | } 191 | 192 | const findByIdStub = sinon.stub(fakeUser, 'findById'); 193 | findByIdStub.withArgs(fakeId).rejects({ message: 'Error' }); 194 | response.status.withArgs(422).returns(response); 195 | 196 | const usersController = new UsersController(fakeUser); 197 | 198 | await usersController.update(request, response); 199 | sinon.assert.calledWith(response.send, 'Error'); 200 | }); 201 | }); 202 | }); 203 | 204 | describe('delete() user', () => { 205 | it('should respond with 204 when the user has been deleted', async () => { 206 | const fakeId = 'a-fake-id'; 207 | const request = { 208 | params: { 209 | id: fakeId 210 | } 211 | }; 212 | const response = { 213 | sendStatus: sinon.spy() 214 | }; 215 | 216 | class fakeUser { 217 | static deleteOne() {} 218 | } 219 | 220 | const deleteOneStub = sinon.stub(fakeUser, 'deleteOne'); 221 | 222 | deleteOneStub.withArgs({ _id: fakeId }).resolves([1]); 223 | 224 | const usersController = new UsersController(fakeUser); 225 | 226 | await usersController.remove(request, response); 227 | sinon.assert.calledWith(response.sendStatus, 204); 228 | }); 229 | 230 | context('when an error occurs', () => { 231 | it('should return 400', async () => { 232 | const fakeId = 'a-fake-id'; 233 | const request = { 234 | params: { 235 | id: fakeId 236 | } 237 | }; 238 | const response = { 239 | send: sinon.spy(), 240 | status: sinon.stub() 241 | }; 242 | 243 | class fakeUser { 244 | static deleteOne() {} 245 | } 246 | 247 | const deleteOneStub = sinon.stub(fakeUser, 'deleteOne'); 248 | 249 | deleteOneStub.withArgs({ _id: fakeId }).rejects({ message: 'Error' }); 250 | response.status.withArgs(400).returns(response); 251 | 252 | const usersController = new UsersController(fakeUser); 253 | 254 | await usersController.remove(request, response); 255 | sinon.assert.calledWith(response.send, 'Error'); 256 | }); 257 | }); 258 | }); 259 | 260 | describe('authenticate', () => { 261 | it('should authenticate a user', async () => { 262 | const fakeUserModel = {}; 263 | const user = { 264 | name: 'Jhon Doe', 265 | email: 'jhondoe@mail.com', 266 | password: '12345', 267 | role: 'admin' 268 | }; 269 | const userWithEncryptedPassword = { 270 | ...user, 271 | password: bcrypt.hashSync(user.password, 10) 272 | }; 273 | const jwtToken = jwt.sign( 274 | userWithEncryptedPassword, 275 | config.get('auth.key'), 276 | { 277 | expiresIn: config.get('auth.tokenExpiresIn') 278 | } 279 | ); 280 | class FakeAuthService { 281 | authenticate() { 282 | return Promise.resolve(userWithEncryptedPassword); 283 | } 284 | 285 | static generateToken() { 286 | return jwtToken; 287 | } 288 | } 289 | const fakeReq = { 290 | body: user 291 | }; 292 | const fakeRes = { 293 | send: sinon.spy() 294 | }; 295 | const usersController = new UsersController( 296 | fakeUserModel, 297 | FakeAuthService 298 | ); 299 | await usersController.authenticate(fakeReq, fakeRes); 300 | sinon.assert.calledWith(fakeRes.send, { token: jwtToken }); 301 | }); 302 | 303 | it('should return 401 when the user can not be found', async () => { 304 | const fakeUserModel = {}; 305 | class FakeAuthService { 306 | authenticate() { 307 | return Promise.resolve(false); 308 | } 309 | } 310 | const user = { 311 | name: 'Jhon Doe', 312 | email: 'jhondoe@mail.com', 313 | password: '12345', 314 | role: 'admin' 315 | }; 316 | const fakeReq = { 317 | body: user 318 | }; 319 | const fakeRes = { 320 | sendStatus: sinon.spy() 321 | }; 322 | const usersController = new UsersController( 323 | fakeUserModel, 324 | FakeAuthService 325 | ); 326 | 327 | await usersController.authenticate(fakeReq, fakeRes); 328 | sinon.assert.calledWith(fakeRes.sendStatus, 401); 329 | }); 330 | }); 331 | }); 332 | -------------------------------------------------------------------------------- /test/unit/helpers.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | 3 | global.expect = chai.expect; 4 | -------------------------------------------------------------------------------- /test/unit/middlewares/auth_spec.js: -------------------------------------------------------------------------------- 1 | import authMiddleware from '../../../src/middlewares/auth'; 2 | import jwt from 'jsonwebtoken'; 3 | import config from 'config'; 4 | 5 | describe('AuthMiddleware', () => { 6 | it('should verify a JWT token and call the next middleware', done => { 7 | const jwtToken = jwt.sign({ data: 'fake' }, config.get('auth.key')); 8 | const reqFake = { 9 | headers: { 10 | 'x-access-token': jwtToken 11 | } 12 | }; 13 | const resFake = {}; 14 | authMiddleware(reqFake, resFake, done); 15 | }); 16 | it('should call the next middleware passing an error when the token validation fails', done => { 17 | const reqFake = { 18 | headers: { 19 | 'x-access-token': 'invalid token' 20 | } 21 | }; 22 | const resFake = {}; 23 | authMiddleware(reqFake, resFake, err => { 24 | expect(err.message).to.eq('jwt malformed'); 25 | done(); 26 | }); 27 | }); 28 | 29 | it('should call next middleware if theres no token', done => { 30 | const reqFake = { 31 | headers: {} 32 | }; 33 | const resFake = {}; 34 | authMiddleware(reqFake, resFake, done); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/unit/mocha.opts: -------------------------------------------------------------------------------- 1 | --require @babel/register 2 | --require test/unit/helpers.js 3 | --reporter spec 4 | --slow 5000 5 | -------------------------------------------------------------------------------- /test/unit/services/auth_spec.js: -------------------------------------------------------------------------------- 1 | import AuthService from '../../../src/services/auth'; 2 | import bcrypt from 'bcrypt'; 3 | import Util from 'util'; 4 | import sinon from 'sinon'; 5 | import jwt from 'jsonwebtoken'; 6 | import config from 'config'; 7 | 8 | const hashAsync = Util.promisify(bcrypt.hash); 9 | 10 | describe('Service: Auth', () => { 11 | context('authenticate', () => { 12 | it('should authenticate a user', async () => { 13 | const fakeUserModel = { 14 | findOne: sinon.stub() 15 | }; 16 | const user = { 17 | name: 'John', 18 | email: 'jhondoe@mail.com', 19 | password: '12345' 20 | }; 21 | 22 | const authService = new AuthService(fakeUserModel); 23 | const hashedPassword = await hashAsync('12345', 10); 24 | const userFromDatabase = { ...user, password: hashedPassword }; 25 | 26 | fakeUserModel.findOne 27 | .withArgs({ email: 'jhondoe@mail.com' }) 28 | .resolves(userFromDatabase); 29 | 30 | const res = await authService.authenticate(user); 31 | 32 | expect(res).to.eql(userFromDatabase); 33 | }); 34 | 35 | it('should return false when the password does not match', async () => { 36 | const user = { 37 | email: 'jhondoe@mail.com', 38 | password: '12345' 39 | }; 40 | const fakeUserModel = { 41 | findOne: sinon.stub() 42 | }; 43 | fakeUserModel.findOne.resolves({ 44 | email: user.email, 45 | password: 'aFakeHashedPassword' 46 | }); 47 | const authService = new AuthService(fakeUserModel); 48 | const response = await authService.authenticate(user); 49 | 50 | expect(response).to.be.false; 51 | }); 52 | }); 53 | 54 | context('generateToken', () => { 55 | it('should generate a JWT token from a payload', () => { 56 | const payload = { 57 | name: 'John', 58 | email: 'jhondoe@mail.com', 59 | password: '12345' 60 | }; 61 | const expectedToken = jwt.sign(payload, config.get('auth.key'), { 62 | expiresIn: config.get('auth.tokenExpiresIn') 63 | }); 64 | const generatedToken = AuthService.generateToken(payload); 65 | expect(generatedToken).to.eql(expectedToken); 66 | }); 67 | }); 68 | }); 69 | --------------------------------------------------------------------------------