├── .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 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | Tecnologias |
27 | Projeto |
28 | Como rodar |
29 | Licença
30 |
31 |
32 |
33 |
34 |
35 |
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 |
--------------------------------------------------------------------------------