├── .nvmrc ├── api ├── application │ ├── models │ │ ├── validators │ │ │ └── index.js │ │ ├── request-schemas.js │ │ └── response-error.js │ ├── index.js │ ├── translators │ │ ├── tweet-translator.js │ │ └── error-translator.js │ ├── like-tweet.js │ ├── create-tweet.js │ ├── middlewares │ │ ├── error.js │ │ ├── request-validation.js │ │ ├── error.spec.js │ │ └── request-validation.spec.js │ ├── top-liked-tweets.js │ └── routes.js ├── index.js ├── commons │ └── index.js ├── usecases │ ├── like-tweet.js │ ├── create-tweet.js │ ├── top-liked-tweets.js │ ├── create-tweet.spec.js │ └── like-tweet.spec.js ├── adapters │ ├── redis-cache.js │ ├── mongodb-tweet-repository.js │ ├── redis-cache.spec.js │ └── mongodb-tweet-repository.spec.js └── domain │ ├── tweet.js │ └── error.js ├── config ├── environments │ ├── dev.env │ └── test.env ├── index.js └── env-loader.js ├── infrastructure ├── logger │ └── index.js ├── mongodb │ └── index.js └── redis │ └── index.js ├── .editorconfig ├── tests ├── helpers │ ├── index.js │ ├── utils.js │ └── server.js └── api │ └── create-tweet.spec.js ├── server ├── server.js ├── app.js └── index.js ├── .nycrc ├── .eslintrc ├── .gitignore ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/fermium 2 | -------------------------------------------------------------------------------- /api/application/models/validators/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /config/environments/dev.env: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | 3 | LOG_LEVEL=debug 4 | LOG_PRETTY=true 5 | -------------------------------------------------------------------------------- /config/environments/test.env: -------------------------------------------------------------------------------- 1 | PORT=0 2 | 3 | LOG_LEVEL=debug 4 | LOG_PRETTY=true 5 | -------------------------------------------------------------------------------- /api/application/index.js: -------------------------------------------------------------------------------- 1 | const Routes = require('./routes') 2 | 3 | module.exports = Routes 4 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | const Application = require('./application') 2 | 3 | module.exports = Application 4 | -------------------------------------------------------------------------------- /infrastructure/logger/index.js: -------------------------------------------------------------------------------- 1 | const pino = require('pino') 2 | const config = require('_config').logging 3 | 4 | module.exports = pino(config) 5 | -------------------------------------------------------------------------------- /api/commons/index.js: -------------------------------------------------------------------------------- 1 | const awaitAll = Promise.all.bind(Promise) 2 | 3 | const resolve = Promise.resolve.bind(Promise) 4 | 5 | const reject = Promise.reject.bind(Promise) 6 | 7 | module.exports = { 8 | awaitAll, 9 | resolve, 10 | reject, 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | indent_style = space 9 | 10 | [*.{js,jsx,html,json,yml}] 11 | indent_size = 2 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /api/usecases/like-tweet.js: -------------------------------------------------------------------------------- 1 | const like = tweetRepository => id => 2 | tweetRepository.increment(id, 'likes.amount') 3 | 4 | const LikeTweet = ({ tweetRepository }) => ({ 5 | /** 6 | * @param {String} id 7 | * @returns {Promise.} 8 | */ 9 | like: like(tweetRepository), 10 | }) 11 | 12 | module.exports = LikeTweet 13 | -------------------------------------------------------------------------------- /tests/helpers/index.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai') 2 | const promised = require('chai-as-promised') 3 | const sinon = require('sinon') 4 | const sinonChai = require('sinon-chai') 5 | 6 | chai.use(promised) 7 | chai.use(sinonChai) 8 | chai.should() 9 | 10 | global.expect = chai.expect 11 | global.assert = chai.assert 12 | global.sinon = sinon 13 | -------------------------------------------------------------------------------- /api/usecases/create-tweet.js: -------------------------------------------------------------------------------- 1 | const create = tweetRepository => tweet => 2 | tweetRepository.add(tweet) 3 | 4 | const CreateTweet = ({ tweetRepository }) => ({ 5 | /** 6 | * @param {Tweet} tweetRepository 7 | * @returns {Promise.} saved tweet 8 | */ 9 | create: create(tweetRepository), 10 | }) 11 | 12 | module.exports = CreateTweet 13 | -------------------------------------------------------------------------------- /api/adapters/redis-cache.js: -------------------------------------------------------------------------------- 1 | const Cache = ({ redis }) => { 2 | const put = (key, value, expiration = 0) => 3 | expiration 4 | ? redis.set(key, value, 'EX', expiration) 5 | : redis.set(key, value) 6 | 7 | const get = key => 8 | redis.get(key) 9 | 10 | return { 11 | put, 12 | get, 13 | } 14 | } 15 | 16 | module.exports = Cache 17 | -------------------------------------------------------------------------------- /api/application/models/request-schemas.js: -------------------------------------------------------------------------------- 1 | const createTweet = { 2 | text: { 3 | in: 'body', 4 | notEmpty: true, 5 | isLength: { 6 | options: [{ min: 1, max: 140 }], 7 | }, 8 | }, 9 | } 10 | 11 | const likeTweet = { 12 | id: { 13 | in: 'params', 14 | notEmpty: true, 15 | isUUID: true, 16 | }, 17 | } 18 | 19 | module.exports = { 20 | createTweet, 21 | likeTweet, 22 | } 23 | -------------------------------------------------------------------------------- /api/application/models/response-error.js: -------------------------------------------------------------------------------- 1 | /** 2 | * HTTP Response value object 3 | * 4 | * @param {String} code application error code 5 | * @param {Number} status http status code 6 | * @param {String} message text message 7 | */ 8 | const ErrorResponse = (code, status, message) => ({ 9 | status, 10 | body: { 11 | statusCode: status, 12 | error: code, 13 | message, 14 | }, 15 | }) 16 | 17 | module.exports = ErrorResponse 18 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const HttpApp = require('./app') 2 | const config = require('_config').api 3 | 4 | const HttpServer = infrastructure => { 5 | const app = HttpApp(infrastructure) 6 | 7 | const start = () => 8 | new Promise((resolve, reject) => 9 | app.listen(config.port) 10 | .once('listening', () => resolve(app)) 11 | .once('error', reject) 12 | ) 13 | 14 | return { 15 | start, 16 | } 17 | } 18 | 19 | module.exports = HttpServer 20 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa') 2 | const Router = require('koa-router') 3 | const bodyParser = require('koa-bodyparser') 4 | const Api = require('_api') 5 | 6 | const HttpApp = (infrastructure) => { 7 | const app = new Koa() 8 | const router = new Router() 9 | const api = Api(infrastructure) 10 | 11 | router.use('/api', api.routes()) 12 | 13 | app.use(bodyParser()) 14 | app.use(router.routes()) 15 | 16 | return app 17 | } 18 | 19 | module.exports = HttpApp 20 | -------------------------------------------------------------------------------- /infrastructure/mongodb/index.js: -------------------------------------------------------------------------------- 1 | const { MongoClient } = require('mongodb') 2 | const config = require('_config').mongodb 3 | 4 | const client = new MongoClient(config.url, config.connectionOptions) 5 | 6 | const connect = () => 7 | client.connect() 8 | .then(() => client.db(config.database)) 9 | 10 | const close = () => 11 | client.close() 12 | 13 | const isConnected = () => 14 | client.isConnected() 15 | 16 | module.exports = { 17 | connect, 18 | close, 19 | isConnected, 20 | } 21 | -------------------------------------------------------------------------------- /tests/helpers/utils.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | 3 | const commons = require('_api/commons') 4 | 5 | const deleteCollection = collection => 6 | collection.deleteMany({}) 7 | 8 | const deleteCollections = R.pipe( 9 | R.map(deleteCollection), 10 | commons.awaitAll 11 | ) 12 | 13 | const cleanDataBase = mongoDb => 14 | mongoDb 15 | .collections() 16 | .then(deleteCollections) 17 | 18 | const cleanCache = redis => 19 | redis.flushdb() 20 | 21 | module.exports = { 22 | ...commons, 23 | cleanDataBase, 24 | cleanCache, 25 | } 26 | -------------------------------------------------------------------------------- /api/application/translators/tweet-translator.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | 3 | const { Tweet } = require('_domain/tweet') 4 | 5 | /** 6 | * @param {Object} tweet 7 | * @param {String} tweet.text 8 | */ 9 | const toDomainModel = ({ text }) => 10 | Tweet({ text }) 11 | 12 | const toApplication = ({ id, text, likes: { amount } }) => ({ 13 | id, 14 | text, 15 | likes: amount, 16 | }) 17 | 18 | const toApplicationList = R.map(toApplication) 19 | 20 | module.exports = { 21 | toDomainModel, 22 | toApplication, 23 | toApplicationList, 24 | } 25 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "instrument": true, 4 | "extension": [ 5 | "js" 6 | ], 7 | "reporter": [ 8 | "lcov", 9 | "text" 10 | ], 11 | "exclude": [ 12 | "coverage", 13 | "server/**", 14 | "config/**", 15 | "tests/**/*", 16 | "**/*.spec.js", 17 | "lcov-report/**/*", 18 | "scripts" 19 | ], 20 | "check-coverage": true, 21 | "branches": 80, 22 | "lines": 80, 23 | "functions": 80, 24 | "statements": 80, 25 | "watermarks": { 26 | "lines": [80, 95], 27 | "functions": [80, 95], 28 | "branches": [80, 95], 29 | "statements": [80, 95] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /infrastructure/redis/index.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | const Redis = require('ioredis') 3 | 4 | const config = require('_config').redis 5 | 6 | const redis = new Redis( 7 | config.port, 8 | config.host, 9 | { 10 | lazyConnect: true, 11 | keyPrefix: config.keyPrefix, 12 | db: config.db, 13 | } 14 | ) 15 | 16 | const connect = () => 17 | redis 18 | .connect() 19 | .then(R.always(redis)) 20 | 21 | const close = () => 22 | redis.quit() 23 | 24 | const isConnected = () => 25 | redis.status === 'ready' 26 | 27 | module.exports = { 28 | connect, 29 | close, 30 | isConnected, 31 | } 32 | -------------------------------------------------------------------------------- /api/domain/tweet.js: -------------------------------------------------------------------------------- 1 | const uuid = require('uuid') 2 | const moment = require('moment') 3 | 4 | /** 5 | * @param {Number} amount number of likes 6 | * @returns Like value object 7 | */ 8 | const Like = amount => ({ 9 | amount, 10 | }) 11 | 12 | /** @typedef {String} UUID */ 13 | 14 | /** 15 | * @param {Object} options 16 | * @param {UUID} options.id 17 | * @param {String} options.text 18 | * @param {Number} options.likes 19 | * @returns Tweet entity 20 | */ 21 | const Tweet = ({ id, text, likes, createdAt }) => ({ 22 | text, 23 | likes: Like(likes || 0), 24 | createdAt: createdAt || moment.utc().toISOString(), 25 | id: id || uuid.v4(), 26 | }) 27 | 28 | module.exports = { 29 | Tweet, 30 | Like, 31 | } 32 | -------------------------------------------------------------------------------- /api/application/like-tweet.js: -------------------------------------------------------------------------------- 1 | const LikeTweetUsecase = require('_usecases/like-tweet') 2 | const TweetRepository = require('_adapters/mongodb-tweet-repository') 3 | const { toApplication } = require('./translators/tweet-translator') 4 | 5 | const tweetLiked = ctx => tweet => { 6 | ctx.status = 200 7 | ctx.body = tweet 8 | } 9 | 10 | const LikeTweet = ({ mongoDb }) => { 11 | const tweetRepository = TweetRepository({ mongoDb }) 12 | const likeTweetUsecase = LikeTweetUsecase({ tweetRepository }) 13 | 14 | const like = ctx => 15 | likeTweetUsecase 16 | .like(ctx.params.id) 17 | .then(toApplication) 18 | .then(tweetLiked(ctx)) 19 | 20 | return { 21 | like, 22 | } 23 | } 24 | 25 | module.exports = LikeTweet 26 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | require('./env-loader').load(process.env.NODE_ENV) 2 | 3 | module.exports = { 4 | api: { 5 | port: process.env.PORT || 3000, 6 | }, 7 | logging: { 8 | name: 'rs-ws-env', 9 | level: process.env.LOG_LEVEL || 'info', 10 | prettyPrint: process.env.LOG_PRETTY === 'true', 11 | }, 12 | mongodb: { 13 | url: process.env.MONGODB_URL, 14 | database: process.env.MONGODB_DATABASE, 15 | connectionOptions: { 16 | useNewUrlParser: true, 17 | useUnifiedTopology: true, 18 | poolSize: parseInt(process.env.MONGODB_POOL_SIZE || 10), 19 | }, 20 | }, 21 | redis: { 22 | host: process.env.REDIS_HOST, 23 | port: parseInt(process.env.REDIS_PORT), 24 | db: parseInt(process.env.REDIS_DB), 25 | keyPrefix: process.env.REDIS_KEY_PREFIX || `${process.env.NODE_ENV}:`, 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /api/application/create-tweet.js: -------------------------------------------------------------------------------- 1 | const CreateTweetUsecase = require('_usecases/create-tweet') 2 | const TweetRepository = require('_adapters/mongodb-tweet-repository') 3 | const { toDomainModel, toApplication } = require('./translators/tweet-translator') 4 | 5 | const tweetCreated = ctx => tweet => { 6 | ctx.status = 201 7 | ctx.body = tweet 8 | } 9 | 10 | const CreateTweet = ({ mongoDb }) => { 11 | const tweetRepository = TweetRepository({ mongoDb }) 12 | const createTweetUsecase = CreateTweetUsecase({ tweetRepository }) 13 | 14 | const create = ctx => { 15 | const tweet = toDomainModel(ctx.request.body) 16 | return createTweetUsecase 17 | .create(tweet) 18 | .then(toApplication) 19 | .then(tweetCreated(ctx)) 20 | } 21 | 22 | return { 23 | create, 24 | } 25 | } 26 | 27 | module.exports = CreateTweet 28 | -------------------------------------------------------------------------------- /api/application/middlewares/error.js: -------------------------------------------------------------------------------- 1 | const logger = require('_infrastructure/logger') 2 | const errorTranslator = require('_application/translators/error-translator') 3 | 4 | const handleError = ctx => error => { 5 | logger.error(error, 'Failed processing the request') 6 | const response = errorTranslator.toApplication(error) 7 | ctx.status = response.status 8 | ctx.body = response.body 9 | ctx.body.cause = error.cause 10 | } 11 | 12 | /** 13 | * Error handling middleware. 14 | * Should be mounted at the top of the application router. 15 | * 16 | * @async 17 | * @name error 18 | * @param {Object} ctx context from koa 19 | * @param {function(): Promise} next middleware continuation function 20 | * @returns {Promise} 21 | */ 22 | const error = (ctx, next) => 23 | next() 24 | .catch(handleError(ctx)) 25 | 26 | module.exports = error 27 | -------------------------------------------------------------------------------- /api/application/top-liked-tweets.js: -------------------------------------------------------------------------------- 1 | const TopLikedTweetsUseCase = require('_usecases/top-liked-tweets') 2 | const TweetRepository = require('_adapters/mongodb-tweet-repository') 3 | const Cache = require('_adapters/redis-cache') 4 | const { toApplicationList } = require('./translators/tweet-translator') 5 | 6 | const tweetsFound = ctx => tweets => { 7 | ctx.status = 200 8 | ctx.body = tweets 9 | } 10 | 11 | const TopLikedTweets = ({ mongoDb, redis }) => { 12 | const tweetRepository = TweetRepository({ mongoDb }) 13 | const cache = Cache({ redis }) 14 | const useCase = TopLikedTweetsUseCase({ tweetRepository, cache }) 15 | 16 | const topLiked = ctx => 17 | useCase 18 | .topLiked() 19 | .then(toApplicationList) 20 | .then(tweetsFound(ctx)) 21 | 22 | return { 23 | topLiked, 24 | } 25 | } 26 | 27 | module.exports = TopLikedTweets 28 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const HttpServer = require('./server') 2 | 3 | const config = require('_config') 4 | const logger = require('_infrastructure/logger') 5 | 6 | const mongodb = require('_infrastructure/mongodb') 7 | const redisClient = require('_infrastructure/redis') 8 | 9 | const connectInfrastructure = () => 10 | Promise 11 | .all([ 12 | mongodb.connect(), 13 | redisClient.connect(), 14 | ]) 15 | .then(([mongoDb, redis]) => ({ 16 | mongoDb, 17 | redis, 18 | })) 19 | 20 | const startServer = infrastructure => 21 | HttpServer(infrastructure).start() 22 | 23 | connectInfrastructure() 24 | .then(startServer) 25 | .then(() => 26 | logger.info(`Application started successfully in port ${config.api.port}`) 27 | ) 28 | .catch(error => { 29 | logger.error(error, 'Failed starting the application. Terminating process') 30 | process.exit(1) 31 | }) 32 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "standard" ], 3 | "plugins": [ "json" ], 4 | "rules": { 5 | "complexity": ["error", 5], 6 | "eqeqeq": "error", 7 | "max-statements": [ "error", { "max": 15 } ], 8 | "comma-dangle": ["error", { 9 | "arrays": "always-multiline", 10 | "objects": "always-multiline", 11 | "imports": "always-multiline", 12 | "exports": "always", 13 | "functions": "never" 14 | }] 15 | }, 16 | "overrides": [ 17 | { 18 | "files": [ 19 | "*.spec.js", 20 | "tests/**/*.js" 21 | ], 22 | "rules": { 23 | "no-unused-expressions": 0 24 | }, 25 | "globals": { 26 | "it": true, 27 | "describe": true, 28 | "expect": true, 29 | "sinon": true, 30 | "before": true, 31 | "beforeEach": true, 32 | "after": true, 33 | "afterEach": true, 34 | "context": true 35 | } 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /api/application/translators/error-translator.js: -------------------------------------------------------------------------------- 1 | const { catalogue: errors } = require('_domain/error') 2 | const ErrorResponse = require('_application/models/response-error') 3 | 4 | /** 5 | * Table that maps error codes to their respectve responses. 6 | * 7 | * @name responses 8 | */ 9 | const responsesTable = { 10 | [errors.INVALID_REQUEST]: ErrorResponse(errors.INVALID_REQUEST, 400, 'Invalid request parameters'), 11 | [errors.INTERNAL]: ErrorResponse(errors.INVALID_REQUEST, 500, 'Internal server error'), 12 | [errors.TWEET_NOT_FOUND]: ErrorResponse(errors.TWEET_NOT_FOUND, 404, 'Tweet not found'), 13 | } 14 | 15 | /** @typedef {{code: string}} ApplicationError */ 16 | 17 | /** 18 | * @param {ApplicationError} applicationError 19 | * @returns serialized response error 20 | */ 21 | const toApplication = applicationError => 22 | responsesTable[applicationError.code] || responsesTable[errors.INTERNAL] 23 | 24 | module.exports = { 25 | responsesTable, 26 | toApplication, 27 | } 28 | -------------------------------------------------------------------------------- /config/env-loader.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path') 2 | 3 | const DEV_ENVIRONMENTS = ['dev', 'development', 'local', 'debug'] 4 | const TEST_ENVIRONMENTS = ['test'] 5 | const PRODUCTION_ENVIRONMENTS = ['prod', 'production'] 6 | 7 | const includeIgnoresCase = (list, value) => 8 | list.some(e => 9 | Boolean(value) && e.toUpperCase() === value.toUpperCase()) 10 | 11 | const loadDotEnv = nodeEnv => { 12 | const dotenv = require('dotenv') 13 | 14 | if (includeIgnoresCase(DEV_ENVIRONMENTS, nodeEnv)) { 15 | return dotenv.config({ 16 | path: join(__dirname, 'environments', 'dev.env'), 17 | }) 18 | } 19 | if (includeIgnoresCase(TEST_ENVIRONMENTS, nodeEnv)) { 20 | return dotenv.config({ 21 | path: join(__dirname, 'environments', 'test.env'), 22 | }) 23 | } 24 | } 25 | 26 | const load = nodeEnv => { 27 | if (PRODUCTION_ENVIRONMENTS.includes(nodeEnv)) { 28 | return 29 | } 30 | loadDotEnv(nodeEnv) 31 | } 32 | 33 | module.exports = { 34 | load, 35 | } 36 | -------------------------------------------------------------------------------- /api/usecases/top-liked-tweets.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | 3 | const CACHE_KEY = 'TOP_LIKED_TWEETS' 4 | const CACHE_EXPIRATION_SECONDS = 60 5 | 6 | const serialize = R.unary(JSON.stringify) 7 | 8 | const deserialize = R.unary(JSON.parse) 9 | 10 | const deserializeCacheValue = R.ifElse( 11 | R.not, 12 | R.always([]), 13 | deserialize 14 | ) 15 | 16 | const TopLikedTweets = ({ tweetRepository, cache }) => { 17 | const getFromCache = () => 18 | cache 19 | .get(CACHE_KEY) 20 | .then(deserializeCacheValue) 21 | 22 | const putInCache = data => 23 | cache 24 | .put(CACHE_KEY, serialize(data), CACHE_EXPIRATION_SECONDS) 25 | .then(R.always(data)) 26 | 27 | const fetchAndCache = () => 28 | tweetRepository 29 | .orderBy('likes.amount', 5) 30 | .then(putInCache) 31 | 32 | const topLiked = () => 33 | getFromCache() 34 | .then(R.when(R.isEmpty, fetchAndCache)) 35 | 36 | return { 37 | topLiked, 38 | } 39 | } 40 | 41 | module.exports = TopLikedTweets 42 | -------------------------------------------------------------------------------- /api/usecases/create-tweet.spec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon') 2 | const faker = require('faker') 3 | const { resolve } = require('_api/commons') 4 | const { Tweet } = require('_domain/tweet') 5 | const CreateTweet = require('./create-tweet') 6 | 7 | describe('create-tweet usecase test', () => { 8 | let sandbox 9 | beforeEach(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | 16 | describe('#create test', () => { 17 | context('When given a tweet', () => { 18 | it('Adds it to the repository', async () => { 19 | const tweet = Tweet({ 20 | text: faker.lorem.sentence(), 21 | }) 22 | const tweetRepository = ({ 23 | add: sandbox.stub().callsFake(resolve), 24 | }) 25 | const useCase = CreateTweet({ tweetRepository }) 26 | 27 | const result = await useCase.create(tweet) 28 | 29 | expect(result).to.deep.equal(tweet) 30 | expect(tweetRepository.add).to.have.been.calledOnceWith(tweet) 31 | }) 32 | }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /tests/helpers/server.js: -------------------------------------------------------------------------------- 1 | const supertest = require('supertest') 2 | 3 | const config = require('_config') 4 | const app = require('_server/app') 5 | const mongoClient = require('_infrastructure/mongodb') 6 | const redisClient = require('_infrastructure/redis') 7 | 8 | const { cleanDataBase, cleanCache } = require('./utils') 9 | 10 | let _server 11 | let _api 12 | let _mongoDb 13 | let _redis 14 | 15 | before(async () => { 16 | [_mongoDb, _redis] = await Promise.all([mongoClient.connect(), redisClient.connect()]) 17 | _server = app({ mongoDb: _mongoDb, redis: _redis }).listen(config.api.port) 18 | _api = supertest(_server) 19 | }) 20 | 21 | after(() => 22 | Promise.all([ 23 | mongoClient.close(), 24 | redisClient.close(), 25 | _server.close(), 26 | ]) 27 | ) 28 | 29 | afterEach(() => 30 | Promise.all([ 31 | (_mongoDb && cleanDataBase(_mongoDb)), 32 | (_redis && cleanCache(_redis)), 33 | ]) 34 | ) 35 | 36 | /** 37 | * Supertest instance connected to the application server. 38 | * You can use it for executing integration tests. 39 | */ 40 | const api = () => _api 41 | 42 | module.exports = api 43 | -------------------------------------------------------------------------------- /api/application/routes.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | const validator = require('koa-async-validator') 3 | 4 | // middlewares 5 | const error = require('./middlewares/error') 6 | const validate = require('./middlewares/request-validation') 7 | const customValidators = require('./models/validators') 8 | const schemas = require('./models/request-schemas') 9 | 10 | // application 11 | const CreateTweet = require('./create-tweet') 12 | const LikeTweet = require('./like-tweet') 13 | const TopLikedTweets = require('./top-liked-tweets') 14 | 15 | const Routes = infrastructure => { 16 | const router = new Router() 17 | 18 | router.use(error) 19 | router.use(validator({ customValidators })) 20 | 21 | const createTweet = CreateTweet(infrastructure) 22 | const likeTweet = LikeTweet(infrastructure) 23 | const topLikedTweets = TopLikedTweets(infrastructure) 24 | 25 | router.post('/tweets', validate(schemas.createTweet), createTweet.create) 26 | router.put('/tweets/:id/likes', validate(schemas.likeTweet), likeTweet.like) 27 | router.get('/tweets', topLikedTweets.topLiked) 28 | 29 | return router 30 | } 31 | 32 | module.exports = Routes 33 | -------------------------------------------------------------------------------- /api/usecases/like-tweet.spec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon') 2 | const uuid = require('uuid') 3 | const faker = require('faker') 4 | const { Tweet } = require('_domain/tweet') 5 | const LikeTweet = require('./like-tweet') 6 | 7 | describe('create-tweet usecase test', () => { 8 | let sandbox 9 | beforeEach(() => { 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | 16 | describe('#create test', () => { 17 | context('When given a tweet', () => { 18 | it('Adds it to the repository', async () => { 19 | const id = uuid.v4() 20 | const savedTweet = Tweet({ 21 | id, 22 | likes: 1, 23 | text: faker.lorem.sentence(), 24 | }) 25 | const tweetRepository = ({ 26 | increment: sandbox.stub().resolves(savedTweet), 27 | }) 28 | const useCase = LikeTweet({ tweetRepository }) 29 | 30 | const result = await useCase.like(id) 31 | 32 | expect(result).to.deep.equal(savedTweet) 33 | expect(tweetRepository.increment).to.have.been.calledOnceWith(id, 'likes.amount') 34 | }) 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /api/application/middlewares/request-validation.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | const { catalogue, rejectWithApplicationError } = require('_domain/error') 3 | 4 | const joinMessages = R.pipe( 5 | R.map(R.prop('msg')), 6 | R.join(', ') 7 | ) 8 | 9 | const buildErrorCause = R.pipe( 10 | R.groupBy(R.prop('param')), 11 | R.toPairs, 12 | R.map(R.evolve({ 1: joinMessages })), 13 | R.map(R.join(': ')) 14 | ) 15 | 16 | const handleValidationErrors = R.pipe( 17 | buildErrorCause, 18 | R.objOf('cause'), 19 | R.assoc('code', catalogue.INVALID_REQUEST), 20 | rejectWithApplicationError 21 | ) 22 | 23 | /** 24 | * Creates request validation middleware that validates the request against the provided schema. 25 | * 26 | * @async 27 | * @name validate 28 | * @param {Object} schema request definition schema 29 | * @returns {function(Object, Function): Promise} validation middlewares: uses koa-async-validator applying the schema to the context: 30 | * - verifies if the are validation errors in the context 31 | * - throws INVALID_REQUEST error if there are 32 | */ 33 | const validate = schema => (ctx, next) => { 34 | ctx.check(schema) 35 | return ctx 36 | .validationErrors() 37 | .then(e => 38 | R.ifElse( 39 | R.identity, 40 | handleValidationErrors, 41 | next 42 | )(e)) 43 | } 44 | 45 | module.exports = validate 46 | -------------------------------------------------------------------------------- /api/adapters/mongodb-tweet-repository.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | 3 | const { rejectWithApplicationError, catalogue } = require('_domain/error') 4 | 5 | const encodeTweet = ({ id, ...tweet }) => ({ 6 | ...tweet, 7 | _id: id, 8 | }) 9 | 10 | const decodeTweet = ({ _id, ...tweet }) => ({ 11 | ...tweet, 12 | id: _id, 13 | }) 14 | 15 | const getInsertedDocument = R.path(['ops', 0]) 16 | 17 | const tweetNotFound = () => 18 | rejectWithApplicationError({ code: catalogue.TWEET_NOT_FOUND }) 19 | 20 | const getUpdatedDocument = R.pipe( 21 | R.prop('value'), 22 | R.when(R.not, tweetNotFound) 23 | ) 24 | 25 | const TweetRepository = ({ mongoDb }) => { 26 | const add = tweet => 27 | mongoDb 28 | .collection('tweet') 29 | .insertOne(encodeTweet(tweet)) 30 | .then(getInsertedDocument) 31 | .then(decodeTweet) 32 | 33 | const increment = (id, field) => 34 | mongoDb 35 | .collection('tweet') 36 | .findOneAndUpdate( 37 | { _id: id }, 38 | { $inc: { [field]: 1 } }, 39 | { returnOriginal: false } 40 | ) 41 | .then(getUpdatedDocument) 42 | .then(decodeTweet) 43 | 44 | const orderBy = (field, limit, ascending = false) => 45 | mongoDb 46 | .collection('tweet') 47 | .find() 48 | .limit(limit) 49 | .sort({ [field]: ascending ? 1 : -1 }) 50 | .toArray() 51 | .then(R.map(decodeTweet)) 52 | 53 | return { 54 | add, 55 | increment, 56 | orderBy, 57 | encodeTweet, 58 | } 59 | } 60 | 61 | module.exports = TweetRepository 62 | -------------------------------------------------------------------------------- /api/domain/error.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | 3 | /** 4 | * Enumeration of possible application errors. 5 | * 6 | * @name catalogue 7 | */ 8 | const catalogue = { 9 | INVALID_REQUEST: 'INVALID_REQUEST', 10 | INTERNAL: 'INTERNAL', 11 | TWEET_NOT_FOUND: 'TWEET_NOT_FOUND', 12 | } 13 | 14 | /** 15 | * Custom Error class representing an application error i.e. the result of capturing a runtime error and handling it. 16 | * 17 | * @name ApplicationError 18 | * @class 19 | */ 20 | class ApplicationError extends Error { 21 | /** 22 | * @constructor 23 | * @param {Object} config 24 | * @param {String} config.code error code 25 | * @param {String} config.message error message 26 | * @param {Array.} config.cause error message 27 | */ 28 | constructor ({ code, message, cause }) { 29 | super(message || code) 30 | this._code = code 31 | this._cause = R.or(cause, []) 32 | Error.captureStackTrace(this, ApplicationError) 33 | } 34 | 35 | get code () { 36 | return this._code 37 | } 38 | 39 | get cause () { 40 | return this._cause 41 | } 42 | } 43 | 44 | /** 45 | * @name rejectWithApplicationError 46 | * 47 | * @param {Object} config 48 | * @param {String} config.code error code 49 | * @param {String} config.message error message 50 | * @param {Array.} config.cause error message 51 | * @returns {Promise} promise rejected with an instance of ApplicationError 52 | */ 53 | const rejectWithApplicationError = ({ code, message, cause }) => 54 | Promise.reject(new ApplicationError({ code, message, cause })) 55 | 56 | module.exports = { 57 | catalogue, 58 | ApplicationError, 59 | rejectWithApplicationError, 60 | } 61 | -------------------------------------------------------------------------------- /api/application/middlewares/error.spec.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | const sinon = require('sinon') 3 | 4 | const { catalogue, ApplicationError } = require('_domain/error') 5 | const { responsesTable } = require('_application/translators/error-translator') 6 | const { awaitAll } = require('_api/commons') 7 | const middleware = require('./error') 8 | 9 | describe('middlewares/error test', () => { 10 | let sandbox 11 | beforeEach(() => { 12 | sandbox = sinon.createSandbox() 13 | }) 14 | afterEach(() => { 15 | sandbox.restore() 16 | }) 17 | 18 | const testMiddleware = async (nextError, expectedResponse) => { 19 | const ctx = {} 20 | const next = sandbox.stub().rejects(nextError) 21 | 22 | await middleware(ctx, next) 23 | 24 | expect(ctx.status).to.equal(expectedResponse.status) 25 | expect(ctx.body).to.deep.equal(expectedResponse.body) 26 | } 27 | 28 | const testResponse = (code, response) => 29 | testMiddleware(new ApplicationError({ code }), response) 30 | 31 | const testAllResponses = R.pipe( 32 | R.toPairs, 33 | R.map(R.apply(testResponse)), 34 | awaitAll 35 | ) 36 | 37 | context('When middleware continuation rejects', () => { 38 | it('Serializes the matching response', () => 39 | testAllResponses(responsesTable) 40 | ) 41 | context('And the error does not have a matching response', () => { 42 | it('Serializes the default response error', () => 43 | testMiddleware( 44 | new Error('Unexpected undocumented error'), 45 | responsesTable[catalogue.INTERNAL] 46 | ) 47 | ) 48 | }) 49 | }) 50 | context('When middleware continuation resolve', () => { 51 | it('Resolves without touching the response', async () => { 52 | const ctx = {} 53 | const next = sandbox.stub().resolves() 54 | 55 | await middleware(ctx, next) 56 | 57 | expect(ctx).to.deep.equal({}) 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /api/application/middlewares/request-validation.spec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon') 2 | const middleware = require('./request-validation') 3 | const { catalogue, ApplicationError } = require('_domain/error') 4 | 5 | describe('middlewares/request-validation test', () => { 6 | let sandbox 7 | beforeEach(() => { 8 | sandbox = sinon.createSandbox() 9 | }) 10 | afterEach(() => { 11 | sandbox.restore() 12 | }) 13 | 14 | context('When validating request against schema yields errors', () => { 15 | it('Rejects with \'INVALID_REQUEST\' error ending the middleware stack', async () => { 16 | const schema = {} 17 | const check = sandbox.stub().returns() 18 | const validationErrors = sandbox.stub().resolves([ 19 | { param: 'x', msg: 'a' }, 20 | { param: 'x', msg: 'b' }, 21 | { param: 'y', msg: 'c' }, 22 | ]) 23 | const ctx = { check, validationErrors } 24 | const next = sandbox.stub().resolves() 25 | const expectedCode = catalogue.INVALID_REQUEST 26 | const expectedCause = ['x: a, b', 'y: c'] 27 | 28 | const promise = middleware(schema)(ctx, next) 29 | 30 | await expect(promise) 31 | .to.eventually.be 32 | .rejectedWith(ApplicationError) 33 | .and.to.include.deep({ 34 | code: expectedCode, 35 | cause: expectedCause, 36 | }) 37 | expect(next).to.not.have.been.called 38 | expect(check).to.have.been.calledOnceWith(schema) 39 | }) 40 | }) 41 | context('When there are no validation errors', () => { 42 | it('Continues middleware stack', async () => { 43 | const schema = {} 44 | const check = sandbox.stub().returns() 45 | const validationErrors = sandbox.stub().resolves() 46 | const ctx = { check, validationErrors } 47 | const next = sandbox.stub().resolves() 48 | 49 | await middleware(schema)(ctx, next) 50 | 51 | expect(next).to.have.been.calledOnce 52 | expect(check).to.have.been.calledOnceWith(schema) 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### macOS ### 2 | # General 3 | .DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | 7 | # Icon must end with two \r 8 | Icon 9 | 10 | # Thumbnails 11 | ._* 12 | 13 | # Files that might appear in the root of a volume 14 | .DocumentRevisions-V100 15 | .fseventsd 16 | .Spotlight-V100 17 | .TemporaryItems 18 | .Trashes 19 | .VolumeIcon.icns 20 | .com.apple.timemachine.donotpresent 21 | 22 | # Directories potentially created on remote AFP share 23 | .AppleDB 24 | .AppleDesktop 25 | Network Trash Folder 26 | Temporary Items 27 | .apdisk 28 | 29 | ### Node ### 30 | # Logs 31 | logs 32 | *.log 33 | npm-debug.log* 34 | yarn-debug.log* 35 | yarn-error.log* 36 | lerna-debug.log* 37 | 38 | # Diagnostic reports (https://nodejs.org/api/report.html) 39 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 40 | 41 | # Runtime data 42 | pids 43 | *.pid 44 | *.seed 45 | *.pid.lock 46 | 47 | # Directory for instrumented libs generated by jscoverage/JSCover 48 | lib-cov 49 | 50 | # Coverage directory used by tools like istanbul 51 | coverage 52 | lcov.info 53 | lcov-report 54 | 55 | # nyc test coverage 56 | .nyc_output 57 | 58 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 59 | .grunt 60 | 61 | # node-waf configuration 62 | .lock-wscript 63 | 64 | # Compiled binary addons (https://nodejs.org/api/addons.html) 65 | build/Release 66 | 67 | # Dependency directories 68 | node_modules/ 69 | jspm_packages/ 70 | 71 | # TypeScript v1 declaration files 72 | typings/ 73 | 74 | # Optional npm cache directory 75 | .npm 76 | 77 | # Optional eslint cache 78 | .eslintcache 79 | 80 | # Optional REPL history 81 | .node_repl_history 82 | 83 | # Output of 'npm pack' 84 | *.tgz 85 | 86 | # Yarn Integrity file 87 | .yarn-integrity 88 | 89 | # dotenv environment variables file 90 | .env 91 | .env.test 92 | 93 | # parcel-bundler cache (https://parceljs.org/) 94 | .cache 95 | 96 | # next.js build output 97 | .next 98 | 99 | # End of https://www.gitignore.io/api/node,macos 100 | 101 | .vscode 102 | 103 | docs/jsdoc 104 | -------------------------------------------------------------------------------- /tests/api/create-tweet.spec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon') 2 | const faker = require('faker') 3 | 4 | const createTestApi = require('_tests/helpers/server') 5 | 6 | describe('POST /api/tweets endpoint test', () => { 7 | let sandbox, testApi 8 | beforeEach(async () => { 9 | testApi = await createTestApi() 10 | sandbox = sinon.createSandbox() 11 | }) 12 | afterEach(() => { 13 | sandbox.restore() 14 | }) 15 | 16 | context('When sending a valid tweet text', () => { 17 | it('Responds with created tweet', async () => { 18 | const text = faker.lorem.sentence().substr(0, 140) 19 | 20 | const response = await testApi 21 | .post('/api/tweets') 22 | .send({ text }) 23 | 24 | expect(response.status).to.equal(201) 25 | expect(response.body.text).to.equal(text) 26 | }) 27 | }) 28 | context('When sending an empty text', () => { 29 | it('Responds with invalid request', async () => { 30 | const text = '' 31 | 32 | const response = await testApi 33 | .post('/api/tweets') 34 | .send({ text }) 35 | 36 | expect(response.status).to.equal(400) 37 | expect(response.body.error).to.equal('INVALID_REQUEST') 38 | expect(response.body.message).to.equal('Invalid request parameters') 39 | }) 40 | }) 41 | context('When sending a long text', () => { 42 | it('Responds with invalid request', async () => { 43 | const text = faker.random.alphaNumeric(faker.random.number({ min: 141, max: 250, precision: 1 })) 44 | 45 | const response = await testApi 46 | .post('/api/tweets') 47 | .send({ text }) 48 | 49 | expect(response.status).to.equal(400) 50 | expect(response.body.error).to.equal('INVALID_REQUEST') 51 | expect(response.body.message).to.equal('Invalid request parameters') 52 | }) 53 | }) 54 | context('When sending body without text', () => { 55 | it('Responds with invalid request', async () => { 56 | const body = {} 57 | 58 | const response = await testApi 59 | .post('/api/tweets') 60 | .send(body) 61 | 62 | expect(response.status).to.equal(400) 63 | expect(response.body.error).to.equal('INVALID_REQUEST') 64 | expect(response.body.message).to.equal('Invalid request parameters') 65 | }) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rs-ws-env", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server/index.js", 6 | "scripts": { 7 | "start": "node server/index.js", 8 | "start:dev": "NODE_ENV=dev nodemon --inspect=0.0.0.0 server/index.js", 9 | "lint": "npm run prepare:pipeline; eslint ./", 10 | "lint:fix": "npm run lint -- --fix", 11 | "prepare:pipeline": "rm -rf */lcov-report coverage", 12 | "test": "npm run test:unit && npm run test:integration", 13 | "test:cover": "nyc npm test", 14 | "test:unit": "NODE_ENV=test mocha --require tests/helpers \"api/**/*@(.spec.js)\" --timeout 5000", 15 | "test:unit:debug": "NODE_ENV=test mocha --inspect-brk --require tests/helpers \"api/**/*@(.spec.js)\" --timeout 5000", 16 | "test:integration": "NODE_ENV=test mocha --require tests/helpers \"tests/api/**/*@(.spec.js)\" --timeout 5000", 17 | "modulealias:link": "link-module-alias", 18 | "modulealias:clean": "command -v link-module-alias && link-module-alias clean || true", 19 | "postinstall": "npm run modulealias:link", 20 | "preinstall": "npm run modulealias:clean" 21 | }, 22 | "keywords": [], 23 | "author": "", 24 | "license": "ISC", 25 | "devDependencies": { 26 | "chai": "^4.2.0", 27 | "chai-as-promised": "^7.1.1", 28 | "dotenv": "^8.2.0", 29 | "eslint": "^7.13.0", 30 | "eslint-config-standard": "^16.0.1", 31 | "eslint-plugin-import": "^2.22.1", 32 | "eslint-plugin-json": "^2.1.2", 33 | "eslint-plugin-node": "^11.1.0", 34 | "eslint-plugin-promise": "^4.2.1", 35 | "eslint-plugin-standard": "^4.1.0", 36 | "eslint-watch": "^7.0.0", 37 | "faker": "^5.1.0", 38 | "link-module-alias": "^1.2.0", 39 | "mocha": "^8.2.1", 40 | "nodemon": "^2.0.6", 41 | "nyc": "^15.1.0", 42 | "sinon": "^9.2.1", 43 | "sinon-chai": "^3.5.0", 44 | "supertest": "^6.0.1" 45 | }, 46 | "dependencies": { 47 | "ioredis": "^4.19.2", 48 | "koa": "^2.13.0", 49 | "koa-async-validator": "^0.4.1", 50 | "koa-bodyparser": "^4.3.0", 51 | "koa-router": "^10.0.0", 52 | "moment": "^2.29.1", 53 | "mongodb": "^3.6.3", 54 | "pino": "^6.2.0", 55 | "pino-pretty": "^4.0.0", 56 | "ramda": "^0.27.1", 57 | "uuid": "^8.3.1" 58 | }, 59 | "_moduleAliases": { 60 | "_application": "api/application", 61 | "_domain": "api/domain", 62 | "_usecases": "api/usecases", 63 | "_adapters": "api/adapters", 64 | "_api": "api", 65 | "_config": "config", 66 | "_infrastructure": "infrastructure", 67 | "_server": "server", 68 | "_tests": "tests" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /api/adapters/redis-cache.spec.js: -------------------------------------------------------------------------------- 1 | const faker = require('faker') 2 | 3 | const Cache = require('./redis-cache') 4 | const redisClient = require('_infrastructure/redis') 5 | 6 | const { cleanCache } = require('_tests/helpers/utils') 7 | 8 | describe('redis-cache test', () => { 9 | let redis 10 | 11 | before(async () => { 12 | redis = await redisClient.connect() 13 | }) 14 | afterEach(() => 15 | cleanCache(redis) 16 | ) 17 | after(() => 18 | redisClient.close() 19 | ) 20 | 21 | describe('#put test', () => { 22 | context('When given a key and a value', () => { 23 | it('Sets the key value entry in redis without expiration', async () => { 24 | const cache = Cache({ redis }) 25 | const key = faker.lorem.word() 26 | const value = faker.lorem.word() 27 | 28 | await cache.put(key, value) 29 | 30 | const [savedValue, keyTTL] = await Promise.all([ 31 | redis.get(key), 32 | redis.ttl(key), 33 | ]) 34 | expect(savedValue).to.equal(value) 35 | expect(keyTTL > 0).to.be.false 36 | }) 37 | }) 38 | context('When given a key, a value and a positive expiration time', () => { 39 | it('Sets the key value entry in redis with expiration', async () => { 40 | const cache = Cache({ redis }) 41 | const key = faker.lorem.word() 42 | const value = faker.lorem.word() 43 | const expiration = faker.random.number({ min: 60, max: 120, precision: 1 }) 44 | 45 | await cache.put(key, value, expiration) 46 | 47 | const [savedValue, keyTTL] = await Promise.all([ 48 | redis.get(key), 49 | redis.ttl(key), 50 | ]) 51 | expect(savedValue).to.equal(value) 52 | expect(keyTTL > 0).to.be.true 53 | expect(keyTTL <= expiration).to.be.true 54 | }) 55 | }) 56 | }) 57 | describe('#get test', () => { 58 | context('When given a key that was not set', () => { 59 | it('Resolves null', async () => { 60 | const cache = Cache({ redis }) 61 | const key = faker.lorem.word() 62 | 63 | const result = await cache.get(key) 64 | 65 | expect(result).to.be.null 66 | }) 67 | }) 68 | context('When given a key that was set', () => { 69 | it('Resolves its value', async () => { 70 | const cache = Cache({ redis }) 71 | const key = faker.lorem.word() 72 | const value = faker.lorem.word() 73 | await redis.set(key, value) 74 | 75 | const result = await cache.get(key) 76 | 77 | expect(result).to.equal(value) 78 | }) 79 | }) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /api/adapters/mongodb-tweet-repository.spec.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | const faker = require('faker') 3 | const uuid = require('uuid') 4 | 5 | const Repository = require('./mongodb-tweet-repository') 6 | const mongoClient = require('_infrastructure/mongodb') 7 | const { Tweet } = require('_domain/tweet') 8 | const { catalogue: { TWEET_NOT_FOUND }, ApplicationError } = require('_domain/error') 9 | 10 | const { cleanDataBase } = require('_tests/helpers/utils') 11 | const { expect } = require('chai') 12 | 13 | describe('mongodb-tweet-repository test', () => { 14 | let mongoDb 15 | 16 | before(async () => { 17 | mongoDb = await mongoClient.connect() 18 | }) 19 | afterEach(() => 20 | cleanDataBase(mongoDb) 21 | ) 22 | after(() => 23 | mongoClient.close() 24 | ) 25 | 26 | describe('#add test', () => { 27 | context('When given a tweet', () => { 28 | it('Inserts it into the database', async () => { 29 | const repository = Repository({ mongoDb }) 30 | const tweet = Tweet({ text: faker.lorem.sentence() }) 31 | 32 | const result = await repository.add(tweet) 33 | 34 | expect(result.id).to.equal(tweet.id) 35 | const savedTweet = await mongoDb.collection('tweet').findOne({ _id: tweet.id }) 36 | expect(savedTweet).to.not.be.null 37 | expect(savedTweet).to.not.be.undefined 38 | }) 39 | }) 40 | }) 41 | describe('#increment test', () => { 42 | context('When tweet with matching id is not found', () => { 43 | it('Rejects with not found error', async () => { 44 | const repository = Repository({ mongoDb }) 45 | const id = uuid.v4() 46 | 47 | const promise = repository.increment(id, 'likes.amount') 48 | 49 | await expect(promise) 50 | .to.eventually.be.rejectedWith(ApplicationError) 51 | .with.property('code', TWEET_NOT_FOUND) 52 | }) 53 | }) 54 | context('When document with matching id is found', () => { 55 | it('Increments the field by 1', async () => { 56 | const repository = Repository({ mongoDb }) 57 | const currentFieldValue = faker.random.number({ 58 | min: 1, 59 | max: 100, 60 | precision: 1, 61 | }) 62 | const id = uuid.v4() 63 | const savedTweet = { 64 | _id: id, 65 | some: { random: { field: currentFieldValue } }, 66 | } 67 | await mongoDb.collection('tweet').insertOne(savedTweet) 68 | 69 | const result = await repository.increment(id, 'some.random.field') 70 | 71 | expect(result.some.random.field).to.equal(currentFieldValue + 1) 72 | const updatedDocument = await mongoDb.collection('tweet').findOne({ _id: id }) 73 | expect(updatedDocument.some.random.field).to.equal(currentFieldValue + 1) 74 | }) 75 | }) 76 | }) 77 | describe('#orderBy test', () => { 78 | const randomInt = (min, max) => faker.random.number({ 79 | min, 80 | max, 81 | precision: 1, 82 | }) 83 | const createTweet = () => Tweet({ 84 | text: faker.lorem.sentence(), 85 | likes: randomInt(1, 200), 86 | }) 87 | const createTweets = R.pipe( 88 | R.times(createTweet), 89 | R.uniqBy(R.path(['likes', 'amount'])) 90 | ) 91 | 92 | const insertTweets = (repository, tweets) => 93 | mongoDb 94 | .collection('tweet') 95 | .insertMany(R.map(repository.encodeTweet, tweets)) 96 | 97 | const ascending = (fieldPath, limit) => R.pipe( 98 | R.sort(R.ascend(R.path(fieldPath))), 99 | R.take(limit) 100 | ) 101 | const descending = (fieldPath, limit) => R.pipe( 102 | R.sort(R.descend(R.path(fieldPath))), 103 | R.take(limit) 104 | ) 105 | context('When ascending is true', () => { 106 | it('Sorts documents by field ascending and limits the result', async () => { 107 | const repository = Repository({ mongoDb }) 108 | const totalDocuments = randomInt(10, 20) 109 | const limit = Math.floor(totalDocuments / 2) 110 | const registeredTweets = createTweets(totalDocuments) 111 | const expectedTweets = ascending(['likes', 'amount'], limit)(registeredTweets) 112 | await insertTweets(repository, registeredTweets) 113 | 114 | const result = await repository.orderBy('likes.amount', limit, true) 115 | 116 | expect(result).to.deep.equal(expectedTweets) 117 | }) 118 | }) 119 | context('When descending is true', () => { 120 | it('Sorts documents by field descending and limits the result', async () => { 121 | const repository = Repository({ mongoDb }) 122 | const totalDocuments = randomInt(10, 20) 123 | const limit = Math.floor(totalDocuments / 2) 124 | const registeredTweets = createTweets(totalDocuments) 125 | const expectedTweets = descending(['likes', 'amount'], limit)(registeredTweets) 126 | await insertTweets(repository, registeredTweets) 127 | 128 | const result = await repository.orderBy('likes.amount', limit) 129 | 130 | expect(result).to.deep.equal(expectedTweets) 131 | }) 132 | }) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Containerizando o ambiente com docker-compose 4 | 5 | Nesse workshop vamos aprender a utilizar containers para criar dependências de infra-estrutura das nossas aplicações (como bancos de dados, serviços de cache). 6 | 7 | Utilizaremos [Docker](https://www.docker.com/) como nossa ferramenta de containerização e utilizaremos o [docker-compose](https://docs.docker.com/compose/) para orquestrar o ciclo de vida de nossos containers. 8 | 9 | Aprenderemos algumas abordagens para conectar nossa aplicação localmente tanto para executá-la quanto para rodar testes integrados. 10 | 11 | No final utilizaremos essas tecnologias também para criação de um pipeline de integração contínua. 12 | 13 | Vamos ver que com o uso dessas ferramentas ganharemos: 14 | - Não precisar instalar essas dependências em nosso ambiente local 15 | - Não precisar instalar essas dependências remotamente e disponibilizá-las na rede 16 | - Não precisar compartilhar essas dependências entre colaboradores 17 | - Não precisar se preocupar com o ciclo de vida dessas dependências (considerá-las efêmeras) 18 | 19 | ## O Workshop 20 | 21 | O workshop será dividido nas seguintes etapas: 22 | 23 | 1. Apresentação da aplicação 24 | - familiarização com o código fonte, arquitetura de software, testes 25 | 26 | 2. Adição de bancos de dados [MongoDB](https://www.mongodb.com/) 27 | - Como utilizar o _docker-compose_ para "subir" o banco localmente (desenvolvimento local, testes automatizados) 28 | - utilizando rede virtual 29 | - expondo na máquina host 30 | 31 | 3. Adição de serviço de cache [Redis](https://redis.io/) 32 | - Como utilizar o _docker-compose_ para "subir" o serviço de cache localmente (desenvolvimento local, testes automatizados) 33 | - Utilizando rede virtual 34 | - expondo na máquina host 35 | 36 | 4. Conectando a aplicação 37 | - Testes 38 | - Desenvolvimento 39 | - Como utilizar o _docker-compose_ para "subir" a aplicação localmente conectando com os recursos 40 | - Expondo na máquina host 41 | 42 | 5. Criar _pipeline_ de _CI_ com [Github Actions](https://github.com/features/actions) 43 | - Com etapa de testes de integração 44 | - **importante**: o foco não é aprender _github actions_ mas sim como utilizar containers pra facilitar o processo 45 | 46 | 6. Considerações finais 47 | 48 | 7. Q&A 49 | 50 | ### A aplicação 51 | 52 | A aplicação consiste de uma API REST em [Node.JS](https://nodejs.org/en/). 53 | 54 | Essa API será um serviço que gerencia posts de texto do tipo `tweet` (inspirado no [twitter](https://twitter.com/)): 55 | - Criação de um _tweet_ que será identificado por um [uuid](https://en.wikipedia.org/wiki/Universally_unique_identifier) versão 4 56 | - Acrescentar _likes_ nos _tweets_ cadastrados 57 | - Listar os top _tweets_ com mais _likes_ 58 | 59 | ## Pré-requisitos 60 | 61 | **Disclaimer**: O setup não foi testado em sistema operacional _Windows_ então pode ser que não funcione. 62 | Recomendo a utilização de alguma distribuição _Linux_ ou _Mac_. 63 | 64 | Para conseguir acompanhar o workshop será necessário ter no ambiente 65 | - [Docker](https://www.docker.com/) e [docker-compose](https://docs.docker.com/compose/) 66 | - [Git](https://git-scm.com/) 67 | - Conta no [Github](https://github.com/) 68 | - Última versão de [Node.JS](https://nodejs.org/en/) (14+) 69 | - recomendo a utilização do [NVM](https://github.com/nvm-sh/nvm) 70 | - Preparar sua versão do repositório 71 | - [fork](https://github.com/rodrigobotti/rs-ws-2020-env/fork) desse repositório 72 | - _git clone_ do repositório 73 | - executar no diretório do projeto 74 | ```sh 75 | # caso tenha optado pelo uso do nvm 76 | # (a versão de node vem do arquivo .nvmrc na raíz) 77 | nvm install 78 | nvm use 79 | 80 | # instalar as dependências 81 | npm install 82 | 83 | # verificar: 84 | 85 | # executar testes unitários e de integração 86 | # como falta o mongodb e o redis, deve falhar com timeout de conexão 87 | npm test 88 | 89 | # tentar subir a aplicação localmente em modo debug 90 | # como falta o mongodb e o redis, deve falhar por erro de conexão 91 | npm run start:dev 92 | ``` 93 | 94 | ## Pós workshop 95 | 96 | ### Código produzido 97 | Todo código produzido nesse workshop está disponível no branch `cheat-sheet`. 98 | 99 | ### Lição de casa 100 | 101 | Durante o workshop, eu "expliquei" (nem sei se pode chamar disso) de forma bem grosseira o que é um container. 102 | Recomendo [consultar a fonte](https://www.docker.com/resources/what-container) pra saber de fato o que são e a tecnologia de fato por trás. 103 | 104 | Além disso, a parte de networking foi explicada no modo "vamos acreditar que funciona" e foram utilizadas abordagens específicas. 105 | Recomendo a leitura da [documentação oficial](https://www.docker.com/resources/what-container) para esse assunto, 106 | tanto para entender os detalhes quanto para entender todo o leque de opções desse assunto vasto. 107 | 108 | #### Exercícios 109 | 110 | **Disclaimer**: originalmente o workshop tinha sido feito para que construíssemos features da aplicação ao mesmo tempo que integrassemos com containers. 111 | Primeiro implementaríamos as features de _criação_ e _adicionar likes_ e finalmente a de _top com mais likes_ junto com seus respectivos testes. 112 | TL;DR: originalmente, produziríamos código de aplicação no workshop também. 113 | 114 | Caso você queira mexer um pouco na aplicação, você pode implementar os seguintes exercícios: 115 | 116 | 1 . Criar testes que estão faltando 117 | 118 | 1.1 . Unitários para os componentes da funcionalidade _adicionar likes_ 119 | 120 | 1.2 . De integração para a funcionalidade de _adicionar likes_ 121 | 122 | 1.3 . De integração para a funcionalidade de _top tweets com mais likes_ 123 | 124 | 2 . No pipeline de CI, testar com check de cobertura de código 125 | 126 | 3 . Criar `Makefile` para agrupar tarefas utilizando `make` 127 | 128 | 3.1 . Task `test`: cria infraestrutura + executa testes com check de cobertura + derruba a infraestrutura 129 | 130 | 3.2 . Task `run`: cria infraestrura + inicia a aplicação 131 | 132 | 3.3 . Modificar o pipeline de CI para utilizar a task `test` 133 | 134 | ## Expert 135 | 136 | | [](https://github.com/rodrigobotti) | 137 | | :-: | 138 | | [Rodrigo Botti](https://github.com/rodrigobotti) | 139 | --------------------------------------------------------------------------------