├── .husky ├── .gitignore └── pre-commit ├── tsconfig-build.json ├── src ├── application │ ├── config │ │ └── pagination.ts │ ├── interfaces │ │ ├── cryptography │ │ │ ├── JWTGenerator.ts │ │ │ ├── JWTVerifier.ts │ │ │ ├── HashGenerator.ts │ │ │ └── HashComparer.ts │ │ ├── use-cases │ │ │ ├── UseCase.ts │ │ │ ├── posts │ │ │ │ ├── DeletePostInterface.ts │ │ │ │ ├── UpdatePostTotalCommentsInterface.ts │ │ │ │ ├── CreatePostInterface.ts │ │ │ │ ├── GetLatestPostsInterface.ts │ │ │ │ ├── GetPostByIdInterface.ts │ │ │ │ └── UpdatePostInterface.ts │ │ │ ├── comments │ │ │ │ ├── DeleteCommentInterface.ts │ │ │ │ ├── DeleteCommentsByPostIdInterface.ts │ │ │ │ ├── CreateCommentInterface.ts │ │ │ │ ├── GetCommentByIdInterface.ts │ │ │ │ ├── GetLatestCommentsByPostIdInterface.ts │ │ │ │ └── UpdateCommentInterface.ts │ │ │ └── authentication │ │ │ │ ├── SignInInterface.ts │ │ │ │ ├── AuthenticateInterface.ts │ │ │ │ └── SignUpInterface.ts │ │ └── repositories │ │ │ ├── posts │ │ │ ├── DeletePostRepository.ts │ │ │ ├── GetPostByIdRepository.ts │ │ │ ├── CreatePostRepository.ts │ │ │ ├── UpdatePostRepository.ts │ │ │ ├── UpdatePostTotalCommentsRepository.ts │ │ │ └── GetLatestPostsRepository.ts │ │ │ ├── comments │ │ │ ├── DeleteCommentRepository.ts │ │ │ ├── DeleteCommentsByPostIdRepository.ts │ │ │ ├── GetTotalCommentsByPostIdRepository.ts │ │ │ ├── GetCommentByIdRepository.ts │ │ │ ├── CreateCommentRepository.ts │ │ │ ├── UpdateCommentRepository.ts │ │ │ └── GetLatestCommentsByPostIdRepository.ts │ │ │ └── authentication │ │ │ ├── LoadUserByEmailRepository.ts │ │ │ └── CreateUserRepository.ts │ ├── errors │ │ ├── ForbiddenError.ts │ │ ├── UnauthorizedError.ts │ │ ├── EmailInUseError.ts │ │ ├── PostNotFoundError.ts │ │ └── CommentNotFoundError.ts │ └── use-cases │ │ ├── posts │ │ ├── DeletePost.ts │ │ ├── CreatePost.ts │ │ ├── GetPostById.ts │ │ ├── GetLatestPosts.ts │ │ ├── UpdatePost.ts │ │ └── UpdatePostTotalComments.ts │ │ ├── comments │ │ ├── DeleteComment.ts │ │ ├── CreateComment.ts │ │ ├── DeleteCommentsByPostId.ts │ │ ├── GetCommentById.ts │ │ ├── GetLatestCommentsByPostId.ts │ │ └── UpdateComment.ts │ │ └── authentication │ │ ├── Authenticate.ts │ │ ├── SignIn.ts │ │ └── SignUp.ts ├── main │ ├── middlewares │ │ ├── body-parser.ts │ │ ├── content-type.ts │ │ ├── auth-middleware.ts │ │ └── cors.ts │ ├── config │ │ ├── custom-modules.d.ts │ │ ├── env.ts │ │ ├── app.ts │ │ ├── middlewares.ts │ │ └── routes.ts │ ├── factories │ │ ├── controllers │ │ │ ├── comments │ │ │ │ ├── update-comment │ │ │ │ │ ├── validation-factory.ts │ │ │ │ │ └── controller-factory.ts │ │ │ │ ├── create-comment │ │ │ │ │ ├── validation-factory.ts │ │ │ │ │ └── controller-factory.ts │ │ │ │ ├── get-latest-comments-by-post-id │ │ │ │ │ └── controller-factory.ts │ │ │ │ └── delete-comment │ │ │ │ │ └── controller-factory.ts │ │ │ ├── posts │ │ │ │ ├── create-post │ │ │ │ │ ├── validation-factory.ts │ │ │ │ │ └── controller-factory.ts │ │ │ │ ├── update-post │ │ │ │ │ ├── validation-factory.ts │ │ │ │ │ └── controller-factory.ts │ │ │ │ ├── get-post-by-id │ │ │ │ │ └── controller-factory.ts │ │ │ │ ├── get-latest-posts │ │ │ │ │ └── controller-factory.ts │ │ │ │ └── delete-post │ │ │ │ │ └── controller-factory.ts │ │ │ └── authentication │ │ │ │ ├── sign-in │ │ │ │ ├── controller-factory.ts │ │ │ │ └── validation-factory.ts │ │ │ │ └── sign-up │ │ │ │ ├── controller-factory.ts │ │ │ │ └── validation-factory.ts │ │ ├── use-cases │ │ │ ├── posts │ │ │ │ ├── create-post-factory.ts │ │ │ │ ├── delete-post-factory.ts │ │ │ │ ├── get-post-by-id-factory.ts │ │ │ │ ├── update-post-factory.ts │ │ │ │ ├── get-latest-posts-factory.ts │ │ │ │ └── update-post-total-comments-factory.ts │ │ │ ├── comments │ │ │ │ ├── create-comment-factory.ts │ │ │ │ ├── delete-comment-factory.ts │ │ │ │ ├── get-comment-by-id-factory.ts │ │ │ │ ├── update-comment-factory.ts │ │ │ │ ├── delete-comments-by-post-id-factory.ts │ │ │ │ └── get-latest-comments-by-post-id-factory.ts │ │ │ └── authentication │ │ │ │ ├── authenticate-factory.ts │ │ │ │ ├── sign-up-factory.ts │ │ │ │ └── sign-in-factory.ts │ │ └── middlewares │ │ │ └── auth-middleware-factory.ts │ ├── server.ts │ ├── routes │ │ ├── authentication-routes.ts │ │ ├── comment-routes.ts │ │ └── post-routes.ts │ └── adapters │ │ ├── express-middleware-adapter.ts │ │ └── express-route-adapter.ts ├── infra │ ├── http │ │ ├── interfaces │ │ │ ├── Validation.ts │ │ │ ├── HttpResponse.ts │ │ │ └── HttpRequest.ts │ │ ├── validations │ │ │ ├── interfaces │ │ │ │ └── EmailValidator.ts │ │ │ ├── RequiredFieldValidation.ts │ │ │ ├── ValidationComposite.ts │ │ │ └── EmailValidation.ts │ │ ├── errors │ │ │ ├── PermissionError.ts │ │ │ ├── InvalidAuthTokenError.ts │ │ │ ├── InvalidParamError.ts │ │ │ ├── MissingParamError.ts │ │ │ ├── ServerError.ts │ │ │ └── AuthTokenNotProvidedError.ts │ │ ├── validators │ │ │ └── EmailValidatorAdapter.ts │ │ ├── middlewares │ │ │ ├── BaseMiddleware.ts │ │ │ └── authentication │ │ │ │ └── AuthMiddleware.ts │ │ ├── controllers │ │ │ ├── BaseController.ts │ │ │ ├── posts │ │ │ │ ├── GetLatestPostsController.ts │ │ │ │ ├── CreatePostController.ts │ │ │ │ ├── GetPostByIdController.ts │ │ │ │ ├── DeletePostController.ts │ │ │ │ └── UpdatePostController.ts │ │ │ ├── comments │ │ │ │ ├── GetLatestCommentsByPostIdController.ts │ │ │ │ ├── CreateCommentController.ts │ │ │ │ ├── DeleteCommentController.ts │ │ │ │ └── UpdateCommentController.ts │ │ │ └── authentication │ │ │ │ ├── SignInController.ts │ │ │ │ └── SignUpController.ts │ │ └── helpers │ │ │ └── http.ts │ ├── cryptography │ │ ├── BcryptAdapter.ts │ │ └── JWTAdapter.ts │ └── db │ │ └── mongodb │ │ ├── helpers │ │ ├── mapper.ts │ │ └── db-connection.ts │ │ └── repositories │ │ ├── UserRepository.ts │ │ ├── PostRepository.ts │ │ └── CommentRepository.ts └── domain │ └── entities │ ├── Comment.ts │ ├── User.ts │ └── Post.ts ├── .gitignore ├── docs ├── logo.png ├── favicon.ico ├── index.html └── bundle.css ├── jest-mongodb-config.js ├── tests ├── domain │ └── mocks │ │ └── entities.ts ├── application │ ├── mocks │ │ ├── comments │ │ │ └── use-cases.ts │ │ ├── authentication │ │ │ └── use-cases.ts │ │ └── posts │ │ │ └── use-cases.ts │ └── use-cases │ │ └── posts │ │ ├── DeletePost.spec.ts │ │ ├── CreatePost.spec.ts │ │ ├── GetPostById.spec.ts │ │ ├── GetLatestPosts.spec.ts │ │ ├── UpdatePostTotalComments.spec.ts │ │ └── UpdatePost.spec.ts ├── infra │ ├── mocks │ │ ├── validators.ts │ │ ├── controllers.ts │ │ ├── middlewares.ts │ │ ├── comments │ │ │ └── repositories.ts │ │ └── posts │ │ │ └── repositories.ts │ ├── db │ │ └── mongodb │ │ │ ├── helpers │ │ │ └── db-connection.spec.ts │ │ │ └── repositories │ │ │ └── PostRepository.spec.ts │ └── http │ │ ├── validations │ │ ├── RequiredFieldValidation.spec.ts │ │ ├── EmailValidation.spec.ts │ │ └── ValidationComposite.spec.ts │ │ ├── validators │ │ └── EmailValidatorAdapter.spec.ts │ │ ├── middlewares │ │ ├── BaseMiddleware.spec.ts │ │ └── authentication │ │ │ └── AuthMiddleware.spec.ts │ │ └── controllers │ │ ├── posts │ │ ├── GetLatestPostsController.spec.ts │ │ ├── CreatePostController.spec.ts │ │ ├── GetPostByIdController.spec.ts │ │ ├── DeletePostController.spec.ts │ │ └── UpdatePostController.spec.ts │ │ └── BaseController.spec.ts └── main │ └── routes │ └── authentication-routes.spec.ts ├── docker-compose.yml ├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── package.json ├── README.md ├── jest.config.ts └── tsconfig.json /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /tsconfig-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["tests"] 4 | } 5 | -------------------------------------------------------------------------------- /src/application/config/pagination.ts: -------------------------------------------------------------------------------- 1 | export const paginationConfig = { 2 | paginationLimit: 10, 3 | }; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | /node_modules 4 | /dist 5 | /coverage 6 | /globalConfig.json 7 | /data 8 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/simple-blog-application-backend-challenge/HEAD/docs/logo.png -------------------------------------------------------------------------------- /src/main/middlewares/body-parser.ts: -------------------------------------------------------------------------------- 1 | import { json } from 'express'; 2 | 3 | export const bodyParser = json(); 4 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyarleniber/simple-blog-application-backend-challenge/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /src/infra/http/interfaces/Validation.ts: -------------------------------------------------------------------------------- 1 | export interface Validation { 2 | validate: (input: any) => Error | null 3 | } 4 | -------------------------------------------------------------------------------- /src/infra/http/interfaces/HttpResponse.ts: -------------------------------------------------------------------------------- 1 | export type HttpResponse = { 2 | statusCode: number; 3 | body?: T; 4 | }; 5 | -------------------------------------------------------------------------------- /src/main/config/custom-modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module Express { 2 | interface Request { 3 | userId?: string; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/infra/http/validations/interfaces/EmailValidator.ts: -------------------------------------------------------------------------------- 1 | export interface EmailValidator { 2 | isValid: (email: string) => boolean 3 | } 4 | -------------------------------------------------------------------------------- /src/application/interfaces/cryptography/JWTGenerator.ts: -------------------------------------------------------------------------------- 1 | export interface JWTGenerator { 2 | generate(payload: string): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/application/interfaces/cryptography/JWTVerifier.ts: -------------------------------------------------------------------------------- 1 | export interface JWTVerifier { 2 | verify(jwt: string): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/application/interfaces/cryptography/HashGenerator.ts: -------------------------------------------------------------------------------- 1 | export interface HashGenerator { 2 | hash(data: string): Promise | string; 3 | } 4 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/UseCase.ts: -------------------------------------------------------------------------------- 1 | export interface UseCase { 2 | execute(request: TRequest): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/application/interfaces/cryptography/HashComparer.ts: -------------------------------------------------------------------------------- 1 | export interface HashComparer { 2 | compare(plaintext: string, hash: string): Promise | boolean; 3 | } 4 | -------------------------------------------------------------------------------- /src/application/errors/ForbiddenError.ts: -------------------------------------------------------------------------------- 1 | export class ForbiddenError extends Error { 2 | constructor() { 3 | super('Forbidden'); 4 | this.name = 'ForbiddenError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/application/errors/UnauthorizedError.ts: -------------------------------------------------------------------------------- 1 | export class UnauthorizedError extends Error { 2 | constructor() { 3 | super('Unauthorized'); 4 | this.name = 'UnauthorizedError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/infra/http/errors/PermissionError.ts: -------------------------------------------------------------------------------- 1 | export class PermissionError extends Error { 2 | constructor() { 3 | super('Permission denied'); 4 | this.name = 'PermissionError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/application/errors/EmailInUseError.ts: -------------------------------------------------------------------------------- 1 | export class EmailInUseError extends Error { 2 | constructor() { 3 | super('Email is already in use'); 4 | this.name = 'EmailInUseError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/application/errors/PostNotFoundError.ts: -------------------------------------------------------------------------------- 1 | export class PostNotFoundError extends Error { 2 | constructor() { 3 | super('The post was not found'); 4 | this.name = 'PostNotFoundError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/infra/http/interfaces/HttpRequest.ts: -------------------------------------------------------------------------------- 1 | export type HttpRequest = { 2 | body?: TBody; 3 | params?: TParams; 4 | headers?: THeaders; 5 | userId?: string; 6 | }; 7 | -------------------------------------------------------------------------------- /src/application/errors/CommentNotFoundError.ts: -------------------------------------------------------------------------------- 1 | export class CommentNotFoundError extends Error { 2 | constructor() { 3 | super('The comment was not found'); 4 | this.name = 'CommentNotFoundError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/infra/http/errors/InvalidAuthTokenError.ts: -------------------------------------------------------------------------------- 1 | export class InvalidAuthTokenError extends Error { 2 | constructor() { 3 | super('Invalid authentication token'); 4 | this.name = 'InvalidAuthTokenError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/infra/http/errors/InvalidParamError.ts: -------------------------------------------------------------------------------- 1 | export class InvalidParamError extends Error { 2 | constructor(paramName: string) { 3 | super(`Invalid param: ${paramName}`); 4 | this.name = 'InvalidParamError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/infra/http/errors/MissingParamError.ts: -------------------------------------------------------------------------------- 1 | export class MissingParamError extends Error { 2 | constructor(paramName: string) { 3 | super(`Missing param: ${paramName}`); 4 | this.name = 'MissingParamError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/infra/http/errors/ServerError.ts: -------------------------------------------------------------------------------- 1 | export class ServerError extends Error { 2 | constructor(stack?: string) { 3 | super('Internal server error'); 4 | this.name = 'ServerError'; 5 | this.stack = stack; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/middlewares/content-type.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | 3 | export const contentType = (req: Request, res: Response, next: NextFunction): void => { 4 | res.type('json'); 5 | next(); 6 | }; 7 | -------------------------------------------------------------------------------- /src/infra/http/errors/AuthTokenNotProvidedError.ts: -------------------------------------------------------------------------------- 1 | export class AuthTokenNotProvidedError extends Error { 2 | constructor() { 3 | super('Authentication token not provided'); 4 | this.name = 'AuthTokenNotProvidedError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/main/config/env.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | port: process.env.PORT || 5050, 3 | mongodbUrl: process.env.MONGO_URL || 'mongodb://localhost:27017/simple-blog-db', 4 | bcryptSalt: 12, 5 | jwtSecret: process.env.JWT_SECRET || 'secret', 6 | }; 7 | -------------------------------------------------------------------------------- /jest-mongodb-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mongodbMemoryServerOptions: { 3 | binary: { 4 | version: '4.0.3', 5 | skipMD5: true, 6 | }, 7 | instance: { 8 | dbName: 'jest', 9 | }, 10 | autoStart: false, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/main/middlewares/auth-middleware.ts: -------------------------------------------------------------------------------- 1 | import { expressMiddlewareAdapter } from '@main/adapters/express-middleware-adapter'; 2 | import { makeAuthMiddleware } from '@main/factories/middlewares/auth-middleware-factory'; 3 | 4 | export const authMiddleware = expressMiddlewareAdapter(makeAuthMiddleware()); 5 | -------------------------------------------------------------------------------- /tests/domain/mocks/entities.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@domain/entities/Post'; 2 | 3 | export const makeFakePost = (): Post => new Post({ 4 | id: 'any_id', 5 | userId: 'any_user_id', 6 | title: 'any_title', 7 | text: 'any_text', 8 | totalComments: 0, 9 | createdAt: new Date(), 10 | }); 11 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/posts/DeletePostRepository.ts: -------------------------------------------------------------------------------- 1 | export interface DeletePostRepository { 2 | deletePost(postId: DeletePostRepository.Request): Promise; 3 | } 4 | 5 | export namespace DeletePostRepository { 6 | export type Request = string; 7 | export type Response = void; 8 | } 9 | -------------------------------------------------------------------------------- /src/main/config/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Express } from 'express'; 2 | import setupMiddlewares from '@main/config/middlewares'; 3 | import setupRoutes from '@main/config/routes'; 4 | 5 | export default (): Express => { 6 | const app = express(); 7 | setupMiddlewares(app); 8 | setupRoutes(app); 9 | return app; 10 | }; 11 | -------------------------------------------------------------------------------- /src/infra/http/validators/EmailValidatorAdapter.ts: -------------------------------------------------------------------------------- 1 | import validator from 'validator'; 2 | import { EmailValidator } from '@infra/http/validations/interfaces/EmailValidator'; 3 | 4 | export class EmailValidatorAdapter implements EmailValidator { 5 | isValid(email: string): boolean { 6 | return validator.isEmail(email); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/middlewares/cors.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | 3 | export const cors = (req: Request, res: Response, next: NextFunction): void => { 4 | res.set('Access-Control-Allow-Origin', '*'); 5 | res.set('Access-Control-Allow-Methods', '*'); 6 | res.set('Access-Control-Allow-Headers', '*'); 7 | next(); 8 | }; 9 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/comments/DeleteCommentRepository.ts: -------------------------------------------------------------------------------- 1 | export interface DeleteCommentRepository { 2 | deleteComment( 3 | commentId: DeleteCommentRepository.Request 4 | ): Promise; 5 | } 6 | 7 | export namespace DeleteCommentRepository { 8 | export type Request = string; 9 | export type Response = void; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/config/middlewares.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express'; 2 | import { bodyParser } from '@main/middlewares/body-parser'; 3 | import { cors } from '@main/middlewares/cors'; 4 | import { contentType } from '@main/middlewares/content-type'; 5 | 6 | export default (app: Express): void => { 7 | app.use(bodyParser); 8 | app.use(cors); 9 | app.use(contentType); 10 | }; 11 | -------------------------------------------------------------------------------- /tests/application/mocks/comments/use-cases.ts: -------------------------------------------------------------------------------- 1 | import { DeleteCommentsByPostIdInterface } from '@application/interfaces/use-cases/comments/DeleteCommentsByPostIdInterface'; 2 | 3 | export class DeleteCommentsByPostIdStub implements DeleteCommentsByPostIdInterface { 4 | async execute( 5 | _postId: DeleteCommentsByPostIdInterface.Request, 6 | ): Promise {} 7 | } 8 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/posts/GetPostByIdRepository.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@domain/entities/Post'; 2 | 3 | export interface GetPostByIdRepository { 4 | getPostById(postId: GetPostByIdRepository.Request): Promise; 5 | } 6 | 7 | export namespace GetPostByIdRepository { 8 | export type Request = string; 9 | export type Response = Post | null; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/factories/controllers/comments/update-comment/validation-factory.ts: -------------------------------------------------------------------------------- 1 | import { ValidationComposite } from '@infra/http/validations/ValidationComposite'; 2 | import { RequiredFieldValidation } from '@infra/http/validations/RequiredFieldValidation'; 3 | 4 | export const makeUpdateCommentValidation = (): ValidationComposite => new ValidationComposite([ 5 | new RequiredFieldValidation('text'), 6 | ], 'body'); 7 | -------------------------------------------------------------------------------- /tests/application/mocks/authentication/use-cases.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticateInterface } from '@application/interfaces/use-cases/authentication/AuthenticateInterface'; 2 | 3 | export class AuthenticateStub implements AuthenticateInterface { 4 | async execute( 5 | _authenticationToken: AuthenticateInterface.Request, 6 | ): Promise { 7 | return 'any_token'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/comments/DeleteCommentsByPostIdRepository.ts: -------------------------------------------------------------------------------- 1 | export interface DeleteCommentsByPostIdRepository { 2 | deleteCommentsByPostId( 3 | postId: DeleteCommentsByPostIdRepository.Request 4 | ): Promise; 5 | } 6 | 7 | export namespace DeleteCommentsByPostIdRepository { 8 | export type Request = string; 9 | export type Response = void; 10 | } 11 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/comments/GetTotalCommentsByPostIdRepository.ts: -------------------------------------------------------------------------------- 1 | export interface GetTotalCommentsByPostIdRepository { 2 | getTotalCommentsByPostId( 3 | postId: GetTotalCommentsByPostIdRepository.Request 4 | ): Promise; 5 | } 6 | 7 | export namespace GetTotalCommentsByPostIdRepository { 8 | export type Request = string; 9 | export type Response = number; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/factories/controllers/posts/create-post/validation-factory.ts: -------------------------------------------------------------------------------- 1 | import { ValidationComposite } from '@infra/http/validations/ValidationComposite'; 2 | import { RequiredFieldValidation } from '@infra/http/validations/RequiredFieldValidation'; 3 | 4 | export const makeCreatePostValidation = (): ValidationComposite => new ValidationComposite([ 5 | new RequiredFieldValidation('title'), 6 | new RequiredFieldValidation('text'), 7 | ], 'body'); 8 | -------------------------------------------------------------------------------- /src/main/factories/controllers/posts/update-post/validation-factory.ts: -------------------------------------------------------------------------------- 1 | import { ValidationComposite } from '@infra/http/validations/ValidationComposite'; 2 | import { RequiredFieldValidation } from '@infra/http/validations/RequiredFieldValidation'; 3 | 4 | export const makeUpdatePostValidation = (): ValidationComposite => new ValidationComposite([ 5 | new RequiredFieldValidation('title'), 6 | new RequiredFieldValidation('text'), 7 | ], 'body'); 8 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/posts/CreatePostRepository.ts: -------------------------------------------------------------------------------- 1 | import { PostProps } from '@domain/entities/Post'; 2 | 3 | export interface CreatePostRepository { 4 | createPost(postData: CreatePostRepository.Request): Promise; 5 | } 6 | 7 | export namespace CreatePostRepository { 8 | export type Request = Omit; 9 | export type Response = string; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/factories/controllers/comments/create-comment/validation-factory.ts: -------------------------------------------------------------------------------- 1 | import { ValidationComposite } from '@infra/http/validations/ValidationComposite'; 2 | import { RequiredFieldValidation } from '@infra/http/validations/RequiredFieldValidation'; 3 | 4 | export const makeCreateCommentValidation = (): ValidationComposite => new ValidationComposite([ 5 | new RequiredFieldValidation('postId'), 6 | new RequiredFieldValidation('text'), 7 | ], 'body'); 8 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/authentication/LoadUserByEmailRepository.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@domain/entities/User'; 2 | 3 | export interface LoadUserByEmailRepository { 4 | loadUserByEmail( 5 | email: LoadUserByEmailRepository.Request 6 | ): Promise; 7 | } 8 | 9 | export namespace LoadUserByEmailRepository { 10 | export type Request = string; 11 | export type Response = User | null; 12 | } 13 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/comments/GetCommentByIdRepository.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from '@domain/entities/Comment'; 2 | 3 | export interface GetCommentByIdRepository { 4 | getCommentById( 5 | commentId: GetCommentByIdRepository.Request 6 | ): Promise; 7 | } 8 | 9 | export namespace GetCommentByIdRepository { 10 | export type Request = string; 11 | export type Response = Comment | null; 12 | } 13 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/authentication/CreateUserRepository.ts: -------------------------------------------------------------------------------- 1 | import { UserProps } from '@domain/entities/User'; 2 | 3 | export interface CreateUserRepository { 4 | createUser( 5 | userData: CreateUserRepository.Request 6 | ): Promise; 7 | } 8 | 9 | export namespace CreateUserRepository { 10 | export type Request = Omit; 11 | export type Response = string; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/config/routes.ts: -------------------------------------------------------------------------------- 1 | import { Express, Router } from 'express'; 2 | import authenticationRoutes from '@main/routes/authentication-routes'; 3 | import postRoutes from '@main/routes/post-routes'; 4 | import commentRoutes from '@main/routes/comment-routes'; 5 | 6 | export default (app: Express): void => { 7 | const router = Router(); 8 | app.use('/api', router); 9 | authenticationRoutes(router); 10 | postRoutes(router); 11 | commentRoutes(router); 12 | }; 13 | -------------------------------------------------------------------------------- /tests/infra/mocks/validators.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '@infra/http/interfaces/Validation'; 2 | import { EmailValidator } from '@infra/http/validations/interfaces/EmailValidator'; 3 | 4 | export class ValidationStub implements Validation { 5 | validate(_input: any): Error | null { 6 | return null; 7 | } 8 | } 9 | 10 | export class EmailValidatorStub implements EmailValidator { 11 | isValid(_email: string): boolean { 12 | return true; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/posts/create-post-factory.ts: -------------------------------------------------------------------------------- 1 | import { CreatePostInterface } from '@application/interfaces/use-cases/posts/CreatePostInterface'; 2 | import { CreatePost } from '@application/use-cases/posts/CreatePost'; 3 | import { PostRepository } from '@infra/db/mongodb/repositories/PostRepository'; 4 | 5 | export const makeCreatePost = (): CreatePostInterface => { 6 | const postRepository = new PostRepository(); 7 | return new CreatePost(postRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/posts/delete-post-factory.ts: -------------------------------------------------------------------------------- 1 | import { DeletePostInterface } from '@application/interfaces/use-cases/posts/DeletePostInterface'; 2 | import { DeletePost } from '@application/use-cases/posts/DeletePost'; 3 | import { PostRepository } from '@infra/db/mongodb/repositories/PostRepository'; 4 | 5 | export const makeDeletePost = (): DeletePostInterface => { 6 | const postRepository = new PostRepository(); 7 | return new DeletePost(postRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /tests/infra/mocks/controllers.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@infra/http/controllers/BaseController'; 2 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 3 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 4 | import { ok } from '@infra/http/helpers/http'; 5 | 6 | export class ControllerStub extends BaseController { 7 | async execute(_httpRequest: HttpRequest): Promise { 8 | return ok('any_body'); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/infra/mocks/middlewares.ts: -------------------------------------------------------------------------------- 1 | import { BaseMiddleware } from '@infra/http/middlewares/BaseMiddleware'; 2 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 3 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 4 | import { ok } from '@infra/http/helpers/http'; 5 | 6 | export class MiddlewareStub extends BaseMiddleware { 7 | async execute(_httpRequest: HttpRequest): Promise { 8 | return ok('any_body'); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/posts/DeletePostInterface.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 2 | 3 | export interface DeletePostInterface 4 | extends UseCase { 5 | execute(postId: DeletePostInterface.Request): Promise; 6 | } 7 | 8 | export namespace DeletePostInterface { 9 | export type Request = string; 10 | export type Response = void; 11 | } 12 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/posts/get-post-by-id-factory.ts: -------------------------------------------------------------------------------- 1 | import { GetPostByIdInterface } from '@application/interfaces/use-cases/posts/GetPostByIdInterface'; 2 | import { GetPostById } from '@application/use-cases/posts/GetPostById'; 3 | import { PostRepository } from '@infra/db/mongodb/repositories/PostRepository'; 4 | 5 | export const makeGetPostById = (): GetPostByIdInterface => { 6 | const postRepository = new PostRepository(); 7 | return new GetPostById(postRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/posts/update-post-factory.ts: -------------------------------------------------------------------------------- 1 | import { UpdatePostInterface } from '@application/interfaces/use-cases/posts/UpdatePostInterface'; 2 | import { UpdatePost } from '@application/use-cases/posts/UpdatePost'; 3 | import { PostRepository } from '@infra/db/mongodb/repositories/PostRepository'; 4 | 5 | export const makeUpdatePost = (): UpdatePostInterface => { 6 | const postRepository = new PostRepository(); 7 | return new UpdatePost(postRepository, postRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/comments/CreateCommentRepository.ts: -------------------------------------------------------------------------------- 1 | import { CommentProps } from '@domain/entities/Comment'; 2 | 3 | export interface CreateCommentRepository { 4 | createComment( 5 | commentData: CreateCommentRepository.Request 6 | ): Promise; 7 | } 8 | 9 | export namespace CreateCommentRepository { 10 | export type Request = Omit; 11 | export type Response = string; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/factories/controllers/posts/get-post-by-id/controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@infra/http/controllers/BaseController'; 2 | import { GetPostByIdController } from '@infra/http/controllers/posts/GetPostByIdController'; 3 | import { makeGetPostById } from '@main/factories/use-cases/posts/get-post-by-id-factory'; 4 | 5 | export const makeGetPostByIdController = (): BaseController => { 6 | const useCase = makeGetPostById(); 7 | return new GetPostByIdController(useCase); 8 | }; 9 | -------------------------------------------------------------------------------- /src/main/factories/middlewares/auth-middleware-factory.ts: -------------------------------------------------------------------------------- 1 | import { BaseMiddleware } from '@infra/http/middlewares/BaseMiddleware'; 2 | import { AuthMiddleware } from '@infra/http/middlewares/authentication/AuthMiddleware'; 3 | import { makeAuthenticate } from '@main/factories/use-cases/authentication/authenticate-factory'; 4 | 5 | export const makeAuthMiddleware = (): BaseMiddleware => { 6 | const authenticateUseCase = makeAuthenticate(); 7 | return new AuthMiddleware(authenticateUseCase); 8 | }; 9 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/posts/get-latest-posts-factory.ts: -------------------------------------------------------------------------------- 1 | import { GetLatestPostsInterface } from '@application/interfaces/use-cases/posts/GetLatestPostsInterface'; 2 | import { GetLatestPosts } from '@application/use-cases/posts/GetLatestPosts'; 3 | import { PostRepository } from '@infra/db/mongodb/repositories/PostRepository'; 4 | 5 | export const makeGetLatestPosts = (): GetLatestPostsInterface => { 6 | const postRepository = new PostRepository(); 7 | return new GetLatestPosts(postRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/comments/DeleteCommentInterface.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 2 | 3 | export interface DeleteCommentInterface 4 | extends UseCase { 5 | execute(commentId: DeleteCommentInterface.Request): Promise; 6 | } 7 | 8 | export namespace DeleteCommentInterface { 9 | export type Request = string; 10 | export type Response = void; 11 | } 12 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/posts/UpdatePostRepository.ts: -------------------------------------------------------------------------------- 1 | import { PostProps, Post } from '@domain/entities/Post'; 2 | 3 | export interface UpdatePostRepository { 4 | updatePost(params: UpdatePostRepository.Request): Promise; 5 | } 6 | 7 | export namespace UpdatePostRepository { 8 | export type Request = { postId: string, postData: Partial> }; 9 | export type Response = Post; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/server.ts: -------------------------------------------------------------------------------- 1 | import 'module-alias/register'; 2 | import DbConnection from '@infra/db/mongodb/helpers/db-connection'; 3 | import setupApp from '@main/config/app'; 4 | import env from '@main/config/env'; 5 | 6 | DbConnection.connect(env.mongodbUrl) 7 | .then(async () => { 8 | const app = setupApp(); 9 | app.listen(env.port, () => { 10 | // eslint-disable-next-line no-console 11 | console.log(`Server is running on port ${env.port}`); 12 | }); 13 | }) 14 | .catch(console.error); 15 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/posts/UpdatePostTotalCommentsRepository.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@domain/entities/Post'; 2 | 3 | export interface UpdatePostTotalCommentsRepository { 4 | updatePostTotalComments( 5 | params: UpdatePostTotalCommentsRepository.Request 6 | ): Promise; 7 | } 8 | 9 | export namespace UpdatePostTotalCommentsRepository { 10 | export type Request = { postId: string, totalComments: number }; 11 | export type Response = Post; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/factories/controllers/posts/get-latest-posts/controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@infra/http/controllers/BaseController'; 2 | import { GetLatestPostsController } from '@infra/http/controllers/posts/GetLatestPostsController'; 3 | import { makeGetLatestPosts } from '@main/factories/use-cases/posts/get-latest-posts-factory'; 4 | 5 | export const makeGetLatestPostsController = (): BaseController => { 6 | const useCase = makeGetLatestPosts(); 7 | return new GetLatestPostsController(useCase); 8 | }; 9 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/comments/create-comment-factory.ts: -------------------------------------------------------------------------------- 1 | import { CreateCommentInterface } from '@application/interfaces/use-cases/comments/CreateCommentInterface'; 2 | import { CreateComment } from '@application/use-cases/comments/CreateComment'; 3 | import { CommentRepository } from '@infra/db/mongodb/repositories/CommentRepository'; 4 | 5 | export const makeCreateComment = (): CreateCommentInterface => { 6 | const commentRepository = new CommentRepository(); 7 | return new CreateComment(commentRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/comments/delete-comment-factory.ts: -------------------------------------------------------------------------------- 1 | import { DeleteCommentInterface } from '@application/interfaces/use-cases/comments/DeleteCommentInterface'; 2 | import { DeleteComment } from '@application/use-cases/comments/DeleteComment'; 3 | import { CommentRepository } from '@infra/db/mongodb/repositories/CommentRepository'; 4 | 5 | export const makeDeleteComment = (): DeleteCommentInterface => { 6 | const commentRepository = new CommentRepository(); 7 | return new DeleteComment(commentRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/posts/GetLatestPostsRepository.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@domain/entities/Post'; 2 | 3 | export interface GetLatestPostsRepository { 4 | getLatestPosts( 5 | params: GetLatestPostsRepository.Request 6 | ): Promise; 7 | } 8 | 9 | export namespace GetLatestPostsRepository { 10 | export type Request = { page: number, paginationLimit: number }; 11 | export type Response = { data: Post[], page: number, total: number, totalPages: number }; 12 | } 13 | -------------------------------------------------------------------------------- /src/infra/http/validations/RequiredFieldValidation.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '@infra/http/interfaces/Validation'; 2 | import { MissingParamError } from '@infra/http/errors/MissingParamError'; 3 | 4 | export class RequiredFieldValidation implements Validation { 5 | constructor( 6 | private readonly fieldName: string, 7 | ) {} 8 | 9 | validate(input: any): Error | null { 10 | if (!input[this.fieldName]) { 11 | return new MissingParamError(this.fieldName); 12 | } 13 | return null; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/comments/get-comment-by-id-factory.ts: -------------------------------------------------------------------------------- 1 | import { GetCommentByIdInterface } from '@application/interfaces/use-cases/comments/GetCommentByIdInterface'; 2 | import { GetCommentById } from '@application/use-cases/comments/GetCommentById'; 3 | import { CommentRepository } from '@infra/db/mongodb/repositories/CommentRepository'; 4 | 5 | export const makeGetCommentById = (): GetCommentByIdInterface => { 6 | const commentRepository = new CommentRepository(); 7 | return new GetCommentById(commentRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/comments/update-comment-factory.ts: -------------------------------------------------------------------------------- 1 | import { UpdateCommentInterface } from '@application/interfaces/use-cases/comments/UpdateCommentInterface'; 2 | import { UpdateComment } from '@application/use-cases/comments/UpdateComment'; 3 | import { CommentRepository } from '@infra/db/mongodb/repositories/CommentRepository'; 4 | 5 | export const makeUpdateComment = (): UpdateCommentInterface => { 6 | const commentRepository = new CommentRepository(); 7 | return new UpdateComment(commentRepository, commentRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/authentication/authenticate-factory.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticateInterface } from '@application/interfaces/use-cases/authentication/AuthenticateInterface'; 2 | import { Authenticate } from '@application/use-cases/authentication/Authenticate'; 3 | import { JWTAdapter } from '@infra/cryptography/JWTAdapter'; 4 | import env from '@main/config/env'; 5 | 6 | export const makeAuthenticate = (): AuthenticateInterface => { 7 | const jwtAdapter = new JWTAdapter(env.jwtSecret); 8 | return new Authenticate(jwtAdapter); 9 | }; 10 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/comments/UpdateCommentRepository.ts: -------------------------------------------------------------------------------- 1 | import { CommentProps, Comment } from '@domain/entities/Comment'; 2 | 3 | export interface UpdateCommentRepository { 4 | updateComment(params: UpdateCommentRepository.Request): Promise; 5 | } 6 | 7 | export namespace UpdateCommentRepository { 8 | export type Request = { commentId: string, commentData: Partial> }; 9 | export type Response = Comment; 10 | } 11 | -------------------------------------------------------------------------------- /src/infra/http/validations/ValidationComposite.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '@infra/http/interfaces/Validation'; 2 | 3 | export class ValidationComposite implements Validation { 4 | constructor( 5 | private readonly validations: Validation[], 6 | private readonly segment: string, 7 | ) {} 8 | 9 | validate(request: any): Error | null { 10 | const input = request[this.segment]; 11 | return this.validations.reduce( 12 | (error: Error | null, validation: Validation) => error || validation.validate(input), 13 | null, 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/comments/DeleteCommentsByPostIdInterface.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 2 | 3 | export interface DeleteCommentsByPostIdInterface 4 | extends 5 | UseCase { 6 | execute(postId: DeleteCommentsByPostIdInterface.Request): 7 | Promise; 8 | } 9 | 10 | export namespace DeleteCommentsByPostIdInterface { 11 | export type Request = string; 12 | export type Response = void; 13 | } 14 | -------------------------------------------------------------------------------- /src/main/routes/authentication-routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { expressRouteAdapter } from '@main/adapters/express-route-adapter'; 3 | import { makeSignInController } from '@main/factories/controllers/authentication/sign-in/controller-factory'; 4 | import { makeSignUpController } from '@main/factories/controllers/authentication/sign-up/controller-factory'; 5 | 6 | export default (router: Router): void => { 7 | router.post('/login', expressRouteAdapter(makeSignInController())); 8 | router.post('/register', expressRouteAdapter(makeSignUpController())); 9 | }; 10 | -------------------------------------------------------------------------------- /src/application/use-cases/posts/DeletePost.ts: -------------------------------------------------------------------------------- 1 | import { DeletePostInterface } from '@application/interfaces/use-cases/posts/DeletePostInterface'; 2 | import { DeletePostRepository } from '@application/interfaces/repositories/posts/DeletePostRepository'; 3 | 4 | export class DeletePost implements DeletePostInterface { 5 | constructor( 6 | private readonly deletePostRepository: DeletePostRepository, 7 | ) {} 8 | 9 | async execute(postId: DeletePostInterface.Request): Promise { 10 | await this.deletePostRepository.deletePost(postId); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/infra/http/middlewares/BaseMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 2 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 3 | import { serverError } from '@infra/http/helpers/http'; 4 | 5 | export abstract class BaseMiddleware { 6 | abstract execute(httpRequest: HttpRequest): Promise; 7 | 8 | async handle(httpRequest: HttpRequest): Promise { 9 | try { 10 | return await this.execute(httpRequest); 11 | } catch (error) { 12 | return serverError(error); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/comments/delete-comments-by-post-id-factory.ts: -------------------------------------------------------------------------------- 1 | import { DeleteCommentsByPostIdInterface } from '@application/interfaces/use-cases/comments/DeleteCommentsByPostIdInterface'; 2 | import { DeleteCommentsByPostId } from '@application/use-cases/comments/DeleteCommentsByPostId'; 3 | import { CommentRepository } from '@infra/db/mongodb/repositories/CommentRepository'; 4 | 5 | export const makeDeleteCommentsByPostId = (): DeleteCommentsByPostIdInterface => { 6 | const commentRepository = new CommentRepository(); 7 | return new DeleteCommentsByPostId(commentRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/authentication/SignInInterface.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 2 | import { UnauthorizedError } from '@application/errors/UnauthorizedError'; 3 | 4 | export interface SignInInterface 5 | extends UseCase { 6 | execute(credentials: SignInInterface.Request): Promise; 7 | } 8 | 9 | export namespace SignInInterface { 10 | export type Request = { email: string; password: string }; 11 | export type Response = string | UnauthorizedError; 12 | } 13 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/posts/UpdatePostTotalCommentsInterface.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 2 | 3 | export interface UpdatePostTotalCommentsInterface 4 | extends UseCase 5 | { 6 | execute( 7 | postId: UpdatePostTotalCommentsInterface.Request 8 | ): Promise; 9 | } 10 | 11 | export namespace UpdatePostTotalCommentsInterface { 12 | export type Request = string; 13 | export type Response = void; 14 | } 15 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/posts/CreatePostInterface.ts: -------------------------------------------------------------------------------- 1 | import { PostProps } from '@domain/entities/Post'; 2 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 3 | 4 | export interface CreatePostInterface 5 | extends UseCase { 6 | execute(postData: CreatePostInterface.Request): Promise; 7 | } 8 | 9 | export namespace CreatePostInterface { 10 | export type Request = Omit; 11 | export type Response = string; 12 | } 13 | -------------------------------------------------------------------------------- /tests/infra/mocks/comments/repositories.ts: -------------------------------------------------------------------------------- 1 | import { GetTotalCommentsByPostIdRepository } from '@application/interfaces/repositories/comments/GetTotalCommentsByPostIdRepository'; 2 | import { makeFakePost } from '@tests/domain/mocks/entities'; 3 | 4 | export class GetTotalCommentsByPostIdRepositoryStub implements GetTotalCommentsByPostIdRepository { 5 | async getTotalCommentsByPostId( 6 | _postId: GetTotalCommentsByPostIdRepository.Request, 7 | ): Promise { 8 | const { totalComments } = makeFakePost(); 9 | return totalComments; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/factories/controllers/comments/get-latest-comments-by-post-id/controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@infra/http/controllers/BaseController'; 2 | import { GetLatestCommentsByPostIdController } from '@infra/http/controllers/comments/GetLatestCommentsByPostIdController'; 3 | import { makeGetLatestCommentsByPostId } from '@main/factories/use-cases/comments/get-latest-comments-by-post-id-factory'; 4 | 5 | export const makeGetLatestCommentsByPostIdController = (): BaseController => { 6 | const useCase = makeGetLatestCommentsByPostId(); 7 | return new GetLatestCommentsByPostIdController(useCase); 8 | }; 9 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/comments/get-latest-comments-by-post-id-factory.ts: -------------------------------------------------------------------------------- 1 | import { GetLatestCommentsByPostIdInterface } from '@application/interfaces/use-cases/comments/GetLatestCommentsByPostIdInterface'; 2 | import { GetLatestCommentsByPostId } from '@application/use-cases/comments/GetLatestCommentsByPostId'; 3 | import { CommentRepository } from '@infra/db/mongodb/repositories/CommentRepository'; 4 | 5 | export const makeGetLatestCommentsByPostId = (): GetLatestCommentsByPostIdInterface => { 6 | const commentRepository = new CommentRepository(); 7 | return new GetLatestCommentsByPostId(commentRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /src/application/interfaces/repositories/comments/GetLatestCommentsByPostIdRepository.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from '@domain/entities/Comment'; 2 | 3 | export interface GetLatestCommentsByPostIdRepository { 4 | getLatestCommentsByPostId( 5 | params: GetLatestCommentsByPostIdRepository.Request 6 | ): Promise; 7 | } 8 | 9 | export namespace GetLatestCommentsByPostIdRepository { 10 | export type Request = { postId: string, page: number, paginationLimit: number }; 11 | export type Response = { data: Comment[], page: number, total: number, totalPages: number }; 12 | } 13 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/comments/CreateCommentInterface.ts: -------------------------------------------------------------------------------- 1 | import { CommentProps } from '@domain/entities/Comment'; 2 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 3 | 4 | export interface CreateCommentInterface 5 | extends UseCase { 6 | execute(commentData: CreateCommentInterface.Request): Promise; 7 | } 8 | 9 | export namespace CreateCommentInterface { 10 | export type Request = Omit; 11 | export type Response = string; 12 | } 13 | -------------------------------------------------------------------------------- /src/infra/cryptography/BcryptAdapter.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import { HashGenerator } from '@application/interfaces/cryptography/HashGenerator'; 3 | import { HashComparer } from '@application/interfaces/cryptography/HashComparer'; 4 | 5 | export class BcryptAdapter implements HashGenerator, HashComparer { 6 | constructor(private readonly salt: number) {} 7 | 8 | async hash(value: string): Promise { 9 | return bcrypt.hash(value, this.salt); 10 | } 11 | 12 | async compare(plaintext: string, hash: string): Promise { 13 | return bcrypt.compare(plaintext, hash); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/authentication/AuthenticateInterface.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 2 | import { ForbiddenError } from '@application/errors/ForbiddenError'; 3 | 4 | export interface AuthenticateInterface 5 | extends UseCase { 6 | execute( 7 | authenticationToken: AuthenticateInterface.Request 8 | ): Promise; 9 | } 10 | 11 | export namespace AuthenticateInterface { 12 | export type Request = string; 13 | export type Response = string | ForbiddenError; 14 | } 15 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/posts/GetLatestPostsInterface.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@domain/entities/Post'; 2 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 3 | 4 | export interface GetLatestPostsInterface 5 | extends UseCase { 6 | execute(params: GetLatestPostsInterface.Request): Promise; 7 | } 8 | 9 | export namespace GetLatestPostsInterface { 10 | export type Request = { page?: number }; 11 | export type Response = { data: Post[], page: number, total: number, totalPages: number }; 12 | } 13 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/posts/GetPostByIdInterface.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@domain/entities/Post'; 2 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 3 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 4 | 5 | export interface GetPostByIdInterface 6 | extends UseCase { 7 | execute(postId: GetPostByIdInterface.Request): Promise; 8 | } 9 | 10 | export namespace GetPostByIdInterface { 11 | export type Request = string; 12 | export type Response = Post | PostNotFoundError; 13 | } 14 | -------------------------------------------------------------------------------- /src/application/use-cases/posts/CreatePost.ts: -------------------------------------------------------------------------------- 1 | import { CreatePostInterface } from '@application/interfaces/use-cases/posts/CreatePostInterface'; 2 | import { CreatePostRepository } from '@application/interfaces/repositories/posts/CreatePostRepository'; 3 | 4 | export class CreatePost implements CreatePostInterface { 5 | constructor( 6 | private readonly createPostRepository: CreatePostRepository, 7 | ) {} 8 | 9 | async execute(postData: CreatePostInterface.Request): Promise { 10 | return this.createPostRepository.createPost({ 11 | ...postData, 12 | totalComments: 0, 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/authentication/SignUpInterface.ts: -------------------------------------------------------------------------------- 1 | import { UserProps } from '@domain/entities/User'; 2 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 3 | import { EmailInUseError } from '@application/errors/EmailInUseError'; 4 | 5 | export interface SignUpInterface 6 | extends UseCase { 7 | execute(userData: SignUpInterface.Request): Promise; 8 | } 9 | 10 | export namespace SignUpInterface { 11 | export type Request = Omit; 12 | export type Response = string | EmailInUseError; 13 | } 14 | -------------------------------------------------------------------------------- /src/main/factories/controllers/authentication/sign-in/controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@infra/http/controllers/BaseController'; 2 | import { SignInController } from '@infra/http/controllers/authentication/SignInController'; 3 | import { makeSignInValidation } from '@main/factories/controllers/authentication/sign-in/validation-factory'; 4 | import { makeSignIn } from '@main/factories/use-cases/authentication/sign-in-factory'; 5 | 6 | export const makeSignInController = (): BaseController => { 7 | const validation = makeSignInValidation(); 8 | const useCase = makeSignIn(); 9 | return new SignInController(validation, useCase); 10 | }; 11 | -------------------------------------------------------------------------------- /src/application/use-cases/comments/DeleteComment.ts: -------------------------------------------------------------------------------- 1 | import { DeleteCommentInterface } from '@application/interfaces/use-cases/comments/DeleteCommentInterface'; 2 | import { DeleteCommentRepository } from '@application/interfaces/repositories/comments/DeleteCommentRepository'; 3 | 4 | export class DeleteComment implements DeleteCommentInterface { 5 | constructor( 6 | private readonly deleteCommentRepository: DeleteCommentRepository, 7 | ) {} 8 | 9 | async execute( 10 | commentId: DeleteCommentInterface.Request, 11 | ): Promise { 12 | await this.deleteCommentRepository.deleteComment(commentId); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/application/use-cases/comments/CreateComment.ts: -------------------------------------------------------------------------------- 1 | import { CreateCommentInterface } from '@application/interfaces/use-cases/comments/CreateCommentInterface'; 2 | import { CreateCommentRepository } from '@application/interfaces/repositories/comments/CreateCommentRepository'; 3 | 4 | export class CreateComment implements CreateCommentInterface { 5 | constructor( 6 | private readonly createCommentRepository: CreateCommentRepository, 7 | ) {} 8 | 9 | async execute( 10 | commentData: CreateCommentInterface.Request, 11 | ): Promise { 12 | return this.createCommentRepository.createComment(commentData); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/factories/controllers/posts/create-post/controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@infra/http/controllers/BaseController'; 2 | import { CreatePostController } from '@infra/http/controllers/posts/CreatePostController'; 3 | import { makeCreatePostValidation } from '@main/factories/controllers/posts/create-post/validation-factory'; 4 | import { makeCreatePost } from '@main/factories/use-cases/posts/create-post-factory'; 5 | 6 | export const makeCreatePostController = (): BaseController => { 7 | const validation = makeCreatePostValidation(); 8 | const useCase = makeCreatePost(); 9 | return new CreatePostController(validation, useCase); 10 | }; 11 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/authentication/sign-up-factory.ts: -------------------------------------------------------------------------------- 1 | import { SignUpInterface } from '@application/interfaces/use-cases/authentication/SignUpInterface'; 2 | import { SignUp } from '@application/use-cases/authentication/SignUp'; 3 | import { UserRepository } from '@infra/db/mongodb/repositories/UserRepository'; 4 | import { BcryptAdapter } from '@infra/cryptography/BcryptAdapter'; 5 | import env from '@main/config/env'; 6 | 7 | export const makeSignUp = (): SignUpInterface => { 8 | const userRepository = new UserRepository(); 9 | const bcryptAdapter = new BcryptAdapter(env.bcryptSalt); 10 | return new SignUp(userRepository, userRepository, bcryptAdapter); 11 | }; 12 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/comments/GetCommentByIdInterface.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from '@domain/entities/Comment'; 2 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 3 | import { CommentNotFoundError } from '@application/errors/CommentNotFoundError'; 4 | 5 | export interface GetCommentByIdInterface 6 | extends UseCase { 7 | execute(commentId: GetCommentByIdInterface.Request): Promise; 8 | } 9 | 10 | export namespace GetCommentByIdInterface { 11 | export type Request = string; 12 | export type Response = Comment | CommentNotFoundError; 13 | } 14 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/helpers/mapper.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | 3 | export const isValidObjectId = (id: string): boolean => ObjectId.isValid(id); 4 | 5 | export const objectIdToString = (objectId: ObjectId): string => objectId.toHexString(); 6 | 7 | export const stringToObjectId = (string: string): ObjectId => new ObjectId(string); 8 | 9 | export const mapDocument = (document: any): any => { 10 | const { _id: objectId, ...rest } = document; 11 | const id = objectIdToString(objectId); 12 | return { ...rest, id }; 13 | }; 14 | 15 | export const mapCollection = (collection: any[]): any[] => { 16 | return collection.map((document) => mapDocument(document)); 17 | }; 18 | -------------------------------------------------------------------------------- /src/infra/cryptography/JWTAdapter.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import { JWTGenerator } from '@application/interfaces/cryptography/JWTGenerator'; 3 | import { JWTVerifier } from '@application/interfaces/cryptography/JWTVerifier'; 4 | 5 | export class JWTAdapter implements JWTGenerator, JWTVerifier { 6 | constructor(private readonly secret: string) {} 7 | 8 | async generate(payload: string): Promise { 9 | return jwt.sign(payload, this.secret); 10 | } 11 | 12 | async verify(token: string): Promise { 13 | try { 14 | return jwt.verify(token, this.secret) as string; 15 | } catch (error) { 16 | return null; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | mongodb: 4 | container_name: simple-blog-mongodb 5 | image: mongo:4 6 | restart: always 7 | volumes: 8 | - ./data/db:/data/db 9 | ports: 10 | - "27017:27017" 11 | api: 12 | container_name: simple-blog-api 13 | image: node:14 14 | working_dir: /usr/src/app 15 | restart: always 16 | command: bash -c "npm install && npm run dev" 17 | environment: 18 | - MONGO_URL=mongodb://mongodb:27017/simple-blog-db 19 | volumes: 20 | - ./dist/:/usr/src/app/dist/ 21 | - ./package.json:/usr/src/app/package.json 22 | ports: 23 | - "5050:5050" 24 | - "9222:9222" 25 | links: 26 | - mongodb -------------------------------------------------------------------------------- /src/infra/http/validations/EmailValidation.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '@infra/http/interfaces/Validation'; 2 | import { EmailValidator } from '@infra/http/validations/interfaces/EmailValidator'; 3 | import { InvalidParamError } from '@infra/http/errors/InvalidParamError'; 4 | 5 | export class EmailValidation implements Validation { 6 | constructor( 7 | private readonly fieldName: string, 8 | private readonly emailValidator: EmailValidator, 9 | ) {} 10 | 11 | validate(input: any): Error | null { 12 | const isValid = this.emailValidator.isValid(input[this.fieldName]); 13 | if (!isValid) { 14 | return new InvalidParamError(this.fieldName); 15 | } 16 | return null; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/infra/db/mongodb/helpers/db-connection.spec.ts: -------------------------------------------------------------------------------- 1 | import DbConnection from '@infra/db/mongodb/helpers/db-connection'; 2 | import env from '@main/config/env'; 3 | 4 | describe('DbConnection', () => { 5 | beforeAll(async () => { 6 | await DbConnection.connect(env.mongodbUrl); 7 | }); 8 | 9 | afterAll(async () => { 10 | await DbConnection.disconnect(); 11 | }); 12 | 13 | it('should reconnect if mongodb is down', async () => { 14 | let collection = await DbConnection.getCollection('posts'); 15 | expect(collection).toBeTruthy(); 16 | await DbConnection.disconnect(); 17 | collection = await DbConnection.getCollection('posts'); 18 | expect(collection).toBeTruthy(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/posts/update-post-total-comments-factory.ts: -------------------------------------------------------------------------------- 1 | import { UpdatePostTotalCommentsInterface } from '@application/interfaces/use-cases/posts/UpdatePostTotalCommentsInterface'; 2 | import { UpdatePostTotalComments } from '@application/use-cases/posts/UpdatePostTotalComments'; 3 | import { CommentRepository } from '@infra/db/mongodb/repositories/CommentRepository'; 4 | import { PostRepository } from '@infra/db/mongodb/repositories/PostRepository'; 5 | 6 | export const makeUpdatePostTotalComments = (): UpdatePostTotalCommentsInterface => { 7 | const commentRepository = new CommentRepository(); 8 | const postRepository = new PostRepository(); 9 | return new UpdatePostTotalComments(commentRepository, postRepository); 10 | }; 11 | -------------------------------------------------------------------------------- /src/main/factories/controllers/authentication/sign-in/validation-factory.ts: -------------------------------------------------------------------------------- 1 | import { ValidationComposite } from '@infra/http/validations/ValidationComposite'; 2 | import { RequiredFieldValidation } from '@infra/http/validations/RequiredFieldValidation'; 3 | import { EmailValidation } from '@infra/http/validations/EmailValidation'; 4 | import { EmailValidatorAdapter } from '@infra/http/validators/EmailValidatorAdapter'; 5 | 6 | export const makeSignInValidation = (): ValidationComposite => { 7 | const emailValidator = new EmailValidatorAdapter(); 8 | return new ValidationComposite([ 9 | new RequiredFieldValidation('email'), 10 | new RequiredFieldValidation('password'), 11 | new EmailValidation('email', emailValidator), 12 | ], 'body'); 13 | }; 14 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/comments/GetLatestCommentsByPostIdInterface.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from '@domain/entities/Comment'; 2 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 3 | 4 | export interface GetLatestCommentsByPostIdInterface 5 | extends UseCase< 6 | GetLatestCommentsByPostIdInterface.Request, 7 | GetLatestCommentsByPostIdInterface.Response> { 8 | execute( 9 | params: GetLatestCommentsByPostIdInterface.Request 10 | ): Promise; 11 | } 12 | 13 | export namespace GetLatestCommentsByPostIdInterface { 14 | export type Request = { postId: string, page?: number }; 15 | export type Response = { data: Comment[], page: number, total: number, totalPages: number }; 16 | } 17 | -------------------------------------------------------------------------------- /src/application/use-cases/comments/DeleteCommentsByPostId.ts: -------------------------------------------------------------------------------- 1 | import { DeleteCommentsByPostIdInterface } from '@application/interfaces/use-cases/comments/DeleteCommentsByPostIdInterface'; 2 | import { DeleteCommentsByPostIdRepository } from '@application/interfaces/repositories/comments/DeleteCommentsByPostIdRepository'; 3 | 4 | export class DeleteCommentsByPostId implements DeleteCommentsByPostIdInterface { 5 | constructor( 6 | private readonly deleteCommentsByPostIdRepository: DeleteCommentsByPostIdRepository, 7 | ) {} 8 | 9 | async execute( 10 | postId: DeleteCommentsByPostIdInterface.Request, 11 | ): Promise { 12 | await this.deleteCommentsByPostIdRepository.deleteCommentsByPostId(postId); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/application/use-cases/posts/GetPostById.ts: -------------------------------------------------------------------------------- 1 | import { GetPostByIdInterface } from '@application/interfaces/use-cases/posts/GetPostByIdInterface'; 2 | import { GetPostByIdRepository } from '@application/interfaces/repositories/posts/GetPostByIdRepository'; 3 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 4 | 5 | export class GetPostById implements GetPostByIdInterface { 6 | constructor( 7 | private readonly getPostByIdRepository: GetPostByIdRepository, 8 | ) {} 9 | 10 | async execute(postId: GetPostByIdInterface.Request): Promise { 11 | const post = await this.getPostByIdRepository.getPostById(postId); 12 | if (!post) { 13 | return new PostNotFoundError(); 14 | } 15 | return post; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/infra/http/validations/RequiredFieldValidation.spec.ts: -------------------------------------------------------------------------------- 1 | import { RequiredFieldValidation } from '@infra/http/validations/RequiredFieldValidation'; 2 | import { MissingParamError } from '@infra/http/errors/MissingParamError'; 3 | 4 | const makeSut = (): RequiredFieldValidation => new RequiredFieldValidation('any_field'); 5 | 6 | describe('RequiredFieldValidation', () => { 7 | it('should return a MissingParamError on failure', () => { 8 | const sut = makeSut(); 9 | const error = sut.validate({}); 10 | expect(error).toEqual(new MissingParamError('any_field')); 11 | }); 12 | 13 | it('should return null on success', () => { 14 | const sut = makeSut(); 15 | const error = sut.validate({ any_field: 'any_value' }); 16 | expect(error).toBeNull(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/main/factories/use-cases/authentication/sign-in-factory.ts: -------------------------------------------------------------------------------- 1 | import { SignInInterface } from '@application/interfaces/use-cases/authentication/SignInInterface'; 2 | import { SignIn } from '@application/use-cases/authentication/SignIn'; 3 | import { UserRepository } from '@infra/db/mongodb/repositories/UserRepository'; 4 | import { BcryptAdapter } from '@infra/cryptography/BcryptAdapter'; 5 | import { JWTAdapter } from '@infra/cryptography/JWTAdapter'; 6 | import env from '@main/config/env'; 7 | 8 | export const makeSignIn = (): SignInInterface => { 9 | const userRepository = new UserRepository(); 10 | const bcryptAdapter = new BcryptAdapter(env.bcryptSalt); 11 | const jwtAdapter = new JWTAdapter(env.jwtSecret); 12 | return new SignIn(userRepository, bcryptAdapter, jwtAdapter); 13 | }; 14 | -------------------------------------------------------------------------------- /src/application/use-cases/authentication/Authenticate.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticateInterface } from '@application/interfaces/use-cases/authentication/AuthenticateInterface'; 2 | import { JWTVerifier } from '@application/interfaces/cryptography/JWTVerifier'; 3 | import { ForbiddenError } from '@application/errors/ForbiddenError'; 4 | 5 | export class Authenticate implements AuthenticateInterface { 6 | constructor( 7 | private readonly jwtVerifier: JWTVerifier, 8 | ) {} 9 | 10 | async execute( 11 | authenticationToken: AuthenticateInterface.Request, 12 | ): Promise { 13 | const decodedToken = await this.jwtVerifier.verify(authenticationToken); 14 | if (!decodedToken) { 15 | return new ForbiddenError(); 16 | } 17 | return decodedToken; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/factories/controllers/authentication/sign-up/controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@infra/http/controllers/BaseController'; 2 | import { SignUpController } from '@infra/http/controllers/authentication/SignUpController'; 3 | import { makeSignUpValidation } from '@main/factories/controllers/authentication/sign-up/validation-factory'; 4 | import { makeSignUp } from '@main/factories/use-cases/authentication/sign-up-factory'; 5 | import { makeSignIn } from '@main/factories/use-cases/authentication/sign-in-factory'; 6 | 7 | export const makeSignUpController = (): BaseController => { 8 | const validation = makeSignUpValidation(); 9 | const signUpUseCase = makeSignUp(); 10 | const signInUseCase = makeSignIn(); 11 | return new SignUpController(validation, signUpUseCase, signInUseCase); 12 | }; 13 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/posts/UpdatePostInterface.ts: -------------------------------------------------------------------------------- 1 | import { PostProps, Post } from '@domain/entities/Post'; 2 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 3 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 4 | 5 | export interface UpdatePostInterface 6 | extends UseCase { 7 | execute(params: UpdatePostInterface.Request): Promise; 8 | } 9 | 10 | export namespace UpdatePostInterface { 11 | export type PostIdType = string; 12 | export type PostDataType = Partial>; 13 | export type Request = { postId: PostIdType, postData: PostDataType }; 14 | export type Response = Post | PostNotFoundError; 15 | } 16 | -------------------------------------------------------------------------------- /src/application/use-cases/posts/GetLatestPosts.ts: -------------------------------------------------------------------------------- 1 | import { GetLatestPostsInterface } from '@application/interfaces/use-cases/posts/GetLatestPostsInterface'; 2 | import { GetLatestPostsRepository } from '@application/interfaces/repositories/posts/GetLatestPostsRepository'; 3 | import { paginationConfig } from '@application/config/pagination'; 4 | 5 | export class GetLatestPosts implements GetLatestPostsInterface { 6 | constructor( 7 | private readonly getLatestPostsRepository: GetLatestPostsRepository, 8 | ) {} 9 | 10 | async execute( 11 | params: GetLatestPostsInterface.Request, 12 | ): Promise { 13 | const { page = 1 } = params; 14 | const { paginationLimit } = paginationConfig; 15 | return this.getLatestPostsRepository.getLatestPosts({ page, paginationLimit }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/adapters/express-middleware-adapter.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { BaseMiddleware } from '@infra/http/middlewares/BaseMiddleware'; 3 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 4 | 5 | export const expressMiddlewareAdapter = ( 6 | middleware: BaseMiddleware, 7 | ) => async (req: Request, res: Response, next: NextFunction) => { 8 | const httpRequest: HttpRequest = { 9 | body: req.body, 10 | params: req.params, 11 | headers: req.headers, 12 | }; 13 | const httpResponse = await middleware.handle(httpRequest); 14 | if (httpResponse.statusCode === 200) { 15 | Object.assign(req, httpResponse.body); 16 | next(); 17 | } else { 18 | res.status(httpResponse.statusCode).json({ 19 | error: httpResponse.body?.message, 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/main/factories/controllers/posts/update-post/controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@infra/http/controllers/BaseController'; 2 | import { UpdatePostController } from '@infra/http/controllers/posts/UpdatePostController'; 3 | import { makeUpdatePostValidation } from '@main/factories/controllers/posts/update-post/validation-factory'; 4 | import { makeGetPostById } from '@main/factories/use-cases/posts/get-post-by-id-factory'; 5 | import { makeUpdatePost } from '@main/factories/use-cases/posts/update-post-factory'; 6 | 7 | export const makeUpdatePostController = (): BaseController => { 8 | const validation = makeUpdatePostValidation(); 9 | const getPostByIdUseCase = makeGetPostById(); 10 | const updatePostUseCase = makeUpdatePost(); 11 | return new UpdatePostController(validation, getPostByIdUseCase, updatePostUseCase); 12 | }; 13 | -------------------------------------------------------------------------------- /src/main/factories/controllers/authentication/sign-up/validation-factory.ts: -------------------------------------------------------------------------------- 1 | import { ValidationComposite } from '@infra/http/validations/ValidationComposite'; 2 | import { RequiredFieldValidation } from '@infra/http/validations/RequiredFieldValidation'; 3 | import { EmailValidation } from '@infra/http/validations/EmailValidation'; 4 | import { EmailValidatorAdapter } from '@infra/http/validators/EmailValidatorAdapter'; 5 | 6 | export const makeSignUpValidation = (): ValidationComposite => { 7 | const emailValidator = new EmailValidatorAdapter(); 8 | return new ValidationComposite([ 9 | new RequiredFieldValidation('name'), 10 | new RequiredFieldValidation('username'), 11 | new RequiredFieldValidation('email'), 12 | new RequiredFieldValidation('password'), 13 | new EmailValidation('email', emailValidator), 14 | ], 'body'); 15 | }; 16 | -------------------------------------------------------------------------------- /src/domain/entities/Comment.ts: -------------------------------------------------------------------------------- 1 | export type CommentProps = { 2 | id: string; 3 | userId: string; 4 | postId: string; 5 | title?: string; 6 | text: string; 7 | createdAt: Date; 8 | updatedAt?: Date; 9 | }; 10 | 11 | export class Comment { 12 | public readonly id: string; 13 | 14 | public readonly userId: string; 15 | 16 | public readonly postId: string; 17 | 18 | public readonly title?: string; 19 | 20 | public readonly text: string; 21 | 22 | public readonly createdAt: Date; 23 | 24 | public readonly updatedAt?: Date; 25 | 26 | constructor(props: CommentProps) { 27 | this.id = props.id; 28 | this.userId = props.userId; 29 | this.postId = props.postId; 30 | this.title = props.title; 31 | this.text = props.text; 32 | this.createdAt = props.createdAt; 33 | this.updatedAt = props.updatedAt; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/domain/entities/User.ts: -------------------------------------------------------------------------------- 1 | export type UserProps = { 2 | id: string; 3 | name: string; 4 | username: string; 5 | email: string; 6 | password: string; 7 | createdAt: Date; 8 | updatedAt?: Date; 9 | }; 10 | 11 | export class User { 12 | public readonly id: string; 13 | 14 | public readonly name: string; 15 | 16 | public readonly username: string; 17 | 18 | public readonly email: string; 19 | 20 | public readonly password: string; 21 | 22 | public readonly createdAt: Date; 23 | 24 | public readonly updatedAt?: Date; 25 | 26 | constructor(props: UserProps) { 27 | this.id = props.id; 28 | this.name = props.name; 29 | this.username = props.username; 30 | this.email = props.email; 31 | this.password = props.password; 32 | this.createdAt = props.createdAt; 33 | this.updatedAt = props.updatedAt; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/helpers/db-connection.ts: -------------------------------------------------------------------------------- 1 | import { Collection, MongoClient } from 'mongodb'; 2 | 3 | class DbConnection { 4 | private url?: string; 5 | 6 | private client?: MongoClient; 7 | 8 | async connect(url: string): Promise { 9 | this.url = url; 10 | this.client = new MongoClient(url); 11 | await this.client.connect(); 12 | } 13 | 14 | async disconnect(): Promise { 15 | await this.client?.close(); 16 | this.client = undefined; 17 | } 18 | 19 | async getCollection(name: string): Promise { 20 | if (!this.client && this.url) { 21 | await this.connect(this.url); 22 | } 23 | const db = this.client?.db(); 24 | if (!db) { 25 | throw new Error('Database is not connected'); 26 | } 27 | return db.collection(name); 28 | } 29 | } 30 | 31 | export default new DbConnection(); 32 | -------------------------------------------------------------------------------- /src/infra/http/controllers/BaseController.ts: -------------------------------------------------------------------------------- 1 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 2 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 3 | import { Validation } from '@infra/http/interfaces/Validation'; 4 | import { badRequest, serverError } from '@infra/http/helpers/http'; 5 | 6 | export abstract class BaseController { 7 | constructor(private readonly validation?: Validation) {} 8 | 9 | abstract execute(httpRequest: HttpRequest): Promise; 10 | 11 | async handle(httpRequest: HttpRequest): Promise { 12 | try { 13 | const error = this.validation?.validate(httpRequest); 14 | if (error) { 15 | return badRequest(error); 16 | } 17 | return await this.execute(httpRequest); 18 | } catch (error) { 19 | return serverError(error); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/adapters/express-route-adapter.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { BaseController } from '@infra/http/controllers/BaseController'; 3 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 4 | 5 | export const expressRouteAdapter = ( 6 | controller: BaseController, 7 | ) => async (req: Request, res: Response) => { 8 | const httpRequest: HttpRequest = { 9 | body: req.body, 10 | params: req.params, 11 | headers: req.headers, 12 | userId: req.userId, 13 | }; 14 | const httpResponse = await controller.handle(httpRequest); 15 | if (httpResponse.statusCode >= 200 && httpResponse.statusCode <= 299) { 16 | res.status(httpResponse.statusCode).json(httpResponse.body); 17 | } else { 18 | res.status(httpResponse.statusCode).json({ 19 | error: httpResponse.body?.message, 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/application/use-cases/comments/GetCommentById.ts: -------------------------------------------------------------------------------- 1 | import { GetCommentByIdInterface } from '@application/interfaces/use-cases/comments/GetCommentByIdInterface'; 2 | import { GetCommentByIdRepository } from '@application/interfaces/repositories/comments/GetCommentByIdRepository'; 3 | import { CommentNotFoundError } from '@application/errors/CommentNotFoundError'; 4 | 5 | export class GetCommentById implements GetCommentByIdInterface { 6 | constructor( 7 | private readonly getCommentByIdRepository: GetCommentByIdRepository, 8 | ) {} 9 | 10 | async execute( 11 | commentId: GetCommentByIdInterface.Request, 12 | ): Promise { 13 | const comment = await this.getCommentByIdRepository.getCommentById(commentId); 14 | if (!comment) { 15 | return new CommentNotFoundError(); 16 | } 17 | return comment; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/domain/entities/Post.ts: -------------------------------------------------------------------------------- 1 | export type PostProps = { 2 | id: string; 3 | userId: string; 4 | title: string; 5 | text: string; 6 | totalComments: number; 7 | createdAt: Date; 8 | updatedAt?: Date; 9 | }; 10 | 11 | export class Post { 12 | public readonly id: string; 13 | 14 | public readonly userId: string; 15 | 16 | public readonly title: string; 17 | 18 | public readonly text: string; 19 | 20 | public readonly totalComments: number; 21 | 22 | public readonly createdAt: Date; 23 | 24 | public readonly updatedAt?: Date; 25 | 26 | constructor(props: PostProps) { 27 | this.id = props.id; 28 | this.userId = props.userId; 29 | this.title = props.title; 30 | this.text = props.text; 31 | this.totalComments = props.totalComments; 32 | this.createdAt = props.createdAt; 33 | this.updatedAt = props.updatedAt; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/application/interfaces/use-cases/comments/UpdateCommentInterface.ts: -------------------------------------------------------------------------------- 1 | import { CommentProps, Comment } from '@domain/entities/Comment'; 2 | import { UseCase } from '@application/interfaces/use-cases/UseCase'; 3 | import { CommentNotFoundError } from '@application/errors/CommentNotFoundError'; 4 | 5 | export interface UpdateCommentInterface 6 | extends UseCase { 7 | execute(params: UpdateCommentInterface.Request): Promise; 8 | } 9 | 10 | export namespace UpdateCommentInterface { 11 | export type CommentIdType = string; 12 | export type CommentDataType = Partial>; 13 | export type Request = { commentId: CommentIdType, commentData: CommentDataType }; 14 | export type Response = Comment | CommentNotFoundError; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/factories/controllers/comments/update-comment/controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@infra/http/controllers/BaseController'; 2 | import { UpdateCommentController } from '@infra/http/controllers/comments/UpdateCommentController'; 3 | import { makeUpdateCommentValidation } from '@main/factories/controllers/comments/update-comment/validation-factory'; 4 | import { makeGetCommentById } from '@main/factories/use-cases/comments/get-comment-by-id-factory'; 5 | import { makeUpdateComment } from '@main/factories/use-cases/comments/update-comment-factory'; 6 | 7 | export const makeUpdateCommentController = (): BaseController => { 8 | const validation = makeUpdateCommentValidation(); 9 | const getCommentByIdUseCase = makeGetCommentById(); 10 | const updateCommentUseCase = makeUpdateComment(); 11 | return new UpdateCommentController(validation, getCommentByIdUseCase, updateCommentUseCase); 12 | }; 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "airbnb-base", 8 | "airbnb-typescript/base" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": "latest", 13 | "sourceType": "module", 14 | "project": "./tsconfig.json" 15 | }, 16 | "plugins": [ 17 | "@typescript-eslint" 18 | ], 19 | "ignorePatterns": [ 20 | "jest.config.ts" 21 | ], 22 | "rules": { 23 | "import/export": "off", 24 | "import/prefer-default-export": "off", 25 | "class-methods-use-this": "off", 26 | "max-classes-per-file": "off", 27 | "arrow-body-style": "off", 28 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 29 | "@typescript-eslint/no-empty-function": "off" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/factories/controllers/posts/delete-post/controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@infra/http/controllers/BaseController'; 2 | import { DeletePostController } from '@infra/http/controllers/posts/DeletePostController'; 3 | import { makeGetPostById } from '@main/factories/use-cases/posts/get-post-by-id-factory'; 4 | import { makeDeletePost } from '@main/factories/use-cases/posts/delete-post-factory'; 5 | import { makeDeleteCommentsByPostId } from '@main/factories/use-cases/comments/delete-comments-by-post-id-factory'; 6 | 7 | export const makeDeletePostController = (): BaseController => { 8 | const getPostByIdUseCase = makeGetPostById(); 9 | const deletePostUseCase = makeDeletePost(); 10 | const deleteCommentsByPostIdUseCase = makeDeleteCommentsByPostId(); 11 | return new DeletePostController( 12 | getPostByIdUseCase, 13 | deletePostUseCase, 14 | deleteCommentsByPostIdUseCase, 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/main/factories/controllers/comments/delete-comment/controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@infra/http/controllers/BaseController'; 2 | import { DeleteCommentController } from '@infra/http/controllers/comments/DeleteCommentController'; 3 | import { makeGetCommentById } from '@main/factories/use-cases/comments/get-comment-by-id-factory'; 4 | import { makeDeleteComment } from '@main/factories/use-cases/comments/delete-comment-factory'; 5 | import { makeUpdatePostTotalComments } from '@main/factories/use-cases/posts/update-post-total-comments-factory'; 6 | 7 | export const makeDeleteCommentController = (): BaseController => { 8 | const getCommentByIdUseCase = makeGetCommentById(); 9 | const deleteCommentUseCase = makeDeleteComment(); 10 | const updatePostTotalCommentsUseCase = makeUpdatePostTotalComments(); 11 | return new DeleteCommentController( 12 | getCommentByIdUseCase, 13 | deleteCommentUseCase, 14 | updatePostTotalCommentsUseCase, 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/application/use-cases/comments/GetLatestCommentsByPostId.ts: -------------------------------------------------------------------------------- 1 | import { GetLatestCommentsByPostIdInterface } from '@application/interfaces/use-cases/comments/GetLatestCommentsByPostIdInterface'; 2 | import { GetLatestCommentsByPostIdRepository } from '@application/interfaces/repositories/comments/GetLatestCommentsByPostIdRepository'; 3 | import { paginationConfig } from '@application/config/pagination'; 4 | 5 | export class GetLatestCommentsByPostId implements GetLatestCommentsByPostIdInterface { 6 | constructor( 7 | private readonly getLatestCommentsByPostIdRepository: GetLatestCommentsByPostIdRepository, 8 | ) {} 9 | 10 | async execute( 11 | params: GetLatestCommentsByPostIdInterface.Request, 12 | ): Promise { 13 | const { postId, page = 1 } = params; 14 | const { paginationLimit } = paginationConfig; 15 | return this.getLatestCommentsByPostIdRepository 16 | .getLatestCommentsByPostId({ postId, page, paginationLimit }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/application/use-cases/posts/DeletePost.spec.ts: -------------------------------------------------------------------------------- 1 | import { DeletePost } from '@application/use-cases/posts/DeletePost'; 2 | import { DeletePostRepositoryStub } from '@tests/infra/mocks/posts/repositories'; 3 | import { makeFakePost } from '@tests/domain/mocks/entities'; 4 | 5 | type SutTypes = { 6 | sut: DeletePost; 7 | deletePostRepositoryStub: DeletePostRepositoryStub; 8 | }; 9 | 10 | const makeSut = (): SutTypes => { 11 | const deletePostRepositoryStub = new DeletePostRepositoryStub(); 12 | const sut = new DeletePost(deletePostRepositoryStub); 13 | return { 14 | sut, 15 | deletePostRepositoryStub, 16 | }; 17 | }; 18 | 19 | describe('DeletePost', () => { 20 | it('should call DeletePostRepository with correct post id', async () => { 21 | const { sut, deletePostRepositoryStub } = makeSut(); 22 | const deletePostSpy = jest.spyOn(deletePostRepositoryStub, 'deletePost'); 23 | const { id } = makeFakePost(); 24 | await sut.execute(id); 25 | expect(deletePostSpy).toHaveBeenCalledWith(id); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/application/use-cases/posts/UpdatePost.ts: -------------------------------------------------------------------------------- 1 | import { UpdatePostInterface } from '@application/interfaces/use-cases/posts/UpdatePostInterface'; 2 | import { GetPostByIdRepository } from '@application/interfaces/repositories/posts/GetPostByIdRepository'; 3 | import { UpdatePostRepository } from '@application/interfaces/repositories/posts/UpdatePostRepository'; 4 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 5 | 6 | export class UpdatePost implements UpdatePostInterface { 7 | constructor( 8 | private readonly getPostByIdRepository: GetPostByIdRepository, 9 | private readonly updatePostRepository: UpdatePostRepository, 10 | ) {} 11 | 12 | async execute(params: UpdatePostInterface.Request): Promise { 13 | const { postId, postData } = params; 14 | const post = await this.getPostByIdRepository.getPostById(postId); 15 | if (!post) { 16 | return new PostNotFoundError(); 17 | } 18 | return this.updatePostRepository.updatePost({ postId, postData }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [14.x] 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v2 21 | 22 | - name: Set up Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: 'npm' 27 | 28 | - name: Cache dependencies 29 | uses: actions/cache@v2 30 | with: 31 | path: | 32 | **/node_modules 33 | key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 34 | 35 | - name: Install dependencies 36 | run: npm install 37 | 38 | - name: Run all tests and generate coverage report 39 | run: npm run test:ci 40 | 41 | - name: Upload coverage to Coveralls 42 | uses: coverallsapp/github-action@master 43 | with: 44 | github-token: ${{ secrets.GITHUB_TOKEN }} 45 | -------------------------------------------------------------------------------- /src/infra/http/controllers/posts/GetLatestPostsController.ts: -------------------------------------------------------------------------------- 1 | import { GetLatestPostsInterface } from '@application/interfaces/use-cases/posts/GetLatestPostsInterface'; 2 | import { BaseController } from '@infra/http/controllers/BaseController'; 3 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 4 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 5 | import { ok } from '@infra/http/helpers/http'; 6 | 7 | export class GetLatestPostsController extends BaseController { 8 | constructor( 9 | private readonly getLatestPosts: GetLatestPostsInterface, 10 | ) { 11 | super(); 12 | } 13 | 14 | async execute( 15 | httpRequest: GetLatestPostsController.Request, 16 | ): Promise { 17 | const { page } = httpRequest.params!; 18 | const response = await this.getLatestPosts.execute({ page }); 19 | return ok(response); 20 | } 21 | } 22 | 23 | export namespace GetLatestPostsController { 24 | export type Request = HttpRequest; 25 | export type Response = HttpResponse; 26 | } 27 | -------------------------------------------------------------------------------- /src/infra/http/helpers/http.ts: -------------------------------------------------------------------------------- 1 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 2 | import { ServerError } from '@infra/http/errors/ServerError'; 3 | 4 | export const ok = (body: T): HttpResponse => ({ 5 | statusCode: 200, 6 | body, 7 | }); 8 | 9 | export const noContent = (): HttpResponse => ({ 10 | statusCode: 204, 11 | }); 12 | 13 | export const badRequest = (error: Error): HttpResponse => ({ 14 | statusCode: 400, 15 | body: error, 16 | }); 17 | 18 | export const unauthorized = (error: Error): HttpResponse => ({ 19 | statusCode: 401, 20 | body: error, 21 | }); 22 | 23 | export const forbidden = (error: Error): HttpResponse => ({ 24 | statusCode: 403, 25 | body: error, 26 | }); 27 | 28 | export const notFound = (error: Error): HttpResponse => ({ 29 | statusCode: 404, 30 | body: error, 31 | }); 32 | 33 | export const serverError = (error?: Error | unknown): HttpResponse => { 34 | const stack = error instanceof Error ? error.stack : undefined; 35 | return { 36 | statusCode: 500, 37 | body: new ServerError(stack), 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dyarlen Iber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/application/use-cases/comments/UpdateComment.ts: -------------------------------------------------------------------------------- 1 | import { UpdateCommentInterface } from '@application/interfaces/use-cases/comments/UpdateCommentInterface'; 2 | import { GetCommentByIdRepository } from '@application/interfaces/repositories/comments/GetCommentByIdRepository'; 3 | import { UpdateCommentRepository } from '@application/interfaces/repositories/comments/UpdateCommentRepository'; 4 | import { CommentNotFoundError } from '@application/errors/CommentNotFoundError'; 5 | 6 | export class UpdateComment implements UpdateCommentInterface { 7 | constructor( 8 | private readonly getCommentByIdRepository: GetCommentByIdRepository, 9 | private readonly updateCommentRepository: UpdateCommentRepository, 10 | ) {} 11 | 12 | async execute(params: UpdateCommentInterface.Request): Promise { 13 | const { commentId, commentData } = params; 14 | const comment = await this.getCommentByIdRepository.getCommentById(commentId); 15 | if (!comment) { 16 | return new CommentNotFoundError(); 17 | } 18 | return this.updateCommentRepository.updateComment({ commentId, commentData }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/factories/controllers/comments/create-comment/controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from '@infra/http/controllers/BaseController'; 2 | import { CreateCommentController } from '@infra/http/controllers/comments/CreateCommentController'; 3 | import { makeCreateCommentValidation } from '@main/factories/controllers/comments/create-comment/validation-factory'; 4 | import { makeGetPostById } from '@main/factories/use-cases/posts/get-post-by-id-factory'; 5 | import { makeCreateComment } from '@main/factories/use-cases/comments/create-comment-factory'; 6 | import { makeUpdatePostTotalComments } from '@main/factories/use-cases/posts/update-post-total-comments-factory'; 7 | 8 | export const makeCreateCommentController = (): BaseController => { 9 | const validation = makeCreateCommentValidation(); 10 | const getPostByIdUseCase = makeGetPostById(); 11 | const createCommentUseCase = makeCreateComment(); 12 | const updatePostTotalCommentsUseCase = makeUpdatePostTotalComments(); 13 | return new CreateCommentController( 14 | validation, 15 | getPostByIdUseCase, 16 | createCommentUseCase, 17 | updatePostTotalCommentsUseCase, 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/application/use-cases/posts/UpdatePostTotalComments.ts: -------------------------------------------------------------------------------- 1 | import { UpdatePostTotalCommentsInterface } from '@application/interfaces/use-cases/posts/UpdatePostTotalCommentsInterface'; 2 | import { GetTotalCommentsByPostIdRepository } from '@application/interfaces/repositories/comments/GetTotalCommentsByPostIdRepository'; 3 | import { UpdatePostTotalCommentsRepository } from '@application/interfaces/repositories/posts/UpdatePostTotalCommentsRepository'; 4 | 5 | export class UpdatePostTotalComments implements UpdatePostTotalCommentsInterface { 6 | constructor( 7 | private readonly getTotalCommentsByPostIdRepository: GetTotalCommentsByPostIdRepository, 8 | private readonly updatePostTotalCommentsRepository: UpdatePostTotalCommentsRepository, 9 | ) {} 10 | 11 | async execute( 12 | postId: UpdatePostTotalCommentsInterface.Request, 13 | ): Promise { 14 | const totalComments = await this.getTotalCommentsByPostIdRepository.getTotalCommentsByPostId( 15 | postId, 16 | ); 17 | await this.updatePostTotalCommentsRepository.updatePostTotalComments({ 18 | postId, 19 | totalComments, 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/routes/comment-routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { expressRouteAdapter } from '@main/adapters/express-route-adapter'; 3 | import { makeGetLatestCommentsByPostIdController } from '@main/factories/controllers/comments/get-latest-comments-by-post-id/controller-factory'; 4 | import { makeCreateCommentController } from '@main/factories/controllers/comments/create-comment/controller-factory'; 5 | import { makeUpdateCommentController } from '@main/factories/controllers/comments/update-comment/controller-factory'; 6 | import { makeDeleteCommentController } from '@main/factories/controllers/comments/delete-comment/controller-factory'; 7 | import { authMiddleware } from '@main/middlewares/auth-middleware'; 8 | 9 | export default (router: Router): void => { 10 | router.get('/comments/:postId', expressRouteAdapter(makeGetLatestCommentsByPostIdController())); 11 | router.post('/comments', authMiddleware, expressRouteAdapter(makeCreateCommentController())); 12 | router.patch('/comments/:id', authMiddleware, expressRouteAdapter(makeUpdateCommentController())); 13 | router.delete('/comments/:id', authMiddleware, expressRouteAdapter(makeDeleteCommentController())); 14 | }; 15 | -------------------------------------------------------------------------------- /src/infra/http/controllers/posts/CreatePostController.ts: -------------------------------------------------------------------------------- 1 | import { CreatePostInterface } from '@application/interfaces/use-cases/posts/CreatePostInterface'; 2 | import { BaseController } from '@infra/http/controllers/BaseController'; 3 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 4 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 5 | import { Validation } from '@infra/http/interfaces/Validation'; 6 | import { ok } from '@infra/http/helpers/http'; 7 | 8 | export class CreatePostController extends BaseController { 9 | constructor( 10 | private readonly createPostValidation: Validation, 11 | private readonly createPost: CreatePostInterface, 12 | ) { 13 | super(createPostValidation); 14 | } 15 | 16 | async execute(httpRequest: CreatePostController.Request): Promise { 17 | const userId = httpRequest.userId!; 18 | const { title, text } = httpRequest.body!; 19 | const id = await this.createPost.execute({ userId, title, text }); 20 | return ok({ id }); 21 | } 22 | } 23 | 24 | export namespace CreatePostController { 25 | export type Request = HttpRequest>; 26 | export type Response = HttpResponse<{ id: string }>; 27 | } 28 | -------------------------------------------------------------------------------- /tests/infra/http/validators/EmailValidatorAdapter.spec.ts: -------------------------------------------------------------------------------- 1 | import validator from 'validator'; 2 | import { EmailValidatorAdapter } from '@infra/http/validators/EmailValidatorAdapter'; 3 | 4 | jest.mock('validator', () => ({ 5 | isEmail(_email: string): boolean { 6 | return true; 7 | }, 8 | })); 9 | 10 | describe('EmailValidatorAdapter', () => { 11 | it('should call validator with correct email', () => { 12 | const emailValidatorAdapter = new EmailValidatorAdapter(); 13 | const isEmailSpy = jest.spyOn(validator, 'isEmail'); 14 | emailValidatorAdapter.isValid('any_email@mail.com'); 15 | expect(isEmailSpy).toHaveBeenCalledWith('any_email@mail.com'); 16 | }); 17 | 18 | it('should return false if validator returns false', () => { 19 | const emailValidatorAdapter = new EmailValidatorAdapter(); 20 | jest.spyOn(validator, 'isEmail').mockReturnValueOnce(false); 21 | const isValid = emailValidatorAdapter.isValid('invalid_email'); 22 | expect(isValid).toBe(false); 23 | }); 24 | 25 | it('should return true if validator returns true', () => { 26 | const emailValidatorAdapter = new EmailValidatorAdapter(); 27 | const isValid = emailValidatorAdapter.isValid('valid_email@mail.com'); 28 | expect(isValid).toBe(true); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/infra/http/controllers/comments/GetLatestCommentsByPostIdController.ts: -------------------------------------------------------------------------------- 1 | import { GetLatestCommentsByPostIdInterface } from '@application/interfaces/use-cases/comments/GetLatestCommentsByPostIdInterface'; 2 | import { BaseController } from '@infra/http/controllers/BaseController'; 3 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 4 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 5 | import { ok } from '@infra/http/helpers/http'; 6 | 7 | export class GetLatestCommentsByPostIdController extends BaseController { 8 | constructor( 9 | private readonly getLatestCommentsByPostId: GetLatestCommentsByPostIdInterface, 10 | ) { 11 | super(); 12 | } 13 | 14 | async execute( 15 | httpRequest: GetLatestCommentsByPostIdController.Request, 16 | ): Promise { 17 | const { postId, page } = httpRequest.params!; 18 | const response = await this.getLatestCommentsByPostId.execute({ postId, page }); 19 | return ok(response); 20 | } 21 | } 22 | 23 | export namespace GetLatestCommentsByPostIdController { 24 | export type Request = HttpRequest; 25 | export type Response = HttpResponse; 26 | } 27 | -------------------------------------------------------------------------------- /src/infra/http/controllers/posts/GetPostByIdController.ts: -------------------------------------------------------------------------------- 1 | import { GetPostByIdInterface } from '@application/interfaces/use-cases/posts/GetPostByIdInterface'; 2 | import { BaseController } from '@infra/http/controllers/BaseController'; 3 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 4 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 5 | import { notFound, ok } from '@infra/http/helpers/http'; 6 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 7 | 8 | export class GetPostByIdController extends BaseController { 9 | constructor( 10 | private readonly getPostById: GetPostByIdInterface, 11 | ) { 12 | super(); 13 | } 14 | 15 | async execute( 16 | httpRequest: GetPostByIdController.Request, 17 | ): Promise { 18 | const { id } = httpRequest.params!; 19 | const postOrError = await this.getPostById.execute(id); 20 | if (postOrError instanceof PostNotFoundError) { 21 | return notFound(postOrError); 22 | } 23 | return ok(postOrError); 24 | } 25 | } 26 | 27 | export namespace GetPostByIdController { 28 | export type Request = HttpRequest; 29 | export type Response = HttpResponse; 30 | } 31 | -------------------------------------------------------------------------------- /src/main/routes/post-routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { expressRouteAdapter } from '@main/adapters/express-route-adapter'; 3 | import { makeGetLatestPostsController } from '@main/factories/controllers/posts/get-latest-posts/controller-factory'; 4 | import { makeGetPostByIdController } from '@main/factories/controllers/posts/get-post-by-id/controller-factory'; 5 | import { makeCreatePostController } from '@main/factories/controllers/posts/create-post/controller-factory'; 6 | import { makeUpdatePostController } from '@main/factories/controllers/posts/update-post/controller-factory'; 7 | import { makeDeletePostController } from '@main/factories/controllers/posts/delete-post/controller-factory'; 8 | import { authMiddleware } from '@main/middlewares/auth-middleware'; 9 | 10 | export default (router: Router): void => { 11 | router.get('/posts', expressRouteAdapter(makeGetLatestPostsController())); 12 | router.get('/posts/:id', expressRouteAdapter(makeGetPostByIdController())); 13 | router.post('/posts', authMiddleware, expressRouteAdapter(makeCreatePostController())); 14 | router.patch('/posts/:id', authMiddleware, expressRouteAdapter(makeUpdatePostController())); 15 | router.delete('/posts/:id', authMiddleware, expressRouteAdapter(makeDeletePostController())); 16 | }; 17 | -------------------------------------------------------------------------------- /src/application/use-cases/authentication/SignIn.ts: -------------------------------------------------------------------------------- 1 | import { SignInInterface } from '@application/interfaces/use-cases/authentication/SignInInterface'; 2 | import { LoadUserByEmailRepository } from '@application/interfaces/repositories/authentication/LoadUserByEmailRepository'; 3 | import { HashComparer } from '@application/interfaces/cryptography/HashComparer'; 4 | import { JWTGenerator } from '@application/interfaces/cryptography/JWTGenerator'; 5 | import { UnauthorizedError } from '@application/errors/UnauthorizedError'; 6 | 7 | export class SignIn implements SignInInterface { 8 | constructor( 9 | private readonly loadUserByEmailRepository: LoadUserByEmailRepository, 10 | private readonly hashComparer: HashComparer, 11 | private readonly jwtGenerator: JWTGenerator, 12 | ) {} 13 | 14 | async execute( 15 | credentials: SignInInterface.Request, 16 | ): Promise { 17 | const { email, password } = credentials; 18 | const user = await this.loadUserByEmailRepository.loadUserByEmail(email); 19 | if (!user) { 20 | return new UnauthorizedError(); 21 | } 22 | const isPasswordValid = await this.hashComparer.compare(password, user.password); 23 | if (!isPasswordValid) { 24 | return new UnauthorizedError(); 25 | } 26 | return this.jwtGenerator.generate(user.id); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/application/use-cases/authentication/SignUp.ts: -------------------------------------------------------------------------------- 1 | import { SignUpInterface } from '@application/interfaces/use-cases/authentication/SignUpInterface'; 2 | import { LoadUserByEmailRepository } from '@application/interfaces/repositories/authentication/LoadUserByEmailRepository'; 3 | import { CreateUserRepository } from '@application/interfaces/repositories/authentication/CreateUserRepository'; 4 | import { HashGenerator } from '@application/interfaces/cryptography/HashGenerator'; 5 | import { EmailInUseError } from '@application/errors/EmailInUseError'; 6 | 7 | export class SignUp implements SignUpInterface { 8 | constructor( 9 | private readonly loadUserByEmailRepository: LoadUserByEmailRepository, 10 | private readonly createUserRepository: CreateUserRepository, 11 | private readonly hashGenerator: HashGenerator, 12 | ) {} 13 | 14 | async execute( 15 | userData: SignUpInterface.Request, 16 | ): Promise { 17 | const { email, password } = userData; 18 | const existingUser = await this.loadUserByEmailRepository.loadUserByEmail(email); 19 | if (existingUser) { 20 | return new EmailInUseError(); 21 | } 22 | const hashedPassword = await this.hashGenerator.hash(password); 23 | return this.createUserRepository.createUser({ 24 | ...userData, 25 | password: hashedPassword, 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/repositories/UserRepository.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'mongodb'; 2 | import DbConnection from '@infra/db/mongodb/helpers/db-connection'; 3 | import { CreateUserRepository } from '@application/interfaces/repositories/authentication/CreateUserRepository'; 4 | import { LoadUserByEmailRepository } from '@application/interfaces/repositories/authentication/LoadUserByEmailRepository'; 5 | import { objectIdToString, mapDocument } from '@infra/db/mongodb/helpers/mapper'; 6 | 7 | export class UserRepository implements 8 | CreateUserRepository, 9 | LoadUserByEmailRepository { 10 | static async getCollection(): Promise { 11 | return DbConnection.getCollection('users'); 12 | } 13 | 14 | async createUser( 15 | userData: CreateUserRepository.Request, 16 | ): Promise { 17 | const collection = await UserRepository.getCollection(); 18 | const { insertedId } = await collection.insertOne({ ...userData, createdAt: new Date() }); 19 | return objectIdToString(insertedId); 20 | } 21 | 22 | async loadUserByEmail( 23 | email: LoadUserByEmailRepository.Request, 24 | ): Promise { 25 | const collection = await UserRepository.getCollection(); 26 | const rawUser = await collection.findOne({ email }); 27 | return rawUser && mapDocument(rawUser); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/application/use-cases/posts/CreatePost.spec.ts: -------------------------------------------------------------------------------- 1 | import { CreatePost } from '@application/use-cases/posts/CreatePost'; 2 | import { CreatePostRepositoryStub } from '@tests/infra/mocks/posts/repositories'; 3 | import { makeFakePost } from '@tests/domain/mocks/entities'; 4 | 5 | type SutTypes = { 6 | sut: CreatePost; 7 | createPostRepositoryStub: CreatePostRepositoryStub; 8 | }; 9 | 10 | const makeSut = (): SutTypes => { 11 | const createPostRepositoryStub = new CreatePostRepositoryStub(); 12 | const sut = new CreatePost(createPostRepositoryStub); 13 | return { 14 | sut, 15 | createPostRepositoryStub, 16 | }; 17 | }; 18 | 19 | describe('CreatePost', () => { 20 | it('should call CreatePostRepository with correct data', async () => { 21 | const { sut, createPostRepositoryStub } = makeSut(); 22 | const createPostSpy = jest.spyOn(createPostRepositoryStub, 'createPost'); 23 | const { userId, title, text } = makeFakePost(); 24 | await sut.execute({ userId, title, text }); 25 | expect(createPostSpy).toHaveBeenCalledWith({ 26 | userId, title, text, totalComments: 0, 27 | }); 28 | }); 29 | 30 | it('should return the post id on success', async () => { 31 | const { sut } = makeSut(); 32 | const { 33 | id, userId, title, text, 34 | } = makeFakePost(); 35 | const response = await sut.execute({ userId, title, text }); 36 | expect(response).toBe(id); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/infra/http/middlewares/BaseMiddleware.spec.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareStub } from '@tests/infra/mocks/middlewares'; 2 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 3 | import { serverError } from '@infra/http/helpers/http'; 4 | 5 | const makeFakeHttpRequest = (): HttpRequest => ({ 6 | headers: { authorization: 'Bearer any_token' }, 7 | }); 8 | 9 | describe('BaseMiddleware', () => { 10 | it('should call the execute method with correct params', async () => { 11 | const middlewareStub = new MiddlewareStub(); 12 | const executeSpy = jest.spyOn(middlewareStub, 'execute'); 13 | const httpRequest = makeFakeHttpRequest(); 14 | await middlewareStub.handle(httpRequest); 15 | expect(executeSpy).toHaveBeenCalledWith(httpRequest); 16 | }); 17 | 18 | it('should return the same response as the execute method', async () => { 19 | const middlewareStub = new MiddlewareStub(); 20 | const executeHttpResponse = await middlewareStub.execute({}); 21 | const httpResponse = await middlewareStub.handle({}); 22 | expect(httpResponse).toEqual(executeHttpResponse); 23 | }); 24 | 25 | it('should return 500 if the execute method throws', async () => { 26 | const middlewareStub = new MiddlewareStub(); 27 | jest.spyOn(middlewareStub, 'execute').mockImplementationOnce(async () => { 28 | throw new Error('any_error'); 29 | }); 30 | const httpResponse = await middlewareStub.handle({}); 31 | expect(httpResponse).toEqual(serverError(new Error('any_error'))); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/infra/http/controllers/authentication/SignInController.ts: -------------------------------------------------------------------------------- 1 | import { SignInInterface } from '@application/interfaces/use-cases/authentication/SignInInterface'; 2 | import { BaseController } from '@infra/http/controllers/BaseController'; 3 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 4 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 5 | import { Validation } from '@infra/http/interfaces/Validation'; 6 | import { unauthorized, ok } from '@infra/http/helpers/http'; 7 | import { UnauthorizedError } from '@application/errors/UnauthorizedError'; 8 | 9 | export class SignInController extends BaseController { 10 | constructor( 11 | private readonly signInValidation: Validation, 12 | private readonly signIn: SignInInterface, 13 | ) { 14 | super(signInValidation); 15 | } 16 | 17 | async execute( 18 | httpRequest: SignInController.Request, 19 | ): Promise { 20 | const { email, password } = httpRequest.body!; 21 | const authenticationTokenOrError = await this.signIn.execute({ email, password }); 22 | if (authenticationTokenOrError instanceof UnauthorizedError) { 23 | return unauthorized(authenticationTokenOrError); 24 | } 25 | return ok({ 26 | authenticationToken: authenticationTokenOrError, 27 | }); 28 | } 29 | } 30 | 31 | export namespace SignInController { 32 | export type Request = HttpRequest; 33 | export type Response = HttpResponse<{ authenticationToken: string } | UnauthorizedError>; 34 | } 35 | -------------------------------------------------------------------------------- /tests/infra/http/validations/EmailValidation.spec.ts: -------------------------------------------------------------------------------- 1 | import { EmailValidation } from '@infra/http/validations/EmailValidation'; 2 | import { InvalidParamError } from '@infra/http/errors/InvalidParamError'; 3 | import { EmailValidatorStub } from '@tests/infra/mocks/validators'; 4 | 5 | type SutTypes = { 6 | sut: EmailValidation 7 | emailValidatorStub: EmailValidatorStub 8 | }; 9 | 10 | const makeSut = (): SutTypes => { 11 | const emailValidatorStub = new EmailValidatorStub(); 12 | const sut = new EmailValidation('email', emailValidatorStub); 13 | return { 14 | sut, 15 | emailValidatorStub, 16 | }; 17 | }; 18 | 19 | describe('EmailValidation', () => { 20 | it('should call EmailValidator with correct email', () => { 21 | const { sut, emailValidatorStub } = makeSut(); 22 | const isValidSpy = jest.spyOn(emailValidatorStub, 'isValid'); 23 | sut.validate({ email: 'any_email@mail.com' }); 24 | expect(isValidSpy).toHaveBeenCalledWith('any_email@mail.com'); 25 | }); 26 | 27 | it('should return an InvalidParamError if EmailValidator returns false', () => { 28 | const { sut, emailValidatorStub } = makeSut(); 29 | jest.spyOn(emailValidatorStub, 'isValid').mockReturnValueOnce(false); 30 | const error = sut.validate({ email: 'invalid_email' }); 31 | expect(error).toEqual(new InvalidParamError('email')); 32 | }); 33 | 34 | it('should return null on success', () => { 35 | const { sut } = makeSut(); 36 | const error = sut.validate({ email: 'valid_email@mail.com' }); 37 | expect(error).toBeNull(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /tests/infra/http/controllers/posts/GetLatestPostsController.spec.ts: -------------------------------------------------------------------------------- 1 | import { GetLatestPostsController } from '@infra/http/controllers/posts/GetLatestPostsController'; 2 | import { GetLatestPostsStub } from '@tests/application/mocks/posts/use-cases'; 3 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 4 | 5 | type SutTypes = { 6 | sut: GetLatestPostsController; 7 | getLatestPostsStub: GetLatestPostsStub; 8 | }; 9 | 10 | const makeSut = (): SutTypes => { 11 | const getLatestPostsStub = new GetLatestPostsStub(); 12 | const sut = new GetLatestPostsController(getLatestPostsStub); 13 | return { 14 | sut, 15 | getLatestPostsStub, 16 | }; 17 | }; 18 | 19 | const makeFakeHttpRequest = (): HttpRequest => { 20 | return { 21 | params: { page: 1 }, 22 | }; 23 | }; 24 | 25 | describe('GetLatestPostsController', () => { 26 | it('should call GetLatestPosts with correct params', async () => { 27 | const { sut, getLatestPostsStub } = makeSut(); 28 | const getLatestPostsSpy = jest.spyOn(getLatestPostsStub, 'execute'); 29 | const httpRequest = makeFakeHttpRequest(); 30 | await sut.handle(httpRequest); 31 | expect(getLatestPostsSpy).toHaveBeenCalledWith(httpRequest.params); 32 | }); 33 | 34 | it('should return 200 on success', async () => { 35 | const { sut } = makeSut(); 36 | const httpResponse = await sut.handle(makeFakeHttpRequest()); 37 | expect(httpResponse.statusCode).toBe(200); 38 | expect(httpResponse.body.data).toBeTruthy(); 39 | expect(httpResponse.body.page).toBeTruthy(); 40 | expect(httpResponse.body.total).toBeTruthy(); 41 | expect(httpResponse.body.totalPages).toBeTruthy(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/infra/http/middlewares/authentication/AuthMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { BaseMiddleware } from '@infra/http/middlewares/BaseMiddleware'; 2 | import { AuthenticateInterface } from '@application/interfaces/use-cases/authentication/AuthenticateInterface'; 3 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 4 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 5 | import { forbidden, ok } from '@infra/http/helpers/http'; 6 | import { ForbiddenError } from '@application/errors/ForbiddenError'; 7 | import { AuthTokenNotProvidedError } from '@infra/http/errors/AuthTokenNotProvidedError'; 8 | import { InvalidAuthTokenError } from '@infra/http/errors/InvalidAuthTokenError'; 9 | 10 | export class AuthMiddleware extends BaseMiddleware { 11 | constructor( 12 | private readonly authenticate: AuthenticateInterface, 13 | ) { 14 | super(); 15 | } 16 | 17 | async execute(httpRequest: AuthMiddleware.Request): Promise { 18 | const authHeader = httpRequest.headers?.authorization; 19 | if (!authHeader) { 20 | return forbidden(new AuthTokenNotProvidedError()); 21 | } 22 | const [, authToken] = authHeader.split(' '); 23 | const userIdOrError = await this.authenticate.execute(authToken); 24 | if (userIdOrError instanceof ForbiddenError) { 25 | return forbidden(new InvalidAuthTokenError()); 26 | } 27 | return ok({ userId: userIdOrError }); 28 | } 29 | } 30 | 31 | export namespace AuthMiddleware { 32 | export type Request = HttpRequest; 33 | export type Response = 34 | HttpResponse<{ userId: string } | AuthTokenNotProvidedError | InvalidAuthTokenError>; 35 | } 36 | -------------------------------------------------------------------------------- /tests/application/use-cases/posts/GetPostById.spec.ts: -------------------------------------------------------------------------------- 1 | import { GetPostById } from '@application/use-cases/posts/GetPostById'; 2 | import { GetPostByIdRepositoryStub } from '@tests/infra/mocks/posts/repositories'; 3 | import { makeFakePost } from '@tests/domain/mocks/entities'; 4 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 5 | 6 | type SutTypes = { 7 | sut: GetPostById; 8 | getPostByIdRepositoryStub: GetPostByIdRepositoryStub; 9 | }; 10 | 11 | const makeSut = (): SutTypes => { 12 | const getPostByIdRepositoryStub = new GetPostByIdRepositoryStub(); 13 | const sut = new GetPostById(getPostByIdRepositoryStub); 14 | return { 15 | sut, 16 | getPostByIdRepositoryStub, 17 | }; 18 | }; 19 | 20 | describe('GetPostById', () => { 21 | it('should call GetPostByIdRepository with correct post id', async () => { 22 | const { sut, getPostByIdRepositoryStub } = makeSut(); 23 | const getPostByIdSpy = jest.spyOn(getPostByIdRepositoryStub, 'getPostById'); 24 | const { id } = makeFakePost(); 25 | await sut.execute(id); 26 | expect(getPostByIdSpy).toHaveBeenCalledWith(id); 27 | }); 28 | 29 | it('should return a PostNotFoundError if GetPostByIdRepository returns null', async () => { 30 | const { sut, getPostByIdRepositoryStub } = makeSut(); 31 | jest.spyOn(getPostByIdRepositoryStub, 'getPostById').mockReturnValueOnce(Promise.resolve(null)); 32 | const { id } = makeFakePost(); 33 | const response = await sut.execute(id); 34 | expect(response).toEqual(new PostNotFoundError()); 35 | }); 36 | 37 | it('should return a post on success', async () => { 38 | const { sut } = makeSut(); 39 | const post = makeFakePost(); 40 | const response = await sut.execute(post.id); 41 | expect(response).toEqual(post); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/application/mocks/posts/use-cases.ts: -------------------------------------------------------------------------------- 1 | import { CreatePostInterface } from '@application/interfaces/use-cases/posts/CreatePostInterface'; 2 | import { DeletePostInterface } from '@application/interfaces/use-cases/posts/DeletePostInterface'; 3 | import { GetLatestPostsInterface } from '@application/interfaces/use-cases/posts/GetLatestPostsInterface'; 4 | import { GetPostByIdInterface } from '@application/interfaces/use-cases/posts/GetPostByIdInterface'; 5 | import { UpdatePostInterface } from '@application/interfaces/use-cases/posts/UpdatePostInterface'; 6 | import { makeFakePost } from '@tests/domain/mocks/entities'; 7 | 8 | export class CreatePostStub implements CreatePostInterface { 9 | async execute(_postData: CreatePostInterface.Request): Promise { 10 | const { id } = makeFakePost(); 11 | return id; 12 | } 13 | } 14 | 15 | export class DeletePostStub implements DeletePostInterface { 16 | async execute(_postId: DeletePostInterface.Request): Promise {} 17 | } 18 | 19 | export class GetLatestPostsStub implements GetLatestPostsInterface { 20 | async execute( 21 | _params: GetLatestPostsInterface.Request, 22 | ): Promise { 23 | const post = makeFakePost(); 24 | return { 25 | data: [post], 26 | page: 1, 27 | total: 1, 28 | totalPages: 1, 29 | }; 30 | } 31 | } 32 | 33 | export class GetPostByIdStub implements GetPostByIdInterface { 34 | async execute(_postId: GetPostByIdInterface.Request): Promise { 35 | return makeFakePost(); 36 | } 37 | } 38 | 39 | export class UpdatePostStub implements UpdatePostInterface { 40 | async execute(_params: UpdatePostInterface.Request): Promise { 41 | return makeFakePost(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/infra/http/controllers/posts/CreatePostController.spec.ts: -------------------------------------------------------------------------------- 1 | import { CreatePostController } from '@infra/http/controllers/posts/CreatePostController'; 2 | import { ValidationStub } from '@tests/infra/mocks/validators'; 3 | import { CreatePostStub } from '@tests/application/mocks/posts/use-cases'; 4 | import { ok } from '@infra/http/helpers/http'; 5 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 6 | import { makeFakePost } from '@tests/domain/mocks/entities'; 7 | 8 | type SutTypes = { 9 | sut: CreatePostController; 10 | validationStub: ValidationStub; 11 | createPostStub: CreatePostStub; 12 | }; 13 | 14 | const makeSut = (): SutTypes => { 15 | const validationStub = new ValidationStub(); 16 | const createPostStub = new CreatePostStub(); 17 | const sut = new CreatePostController(validationStub, createPostStub); 18 | return { 19 | sut, 20 | validationStub, 21 | createPostStub, 22 | }; 23 | }; 24 | 25 | const makeFakeHttpRequest = (): HttpRequest => { 26 | const { 27 | userId, title, text, 28 | } = makeFakePost(); 29 | return { 30 | userId, 31 | body: { title, text }, 32 | }; 33 | }; 34 | 35 | describe('CreatePostController', () => { 36 | it('should call CreatePost with correct params', async () => { 37 | const { sut, createPostStub } = makeSut(); 38 | const createPostSpy = jest.spyOn(createPostStub, 'execute'); 39 | const httpRequest = makeFakeHttpRequest(); 40 | await sut.handle(httpRequest); 41 | expect(createPostSpy).toHaveBeenCalledWith({ userId: httpRequest.userId, ...httpRequest.body }); 42 | }); 43 | 44 | it('should return 200 on success', async () => { 45 | const { sut } = makeSut(); 46 | const { id } = makeFakePost(); 47 | const httpResponse = await sut.handle(makeFakeHttpRequest()); 48 | expect(httpResponse).toEqual(ok({ id })); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/infra/http/controllers/authentication/SignUpController.ts: -------------------------------------------------------------------------------- 1 | import { SignUpInterface } from '@application/interfaces/use-cases/authentication/SignUpInterface'; 2 | import { SignInInterface } from '@application/interfaces/use-cases/authentication/SignInInterface'; 3 | import { BaseController } from '@infra/http/controllers/BaseController'; 4 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 5 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 6 | import { Validation } from '@infra/http/interfaces/Validation'; 7 | import { forbidden, ok } from '@infra/http/helpers/http'; 8 | import { EmailInUseError } from '@application/errors/EmailInUseError'; 9 | 10 | export class SignUpController extends BaseController { 11 | constructor( 12 | private readonly signUpValidation: Validation, 13 | private readonly signUp: SignUpInterface, 14 | private readonly signIn: SignInInterface, 15 | ) { 16 | super(signUpValidation); 17 | } 18 | 19 | async execute( 20 | httpRequest: SignUpController.Request, 21 | ): Promise { 22 | const { 23 | name, username, email, password, 24 | } = httpRequest.body!; 25 | const idOrError = await this.signUp.execute({ 26 | name, username, email, password, 27 | }); 28 | if (idOrError instanceof EmailInUseError) { 29 | return forbidden(idOrError); 30 | } 31 | const authenticationTokenOrError = await this.signIn.execute({ email, password }); 32 | if (authenticationTokenOrError instanceof Error) { 33 | throw authenticationTokenOrError; 34 | } 35 | return ok({ 36 | authenticationToken: authenticationTokenOrError, 37 | }); 38 | } 39 | } 40 | 41 | export namespace SignUpController { 42 | export type Request = HttpRequest; 43 | export type Response = HttpResponse<{ authenticationToken: string } | EmailInUseError>; 44 | } 45 | -------------------------------------------------------------------------------- /src/infra/http/controllers/posts/DeletePostController.ts: -------------------------------------------------------------------------------- 1 | import { DeletePostInterface } from '@application/interfaces/use-cases/posts/DeletePostInterface'; 2 | import { GetPostByIdInterface } from '@application/interfaces/use-cases/posts/GetPostByIdInterface'; 3 | import { DeleteCommentsByPostIdInterface } from '@application/interfaces/use-cases/comments/DeleteCommentsByPostIdInterface'; 4 | import { BaseController } from '@infra/http/controllers/BaseController'; 5 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 6 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 7 | import { forbidden, noContent, notFound } from '@infra/http/helpers/http'; 8 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 9 | import { PermissionError } from '@infra/http/errors/PermissionError'; 10 | 11 | export class DeletePostController extends BaseController { 12 | constructor( 13 | private readonly getPostById: GetPostByIdInterface, 14 | private readonly deletePost: DeletePostInterface, 15 | private readonly deleteCommentsByPostId: DeleteCommentsByPostIdInterface, 16 | ) { 17 | super(); 18 | } 19 | 20 | async execute(httpRequest: DeletePostController.Request): Promise { 21 | const userId = httpRequest.userId!; 22 | const { id } = httpRequest.params!; 23 | const postOrError = await this.getPostById.execute(id); 24 | if (postOrError instanceof PostNotFoundError) { 25 | return notFound(postOrError); 26 | } 27 | if (postOrError.userId !== userId) { 28 | return forbidden(new PermissionError()); 29 | } 30 | await this.deletePost.execute(id); 31 | await this.deleteCommentsByPostId.execute(id); 32 | return noContent(); 33 | } 34 | } 35 | 36 | export namespace DeletePostController { 37 | export type Request = HttpRequest; 38 | export type Response = HttpResponse; 39 | } 40 | -------------------------------------------------------------------------------- /tests/application/use-cases/posts/GetLatestPosts.spec.ts: -------------------------------------------------------------------------------- 1 | import { GetLatestPosts } from '@application/use-cases/posts/GetLatestPosts'; 2 | import { GetLatestPostsRepositoryStub, makeFakePaginationData } from '@tests/infra/mocks/posts/repositories'; 3 | import { makeFakePost } from '@tests/domain/mocks/entities'; 4 | import { paginationConfig } from '@application/config/pagination'; 5 | 6 | type SutTypes = { 7 | sut: GetLatestPosts; 8 | getLatestPostsRepositoryStub: GetLatestPostsRepositoryStub; 9 | }; 10 | 11 | const makeSut = (): SutTypes => { 12 | const getLatestPostsRepositoryStub = new GetLatestPostsRepositoryStub(); 13 | const sut = new GetLatestPosts(getLatestPostsRepositoryStub); 14 | return { 15 | sut, 16 | getLatestPostsRepositoryStub, 17 | }; 18 | }; 19 | 20 | describe('GetLatestPosts', () => { 21 | it('should call GetLatestPostsRepository with correct data if no page is provided', async () => { 22 | const { sut, getLatestPostsRepositoryStub } = makeSut(); 23 | const getLatestPostsSpy = jest.spyOn(getLatestPostsRepositoryStub, 'getLatestPosts'); 24 | await sut.execute({}); 25 | expect(getLatestPostsSpy).toHaveBeenCalledWith({ 26 | page: 1, 27 | paginationLimit: paginationConfig.paginationLimit, 28 | }); 29 | }); 30 | 31 | it('should call GetLatestPostsRepository with correct data if a page is provided', async () => { 32 | const { sut, getLatestPostsRepositoryStub } = makeSut(); 33 | const getLatestPostsSpy = jest.spyOn(getLatestPostsRepositoryStub, 'getLatestPosts'); 34 | await sut.execute({ page: 2 }); 35 | expect(getLatestPostsSpy).toHaveBeenCalledWith({ 36 | page: 2, 37 | paginationLimit: paginationConfig.paginationLimit, 38 | }); 39 | }); 40 | 41 | it('should return an array of posts and the pagination data from repository on success', async () => { 42 | const { sut } = makeSut(); 43 | const post = makeFakePost(); 44 | const response = await sut.execute({}); 45 | expect(response).toEqual(makeFakePaginationData([post])); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/infra/http/controllers/comments/CreateCommentController.ts: -------------------------------------------------------------------------------- 1 | import { CreateCommentInterface } from '@application/interfaces/use-cases/comments/CreateCommentInterface'; 2 | import { GetPostByIdInterface } from '@application/interfaces/use-cases/posts/GetPostByIdInterface'; 3 | import { UpdatePostTotalCommentsInterface } from '@application/interfaces/use-cases/posts/UpdatePostTotalCommentsInterface'; 4 | import { BaseController } from '@infra/http/controllers/BaseController'; 5 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 6 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 7 | import { Validation } from '@infra/http/interfaces/Validation'; 8 | import { notFound, ok } from '@infra/http/helpers/http'; 9 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 10 | 11 | export class CreateCommentController extends BaseController { 12 | constructor( 13 | private readonly createCommentValidation: Validation, 14 | private readonly getPostById: GetPostByIdInterface, 15 | private readonly createComment: CreateCommentInterface, 16 | private readonly updatePostTotalComments: UpdatePostTotalCommentsInterface, 17 | ) { 18 | super(createCommentValidation); 19 | } 20 | 21 | async execute( 22 | httpRequest: CreateCommentController.Request, 23 | ): Promise { 24 | const userId = httpRequest.userId!; 25 | const { postId, title, text } = httpRequest.body!; 26 | const postOrError = await this.getPostById.execute(postId); 27 | if (postOrError instanceof PostNotFoundError) { 28 | return notFound(postOrError); 29 | } 30 | const id = await this.createComment.execute({ 31 | userId, postId, title, text, 32 | }); 33 | await this.updatePostTotalComments.execute(postId); 34 | return ok({ id }); 35 | } 36 | } 37 | 38 | export namespace CreateCommentController { 39 | export type Request = HttpRequest>; 40 | export type Response = HttpResponse<{ id: string } | PostNotFoundError>; 41 | } 42 | -------------------------------------------------------------------------------- /tests/infra/http/controllers/posts/GetPostByIdController.spec.ts: -------------------------------------------------------------------------------- 1 | import { GetPostByIdController } from '@infra/http/controllers/posts/GetPostByIdController'; 2 | import { GetPostByIdStub } from '@tests/application/mocks/posts/use-cases'; 3 | import { notFound, ok } from '@infra/http/helpers/http'; 4 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 5 | import { makeFakePost } from '@tests/domain/mocks/entities'; 6 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 7 | 8 | type SutTypes = { 9 | sut: GetPostByIdController; 10 | getPostByIdStub: GetPostByIdStub; 11 | }; 12 | 13 | const makeSut = (): SutTypes => { 14 | const getPostByIdStub = new GetPostByIdStub(); 15 | const sut = new GetPostByIdController(getPostByIdStub); 16 | return { 17 | sut, 18 | getPostByIdStub, 19 | }; 20 | }; 21 | 22 | const makeFakeHttpRequest = (): HttpRequest => { 23 | const { id } = makeFakePost(); 24 | return { 25 | params: { id }, 26 | }; 27 | }; 28 | 29 | describe('GetPostByIdController', () => { 30 | it('should call GetPostById with correct params', async () => { 31 | const { sut, getPostByIdStub } = makeSut(); 32 | const getPostByIdSpy = jest.spyOn(getPostByIdStub, 'execute'); 33 | const httpRequest = makeFakeHttpRequest(); 34 | await sut.handle(httpRequest); 35 | expect(getPostByIdSpy).toHaveBeenCalledWith(httpRequest.params.id); 36 | }); 37 | 38 | it('should return 404 if GetPostById returns a PostNotFoundError', async () => { 39 | const { sut, getPostByIdStub } = makeSut(); 40 | jest.spyOn(getPostByIdStub, 'execute').mockImplementation(async () => new PostNotFoundError()); 41 | const httpResponse = await sut.handle(makeFakeHttpRequest()); 42 | expect(httpResponse).toEqual(notFound(new PostNotFoundError())); 43 | }); 44 | 45 | it('should return 200 on success', async () => { 46 | const { sut } = makeSut(); 47 | const post = makeFakePost(); 48 | const httpResponse = await sut.handle(makeFakeHttpRequest()); 49 | expect(httpResponse).toEqual(ok(post)); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/infra/http/controllers/comments/DeleteCommentController.ts: -------------------------------------------------------------------------------- 1 | import { DeleteCommentInterface } from '@application/interfaces/use-cases/comments/DeleteCommentInterface'; 2 | import { GetCommentByIdInterface } from '@application/interfaces/use-cases/comments/GetCommentByIdInterface'; 3 | import { UpdatePostTotalCommentsInterface } from '@application/interfaces/use-cases/posts/UpdatePostTotalCommentsInterface'; 4 | import { BaseController } from '@infra/http/controllers/BaseController'; 5 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 6 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 7 | import { forbidden, noContent, notFound } from '@infra/http/helpers/http'; 8 | import { CommentNotFoundError } from '@application/errors/CommentNotFoundError'; 9 | import { PermissionError } from '@infra/http/errors/PermissionError'; 10 | 11 | export class DeleteCommentController extends BaseController { 12 | constructor( 13 | private readonly getCommentById: GetCommentByIdInterface, 14 | private readonly deleteComment: DeleteCommentInterface, 15 | private readonly updatePostTotalComments: UpdatePostTotalCommentsInterface, 16 | ) { 17 | super(); 18 | } 19 | 20 | async execute( 21 | httpRequest: DeleteCommentController.Request, 22 | ): Promise { 23 | const userId = httpRequest.userId!; 24 | const { id } = httpRequest.params!; 25 | const commentOrError = await this.getCommentById.execute(id); 26 | if (commentOrError instanceof CommentNotFoundError) { 27 | return notFound(commentOrError); 28 | } 29 | if (commentOrError.userId !== userId) { 30 | return forbidden(new PermissionError()); 31 | } 32 | await this.deleteComment.execute(id); 33 | await this.updatePostTotalComments.execute(commentOrError.postId); 34 | return noContent(); 35 | } 36 | } 37 | 38 | export namespace DeleteCommentController { 39 | export type Request = HttpRequest; 40 | export type Response = HttpResponse; 41 | } 42 | -------------------------------------------------------------------------------- /tests/infra/http/validations/ValidationComposite.spec.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '@infra/http/interfaces/Validation'; 2 | import { ValidationComposite } from '@infra/http/validations/ValidationComposite'; 3 | import { ValidationStub } from '@tests/infra/mocks/validators'; 4 | 5 | type SutTypes = { 6 | sut: ValidationComposite; 7 | validationStubs: Validation[]; 8 | }; 9 | 10 | const makeSut = (): SutTypes => { 11 | const validationStubs = [new ValidationStub(), new ValidationStub()]; 12 | const sut = new ValidationComposite(validationStubs, 'body'); 13 | return { 14 | sut, 15 | validationStubs, 16 | }; 17 | }; 18 | 19 | describe('ValidationComposite', () => { 20 | it('should return an error if any validation fails', () => { 21 | const { sut, validationStubs } = makeSut(); 22 | jest.spyOn(validationStubs[0], 'validate').mockImplementation(() => new Error('any_error')); 23 | const error = sut.validate({}); 24 | expect(error).toEqual(new Error('any_error')); 25 | }); 26 | 27 | it('should return the first error if more than one validation fails', () => { 28 | const { sut, validationStubs } = makeSut(); 29 | jest.spyOn(validationStubs[0], 'validate').mockImplementation(() => new Error('any_error')); 30 | jest.spyOn(validationStubs[1], 'validate').mockImplementation(() => new Error('other_error')); 31 | const error = sut.validate({}); 32 | expect(error).toEqual(new Error('any_error')); 33 | }); 34 | 35 | it('should return null if no validation fails', () => { 36 | const { sut } = makeSut(); 37 | const error = sut.validate({}); 38 | expect(error).toBeNull(); 39 | }); 40 | 41 | it('should run through all its validations and calls the validate method on them if no validation fails', () => { 42 | const { sut, validationStubs } = makeSut(); 43 | const validateSpy1 = jest.spyOn(validationStubs[0], 'validate'); 44 | const validateSpy2 = jest.spyOn(validationStubs[1], 'validate'); 45 | const request = { body: {} }; 46 | sut.validate(request); 47 | expect(validateSpy1).toHaveBeenCalledWith(request.body); 48 | expect(validateSpy2).toHaveBeenCalledWith(request.body); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /tests/application/use-cases/posts/UpdatePostTotalComments.spec.ts: -------------------------------------------------------------------------------- 1 | import { UpdatePostTotalComments } from '@application/use-cases/posts/UpdatePostTotalComments'; 2 | import { GetTotalCommentsByPostIdRepositoryStub } from '@tests/infra/mocks/comments/repositories'; 3 | import { UpdatePostTotalCommentsRepositoryStub } from '@tests/infra/mocks/posts/repositories'; 4 | import { makeFakePost } from '@tests/domain/mocks/entities'; 5 | 6 | type SutTypes = { 7 | sut: UpdatePostTotalComments; 8 | getTotalCommentsByPostIdRepositoryStub: GetTotalCommentsByPostIdRepositoryStub; 9 | updatePostTotalCommentsRepositoryStub: UpdatePostTotalCommentsRepositoryStub; 10 | }; 11 | 12 | const makeSut = (): SutTypes => { 13 | const getTotalCommentsByPostIdRepositoryStub = new GetTotalCommentsByPostIdRepositoryStub(); 14 | const updatePostTotalCommentsRepositoryStub = new UpdatePostTotalCommentsRepositoryStub(); 15 | const sut = new UpdatePostTotalComments( 16 | getTotalCommentsByPostIdRepositoryStub, 17 | updatePostTotalCommentsRepositoryStub, 18 | ); 19 | return { 20 | sut, 21 | getTotalCommentsByPostIdRepositoryStub, 22 | updatePostTotalCommentsRepositoryStub, 23 | }; 24 | }; 25 | 26 | describe('DeletePost', () => { 27 | it('should call GetTotalCommentsByPostIdRepository with correct post id', async () => { 28 | const { sut, getTotalCommentsByPostIdRepositoryStub } = makeSut(); 29 | const getTotalCommentsByPostIdSpy = jest.spyOn(getTotalCommentsByPostIdRepositoryStub, 'getTotalCommentsByPostId'); 30 | const { id } = makeFakePost(); 31 | await sut.execute(id); 32 | expect(getTotalCommentsByPostIdSpy).toHaveBeenCalledWith(id); 33 | }); 34 | 35 | it('should call UpdatePostTotalCommentsRepository with correct params', async () => { 36 | const { sut, updatePostTotalCommentsRepositoryStub } = makeSut(); 37 | const updatePostTotalCommentsSpy = jest.spyOn(updatePostTotalCommentsRepositoryStub, 'updatePostTotalComments'); 38 | const { id, totalComments } = makeFakePost(); 39 | await sut.execute(id); 40 | expect(updatePostTotalCommentsSpy).toHaveBeenCalledWith({ 41 | postId: id, 42 | totalComments, 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /tests/main/routes/authentication-routes.spec.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'mongodb'; 2 | import request from 'supertest'; 3 | import bcrypt from 'bcrypt'; 4 | import DbConnection from '@infra/db/mongodb/helpers/db-connection'; 5 | import setupApp from '@main/config/app'; 6 | import { UserRepository } from '@infra/db/mongodb/repositories/UserRepository'; 7 | import env from '@main/config/env'; 8 | 9 | describe('authentication routes', () => { 10 | const app = setupApp(); 11 | let userCollection: Collection; 12 | 13 | beforeAll(async () => { 14 | await DbConnection.connect(env.mongodbUrl); 15 | }); 16 | 17 | afterAll(async () => { 18 | await DbConnection.disconnect(); 19 | }); 20 | 21 | beforeEach(async () => { 22 | userCollection = await UserRepository.getCollection(); 23 | await userCollection.deleteMany({}); 24 | }); 25 | 26 | describe('POST /register', () => { 27 | it('should return 200 on sign up success', async () => { 28 | await request(app) 29 | .post('/api/register') 30 | .send({ 31 | name: 'any_name', 32 | username: 'any_username', 33 | email: 'any_email@mail.com', 34 | password: 'any_password', 35 | }) 36 | .expect(200); 37 | }); 38 | }); 39 | 40 | describe('POST /login', () => { 41 | it('should return 200 on sign in success', async () => { 42 | const hashedPassword = await bcrypt.hash('any_password', env.bcryptSalt); 43 | await userCollection.insertOne({ 44 | name: 'any_name', 45 | username: 'any_username', 46 | email: 'any_email@mail.com', 47 | password: hashedPassword, 48 | }); 49 | await request(app) 50 | .post('/api/login') 51 | .send({ 52 | email: 'any_email@mail.com', 53 | password: 'any_password', 54 | }) 55 | .expect(200); 56 | }); 57 | 58 | it('should return 401 on sign in failure', async () => { 59 | await request(app) 60 | .post('/api/login') 61 | .send({ 62 | email: 'any_email@mail.com', 63 | password: 'any_password', 64 | }) 65 | .expect(401); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/infra/http/controllers/posts/UpdatePostController.ts: -------------------------------------------------------------------------------- 1 | import { UpdatePostInterface } from '@application/interfaces/use-cases/posts/UpdatePostInterface'; 2 | import { GetPostByIdInterface } from '@application/interfaces/use-cases/posts/GetPostByIdInterface'; 3 | import { BaseController } from '@infra/http/controllers/BaseController'; 4 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 5 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 6 | import { Validation } from '@infra/http/interfaces/Validation'; 7 | import { forbidden, notFound, ok } from '@infra/http/helpers/http'; 8 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 9 | import { PermissionError } from '@infra/http/errors/PermissionError'; 10 | 11 | export class UpdatePostController extends BaseController { 12 | constructor( 13 | private readonly updatePostValidation: Validation, 14 | private readonly getPostById: GetPostByIdInterface, 15 | private readonly updatePost: UpdatePostInterface, 16 | ) { 17 | super(updatePostValidation); 18 | } 19 | 20 | async execute( 21 | httpRequest: UpdatePostController.Request, 22 | ): Promise { 23 | const userId = httpRequest.userId!; 24 | const { id } = httpRequest.params!; 25 | const { title, text } = httpRequest.body!; 26 | const postOrError = await this.getPostById.execute(id); 27 | if (postOrError instanceof PostNotFoundError) { 28 | return notFound(postOrError); 29 | } 30 | if (postOrError.userId !== userId) { 31 | return forbidden(new PermissionError()); 32 | } 33 | const updatedPostOrError = await this.updatePost.execute({ 34 | postId: id, 35 | postData: { title, text }, 36 | }); 37 | if (updatedPostOrError instanceof PostNotFoundError) { 38 | return notFound(updatedPostOrError); 39 | } 40 | return ok(updatedPostOrError); 41 | } 42 | } 43 | 44 | export namespace UpdatePostController { 45 | export type Request = HttpRequest; 46 | export type Response = 47 | HttpResponse; 48 | } 49 | -------------------------------------------------------------------------------- /src/infra/http/controllers/comments/UpdateCommentController.ts: -------------------------------------------------------------------------------- 1 | import { UpdateCommentInterface } from '@application/interfaces/use-cases/comments/UpdateCommentInterface'; 2 | import { GetCommentByIdInterface } from '@application/interfaces/use-cases/comments/GetCommentByIdInterface'; 3 | import { BaseController } from '@infra/http/controllers/BaseController'; 4 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 5 | import { HttpResponse } from '@infra/http/interfaces/HttpResponse'; 6 | import { Validation } from '@infra/http/interfaces/Validation'; 7 | import { forbidden, notFound, ok } from '@infra/http/helpers/http'; 8 | import { CommentNotFoundError } from '@application/errors/CommentNotFoundError'; 9 | import { PermissionError } from '@infra/http/errors/PermissionError'; 10 | 11 | export class UpdateCommentController extends BaseController { 12 | constructor( 13 | private readonly updateCommentValidation: Validation, 14 | private readonly getCommentById: GetCommentByIdInterface, 15 | private readonly updateComment: UpdateCommentInterface, 16 | ) { 17 | super(updateCommentValidation); 18 | } 19 | 20 | async execute( 21 | httpRequest: UpdateCommentController.Request, 22 | ): Promise { 23 | const userId = httpRequest.userId!; 24 | const { id } = httpRequest.params!; 25 | const { title, text } = httpRequest.body!; 26 | const commentOrError = await this.getCommentById.execute(id); 27 | if (commentOrError instanceof CommentNotFoundError) { 28 | return notFound(commentOrError); 29 | } 30 | if (commentOrError.userId !== userId) { 31 | return forbidden(new PermissionError()); 32 | } 33 | const updatedCommentOrError = await this.updateComment.execute({ 34 | commentId: id, 35 | commentData: { title, text }, 36 | }); 37 | if (updatedCommentOrError instanceof CommentNotFoundError) { 38 | return notFound(updatedCommentOrError); 39 | } 40 | return ok(updatedCommentOrError); 41 | } 42 | } 43 | 44 | export namespace UpdateCommentController { 45 | export type Request = HttpRequest; 46 | export type Response = 47 | HttpResponse; 48 | } 49 | -------------------------------------------------------------------------------- /tests/infra/http/middlewares/authentication/AuthMiddleware.spec.ts: -------------------------------------------------------------------------------- 1 | import { AuthMiddleware } from '@infra/http/middlewares/authentication/AuthMiddleware'; 2 | import { AuthenticateStub } from '@tests/application/mocks/authentication/use-cases'; 3 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 4 | import { forbidden, ok } from '@infra/http/helpers/http'; 5 | import { ForbiddenError } from '@application/errors/ForbiddenError'; 6 | import { AuthTokenNotProvidedError } from '@infra/http/errors/AuthTokenNotProvidedError'; 7 | import { InvalidAuthTokenError } from '@infra/http/errors/InvalidAuthTokenError'; 8 | 9 | const makeFakeHttpRequest = (): HttpRequest => ({ 10 | headers: { authorization: 'Bearer any_token' }, 11 | }); 12 | 13 | type SutTypes = { 14 | sut: AuthMiddleware; 15 | authenticateStub: AuthenticateStub; 16 | }; 17 | 18 | const makeSut = (): SutTypes => { 19 | const authenticateStub = new AuthenticateStub(); 20 | const sut = new AuthMiddleware(authenticateStub); 21 | return { 22 | sut, 23 | authenticateStub, 24 | }; 25 | }; 26 | 27 | describe('AuthMiddleware', () => { 28 | it('should return 403 if no auth token exists in headers', async () => { 29 | const { sut } = makeSut(); 30 | const httpResponse = await sut.handle({}); 31 | expect(httpResponse).toEqual(forbidden(new AuthTokenNotProvidedError())); 32 | }); 33 | 34 | it('should call Authenticate with correct token', async () => { 35 | const { sut, authenticateStub } = makeSut(); 36 | const executeSpy = jest.spyOn(authenticateStub, 'execute'); 37 | await sut.handle(makeFakeHttpRequest()); 38 | expect(executeSpy).toHaveBeenCalledWith('any_token'); 39 | }); 40 | 41 | it('should return 403 if Authenticate returns error', async () => { 42 | const { sut, authenticateStub } = makeSut(); 43 | jest.spyOn(authenticateStub, 'execute').mockImplementation(async () => { 44 | return new ForbiddenError(); 45 | }); 46 | const httpResponse = await sut.handle(makeFakeHttpRequest()); 47 | expect(httpResponse).toEqual(forbidden(new InvalidAuthTokenError())); 48 | }); 49 | 50 | it('should return 200 if Authenticate returns a decoded token', async () => { 51 | const { sut } = makeSut(); 52 | const httpResponse = await sut.handle(makeFakeHttpRequest()); 53 | expect(httpResponse).toEqual(ok({ userId: 'any_token' })); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /tests/application/use-cases/posts/UpdatePost.spec.ts: -------------------------------------------------------------------------------- 1 | import { UpdatePost } from '@application/use-cases/posts/UpdatePost'; 2 | import { GetPostByIdRepositoryStub, UpdatePostRepositoryStub } from '@tests/infra/mocks/posts/repositories'; 3 | import { makeFakePost } from '@tests/domain/mocks/entities'; 4 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 5 | 6 | type SutTypes = { 7 | sut: UpdatePost; 8 | getPostByIdRepositoryStub: GetPostByIdRepositoryStub; 9 | updatePostRepositoryStub: UpdatePostRepositoryStub; 10 | }; 11 | 12 | const makeSut = (): SutTypes => { 13 | const getPostByIdRepositoryStub = new GetPostByIdRepositoryStub(); 14 | const updatePostRepositoryStub = new UpdatePostRepositoryStub(); 15 | const sut = new UpdatePost(getPostByIdRepositoryStub, updatePostRepositoryStub); 16 | return { 17 | sut, 18 | getPostByIdRepositoryStub, 19 | updatePostRepositoryStub, 20 | }; 21 | }; 22 | 23 | describe('UpdatePost', () => { 24 | it('should call GetPostByIdRepository with correct post id', async () => { 25 | const { sut, getPostByIdRepositoryStub } = makeSut(); 26 | const getPostByIdSpy = jest.spyOn(getPostByIdRepositoryStub, 'getPostById'); 27 | const { id, title, text } = makeFakePost(); 28 | await sut.execute({ postId: id, postData: { title, text } }); 29 | expect(getPostByIdSpy).toHaveBeenCalledWith(id); 30 | }); 31 | 32 | it('should return a PostNotFoundError if GetPostByIdRepository returns null', async () => { 33 | const { sut, getPostByIdRepositoryStub } = makeSut(); 34 | jest.spyOn(getPostByIdRepositoryStub, 'getPostById').mockReturnValueOnce(Promise.resolve(null)); 35 | const { id, title, text } = makeFakePost(); 36 | const response = await sut.execute({ postId: id, postData: { title, text } }); 37 | expect(response).toEqual(new PostNotFoundError()); 38 | }); 39 | 40 | it('should call UpdatePostRepository with correct params', async () => { 41 | const { sut, updatePostRepositoryStub } = makeSut(); 42 | const updatePostSpy = jest.spyOn(updatePostRepositoryStub, 'updatePost'); 43 | const { id, title, text } = makeFakePost(); 44 | await sut.execute({ postId: id, postData: { title, text } }); 45 | expect(updatePostSpy).toHaveBeenCalledWith({ postId: id, postData: { title, text } }); 46 | }); 47 | 48 | it('should return an updated post on success', async () => { 49 | const { sut } = makeSut(); 50 | const post = makeFakePost(); 51 | const { id, title, text } = post; 52 | const response = await sut.execute({ postId: id, postData: { title, text } }); 53 | expect(response).toEqual(post); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /tests/infra/mocks/posts/repositories.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@domain/entities/Post'; 2 | import { CreatePostRepository } from '@application/interfaces/repositories/posts/CreatePostRepository'; 3 | import { DeletePostRepository } from '@application/interfaces/repositories/posts/DeletePostRepository'; 4 | import { GetLatestPostsRepository } from '@application/interfaces/repositories/posts/GetLatestPostsRepository'; 5 | import { GetPostByIdRepository } from '@application/interfaces/repositories/posts/GetPostByIdRepository'; 6 | import { UpdatePostRepository } from '@application/interfaces/repositories/posts/UpdatePostRepository'; 7 | import { UpdatePostTotalCommentsRepository } from '@application/interfaces/repositories/posts/UpdatePostTotalCommentsRepository'; 8 | import { makeFakePost } from '@tests/domain/mocks/entities'; 9 | 10 | export const makeFakePaginationData = (data: Post[]): { 11 | data: Post[]; 12 | page: number; 13 | total: number; 14 | totalPages: number; 15 | } => ({ 16 | data, 17 | page: 1, 18 | total: 1, 19 | totalPages: 1, 20 | }); 21 | 22 | export class CreatePostRepositoryStub implements CreatePostRepository { 23 | async createPost( 24 | _postData: CreatePostRepository.Request, 25 | ): Promise { 26 | const { id } = makeFakePost(); 27 | return id; 28 | } 29 | } 30 | 31 | export class DeletePostRepositoryStub implements DeletePostRepository { 32 | async deletePost(_postId: DeletePostRepository.Request): Promise {} 33 | } 34 | 35 | export class GetLatestPostsRepositoryStub implements GetLatestPostsRepository { 36 | async getLatestPosts( 37 | _params: GetLatestPostsRepository.Request, 38 | ): Promise { 39 | const post = makeFakePost(); 40 | return makeFakePaginationData([post]); 41 | } 42 | } 43 | 44 | export class GetPostByIdRepositoryStub implements GetPostByIdRepository { 45 | async getPostById( 46 | _postId: GetPostByIdRepository.Request, 47 | ): Promise { 48 | return makeFakePost(); 49 | } 50 | } 51 | 52 | export class UpdatePostRepositoryStub implements UpdatePostRepository { 53 | async updatePost( 54 | _params: UpdatePostRepository.Request, 55 | ): Promise { 56 | return makeFakePost(); 57 | } 58 | } 59 | 60 | export class UpdatePostTotalCommentsRepositoryStub implements UpdatePostTotalCommentsRepository { 61 | async updatePostTotalComments( 62 | _params: UpdatePostTotalCommentsRepository.Request, 63 | ): Promise { 64 | return makeFakePost(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-blog-application", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/main/server.js", 6 | "scripts": { 7 | "start": "node dist/main/server.js", 8 | "build": "rimraf dist && tsc -p tsconfig-build.json", 9 | "build:watch": "npm run build -- --watch", 10 | "dev": "nodemon -L --watch ./dist ./dist/main/server.js", 11 | "up": "concurrently --kill-others-on-fail \"npm run build:watch\" \"docker-compose up\"", 12 | "down": "docker-compose down", 13 | "test": "jest --passWithNoTests --runInBand", 14 | "test:staged": "npm run test -- --findRelatedTests", 15 | "test:ci": "npm run test -- --coverage", 16 | "lint": "eslint --ignore-path .gitignore --ext .ts --fix", 17 | "prepare": "husky install" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/dyarleniber/simple-blog-application.git" 22 | }, 23 | "keywords": [], 24 | "author": "", 25 | "license": "ISC", 26 | "bugs": { 27 | "url": "https://github.com/dyarleniber/simple-blog-application/issues" 28 | }, 29 | "homepage": "https://github.com/dyarleniber/simple-blog-application#readme", 30 | "devDependencies": { 31 | "@shelf/jest-mongodb": "^2.2.0", 32 | "@types/bcrypt": "^5.0.0", 33 | "@types/express": "^4.17.13", 34 | "@types/jest": "^27.4.0", 35 | "@types/jsonwebtoken": "^8.5.8", 36 | "@types/node": "^17.0.8", 37 | "@types/supertest": "^2.0.11", 38 | "@types/validator": "^13.7.1", 39 | "@typescript-eslint/eslint-plugin": "^5.9.1", 40 | "@typescript-eslint/parser": "^5.9.1", 41 | "concurrently": "^7.0.0", 42 | "eslint": "^8.7.0", 43 | "eslint-config-airbnb-base": "^15.0.0", 44 | "eslint-config-airbnb-typescript": "^16.1.0", 45 | "eslint-plugin-import": "^2.25.4", 46 | "husky": "^7.0.0", 47 | "jest": "^27.4.7", 48 | "lint-staged": "^12.1.7", 49 | "nodemon": "^2.0.15", 50 | "rimraf": "^3.0.2", 51 | "supertest": "^6.2.2", 52 | "ts-jest": "^27.1.3", 53 | "ts-node": "^10.4.0", 54 | "typescript": "^4.5.4" 55 | }, 56 | "dependencies": { 57 | "bcrypt": "^5.0.1", 58 | "express": "^4.17.2", 59 | "jsonwebtoken": "^8.5.1", 60 | "module-alias": "^2.2.2", 61 | "mongodb": "^4.3.0", 62 | "validator": "^13.7.0" 63 | }, 64 | "lint-staged": { 65 | "*.ts": [ 66 | "npm run lint", 67 | "npm run test:staged" 68 | ] 69 | }, 70 | "engines": { 71 | "node": "14.x" 72 | }, 73 | "_moduleAliases": { 74 | "@domain": "dist/domain", 75 | "@application": "dist/application", 76 | "@infra": "dist/infra", 77 | "@main": "dist/main" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/infra/http/controllers/BaseController.spec.ts: -------------------------------------------------------------------------------- 1 | import { ControllerStub } from '@tests/infra/mocks/controllers'; 2 | import { ValidationStub } from '@tests/infra/mocks/validators'; 3 | import { badRequest, serverError } from '@infra/http/helpers/http'; 4 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 5 | 6 | type SutTypes = { 7 | sut: ControllerStub; 8 | validationStub: ValidationStub; 9 | }; 10 | 11 | const makeSut = (): SutTypes => { 12 | const validationStub = new ValidationStub(); 13 | const sut = new ControllerStub(validationStub); 14 | return { 15 | sut, 16 | validationStub, 17 | }; 18 | }; 19 | 20 | const makeFakeHttpRequest = (): HttpRequest => ({ 21 | body: { any_field: 'any_value' }, 22 | }); 23 | 24 | describe('BaseController', () => { 25 | it('should call Validation with correct param', async () => { 26 | const { sut, validationStub } = makeSut(); 27 | const validateSpy = jest.spyOn(validationStub, 'validate'); 28 | const httpRequest = makeFakeHttpRequest(); 29 | await sut.handle(httpRequest); 30 | expect(validateSpy).toHaveBeenCalledWith(httpRequest); 31 | }); 32 | 33 | it('should return 400 if Validation fails', async () => { 34 | const { sut, validationStub } = makeSut(); 35 | jest.spyOn(validationStub, 'validate').mockReturnValueOnce(new Error('any_error')); 36 | const httpResponse = await sut.handle({}); 37 | expect(httpResponse).toEqual(badRequest(new Error('any_error'))); 38 | }); 39 | 40 | it('should return 500 if Validation throws', async () => { 41 | const { sut, validationStub } = makeSut(); 42 | jest.spyOn(validationStub, 'validate').mockImplementationOnce(() => { 43 | throw new Error('any_error'); 44 | }); 45 | const httpResponse = await sut.handle({}); 46 | expect(httpResponse).toEqual(serverError(new Error('any_error'))); 47 | }); 48 | 49 | it('should call the execute method with correct params', async () => { 50 | const { sut } = makeSut(); 51 | const executeSpy = jest.spyOn(sut, 'execute'); 52 | const httpRequest = makeFakeHttpRequest(); 53 | await sut.handle(httpRequest); 54 | expect(executeSpy).toHaveBeenCalledWith(httpRequest); 55 | }); 56 | 57 | it('should return the same response as the execute method', async () => { 58 | const { sut } = makeSut(); 59 | const executeHttpResponse = await sut.execute({}); 60 | const httpResponse = await sut.handle({}); 61 | expect(httpResponse).toEqual(executeHttpResponse); 62 | }); 63 | 64 | it('should return 500 if the execute method throws', async () => { 65 | const { sut } = makeSut(); 66 | jest.spyOn(sut, 'execute').mockImplementationOnce(async () => { 67 | throw new Error('any_error'); 68 | }); 69 | const httpResponse = await sut.handle({}); 70 | expect(httpResponse).toEqual(serverError(new Error('any_error'))); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /tests/infra/http/controllers/posts/DeletePostController.spec.ts: -------------------------------------------------------------------------------- 1 | import { DeletePostController } from '@infra/http/controllers/posts/DeletePostController'; 2 | import { DeletePostStub, GetPostByIdStub } from '@tests/application/mocks/posts/use-cases'; 3 | import { DeleteCommentsByPostIdStub } from '@tests/application/mocks/comments/use-cases'; 4 | import { forbidden, noContent, notFound } from '@infra/http/helpers/http'; 5 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 6 | import { makeFakePost } from '@tests/domain/mocks/entities'; 7 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 8 | import { PermissionError } from '@infra/http/errors/PermissionError'; 9 | 10 | type SutTypes = { 11 | sut: DeletePostController; 12 | getPostByIdStub: GetPostByIdStub; 13 | deletePostStub: DeletePostStub; 14 | deleteCommentsByPostIdStub: DeleteCommentsByPostIdStub 15 | }; 16 | 17 | const makeSut = (): SutTypes => { 18 | const getPostByIdStub = new GetPostByIdStub(); 19 | const deletePostStub = new DeletePostStub(); 20 | const deleteCommentsByPostIdStub = new DeleteCommentsByPostIdStub(); 21 | const sut = new DeletePostController(getPostByIdStub, deletePostStub, deleteCommentsByPostIdStub); 22 | return { 23 | sut, 24 | getPostByIdStub, 25 | deletePostStub, 26 | deleteCommentsByPostIdStub, 27 | }; 28 | }; 29 | 30 | const makeFakeHttpRequest = (): HttpRequest => { 31 | const { userId, id } = makeFakePost(); 32 | return { 33 | userId, 34 | params: { id }, 35 | }; 36 | }; 37 | 38 | describe('DeletePostController', () => { 39 | it('should call DeletePost with correct id', async () => { 40 | const { sut, deletePostStub } = makeSut(); 41 | const deletePostSpy = jest.spyOn(deletePostStub, 'execute'); 42 | const httpRequest = makeFakeHttpRequest(); 43 | await sut.handle(httpRequest); 44 | expect(deletePostSpy).toHaveBeenCalledWith(httpRequest.params.id); 45 | }); 46 | 47 | it('should call DeleteCommentsByPostId with correct id', async () => { 48 | const { sut, deleteCommentsByPostIdStub } = makeSut(); 49 | const deleteCommentsByPostIdSpy = jest.spyOn(deleteCommentsByPostIdStub, 'execute'); 50 | const httpRequest = makeFakeHttpRequest(); 51 | await sut.handle(httpRequest); 52 | expect(deleteCommentsByPostIdSpy).toHaveBeenCalledWith(httpRequest.params.id); 53 | }); 54 | 55 | it('should return 404 if GetPostById returns a PostNotFoundError', async () => { 56 | const { sut, getPostByIdStub } = makeSut(); 57 | jest.spyOn(getPostByIdStub, 'execute').mockImplementation(async () => new PostNotFoundError()); 58 | const httpResponse = await sut.handle(makeFakeHttpRequest()); 59 | expect(httpResponse).toEqual(notFound(new PostNotFoundError())); 60 | }); 61 | 62 | it('should return 403 if the request user id is different from the post user id', async () => { 63 | const { sut } = makeSut(); 64 | const httpRequest = makeFakeHttpRequest(); 65 | const httpResponse = await sut.handle({ ...httpRequest, userId: 'other_user_id' }); 66 | expect(httpResponse).toEqual(forbidden(new PermissionError())); 67 | }); 68 | 69 | it('should return 204 on success', async () => { 70 | const { sut } = makeSut(); 71 | const httpResponse = await sut.handle(makeFakeHttpRequest()); 72 | expect(httpResponse).toEqual(noContent()); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /tests/infra/http/controllers/posts/UpdatePostController.spec.ts: -------------------------------------------------------------------------------- 1 | import { UpdatePostController } from '@infra/http/controllers/posts/UpdatePostController'; 2 | import { ValidationStub } from '@tests/infra/mocks/validators'; 3 | import { GetPostByIdStub, UpdatePostStub } from '@tests/application/mocks/posts/use-cases'; 4 | import { forbidden, notFound, ok } from '@infra/http/helpers/http'; 5 | import { HttpRequest } from '@infra/http/interfaces/HttpRequest'; 6 | import { makeFakePost } from '@tests/domain/mocks/entities'; 7 | import { PostNotFoundError } from '@application/errors/PostNotFoundError'; 8 | import { PermissionError } from '@infra/http/errors/PermissionError'; 9 | 10 | type SutTypes = { 11 | sut: UpdatePostController; 12 | validationStub: ValidationStub; 13 | getPostByIdStub: GetPostByIdStub; 14 | updatePostStub: UpdatePostStub; 15 | }; 16 | 17 | const makeSut = (): SutTypes => { 18 | const validationStub = new ValidationStub(); 19 | const getPostByIdStub = new GetPostByIdStub(); 20 | const updatePostStub = new UpdatePostStub(); 21 | const sut = new UpdatePostController(validationStub, getPostByIdStub, updatePostStub); 22 | return { 23 | sut, 24 | getPostByIdStub, 25 | updatePostStub, 26 | validationStub, 27 | }; 28 | }; 29 | 30 | const makeFakeHttpRequest = (): HttpRequest => { 31 | const { 32 | userId, id, title, text, 33 | } = makeFakePost(); 34 | return { 35 | userId, 36 | params: { id }, 37 | body: { 38 | title, 39 | text, 40 | }, 41 | }; 42 | }; 43 | 44 | describe('UpdatePostController', () => { 45 | it('should call UpdatePost with correct params', async () => { 46 | const { sut, updatePostStub } = makeSut(); 47 | const updatePostSpy = jest.spyOn(updatePostStub, 'execute'); 48 | const httpRequest = makeFakeHttpRequest(); 49 | await sut.handle(httpRequest); 50 | expect(updatePostSpy).toHaveBeenCalledWith({ 51 | postId: httpRequest.params.id, 52 | postData: httpRequest.body, 53 | }); 54 | }); 55 | 56 | it('should return 404 if GetPostById returns a PostNotFoundError', async () => { 57 | const { sut, getPostByIdStub } = makeSut(); 58 | jest.spyOn(getPostByIdStub, 'execute').mockImplementation(async () => new PostNotFoundError()); 59 | const httpResponse = await sut.handle(makeFakeHttpRequest()); 60 | expect(httpResponse).toEqual(notFound(new PostNotFoundError())); 61 | }); 62 | 63 | it('should return 403 if the request user id is different from the post user id', async () => { 64 | const { sut } = makeSut(); 65 | const httpRequest = makeFakeHttpRequest(); 66 | const httpResponse = await sut.handle({ ...httpRequest, userId: 'other_user_id' }); 67 | expect(httpResponse).toEqual(forbidden(new PermissionError())); 68 | }); 69 | 70 | it('should return 404 if UpdatePost returns a PostNotFoundError', async () => { 71 | const { sut, updatePostStub } = makeSut(); 72 | jest.spyOn(updatePostStub, 'execute').mockImplementation(async () => new PostNotFoundError()); 73 | const httpResponse = await sut.handle(makeFakeHttpRequest()); 74 | expect(httpResponse).toEqual(notFound(new PostNotFoundError())); 75 | }); 76 | 77 | it('should return 200 on success', async () => { 78 | const { sut } = makeSut(); 79 | const post = makeFakePost(); 80 | const httpResponse = await sut.handle(makeFakeHttpRequest()); 81 | expect(httpResponse).toEqual(ok(post)); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/repositories/PostRepository.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'mongodb'; 2 | import DbConnection from '@infra/db/mongodb/helpers/db-connection'; 3 | import { CreatePostRepository } from '@application/interfaces/repositories/posts/CreatePostRepository'; 4 | import { DeletePostRepository } from '@application/interfaces/repositories/posts/DeletePostRepository'; 5 | import { GetLatestPostsRepository } from '@application/interfaces/repositories/posts/GetLatestPostsRepository'; 6 | import { GetPostByIdRepository } from '@application/interfaces/repositories/posts/GetPostByIdRepository'; 7 | import { UpdatePostRepository } from '@application/interfaces/repositories/posts/UpdatePostRepository'; 8 | import { UpdatePostTotalCommentsRepository } from '@application/interfaces/repositories/posts/UpdatePostTotalCommentsRepository'; 9 | import { 10 | objectIdToString, stringToObjectId, mapDocument, mapCollection, isValidObjectId, 11 | } from '@infra/db/mongodb/helpers/mapper'; 12 | 13 | export class PostRepository implements 14 | CreatePostRepository, 15 | DeletePostRepository, 16 | GetLatestPostsRepository, 17 | GetPostByIdRepository, 18 | UpdatePostRepository, 19 | UpdatePostTotalCommentsRepository { 20 | static async getCollection(): Promise { 21 | return DbConnection.getCollection('posts'); 22 | } 23 | 24 | async createPost(postData: CreatePostRepository.Request): Promise { 25 | const collection = await PostRepository.getCollection(); 26 | const { insertedId } = await collection.insertOne({ ...postData, createdAt: new Date() }); 27 | return objectIdToString(insertedId); 28 | } 29 | 30 | async deletePost(postId: DeletePostRepository.Request): Promise { 31 | const collection = await PostRepository.getCollection(); 32 | await collection.deleteOne({ _id: stringToObjectId(postId) }); 33 | } 34 | 35 | async getLatestPosts( 36 | params: GetLatestPostsRepository.Request, 37 | ): Promise { 38 | const collection = await PostRepository.getCollection(); 39 | const { page, paginationLimit } = params; 40 | const offset = (page - 1) * paginationLimit; 41 | const rawPosts = await collection 42 | .find({}) 43 | .sort({ createdAt: -1 }) 44 | .skip(offset) 45 | .limit(paginationLimit) 46 | .toArray(); 47 | const posts = mapCollection(rawPosts); 48 | const total = await collection.countDocuments({}); 49 | const totalPages = Math.ceil(total / paginationLimit); 50 | return { 51 | data: posts, page, total, totalPages, 52 | }; 53 | } 54 | 55 | async getPostById( 56 | postId: GetPostByIdRepository.Request, 57 | ): Promise { 58 | if (!isValidObjectId(postId)) { 59 | return null; 60 | } 61 | const collection = await PostRepository.getCollection(); 62 | const rawPost = await collection.findOne({ _id: stringToObjectId(postId) }); 63 | return rawPost && mapDocument(rawPost); 64 | } 65 | 66 | async updatePost(params: UpdatePostRepository.Request): Promise { 67 | const collection = await PostRepository.getCollection(); 68 | const { postId, postData } = params; 69 | const { value: rawPost } = await collection.findOneAndUpdate( 70 | { _id: stringToObjectId(postId) }, 71 | { $set: { ...postData, updatedAt: new Date() } }, 72 | { upsert: true, returnDocument: 'after' }, 73 | ); 74 | return mapDocument(rawPost); 75 | } 76 | 77 | async updatePostTotalComments( 78 | params: UpdatePostTotalCommentsRepository.Request, 79 | ): Promise { 80 | const collection = await PostRepository.getCollection(); 81 | const { postId, totalComments } = params; 82 | const { value: rawPost } = await collection.findOneAndUpdate( 83 | { _id: stringToObjectId(postId) }, 84 | { $set: { totalComments } }, 85 | { upsert: true, returnDocument: 'after' }, 86 | ); 87 | return mapDocument(rawPost); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/repositories/CommentRepository.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'mongodb'; 2 | import DbConnection from '@infra/db/mongodb/helpers/db-connection'; 3 | import { CreateCommentRepository } from '@application/interfaces/repositories/comments/CreateCommentRepository'; 4 | import { DeleteCommentRepository } from '@application/interfaces/repositories/comments/DeleteCommentRepository'; 5 | import { DeleteCommentsByPostIdRepository } from '@application/interfaces/repositories/comments/DeleteCommentsByPostIdRepository'; 6 | import { GetCommentByIdRepository } from '@application/interfaces/repositories/comments/GetCommentByIdRepository'; 7 | import { GetLatestCommentsByPostIdRepository } from '@application/interfaces/repositories/comments/GetLatestCommentsByPostIdRepository'; 8 | import { GetTotalCommentsByPostIdRepository } from '@application/interfaces/repositories/comments/GetTotalCommentsByPostIdRepository'; 9 | import { UpdateCommentRepository } from '@application/interfaces/repositories/comments/UpdateCommentRepository'; 10 | import { 11 | isValidObjectId, 12 | mapCollection, 13 | mapDocument, 14 | objectIdToString, 15 | stringToObjectId, 16 | } from '@infra/db/mongodb/helpers/mapper'; 17 | 18 | export class CommentRepository implements 19 | CreateCommentRepository, 20 | DeleteCommentRepository, 21 | DeleteCommentsByPostIdRepository, 22 | GetCommentByIdRepository, 23 | GetLatestCommentsByPostIdRepository, 24 | GetTotalCommentsByPostIdRepository, 25 | UpdateCommentRepository { 26 | static async getCollection(): Promise { 27 | return DbConnection.getCollection('comments'); 28 | } 29 | 30 | async createComment( 31 | commentData: CreateCommentRepository.Request, 32 | ): Promise { 33 | const collection = await CommentRepository.getCollection(); 34 | const { insertedId } = await collection.insertOne({ ...commentData, createdAt: new Date() }); 35 | return objectIdToString(insertedId); 36 | } 37 | 38 | async deleteComment( 39 | commentId: DeleteCommentRepository.Request, 40 | ): Promise { 41 | const collection = await CommentRepository.getCollection(); 42 | await collection.deleteOne({ _id: stringToObjectId(commentId) }); 43 | } 44 | 45 | async deleteCommentsByPostId( 46 | postId: DeleteCommentsByPostIdRepository.Request, 47 | ): Promise { 48 | const collection = await CommentRepository.getCollection(); 49 | await collection.deleteMany({ postId }); 50 | } 51 | 52 | async getCommentById( 53 | commentId: GetCommentByIdRepository.Request, 54 | ): Promise { 55 | if (!isValidObjectId(commentId)) { 56 | return null; 57 | } 58 | const collection = await CommentRepository.getCollection(); 59 | const rawComment = await collection.findOne({ _id: stringToObjectId(commentId) }); 60 | return rawComment && mapDocument(rawComment); 61 | } 62 | 63 | async getLatestCommentsByPostId( 64 | params: GetLatestCommentsByPostIdRepository.Request, 65 | ): Promise { 66 | const collection = await CommentRepository.getCollection(); 67 | const { postId, page, paginationLimit } = params; 68 | const offset = (page - 1) * paginationLimit; 69 | const rawComments = await collection 70 | .find({ postId }) 71 | .sort({ createdAt: -1 }) 72 | .skip(offset) 73 | .limit(paginationLimit) 74 | .toArray(); 75 | const comments = mapCollection(rawComments); 76 | const total = await collection.countDocuments({ postId }); 77 | const totalPages = Math.ceil(total / paginationLimit); 78 | return { 79 | data: comments, page, total, totalPages, 80 | }; 81 | } 82 | 83 | async getTotalCommentsByPostId( 84 | postId: GetTotalCommentsByPostIdRepository.Request, 85 | ): Promise { 86 | const collection = await CommentRepository.getCollection(); 87 | return collection.countDocuments({ postId }); 88 | } 89 | 90 | async updateComment( 91 | params: UpdateCommentRepository.Request, 92 | ): Promise { 93 | const collection = await CommentRepository.getCollection(); 94 | const { commentId, commentData } = params; 95 | const { value: rawComment } = await collection.findOneAndUpdate( 96 | { _id: stringToObjectId(commentId) }, 97 | { $set: { ...commentData, updatedAt: new Date() } }, 98 | { upsert: true, returnDocument: 'after' }, 99 | ); 100 | return mapDocument(rawComment); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/infra/db/mongodb/repositories/PostRepository.spec.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'mongodb'; 2 | import DbConnection from '@infra/db/mongodb/helpers/db-connection'; 3 | import { PostRepository } from '@infra/db/mongodb/repositories/PostRepository'; 4 | import { objectIdToString } from '@infra/db/mongodb/helpers/mapper'; 5 | import env from '@main/config/env'; 6 | import { makeFakePost } from '@tests/domain/mocks/entities'; 7 | 8 | describe('PostRepository', () => { 9 | let postCollection: Collection; 10 | 11 | beforeAll(async () => { 12 | await DbConnection.connect(env.mongodbUrl); 13 | }); 14 | 15 | afterAll(async () => { 16 | await DbConnection.disconnect(); 17 | }); 18 | 19 | beforeEach(async () => { 20 | postCollection = await PostRepository.getCollection(); 21 | await postCollection.deleteMany({}); 22 | }); 23 | 24 | describe('createPost', () => { 25 | it('should create a new post and return an id on success', async () => { 26 | const postRepository = new PostRepository(); 27 | const { 28 | userId, title, text, totalComments, 29 | } = makeFakePost(); 30 | const response = await postRepository.createPost({ 31 | userId, title, text, totalComments, 32 | }); 33 | expect(response).toBeTruthy(); 34 | const count = await postCollection.countDocuments(); 35 | expect(count).toBe(1); 36 | }); 37 | }); 38 | 39 | describe('deletePost', () => { 40 | it('should delete a post on success', async () => { 41 | const postRepository = new PostRepository(); 42 | const { 43 | userId, title, text, totalComments, 44 | } = makeFakePost(); 45 | const { insertedId } = await postCollection.insertOne({ 46 | userId, title, text, totalComments, 47 | }); 48 | await postRepository.deletePost(objectIdToString(insertedId)); 49 | const count = await postCollection.countDocuments(); 50 | expect(count).toBe(0); 51 | }); 52 | }); 53 | 54 | describe('getLatestPosts', () => { 55 | it('should return the latest posts on success', async () => { 56 | const postRepository = new PostRepository(); 57 | const paginationLimit = 2; 58 | const { 59 | userId, title, text, totalComments, 60 | } = makeFakePost(); 61 | const { insertedId: id1 } = await postCollection.insertOne({ 62 | userId, title, text, totalComments, createdAt: new Date(2022, 1, 1), 63 | }); 64 | const { insertedId: id2 } = await postCollection.insertOne({ 65 | userId, title, text, totalComments, createdAt: new Date(2022, 2, 1), 66 | }); 67 | const { insertedId: id3 } = await postCollection.insertOne({ 68 | userId, title, text, totalComments, createdAt: new Date(2022, 3, 1), 69 | }); 70 | const { insertedId: id4 } = await postCollection.insertOne({ 71 | userId, title, text, totalComments, createdAt: new Date(2022, 4, 1), 72 | }); 73 | const firstPageResponse = await postRepository.getLatestPosts({ page: 1, paginationLimit }); 74 | const secondPageResponse = await postRepository.getLatestPosts({ page: 2, paginationLimit }); 75 | [firstPageResponse, secondPageResponse].forEach((pageResponse) => { 76 | expect(pageResponse.total).toBe(4); 77 | expect(pageResponse.totalPages).toBe(2); 78 | }); 79 | expect(firstPageResponse.page).toBe(1); 80 | expect(firstPageResponse.data[0].id).toBe(objectIdToString(id4)); 81 | expect(firstPageResponse.data[1].id).toBe(objectIdToString(id3)); 82 | expect(secondPageResponse.page).toBe(2); 83 | expect(secondPageResponse.data[0].id).toBe(objectIdToString(id2)); 84 | expect(secondPageResponse.data[1].id).toBe(objectIdToString(id1)); 85 | }); 86 | }); 87 | 88 | describe('getPostById', () => { 89 | it('should return the post if post exists', async () => { 90 | const postRepository = new PostRepository(); 91 | const { 92 | userId, title, text, totalComments, 93 | } = makeFakePost(); 94 | const { insertedId } = await postCollection.insertOne({ 95 | userId, title, text, totalComments, 96 | }); 97 | const response = await postRepository.getPostById(objectIdToString(insertedId)); 98 | expect(response).toBeTruthy(); 99 | }); 100 | 101 | it('should return null if post does not exist', async () => { 102 | const postRepository = new PostRepository(); 103 | const response = await postRepository.getPostById('551137c2f9e1fac808a5f572'); 104 | expect(response).toBeNull(); 105 | }); 106 | 107 | it('should return null if an invalid ObjectId is provided', async () => { 108 | const postRepository = new PostRepository(); 109 | const response = await postRepository.getPostById('invalid_id'); 110 | expect(response).toBeNull(); 111 | }); 112 | }); 113 | 114 | it('should update a post and return the updated post on success', async () => { 115 | const postRepository = new PostRepository(); 116 | const { 117 | userId, title, text, totalComments, 118 | } = makeFakePost(); 119 | const { insertedId } = await postCollection.insertOne({ 120 | userId, title, text, totalComments, 121 | }); 122 | const newTitle = 'new title'; 123 | const newText = 'new text'; 124 | const updatedPost = await postRepository.updatePost({ 125 | postId: objectIdToString(insertedId), 126 | postData: { title: newTitle, text: newText }, 127 | }); 128 | expect(updatedPost.title).toBe(newTitle); 129 | expect(updatedPost.text).toBe(newText); 130 | }); 131 | 132 | it('should update the total comments of a post and return the updated post on success', async () => { 133 | const postRepository = new PostRepository(); 134 | const { 135 | userId, title, text, totalComments, 136 | } = makeFakePost(); 137 | const { insertedId } = await postCollection.insertOne({ 138 | userId, title, text, totalComments, 139 | }); 140 | const newTotalComments = 2; 141 | const updatedPost = await postRepository.updatePostTotalComments({ 142 | postId: objectIdToString(insertedId), 143 | totalComments: newTotalComments, 144 | }); 145 | expect(updatedPost.totalComments).toBe(newTotalComments); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Blog Application: backend challenge 2 | 3 | [![CI](https://github.com/dyarleniber/simple-blog-application/actions/workflows/ci.yml/badge.svg)](https://github.com/dyarleniber/simple-blog-application/actions/workflows/ci.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/dyarleniber/simple-blog-application/badge.svg?branch=main)](https://coveralls.io/github/dyarleniber/simple-blog-application?branch=main) 5 | 6 | Simple Blog API built with [TypeScript](https://www.typescriptlang.org/) and [MongoDB](https://www.mongodb.com/), using Clean Architecture, and SOLID principles. 7 | 8 | The API allows users to create, read, update and delete blog posts and comments. It also has an authentication system that supports login and signup, which uses JWT. 9 | In order to make operations to create/update/delete posts and comments, the user must be authenticated. 10 | 11 | A CI workflow created on [GitHub Actions](https://github.com/features/actions) is responsible for automatically test the source code, generate a coverage report and upload it on [Coveralls](https://coveralls.io). All these jobs are activated by a push or pull request event on main branch. 12 | 13 | To run this API locally, you can use the container environment created for this project using [Docker Compose](https://docs.docker.com/compose/) with the right version of [Node.js](https://nodejs.org/en/) and [MongoDB](https://www.mongodb.com/). Check the configuration section below. 14 | 15 | An API documentation with some requests and responses examples is available on [dyarleniber.github.io/simple-blog-application-backend-challenge](https://dyarleniber.github.io/simple-blog-application-backend-challenge/). This documentation was generated using [Insomnia](https://insomnia.rest/) and [Insomnia Documenter](https://github.com/jozsefsallai/insomnia-documenter). 16 | 17 | ## Architecture 18 | 19 | To separate concerns, the application was built with a **Clean Architecture**. It is divided into **Domain**, **Application**, and **Infrastructure** layers: There is also a **Main** layer, which is the entry point of the API. 20 | 21 | There are unit and integration tests covering each layer. The main tool used for testing is [Jest](https://facebook.github.io/jest/). 22 | 23 | To cover the **Main** layer, integration tests were created to test the HTTP requests of the API. That way, I can assure that the [Express](https://expressjs.com/) server is working correctly, all the adapters are also working as expected, and all the dependencies are being injected correctly. 24 | For all the other layers, unit tests were created, using mocks and stubs to mock the dependencies. 25 | 26 | And for testing the MongoDB, an in-memory implementation was used as a Jest preset. 27 | 28 | > Due to a lack of time, the tests were implemented just for posts. And the integration tests were implemented just for login and signup. 29 | > 30 | > However, as mentioned above, the tests were implemented to cover all layers. And the same approach that was used to test the controllers, middlewares, repositories, routes, etc. for posts, it can also be used to test comments or any other subdomain. 31 | 32 | ### Domain Layer 33 | 34 | The **Domain** layer is the layer that contains the business logic of the application. It contains the **Entities**, which are the classes that represent the data of the API. This layer is isolated from outer layers concerns. 35 | 36 | Due to limited time, I decided to take a simpler approach here. And, although some **Domain-Driven Design** patterns have been implemented, such as **DTOs**, **Mappers** , **Entities**, and the **Repository** pattern. Some other DDD patterns could also be implemented to enrich the application domain, and avoid illegal operations and illegal states. 37 | 38 | Such as **Value Objects**, they could be used to define the minimum and maximum size, and the standards that the content of the post must follow. Not only that, but they could also be used to override all (or most) of the primitive types, such as strings, numbers, and booleans. 39 | 40 | ### Application Layer 41 | 42 | The **Application** layer is the layer that contains the _application specific_ business rules. It implements all the use cases of the API, it uses the domain classes, but it is isolated from the details and implementation of outer layers, such as databases, adapters, etc. This layer just holds interfaces to interact with the outside world. 43 | 44 | I also defined interfaces for each use case, in order to make the application more testable, since I'm using these interfaces to create stubs for testing the controllers and middlewares in the infrastructure layer. 45 | 46 | ### Infrastructure Layer 47 | 48 | The **Infrastructure** layer is the layer that contains all the concrete implementations of the application. It contains repositories, adapters, controllers, middlewares, etc. It also contains the validators, which are used to validate the data of the controllers. 49 | 50 | ### Main Layer 51 | 52 | The **Main** layer is the entry point of the application. It is the layer that contains the Express server, and where all the routes are defined. In this layer I also compose all the controllers, middlewares, and use cases, injecting the dependencies that are needed, since I am not using any dependency injection container. 53 | 54 | ## Configuration 55 | 56 | To clone and run this application, you’ll need to have [Git](https://git-scm.com), [Docker](https://www.docker.com), [Docker Compose](https://docs.docker.com/compose), and [npm](https://www.npmjs.com) installed on your computer. 57 | 58 | From your command line: 59 | 60 | ```bash 61 | # Clone this repository 62 | $ git clone https://github.com/dyarleniber/simple-blog-application-backend-challenge.git 63 | 64 | # Go into the repository folder 65 | $ cd simple-blog-application-backend-challenge 66 | 67 | # Start the application 68 | $ npm run up 69 | # This will build and run the Node.js and MongoDB images, 70 | # build the TypeScript files, and run the application in watch mode. 71 | 72 | # To shut down the application run the following command 73 | $ npm run down 74 | ``` 75 | 76 | To run the tests, use the following commands: 77 | 78 | ```bash 79 | $ npm run test 80 | 81 | # Or 82 | 83 | $ npm run test:ci 84 | # This will also generate the coverage report 85 | ``` 86 | 87 | Use the following command to run [ESLint](https://eslint.org) from the command line: 88 | 89 | ```bash 90 | $ npm run lint 91 | ``` 92 | 93 | ## Improvements 94 | 95 | Some improvements that could be made in the future: 96 | - Include an ODM and a Schema validation library like [mongoose](https://mongoosejs.com/). 97 | - Involve all the database operations in a transaction, to avoid data inconsistency. 98 | - Improve the validations applied to the controllers, to make them more strict. 99 | - Include more use cases to manage the users. 100 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | const { defaults: tsjPreset } = require('ts-jest/presets'); 7 | 8 | export default { 9 | // All imported modules in your tests should be mocked automatically 10 | // automock: false, 11 | 12 | // Stop running tests after `n` failures 13 | // bail: 0, 14 | 15 | // The directory where Jest should store its cached dependency information 16 | // cacheDirectory: "/private/var/folders/ch/p5q95d4929x_bksbsdftwgnc0000gn/T/jest_dx", 17 | 18 | // Automatically clear mock calls, instances and results before every test 19 | // clearMocks: false, 20 | 21 | // Indicates whether the coverage information should be collected while executing the test 22 | collectCoverage: false, 23 | 24 | // An array of glob patterns indicating a set of files for which coverage information should be collected 25 | collectCoverageFrom: [ 26 | "/src/**/*.ts", 27 | "!/src/main/**" 28 | ], 29 | 30 | // The directory where Jest should output its coverage files 31 | coverageDirectory: "coverage", 32 | 33 | // An array of regexp pattern strings used to skip coverage collection 34 | // coveragePathIgnorePatterns: [ 35 | // "/node_modules/" 36 | // ], 37 | 38 | // Indicates which provider should be used to instrument code for coverage 39 | coverageProvider: "v8", 40 | 41 | // A list of reporter names that Jest uses when writing coverage reports 42 | // coverageReporters: [ 43 | // "json", 44 | // "text", 45 | // "lcov", 46 | // "clover" 47 | // ], 48 | 49 | // An object that configures minimum threshold enforcement for coverage results 50 | // coverageThreshold: undefined, 51 | 52 | // A path to a custom dependency extractor 53 | // dependencyExtractor: undefined, 54 | 55 | // Make calling deprecated APIs throw helpful error messages 56 | // errorOnDeprecated: false, 57 | 58 | // Force coverage collection from ignored files using an array of glob patterns 59 | // forceCoverageMatch: [], 60 | 61 | // A path to a module which exports an async function that is triggered once before all test suites 62 | // globalSetup: undefined, 63 | 64 | // A path to a module which exports an async function that is triggered once after all test suites 65 | // globalTeardown: undefined, 66 | 67 | // A set of global variables that need to be available in all test environments 68 | // globals: {}, 69 | 70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 71 | // maxWorkers: "50%", 72 | 73 | // An array of directory names to be searched recursively up from the requiring module's location 74 | // moduleDirectories: [ 75 | // "node_modules" 76 | // ], 77 | 78 | // An array of file extensions your modules use 79 | // moduleFileExtensions: [ 80 | // "js", 81 | // "jsx", 82 | // "ts", 83 | // "tsx", 84 | // "json", 85 | // "node" 86 | // ], 87 | 88 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 89 | moduleNameMapper: { 90 | "@domain/(.*)": "/src/domain/$1", 91 | "@application/(.*)": "/src/application/$1", 92 | "@infra/(.*)": "/src/infra/$1", 93 | "@main/(.*)": "/src/main/$1", 94 | "@tests/(.*)": "/tests/$1" 95 | }, 96 | 97 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 98 | // modulePathIgnorePatterns: [], 99 | 100 | // Activates notifications for test results 101 | // notify: false, 102 | 103 | // An enum that specifies notification mode. Requires { notify: true } 104 | // notifyMode: "failure-change", 105 | 106 | // A preset that is used as a base for Jest's configuration 107 | preset: "@shelf/jest-mongodb", 108 | 109 | // Run tests from one or more projects 110 | // projects: undefined, 111 | 112 | // Use this configuration option to add custom reporters to Jest 113 | // reporters: undefined, 114 | 115 | // Automatically reset mock state before every test 116 | // resetMocks: false, 117 | 118 | // Reset the module registry before running each individual test 119 | // resetModules: false, 120 | 121 | // A path to a custom resolver 122 | // resolver: undefined, 123 | 124 | // Automatically restore mock state and implementation before every test 125 | // restoreMocks: false, 126 | 127 | // The root directory that Jest should scan for tests and modules within 128 | // rootDir: undefined, 129 | 130 | // A list of paths to directories that Jest should use to search for files in 131 | roots: [ 132 | "/tests/" 133 | ], 134 | 135 | // Allows you to use a custom runner instead of Jest's default test runner 136 | // runner: "jest-runner", 137 | 138 | // The paths to modules that run some code to configure or set up the testing environment before each test 139 | // setupFiles: [], 140 | 141 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 142 | // setupFilesAfterEnv: [], 143 | 144 | // The number of seconds after which a test is considered as slow and reported as such in the results. 145 | // slowTestThreshold: 5, 146 | 147 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 148 | // snapshotSerializers: [], 149 | 150 | // The test environment that will be used for testing 151 | // testEnvironment: "jest-environment-node", 152 | 153 | // Options that will be passed to the testEnvironment 154 | // testEnvironmentOptions: {}, 155 | 156 | // Adds a location field to test results 157 | // testLocationInResults: false, 158 | 159 | // The glob patterns Jest uses to detect test files 160 | testMatch: [ 161 | '**/*.spec.ts', 162 | '**/*.test.ts' 163 | ], 164 | 165 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 166 | // testPathIgnorePatterns: [ 167 | // "/node_modules/" 168 | // ], 169 | 170 | // The regexp pattern or array of patterns that Jest uses to detect test files 171 | // testRegex: [], 172 | 173 | // This option allows the use of a custom results processor 174 | // testResultsProcessor: undefined, 175 | 176 | // This option allows use of a custom test runner 177 | // testRunner: "jest-circus/runner", 178 | 179 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 180 | // testURL: "http://localhost", 181 | 182 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 183 | // timers: "real", 184 | 185 | // A map from regular expressions to paths to transformers 186 | transform: { 187 | ...tsjPreset.transform, 188 | }, 189 | 190 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 191 | // transformIgnorePatterns: [ 192 | // "/node_modules/", 193 | // "\\.pnp\\.[^\\/]+$" 194 | // ], 195 | 196 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 197 | // unmockedModulePathPatterns: undefined, 198 | 199 | // Indicates whether each individual test should be reported during the run 200 | // verbose: undefined, 201 | 202 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 203 | // watchPathIgnorePatterns: [], 204 | 205 | // Whether to use watchman for file crawling 206 | // watchman: true, 207 | }; 208 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs", /* Specify what module code is generated. */ 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | "baseUrl": "src", /* Specify the base directory to resolve non-relative module names. */ 31 | "paths": { 32 | "@domain/*": [ 33 | "domain/*" 34 | ], 35 | "@application/*": [ 36 | "application/*" 37 | ], 38 | "@infra/*": [ 39 | "infra/*" 40 | ], 41 | "@main/*": [ 42 | "main/*" 43 | ], 44 | "@tests/*": [ 45 | "../tests/*" 46 | ], 47 | }, /* Specify a set of entries that re-map imports to additional lookup locations. */ 48 | "rootDirs": [ 49 | "src", 50 | "tests" 51 | ], /* Allow multiple folders to be treated as one when resolving modules. */ 52 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 53 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | // "resolveJsonModule": true, /* Enable importing .json files */ 56 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 57 | 58 | /* JavaScript Support */ 59 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 60 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 61 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 62 | 63 | /* Emit */ 64 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 65 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 66 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 67 | "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 68 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 69 | "outDir": "dist", /* Specify an output folder for all emitted files. */ 70 | // "removeComments": true, /* Disable emitting comments. */ 71 | // "noEmit": true, /* Disable emitting files from a compilation. */ 72 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 73 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 74 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 75 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 76 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 77 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 78 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 79 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 80 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 81 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 82 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 83 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 84 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 85 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 86 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 87 | 88 | /* Interop Constraints */ 89 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 90 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 91 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 92 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 93 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 94 | 95 | /* Type Checking */ 96 | "strict": true, /* Enable all strict type-checking options. */ 97 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 98 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 99 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 100 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 101 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 102 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 103 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 104 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 105 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 106 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 107 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 108 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 109 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 110 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 111 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 112 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 113 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 114 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 115 | 116 | /* Completeness */ 117 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 118 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 119 | }, 120 | "include": [ 121 | "src", 122 | "tests" 123 | ], 124 | "exclude": [] 125 | } 126 | -------------------------------------------------------------------------------- /docs/bundle.css: -------------------------------------------------------------------------------- 1 | .item.svelte-u114qp{cursor:default;height:var(--height, 42px);line-height:var(--height, 42px);padding:var(--itemPadding, 0 20px);color:var(--itemColor, inherit);text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.groupHeader.svelte-u114qp{text-transform:var(--groupTitleTextTransform, uppercase)}.groupItem.svelte-u114qp{padding-left:var(--groupItemPaddingLeft, 40px)}.item.svelte-u114qp:active{background:var(--itemActiveBackground, #b9daff)}.item.active.svelte-u114qp{background:var(--itemIsActiveBG, #007aff);color:var(--itemIsActiveColor, #fff)}.item.first.svelte-u114qp{border-radius:var(--itemFirstBorderRadius, 4px 4px 0 0)}.item.hover.svelte-u114qp:not(.active){background:var(--itemHoverBG, #e7f2ff)} 2 | .listContainer.svelte-1rmpqnb{box-shadow:var(--listShadow, 0 2px 3px 0 rgba(44, 62, 80, 0.24));border-radius:var(--listBorderRadius, 4px);max-height:var(--listMaxHeight, 250px);overflow-y:auto;background:var(--listBackground, #fff)}.virtualList.svelte-1rmpqnb{height:var(--virtualListHeight, 200px)}.listGroupTitle.svelte-1rmpqnb{color:var(--groupTitleColor, #8f8f8f);cursor:default;font-size:var(--groupTitleFontSize, 12px);font-weight:var(--groupTitleFontWeight, 600);height:var(--height, 42px);line-height:var(--height, 42px);padding:var(--groupTitlePadding, 0 20px);text-overflow:ellipsis;overflow-x:hidden;white-space:nowrap;text-transform:var(--groupTitleTextTransform, uppercase)}.empty.svelte-1rmpqnb{text-align:var(--listEmptyTextAlign, center);padding:var(--listEmptyPadding, 20px 0);color:var(--listEmptyColor, #78848F)} 3 | .multiSelectItem.svelte-1k6n0vy.svelte-1k6n0vy{background:var(--multiItemBG, #EBEDEF);margin:var(--multiItemMargin, 5px 5px 0 0);border-radius:var(--multiItemBorderRadius, 16px);height:var(--multiItemHeight, 32px);line-height:var(--multiItemHeight, 32px);display:flex;cursor:default;padding:var(--multiItemPadding, 0 10px 0 15px);max-width:100%}.multiSelectItem_label.svelte-1k6n0vy.svelte-1k6n0vy{margin:var(--multiLabelMargin, 0 5px 0 0);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.multiSelectItem.svelte-1k6n0vy.svelte-1k6n0vy:hover,.multiSelectItem.active.svelte-1k6n0vy.svelte-1k6n0vy{background-color:var(--multiItemActiveBG, #006FFF);color:var(--multiItemActiveColor, #fff)}.multiSelectItem.disabled.svelte-1k6n0vy.svelte-1k6n0vy:hover{background:var(--multiItemDisabledHoverBg, #EBEDEF);color:var(--multiItemDisabledHoverColor, #C1C6CC)}.multiSelectItem_clear.svelte-1k6n0vy.svelte-1k6n0vy{border-radius:var(--multiClearRadius, 50%);background:var(--multiClearBG, #52616F);min-width:var(--multiClearWidth, 16px);max-width:var(--multiClearWidth, 16px);height:var(--multiClearHeight, 16px);position:relative;top:var(--multiClearTop, 8px);text-align:var(--multiClearTextAlign, center);padding:var(--multiClearPadding, 1px)}.multiSelectItem_clear.svelte-1k6n0vy.svelte-1k6n0vy:hover,.active.svelte-1k6n0vy .multiSelectItem_clear.svelte-1k6n0vy{background:var(--multiClearHoverBG, #fff)}.multiSelectItem_clear.svelte-1k6n0vy:hover svg.svelte-1k6n0vy,.active.svelte-1k6n0vy .multiSelectItem_clear svg.svelte-1k6n0vy{fill:var(--multiClearHoverFill, #006FFF)}.multiSelectItem_clear.svelte-1k6n0vy svg.svelte-1k6n0vy{fill:var(--multiClearFill, #EBEDEF);vertical-align:top} 4 | .selectContainer.svelte-l63srs.svelte-l63srs{--padding:0 16px;border:var(--border, 1px solid #d8dbdf);border-radius:var(--borderRadius, 3px);height:var(--height, 42px);position:relative;display:flex;align-items:center;padding:var(--padding);background:var(--background, #fff)}.selectContainer.svelte-l63srs input.svelte-l63srs{cursor:default;border:none;color:var(--inputColor, #3f4f5f);height:var(--height, 42px);line-height:var(--height, 42px);padding:var(--inputPadding, var(--padding));width:100%;background:transparent;font-size:var(--inputFontSize, 14px);letter-spacing:var(--inputLetterSpacing, -0.08px);position:absolute;left:var(--inputLeft, 0)}.selectContainer.svelte-l63srs input.svelte-l63srs::placeholder{color:var(--placeholderColor, #78848f);opacity:var(--placeholderOpacity, 1)}.selectContainer.svelte-l63srs input.svelte-l63srs:focus{outline:none}.selectContainer.svelte-l63srs.svelte-l63srs:hover{border-color:var(--borderHoverColor, #b2b8bf)}.selectContainer.focused.svelte-l63srs.svelte-l63srs{border-color:var(--borderFocusColor, #006fe8)}.selectContainer.disabled.svelte-l63srs.svelte-l63srs{background:var(--disabledBackground, #ebedef);border-color:var(--disabledBorderColor, #ebedef);color:var(--disabledColor, #c1c6cc)}.selectContainer.disabled.svelte-l63srs input.svelte-l63srs::placeholder{color:var(--disabledPlaceholderColor, #c1c6cc);opacity:var(--disabledPlaceholderOpacity, 1)}.selectedItem.svelte-l63srs.svelte-l63srs{line-height:var(--height, 42px);height:var(--height, 42px);overflow-x:hidden;padding:var(--selectedItemPadding, 0 20px 0 0)}.selectedItem.svelte-l63srs.svelte-l63srs:focus{outline:none}.clearSelect.svelte-l63srs.svelte-l63srs{position:absolute;right:var(--clearSelectRight, 10px);top:var(--clearSelectTop, 11px);bottom:var(--clearSelectBottom, 11px);width:var(--clearSelectWidth, 20px);color:var(--clearSelectColor, #c5cacf);flex:none !important}.clearSelect.svelte-l63srs.svelte-l63srs:hover{color:var(--clearSelectHoverColor, #2c3e50)}.selectContainer.focused.svelte-l63srs .clearSelect.svelte-l63srs{color:var(--clearSelectFocusColor, #3f4f5f)}.indicator.svelte-l63srs.svelte-l63srs{position:absolute;right:var(--indicatorRight, 10px);top:var(--indicatorTop, 11px);width:var(--indicatorWidth, 20px);height:var(--indicatorHeight, 20px);color:var(--indicatorColor, #c5cacf)}.indicator.svelte-l63srs svg.svelte-l63srs{display:inline-block;fill:var(--indicatorFill, currentcolor);line-height:1;stroke:var(--indicatorStroke, currentcolor);stroke-width:0}.spinner.svelte-l63srs.svelte-l63srs{position:absolute;right:var(--spinnerRight, 10px);top:var(--spinnerLeft, 11px);width:var(--spinnerWidth, 20px);height:var(--spinnerHeight, 20px);color:var(--spinnerColor, #51ce6c);animation:svelte-l63srs-rotate 0.75s linear infinite}.spinner_icon.svelte-l63srs.svelte-l63srs{display:block;height:100%;transform-origin:center center;width:100%;position:absolute;top:0;bottom:0;left:0;right:0;margin:auto;-webkit-transform:none}.spinner_path.svelte-l63srs.svelte-l63srs{stroke-dasharray:90;stroke-linecap:round}.multiSelect.svelte-l63srs.svelte-l63srs{display:flex;padding:var(--multiSelectPadding, 0 35px 0 16px);height:auto;flex-wrap:wrap;align-items:stretch}.multiSelect.svelte-l63srs>.svelte-l63srs{flex:1 1 50px}.selectContainer.multiSelect.svelte-l63srs input.svelte-l63srs{padding:var(--multiSelectInputPadding, 0);position:relative;margin:var(--multiSelectInputMargin, 0)}.hasError.svelte-l63srs.svelte-l63srs{border:var(--errorBorder, 1px solid #ff2d55);background:var(--errorBackground, #fff)}@keyframes svelte-l63srs-rotate{100%{transform:rotate(360deg)}} 5 | .selection.svelte-17yna57{text-overflow:ellipsis;overflow-x:hidden;white-space:nowrap} 6 | svelte-virtual-list-viewport.svelte-8nn5yg{position:relative;overflow-y:auto;-webkit-overflow-scrolling:touch;display:block}svelte-virtual-list-contents.svelte-8nn5yg,svelte-virtual-list-row.svelte-8nn5yg{display:block}svelte-virtual-list-row.svelte-8nn5yg{overflow:hidden} 7 | .hljs{display:block;overflow-x:auto;padding:0.5em;background:#272822;color:#ddd}.hljs-tag,.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-strong,.hljs-name{color:#f92672}.hljs-code{color:#66d9ef}.hljs-class .hljs-title{color:white}.hljs-attribute,.hljs-symbol,.hljs-regexp,.hljs-link{color:#bf79db}.hljs-string,.hljs-bullet,.hljs-subst,.hljs-title,.hljs-section,.hljs-emphasis,.hljs-type,.hljs-built_in,.hljs-builtin-name,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-addition,.hljs-variable,.hljs-template-tag,.hljs-template-variable{color:#a6e22e}.hljs-comment,.hljs-quote,.hljs-deletion,.hljs-meta{color:#75715e}.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-doctag,.hljs-title,.hljs-section,.hljs-type,.hljs-selector-id{font-weight:bold}html,body{position:relative;width:100%}body{color:#333;margin:0;box-sizing:border-box;font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif}h1{font-size:36px}h1,h2,h3,h4{font-weight:normal;margin:0;padding:10px 0}hr{border:none;display:block;height:1px;background:#e7e7e7}a{color:#0064c8;text-decoration:none}a:hover{text-decoration:underline}label{display:block}input,button,select,textarea{font-family:inherit;font-size:inherit;padding:0.4em;margin:0 0 0.5em 0;box-sizing:border-box;border:1px solid #ccc;border-radius:2px}input:disabled{color:#ccc}input[type="range"]{height:0}button{color:#333;background-color:#f4f4f4;outline:none}button:active{background-color:#ddd}button:focus{background-color:#666}.row{display:flex;height:100%}.left,.right{box-sizing:border-box;padding:10px 15px;flex:1;min-width:50%}.left{transition-duration:.2s;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-property:min-width;will-change:transform}.right{background:#232323;color:#fff;max-width:0}.wrapper.hide-right .left{min-width:100%}.sidebar-list-link{display:block;padding:5px 15px;margin-bottom:5px;text-decoration:none;color:#222;border-top-left-radius:5px;border-bottom-left-radius:5px}.sidebar-list-link:hover{background:rgba(0, 0, 0, 0.05);text-decoration:none}.sidebar-list-link.expanded{background:rgba(0, 0, 0, 0.1)}.sidebar-list-link::before,.sidebar-list-link span,.sidebar-list-link strong{display:inline-block;vertical-align:middle}.sidebar-list-link::before{margin-right:7px}.sidebar-list-link strong{font-size:11px;margin-right:5px}.sidebar-list-link strong.get{color:#7d69cb}.sidebar-list-link strong.post{color:#59a210}.sidebar-list-link strong.put{color:#ff9a1f}.sidebar-list-link strong.patch{color:#d07502}.sidebar-list-link strong.delete{color:#d04444}.sidebar-list-link strong.options,.sidebar-list-link strong.head{color:#1c90b4}.request-title{font-weight:600}.request-title strong{display:inline-block;padding:5px 8px;text-transform:uppercase;color:#fff;border-radius:3px;margin-right:10px;font-size:17px;background:#1c90b4}.request-title strong.get{background:#7d69cb}.request-title strong.post{background:#59a210}.request-title strong.put{background:#ff9a1f}.request-title strong.patch{background:#d07502}.request-title strong.delete{background:#d04444}.hljs{padding:0;background:transparent}.description{overflow-x:auto}.description code,.description pre{background:#eee;text-shadow:0 1px #fff;padding:0 .3em;white-space:pre-wrap;overflow-wrap:break-word}.description pre{padding:10px 15px}.description pre code{padding:0}.language-selector .selectContainer{transition:background 150ms linear}.language-selector .selectContainer:hover{background:#454545}.language-selector .selectContainer *{cursor:pointer !important}.table pre{margin:0;background:#efefef;padding:10px;white-space:pre-wrap;overflow-wrap:break-word}.tables .table:last-child{border-bottom:none !important}.hamburger-toggler{display:none}.example-toggler{margin-right:32px;font-size:1.7em;position:relative;color:#6a57d5;bottom:2px}.example-toggler.inactive{color:#c9c9c9}.env-variable{font-weight:bold;color:white;margin:0 1px;background-color:#414141;padding:2px 6px;border-radius:3px}table{background:#fff;border:solid 1px #ddd;margin-bottom:1.25rem;width:100%;border-collapse:collapse}table thead,table tfoot{background:#f5f5f5}table th,table td{color:#222222;padding:0.5rem}table tr:nth-of-type(even){background:#F9F9F9}@media only screen and (max-width: 1000px){aside{display:none}.content{margin-left:0 !important}.row{display:block}.left,.right{width:auto;max-width:100%}.right{padding:0}.language-selector{padding:10px}.language-selector select{margin:0}header .environment span{display:none}header .title{font-size:18px !important}header .logo{margin-left:0 !important;padding:0 !important}header .environment{padding:0 5px !important}header .run,header .example-toggler{display:none !important}.hamburger-toggler{display:inline-block;padding:15px}}header{box-sizing:border-box;position:fixed;top:0;left:0;right:0;border-bottom:1px solid #dedede;background:#fff;z-index:10000;display:flex;justify-content:space-between;height:60px;overflow:hidden}header .header-left,header .header-right{display:flex;align-items:center;flex-wrap:wrap}header .title{padding:0 10px;margin:0;font-size:22px;font-weight:600;display:inline-block;vertical-align:middle}header .hamburger-toggler{vertical-align:middle;font-size:22px;color:#000}header .logo{display:inline-block;vertical-align:middle;padding:0 5px;margin-left:30px;width:48px;height:48px}header .logo img{width:100%;height:100%}header .environment{font-size:13px;padding:0 30px;display:inline-block;vertical-align:middle}header .environment select{margin-bottom:0}header .run{display:inline-block;vertical-align:middle}.wrapper{margin-top:60px} 8 | .error-page.svelte-19j2wr5{width:760px;margin:60px auto 0}@media only screen and ( max-width: 760px ){.error-page.svelte-19j2wr5{width:auto;padding:15px}} 9 | .content.svelte-1jbhgwi{margin-left:260px;overflow-x:hidden}.language-selector.svelte-1jbhgwi{text-align:center}.language-selector.svelte-1jbhgwi{--background:#555;--color:#fff;--listBackground:#343434;--itemHoverBG:#121212;--itemIsActiveBG:#6a57d5;--listMaxHeight:auto;--border:none} 10 | aside.svelte-dekk65{background:#f6f6f6;width:260px;position:fixed;top:60px;left:0;bottom:0;overflow:auto;text-overflow:clip;white-space:nowrap;z-index:10001}aside.visible.svelte-dekk65{display:block} 11 | .anchor.svelte-1suc8s6.svelte-1suc8s6{display:block;position:relative;top:-60px;visibility:hidden;height:0}pre.url.svelte-1suc8s6.svelte-1suc8s6{padding:8px;background:#e9e9e9;border:1px solid #d4d4d4;border-radius:2px;overflow-x:auto}.code-example.svelte-1suc8s6 .header.svelte-1suc8s6{display:flex;justify-content:space-between;background:#404040;color:#fff;font-size:14px;font-weight:600}.code-example.svelte-1suc8s6 .header .title.svelte-1suc8s6,.code-example.svelte-1suc8s6 .header .copy a.svelte-1suc8s6{padding:8px 15px}.code-example.svelte-1suc8s6 .header .copy a.svelte-1suc8s6{display:inline-block;text-decoration:none !important;color:#fff;background:#333}.code-example.svelte-1suc8s6 pre.svelte-1suc8s6{padding:10px 15px;border:1px solid #404040;border-top:0;margin:0;white-space:pre-wrap;overflow-x:auto}.example-response.svelte-1suc8s6.svelte-1suc8s6{margin-top:25px}.example-response.default.svelte-1suc8s6 .header.svelte-1suc8s6{background:#675bc0}.example-response.default.svelte-1suc8s6 pre.svelte-1suc8s6{border-color:#675bc0}.example-response.info.svelte-1suc8s6 .header.svelte-1suc8s6{background:#3949ab}.example-response.info.svelte-1suc8s6 pre.svelte-1suc8s6{border-color:#3949ab}.example-response.success.svelte-1suc8s6 .header.svelte-1suc8s6{background:#43a047}.example-response.success.svelte-1suc8s6 pre.svelte-1suc8s6{border-color:#43a047}.example-response.redirect.svelte-1suc8s6 .header.svelte-1suc8s6{background:#6d4c41}.example-response.redirect.svelte-1suc8s6 pre.svelte-1suc8s6{border-color:#6d4c41}.example-response.client-error.svelte-1suc8s6 .header.svelte-1suc8s6{background:#fb8c00}.example-response.client-error.svelte-1suc8s6 pre.svelte-1suc8s6{border-color:#fb8c00}.example-response.server-error.svelte-1suc8s6 .header.svelte-1suc8s6{background:#e53935}.example-response.server-error.svelte-1suc8s6 pre.svelte-1suc8s6{border-color:#e53935} 12 | .table.svelte-t9o7qk.svelte-t9o7qk{padding:10px 0;border-bottom:1px solid #cdcdcd;margin-bottom:20px}.table.svelte-t9o7qk .header.svelte-t9o7qk{font-size:18px;font-weight:600;margin-bottom:10px}.table.svelte-t9o7qk .header span.svelte-t9o7qk,.table.svelte-t9o7qk .header .note.svelte-t9o7qk{display:inline-block;vertical-align:middle}.table.svelte-t9o7qk .header .note.svelte-t9o7qk{font-size:12px;margin-left:5px;padding:3px 5px;background:#ababab;border-radius:3px;color:#fff}.table.svelte-t9o7qk .row.svelte-t9o7qk{display:flex;justify-content:space-between;font-size:13px}.table.svelte-t9o7qk .row .name.svelte-t9o7qk,.table.svelte-t9o7qk .row .value.svelte-t9o7qk{padding:10px 0;overflow-wrap:break-word}.table.svelte-t9o7qk .row .name.svelte-t9o7qk{font-weight:600;min-width:25%}.table.svelte-t9o7qk .row .value.svelte-t9o7qk{width:75%}.table.svelte-t9o7qk .row.description.svelte-t9o7qk{color:#787878;margin-bottom:10px} 13 | .sidebar-list-link.svelte-7lkbuh::before{font-family:FontAwesome;content:"\f07b"}.sidebar-list-link.expanded.svelte-7lkbuh::before{content:"\f07c"}ul.svelte-7lkbuh{list-style-type:none;padding-inline-start:15px;font-size:12px} 14 | 15 | /*# sourceMappingURL=bundle.css.map */ --------------------------------------------------------------------------------