├── .editorconfig ├── .env.example ├── .eslintrc.js ├── .github └── banner-crud-nestjs.png ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── docker-compose.yml ├── nest-cli.json ├── package.json ├── src ├── app.module.ts ├── common │ └── swagger │ │ └── responses │ │ ├── ErrorResponse.ts │ │ └── ProductResponse.ts ├── database │ ├── config │ │ └── typeorm.config.ts │ └── migrations │ │ └── 1600539230151-CreateProductsTable.ts ├── main.ts └── modules │ └── products │ ├── controller │ ├── products.controller.spec.ts │ └── products.controller.ts │ ├── dtos │ ├── create-product.dto.ts │ └── update-product.dto.ts │ ├── entity │ └── products.entity.ts │ ├── products.module.ts │ └── service │ ├── products.service.spec.ts │ └── products.service.ts ├── test ├── e2e │ └── products │ │ └── products.e2e-spec.ts ├── jest-e2e.json └── util │ └── TestUtil.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_HOST=localhost 2 | DATABASE_PORT=5432 3 | DATABASE_USERNAME= # usuário do banco 4 | DATABASE_PASSWORD= # senha do banco 5 | DATABASE_NAME= # nome do banco 6 | DATABASE_SYNC=true 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.github/banner-crud-nestjs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goncadanilo/simple-crud-nestjs/9b86964189d2caf5a1dceb072c89d1af75cea76b/.github/banner-crud-nestjs.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | # Environment 37 | .env 38 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Danilo Gonçalves 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 🚀 CRUD NestJS 3 |

4 | 5 |

6 | GitHub language count 7 | 8 | 9 | GitHub last commit 10 | 11 | 12 | 13 | Repository issues 14 | 15 | 16 | Coverage 17 | 18 | License 19 | 20 | 21 | Author 22 | 23 |

24 | 25 |

26 | Tecnologias   |    27 | Projeto   |    28 | Como rodar   |    29 | Licença 30 |

31 | 32 |
33 | 34 |

35 | CRUD NestJS 36 |

37 | 38 | ## 🚀 Tecnologias 39 | 40 | Esse projeto foi desenvolvido com as seguintes tecnologias: 41 | 42 | - [NestJS](https://nestjs.com/): framework utilizado para criação da aplicação. 43 | - [Postgres](https://www.postgresql.org/): banco SQL utilizado para armazenar os dados. 44 | - [Docker](https://www.docker.com/) e [Docker-compose](https://docs.docker.com/compose/install/): utilizado para criar e rodar o container do banco de dados. 45 | - [Jest](https://jestjs.io/): utilizado para escrever os testes da aplicação. 46 | - [GitFlow](https://github.com/nvie/gitflow): utilizado no fluxo de desenvolvimento. 47 | - [Swagger](https://swagger.io/): utilizado para documentar a aplicação. 48 | 49 | ## 💻 Projeto 50 | 51 | Esse projeto é um simple CRUD de produtos desenvolvido com o intuito de estudar o framework [NestJS](https://nestjs.com/). Porém aplicando alguns conceitos mais avançados como: TDD, GitFlow, Docker, etc. 52 | 53 | ## ⚡ Como rodar 54 | 55 | ### Requisitos 56 | 57 | - [Node.js](https://nodejs.org/en/). 58 | - [NestJS CLI](https://docs.nestjs.com/first-steps). 59 | - [Yarn](https://yarnpkg.com/) ou se preferir, pode usar o npm _(já vem com o node)_. 60 | - [Docker](https://www.docker.com/) e [Docker-compose](https://docs.docker.com/compose/install/) _(opcional)_. 61 | 62 | ### Subir o banco 63 | 64 | - crie uma cópia do `.env.example` como `.env` e defina suas variáveis do banco. 65 | - suba o banco de dados com docker: `docker-compose up -d`. 66 | 67 | _(se você não estiver usando o docker, é necessário criar o banco manualmente)_. 68 | - rode as migrations: `yarn typeorm migration:run`. 69 | 70 | ### Rodar a aplicação 71 | 72 | - para rodar a aplicação: `yarn start`. 73 | - para rodar a aplicação em modo watch: `yarn start:dev`. 74 | - a aplicação estará disponível no endereço: `http://localhost:3000`. 75 | - a documentação estará disponível no endereço: `http://localhost:3000/api`. 76 | 77 | ### Rodar os testes 78 | 79 | - para rodar os testes unitários: `yarn test`. 80 | - para ver a cobertura dos testes unitários: `yarn test:cov`. 81 | - para rodar os testes e2e: `yarn test:e2e` 82 | 83 | ## 📝 Licença 84 | 85 | Esse projeto está sob a licença MIT. Veja o arquivo [LICENSE](LICENSE.md) para mais detalhes. 86 | 87 | --- 88 | 89 | Feito com ♥ by [Danilo Gonçalves](https://github.com/goncadanilo). Me adicione no [LinkedIn](https://www.linkedin.com/in/goncadanilo/) :wave: 90 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | db: 5 | container_name: crud_nest_db 6 | image: postgres:9.6-alpine 7 | environment: 8 | POSTGRES_PASSWORD: ${DATABASE_PASSWORD} 9 | POSTGRES_USER: ${DATABASE_USERNAME} 10 | POSTGRES_DB: ${DATABASE_NAME} 11 | PG_DATA: /var/lib/postgresql/data 12 | ports: 13 | - ${DATABASE_PORT}:5432 14 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crud-nestjs", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:ver": "jest --verbose", 19 | "test:watch": "jest --watch", 20 | "test:cov": "jest --coverage", 21 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 22 | "test:e2e": "jest --config ./test/jest-e2e.json", 23 | "typeorm": "ts-node ./node_modules/typeorm/cli.js --config src/database/config/typeorm.config.ts" 24 | }, 25 | "dependencies": { 26 | "@nestjs/common": "^7.0.0", 27 | "@nestjs/config": "^0.5.0", 28 | "@nestjs/core": "^7.0.0", 29 | "@nestjs/mapped-types": "^0.1.0", 30 | "@nestjs/platform-express": "^7.0.0", 31 | "@nestjs/swagger": "^4.6.1", 32 | "@nestjs/typeorm": "^7.1.4", 33 | "class-transformer": "^0.3.1", 34 | "class-validator": "^0.12.2", 35 | "pg": "^8.3.3", 36 | "reflect-metadata": "^0.1.13", 37 | "rimraf": "^3.0.2", 38 | "rxjs": "^6.5.4", 39 | "swagger-ui-express": "^4.1.4", 40 | "typeorm": "^0.2.26" 41 | }, 42 | "devDependencies": { 43 | "@nestjs/cli": "^7.0.0", 44 | "@nestjs/schematics": "^7.0.0", 45 | "@nestjs/testing": "^7.0.0", 46 | "@types/express": "^4.17.3", 47 | "@types/jest": "26.0.10", 48 | "@types/node": "^13.9.1", 49 | "@types/supertest": "^2.0.8", 50 | "@typescript-eslint/eslint-plugin": "3.9.1", 51 | "@typescript-eslint/parser": "3.9.1", 52 | "eslint": "7.7.0", 53 | "eslint-config-prettier": "^6.10.0", 54 | "eslint-plugin-import": "^2.20.1", 55 | "jest": "26.4.2", 56 | "prettier": "^1.19.1", 57 | "supertest": "^4.0.2", 58 | "ts-jest": "26.2.0", 59 | "ts-loader": "^6.2.1", 60 | "ts-node": "9.0.0", 61 | "tsconfig-paths": "^3.9.0", 62 | "typescript": "^3.7.4" 63 | }, 64 | "jest": { 65 | "moduleFileExtensions": [ 66 | "js", 67 | "json", 68 | "ts" 69 | ], 70 | "rootDir": "src", 71 | "testRegex": ".spec.ts$", 72 | "transform": { 73 | "^.+\\.(t|j)s$": "ts-jest" 74 | }, 75 | "coverageDirectory": "../coverage", 76 | "testEnvironment": "node" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { resolve } from 'path'; 5 | import { ProductsModule } from './modules/products/products.module'; 6 | 7 | @Module({ 8 | imports: [ 9 | ConfigModule.forRoot(), 10 | TypeOrmModule.forRoot({ 11 | type: 'postgres', 12 | host: process.env.DATABASE_HOST, 13 | port: +process.env.DATABASE_PORT, 14 | username: process.env.DATABASE_USERNAME, 15 | password: process.env.DATABASE_PASSWORD, 16 | database: process.env.DATABASE_NAME, 17 | entities: [ 18 | resolve(__dirname, 'modules', '**', 'entity', '*.entity.{ts,js}'), 19 | ], 20 | synchronize: process.env.DATABASE_SYNC === 'true', 21 | }), 22 | ProductsModule, 23 | ], 24 | }) 25 | export class AppModule {} 26 | -------------------------------------------------------------------------------- /src/common/swagger/responses/ErrorResponse.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class ErrorResponse { 4 | @ApiProperty() 5 | statusCode: number; 6 | 7 | @ApiProperty() 8 | message: string; 9 | 10 | @ApiProperty() 11 | error: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/common/swagger/responses/ProductResponse.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class ProductResponse { 4 | @ApiProperty() 5 | id: number; 6 | 7 | @ApiProperty() 8 | title: string; 9 | 10 | @ApiProperty() 11 | description: string; 12 | 13 | @ApiProperty() 14 | price: number; 15 | 16 | @ApiProperty() 17 | createdAt: Date; 18 | } 19 | -------------------------------------------------------------------------------- /src/database/config/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 2 | import { join, resolve } from 'path'; 3 | 4 | const typeOrmOptions: TypeOrmModuleOptions = { 5 | type: 'postgres', 6 | host: process.env.DATABASE_HOST, 7 | port: +process.env.DATABASE_PORT, 8 | username: process.env.DATABASE_USERNAME, 9 | password: process.env.DATABASE_PASSWORD, 10 | database: process.env.DATABASE_NAME, 11 | entities: [ 12 | resolve( 13 | __dirname, 14 | '..', 15 | '..', 16 | 'modules', 17 | '**', 18 | 'entity', 19 | '*.entity.{ts,js}', 20 | ), 21 | ], 22 | migrations: [resolve(__dirname, '..', 'migrations', '*.{ts,js}')], 23 | cli: { 24 | migrationsDir: join('src', 'database', 'migrations'), 25 | }, 26 | }; 27 | 28 | module.exports = typeOrmOptions; 29 | -------------------------------------------------------------------------------- /src/database/migrations/1600539230151-CreateProductsTable.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table } from 'typeorm'; 2 | 3 | export class CreateProductsTable1600539230151 implements MigrationInterface { 4 | private table = new Table({ 5 | name: 'products', 6 | columns: [ 7 | { 8 | name: 'id', 9 | type: 'int', 10 | isPrimary: true, 11 | isGenerated: true, 12 | generationStrategy: 'increment', 13 | }, 14 | { 15 | name: 'title', 16 | type: 'varchar', 17 | isNullable: false, 18 | }, 19 | { 20 | name: 'description', 21 | type: 'varchar', 22 | isNullable: false, 23 | }, 24 | { 25 | name: 'price', 26 | type: 'float4', 27 | isNullable: false, 28 | }, 29 | { 30 | name: 'created_at', 31 | type: 'timestamp', 32 | default: 'now()', 33 | }, 34 | ], 35 | }); 36 | 37 | public async up(queryRunner: QueryRunner): Promise { 38 | await queryRunner.createTable(this.table); 39 | } 40 | 41 | public async down(queryRunner: QueryRunner): Promise { 42 | await queryRunner.dropTable(this.table); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 4 | import { AppModule } from './app.module'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | 9 | app.useGlobalPipes(new ValidationPipe()); 10 | 11 | const options = new DocumentBuilder() 12 | .setTitle('Products API') 13 | .setDescription('Products API documentation') 14 | .setVersion('1.0') 15 | .build(); 16 | 17 | const document = SwaggerModule.createDocument(app, options); 18 | SwaggerModule.setup('api', app, document); 19 | 20 | await app.listen(3000); 21 | } 22 | bootstrap(); 23 | -------------------------------------------------------------------------------- /src/modules/products/controller/products.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { TestUtil } from '../../../../test/util/TestUtil'; 3 | import { Products } from '../entity/products.entity'; 4 | import { ProductsService } from '../service/products.service'; 5 | import { ProductsController } from './products.controller'; 6 | 7 | describe('ProductsController', () => { 8 | let controller: ProductsController; 9 | let mockProduct: Products; 10 | 11 | const mockProductsService = { 12 | createProduct: jest.fn(), 13 | findAllProducts: jest.fn(), 14 | findProductById: jest.fn(), 15 | updateProduct: jest.fn(), 16 | deleteProduct: jest.fn(), 17 | }; 18 | 19 | beforeAll(async () => { 20 | const module: TestingModule = await Test.createTestingModule({ 21 | controllers: [ProductsController], 22 | providers: [{ provide: ProductsService, useValue: mockProductsService }], 23 | }).compile(); 24 | 25 | controller = module.get(ProductsController); 26 | mockProduct = TestUtil.getMockProduct(); 27 | }); 28 | 29 | beforeEach(() => { 30 | mockProductsService.createProduct.mockReset(); 31 | mockProductsService.findAllProducts.mockReset(); 32 | mockProductsService.findProductById.mockReset(); 33 | mockProductsService.updateProduct.mockReset(); 34 | mockProductsService.deleteProduct.mockReset(); 35 | }); 36 | 37 | it('should be defined', () => { 38 | expect(controller).toBeDefined(); 39 | }); 40 | 41 | describe('when create product', () => { 42 | it('should create a product and return it', async () => { 43 | mockProductsService.createProduct.mockReturnValue(mockProduct); 44 | 45 | const product = { 46 | title: mockProduct.title, 47 | description: mockProduct.description, 48 | price: mockProduct.price, 49 | }; 50 | 51 | const createdProduct = await controller.createProduct(product); 52 | 53 | expect(createdProduct).toHaveProperty('id', 1); 54 | expect(createdProduct).toMatchObject(mockProduct); 55 | expect(mockProductsService.createProduct).toBeCalledWith(product); 56 | expect(mockProductsService.createProduct).toBeCalledTimes(1); 57 | }); 58 | }); 59 | 60 | describe('when search all products', () => { 61 | it('should search all products and return them', async () => { 62 | mockProductsService.findAllProducts.mockReturnValue([mockProduct]); 63 | 64 | const products = await controller.findAllProducts(); 65 | 66 | expect(products).toHaveLength(1); 67 | expect(products).toMatchObject([mockProduct]); 68 | expect(mockProductsService.findAllProducts).toBeCalledTimes(1); 69 | }); 70 | }); 71 | 72 | describe('when search product by id', () => { 73 | it('should find a existing product and return it', async () => { 74 | mockProductsService.findProductById.mockReturnValue(mockProduct); 75 | 76 | const product = await controller.findProductById('1'); 77 | 78 | expect(product).toMatchObject(mockProduct); 79 | expect(mockProductsService.findProductById).toBeCalledWith('1'); 80 | expect(mockProductsService.findProductById).toBeCalledTimes(1); 81 | }); 82 | }); 83 | 84 | describe('when update a product', () => { 85 | it('should update a existing product and return it', async () => { 86 | const productTitleUpdate = { 87 | title: 'Update Product Title', 88 | }; 89 | 90 | mockProductsService.updateProduct.mockReturnValue({ 91 | ...mockProduct, 92 | ...productTitleUpdate, 93 | }); 94 | 95 | const updatedProduct = await controller.updateProduct( 96 | '1', 97 | productTitleUpdate, 98 | ); 99 | 100 | expect(updatedProduct).toMatchObject(productTitleUpdate); 101 | expect(mockProductsService.updateProduct).toBeCalledWith( 102 | '1', 103 | productTitleUpdate, 104 | ); 105 | expect(mockProductsService.updateProduct).toBeCalledTimes(1); 106 | }); 107 | }); 108 | 109 | describe('when delete a product', () => { 110 | it('should delete a existing product', async () => { 111 | await controller.deleteProduct('1'); 112 | 113 | expect(mockProductsService.deleteProduct).toBeCalledWith('1'); 114 | expect(mockProductsService.deleteProduct).toBeCalledTimes(1); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /src/modules/products/controller/products.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | Param, 8 | Post, 9 | Put, 10 | } from '@nestjs/common'; 11 | import { 12 | ApiBadRequestResponse, 13 | ApiCreatedResponse, 14 | ApiNoContentResponse, 15 | ApiNotFoundResponse, 16 | ApiOkResponse, 17 | ApiOperation, 18 | ApiTags, 19 | } from '@nestjs/swagger'; 20 | import { ErrorResponse } from '../../../common/swagger/responses/ErrorResponse'; 21 | import { ProductResponse } from '../../../common/swagger/responses/ProductResponse'; 22 | import { CreateProductDto } from '../dtos/create-product.dto'; 23 | import { UpdateProductDto } from '../dtos/update-product.dto'; 24 | import { Products } from '../entity/products.entity'; 25 | import { ProductsService } from '../service/products.service'; 26 | 27 | @ApiTags('Products') 28 | @Controller('products') 29 | export class ProductsController { 30 | constructor(private productsService: ProductsService) {} 31 | 32 | @Get() 33 | @HttpCode(200) 34 | @ApiOperation({ summary: 'Search all products' }) 35 | @ApiOkResponse({ type: [ProductResponse], description: 'The found products' }) 36 | async findAllProducts(): Promise { 37 | return await this.productsService.findAllProducts(); 38 | } 39 | 40 | @Post() 41 | @HttpCode(201) 42 | @ApiOperation({ summary: 'Create a new product' }) 43 | @ApiCreatedResponse({ type: ProductResponse, description: 'Created product' }) 44 | @ApiBadRequestResponse({ type: ErrorResponse, description: 'Bad Request' }) 45 | async createProduct(@Body() data: CreateProductDto): Promise { 46 | return await this.productsService.createProduct(data); 47 | } 48 | 49 | @Get(':id') 50 | @HttpCode(200) 51 | @ApiOperation({ summary: 'Search a products by id' }) 52 | @ApiOkResponse({ type: ProductResponse, description: 'The found product' }) 53 | @ApiNotFoundResponse({ type: ErrorResponse, description: 'Not Found' }) 54 | async findProductById(@Param('id') id: string): Promise { 55 | return await this.productsService.findProductById(id); 56 | } 57 | 58 | @Put(':id') 59 | @HttpCode(200) 60 | @ApiOperation({ summary: 'Update a product' }) 61 | @ApiOkResponse({ type: ProductResponse, description: 'Updated product' }) 62 | @ApiNotFoundResponse({ type: ErrorResponse, description: 'Not Found' }) 63 | async updateProduct( 64 | @Param('id') id: string, 65 | @Body() data: UpdateProductDto, 66 | ): Promise { 67 | return this.productsService.updateProduct(id, data); 68 | } 69 | 70 | @Delete(':id') 71 | @HttpCode(204) 72 | @ApiOperation({ summary: 'Delete a product' }) 73 | @ApiNoContentResponse({ description: 'Deleted product' }) 74 | @ApiNotFoundResponse({ type: ErrorResponse, description: 'Not Found' }) 75 | async deleteProduct(@Param('id') id: string): Promise { 76 | await this.productsService.deleteProduct(id); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/modules/products/dtos/create-product.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsNotEmpty, 4 | IsNumber, 5 | IsPositive, 6 | IsString, 7 | MaxLength, 8 | } from 'class-validator'; 9 | 10 | export class CreateProductDto { 11 | @IsString() 12 | @IsNotEmpty() 13 | @ApiProperty() 14 | title: string; 15 | 16 | @IsString() 17 | @IsNotEmpty() 18 | @MaxLength(200) 19 | @ApiProperty() 20 | description: string; 21 | 22 | @IsNumber() 23 | @IsPositive() 24 | @IsNotEmpty() 25 | @ApiProperty() 26 | price: number; 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/products/dtos/update-product.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsNotEmpty, 4 | IsNumber, 5 | IsPositive, 6 | IsString, 7 | MaxLength, 8 | } from 'class-validator'; 9 | 10 | export class UpdateProductDto { 11 | @IsString() 12 | @IsNotEmpty() 13 | @ApiProperty({ required: false }) 14 | title?: string; 15 | 16 | @IsString() 17 | @IsNotEmpty() 18 | @MaxLength(200) 19 | @ApiProperty({ required: false }) 20 | description?: string; 21 | 22 | @IsNumber() 23 | @IsPositive() 24 | @IsNotEmpty() 25 | @ApiProperty({ required: false }) 26 | price?: number; 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/products/entity/products.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | } from 'typeorm'; 7 | 8 | @Entity() 9 | export class Products { 10 | @PrimaryGeneratedColumn() 11 | id: number; 12 | 13 | @Column() 14 | title: string; 15 | 16 | @Column() 17 | description: string; 18 | 19 | @Column({ type: 'float4' }) 20 | price: number; 21 | 22 | @CreateDateColumn({ name: 'created_at' }) 23 | createdAt: Date; 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/products/products.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { ProductsController } from './controller/products.controller'; 4 | import { Products } from './entity/products.entity'; 5 | import { ProductsService } from './service/products.service'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Products])], 9 | providers: [ProductsService], 10 | controllers: [ProductsController], 11 | }) 12 | export class ProductsModule {} 13 | -------------------------------------------------------------------------------- /src/modules/products/service/products.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { getRepositoryToken } from '@nestjs/typeorm'; 4 | import { TestUtil } from '../../../../test/util/TestUtil'; 5 | import { Products } from '../entity/products.entity'; 6 | import { ProductsService } from './products.service'; 7 | 8 | describe('ProductsService', () => { 9 | let service: ProductsService; 10 | let mockProduct: Products; 11 | 12 | const mockRepository = { 13 | create: jest.fn(), 14 | save: jest.fn(), 15 | find: jest.fn(), 16 | findOne: jest.fn(), 17 | update: jest.fn(), 18 | delete: jest.fn(), 19 | }; 20 | 21 | beforeAll(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | providers: [ 24 | ProductsService, 25 | { provide: getRepositoryToken(Products), useValue: mockRepository }, 26 | ], 27 | }).compile(); 28 | 29 | service = module.get(ProductsService); 30 | mockProduct = TestUtil.getMockProduct(); 31 | }); 32 | 33 | beforeEach(() => { 34 | mockRepository.create.mockReset(); 35 | mockRepository.save.mockReset(); 36 | mockRepository.find.mockReset(); 37 | mockRepository.findOne.mockReset(); 38 | mockRepository.update.mockReset(); 39 | mockRepository.delete.mockReset(); 40 | }); 41 | 42 | it('should be defined', () => { 43 | expect(service).toBeDefined(); 44 | }); 45 | 46 | describe('when create product', () => { 47 | it('should create a product', async () => { 48 | mockRepository.create.mockReturnValueOnce(mockProduct); 49 | mockRepository.save.mockReturnValueOnce(mockProduct); 50 | 51 | const product = { 52 | title: mockProduct.title, 53 | description: mockProduct.description, 54 | price: mockProduct.price, 55 | }; 56 | 57 | const savedProduct = await service.createProduct(product); 58 | 59 | expect(savedProduct).toHaveProperty('id', 1); 60 | expect(savedProduct).toMatchObject(mockProduct); 61 | expect(mockRepository.create).toBeCalledWith(product); 62 | expect(mockRepository.create).toBeCalledTimes(1); 63 | expect(mockRepository.save).toBeCalledTimes(1); 64 | }); 65 | }); 66 | 67 | describe('when search all products', () => { 68 | it('should list all products', async () => { 69 | mockRepository.find.mockReturnValue([mockProduct]); 70 | 71 | const products = await service.findAllProducts(); 72 | 73 | expect(products).toHaveLength(1); 74 | expect(mockRepository.find).toBeCalledTimes(1); 75 | }); 76 | }); 77 | 78 | describe('when search product by id', () => { 79 | it('should find a existing product', async () => { 80 | mockRepository.findOne.mockReturnValue(mockProduct); 81 | 82 | const product = await service.findProductById('1'); 83 | 84 | expect(product).toMatchObject(mockProduct); 85 | expect(mockRepository.findOne).toBeCalledWith('1'); 86 | expect(mockRepository.findOne).toBeCalledTimes(1); 87 | }); 88 | 89 | it('should return a exception when does not to find a product', async () => { 90 | mockRepository.findOne.mockReturnValue(null); 91 | 92 | await service.findProductById('3').catch(error => { 93 | expect(error).toBeInstanceOf(NotFoundException); 94 | expect(error).toMatchObject({ message: 'Product not found' }); 95 | expect(mockRepository.findOne).toBeCalledWith('3'); 96 | expect(mockRepository.findOne).toBeCalledTimes(1); 97 | }); 98 | }); 99 | }); 100 | 101 | describe('when update a product', () => { 102 | it('should update a existing product', async () => { 103 | const productTitleUpdate = { 104 | title: 'Update Product Title', 105 | }; 106 | 107 | mockRepository.findOne.mockReturnValue(mockProduct); 108 | mockRepository.update.mockReturnValue({ 109 | ...mockProduct, 110 | ...productTitleUpdate, 111 | }); 112 | mockRepository.create.mockReturnValue({ 113 | ...mockProduct, 114 | ...productTitleUpdate, 115 | }); 116 | 117 | const updatedProduct = await service.updateProduct( 118 | '1', 119 | productTitleUpdate, 120 | ); 121 | 122 | expect(updatedProduct).toMatchObject(productTitleUpdate); 123 | expect(mockRepository.findOne).toBeCalledWith('1'); 124 | expect(mockRepository.findOne).toBeCalledTimes(1); 125 | expect(mockRepository.update).toBeCalledWith('1', productTitleUpdate); 126 | expect(mockRepository.update).toBeCalledTimes(1); 127 | expect(mockRepository.create).toBeCalledWith({ 128 | ...mockProduct, 129 | ...productTitleUpdate, 130 | }); 131 | expect(mockRepository.create).toBeCalledTimes(1); 132 | }); 133 | }); 134 | 135 | describe('when delete a product', () => { 136 | it('should delete a existing product', async () => { 137 | mockRepository.findOne.mockReturnValue(mockProduct); 138 | mockRepository.delete.mockReturnValue(mockProduct); 139 | 140 | await service.deleteProduct('1'); 141 | 142 | expect(mockRepository.findOne).toBeCalledWith('1'); 143 | expect(mockRepository.findOne).toBeCalledTimes(1); 144 | expect(mockRepository.delete).toBeCalledWith('1'); 145 | expect(mockRepository.delete).toBeCalledTimes(1); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/modules/products/service/products.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { CreateProductDto } from '../dtos/create-product.dto'; 5 | import { UpdateProductDto } from '../dtos/update-product.dto'; 6 | import { Products } from '../entity/products.entity'; 7 | 8 | @Injectable() 9 | export class ProductsService { 10 | constructor( 11 | @InjectRepository(Products) 12 | private repository: Repository, 13 | ) {} 14 | 15 | async findAllProducts(): Promise { 16 | return await this.repository.find(); 17 | } 18 | 19 | async createProduct(data: CreateProductDto): Promise { 20 | const product = this.repository.create(data); 21 | return await this.repository.save(product); 22 | } 23 | 24 | async findProductById(id: string): Promise { 25 | const product = await this.repository.findOne(id); 26 | 27 | if (!product) { 28 | throw new NotFoundException('Product not found'); 29 | } 30 | 31 | return product; 32 | } 33 | 34 | async updateProduct(id: string, data: UpdateProductDto): Promise { 35 | const product = await this.findProductById(id); 36 | await this.repository.update(id, { ...data }); 37 | 38 | return this.repository.create({ ...product, ...data }); 39 | } 40 | 41 | async deleteProduct(id: string): Promise { 42 | await this.findProductById(id); 43 | await this.repository.delete(id); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/e2e/products/products.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from '../../../src/app.module'; 5 | import { Products } from '../../../src/modules/products/entity/products.entity'; 6 | import { ProductsService } from '../../../src/modules/products/service/products.service'; 7 | import { TestUtil } from '../../util/TestUtil'; 8 | 9 | describe('Products (e2e)', () => { 10 | let app: INestApplication; 11 | let mockProduct: Products; 12 | 13 | const mockProductsService = { 14 | createProduct: jest.fn(), 15 | findAllProducts: jest.fn(), 16 | findProductById: jest.fn(), 17 | updateProduct: jest.fn(), 18 | deleteProduct: jest.fn(), 19 | }; 20 | 21 | beforeAll(async () => { 22 | const moduleRef = await Test.createTestingModule({ 23 | imports: [AppModule], 24 | }) 25 | .overrideProvider(ProductsService) 26 | .useValue(mockProductsService) 27 | .compile(); 28 | 29 | app = moduleRef.createNestApplication(); 30 | await app.init(); 31 | 32 | mockProduct = TestUtil.getMockProduct(); 33 | }); 34 | 35 | beforeEach(() => { 36 | mockProductsService.createProduct.mockReset(); 37 | mockProductsService.findAllProducts.mockReset(); 38 | mockProductsService.findProductById.mockReset(); 39 | mockProductsService.updateProduct.mockReset(); 40 | mockProductsService.deleteProduct.mockReset(); 41 | }); 42 | 43 | afterAll(async () => { 44 | await app.close(); 45 | }); 46 | 47 | describe('/products (POST)', () => { 48 | it('should create a product and return it with http code 201', async () => { 49 | mockProductsService.createProduct.mockReturnValue(mockProduct); 50 | 51 | const product = { 52 | title: mockProduct.title, 53 | description: mockProduct.description, 54 | price: mockProduct.price, 55 | }; 56 | 57 | const response = await request(app.getHttpServer()) 58 | .post('/products') 59 | .send(product); 60 | 61 | expect(response.body).toHaveProperty('id', 1); 62 | expect(response.body).toMatchObject({ 63 | ...mockProduct, 64 | createdAt: mockProduct.createdAt.toISOString(), 65 | }); 66 | expect(response.status).toBe(201); 67 | expect(mockProductsService.createProduct).toBeCalledWith(product); 68 | expect(mockProductsService.createProduct).toBeCalledTimes(1); 69 | }); 70 | }); 71 | 72 | describe('/products (GET)', () => { 73 | it('should search all products and return them with http code 200', async () => { 74 | mockProductsService.findAllProducts.mockReturnValue([mockProduct]); 75 | 76 | const response = await request(app.getHttpServer()).get('/products'); 77 | 78 | expect(response.body).toMatchObject([ 79 | { 80 | ...mockProduct, 81 | createdAt: mockProduct.createdAt.toISOString(), 82 | }, 83 | ]); 84 | expect(response.status).toBe(200); 85 | expect(mockProductsService.findAllProducts).toBeCalledTimes(1); 86 | }); 87 | }); 88 | 89 | describe('/products/:id (GET)', () => { 90 | it('should search a product by id and return it with http code 200', async () => { 91 | mockProductsService.findProductById.mockReturnValue(mockProduct); 92 | 93 | const response = await request(app.getHttpServer()).get('/products/1'); 94 | 95 | expect(response.body).toMatchObject({ 96 | ...mockProduct, 97 | createdAt: mockProduct.createdAt.toISOString(), 98 | }); 99 | expect(response.status).toBe(200); 100 | expect(mockProductsService.findProductById).toBeCalledWith('1'); 101 | expect(mockProductsService.findProductById).toBeCalledTimes(1); 102 | }); 103 | }); 104 | 105 | describe('/products/:id (PUT)', () => { 106 | it('should update a existing product and return it with http code 200', async () => { 107 | const productTitleUpdate = { 108 | title: 'Update Product Title', 109 | }; 110 | 111 | mockProductsService.updateProduct.mockReturnValue({ 112 | ...mockProduct, 113 | ...productTitleUpdate, 114 | }); 115 | 116 | const response = await request(app.getHttpServer()) 117 | .put('/products/1') 118 | .send(productTitleUpdate); 119 | 120 | expect(response.body).toMatchObject(productTitleUpdate); 121 | expect(response.status).toBe(200); 122 | expect(mockProductsService.updateProduct).toBeCalledWith( 123 | '1', 124 | productTitleUpdate, 125 | ); 126 | expect(mockProductsService.updateProduct).toBeCalledTimes(1); 127 | }); 128 | }); 129 | 130 | describe('/products/:id (DELETE)', () => { 131 | it('should delete a existing product and return http code 204', async () => { 132 | const response = await request(app.getHttpServer()).delete('/products/1'); 133 | 134 | expect(response.status).toBe(204); 135 | expect(mockProductsService.deleteProduct).toBeCalledWith('1'); 136 | expect(mockProductsService.deleteProduct).toBeCalledTimes(1); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/util/TestUtil.ts: -------------------------------------------------------------------------------- 1 | import { Products } from '../../src/modules/products/entity/products.entity'; 2 | 3 | export class TestUtil { 4 | static getMockProduct(): Products { 5 | const product = new Products(); 6 | product.id = 1; 7 | product.title = 'Product Title'; 8 | product.description = 'Product Description'; 9 | product.price = 100; 10 | product.createdAt = new Date(); 11 | 12 | return product; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true 14 | } 15 | } 16 | --------------------------------------------------------------------------------