├── .babelrc ├── .dockerignore ├── .eslintrc.json ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── adapters └── controllers │ ├── authController.js │ ├── postController.js │ └── userController.js ├── app.js ├── application ├── repositories │ ├── postDbRepository.js │ ├── postRedisRepository.js │ └── userDbRepository.js ├── services │ └── authService.js └── use_cases │ ├── auth │ └── login.js │ ├── post │ ├── add.js │ ├── countAll.js │ ├── deleteΒyId.js │ ├── findAll.js │ ├── findById.js │ └── updateById.js │ └── user │ ├── add.js │ ├── countAll.js │ ├── findById.js │ └── findByProperty.js ├── config └── config.js ├── docker-compose.yml ├── frameworks ├── database │ ├── mongoDB │ │ ├── connection.js │ │ ├── models │ │ │ ├── post.js │ │ │ └── user.js │ │ └── repositories │ │ │ ├── postRepositoryMongoDB.js │ │ │ └── userRepositoryMongoDB.js │ └── redis │ │ ├── connection.js │ │ └── postRepositoryRedis.js ├── services │ └── authService.js └── webserver │ ├── express.js │ ├── middlewares │ ├── authMiddleware.js │ ├── errorHandlingMiddleware.js │ └── redisCachingMiddleware.js │ ├── routes │ ├── auth.js │ ├── index.js │ ├── post.js │ └── user.js │ └── server.js ├── package.json ├── src └── entities │ ├── post.js │ └── user.js └── tests └── unit ├── fixtures └── posts.js └── post ├── api └── api.spec.test.js └── use_cases └── use_cases.spec.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": [ 4 | "@babel/plugin-transform-runtime" 5 | ] 6 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "globals": { 8 | "Atomics": "readonly", 9 | "SharedArrayBuffer": "readonly" 10 | }, 11 | "parserOptions": { 12 | "ecmaVersion": 2020, 13 | "sourceType": "module" 14 | }, 15 | "rules": { 16 | "no-console": "off", 17 | "no-underscore-dangle": ["error", { "allowAfterThis": true, "allow": ["_id"] }], 18 | "no-buffer-constructor": "off", 19 | "no-restricted-syntax": "off", 20 | "object-curly-newline": ["error", { "consistent": true }], 21 | "max-len": ["error", { "code": 120 }], 22 | "implicit-arrow-linebreak": "off", 23 | "indent": "off", 24 | "operator-linebreak": "off", 25 | "prettier/prettier": "error" 26 | }, 27 | "extends": ["airbnb-base", "prettier"], 28 | "plugins": ["prettier"] 29 | } -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm install 28 | - run: npm run build --if-present 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | mongo_data -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "singleQuote": true, 5 | "trailingComma" : "none" 6 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.12.0-alpine as builder 2 | 3 | COPY package.json /srv/node-clean-architecture/ 4 | WORKDIR /srv/node-clean-architecture/ 5 | 6 | RUN yarn install --production 7 | 8 | COPY .babelrc /srv/node-clean-architecture/ 9 | COPY .eslintrc.json /srv/node-clean-architecture/ 10 | COPY app.js /srv/node-clean-architecture/ 11 | COPY adapters /srv/node-clean-architecture/adapters/ 12 | COPY application /srv/node-clean-architecture/application/ 13 | COPY config /srv/node-clean-architecture/config/ 14 | COPY frameworks /srv/node-clean-architecture/frameworks/ 15 | COPY src /srv/node-clean-architecture/src/ 16 | COPY tests /srv/node-clean-architecture/tests/ 17 | 18 | RUN yarn run build 19 | 20 | FROM node:22.12.0-alpine 21 | 22 | ENV HTTP_MODE http 23 | ARG NODE_PROCESSES=2 24 | ENV NODE_PROCESSES=$NODE_PROCESSES 25 | 26 | # Install pm2 27 | RUN npm install -g pm2 28 | 29 | # Copy over code 30 | WORKDIR /srv/api/ 31 | COPY --from=builder /srv/node-clean-architecture/build /srv/api/build 32 | COPY --from=builder /srv/node-clean-architecture/package.json /srv/api/package.json 33 | 34 | RUN deluser --remove-home node \ 35 | && addgroup -S node -g 9999 \ 36 | && adduser -S -G node -u 9999 node 37 | 38 | CMD ["npm", "start"] 39 | 40 | USER node -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node.js-clean-architecture 2 | A use case of Clean Architecture in Node.js comprising of Express.js, MongoDB and Redis as the main (but replaceable) infrastructure. 3 | 4 | ### Overview 5 | This example is a simple RESTful API application in which a user can create / update / delete / find a post, by using the *Clean Architecture*. 6 | 7 | The objective of *Clean Architecture* by [Robert C. Martin] is the separation of concerns in software. 8 | This separation is achieved by dividing the software into layers. Each layer is encapsulated by a higher level layer and the way to communicate between the layers is with the *Dependency Rule*. 9 | 10 | ![](https://blog.cleancoder.com/uncle-bob/images/2012-08-13-the-clean-architecture/CleanArchitecture.jpg) 11 | 12 | #### Dependency Rule 13 | This rule says that nothing in an inner circle can know anything at all about something in an outer circle. The dependency direction is from the outside in. Meaning that the *Entities* layer is independent and the *Frameworks & Drivers* layer (Web, UI, DB etc.) depends on all the other layers. 14 | #### Entities 15 | Contains all the business entities an application consists of. In our example the *User* and the *Post*. 16 | #### Use Cases 17 | Contains application specific business rules. These use cases orchestrate the flow of data to and from the entities. In our example some of the use cases are: *AddPost*, *AddUser*, *DeleteById* etc. 18 | #### Interface Adapters 19 | This layer is a set of adapters (controllers, presenters, and gateways) that convert data from the format most convenient for the use cases and entities, to the format most convenient for some external agency such as the DB or the Web. In other words, is an entry and exit points to the Use Cases layer. In our example we implemented controllers and presenters together and these are the PostController and the UserController. 20 | #### Frameworks and Drivers 21 | The outermost layer is generally composed of frameworks and tools such as the Database, the Web Framework, etc. 22 | ### How to run it 23 | * Make sure you have [mongoDB] installed. At the terminal run the following command: 24 | ```sh 25 | mongod --dbpath 26 | ``` 27 | * Make sure [Redis] is also installed and running.

28 | * Run the server in development mode by typing the following command: 29 | ```sh 30 | npm run dev 31 | ``` 32 | * Run the server in production mode by typing the following command: 33 | ```sh 34 | npm run start 35 | ``` 36 | 37 | ### How to run it (using Docker) 38 | * Make sure you have [docker] installed. At the root folder run the following command: 39 | ```sh 40 | docker-compose up -d 41 | ``` 42 | ### API documentation 43 | https://documenter.getpostman.com/view/1551953/TzCJgpnb 44 | 45 | 46 | ### Further reading 47 | - https://roystack.home.blog/2019/10/22/node-clean-architecture-deep-dive/ 48 | - https://mannhowie.com/clean-architecture-node 49 | 50 | [Robert C. Martin]: 51 | [docker]: 52 | [mongoDB]: 53 | [Redis]: 54 | 55 | 56 | -------------------------------------------------------------------------------- /adapters/controllers/authController.js: -------------------------------------------------------------------------------- 1 | import login from '../../application/use_cases/auth/login'; 2 | 3 | export default function authController( 4 | userDbRepository, 5 | userDbRepositoryImpl, 6 | authServiceInterface, 7 | authServiceImpl 8 | ) { 9 | const dbRepository = userDbRepository(userDbRepositoryImpl()); 10 | const authService = authServiceInterface(authServiceImpl()); 11 | 12 | const loginUser = (req, res, next) => { 13 | const { email, password } = req.body; 14 | login(email, password, dbRepository, authService) 15 | .then((token) => res.json(token)) 16 | .catch((err) => next(err)); 17 | }; 18 | return { 19 | loginUser 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /adapters/controllers/postController.js: -------------------------------------------------------------------------------- 1 | import findAll from '../../application/use_cases/post/findAll'; 2 | import countAll from '../../application/use_cases/post/countAll'; 3 | import addPost from '../../application/use_cases/post/add'; 4 | import findById from '../../application/use_cases/post/findById'; 5 | import updateById from '../../application/use_cases/post/updateById'; 6 | import deletePost from '../../application/use_cases/post/deleteΒyId'; 7 | 8 | export default function postController( 9 | postDbRepository, 10 | postDbRepositoryImpl, 11 | cachingClient, 12 | postCachingRepository, 13 | postCachingRepositoryImpl 14 | ) { 15 | const dbRepository = postDbRepository(postDbRepositoryImpl()); 16 | const cachingRepository = postCachingRepository( 17 | postCachingRepositoryImpl()(cachingClient) 18 | ); 19 | 20 | // Fetch all the posts of the logged in user 21 | const fetchAllPosts = (req, res, next) => { 22 | const params = {}; 23 | const response = {}; 24 | 25 | // Dynamically created query params based on endpoint params 26 | for (const key in req.query) { 27 | if (Object.prototype.hasOwnProperty.call(req.query, key)) { 28 | params[key] = req.query[key]; 29 | } 30 | } 31 | // predefined query params (apart from dynamically) for pagination 32 | // and current logged in user 33 | params.page = params.page ? parseInt(params.page, 10) : 1; 34 | params.perPage = params.perPage ? parseInt(params.perPage, 10) : 10; 35 | params.userId = req.user.id; 36 | 37 | findAll(params, dbRepository) 38 | .then((posts) => { 39 | response.posts = posts; 40 | const cachingOptions = { 41 | key: 'posts_', 42 | expireTimeSec: 30, 43 | data: JSON.stringify(posts) 44 | }; 45 | // cache the result to redis 46 | cachingRepository.setCache(cachingOptions); 47 | return countAll(params, dbRepository); 48 | }) 49 | .then((totalItems) => { 50 | response.totalItems = totalItems; 51 | response.totalPages = Math.ceil(totalItems / params.perPage); 52 | response.itemsPerPage = params.perPage; 53 | return res.json(response); 54 | }) 55 | .catch((error) => next(error)); 56 | }; 57 | 58 | const fetchPostById = (req, res, next) => { 59 | findById(req.params.id, dbRepository) 60 | .then((post) => { 61 | if (!post) { 62 | throw new Error(`No post found with id: ${req.params.id}`); 63 | } 64 | res.json(post); 65 | }) 66 | .catch((error) => next(error)); 67 | }; 68 | 69 | const addNewPost = (req, res, next) => { 70 | const { title, description } = req.body; 71 | 72 | addPost({ 73 | title, 74 | description, 75 | userId: req.user.id, 76 | postRepository: dbRepository 77 | }) 78 | .then((post) => { 79 | const cachingOptions = { 80 | key: 'posts_', 81 | expireTimeSec: 30, 82 | data: JSON.stringify(post) 83 | }; 84 | // cache the result to redis 85 | cachingRepository.setCache(cachingOptions); 86 | return res.json('post added'); 87 | }) 88 | .catch((error) => next(error)); 89 | }; 90 | 91 | const deletePostById = (req, res, next) => { 92 | deletePost(req.params.id, dbRepository) 93 | .then(() => res.json('post sucessfully deleted!')) 94 | .catch((error) => next(error)); 95 | }; 96 | 97 | const updatePostById = (req, res, next) => { 98 | const { title, description, isPublished } = req.body; 99 | 100 | updateById({ 101 | id: req.params.id, 102 | title, 103 | description, 104 | userId: req.user.id, 105 | isPublished, 106 | postRepository: dbRepository 107 | }) 108 | .then((message) => res.json(message)) 109 | .catch((error) => next(error)); 110 | }; 111 | 112 | return { 113 | fetchAllPosts, 114 | addNewPost, 115 | fetchPostById, 116 | updatePostById, 117 | deletePostById 118 | }; 119 | } 120 | -------------------------------------------------------------------------------- /adapters/controllers/userController.js: -------------------------------------------------------------------------------- 1 | import addUser from '../../application/use_cases/user/add'; 2 | import findByProperty from '../../application/use_cases/user/findByProperty'; 3 | import countAll from '../../application/use_cases/user/countAll'; 4 | import findById from '../../application/use_cases/user/findById'; 5 | 6 | export default function userController( 7 | userDbRepository, 8 | userDbRepositoryImpl, 9 | authServiceInterface, 10 | authServiceImpl 11 | ) { 12 | const dbRepository = userDbRepository(userDbRepositoryImpl()); 13 | const authService = authServiceInterface(authServiceImpl()); 14 | 15 | const fetchUsersByProperty = (req, res, next) => { 16 | const params = {}; 17 | const response = {}; 18 | 19 | // Dynamically created query params based on endpoint params 20 | for (const key in req.query) { 21 | if (Object.prototype.hasOwnProperty.call(req.query, key)) { 22 | params[key] = req.query[key]; 23 | } 24 | } 25 | // predefined query params (apart from dynamically) for pagination 26 | params.page = params.page ? parseInt(params.page, 10) : 1; 27 | params.perPage = params.perPage ? parseInt(params.perPage, 10) : 10; 28 | 29 | findByProperty(params, dbRepository) 30 | .then((users) => { 31 | response.users = users; 32 | return countAll(params, dbRepository); 33 | }) 34 | .then((totalItems) => { 35 | response.totalItems = totalItems; 36 | response.totalPages = Math.ceil(totalItems / params.perPage); 37 | response.itemsPerPage = params.perPage; 38 | return res.json(response); 39 | }) 40 | .catch((error) => next(error)); 41 | }; 42 | 43 | const fetchUserById = (req, res, next) => { 44 | findById(req.params.id, dbRepository) 45 | .then((user) => res.json(user)) 46 | .catch((error) => next(error)); 47 | }; 48 | 49 | const addNewUser = (req, res, next) => { 50 | const { username, password, email, role, createdAt } = req.body; 51 | addUser( 52 | username, 53 | password, 54 | email, 55 | role, 56 | createdAt, 57 | dbRepository, 58 | authService 59 | ) 60 | .then((user) => res.json(user)) 61 | .catch((error) => next(error)); 62 | }; 63 | 64 | return { 65 | fetchUsersByProperty, 66 | fetchUserById, 67 | addNewUser 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import mongoose from 'mongoose'; 3 | import redis from 'redis'; 4 | import config from './config/config'; 5 | import expressConfig from './frameworks/webserver/express'; 6 | import routes from './frameworks/webserver/routes'; 7 | import serverConfig from './frameworks/webserver/server'; 8 | import mongoDbConnection from './frameworks/database/mongoDB/connection'; 9 | import redisConnection from './frameworks/database/redis/connection'; 10 | // middlewares 11 | import errorHandlingMiddleware from './frameworks/webserver/middlewares/errorHandlingMiddleware'; 12 | 13 | const app = express(); 14 | const server = require('http').createServer(app); 15 | 16 | // express.js configuration (middlewares etc.) 17 | expressConfig(app); 18 | 19 | // server configuration and start 20 | serverConfig(app, mongoose, server, config).startServer(); 21 | 22 | // DB configuration and connection create 23 | mongoDbConnection(mongoose, config, { 24 | autoIndex: false, 25 | useCreateIndex: true, 26 | useNewUrlParser: true, 27 | autoReconnect: true, 28 | reconnectTries: Number.MAX_VALUE, 29 | reconnectInterval: 10000, 30 | keepAlive: 120, 31 | connectTimeoutMS: 1000 32 | }).connectToMongo(); 33 | 34 | const redisClient = redisConnection(redis, config).createRedisClient(); 35 | 36 | // routes for each endpoint 37 | routes(app, express, redisClient); 38 | 39 | // error handling middleware 40 | app.use(errorHandlingMiddleware); 41 | 42 | // Expose app 43 | export default app; 44 | -------------------------------------------------------------------------------- /application/repositories/postDbRepository.js: -------------------------------------------------------------------------------- 1 | export default function postRepository(repository) { 2 | const findAll = (params) => repository.findAll(params); 3 | const countAll = (params) => repository.countAll(params); 4 | const findById = (id) => repository.findById(id); 5 | const add = (post) => repository.add(post); 6 | const updateById = (id, post) => repository.updateById(id, post); 7 | const deleteById = (id) => repository.deleteById(id); 8 | 9 | return { 10 | findAll, 11 | countAll, 12 | findById, 13 | add, 14 | updateById, 15 | deleteById 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /application/repositories/postRedisRepository.js: -------------------------------------------------------------------------------- 1 | export default function redisPostRepository(repository) { 2 | const setCache = (options) => repository.setCache(options); 3 | return { 4 | setCache 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /application/repositories/userDbRepository.js: -------------------------------------------------------------------------------- 1 | export default function userRepository(repository) { 2 | const findByProperty = (params) => repository.findByProperty(params); 3 | const countAll = (params) => repository.countAll(params); 4 | const findById = (id) => repository.findById(id); 5 | const add = (user) => repository.add(user); 6 | const deleteById = (id) => repository.deleteById(id); 7 | 8 | return { 9 | findByProperty, 10 | countAll, 11 | findById, 12 | add, 13 | deleteById 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /application/services/authService.js: -------------------------------------------------------------------------------- 1 | export default function authService(service) { 2 | const encryptPassword = (password) => service.encryptPassword(password); 3 | 4 | const compare = (password, hashedPassword) => 5 | service.compare(password, hashedPassword); 6 | 7 | const verify = (token) => service.verify(token); 8 | 9 | const generateToken = (payload) => service.generateToken(payload); 10 | 11 | return { 12 | encryptPassword, 13 | compare, 14 | verify, 15 | generateToken 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /application/use_cases/auth/login.js: -------------------------------------------------------------------------------- 1 | export default function login(email, password, userRepository, authService) { 2 | if (!email || !password) { 3 | const error = new Error('email and password fields cannot be empty'); 4 | error.statusCode = 401; 5 | throw error; 6 | } 7 | return userRepository.findByProperty({ email }).then((user) => { 8 | if (!user.length) { 9 | const error = new Error('Invalid email or password'); 10 | error.statusCode = 401; 11 | throw error; 12 | } 13 | const isMatch = authService.compare(password, user[0].password); 14 | if (!isMatch) { 15 | const error = new Error('Invalid email or password'); 16 | error.statusCode = 401; 17 | throw error; 18 | } 19 | const payload = { 20 | user: { 21 | id: user[0].id 22 | } 23 | }; 24 | return authService.generateToken(payload); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /application/use_cases/post/add.js: -------------------------------------------------------------------------------- 1 | import post from '../../../src/entities/post'; 2 | 3 | export default function addPost({ 4 | title, 5 | description, 6 | createdAt, 7 | isPublished, 8 | userId, 9 | postRepository 10 | }) { 11 | // TODO: add a proper validation (consider using @hapi/joi) 12 | if (!title || !description) { 13 | throw new Error('title and description fields cannot be empty'); 14 | } 15 | 16 | const newPost = post({ title, description, createdAt, isPublished, userId }); 17 | 18 | return postRepository.add(newPost); 19 | } 20 | -------------------------------------------------------------------------------- /application/use_cases/post/countAll.js: -------------------------------------------------------------------------------- 1 | export default function countAll(params, postRepository) { 2 | return postRepository.countAll(params); 3 | } 4 | -------------------------------------------------------------------------------- /application/use_cases/post/deleteΒyId.js: -------------------------------------------------------------------------------- 1 | export default function deleteById(id, postRepository) { 2 | return postRepository.findById(id).then((post) => { 3 | if (!post) { 4 | throw new Error(`No post found with id: ${id}`); 5 | } 6 | return postRepository.deleteById(id); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /application/use_cases/post/findAll.js: -------------------------------------------------------------------------------- 1 | export default function findAll(params, postRepository) { 2 | return postRepository.findAll(params); 3 | } 4 | -------------------------------------------------------------------------------- /application/use_cases/post/findById.js: -------------------------------------------------------------------------------- 1 | export default function findById(id, postRepository) { 2 | return postRepository.findById(id); 3 | } 4 | -------------------------------------------------------------------------------- /application/use_cases/post/updateById.js: -------------------------------------------------------------------------------- 1 | import post from '../../../src/entities/post'; 2 | 3 | export default function updateById({ 4 | id, 5 | title, 6 | description, 7 | createdAt, 8 | isPublished, 9 | userId, 10 | postRepository 11 | }) { 12 | // validate 13 | if (!title || !description) { 14 | throw new Error('title and description fields are mandatory'); 15 | } 16 | const updatedPost = post({ 17 | title, 18 | description, 19 | createdAt, 20 | isPublished, 21 | userId 22 | }); 23 | 24 | return postRepository.findById(id).then((foundPost) => { 25 | if (!foundPost) { 26 | throw new Error(`No post found with id: ${id}`); 27 | } 28 | return postRepository.updateById(id, updatedPost); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /application/use_cases/user/add.js: -------------------------------------------------------------------------------- 1 | import user from '../../../src/entities/user'; 2 | 3 | export default function addUser( 4 | username, 5 | password, 6 | email, 7 | role, 8 | createdAt, 9 | userRepository, 10 | authService 11 | ) { 12 | // TODO: add a proper validation (consider using @hapi/joi) 13 | if (!username || !password || !email) { 14 | throw new Error('username, password and email fields cannot be empty'); 15 | } 16 | 17 | const newUser = user( 18 | username, 19 | authService.encryptPassword(password), 20 | email, 21 | role, 22 | createdAt 23 | ); 24 | 25 | return userRepository 26 | .findByProperty({ username }) 27 | .then((userWithUsername) => { 28 | if (userWithUsername.length) { 29 | throw new Error(`User with username: ${username} already exists`); 30 | } 31 | return userRepository.findByProperty({ email }); 32 | }) 33 | .then((userWithEmail) => { 34 | if (userWithEmail.length) { 35 | throw new Error(`User with email: ${email} already exists`); 36 | } 37 | return userRepository.add(newUser); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /application/use_cases/user/countAll.js: -------------------------------------------------------------------------------- 1 | export default function countAll(params, userRepository) { 2 | return userRepository.countAll(params); 3 | } 4 | -------------------------------------------------------------------------------- /application/use_cases/user/findById.js: -------------------------------------------------------------------------------- 1 | export default function findById(id, userRepository) { 2 | return userRepository.findById(id); 3 | } 4 | -------------------------------------------------------------------------------- /application/use_cases/user/findByProperty.js: -------------------------------------------------------------------------------- 1 | export default function findByProperty(params, userRepository) { 2 | return userRepository.findByProperty(params); 3 | } 4 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | port: process.env.PORT || 1234, 3 | ip: process.env.HOST || '0.0.0.0', 4 | mongo: { 5 | uri: process.env.MONGO_URL || 'mongodb://localhost:27017/post-clean-code' 6 | }, 7 | redis: { 8 | uri: process.env.REDIS_URL || 'redis://localhost:6379' 9 | }, 10 | jwtSecret: process.env.JWT_SECRET || 'jkl!±@£!@ghj1237' 11 | }; 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | networks: 4 | my-net: 5 | 6 | services: 7 | mongo-database: 8 | image: "mongo:latest" 9 | ports: 10 | - 27017:27017 11 | volumes: 12 | - ./mongo_data:/data/db 13 | networks: 14 | - my-net 15 | redis-database: 16 | image: "redis" 17 | ports: 18 | - 6379:6379 19 | networks: 20 | - my-net 21 | web: 22 | build: . 23 | ports: 24 | - 1234:1234 25 | environment: 26 | - MONGO_URL=mongodb://mongo-database:27017/post-clean-code 27 | - REDIS_URL=redis://redis-database:6379 28 | depends_on: 29 | - mongo-database 30 | - redis-database 31 | networks: 32 | - my-net -------------------------------------------------------------------------------- /frameworks/database/mongoDB/connection.js: -------------------------------------------------------------------------------- 1 | export default function connection(mongoose, config, options) { 2 | function connectToMongo() { 3 | mongoose 4 | .connect(config.mongo.uri, options) 5 | .then( 6 | () => {}, 7 | (err) => { 8 | console.info('Mongodb error', err); 9 | } 10 | ) 11 | .catch((err) => { 12 | console.log('ERROR:', err); 13 | }); 14 | } 15 | 16 | mongoose.connection.on('connected', () => { 17 | console.info('Connected to MongoDB!'); 18 | }); 19 | 20 | mongoose.connection.on('reconnected', () => { 21 | console.info('MongoDB reconnected!'); 22 | }); 23 | 24 | mongoose.connection.on('error', (error) => { 25 | console.error(`Error in MongoDb connection: ${error}`); 26 | mongoose.disconnect(); 27 | }); 28 | 29 | mongoose.connection.on('disconnected', () => { 30 | console.error( 31 | `MongoDB disconnected! Reconnecting in ${ 32 | options.reconnectInterval / 1000 33 | }s...` 34 | ); 35 | setTimeout(() => connectToMongo(), options.reconnectInterval); 36 | }); 37 | 38 | return { 39 | connectToMongo 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /frameworks/database/mongoDB/models/post.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | // eslint-disable-next-line prefer-destructuring 4 | const Schema = mongoose.Schema; 5 | const PostsSchema = new Schema({ 6 | title: { 7 | type: String, 8 | unique: true 9 | }, 10 | description: String, 11 | createdAt: { 12 | type: 'Date', 13 | default: Date.now 14 | }, 15 | isPublished: { 16 | type: Boolean, 17 | default: false 18 | }, 19 | userId: { 20 | type: Schema.Types.ObjectId, 21 | ref: 'User' 22 | } 23 | }); 24 | 25 | PostsSchema.index({ userId: 1, title: 1 }); 26 | PostsSchema.index({ userId: 1, description: 1 }); 27 | PostsSchema.index({ userId: 1, createdAt: 1 }); 28 | PostsSchema.index({ userId: 1, isPublished: 1 }); 29 | 30 | const PostModel = mongoose.model('Post', PostsSchema); 31 | 32 | PostModel.ensureIndexes((err) => { 33 | if (err) { 34 | return err; 35 | } 36 | return true; 37 | }); 38 | 39 | export default PostModel; 40 | -------------------------------------------------------------------------------- /frameworks/database/mongoDB/models/user.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | // eslint-disable-next-line prefer-destructuring 4 | const Schema = mongoose.Schema; 5 | const UserSchema = new Schema({ 6 | username: { 7 | type: String, 8 | unique: true 9 | }, 10 | password: { 11 | type: String 12 | }, 13 | email: { 14 | type: String, 15 | required: true, 16 | unique: true, 17 | lowercase: true 18 | }, 19 | role: { 20 | type: String, 21 | default: 'test_user' 22 | }, 23 | createdAt: Date 24 | }); 25 | 26 | UserSchema.index({ role: 1 }); 27 | 28 | const UserModel = mongoose.model('User', UserSchema); 29 | 30 | UserModel.ensureIndexes((err) => { 31 | if (err) { 32 | return err; 33 | } 34 | return true; 35 | }); 36 | 37 | export default UserModel; 38 | -------------------------------------------------------------------------------- /frameworks/database/mongoDB/repositories/postRepositoryMongoDB.js: -------------------------------------------------------------------------------- 1 | import PostModel from '../models/post'; 2 | 3 | function omit(obj, ...props) { 4 | const result = { ...obj }; 5 | props.forEach((prop) => delete result[prop]); 6 | return result; 7 | } 8 | 9 | export default function postRepositoryMongoDB() { 10 | const findAll = (params) => 11 | PostModel.find(omit(params, 'page', 'perPage')) 12 | .skip(params.perPage * params.page - params.perPage) 13 | .limit(params.perPage); 14 | 15 | const countAll = (params) => 16 | PostModel.countDocuments(omit(params, 'page', 'perPage')); 17 | 18 | const findById = (id) => PostModel.findById(id); 19 | 20 | const add = (postEntity) => { 21 | const newPost = new PostModel({ 22 | title: postEntity.getTitle(), 23 | description: postEntity.getDescription(), 24 | createdAt: new Date(), 25 | isPublished: postEntity.isPublished(), 26 | userId: postEntity.getUserId() 27 | }); 28 | 29 | return newPost.save(); 30 | }; 31 | 32 | const updateById = (id, postEntity) => { 33 | const updatedPost = { 34 | title: postEntity.getTitle(), 35 | description: postEntity.getDescription(), 36 | isPublished: postEntity.isPublished() 37 | }; 38 | 39 | return PostModel.findOneAndUpdate( 40 | { _id: id }, 41 | { $set: updatedPost }, 42 | { new: true } 43 | ); 44 | }; 45 | 46 | const deleteById = (id) => PostModel.findByIdAndRemove(id); 47 | 48 | return { 49 | findAll, 50 | countAll, 51 | findById, 52 | add, 53 | updateById, 54 | deleteById 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /frameworks/database/mongoDB/repositories/userRepositoryMongoDB.js: -------------------------------------------------------------------------------- 1 | import UserModel from '../models/user'; 2 | 3 | // move it to a proper place 4 | function omit(obj, ...props) { 5 | const result = { ...obj }; 6 | props.forEach((prop) => delete result[prop]); 7 | return result; 8 | } 9 | 10 | export default function userRepositoryMongoDB() { 11 | const findByProperty = (params) => 12 | UserModel.find(omit(params, 'page', 'perPage')) 13 | .skip(params.perPage * params.page - params.perPage) 14 | .limit(params.perPage); 15 | 16 | const countAll = (params) => 17 | UserModel.countDocuments(omit(params, 'page', 'perPage')); 18 | 19 | const findById = (id) => UserModel.findById(id).select('-password'); 20 | 21 | const add = (userEntity) => { 22 | const newUser = new UserModel({ 23 | username: userEntity.getUserName(), 24 | password: userEntity.getPassword(), 25 | email: userEntity.getEmail(), 26 | role: userEntity.getRole(), 27 | createdAt: new Date() 28 | }); 29 | 30 | return newUser.save(); 31 | }; 32 | 33 | return { 34 | findByProperty, 35 | countAll, 36 | findById, 37 | add 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /frameworks/database/redis/connection.js: -------------------------------------------------------------------------------- 1 | export default function connection(redis, config) { 2 | const createRedisClient = function createRedisClient() { 3 | return redis.createClient(config.redis.uri); 4 | }; 5 | createRedisClient().on('connect', () => { 6 | console.log('Connected to Redis!'); 7 | }); 8 | 9 | createRedisClient().on('error', (err) => { 10 | console.log(`Error ${err}`); 11 | }); 12 | 13 | return { 14 | createRedisClient 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /frameworks/database/redis/postRepositoryRedis.js: -------------------------------------------------------------------------------- 1 | export default function PostRepositoryRedis() { 2 | return function cachingClient(redisClient) { 3 | const setCache = ({ key, expireTimeSec, data }) => 4 | redisClient.setex(key, expireTimeSec, data); 5 | return { 6 | setCache 7 | }; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /frameworks/services/authService.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import bcrypt from 'bcryptjs'; 3 | 4 | import config from '../../config/config'; 5 | 6 | export default function authService() { 7 | const encryptPassword = (password) => { 8 | const salt = bcrypt.genSaltSync(10); 9 | return bcrypt.hashSync(password, salt); 10 | }; 11 | 12 | const compare = (password, hashedPassword) => 13 | bcrypt.compareSync(password, hashedPassword); 14 | 15 | const verify = (token) => jwt.verify(token, config.jwtSecret); 16 | 17 | const generateToken = (payload) => 18 | jwt.sign(payload, config.jwtSecret, { 19 | expiresIn: 360000 20 | }); 21 | 22 | return { 23 | encryptPassword, 24 | compare, 25 | verify, 26 | generateToken 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /frameworks/webserver/express.js: -------------------------------------------------------------------------------- 1 | import morgan from 'morgan'; 2 | import compression from 'compression'; 3 | import bodyParser from 'body-parser'; 4 | import helmet from 'helmet'; 5 | 6 | export default function expressConfig(app) { 7 | // security middleware 8 | app.use(helmet()); 9 | 10 | app.use(compression()); 11 | app.use(bodyParser.json({ limit: '50mb' })); 12 | app.use( 13 | bodyParser.urlencoded({ 14 | limit: '50mb', 15 | extended: true, 16 | parameterLimit: 50000 17 | }) 18 | ); 19 | 20 | app.use((req, res, next) => { 21 | // Website you wish to allow to connect 22 | // res.setHeader('Access-Control-Allow-Origin', 'http://some-accepted-origin'); 23 | // Request methods you wish to allow 24 | res.setHeader( 25 | 'Access-Control-Allow-Methods', 26 | 'GET, POST, OPTIONS, PUT, PATCH, DELETE' 27 | ); 28 | // Request headers you wish to allow 29 | res.setHeader( 30 | 'Access-Control-Allow-Headers', 31 | 'X-Requested-With, Content-type, Authorization, Cache-control, Pragma' 32 | ); 33 | // Pass to next layer of middleware 34 | next(); 35 | }); 36 | app.use(morgan('combined')); 37 | } 38 | -------------------------------------------------------------------------------- /frameworks/webserver/middlewares/authMiddleware.js: -------------------------------------------------------------------------------- 1 | import authServiceImpl from '../../services/authService'; 2 | import authServiceInterface from '../../../application/services/authService'; 3 | 4 | export default function authMiddleware(req, res, next) { 5 | // Get token from header 6 | const token = req.header('Authorization'); 7 | const authService = authServiceInterface(authServiceImpl()); 8 | if (!token) { 9 | throw new Error('No access token found'); 10 | } 11 | if (token.split(' ')[0] !== 'Bearer') { 12 | throw new Error('Invalid access token format'); 13 | } 14 | try { 15 | const decoded = authService.verify(token.split(' ')[1]); 16 | req.user = decoded.user; 17 | next(); 18 | } catch (err) { 19 | throw new Error('Token is not valid'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frameworks/webserver/middlewares/errorHandlingMiddleware.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | // eslint-disable-next-line no-unused-vars 3 | export default function errorHandlingMiddlware(err, req, res, next) { 4 | err.statusCode = err.statusCode || 404; 5 | return err.customMessage || err.message 6 | ? res.status(err.statusCode).json({ 7 | status: err.statusCode, 8 | message: err.customMessage || err.message 9 | }) 10 | : res.status(err.statusCode).json({ status: err.statusCode, message: err }); 11 | } 12 | -------------------------------------------------------------------------------- /frameworks/webserver/middlewares/redisCachingMiddleware.js: -------------------------------------------------------------------------------- 1 | export default function redisCachingMiddleware(redisClient, key) { 2 | // eslint-disable-next-line func-names 3 | return function (req, res, next) { 4 | const params = req.params.id || ''; 5 | redisClient.get(`${key}_${params}`, (err, data) => { 6 | if (err) { 7 | console.log(err); 8 | } 9 | if (data) { 10 | return res.json(JSON.parse(data)); 11 | } 12 | return next(); 13 | }); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /frameworks/webserver/routes/auth.js: -------------------------------------------------------------------------------- 1 | import authController from '../../../adapters/controllers/authController'; 2 | import userDbRepository from '../../../application/repositories/userDbRepository'; 3 | import userDbRepositoryMongoDB from '../../database/mongoDB/repositories/userRepositoryMongoDB'; 4 | import authServiceInterface from '../../../application/services/authService'; 5 | import authServiceImpl from '../../services/authService'; 6 | 7 | export default function authRouter(express) { 8 | const router = express.Router(); 9 | 10 | // load controller with dependencies 11 | const controller = authController( 12 | userDbRepository, 13 | userDbRepositoryMongoDB, 14 | authServiceInterface, 15 | authServiceImpl 16 | ); 17 | 18 | // POST enpdpoints 19 | router.route('/').post(controller.loginUser); 20 | 21 | return router; 22 | } 23 | -------------------------------------------------------------------------------- /frameworks/webserver/routes/index.js: -------------------------------------------------------------------------------- 1 | import postRouter from './post'; 2 | import userRouter from './user'; 3 | import authRouter from './auth'; 4 | 5 | export default function routes(app, express, redisClient) { 6 | app.use('/api/v1/posts', postRouter(express, redisClient)); 7 | app.use('/api/v1/users', userRouter(express, redisClient)); 8 | app.use('/api/v1/login', authRouter(express, redisClient)); 9 | } 10 | -------------------------------------------------------------------------------- /frameworks/webserver/routes/post.js: -------------------------------------------------------------------------------- 1 | import postController from '../../../adapters/controllers/postController'; 2 | import postDbRepository from '../../../application/repositories/postDbRepository'; 3 | import postDbRepositoryMongoDB from '../../database/mongoDB/repositories/postRepositoryMongoDB'; 4 | import postRedisRepository from '../../../application/repositories/postRedisRepository'; 5 | import postRedisRepositoryImpl from '../../database/redis/postRepositoryRedis'; 6 | import redisCachingMiddleware from '../middlewares/redisCachingMiddleware'; 7 | import authMiddleware from '../middlewares/authMiddleware'; 8 | 9 | export default function postRouter(express, redisClient) { 10 | const router = express.Router(); 11 | 12 | // load controller with dependencies 13 | const controller = postController( 14 | postDbRepository, 15 | postDbRepositoryMongoDB, 16 | redisClient, 17 | postRedisRepository, 18 | postRedisRepositoryImpl 19 | ); 20 | 21 | // GET endpoints 22 | router 23 | .route('/') 24 | .get( 25 | [authMiddleware, redisCachingMiddleware(redisClient, 'posts')], 26 | controller.fetchAllPosts 27 | ); 28 | router 29 | .route('/:id') 30 | .get( 31 | [authMiddleware, redisCachingMiddleware(redisClient, 'post')], 32 | controller.fetchPostById 33 | ); 34 | 35 | // POST endpoints 36 | router.route('/').post(authMiddleware, controller.addNewPost); 37 | 38 | // PUT endpoints 39 | router.route('/:id').put(authMiddleware, controller.updatePostById); 40 | 41 | // DELETE endpoints 42 | router.route('/:id').delete(authMiddleware, controller.deletePostById); 43 | 44 | return router; 45 | } 46 | -------------------------------------------------------------------------------- /frameworks/webserver/routes/user.js: -------------------------------------------------------------------------------- 1 | import userController from '../../../adapters/controllers/userController'; 2 | import userDbRepository from '../../../application/repositories/userDbRepository'; 3 | import userDbRepositoryMongoDB from '../../database/mongoDB/repositories/userRepositoryMongoDB'; 4 | import authServiceInterface from '../../../application/services/authService'; 5 | import authServiceImpl from '../../services/authService'; 6 | import authMiddleware from '../middlewares/authMiddleware'; 7 | 8 | export default function userRouter(express) { 9 | const router = express.Router(); 10 | 11 | // load controller with dependencies 12 | const controller = userController( 13 | userDbRepository, 14 | userDbRepositoryMongoDB, 15 | authServiceInterface, 16 | authServiceImpl 17 | ); 18 | 19 | // GET enpdpoints 20 | router.route('/:id').get(authMiddleware, controller.fetchUserById); 21 | router.route('/').get(authMiddleware, controller.fetchUsersByProperty); 22 | 23 | // POST enpdpoints 24 | router.route('/').post(controller.addNewUser); 25 | 26 | return router; 27 | } 28 | -------------------------------------------------------------------------------- /frameworks/webserver/server.js: -------------------------------------------------------------------------------- 1 | const { createTerminus } = require('@godaddy/terminus'); 2 | 3 | export default function serverConfig(app, mongoose, serverInit, config) { 4 | function healthCheck() { 5 | // ERR_CONNECTING_TO_MONGO 6 | if ( 7 | mongoose.connection.readyState === 0 || 8 | mongoose.connection.readyState === 3 9 | ) { 10 | return Promise.reject(new Error('Mongoose has disconnected')); 11 | } 12 | // CONNECTING_TO_MONGO 13 | if (mongoose.connection.readyState === 2) { 14 | return Promise.reject(new Error('Mongoose is connecting')); 15 | } 16 | // CONNECTED_TO_MONGO 17 | return Promise.resolve(); 18 | } 19 | 20 | function onSignal() { 21 | console.log('server is starting cleanup'); 22 | return new Promise((resolve, reject) => { 23 | mongoose 24 | .disconnect(false) 25 | .then(() => { 26 | console.info('Mongoose has disconnected'); 27 | resolve(); 28 | }) 29 | .catch(reject); 30 | }); 31 | } 32 | 33 | function beforeShutdown() { 34 | return new Promise((resolve) => { 35 | setTimeout(resolve, 15000); 36 | }); 37 | } 38 | 39 | function onShutdown() { 40 | console.log('cleanup finished, server is shutting down'); 41 | } 42 | 43 | function startServer() { 44 | createTerminus(serverInit, { 45 | logger: console.log, 46 | signal: 'SIGINT', 47 | healthChecks: { 48 | '/healthcheck': healthCheck 49 | }, 50 | onSignal, 51 | onShutdown, 52 | beforeShutdown 53 | }).listen(config.port, config.ip, () => { 54 | console.log( 55 | 'Express server listening on %d, in %s mode', 56 | config.port, 57 | app.get('env') 58 | ); 59 | }); 60 | } 61 | 62 | return { 63 | startServer 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "app", 5 | "main": "app.js", 6 | "husky": { 7 | "hooks": { 8 | "pre-commit": "npm run lint" 9 | } 10 | }, 11 | "scripts": { 12 | "clean": "rm -rf build && mkdir build", 13 | "build-server": "babel --out-dir ./build . --source-maps --copy-files --ignore 'node_modules/**/*.js'", 14 | "build": "npm run clean && npm run build-server", 15 | "start": "pm2 start ./build/app.js -i ${NODE_PROCESSES} --no-daemon", 16 | "dev": "NODE_ENV=development nodemon --exec babel-node app.js", 17 | "test": "./node_modules/.bin/mocha --require @babel/register './tests/**/*.test.js' --timeout 30000", 18 | "lint": "./node_modules/.bin/eslint --ignore-path .gitignore . --fix" 19 | }, 20 | "author": "panagiop", 21 | "dependencies": { 22 | "@babel/cli": "^7.12.1", 23 | "@babel/core": "^7.12.17", 24 | "@babel/node": "^7.12.17", 25 | "@babel/preset-env": "^7.12.17", 26 | "@babel/runtime": "^7.12.18", 27 | "@godaddy/terminus": "^4.6.0", 28 | "bcryptjs": "^2.4.3", 29 | "body-parser": "^1.19.0", 30 | "compression": "^1.7.4", 31 | "express": "^4.17.1", 32 | "helmet": "^5.0.2", 33 | "jsonwebtoken": "^9.0.0", 34 | "mongoose": "^8.8.3", 35 | "morgan": "^1.10.0", 36 | "pm2": "^5.1.2", 37 | "redis": "^3.0.2" 38 | }, 39 | "devDependencies": { 40 | "@babel/plugin-transform-runtime": "^7.12.17", 41 | "babel-eslint": "^10.1.0", 42 | "chai": "^4.3.0", 43 | "chai-http": "^4.3.0", 44 | "eslint": "^8.8.0", 45 | "eslint-config-airbnb-base": "^14.2.1", 46 | "eslint-config-prettier": "^7.2.0", 47 | "eslint-plugin-import": "^2.22.1", 48 | "eslint-plugin-prettier": "^3.3.1", 49 | "faker": "^5.4.0", 50 | "husky": "^4.3.8", 51 | "mocha": "^9.2.0", 52 | "nodemon": "^2.0.7", 53 | "prettier": "^2.2.1", 54 | "request": "^2.88.2", 55 | "sinon": "^9.2.4" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/entities/post.js: -------------------------------------------------------------------------------- 1 | export default function post({ 2 | title, 3 | description, 4 | createdAt, 5 | isPublished = false, 6 | userId 7 | }) { 8 | return { 9 | getTitle: () => title, 10 | getDescription: () => description, 11 | getCreatedAt: () => createdAt, 12 | isPublished: () => isPublished, 13 | getUserId: () => userId 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/entities/user.js: -------------------------------------------------------------------------------- 1 | export default function user(username, password, email, role, createdAt) { 2 | return { 3 | getUserName: () => username, 4 | getPassword: () => password, 5 | getEmail: () => email, 6 | getRole: () => role, 7 | getCreatedAt: () => createdAt 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /tests/unit/fixtures/posts.js: -------------------------------------------------------------------------------- 1 | export default { 2 | all: { 3 | success: { 4 | res: { 5 | statusCode: 200, 6 | headers: { 7 | 'content-type': 'application/json' 8 | } 9 | }, 10 | body: { 11 | status: 'success', 12 | data: [ 13 | { 14 | isPublished: false, 15 | _id: '5fb1ad6afb45c431a842c394', 16 | title: 'title1', 17 | description: 'description1', 18 | createdAt: '2020-11-15T22:36:26.566Z', 19 | userId: '5fb1ac21fb45c431a842c393' 20 | }, 21 | { 22 | isPublished: false, 23 | _id: '5fb1b12a6ac3e23493ac82e4', 24 | title: 'title667', 25 | description: 'description667', 26 | createdAt: '2020-11-15T22:52:26.194Z', 27 | userId: '5fb1ac21fb45c431a842c393' 28 | } 29 | ] 30 | } 31 | } 32 | }, 33 | single: { 34 | success: { 35 | res: { 36 | statusCode: 200, 37 | headers: { 38 | 'content-type': 'application/json' 39 | } 40 | }, 41 | body: { 42 | status: 'success', 43 | data: [ 44 | { 45 | isPublished: false, 46 | _id: '5fb1ad6afb45c431a842c394', 47 | title: 'title1', 48 | description: 'description1', 49 | createdAt: '2020-11-15T22:36:26.566Z', 50 | userId: '5fb1ac21fb45c431a842c393' 51 | } 52 | ] 53 | } 54 | }, 55 | failure: { 56 | res: { 57 | statusCode: 404, 58 | headers: { 59 | 'content-type': 'application/json' 60 | } 61 | }, 62 | body: { 63 | status: 'error', 64 | message: 'That post does not exist.' 65 | } 66 | } 67 | }, 68 | add: { 69 | success: { 70 | res: { 71 | statusCode: 201, 72 | headers: { 73 | 'content-type': 'application/json' 74 | } 75 | }, 76 | body: { 77 | status: 'success', 78 | data: [ 79 | { 80 | _id: '5fb1ad6afb45c431a842c000', 81 | isPublished: false, 82 | title: 'title3', 83 | description: 'description3', 84 | createdAt: '2020-11-15T22:36:26.566Z', 85 | userId: '5fb1ac21fb45c431a842c393' 86 | } 87 | ] 88 | } 89 | } 90 | }, 91 | update: { 92 | success: { 93 | res: { 94 | statusCode: 200, 95 | headers: { 96 | 'content-type': 'application/json' 97 | } 98 | }, 99 | body: { 100 | status: 'success', 101 | data: [ 102 | { 103 | isPublished: false, 104 | title: 'title3', 105 | description: 'description3updated', 106 | createdAt: '2020-11-15T22:36:26.566Z', 107 | userId: '5fb1ac21fb45c431a842c393' 108 | } 109 | ] 110 | } 111 | } 112 | } 113 | }; 114 | -------------------------------------------------------------------------------- /tests/unit/post/api/api.spec.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import sinon from 'sinon'; 3 | import request from 'request'; 4 | import chai from 'chai'; 5 | 6 | import posts from '../../fixtures/posts'; 7 | /* eslint no-unused-vars: "off" */ 8 | const should = chai.should(); 9 | 10 | const base = 'http://localhost:1234'; 11 | 12 | describe('API', () => { 13 | let getStub = null; 14 | let postStub = null; 15 | let putStub = null; 16 | 17 | beforeEach(() => { 18 | getStub = sinon.stub(request, 'get'); 19 | postStub = sinon.stub(request, 'post'); 20 | putStub = sinon.stub(request, 'put'); 21 | }); 22 | afterEach(() => { 23 | request.get.restore(); 24 | request.post.restore(); 25 | request.put.restore(); 26 | }); 27 | 28 | describe('GET /api/v1/posts', () => { 29 | it('should return all posts', (done) => { 30 | getStub.yields( 31 | null, 32 | posts.all.success.res, 33 | JSON.stringify(posts.all.success.body) 34 | ); 35 | request.get(`${base}/api/v1/post`, (err, res, body) => { 36 | // there should be a 200 status code 37 | res.statusCode.should.eql(200); 38 | // the response should be JSON 39 | res.headers['content-type'].should.contain('application/json'); 40 | // parse response body 41 | body = JSON.parse(body); 42 | // the JSON response body should have a 43 | // key-value pair of {"status": "success"} 44 | body.status.should.eql('success'); 45 | // key-value pair of {"data": [3 posts objects]} 46 | body.data.length.should.eql(2); 47 | // the first object should have the right value for name 48 | body.data[0].title.should.eql('title1'); 49 | done(); 50 | }); 51 | }); 52 | }); 53 | 54 | describe('GET /api/v1/post/:id', () => { 55 | it('should return a specific post', (done) => { 56 | const obj = posts.single.success; 57 | getStub.yields(null, obj.res, JSON.stringify(obj.body)); 58 | request.get( 59 | `${base}/api/v1/posts/5fb1ad6afb45c431a842c394`, 60 | (err, res, body) => { 61 | res.statusCode.should.equal(200); 62 | res.headers['content-type'].should.contain('application/json'); 63 | body = JSON.parse(body); 64 | body.status.should.eql('success'); 65 | body.data[0].title.should.eql('title1'); 66 | done(); 67 | } 68 | ); 69 | }); 70 | it('should throw an error if the post does not exist', (done) => { 71 | const obj = posts.single.failure; 72 | getStub.yields(null, obj.res, JSON.stringify(obj.body)); 73 | request.get( 74 | `${base}/api/v1/posts/5fb1ad6afb45c431a842c666`, 75 | (err, res, body) => { 76 | res.statusCode.should.equal(404); 77 | res.headers['content-type'].should.contain('application/json'); 78 | body = JSON.parse(body); 79 | body.status.should.eql('error'); 80 | body.message.should.eql('That post does not exist.'); 81 | done(); 82 | } 83 | ); 84 | }); 85 | }); 86 | 87 | describe('POST /api/v1/posts', () => { 88 | it('should return the post that was added', (done) => { 89 | const options = { 90 | body: { 91 | isPublished: false, 92 | title: 'title3', 93 | description: 'description3', 94 | createdAt: '2020-11-15T22:36:26.566Z', 95 | userId: '5fb1ac21fb45c431a842c393' 96 | }, 97 | json: true, 98 | url: `${base}/api/v1/posts` 99 | }; 100 | const obj = posts.add.success; 101 | postStub.yields(null, obj.res, JSON.stringify(obj.body)); 102 | request.post(options, (err, res, body) => { 103 | res.statusCode.should.equal(201); 104 | res.headers['content-type'].should.contain('application/json'); 105 | body = JSON.parse(body); 106 | body.status.should.eql('success'); 107 | body.data[0].title.should.eql('title3'); 108 | done(); 109 | }); 110 | }); 111 | }); 112 | 113 | describe('PUT /api/v1/posts', () => { 114 | it('should return the post that was updated', (done) => { 115 | const options = { 116 | body: { description: 'description3updated' }, 117 | json: true, 118 | url: `${base}/api/v1/posts/5fb1ad6afb45c431a842c000` 119 | }; 120 | const obj = posts.update.success; 121 | putStub.yields(null, obj.res, JSON.stringify(obj.body)); 122 | request.put(options, (err, res, body) => { 123 | res.statusCode.should.equal(200); 124 | res.headers['content-type'].should.contain('application/json'); 125 | body = JSON.parse(body); 126 | body.status.should.eql('success'); 127 | body.data[0].description.should.eql('description3updated'); 128 | done(); 129 | }); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /tests/unit/post/use_cases/use_cases.spec.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import chai from 'chai'; 3 | import sinon from 'sinon'; 4 | import faker from 'faker'; 5 | 6 | import post from '../../../../src/entities/post'; 7 | import addPost from '../../../../application/use_cases/post/add'; 8 | import findAll from '../../../../application/use_cases/post/findAll'; 9 | import findById from '../../../../application/use_cases/post/findById'; 10 | import postDbRepository from '../../../../application/repositories/postDbRepository'; 11 | 12 | const { expect } = chai; 13 | 14 | let dbRepository = null; 15 | 16 | describe('Use cases', () => { 17 | beforeEach(() => { 18 | dbRepository = postDbRepository(); 19 | }); 20 | 21 | describe('Fetch a specific post', () => { 22 | it('should fetch a post by id', () => { 23 | const stubPost = { 24 | title: faker.name.findName(), 25 | description: faker.name.findName(), 26 | createdAt: faker.date.past(), 27 | isPublished: false, 28 | userId: faker.random.uuid() 29 | }; 30 | const correspondingPost = post({ 31 | title: stubPost.title, 32 | description: stubPost.description, 33 | createdAt: stubPost.createdAt, 34 | isPublished: stubPost.isPublished, 35 | userId: stubPost.userId, 36 | postRepository: dbRepository 37 | }); 38 | const stubRepositoryFindById = sinon 39 | .stub(dbRepository, 'findById') 40 | .returns(correspondingPost); 41 | const fetchedPost = findById('5fb1b12a6ac3e23493ac82e4', dbRepository); 42 | expect(stubRepositoryFindById.calledOnce).to.be.true; 43 | sinon.assert.calledWith( 44 | stubRepositoryFindById, 45 | '5fb1b12a6ac3e23493ac82e4' 46 | ); 47 | expect(fetchedPost).to.eql(correspondingPost); 48 | }); 49 | }); 50 | 51 | describe('Fetch all posts', () => { 52 | it('should fetch all the posts succesfully', () => { 53 | const stubRepositoryFetchAll = sinon 54 | .stub(dbRepository, 'findAll') 55 | .returns(['post1', 'post2']); 56 | const posts = findAll('602c13e0cfe08b794e1b287b', dbRepository); 57 | expect(stubRepositoryFetchAll.calledOnce).to.be.true; 58 | expect(posts).to.eql(['post1', 'post2']); 59 | }); 60 | }); 61 | 62 | describe('Add new post', () => { 63 | it('should add a new post succesfully', () => { 64 | const stubValue = { 65 | title: faker.name.findName(), 66 | description: faker.name.findName(), 67 | createdAt: faker.date.past(), 68 | isPublished: false, 69 | userId: faker.random.uuid() 70 | }; 71 | const pesristedPost = post({ 72 | title: stubValue.title, 73 | description: stubValue.description, 74 | createdAt: stubValue.createdAt, 75 | isPublished: stubValue.isPublished, 76 | userId: stubValue.userId 77 | }); 78 | const stubRepositoryAdd = sinon 79 | .stub(dbRepository, 'add') 80 | .returns(pesristedPost); 81 | const newPost = addPost({ 82 | title: stubValue.title, 83 | description: stubValue.description, 84 | createdAt: stubValue.createdAt, 85 | isPublished: stubValue.isPublished, 86 | userId: stubValue.userId, 87 | postRepository: dbRepository 88 | }); 89 | expect(stubRepositoryAdd.calledOnce).to.be.true; 90 | expect(newPost.getTitle()).equals(stubValue.title); 91 | expect(newPost.getDescription()).equals(stubValue.description); 92 | expect(newPost.getCreatedAt()).equals(stubValue.createdAt); 93 | expect(newPost.isPublished()).equals(stubValue.isPublished); 94 | expect(newPost.getUserId()).equals(stubValue.userId); 95 | }); 96 | }); 97 | }); 98 | --------------------------------------------------------------------------------